mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 05:20:46 +08:00
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
325 lines
11 KiB
JavaScript
325 lines
11 KiB
JavaScript
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);
|
||
}
|