import { ImagePromptError } from '../shared/image-prompt.mjs'; import { t } from '../shared/i18n.mjs'; const FEISHU_MISSING_MESSAGE_SCOPE_CODE = 99991672; const FEISHU_ERROR_BODY_LIMIT = 64 * 1024; const FEISHU_ERROR_BODY_TIMEOUT_MS = 1_000; const FEISHU_IMAGE_PERMISSION_MESSAGE = '飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。'; export function conversationKey(event) { const chatType = event?.message?.chat_type; if (chatType === 'p2p') { const senderId = event?.sender?.sender_id?.open_id || event?.sender?.sender_id?.user_id; if (!senderId) throw new Error('Feishu p2p event has no sender id'); return `p2p:${senderId}`; } const chatId = event?.message?.chat_id; if (!chatId) throw new Error('Feishu group event has no chat id'); // Topic groups: every message belongs to a thread, so key the session per // thread to keep each topic's Harness conversation isolated. Regular group // chats carry no thread_id and keep the single shared `group:` key. const threadId = event?.message?.thread_id; if (typeof threadId === 'string' && threadId.trim()) return `group:${chatId}:thread:${threadId}`; return `group:${chatId}`; } function parsedMessageContent(event) { const value = event?.message?.content; if (value && typeof value === 'object') return value; if (typeof value !== 'string') return null; try { const parsed = JSON.parse(value); return parsed && typeof parsed === 'object' ? parsed : null; } catch { return null; } } function withoutMentions(text, event) { let result = typeof text === 'string' ? text : ''; for (const mention of event?.message?.mentions ?? []) { if (typeof mention?.key === 'string' && mention.key) { result = result.replaceAll(mention.key, ''); } } return result.trim(); } export function extractText(event) { if (event?.message?.message_type !== 'text') return null; const parsed = parsedMessageContent(event); return parsed ? withoutMentions(parsed.text, event) : null; } function nonEmptyString(value) { return typeof value === 'string' && value.trim() ? value.trim() : null; } function postContent(event, parsed = parsedMessageContent(event)) { if (event?.message?.message_type !== 'post') return null; if (!parsed) return null; const lines = []; const title = nonEmptyString(withoutMentions(parsed.title, event)); if (title) lines.push(title); const imageKeys = []; for (const paragraph of Array.isArray(parsed.content) ? parsed.content : []) { if (!Array.isArray(paragraph)) continue; let visibleText = ''; for (const element of paragraph) { const tag = String(element?.tag ?? '').toLowerCase(); if (tag === 'img') { const key = nonEmptyString(element?.image_key); if (key) imageKeys.push(key); } else if (tag === 'text' || tag === 'a' || tag === 'link') { if (typeof element?.text === 'string') visibleText += element.text; } } const line = nonEmptyString(withoutMentions(visibleText, event)); if (line) lines.push(line); } return { text: lines.join('\n'), imageKeys, }; } function headerValue(headers, name) { if (typeof headers?.get === 'function') return headers.get(name); return headers?.[name] ?? headers?.[name.toLowerCase()] ?? null; } function declaredSize(headers) { const header = headerValue(headers, 'content-length'); if (header === null || header === undefined || header === '') return null; const value = Number(header); return Number.isFinite(value) && value >= 0 ? value : null; } async function readBoundedStream(stream, { signal, maxBytes }) { if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') { throw new Error('Feishu image download returned no readable stream'); } signal?.throwIfAborted(); const abort = () => stream.destroy?.( signal.reason ?? new DOMException('Feishu image download aborted', 'AbortError'), ); signal?.addEventListener('abort', abort, { once: true }); const chunks = []; let size = 0; try { for await (const chunk of stream) { signal?.throwIfAborted(); const data = Buffer.from(chunk); size += data.length; if (size > maxBytes) { stream.destroy?.(); throw new ImagePromptError( 'image-too-large', `Feishu image exceeds ${maxBytes} bytes`, t('图片超过 5 MB,请压缩后重试。'), ); } chunks.push(data); } signal?.throwIfAborted(); return Buffer.concat(chunks, size); } finally { signal?.removeEventListener('abort', abort); } } async function readStream(stream, { signal }) { if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') { throw new Error('Feishu file download returned no readable stream'); } signal?.throwIfAborted(); const abort = () => stream.destroy?.( signal.reason ?? new DOMException('Feishu file download aborted', 'AbortError'), ); signal?.addEventListener('abort', abort, { once: true }); const chunks = []; let size = 0; try { for await (const chunk of stream) { signal?.throwIfAborted(); const data = Buffer.from(chunk); size += data.length; chunks.push(data); } signal?.throwIfAborted(); return Buffer.concat(chunks, size); } finally { signal?.removeEventListener('abort', abort); } } function providerCode(value) { if (!value || typeof value !== 'object') return null; const code = value.code ?? value.error?.code; return Number.isSafeInteger(Number(code)) ? Number(code) : null; } async function readFeishuErrorBody(stream, signal) { if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') return null; signal?.throwIfAborted(); const timeout = AbortSignal.timeout(FEISHU_ERROR_BODY_TIMEOUT_MS); const readSignal = signal ? AbortSignal.any([signal, timeout]) : timeout; const abort = () => stream.destroy?.(readSignal.reason); readSignal.addEventListener('abort', abort, { once: true }); const chunks = []; let size = 0; try { for await (const chunk of stream) { const data = Buffer.from(chunk); size += data.length; if (size > FEISHU_ERROR_BODY_LIMIT) { stream.destroy?.(); return null; } chunks.push(data); } return Buffer.concat(chunks, size).toString('utf8'); } catch { signal?.throwIfAborted(); return null; } finally { readSignal.removeEventListener('abort', abort); } } async function feishuProviderCode(error, signal) { const pending = [error]; const seen = new Set(); while (pending.length > 0 && seen.size < 8) { const value = pending.shift(); if (!value || (typeof value !== 'object' && typeof value !== 'function') || seen.has(value)) { continue; } seen.add(value); const directCode = providerCode(value); const data = value.response?.data ?? value.data; if (directCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE) { data?.destroy?.(); return directCode; } if (data && typeof data[Symbol.asyncIterator] === 'function') { const body = await readFeishuErrorBody(data, signal); try { const parsedCode = providerCode(JSON.parse(body)); if (parsedCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE) return parsedCode; } catch { // Non-JSON provider failures keep the generic image download message. } } else { const dataCode = providerCode(data); if (dataCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE) return dataCode; } pending.push(value.cause); } return null; } async function feishuImageDownloadError(error, signal) { if (await feishuProviderCode(error, signal) !== FEISHU_MISSING_MESSAGE_SCOPE_CODE) return error; return new ImagePromptError( 'feishu-image-permission-required', 'Feishu image download requires the im:message:readonly tenant scope', t(FEISHU_IMAGE_PERMISSION_MESSAGE), { cause: error }, ); } function feishuImageSource(event, client, key) { return { async load({ signal, maxBytes }) { signal?.throwIfAborted(); let resource; try { resource = await client?.im?.v1?.messageResource?.get?.({ path: { message_id: event.message.message_id, file_key: key, }, params: { type: 'image' }, }); } catch (error) { throw await feishuImageDownloadError(error, signal); } signal?.throwIfAborted(); const size = declaredSize(resource?.headers); if (size !== null && size > maxBytes) { resource?.getReadableStream?.().destroy?.(); throw new ImagePromptError( 'image-too-large', `Feishu image declares ${size} bytes; the limit is ${maxBytes}`, t('图片超过 5 MB,请压缩后重试。'), ); } return readBoundedStream(resource?.getReadableStream?.(), { signal, maxBytes }); }, }; } function feishuFileSource(event, client, file) { const key = nonEmptyString(file?.file_key); if (!key) return null; return { name: nonEmptyString(file?.file_name) ?? 'file', async load({ signal } = {}) { signal?.throwIfAborted(); const resource = await client?.im?.v1?.messageResource?.get?.({ path: { message_id: event.message.message_id, file_key: key, }, params: { type: 'file' }, }); signal?.throwIfAborted(); return readStream(resource?.getReadableStream?.(), { signal }); }, }; } export function extractInboundMessage(event, client) { const messageType = event?.message?.message_type; const parsed = parsedMessageContent(event); const post = postContent(event, parsed); const standaloneImageKey = messageType === 'image' ? nonEmptyString(parsed?.image_key) : null; const imageKeys = standaloneImageKey ? [standaloneImageKey] : post?.imageKeys ?? []; const file = messageType === 'file' ? feishuFileSource(event, client, parsed) : null; return { content: messageType === 'text' ? extractText(event) ?? '' : post?.text ?? '', images: imageKeys.map((key) => feishuImageSource(event, client, key)), files: file ? [file] : [], }; } export function splitText(text, maxChars = 9000) { if (text.length <= maxChars) return [text]; const chunks = []; let remaining = text; while (remaining.length > maxChars) { let splitAt = remaining.lastIndexOf('\n', maxChars); if (splitAt < Math.floor(maxChars * 0.6)) splitAt = maxChars; chunks.push(remaining.slice(0, splitAt)); remaining = remaining.slice(splitAt).replace(/^\n+/, ''); } if (remaining) chunks.push(remaining); return chunks; } export function isBotSender(event) { return event?.sender?.sender_type === 'bot'; } export function isAllowedSender(event, allowedOpenIds) { if (!allowedOpenIds || allowedOpenIds.size === 0) return false; if (allowedOpenIds.has('*')) return true; const senderOpenId = event?.sender?.sender_id?.open_id; return typeof senderOpenId === 'string' && allowedOpenIds.has(senderOpenId); }