mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 12:53:15 +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,5 +1,7 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs';
|
||||
|
||||
export const DINGTALK_REGISTRATION_BASE_URL = 'https://oapi.dingtalk.com/';
|
||||
export const DINGTALK_API_BASE_URL = 'https://api.dingtalk.com/';
|
||||
export const DINGTALK_REGISTRATION_SOURCE = 'DING_DWS_CLAW';
|
||||
|
|
@ -14,6 +16,7 @@ export class DingtalkApiError extends Error {
|
|||
this.name = 'DingtalkApiError';
|
||||
this.code = code;
|
||||
this.status = options.status;
|
||||
this.providerCode = options.providerCode;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -21,6 +24,25 @@ function nonEmptyString(value) {
|
|||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function safeProviderCode(value) {
|
||||
const code = nonEmptyString(value);
|
||||
return code && /^[A-Za-z0-9_.:-]{1,160}$/.test(code) ? code : undefined;
|
||||
}
|
||||
|
||||
function secureDingtalkDownloadUrl(value) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch (error) {
|
||||
throw new DingtalkApiError('invalid-image-download', '钉钉服务返回了无效的图片下载地址。', { cause: error });
|
||||
}
|
||||
if (url.protocol === 'http:' && (!url.port || url.port === '80')) {
|
||||
url.protocol = 'https:';
|
||||
url.port = '';
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function isDingtalkHost(hostname) {
|
||||
const normalized = hostname.toLowerCase().replace(/\.$/, '');
|
||||
return normalized === 'dingtalk.com' || normalized.endsWith('.dingtalk.com');
|
||||
|
|
@ -123,10 +145,17 @@ async function requestJson(fetchImpl, url, {
|
|||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
let providerCode;
|
||||
try {
|
||||
const errorBody = await response.json();
|
||||
providerCode = safeProviderCode(errorBody?.code ?? errorBody?.errcode);
|
||||
} catch {
|
||||
// DingTalk occasionally returns an empty or non-JSON error body.
|
||||
}
|
||||
throw new DingtalkApiError(
|
||||
'http-error',
|
||||
`钉钉服务请求失败(HTTP ${response.status})。`,
|
||||
{ status: response.status },
|
||||
{ status: response.status, providerCode },
|
||||
);
|
||||
}
|
||||
try {
|
||||
|
|
@ -403,6 +432,58 @@ export function createDingtalkApi({
|
|||
|
||||
accessToken,
|
||||
|
||||
async downloadImage({
|
||||
clientId,
|
||||
clientSecret,
|
||||
robotCode,
|
||||
downloadCode,
|
||||
signal,
|
||||
maxBytes,
|
||||
}) {
|
||||
const botCode = nonEmptyString(robotCode);
|
||||
const fileCode = nonEmptyString(downloadCode);
|
||||
if (!botCode || !fileCode) throw new TypeError('robotCode and downloadCode are required');
|
||||
const token = await accessToken({ clientId, clientSecret, signal });
|
||||
let response;
|
||||
try {
|
||||
response = await requestJson(
|
||||
fetchImpl,
|
||||
endpoint(apiBase, 'v1.0/robot/messageFiles/download'),
|
||||
{
|
||||
body: { downloadCode: fileCode, robotCode: botCode },
|
||||
headers: { 'x-acs-dingtalk-access-token': token },
|
||||
signal,
|
||||
action: '图片下载地址',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
throw new DingtalkApiError(
|
||||
'image-download-address-failed',
|
||||
'钉钉图片下载地址获取失败。',
|
||||
{ cause: error, status: error?.status, providerCode: error?.providerCode },
|
||||
);
|
||||
}
|
||||
const downloadUrl = nonEmptyString(response?.downloadUrl ?? response?.download_url);
|
||||
if (!downloadUrl) {
|
||||
throw new DingtalkApiError('invalid-image-download', '钉钉服务没有返回图片下载地址。');
|
||||
}
|
||||
try {
|
||||
return await fetchImageBuffer(secureDingtalkDownloadUrl(downloadUrl), {
|
||||
fetchImpl,
|
||||
signal,
|
||||
maxBytes,
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted || error instanceof ImagePromptError) throw error;
|
||||
throw new DingtalkApiError(
|
||||
'image-content-download-failed',
|
||||
'钉钉图片内容下载失败。',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async createAiCard({ clientId, clientSecret, target, initialText, signal }) {
|
||||
const appKey = nonEmptyString(clientId);
|
||||
const appSecret = nonEmptyString(clientSecret);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
|||
import { runCompactCommand } from '../shared/compact-command.mjs';
|
||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
import {
|
||||
hasInboundImages,
|
||||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
|
||||
const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
|
||||
const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。';
|
||||
|
|
@ -20,7 +25,7 @@ const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无
|
|||
const HELP_TEXT = [
|
||||
'钉钉机器人已连接 DeepSeek Harness。',
|
||||
'',
|
||||
'直接发送文字即可继续当前会话。',
|
||||
'直接发送文字或图片即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/compact 压缩当前会话的较早上下文',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
|
|
@ -35,10 +40,123 @@ function nonEmptyString(value) {
|
|||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function safeErrorDiagnostic(error) {
|
||||
const chain = [];
|
||||
const seen = new Set();
|
||||
let current = error;
|
||||
while (current && typeof current === 'object' && chain.length < 3 && !seen.has(current)) {
|
||||
seen.add(current);
|
||||
const name = nonEmptyString(current.name)?.slice(0, 80);
|
||||
const code = nonEmptyString(current.code)?.slice(0, 80);
|
||||
const providerCode = nonEmptyString(current.providerCode)?.slice(0, 160);
|
||||
const status = Number.isInteger(current.status) ? current.status : undefined;
|
||||
chain.push({
|
||||
...(name ? { name } : {}),
|
||||
...(code ? { code } : {}),
|
||||
...(providerCode ? { providerCode } : {}),
|
||||
...(status ? { status } : {}),
|
||||
});
|
||||
current = current.cause;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
function dingtalkImageErrorUserMessage(error) {
|
||||
let current = error;
|
||||
const seen = new Set();
|
||||
while (current && typeof current === 'object' && !seen.has(current)) {
|
||||
seen.add(current);
|
||||
if (current.code === 'image-download-address-failed') {
|
||||
return '钉钉未能换取图片下载地址,请重新发送;若持续失败,请检查机器人的“企业内机器人发送消息权限”。';
|
||||
}
|
||||
if (current.code === 'invalid-image-download') {
|
||||
return '钉钉没有返回图片下载地址,请重新发送。';
|
||||
}
|
||||
if (current.code === 'image-content-download-failed') {
|
||||
return '钉钉返回的图片临时地址无法读取,请重新发送。';
|
||||
}
|
||||
current = current.cause;
|
||||
}
|
||||
return imagePromptUserMessage(error);
|
||||
}
|
||||
|
||||
function senderStaffId(message) {
|
||||
return nonEmptyString(message?.senderStaffId) ?? nonEmptyString(message?.senderId);
|
||||
}
|
||||
|
||||
function parsedMessageContent(message) {
|
||||
if (message?.content && typeof message.content === 'object') return message.content;
|
||||
if (typeof message?.content !== 'string') return null;
|
||||
try {
|
||||
const parsed = JSON.parse(message.content);
|
||||
return parsed && typeof parsed === 'object' ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function richTextEntries(content) {
|
||||
const entries = content?.richText ?? content?.rich_text;
|
||||
return Array.isArray(entries) ? entries : [];
|
||||
}
|
||||
|
||||
function richTextEntryText(entry) {
|
||||
if (typeof entry?.text === 'string') return nonEmptyString(entry.text);
|
||||
if (typeof entry?.text?.content === 'string') return nonEmptyString(entry.text.content);
|
||||
if (String(entry?.type).toLowerCase() === 'text' && typeof entry?.content === 'string') {
|
||||
return nonEmptyString(entry.content);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function downloadCodeFor(value) {
|
||||
return nonEmptyString(value?.downloadCode) ?? nonEmptyString(value?.pictureDownloadCode);
|
||||
}
|
||||
|
||||
/** Normalize DingTalk picture and richText callbacks into lazy image references. */
|
||||
export function dingtalkInboundMessage(message, {
|
||||
api,
|
||||
clientId,
|
||||
clientSecret,
|
||||
} = {}) {
|
||||
const msgtype = String(message?.msgtype ?? '').toLowerCase();
|
||||
const content = parsedMessageContent(message);
|
||||
const richEntries = msgtype === 'richtext' ? richTextEntries(content) : [];
|
||||
const text = msgtype === 'text'
|
||||
? nonEmptyString(message?.text?.content) ?? ''
|
||||
: richEntries.map(richTextEntryText).filter(Boolean).join('\n');
|
||||
const imageCodes = [];
|
||||
if (msgtype === 'picture') {
|
||||
const code = downloadCodeFor(content);
|
||||
if (code) imageCodes.push(code);
|
||||
} else if (msgtype === 'richtext') {
|
||||
for (const entry of richEntries) {
|
||||
if (String(entry?.type ?? '').toLowerCase() !== 'picture') continue;
|
||||
const code = downloadCodeFor(entry);
|
||||
if (code) imageCodes.push(code);
|
||||
}
|
||||
}
|
||||
return {
|
||||
content: text,
|
||||
images: imageCodes.map((downloadCode, index) => ({
|
||||
name: index === 0 ? 'image' : `image-${index + 1}`,
|
||||
load: ({ signal, maxBytes }) => {
|
||||
if (typeof api?.downloadImage !== 'function') {
|
||||
throw new Error('DingTalk API does not support image downloads');
|
||||
}
|
||||
return api.downloadImage({
|
||||
clientId,
|
||||
clientSecret,
|
||||
robotCode: message?.robotCode,
|
||||
downloadCode,
|
||||
signal,
|
||||
maxBytes,
|
||||
});
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function conversationKey(message, sender) {
|
||||
if (String(message?.conversationType) === '2') {
|
||||
const conversationId = nonEmptyString(message?.conversationId);
|
||||
|
|
@ -317,49 +435,63 @@ export class DingtalkHarnessBridge {
|
|||
return;
|
||||
}
|
||||
|
||||
const text = message?.msgtype === 'text' ? nonEmptyString(message?.text?.content) : null;
|
||||
const promptMessage = dingtalkInboundMessage(message, {
|
||||
api: this.#api,
|
||||
clientId: this.#clientId,
|
||||
clientSecret: this.#clientSecret,
|
||||
});
|
||||
const text = promptMessage.content;
|
||||
const hasImages = hasInboundImages(promptMessage);
|
||||
const isPlainText = String(message?.msgtype).toLowerCase() === 'text';
|
||||
let cardStream = null;
|
||||
let cardStarted = false;
|
||||
try {
|
||||
if (!text) {
|
||||
await this.#send(sessionWebhook, '目前仅支持文字消息。');
|
||||
if (!text && !hasImages) {
|
||||
await this.#send(sessionWebhook, '目前支持文字和图片消息。');
|
||||
return;
|
||||
}
|
||||
|
||||
const command = text.toLowerCase();
|
||||
if (command === '/help') {
|
||||
if (isPlainText && !hasImages && command === '/help') {
|
||||
await this.#send(sessionWebhook, HELP_TEXT);
|
||||
return;
|
||||
}
|
||||
if (command === '/status') {
|
||||
if (isPlainText && !hasImages && command === '/status') {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
await this.#send(sessionWebhook, '钉钉机器人与 DeepSeek Harness 连接正常。');
|
||||
return;
|
||||
}
|
||||
if (command === '/new') {
|
||||
if (isPlainText && !hasImages && command === '/new') {
|
||||
await this.#state.clearSession(key);
|
||||
await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。');
|
||||
return;
|
||||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
||||
const workspaceCommand = isPlainText && !hasImages
|
||||
? await runWorkspaceCommand(text, this.#harness, key)
|
||||
: null;
|
||||
if (workspaceCommand) {
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#send(sessionWebhook, reply);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const compactCommand = await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
key,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
const compactCommand = isPlainText && !hasImages
|
||||
? await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
key,
|
||||
{ signal: this.#signal },
|
||||
)
|
||||
: null;
|
||||
if (compactCommand) {
|
||||
await this.#send(sessionWebhook, compactCommand.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = hasImages
|
||||
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
||||
: undefined;
|
||||
if (typeof this.#api.createAiCard === 'function'
|
||||
&& typeof this.#api.updateAiCard === 'function'
|
||||
&& typeof this.#api.finishAiCard === 'function') {
|
||||
|
|
@ -377,7 +509,7 @@ export class DingtalkHarnessBridge {
|
|||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
text,
|
||||
...(hasImages ? { content } : { text }),
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions: {
|
||||
|
|
@ -400,13 +532,17 @@ export class DingtalkHarnessBridge {
|
|||
increment(this.#status, 'messagesReplied');
|
||||
this.#status.lastReplyAt = new Date().toISOString();
|
||||
this.#status.lastError = null;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) return;
|
||||
this.#status.lastError = '钉钉消息处理失败。';
|
||||
this.#logger.error?.('[dsh-dingtalk] failed to process an inbound message');
|
||||
this.#logger.error?.(
|
||||
'[dsh-dingtalk] failed to process an inbound message',
|
||||
safeErrorDiagnostic(error),
|
||||
);
|
||||
try {
|
||||
const streamed = cardStarted && await cardStream.finish(CARD_ERROR_TEXT);
|
||||
if (!streamed) await this.#send(sessionWebhook, CARD_ERROR_TEXT);
|
||||
const errorText = dingtalkImageErrorUserMessage(error) ?? CARD_ERROR_TEXT;
|
||||
const streamed = cardStarted && await cardStream.finish(errorText);
|
||||
if (!streamed) await this.#send(sessionWebhook, errorText);
|
||||
} catch {
|
||||
this.#logger.error?.('[dsh-dingtalk] failed to send the safe error reply');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,19 @@
|
|||
import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
|
||||
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
|
||||
import { DiscordApi } from './discord-api.mjs';
|
||||
import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs';
|
||||
|
||||
const DISCORD_GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 12);
|
||||
const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]);
|
||||
const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
||||
const IMAGE_FILE_TYPES = new Map([
|
||||
['.jpg', 'image/jpeg'],
|
||||
['.jpeg', 'image/jpeg'],
|
||||
['.png', 'image/png'],
|
||||
['.webp', 'image/webp'],
|
||||
['.gif', 'image/gif'],
|
||||
]);
|
||||
const DISCORD_IMAGE_HOSTS = Object.freeze(['cdn.discordapp.com']);
|
||||
|
||||
function socketUrl(value) {
|
||||
const url = new URL(value);
|
||||
|
|
@ -48,7 +58,37 @@ function stripBotMention(text, botId) {
|
|||
return text.replace(new RegExp(`<@!?${botId}>`, 'g'), '').trim();
|
||||
}
|
||||
|
||||
export function normalizeDiscordMessage(message, botId) {
|
||||
function attachmentMediaType(attachment) {
|
||||
const value = typeof attachment?.content_type === 'string'
|
||||
? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : '';
|
||||
if (IMAGE_MEDIA_TYPES.has(value)) return value;
|
||||
const filename = typeof attachment?.filename === 'string' ? attachment.filename.toLowerCase() : '';
|
||||
for (const [extension, mediaType] of IMAGE_FILE_TYPES) {
|
||||
if (filename.endsWith(extension)) return mediaType;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function attachmentSize(value) {
|
||||
return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function discordImageSource(attachment, fetchImpl) {
|
||||
const mediaType = attachmentMediaType(attachment);
|
||||
if (!mediaType || typeof attachment?.url !== 'string') return null;
|
||||
return {
|
||||
name: typeof attachment.filename === 'string' ? attachment.filename : undefined,
|
||||
mediaType,
|
||||
size: attachmentSize(attachment.size),
|
||||
load: (options) => fetchImageBuffer(attachment.url, {
|
||||
...options,
|
||||
fetchImpl,
|
||||
allowedHosts: DISCORD_IMAGE_HOSTS,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } = {}) {
|
||||
if (!message?.id || !message?.channel_id || !message?.author?.id) return null;
|
||||
const direct = !message.guild_id;
|
||||
const addressed = direct
|
||||
|
|
@ -60,6 +100,9 @@ export function normalizeDiscordMessage(message, botId) {
|
|||
kind: direct ? 'direct' : 'group',
|
||||
conversationId: String(message.channel_id),
|
||||
content: stripBotMention(message.content ?? '', botId),
|
||||
images: Array.isArray(message.attachments)
|
||||
? message.attachments.map((attachment) => discordImageSource(attachment, fetchImpl)).filter(Boolean)
|
||||
: [],
|
||||
addressed,
|
||||
replyTarget: {
|
||||
channelId: String(message.channel_id),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -7,13 +7,30 @@ import {
|
|||
} from '../shared/harness-question.mjs';
|
||||
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
import {
|
||||
fetchImageBuffer,
|
||||
hasInboundImages,
|
||||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
|
||||
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
||||
|
||||
export const QQ_IMAGE_HOSTS = Object.freeze([
|
||||
'.myqcloud.com',
|
||||
'.qpic.cn',
|
||||
'.qq.com',
|
||||
'.qq.com.cn',
|
||||
'.tencentcos.com',
|
||||
'.ugcimg.cn',
|
||||
]);
|
||||
|
||||
const QQ_IMAGE_FILENAME = /\.(?:gif|jpe?g|png|webp)$/i;
|
||||
|
||||
const HELP_TEXT = [
|
||||
'QQ 机器人已连接 DeepSeek Harness。',
|
||||
'',
|
||||
'直接发送文字即可继续当前会话。',
|
||||
'直接发送文字或图片即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/compact 压缩当前会话的较早上下文',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
|
|
@ -32,6 +49,51 @@ function safeText(message) {
|
|||
return typeof message?.content === 'string' ? message.content.trim() : '';
|
||||
}
|
||||
|
||||
function attachmentMediaType(attachment) {
|
||||
const value = nonEmptyString(attachment?.content_type ?? attachment?.contentType);
|
||||
if (!value) return null;
|
||||
return value.split(';', 1)[0].trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isQqImageAttachment(attachment) {
|
||||
const mediaType = attachmentMediaType(attachment);
|
||||
return mediaType?.startsWith('image/') === true
|
||||
|| QQ_IMAGE_FILENAME.test(nonEmptyString(attachment?.filename) ?? '');
|
||||
}
|
||||
|
||||
function hasQqImageAttachments(message) {
|
||||
return Array.isArray(message?.attachments)
|
||||
&& message.attachments.some(isQqImageAttachment);
|
||||
}
|
||||
|
||||
/** Convert QQ's attachment metadata into lazily downloaded image references. */
|
||||
export function qqInboundMessage(message, { fetchImpl = fetch } = {}) {
|
||||
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
|
||||
const images = [];
|
||||
for (const attachment of message?.attachments ?? []) {
|
||||
if (!isQqImageAttachment(attachment)) continue;
|
||||
const url = nonEmptyString(attachment?.url);
|
||||
const name = nonEmptyString(attachment?.filename) ?? undefined;
|
||||
const mediaType = attachmentMediaType(attachment);
|
||||
const declaredSize = Number(attachment?.size);
|
||||
images.push({
|
||||
...(name ? { name } : {}),
|
||||
...(mediaType?.startsWith('image/') ? { mediaType } : {}),
|
||||
...(Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}),
|
||||
load: ({ signal, maxBytes }) => {
|
||||
if (!url) throw new Error('QQ image attachment has no download URL');
|
||||
return fetchImageBuffer(url, {
|
||||
fetchImpl,
|
||||
signal,
|
||||
maxBytes,
|
||||
allowedHosts: QQ_IMAGE_HOSTS,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
return { content: safeText(message), images };
|
||||
}
|
||||
|
||||
function nonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
|
@ -40,6 +102,7 @@ function canClaimInteractionReply(message, pending) {
|
|||
return pending.questions[pending.index]
|
||||
&& nonEmptyString(message?.senderId) === pending.actor
|
||||
&& (message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE')
|
||||
&& !hasQqImageAttachments(message)
|
||||
&& nonEmptyString(safeText(message));
|
||||
}
|
||||
|
||||
|
|
@ -64,6 +127,7 @@ export class QqHarnessBridge {
|
|||
#logger;
|
||||
#replyTimeoutMs;
|
||||
#signal;
|
||||
#fetchImpl;
|
||||
#queues = new Map();
|
||||
#pendingInteractions = new Map();
|
||||
#interactionKeys = new Map();
|
||||
|
|
@ -80,10 +144,12 @@ export class QqHarnessBridge {
|
|||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
signal,
|
||||
fetchImpl = fetch,
|
||||
}) {
|
||||
if (!bot || typeof bot.sendText !== 'function') throw new TypeError('QQ bot client is required');
|
||||
if (!ownerUserOpenid) throw new TypeError('QQ scanner identity is required');
|
||||
if (!harness || !state) throw new TypeError('Harness client and state store are required');
|
||||
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
|
||||
this.#bot = bot;
|
||||
this.#ownerUserOpenid = ownerUserOpenid;
|
||||
this.#harness = harness;
|
||||
|
|
@ -92,6 +158,7 @@ export class QqHarnessBridge {
|
|||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#signal = signal;
|
||||
this.#fetchImpl = fetchImpl;
|
||||
this.#approvals = new HarnessApprovalQueue({ label: 'qq', logger });
|
||||
}
|
||||
|
||||
|
|
@ -114,7 +181,7 @@ export class QqHarnessBridge {
|
|||
key,
|
||||
actor: sender,
|
||||
messageId,
|
||||
text: safeText(message),
|
||||
text: hasQqImageAttachments(message) ? '' : safeText(message),
|
||||
addressed: message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE',
|
||||
hasPendingQuestion: Boolean(pending),
|
||||
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
||||
|
|
@ -210,32 +277,37 @@ export class QqHarnessBridge {
|
|||
if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
|
||||
|
||||
const target = message.replyTarget;
|
||||
const text = safeText(message);
|
||||
const promptMessage = qqInboundMessage(message, { fetchImpl: this.#fetchImpl });
|
||||
const text = promptMessage.content;
|
||||
const hasImages = hasInboundImages(promptMessage);
|
||||
let stream = null;
|
||||
try {
|
||||
if (!text) {
|
||||
await this.#bot.sendText(target, '目前仅支持文字消息。');
|
||||
if (!text && !hasImages) {
|
||||
await this.#bot.sendText(target, '目前支持文字和图片消息。');
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const command = text.toLowerCase();
|
||||
if (command === '/help') {
|
||||
if (!hasImages && command === '/help') {
|
||||
await this.#bot.sendText(target, HELP_TEXT);
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
if (command === '/status') {
|
||||
if (!hasImages && command === '/status') {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
await this.#bot.sendText(target, 'QQ 机器人与 DeepSeek Harness 连接正常。');
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
if (command === '/new') {
|
||||
if (!hasImages && command === '/new') {
|
||||
await this.#state.clearSession(key);
|
||||
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
||||
const workspaceCommand = hasImages
|
||||
? null
|
||||
: await runWorkspaceCommand(text, this.#harness, key);
|
||||
if (workspaceCommand) {
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#bot.sendText(target, reply);
|
||||
|
|
@ -243,20 +315,24 @@ export class QqHarnessBridge {
|
|||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const compactCommand = await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
key,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
const compactCommand = hasImages
|
||||
? null
|
||||
: await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
key,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
if (compactCommand) {
|
||||
await this.#bot.sendText(target, compactCommand.message);
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
let stream = null;
|
||||
const content = hasImages
|
||||
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
||||
: undefined;
|
||||
let streamFinished = false;
|
||||
if (message.kind === 'c2c' && target?.msgId && typeof this.#bot.openStream === 'function') {
|
||||
try {
|
||||
|
|
@ -271,7 +347,7 @@ export class QqHarnessBridge {
|
|||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
text,
|
||||
...(hasImages ? { content } : { text }),
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions: {
|
||||
|
|
@ -316,11 +392,15 @@ export class QqHarnessBridge {
|
|||
this.#status.lastReplyAt = new Date().toISOString();
|
||||
this.#status.lastError = null;
|
||||
} catch (error) {
|
||||
stream?.cancel?.();
|
||||
if (this.#signal?.aborted) return;
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#logger.error?.('[dsh-im:qq] failed to process an inbound message:', error);
|
||||
try {
|
||||
await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
|
||||
await this.#bot.sendText(
|
||||
target,
|
||||
imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。',
|
||||
);
|
||||
await this.#state.markSeen(messageId);
|
||||
} catch (sendError) {
|
||||
this.#logger.error?.('[dsh-im:qq] failed to send the safe error reply:', sendError);
|
||||
|
|
@ -345,7 +425,7 @@ export class QqHarnessBridge {
|
|||
|
||||
if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
|
||||
const text = nonEmptyString(safeText(message));
|
||||
if (!text) {
|
||||
if (!text || hasQqImageAttachments(message)) {
|
||||
await this.#bot.sendText(message.replyTarget, '请用文字回答当前问题。');
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -623,7 +623,7 @@ export class HarnessClient {
|
|||
return ownership ? { ownership, recovered: true } : null;
|
||||
}
|
||||
|
||||
async ask(sessionId, text, options = {}) {
|
||||
async ask(sessionId, prompt, options = {}) {
|
||||
if (typeof options === 'number') options = { timeoutMs: options };
|
||||
const timeoutMs = options.timeoutMs ?? 600_000;
|
||||
const signal = options.signal;
|
||||
|
|
@ -693,10 +693,16 @@ export class HarnessClient {
|
|||
]);
|
||||
}
|
||||
|
||||
const content = typeof prompt === 'string'
|
||||
? [{ type: 'text', text: prompt }]
|
||||
: prompt;
|
||||
if (!Array.isArray(content) || content.length === 0) {
|
||||
throw new TypeError('Harness prompt content is required');
|
||||
}
|
||||
await this.rpc('session.prompt', {
|
||||
sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text }],
|
||||
content,
|
||||
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
}, 30_000, { rpcId: promptRpcId, signal });
|
||||
|
||||
|
|
|
|||
268
src/channels/shared/image-prompt.mjs
Normal file
268
src/channels/shared/image-prompt.mjs
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||
const DEFAULT_MAX_IMAGES = 20;
|
||||
const DEFAULT_MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
export const DEFAULT_IMAGE_PROMPT = '请分析这张图片。';
|
||||
|
||||
export class ImagePromptError extends Error {
|
||||
constructor(code, message, userMessage, options = {}) {
|
||||
super(message, options);
|
||||
this.name = 'ImagePromptError';
|
||||
this.code = code;
|
||||
this.userMessage = userMessage;
|
||||
}
|
||||
}
|
||||
|
||||
function requestSignal(signal, timeoutMs) {
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
}
|
||||
|
||||
async function cancelResponseBody(response) {
|
||||
try {
|
||||
await response?.body?.cancel?.();
|
||||
} catch {
|
||||
// The original download error is more useful than a best-effort cleanup failure.
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchImageBuffer(url, {
|
||||
fetchImpl = fetch,
|
||||
headers,
|
||||
signal,
|
||||
maxBytes = DEFAULT_MAX_IMAGE_BYTES,
|
||||
timeoutMs = 15_000,
|
||||
allowedHosts,
|
||||
} = {}) {
|
||||
const target = new URL(url);
|
||||
if (target.protocol !== 'https:') throw new Error('Image download URL must use HTTPS');
|
||||
if (Array.isArray(allowedHosts) && !allowedHosts.some((rule) => (
|
||||
typeof rule === 'string'
|
||||
&& (target.hostname === rule
|
||||
|| (rule.startsWith('.')
|
||||
&& (target.hostname === rule.slice(1) || target.hostname.endsWith(rule))))
|
||||
))) {
|
||||
throw new Error('Image download URL is not hosted by the messaging platform');
|
||||
}
|
||||
const response = await fetchImpl(target, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: requestSignal(signal, timeoutMs),
|
||||
redirect: 'manual',
|
||||
});
|
||||
if (Number.isInteger(response?.status) && response.status >= 300 && response.status < 400) {
|
||||
await cancelResponseBody(response);
|
||||
throw new ImagePromptError(
|
||||
'image-redirect-blocked',
|
||||
`Image download redirect was blocked (HTTP ${response.status})`,
|
||||
'图片下载地址发生了重定向,暂时无法读取。',
|
||||
);
|
||||
}
|
||||
if (!response?.ok) {
|
||||
await cancelResponseBody(response);
|
||||
throw new ImagePromptError(
|
||||
'image-http-error',
|
||||
`Image download failed with HTTP ${response?.status ?? 'unknown'}`,
|
||||
`图片下载失败(HTTP ${response?.status ?? 'unknown'}),请重新发送后再试。`,
|
||||
);
|
||||
}
|
||||
const declaredLength = Number(response.headers?.get?.('content-length'));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
||||
await cancelResponseBody(response);
|
||||
throw new ImagePromptError(
|
||||
'image-too-large',
|
||||
`Image response declares ${declaredLength} bytes; the limit is ${maxBytes}`,
|
||||
'图片超过 5 MB,请压缩后重试。',
|
||||
);
|
||||
}
|
||||
|
||||
if (response.body?.[Symbol.asyncIterator]) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of response.body) {
|
||||
const data = Buffer.from(chunk);
|
||||
size += data.length;
|
||||
if (size > maxBytes) {
|
||||
await response.body.cancel?.().catch?.(() => undefined);
|
||||
throw new ImagePromptError(
|
||||
'image-too-large',
|
||||
`Image response exceeded ${maxBytes} bytes`,
|
||||
'图片超过 5 MB,请压缩后重试。',
|
||||
);
|
||||
}
|
||||
chunks.push(data);
|
||||
}
|
||||
return Buffer.concat(chunks, size);
|
||||
}
|
||||
|
||||
const data = Buffer.from(await response.arrayBuffer());
|
||||
if (data.length > maxBytes) {
|
||||
throw new ImagePromptError(
|
||||
'image-too-large',
|
||||
`Image response contains ${data.length} bytes; the limit is ${maxBytes}`,
|
||||
'图片超过 5 MB,请压缩后重试。',
|
||||
);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function cleanText(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function imageSources(message) {
|
||||
return Array.isArray(message?.images) ? message.images.filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function safeName(value) {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const name = value
|
||||
.replaceAll('\\', '/')
|
||||
.split('/')
|
||||
.at(-1)
|
||||
?.replace(/[\u0000-\u001f\u007f]/g, '')
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
return name || undefined;
|
||||
}
|
||||
|
||||
function detectedImageMediaType(data) {
|
||||
if (data.length >= 8
|
||||
&& data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47
|
||||
&& data[4] === 0x0d && data[5] === 0x0a && data[6] === 0x1a && data[7] === 0x0a) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (data.length >= 6) {
|
||||
const signature = data.subarray(0, 6).toString('ascii');
|
||||
if (signature === 'GIF87a' || signature === 'GIF89a') return 'image/gif';
|
||||
}
|
||||
if (data.length >= 12
|
||||
&& data.subarray(0, 4).toString('ascii') === 'RIFF'
|
||||
&& data.subarray(8, 12).toString('ascii') === 'WEBP') {
|
||||
return 'image/webp';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadedImage(value) {
|
||||
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
|
||||
return { data: Buffer.from(value) };
|
||||
}
|
||||
const raw = value?.data ?? value?.buffer;
|
||||
if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) {
|
||||
return {
|
||||
data: Buffer.from(raw),
|
||||
name: value?.name ?? value?.filename,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasInboundImages(message) {
|
||||
return imageSources(message).length > 0;
|
||||
}
|
||||
|
||||
export function hasInboundPrompt(message) {
|
||||
return Boolean(cleanText(message?.content)) || hasInboundImages(message);
|
||||
}
|
||||
|
||||
export async function promptContentForMessage(message, {
|
||||
signal,
|
||||
maxImageBytes = DEFAULT_MAX_IMAGE_BYTES,
|
||||
maxImages = DEFAULT_MAX_IMAGES,
|
||||
maxTotalImageBytes = DEFAULT_MAX_TOTAL_IMAGE_BYTES,
|
||||
} = {}) {
|
||||
const sources = imageSources(message);
|
||||
if (sources.length > maxImages) {
|
||||
throw new ImagePromptError(
|
||||
'too-many-images',
|
||||
`Image message contains ${sources.length} images; the limit is ${maxImages}`,
|
||||
`一次最多只能处理 ${maxImages} 张图片。`,
|
||||
);
|
||||
}
|
||||
|
||||
const text = cleanText(message?.content);
|
||||
const content = [];
|
||||
let totalImageBytes = 0;
|
||||
if (text) content.push({ type: 'text', text });
|
||||
else if (sources.length > 0) content.push({ type: 'text', text: DEFAULT_IMAGE_PROMPT });
|
||||
|
||||
for (const [index, source] of sources.entries()) {
|
||||
signal?.throwIfAborted();
|
||||
if (Number.isFinite(source?.size) && source.size > maxImageBytes) {
|
||||
throw new ImagePromptError(
|
||||
'image-too-large',
|
||||
`Image ${index + 1} declares ${source.size} bytes; the limit is ${maxImageBytes}`,
|
||||
'图片超过 5 MB,请压缩后重试。',
|
||||
);
|
||||
}
|
||||
if (Number.isFinite(source?.size) && totalImageBytes + source.size > maxTotalImageBytes) {
|
||||
throw new ImagePromptError(
|
||||
'images-too-large',
|
||||
`Images declare more than ${maxTotalImageBytes} bytes in total`,
|
||||
'一次发送的图片总大小过大,请减少图片数量或压缩后重试。',
|
||||
);
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = source?.data === undefined
|
||||
? await source?.load?.({ signal, maxBytes: maxImageBytes })
|
||||
: source.data;
|
||||
} catch (error) {
|
||||
if (signal?.aborted || error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
|
||||
if (error instanceof ImagePromptError) throw error;
|
||||
throw new ImagePromptError(
|
||||
'image-download-failed',
|
||||
`Unable to download image ${index + 1}: ${error?.message ?? String(error)}`,
|
||||
'图片下载失败,请重新发送后再试。',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const loaded = loadedImage(result);
|
||||
if (!loaded?.data.length) {
|
||||
throw new ImagePromptError(
|
||||
'invalid-image-data',
|
||||
`Image ${index + 1} returned no data`,
|
||||
'未能读取图片内容,请重新发送。',
|
||||
);
|
||||
}
|
||||
if (loaded.data.length > maxImageBytes) {
|
||||
throw new ImagePromptError(
|
||||
'image-too-large',
|
||||
`Image ${index + 1} contains ${loaded.data.length} bytes; the limit is ${maxImageBytes}`,
|
||||
'图片超过 5 MB,请压缩后重试。',
|
||||
);
|
||||
}
|
||||
if (totalImageBytes + loaded.data.length > maxTotalImageBytes) {
|
||||
throw new ImagePromptError(
|
||||
'images-too-large',
|
||||
`Images contain more than ${maxTotalImageBytes} bytes in total`,
|
||||
'一次发送的图片总大小过大,请减少图片数量或压缩后重试。',
|
||||
);
|
||||
}
|
||||
totalImageBytes += loaded.data.length;
|
||||
const mediaType = detectedImageMediaType(loaded.data);
|
||||
if (!mediaType) {
|
||||
throw new ImagePromptError(
|
||||
'unsupported-image-type',
|
||||
`Image ${index + 1} is not JPEG, PNG, GIF, or WebP`,
|
||||
'暂不支持该图片格式,请发送 JPEG、PNG、WebP 或 GIF 图片。',
|
||||
);
|
||||
}
|
||||
content.push({
|
||||
type: 'image',
|
||||
mediaType,
|
||||
data: loaded.data.toString('base64'),
|
||||
...(safeName(loaded.name ?? source?.name) ? { name: safeName(loaded.name ?? source?.name) } : {}),
|
||||
});
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
export function imagePromptUserMessage(error) {
|
||||
return error instanceof ImagePromptError ? error.userMessage : null;
|
||||
}
|
||||
|
|
@ -2,6 +2,11 @@ import { runWorkspaceCommand } from './workspace-command.mjs';
|
|||
import { runCompactCommand } from './compact-command.mjs';
|
||||
import { askInWorkspaceSession } from './workspace-session.mjs';
|
||||
import { HarnessApprovalQueue } from './harness-approval.mjs';
|
||||
import {
|
||||
hasInboundImages,
|
||||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from './image-prompt.mjs';
|
||||
import {
|
||||
harnessAnswerForQuestion,
|
||||
harnessQuestionText,
|
||||
|
|
@ -17,6 +22,7 @@ function cleanText(value) {
|
|||
function canClaimInteractionReply(message, pending, senderId) {
|
||||
return pending.actor === senderId
|
||||
&& (message.kind !== 'group' || message.addressed === true)
|
||||
&& !hasInboundImages(message)
|
||||
&& Boolean(cleanText(message.content));
|
||||
}
|
||||
|
||||
|
|
@ -98,7 +104,7 @@ export class TextHarnessBridge {
|
|||
key,
|
||||
actor: senderId,
|
||||
messageId,
|
||||
text: normalized.content,
|
||||
text: hasInboundImages(normalized) ? '' : normalized.content,
|
||||
addressed: normalized.kind !== 'group' || normalized.addressed === true,
|
||||
hasPendingQuestion: Boolean(pending),
|
||||
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
||||
|
|
@ -208,16 +214,17 @@ export class TextHarnessBridge {
|
|||
this.#status.lastRejectedAt = new Date().toISOString();
|
||||
return;
|
||||
}
|
||||
if (!text) {
|
||||
await this.#bot.sendText(target, '目前仅支持文字消息。');
|
||||
const hasImages = hasInboundImages(message);
|
||||
if (!text && !hasImages) {
|
||||
await this.#bot.sendText(target, '目前支持文字和图片消息。');
|
||||
return;
|
||||
}
|
||||
const command = text.toLowerCase();
|
||||
if (command === '/help') {
|
||||
if (!hasImages && command === '/help') {
|
||||
await this.#bot.sendText(target, [
|
||||
`${this.#descriptor.label}机器人已连接 DeepSeek Harness。`,
|
||||
'',
|
||||
'直接发送文字即可继续当前会话。',
|
||||
'直接发送文字或图片即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/compact 压缩当前会话的较早上下文',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
|
|
@ -229,30 +236,34 @@ export class TextHarnessBridge {
|
|||
].join('\n'));
|
||||
return;
|
||||
}
|
||||
if (command === '/status') {
|
||||
if (!hasImages && command === '/status') {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
await this.#bot.sendText(target, `${this.#descriptor.label}机器人与 DeepSeek Harness 连接正常。`);
|
||||
return;
|
||||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, conversationKey);
|
||||
const workspaceCommand = !hasImages
|
||||
? await runWorkspaceCommand(text, this.#harness, conversationKey)
|
||||
: null;
|
||||
if (workspaceCommand) {
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#bot.sendText(target, reply);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (command === '/new') {
|
||||
if (!hasImages && command === '/new') {
|
||||
await this.#state.clearSession(conversationKey);
|
||||
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
|
||||
return;
|
||||
}
|
||||
const compactCommand = await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
conversationKey,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
const compactCommand = !hasImages
|
||||
? await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
conversationKey,
|
||||
{ signal: this.#signal },
|
||||
)
|
||||
: null;
|
||||
if (compactCommand) {
|
||||
await this.#bot.sendText(target, compactCommand.message);
|
||||
return;
|
||||
|
|
@ -272,11 +283,15 @@ export class TextHarnessBridge {
|
|||
);
|
||||
}
|
||||
}
|
||||
const content = hasImages
|
||||
? await promptContentForMessage(message, { signal: this.#signal })
|
||||
: undefined;
|
||||
const { answer } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key: conversationKey,
|
||||
text,
|
||||
content,
|
||||
createOptions: this.#signal ? { signal: this.#signal } : undefined,
|
||||
existsOptions: this.#signal ? { signal: this.#signal } : undefined,
|
||||
askOptions: {
|
||||
|
|
@ -316,6 +331,18 @@ export class TextHarnessBridge {
|
|||
stream?.cancel?.();
|
||||
if (this.#signal?.aborted) return;
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
const imageErrorMessage = imagePromptUserMessage(error);
|
||||
if (imageErrorMessage) {
|
||||
try {
|
||||
await this.#bot.sendText(target, imageErrorMessage);
|
||||
} catch (sendError) {
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:${this.#descriptor.key}] failed to send the image error reply:`,
|
||||
sendError,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
|
||||
try {
|
||||
await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
|
||||
|
|
@ -358,7 +385,7 @@ export class TextHarnessBridge {
|
|||
|
||||
const target = message.replyTarget;
|
||||
const text = cleanText(message.content);
|
||||
if (!text) {
|
||||
if (!text || hasInboundImages(message)) {
|
||||
try {
|
||||
await this.#bot.sendText(target, '请用文字回答当前问题。');
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export async function askInWorkspaceSession({
|
|||
state,
|
||||
key,
|
||||
text,
|
||||
content,
|
||||
createOptions,
|
||||
existsOptions,
|
||||
askOptions,
|
||||
|
|
@ -48,7 +49,7 @@ export async function askInWorkspaceSession({
|
|||
}
|
||||
return {
|
||||
sessionId,
|
||||
answer: await session.ask(text, askOptions),
|
||||
answer: await session.ask(content ?? text, askOptions),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.code !== WORKSPACE_SESSION_STALE) throw error;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ oauth_config:
|
|||
bot:
|
||||
- app_mentions:read
|
||||
- chat:write
|
||||
- files:read
|
||||
- im:history
|
||||
settings:
|
||||
event_subscriptions:
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://api.telegram.org/';
|
||||
const TELEGRAM_FILE_HOSTS = Object.freeze(['api.telegram.org']);
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
|
|
@ -47,6 +50,40 @@ export class TelegramApi {
|
|||
});
|
||||
}
|
||||
|
||||
async getFile({ fileId, signal } = {}) {
|
||||
const id = cleanString(fileId);
|
||||
if (!id || !/^[A-Za-z0-9_-]{1,512}$/.test(id)) {
|
||||
throw new TypeError('Telegram file id is invalid');
|
||||
}
|
||||
return this.#call('getFile', { file_id: id }, { signal });
|
||||
}
|
||||
|
||||
async downloadFile({ fileId, signal, maxBytes } = {}) {
|
||||
const file = await this.getFile({ fileId, signal });
|
||||
const filePath = cleanString(file?.file_path);
|
||||
if (!filePath || filePath.startsWith('/') || filePath.includes('\\')
|
||||
|| filePath.includes('?') || filePath.includes('#')) {
|
||||
throw new Error('Telegram returned an invalid file path');
|
||||
}
|
||||
let decodedSegments;
|
||||
try {
|
||||
decodedSegments = filePath.split('/').map((segment) => decodeURIComponent(segment));
|
||||
} catch {
|
||||
throw new Error('Telegram returned an invalid file path');
|
||||
}
|
||||
if (decodedSegments.some((segment) => !segment || segment === '.' || segment === '..')) {
|
||||
throw new Error('Telegram returned an invalid file path');
|
||||
}
|
||||
const url = new URL(this.#baseUrl);
|
||||
url.pathname = `/file/bot${this.#token}/${filePath}`;
|
||||
return fetchImageBuffer(url, {
|
||||
fetchImpl: this.#fetch,
|
||||
signal,
|
||||
maxBytes,
|
||||
allowedHosts: TELEGRAM_FILE_HOSTS,
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage({ chatId, text, replyToMessageId, messageThreadId, signal }) {
|
||||
return this.#call('sendMessage', {
|
||||
chat_id: chatId,
|
||||
|
|
|
|||
|
|
@ -7,14 +7,17 @@ function escaped(value) {
|
|||
}
|
||||
|
||||
function mentionedUsername(message, username) {
|
||||
if (!username || typeof message?.text !== 'string' || !Array.isArray(message.entities)) return false;
|
||||
return message.entities.some((entity) => {
|
||||
if (entity?.type !== 'mention' || !Number.isInteger(entity.offset) || !Number.isInteger(entity.length)) {
|
||||
return false;
|
||||
}
|
||||
return message.text.slice(entity.offset, entity.offset + entity.length).toLowerCase()
|
||||
=== `@${username.toLowerCase()}`;
|
||||
});
|
||||
if (!username) return false;
|
||||
return [
|
||||
[message?.text, message?.entities],
|
||||
[message?.caption, message?.caption_entities],
|
||||
].some(([text, entities]) => typeof text === 'string' && Array.isArray(entities)
|
||||
&& entities.some((entity) => {
|
||||
if (entity?.type !== 'mention' || !Number.isInteger(entity.offset)
|
||||
|| !Number.isInteger(entity.length)) return false;
|
||||
return text.slice(entity.offset, entity.offset + entity.length).toLowerCase()
|
||||
=== `@${username.toLowerCase()}`;
|
||||
}));
|
||||
}
|
||||
|
||||
function withoutBotMention(text, username) {
|
||||
|
|
@ -22,7 +25,63 @@ function withoutBotMention(text, username) {
|
|||
return text.replace(new RegExp(`@${escaped(username)}\\b`, 'ig'), '').trim();
|
||||
}
|
||||
|
||||
export function normalizeTelegramUpdate(update, { botId, username }) {
|
||||
const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
||||
const IMAGE_FILE_TYPES = new Map([
|
||||
['.jpg', 'image/jpeg'],
|
||||
['.jpeg', 'image/jpeg'],
|
||||
['.png', 'image/png'],
|
||||
['.webp', 'image/webp'],
|
||||
['.gif', 'image/gif'],
|
||||
]);
|
||||
|
||||
function imageTypeForDocument(document) {
|
||||
const declaredType = document?.mime_type ?? document?.mimetype;
|
||||
const type = typeof declaredType === 'string' ? declaredType.toLowerCase() : '';
|
||||
if (IMAGE_MEDIA_TYPES.has(type)) return type;
|
||||
const filename = typeof document?.file_name === 'string' ? document.file_name.toLowerCase() : '';
|
||||
for (const [extension, mediaType] of IMAGE_FILE_TYPES) {
|
||||
if (filename.endsWith(extension)) return mediaType;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fileSize(value) {
|
||||
return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function photoScore(photo) {
|
||||
return fileSize(photo?.file_size) ?? ((Number(photo?.width) || 0) * (Number(photo?.height) || 0));
|
||||
}
|
||||
|
||||
function telegramImageSource(message, loadFile) {
|
||||
let file;
|
||||
let mediaType;
|
||||
let name;
|
||||
if (Array.isArray(message?.photo) && message.photo.length > 0) {
|
||||
file = message.photo.reduce((largest, candidate) => (
|
||||
photoScore(candidate) > photoScore(largest) ? candidate : largest
|
||||
));
|
||||
mediaType = 'image/jpeg';
|
||||
name = `${file.file_unique_id ?? file.file_id ?? 'telegram-photo'}.jpg`;
|
||||
} else if (message?.document) {
|
||||
const type = imageTypeForDocument(message.document);
|
||||
if (!type) return null;
|
||||
file = message.document;
|
||||
mediaType = type;
|
||||
name = typeof file.file_name === 'string' ? file.file_name : undefined;
|
||||
}
|
||||
if (!file || typeof file.file_id !== 'string') return null;
|
||||
return {
|
||||
name,
|
||||
mediaType,
|
||||
size: fileSize(file.file_size),
|
||||
load: (options) => loadFile(file.file_id, options),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTelegramUpdate(update, { botId, username, loadFile = async () => {
|
||||
throw new Error('Telegram file downloader is unavailable');
|
||||
} }) {
|
||||
const message = update?.message;
|
||||
const chatId = message?.chat?.id;
|
||||
const senderId = message?.from?.id;
|
||||
|
|
@ -36,6 +95,7 @@ export function normalizeTelegramUpdate(update, { botId, username }) {
|
|||
|| mentionedUsername(message, username);
|
||||
const messageThreadId = Number.isSafeInteger(message.message_thread_id)
|
||||
? message.message_thread_id : undefined;
|
||||
const image = telegramImageSource(message, loadFile);
|
||||
return {
|
||||
messageId: String(update.update_id),
|
||||
senderId: String(senderId),
|
||||
|
|
@ -44,6 +104,7 @@ export function normalizeTelegramUpdate(update, { botId, username }) {
|
|||
conversationId: messageThreadId === undefined
|
||||
? String(chatId) : `${chatId}:${messageThreadId}`,
|
||||
content: withoutBotMention(message.text ?? message.caption ?? '', username),
|
||||
images: image ? [image] : [],
|
||||
addressed,
|
||||
replyTarget: {
|
||||
chatId,
|
||||
|
|
@ -250,6 +311,7 @@ export class TelegramRuntime {
|
|||
const message = normalizeTelegramUpdate(update, {
|
||||
botId: this.#config.platformId,
|
||||
username: this.#config.username,
|
||||
loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
|
||||
});
|
||||
if (message) {
|
||||
void this.#bridge.accept(message).catch((error) => {
|
||||
|
|
|
|||
|
|
@ -8,11 +8,17 @@ import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
|||
import { runCompactCommand } from '../shared/compact-command.mjs';
|
||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
import {
|
||||
hasInboundImages,
|
||||
ImagePromptError,
|
||||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
|
||||
const HELP_TEXT = [
|
||||
'企业微信机器人已连接 DeepSeek Harness。',
|
||||
'',
|
||||
'直接发送文字即可继续当前会话。',
|
||||
'直接发送文字或图片即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/compact 压缩当前会话的较早上下文',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
|
|
@ -23,6 +29,8 @@ const HELP_TEXT = [
|
|||
'/help 显示本帮助',
|
||||
].join('\n');
|
||||
const MAX_REPLY_BYTES = 18_000;
|
||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||
const MAX_PREFETCHED_IMAGES = 4;
|
||||
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
||||
|
||||
function nonEmptyString(value) {
|
||||
|
|
@ -59,6 +67,102 @@ function messageText(frame) {
|
|||
: text;
|
||||
}
|
||||
|
||||
function imageContents(frame) {
|
||||
const body = bodyOf(frame);
|
||||
if (body.msgtype === 'image') return [body.image];
|
||||
if (body.msgtype !== 'mixed' || !Array.isArray(body.mixed?.msg_item)) return [];
|
||||
return body.mixed.msg_item
|
||||
.filter((item) => item?.msgtype === 'image')
|
||||
.map((item) => item.image);
|
||||
}
|
||||
|
||||
function imageSource(client, image) {
|
||||
const url = nonEmptyString(image?.url);
|
||||
if (!url) return null;
|
||||
const aeskey = nonEmptyString(image?.aeskey) ?? undefined;
|
||||
return {
|
||||
async load({ signal, maxBytes }) {
|
||||
signal?.throwIfAborted();
|
||||
if (typeof client?.downloadFile !== 'function') {
|
||||
throw new Error('Enterprise WeChat image download is unavailable');
|
||||
}
|
||||
const result = await client.downloadFile(url, aeskey);
|
||||
signal?.throwIfAborted();
|
||||
const raw = result?.buffer;
|
||||
if (!Buffer.isBuffer(raw) && !(raw instanceof Uint8Array)) {
|
||||
throw new Error('Enterprise WeChat image download returned no data');
|
||||
}
|
||||
const data = Buffer.from(raw);
|
||||
if (Number.isFinite(maxBytes) && data.length > maxBytes) {
|
||||
throw new ImagePromptError(
|
||||
'image-too-large',
|
||||
`Enterprise WeChat image exceeds ${maxBytes} bytes`,
|
||||
'图片超过 5 MB,请压缩后重试。',
|
||||
);
|
||||
}
|
||||
return { data, name: result?.filename };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function wecomInboundMessage(frame, client) {
|
||||
return {
|
||||
content: messageText(frame),
|
||||
images: imageContents(frame).map((image) => imageSource(client, image)).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
function prefetchInboundImages(message, signal) {
|
||||
if (!hasInboundImages(message)) return message;
|
||||
return {
|
||||
...message,
|
||||
images: message.images.map((source) => {
|
||||
const download = source.load({ signal, maxBytes: MAX_IMAGE_BYTES });
|
||||
// The conversation queue may not consume this promise immediately. Keep
|
||||
// an attached rejection handler while preserving the original outcome.
|
||||
download.catch(() => undefined);
|
||||
return {
|
||||
...source,
|
||||
async load({ signal: loadSignal, maxBytes = MAX_IMAGE_BYTES } = {}) {
|
||||
loadSignal?.throwIfAborted();
|
||||
const result = await download;
|
||||
loadSignal?.throwIfAborted();
|
||||
const raw = result?.data ?? result?.buffer ?? result;
|
||||
const size = Buffer.isBuffer(raw) || raw instanceof Uint8Array ? raw.length : 0;
|
||||
if (size > maxBytes) {
|
||||
throw new ImagePromptError(
|
||||
'image-too-large',
|
||||
`Enterprise WeChat image exceeds ${maxBytes} bytes`,
|
||||
'图片超过 5 MB,请压缩后重试。',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function imageQueueFullMessage(message) {
|
||||
return {
|
||||
...message,
|
||||
images: message.images.map((source) => ({
|
||||
...source,
|
||||
async load() {
|
||||
throw new ImagePromptError(
|
||||
'image-queue-full',
|
||||
`Enterprise WeChat already has ${MAX_PREFETCHED_IMAGES} prefetched images`,
|
||||
'当前待处理图片较多,请稍后重新发送。',
|
||||
);
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function interactionReplyText(frame) {
|
||||
return bodyOf(frame).msgtype === 'text' ? messageText(frame) : '';
|
||||
}
|
||||
|
||||
function splitUtf8(text, maxBytes = MAX_REPLY_BYTES) {
|
||||
const source = String(text ?? '').trim();
|
||||
if (!source) return [];
|
||||
|
|
@ -89,7 +193,7 @@ function progressText(update) {
|
|||
function canClaimInteractionReply(frame, pending) {
|
||||
return pending.questions[pending.index]
|
||||
&& nonEmptyString(bodyOf(frame).from?.userid) === pending.actor
|
||||
&& nonEmptyString(messageText(frame));
|
||||
&& nonEmptyString(interactionReplyText(frame));
|
||||
}
|
||||
|
||||
export function createWecomBridgeStatus() {
|
||||
|
|
@ -119,6 +223,7 @@ export class WecomHarnessBridge {
|
|||
#acceptedMessageIds = new Set();
|
||||
#approvalTasks = new Set();
|
||||
#approvals;
|
||||
#prefetchedImageCount = 0;
|
||||
|
||||
constructor({
|
||||
client,
|
||||
|
|
@ -169,7 +274,7 @@ export class WecomHarnessBridge {
|
|||
key,
|
||||
actor: senderId,
|
||||
messageId,
|
||||
text: messageText(frame),
|
||||
text: interactionReplyText(frame),
|
||||
addressed: true,
|
||||
hasPendingQuestion: Boolean(pending),
|
||||
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
||||
|
|
@ -232,11 +337,28 @@ export class WecomHarnessBridge {
|
|||
releaseMessageId = true,
|
||||
alreadyRecorded = false,
|
||||
} = {}) {
|
||||
// WeCom image URLs expire after five minutes, while a conversation turn
|
||||
// may legally stay queued longer. Start the authenticated SDK download as
|
||||
// soon as the validated callback is accepted, then consume it in order.
|
||||
const inboundMessage = wecomInboundMessage(frame, this.#client);
|
||||
const imageCount = inboundMessage.images.length;
|
||||
let reservedImages = 0;
|
||||
let preparedMessage = inboundMessage;
|
||||
if (imageCount > 0) {
|
||||
if (this.#prefetchedImageCount + imageCount <= MAX_PREFETCHED_IMAGES) {
|
||||
reservedImages = imageCount;
|
||||
this.#prefetchedImageCount += reservedImages;
|
||||
preparedMessage = prefetchInboundImages(inboundMessage, this.#signal);
|
||||
} else {
|
||||
preparedMessage = imageQueueFullMessage(inboundMessage);
|
||||
}
|
||||
}
|
||||
const previous = this.#queues.get(key) ?? Promise.resolve();
|
||||
const current = previous
|
||||
.catch(() => undefined)
|
||||
.then(() => this.#process(frame, { alreadyRecorded }))
|
||||
.then(() => this.#process(frame, { alreadyRecorded, preparedMessage }))
|
||||
.finally(() => {
|
||||
this.#prefetchedImageCount -= reservedImages;
|
||||
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
||||
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
||||
});
|
||||
|
|
@ -275,7 +397,7 @@ export class WecomHarnessBridge {
|
|||
}
|
||||
}
|
||||
|
||||
async #process(frame, { alreadyRecorded = false } = {}) {
|
||||
async #process(frame, { alreadyRecorded = false, preparedMessage } = {}) {
|
||||
if (this.#signal?.aborted) return;
|
||||
const body = bodyOf(frame);
|
||||
const messageId = typeof body.msgid === 'string' ? body.msgid : '';
|
||||
|
|
@ -287,35 +409,39 @@ export class WecomHarnessBridge {
|
|||
this.#status.messagesReceived += 1;
|
||||
this.#status.lastMessageAt = new Date().toISOString();
|
||||
}
|
||||
const text = messageText(frame);
|
||||
const message = preparedMessage ?? wecomInboundMessage(frame, this.#client);
|
||||
const text = message.content;
|
||||
const hasImages = hasInboundImages(message);
|
||||
const key = conversationKey(frame);
|
||||
let streamId = null;
|
||||
let streamStarted = false;
|
||||
try {
|
||||
if (!text) {
|
||||
await this.#sendImmediate(frame, chatId, '目前支持文字、语音转写和图文混排中的文字消息。');
|
||||
if (!text && !hasImages) {
|
||||
await this.#sendImmediate(frame, chatId, '目前支持文字、图片和语音转写消息。');
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const command = text.toLowerCase();
|
||||
if (command === '/help') {
|
||||
if (!hasImages && command === '/help') {
|
||||
await this.#sendImmediate(frame, chatId, HELP_TEXT);
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
if (command === '/status') {
|
||||
if (!hasImages && command === '/status') {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
await this.#sendImmediate(frame, chatId, '企业微信机器人与 DeepSeek Harness 连接正常。');
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
if (command === '/new') {
|
||||
if (!hasImages && command === '/new') {
|
||||
await this.#state.clearSession(key);
|
||||
await this.#sendImmediate(frame, chatId, '已开启新会话。请发送你的问题。');
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
||||
const workspaceCommand = hasImages
|
||||
? null
|
||||
: await runWorkspaceCommand(text, this.#harness, key);
|
||||
if (workspaceCommand) {
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#sendImmediate(frame, chatId, reply);
|
||||
|
|
@ -323,13 +449,15 @@ export class WecomHarnessBridge {
|
|||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const compactCommand = await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
key,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
const compactCommand = hasImages
|
||||
? null
|
||||
: await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
key,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
if (compactCommand) {
|
||||
await this.#sendImmediate(frame, chatId, compactCommand.message);
|
||||
await this.#state.markSeen(messageId);
|
||||
|
|
@ -344,11 +472,15 @@ export class WecomHarnessBridge {
|
|||
this.#logger.warn?.('[dsh-im:wecom] unable to start a stream; using an active reply:', error);
|
||||
}
|
||||
|
||||
const content = hasImages
|
||||
? await promptContentForMessage(message, { signal: this.#signal })
|
||||
: undefined;
|
||||
const { answer } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
text,
|
||||
content,
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions: {
|
||||
|
|
@ -392,11 +524,12 @@ export class WecomHarnessBridge {
|
|||
if (this.#signal?.aborted) return;
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#logger.error?.('[dsh-im:wecom] failed to process an inbound message');
|
||||
const errorText = imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。';
|
||||
try {
|
||||
if (streamStarted && streamId) {
|
||||
await this.#client.replyStream(frame, streamId, '消息处理失败,请稍后重试。', true);
|
||||
await this.#client.replyStream(frame, streamId, errorText, true);
|
||||
} else {
|
||||
await this.#sendImmediate(frame, chatId, '消息处理失败,请稍后重试。');
|
||||
await this.#sendImmediate(frame, chatId, errorText);
|
||||
}
|
||||
await this.#state.markSeen(messageId);
|
||||
} catch {
|
||||
|
|
@ -425,9 +558,9 @@ export class WecomHarnessBridge {
|
|||
this.#status.messagesReceived += 1;
|
||||
this.#status.lastMessageAt = new Date().toISOString();
|
||||
|
||||
const text = nonEmptyString(messageText(frame));
|
||||
const text = nonEmptyString(interactionReplyText(frame));
|
||||
if (!text) {
|
||||
await this.#sendImmediate(frame, chatId, '请用文字或语音回答当前问题。')
|
||||
await this.#sendImmediate(frame, chatId, '请用文字回答当前问题。')
|
||||
.catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
|
||||
|
||||
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
|
||||
|
||||
export const WEIXIN_QR_BASE_URL = 'https://ilinkai.weixin.qq.com/';
|
||||
export const WEIXIN_PROTOCOL_VERSION = '2.4.6';
|
||||
export const DEFAULT_BOT_TYPE = '3';
|
||||
export const WEIXIN_CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c';
|
||||
|
||||
const WEIXIN_CDN_HOST = 'novac2c.cdn.weixin.qq.com';
|
||||
|
||||
const ILINK_APP_ID = 'bot';
|
||||
const ILINK_CLIENT_VERSION = (2 << 16) | (4 << 8) | 6;
|
||||
|
|
@ -32,6 +37,93 @@ function nonEmptyString(value) {
|
|||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function strictBase64(value) {
|
||||
const text = nonEmptyString(value);
|
||||
if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)) return null;
|
||||
return Buffer.from(text, 'base64');
|
||||
}
|
||||
|
||||
/** Parse the two AES key encodings used by Weixin iLink image messages. */
|
||||
export function parseWeixinImageAesKey(imageItem) {
|
||||
const directHex = nonEmptyString(imageItem?.aeskey);
|
||||
if (directHex) {
|
||||
if (!/^[0-9a-fA-F]{32}$/.test(directHex)) {
|
||||
throw new WeixinApiError('invalid-image-key', '微信图片的加密密钥无效。');
|
||||
}
|
||||
return Buffer.from(directHex, 'hex');
|
||||
}
|
||||
|
||||
const encoded = strictBase64(imageItem?.media?.aes_key);
|
||||
if (encoded?.length === 16) return encoded;
|
||||
if (encoded?.length === 32 && /^[0-9a-fA-F]{32}$/.test(encoded.toString('ascii'))) {
|
||||
return Buffer.from(encoded.toString('ascii'), 'hex');
|
||||
}
|
||||
throw new WeixinApiError('invalid-image-key', '微信图片的加密密钥无效。');
|
||||
}
|
||||
|
||||
export function decryptWeixinImage(ciphertext, key) {
|
||||
const encrypted = Buffer.from(ciphertext);
|
||||
const aesKey = Buffer.from(key);
|
||||
if (aesKey.length !== 16 || encrypted.length === 0 || encrypted.length % 16 !== 0) {
|
||||
throw new WeixinApiError('invalid-image-ciphertext', '微信图片的加密数据无效。');
|
||||
}
|
||||
try {
|
||||
const decipher = createDecipheriv('aes-128-ecb', aesKey, null);
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
} catch (error) {
|
||||
throw new WeixinApiError('image-decryption-failed', '微信图片解密失败。', { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
export function weixinImageDownloadUrl(media) {
|
||||
const query = nonEmptyString(media?.encrypt_query_param);
|
||||
if (query) {
|
||||
return `${WEIXIN_CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(query)}`;
|
||||
}
|
||||
|
||||
const fullUrl = nonEmptyString(media?.full_url);
|
||||
if (!fullUrl) throw new WeixinApiError('missing-image-url', '微信图片没有可用的下载地址。');
|
||||
let url;
|
||||
try {
|
||||
url = new URL(fullUrl);
|
||||
} catch {
|
||||
throw new WeixinApiError('invalid-image-url', '微信图片的下载地址无效。');
|
||||
}
|
||||
if (url.protocol !== 'https:' || url.hostname !== WEIXIN_CDN_HOST
|
||||
|| (url.port && url.port !== '443') || !url.pathname.startsWith('/c2c/')) {
|
||||
throw new WeixinApiError('untrusted-image-url', '微信图片的下载地址不受信任。');
|
||||
}
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/** Convert iLink image items into lazily downloaded, decrypted image references. */
|
||||
export function extractWeixinImages(message, { fetchImpl = fetch } = {}) {
|
||||
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
|
||||
const images = [];
|
||||
for (const item of message?.item_list ?? []) {
|
||||
const imageItem = item?.image_item;
|
||||
if (!imageItem || typeof imageItem !== 'object') continue;
|
||||
images.push({
|
||||
name: images.length === 0 ? 'image' : `image-${images.length + 1}`,
|
||||
load: async ({ signal, maxBytes }) => {
|
||||
const key = parseWeixinImageAesKey(imageItem);
|
||||
const url = weixinImageDownloadUrl(imageItem.media);
|
||||
const ciphertext = await fetchImageBuffer(url, {
|
||||
fetchImpl,
|
||||
signal,
|
||||
maxBytes: maxBytes + 16,
|
||||
allowedHosts: [WEIXIN_CDN_HOST],
|
||||
});
|
||||
return decryptWeixinImage(ciphertext, key);
|
||||
},
|
||||
});
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
function isWeixinHost(hostname) {
|
||||
const normalized = hostname.toLowerCase().replace(/\.$/, '');
|
||||
return normalized === 'weixin.qq.com' || normalized.endsWith('.weixin.qq.com');
|
||||
|
|
@ -170,6 +262,10 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
|||
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
|
||||
|
||||
return Object.freeze({
|
||||
inboundImages(message) {
|
||||
return extractWeixinImages(message, { fetchImpl });
|
||||
},
|
||||
|
||||
async beginLogin({ localTokens = [], botType = DEFAULT_BOT_TYPE, signal } = {}) {
|
||||
const tokens = [...new Set(localTokens.map(nonEmptyString).filter(Boolean))].slice(-10);
|
||||
const response = await requestJson(fetchImpl, {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
extractWeixinImages,
|
||||
extractWeixinText,
|
||||
splitWeixinText,
|
||||
weixinMessageId,
|
||||
|
|
@ -12,13 +13,18 @@ import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
|||
import { runCompactCommand } from '../shared/compact-command.mjs';
|
||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
import {
|
||||
hasInboundImages,
|
||||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
|
||||
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
||||
|
||||
const HELP_TEXT = [
|
||||
'微信已连接 DeepSeek Harness。',
|
||||
'',
|
||||
'直接发送文字或带文字识别结果的语音即可继续当前会话。',
|
||||
'直接发送文字、图片或带文字识别结果的语音即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/compact 压缩当前会话的较早上下文',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
|
|
@ -37,9 +43,15 @@ function nonEmptyString(value) {
|
|||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function hasWeixinImageItems(message) {
|
||||
return Array.isArray(message?.item_list)
|
||||
&& message.item_list.some((item) => item?.image_item && typeof item.image_item === 'object');
|
||||
}
|
||||
|
||||
function canClaimInteractionReply(message, pending) {
|
||||
return pending.questions[pending.index]
|
||||
&& nonEmptyString(message?.from_user_id) === pending.actor
|
||||
&& !hasWeixinImageItems(message)
|
||||
&& nonEmptyString(extractWeixinText(message));
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +136,7 @@ export class WeixinHarnessBridge {
|
|||
key,
|
||||
actor: sender,
|
||||
messageId,
|
||||
text: extractWeixinText(message),
|
||||
text: hasWeixinImageItems(message) ? '' : extractWeixinText(message),
|
||||
addressed: true,
|
||||
hasPendingQuestion: Boolean(pending),
|
||||
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
||||
|
|
@ -216,33 +228,40 @@ export class WeixinHarnessBridge {
|
|||
|
||||
const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
|
||||
const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
|
||||
const text = extractWeixinText(message);
|
||||
const text = extractWeixinText(message) ?? '';
|
||||
try {
|
||||
if (!text) {
|
||||
await this.#send(sender, '目前仅支持文字消息,以及微信已转成文字的语音消息。', contextToken, runId);
|
||||
const images = typeof this.#api.inboundImages === 'function'
|
||||
? this.#api.inboundImages(message)
|
||||
: extractWeixinImages(message);
|
||||
const promptMessage = { content: text, images };
|
||||
const hasImages = hasInboundImages(promptMessage);
|
||||
if (!text && !hasImages) {
|
||||
await this.#send(sender, '目前支持文字、图片,以及微信已转成文字的语音消息。', contextToken, runId);
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
const command = text.trim().toLowerCase();
|
||||
if (command === '/help') {
|
||||
if (!hasImages && command === '/help') {
|
||||
await this.#send(sender, HELP_TEXT, contextToken, runId);
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
if (command === '/status') {
|
||||
if (!hasImages && command === '/status') {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
await this.#send(sender, '微信与 DeepSeek Harness 连接正常。', contextToken, runId);
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
if (command === '/new') {
|
||||
if (!hasImages && command === '/new') {
|
||||
await this.#state.clearSession(key);
|
||||
await this.#send(sender, '已开启新会话。请发送你的问题。', contextToken, runId);
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
||||
const workspaceCommand = hasImages
|
||||
? null
|
||||
: await runWorkspaceCommand(text, this.#harness, key);
|
||||
if (workspaceCommand) {
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#send(sender, reply, contextToken, runId);
|
||||
|
|
@ -250,26 +269,31 @@ export class WeixinHarnessBridge {
|
|||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const compactCommand = await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
key,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
const compactCommand = hasImages
|
||||
? null
|
||||
: await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
key,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
if (compactCommand) {
|
||||
await this.#send(sender, compactCommand.message, contextToken, runId);
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = hasImages
|
||||
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
||||
: undefined;
|
||||
let answer;
|
||||
try {
|
||||
({ answer } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
text,
|
||||
...(hasImages ? { content } : { text }),
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions: {
|
||||
|
|
@ -300,7 +324,12 @@ export class WeixinHarnessBridge {
|
|||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
|
||||
try {
|
||||
await this.#send(sender, '消息处理失败,请稍后重试。', contextToken, runId);
|
||||
await this.#send(
|
||||
sender,
|
||||
imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。',
|
||||
contextToken,
|
||||
runId,
|
||||
);
|
||||
await this.#state.markSeen(messageId);
|
||||
} catch (sendError) {
|
||||
this.#logger.error?.('[dsh-weixin] failed to send the safe error reply:', sendError);
|
||||
|
|
@ -326,7 +355,7 @@ export class WeixinHarnessBridge {
|
|||
const text = nonEmptyString(extractWeixinText(message));
|
||||
const contextToken = nonEmptyString(message?.context_token) ?? undefined;
|
||||
const runId = nonEmptyString(message?.run_id) ?? undefined;
|
||||
if (!text) {
|
||||
if (!text || hasWeixinImageItems(message)) {
|
||||
await this.#send(
|
||||
expected.actor,
|
||||
'请用文字回答当前问题。',
|
||||
|
|
|
|||
|
|
@ -1,12 +1,45 @@
|
|||
import {
|
||||
areJidsSameUser,
|
||||
downloadMediaMessage,
|
||||
normalizeMessageContent,
|
||||
} from '@whiskeysockets/baileys';
|
||||
|
||||
import { splitMessageText } from '../shared/editable-message-stream.mjs';
|
||||
import { ImagePromptError } from '../shared/image-prompt.mjs';
|
||||
import { createWhatsappBridgeStatus, WhatsappHarnessBridge } from './whatsapp-bridge.mjs';
|
||||
import { createWhatsappWebSession } from './whatsapp-web-session.mjs';
|
||||
|
||||
const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
||||
const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||
const IMAGE_DOWNLOAD_TIMEOUT_MS = 15_000;
|
||||
const MESSAGE_WRAPPER_KEYS = [
|
||||
'ephemeralMessage',
|
||||
'viewOnceMessage',
|
||||
'documentWithCaptionMessage',
|
||||
'viewOnceMessageV2',
|
||||
'viewOnceMessageV2Extension',
|
||||
'editedMessage',
|
||||
'associatedChildMessage',
|
||||
'groupStatusMessage',
|
||||
'groupStatusMessageV2',
|
||||
];
|
||||
const VIEW_ONCE_WRAPPER_KEYS = new Set([
|
||||
'viewOnceMessage',
|
||||
'viewOnceMessageV2',
|
||||
'viewOnceMessageV2Extension',
|
||||
]);
|
||||
|
||||
function hasViewOnceWrapper(content) {
|
||||
let current = content;
|
||||
for (let depth = 0; depth < 5 && current && typeof current === 'object'; depth += 1) {
|
||||
const wrapperKey = MESSAGE_WRAPPER_KEYS.find((key) => current[key]?.message);
|
||||
if (!wrapperKey) return false;
|
||||
if (VIEW_ONCE_WRAPPER_KEYS.has(wrapperKey)) return true;
|
||||
current = current[wrapperKey].message;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function messageContext(content) {
|
||||
return content?.extendedTextMessage?.contextInfo
|
||||
?? content?.imageMessage?.contextInfo
|
||||
|
|
@ -24,7 +57,114 @@ function messageText(content) {
|
|||
?? '';
|
||||
}
|
||||
|
||||
export function normalizeWhatsappMessage(message, accountJid) {
|
||||
function mediaSize(value) {
|
||||
if (Number.isSafeInteger(value) && value >= 0) return value;
|
||||
let converted;
|
||||
try {
|
||||
converted = Number(value?.toString?.());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return Number.isSafeInteger(converted) && converted >= 0 ? converted : undefined;
|
||||
}
|
||||
|
||||
async function downloadWhatsappImage(message, download, {
|
||||
signal,
|
||||
maxBytes = DEFAULT_MAX_IMAGE_BYTES,
|
||||
} = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const timeout = AbortSignal.timeout(IMAGE_DOWNLOAD_TIMEOUT_MS);
|
||||
const downloadSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
const pendingStream = Promise.resolve().then(() => (
|
||||
download(message, 'stream', { options: { signal: downloadSignal } })
|
||||
));
|
||||
const stream = await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (callback, value) => {
|
||||
if (settled) return false;
|
||||
settled = true;
|
||||
downloadSignal.removeEventListener('abort', onAbort);
|
||||
callback(value);
|
||||
return true;
|
||||
};
|
||||
const onAbort = () => finish(reject, downloadSignal.reason);
|
||||
downloadSignal.addEventListener('abort', onAbort, { once: true });
|
||||
pendingStream.then((value) => {
|
||||
if (!finish(resolve, value)) value?.destroy?.();
|
||||
}, (error) => finish(reject, error));
|
||||
if (downloadSignal.aborted) onAbort();
|
||||
}).catch((error) => {
|
||||
if (signal?.aborted) throw signal.reason ?? error;
|
||||
if (timeout.aborted) {
|
||||
throw new ImagePromptError(
|
||||
'image-download-failed',
|
||||
`WhatsApp image download timed out after ${IMAGE_DOWNLOAD_TIMEOUT_MS} ms`,
|
||||
'图片下载失败,请重新发送后再试。',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
const abortStream = () => stream?.destroy?.(downloadSignal.reason);
|
||||
downloadSignal.addEventListener('abort', abortStream, { once: true });
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
downloadSignal.throwIfAborted();
|
||||
const data = Buffer.from(chunk);
|
||||
size += data.length;
|
||||
if (size > maxBytes) {
|
||||
stream.destroy?.();
|
||||
throw new ImagePromptError(
|
||||
'image-too-large',
|
||||
`WhatsApp image exceeded ${maxBytes} bytes`,
|
||||
'图片超过 5 MB,请压缩后重试。',
|
||||
);
|
||||
}
|
||||
chunks.push(data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw signal.reason ?? error;
|
||||
if (timeout.aborted) {
|
||||
throw new ImagePromptError(
|
||||
'image-download-failed',
|
||||
`WhatsApp image stream timed out after ${IMAGE_DOWNLOAD_TIMEOUT_MS} ms`,
|
||||
'图片下载失败,请重新发送后再试。',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
downloadSignal.removeEventListener('abort', abortStream);
|
||||
}
|
||||
return Buffer.concat(chunks, size);
|
||||
}
|
||||
|
||||
function whatsappImageSource(message, content, download, { viewOnce = false } = {}) {
|
||||
let media;
|
||||
let name;
|
||||
if (!viewOnce && content?.imageMessage && content.imageMessage.viewOnce !== true) {
|
||||
media = content.imageMessage;
|
||||
} else if (content?.documentMessage) {
|
||||
const type = typeof content.documentMessage.mimetype === 'string'
|
||||
? content.documentMessage.mimetype.toLowerCase() : '';
|
||||
if (!IMAGE_MEDIA_TYPES.has(type)) return null;
|
||||
media = content.documentMessage;
|
||||
name = typeof media.fileName === 'string' ? media.fileName : undefined;
|
||||
}
|
||||
if (!media) return null;
|
||||
const mediaType = typeof media.mimetype === 'string' ? media.mimetype.toLowerCase() : '';
|
||||
if (!IMAGE_MEDIA_TYPES.has(mediaType)) return null;
|
||||
return {
|
||||
name,
|
||||
mediaType,
|
||||
size: mediaSize(media.fileLength),
|
||||
load: (options) => downloadWhatsappImage(message, download, options),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeWhatsappMessage(message, accountJid, {
|
||||
download = downloadMediaMessage,
|
||||
} = {}) {
|
||||
const remoteJid = typeof message?.key?.remoteJid === 'string' ? message.key.remoteJid : '';
|
||||
const alternateRemoteJid = typeof message?.key?.remoteJidAlt === 'string'
|
||||
? message.key.remoteJidAlt : '';
|
||||
|
|
@ -38,12 +178,14 @@ export function normalizeWhatsappMessage(message, accountJid) {
|
|||
if (fromMe && !selfChat) return null;
|
||||
const senderJid = selfChat ? accountJid : group ? message.key.participant : remoteJid;
|
||||
if (typeof senderJid !== 'string' || !senderJid) return null;
|
||||
const viewOnce = hasViewOnceWrapper(message.message);
|
||||
const content = normalizeMessageContent(message.message);
|
||||
const context = messageContext(content);
|
||||
const mentioned = Array.isArray(context?.mentionedJid)
|
||||
&& context.mentionedJid.some((jid) => areJidsSameUser(jid, accountJid));
|
||||
const replyToSelf = typeof context?.participant === 'string'
|
||||
&& areJidsSameUser(context.participant, accountJid);
|
||||
const image = whatsappImageSource(message, content, download, { viewOnce });
|
||||
return {
|
||||
messageId: `${remoteJid}:${messageId}`,
|
||||
providerMessageId: messageId,
|
||||
|
|
@ -52,6 +194,7 @@ export function normalizeWhatsappMessage(message, accountJid) {
|
|||
kind: group ? 'group' : 'direct',
|
||||
conversationId: remoteJid,
|
||||
content: messageText(content),
|
||||
images: image ? [image] : [],
|
||||
addressed: !group || mentioned || replyToSelf,
|
||||
selfChat,
|
||||
replyTarget: { jid: remoteJid, quoted: message, selfChat },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue