mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 02:53:20 +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
|
|
@ -1,10 +1,16 @@
|
|||
import {
|
||||
conversationKey,
|
||||
extractInboundMessage,
|
||||
extractText,
|
||||
isAllowedSender,
|
||||
isBotSender,
|
||||
splitText,
|
||||
} from './message-utils.mjs';
|
||||
import {
|
||||
hasInboundImages,
|
||||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
import {
|
||||
harnessAnswerForQuestion,
|
||||
harnessQuestionText,
|
||||
|
|
@ -21,7 +27,7 @@ const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
|
|||
const HELP_TEXT = [
|
||||
'北汇星河 AIOS 已连接 DeepSeek Harness。',
|
||||
'',
|
||||
'直接发送问题即可继续当前会话。',
|
||||
'直接发送文字或图片即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/compact 压缩当前会话的较早上下文',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
|
|
@ -273,7 +279,8 @@ export class FeishuHarnessBridge {
|
|||
await this.#finishReaction(messageId, processingReaction, 'ERROR');
|
||||
await this.#send(
|
||||
event.message.chat_id,
|
||||
'处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
|
||||
imagePromptUserMessage(error)
|
||||
?? '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
||||
|
|
@ -297,40 +304,47 @@ export class FeishuHarnessBridge {
|
|||
this.#status.messagesReceived += 1;
|
||||
}
|
||||
|
||||
const text = extractText(event);
|
||||
if (!text) {
|
||||
await this.#send(event.message.chat_id, '目前仅支持文字消息。');
|
||||
const message = extractInboundMessage(event, this.#client);
|
||||
const text = message.content;
|
||||
const hasImages = hasInboundImages(message);
|
||||
const commandText = event.message.message_type === 'text' && !hasImages ? text : null;
|
||||
if (!text && !hasImages) {
|
||||
await this.#send(event.message.chat_id, '目前支持文字和图片消息。');
|
||||
return;
|
||||
}
|
||||
|
||||
if (text === '/help') {
|
||||
if (commandText === '/help') {
|
||||
await this.#send(event.message.chat_id, HELP_TEXT);
|
||||
return;
|
||||
}
|
||||
if (text === '/new') {
|
||||
if (commandText === '/new') {
|
||||
await this.#state.clearSession(key);
|
||||
await this.#send(event.message.chat_id, '已开启全新 Harness 会话。');
|
||||
return;
|
||||
}
|
||||
if (text === '/status') {
|
||||
if (commandText === '/status') {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
await this.#send(event.message.chat_id, '飞书机器人与 DeepSeek Harness 连接正常。');
|
||||
return;
|
||||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
||||
const workspaceCommand = commandText === null
|
||||
? null
|
||||
: await runWorkspaceCommand(text, this.#harness, key);
|
||||
if (workspaceCommand) {
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#send(event.message.chat_id, reply);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const compactCommand = await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
key,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
const compactCommand = commandText === null
|
||||
? null
|
||||
: await runCompactCommand(
|
||||
commandText,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
key,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
if (compactCommand) {
|
||||
await this.#send(event.message.chat_id, compactCommand.message);
|
||||
return;
|
||||
|
|
@ -338,7 +352,7 @@ export class FeishuHarnessBridge {
|
|||
|
||||
this.#logger.info?.(`[dsh-feishu] processing ${event.message.chat_type} message ${messageId}`);
|
||||
try {
|
||||
await this.#answerWithStream(event, key, text);
|
||||
await this.#answerWithStream(event, key, message);
|
||||
this.#status.messagesReplied += 1;
|
||||
this.#status.lastReplyAt = new Date().toISOString();
|
||||
this.#status.lastError = null;
|
||||
|
|
@ -362,15 +376,20 @@ export class FeishuHarnessBridge {
|
|||
};
|
||||
}
|
||||
|
||||
async #answerWithStream(event, key, text) {
|
||||
async #answerWithStream(event, key, message) {
|
||||
const chatId = event.message.chat_id;
|
||||
const messageId = event.message.message_id;
|
||||
const text = message.content;
|
||||
const content = hasInboundImages(message)
|
||||
? await promptContentForMessage(message, { signal: this.#signal })
|
||||
: undefined;
|
||||
if (!this.#channel?.stream) {
|
||||
const { answer } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
text,
|
||||
content,
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions: this.#interactionAskOptions(event, key),
|
||||
|
|
@ -398,6 +417,7 @@ export class FeishuHarnessBridge {
|
|||
state: this.#state,
|
||||
key,
|
||||
text,
|
||||
content,
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions,
|
||||
|
|
@ -425,6 +445,7 @@ export class FeishuHarnessBridge {
|
|||
state: this.#state,
|
||||
key,
|
||||
text,
|
||||
content,
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions: this.#interactionAskOptions(event, key),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { ImagePromptError } from '../shared/image-prompt.mjs';
|
||||
|
||||
export function conversationKey(event) {
|
||||
const chatType = event?.message?.chat_type;
|
||||
if (chatType === 'p2p') {
|
||||
|
|
@ -10,19 +12,151 @@ export function conversationKey(event) {
|
|||
return `group:${chatId}`;
|
||||
}
|
||||
|
||||
export function extractText(event) {
|
||||
if (event?.message?.message_type !== 'text') return null;
|
||||
let parsed;
|
||||
function parsedMessageContent(event) {
|
||||
const value = event?.message?.content;
|
||||
if (value && typeof value === 'object') return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
try {
|
||||
parsed = JSON.parse(event.message.content);
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let text = typeof parsed.text === 'string' ? parsed.text : '';
|
||||
for (const mention of event.message.mentions ?? []) {
|
||||
if (typeof mention.key === 'string' && mention.key) text = text.replaceAll(mention.key, '');
|
||||
}
|
||||
|
||||
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 text.trim();
|
||||
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`,
|
||||
'图片超过 5 MB,请压缩后重试。',
|
||||
);
|
||||
}
|
||||
chunks.push(data);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
return Buffer.concat(chunks, size);
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
}
|
||||
|
||||
function feishuImageSource(event, client, key) {
|
||||
return {
|
||||
async load({ signal, maxBytes }) {
|
||||
signal?.throwIfAborted();
|
||||
const resource = await client?.im?.v1?.messageResource?.get?.({
|
||||
path: {
|
||||
message_id: event.message.message_id,
|
||||
file_key: key,
|
||||
},
|
||||
params: { type: 'image' },
|
||||
});
|
||||
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}`,
|
||||
'图片超过 5 MB,请压缩后重试。',
|
||||
);
|
||||
}
|
||||
return readBoundedStream(resource?.getReadableStream?.(), { signal, maxBytes });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 ?? [];
|
||||
return {
|
||||
content: messageType === 'text' ? extractText(event) ?? '' : post?.text ?? '',
|
||||
images: imageKeys.map((key) => feishuImageSource(event, client, key)),
|
||||
};
|
||||
}
|
||||
|
||||
export function splitText(text, maxChars = 9000) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export const FEISHU_SECRET_REF = 'DSH_FEISHU_APP_SECRET';
|
|||
export const REQUIRED_TENANT_SCOPES = Object.freeze([
|
||||
'im:message.p2p_msg:readonly',
|
||||
'im:message.group_at_msg:readonly',
|
||||
'im:message:readonly',
|
||||
'im:message:send_as_bot',
|
||||
'im:message.reactions:write_only',
|
||||
'im:message:recall',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue