dsh-im-ops/src/channels/feishu/message-utils.mjs
evanfang0054 da4114dfe3 feat(feishu): isolate Harness sessions per topic in topic groups
conversationKey derived the group session key from chat_id alone, so in
Feishu topic groups every topic shared one Harness session: context,
/new, pending approvals, and batch-input state all leaked across topics.

Append the event's thread_id to the session key when present. Topic
groups stamp every message with a thread_id, so each topic now maps to
its own group:<chat_id>🧵<thread_id> session. Regular group chats
carry no thread_id and keep the single shared group:<chat_id> key; p2p
keys are untouched.

- message-utils.mjs: thread-aware conversationKey
- message-utils.test.mjs: topic isolation + regular-group fallback cases
- lib/index.js: regenerated host bundle
2026-08-31 13:54:35 +08:00

325 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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:<chat_id>` 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);
}