feat: support image prompts across IM channels

This commit is contained in:
xmanrui 2026-08-19 02:56:14 +08:00
parent 99a877c640
commit 65c842c045
40 changed files with 3806 additions and 274 deletions

View file

@ -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);

View file

@ -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');
}

View file

@ -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),

View file

@ -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),

View file

@ -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) {

View file

@ -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',

View file

@ -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;
}

View file

@ -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 });

View 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;
}

View file

@ -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) {

View file

@ -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;

View file

@ -17,6 +17,7 @@ oauth_config:
bot:
- app_mentions:read
- chat:write
- files:read
- im:history
settings:
event_subscriptions:

View file

@ -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();

View file

@ -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) => {

View file

@ -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,

View file

@ -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) => {

View file

@ -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;
}

View file

@ -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, {

View file

@ -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,
'请用文字回答当前问题。',

View file

@ -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 },