feat: support image prompts across IM channels

This commit is contained in:
xmanrui 2026-08-19 02:56:14 +08:00
parent 99a877c640
commit 65c842c045
40 changed files with 3806 additions and 274 deletions

View file

@ -17,6 +17,7 @@ oauth_config:
bot:
- app_mentions:read
- chat:write
- files:read
- im:history
settings:
event_subscriptions:

View file

@ -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();

View file

@ -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) => {