mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-13 03:50:44 +08:00
feat: support image prompts across IM channels
This commit is contained in:
parent
99a877c640
commit
65c842c045
40 changed files with 3806 additions and 274 deletions
|
|
@ -17,6 +17,7 @@ oauth_config:
|
|||
bot:
|
||||
- app_mentions:read
|
||||
- chat:write
|
||||
- files:read
|
||||
- im:history
|
||||
settings:
|
||||
event_subscriptions:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs';
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://slack.com/api/';
|
||||
const SLACK_FILE_HOST = 'files.slack.com';
|
||||
const LEGACY_SLACK_FILE_HOST = 'slack.com';
|
||||
const SLACK_FILE_PATH_PREFIX = '/files-pri/';
|
||||
const SLACK_FILE_HOSTS = Object.freeze([SLACK_FILE_HOST]);
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
|
|
@ -9,6 +15,61 @@ function requestSignal(signal, timeoutMs) {
|
|||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
}
|
||||
|
||||
function isRedirectStatus(status) {
|
||||
return Number.isInteger(status) && status >= 300 && status < 400;
|
||||
}
|
||||
|
||||
async function cancelResponseBody(response) {
|
||||
try {
|
||||
await response?.body?.cancel?.();
|
||||
} catch {
|
||||
// Preserve the download failure when response cleanup also fails.
|
||||
}
|
||||
}
|
||||
|
||||
function secureSlackFileUrl(value) {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== 'https:') throw new Error('Image download URL must use HTTPS');
|
||||
if (url.username || url.password || (url.port && url.port !== '443')) {
|
||||
throw new Error('Image download URL is not hosted by the messaging platform');
|
||||
}
|
||||
if (url.hostname === LEGACY_SLACK_FILE_HOST && url.pathname.startsWith(SLACK_FILE_PATH_PREFIX)) {
|
||||
url.hostname = SLACK_FILE_HOST;
|
||||
}
|
||||
if (url.hostname !== SLACK_FILE_HOST || !url.pathname.startsWith(SLACK_FILE_PATH_PREFIX)) {
|
||||
throw new Error('Image download URL is not hosted by the messaging platform');
|
||||
}
|
||||
url.hash = '';
|
||||
return url;
|
||||
}
|
||||
|
||||
function redirectUrl(response, source) {
|
||||
const location = response?.headers?.get?.('location');
|
||||
if (!location) return null;
|
||||
try {
|
||||
return new URL(location, source);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function responseOAuthScopes(response) {
|
||||
const raw = response?.headers?.get?.('x-oauth-scopes');
|
||||
if (raw === null || raw === undefined) return null;
|
||||
return new Set(raw.split(',').map((scope) => scope.trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
function isSlackWorkspaceRedirect(target) {
|
||||
return target?.protocol === 'https:'
|
||||
&& !target.username
|
||||
&& !target.password
|
||||
&& (!target.port || target.port === '443')
|
||||
&& target.hostname.endsWith('.slack.com')
|
||||
&& target.hostname !== SLACK_FILE_HOST
|
||||
&& target.pathname === '/'
|
||||
&& target.searchParams.has('redir');
|
||||
}
|
||||
|
||||
function delay(ms, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
|
|
@ -79,6 +140,7 @@ export class SlackApi {
|
|||
#appToken;
|
||||
#fetch;
|
||||
#baseUrl;
|
||||
#botScopes = null;
|
||||
|
||||
constructor({ botToken, appToken, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) {
|
||||
if (botToken !== undefined && !validSlackBotToken(botToken)) {
|
||||
|
|
@ -177,6 +239,34 @@ export class SlackApi {
|
|||
});
|
||||
}
|
||||
|
||||
async downloadFile({ url, signal, maxBytes }) {
|
||||
if (!this.#botToken) throw new TypeError('Slack bot token is required for file download');
|
||||
const target = secureSlackFileUrl(url);
|
||||
const fetchSlackFile = async (requestUrl, options) => {
|
||||
const response = await this.#fetch(requestUrl, options);
|
||||
if (!isRedirectStatus(response?.status)) return response;
|
||||
|
||||
const next = redirectUrl(response, requestUrl);
|
||||
if ((this.#botScopes && !this.#botScopes.has('files:read'))
|
||||
|| isSlackWorkspaceRedirect(next)) {
|
||||
await cancelResponseBody(response);
|
||||
throw new ImagePromptError(
|
||||
'slack-file-access-required',
|
||||
'Slack redirected a private file request to the workspace because file access was not granted',
|
||||
'Slack 未授权机器人读取该文件。请为应用添加 files:read 后重新安装,再重新发送图片。',
|
||||
);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
return fetchImageBuffer(target, {
|
||||
fetchImpl: fetchSlackFile,
|
||||
headers: { authorization: `Bearer ${this.#botToken}` },
|
||||
signal,
|
||||
maxBytes,
|
||||
allowedHosts: SLACK_FILE_HOSTS,
|
||||
});
|
||||
}
|
||||
|
||||
async #request(method, {
|
||||
tokenKind,
|
||||
body,
|
||||
|
|
@ -206,6 +296,11 @@ export class SlackApi {
|
|||
throw new Error(`Slack ${method} transport failed`);
|
||||
}
|
||||
|
||||
if (tokenKind === 'bot') {
|
||||
const scopes = responseOAuthScopes(response);
|
||||
if (scopes) this.#botScopes = scopes;
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = await response.json();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { createSlackBridgeStatus, SlackHarnessBridge } from './slack-bridge.mjs'
|
|||
const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]);
|
||||
const SLACK_MESSAGE_LIMIT = 38_000;
|
||||
const SLACK_STREAM_CHUNK_LIMIT = 11_000;
|
||||
const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
||||
|
||||
function addSocketListener(socket, event, listener) {
|
||||
if (typeof socket.addEventListener === 'function') socket.addEventListener(event, listener);
|
||||
|
|
@ -42,13 +43,28 @@ function stripBotMention(value, botUserId) {
|
|||
.trim();
|
||||
}
|
||||
|
||||
export function normalizeSlackEvent(payload, botUserId) {
|
||||
function slackImageSource(file, loadFile) {
|
||||
const mediaType = typeof file?.mimetype === 'string' ? file.mimetype.toLowerCase() : '';
|
||||
const url = typeof file?.url_private_download === 'string' && file.url_private_download
|
||||
? file.url_private_download : file?.url_private;
|
||||
if (!IMAGE_MEDIA_TYPES.has(mediaType) || typeof url !== 'string' || !url) return null;
|
||||
return {
|
||||
name: typeof file.name === 'string' ? file.name : undefined,
|
||||
mediaType,
|
||||
size: Number.isSafeInteger(file.size) && file.size >= 0 ? file.size : undefined,
|
||||
load: (options) => loadFile(url, options),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSlackEvent(payload, botUserId, { loadFile = async () => {
|
||||
throw new Error('Slack file downloader is unavailable');
|
||||
} } = {}) {
|
||||
const event = payload?.event;
|
||||
if (!event || !payload?.event_id || !event.channel || !event.user || !event.ts) return null;
|
||||
const direct = event.type === 'message' && event.channel_type === 'im';
|
||||
const mentioned = event.type === 'app_mention';
|
||||
if (!direct && !mentioned) return null;
|
||||
if (event.subtype || event.bot_id || event.app_id) return null;
|
||||
if ((event.subtype && event.subtype !== 'file_share') || event.bot_id || event.app_id) return null;
|
||||
const threadTs = String(event.thread_ts ?? event.ts);
|
||||
return {
|
||||
messageId: String(payload.event_id),
|
||||
|
|
@ -57,6 +73,9 @@ export function normalizeSlackEvent(payload, botUserId) {
|
|||
kind: direct ? 'direct' : 'group',
|
||||
conversationId: direct ? String(event.channel) : `${event.channel}:${threadTs}`,
|
||||
content: stripBotMention(event.text ?? '', botUserId),
|
||||
images: Array.isArray(event.files)
|
||||
? event.files.map((file) => slackImageSource(file, loadFile)).filter(Boolean)
|
||||
: [],
|
||||
addressed: direct || mentioned,
|
||||
replyTarget: {
|
||||
channelId: String(event.channel),
|
||||
|
|
@ -389,7 +408,9 @@ export class SlackRuntime {
|
|||
if (packet.type !== 'events_api' || packet.payload?.type !== 'event_callback') return;
|
||||
if (this.#appId && packet.payload.api_app_id
|
||||
&& packet.payload.api_app_id !== this.#appId) return;
|
||||
const message = normalizeSlackEvent(packet.payload, this.#config.platformId.split(':')[1]);
|
||||
const message = normalizeSlackEvent(packet.payload, this.#config.platformId.split(':')[1], {
|
||||
loadFile: (url, options) => this.#api.downloadFile({ url, ...options }),
|
||||
});
|
||||
const bridge = this.#bridge;
|
||||
if (message && bridge) {
|
||||
void bridge.accept(message).catch((error) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue