feat: support inbound files across all channels

This commit is contained in:
xmanrui 2026-08-23 22:39:26 +08:00
parent 29bcb67a64
commit 0a26d7df92
45 changed files with 2651 additions and 343 deletions

View file

@ -435,6 +435,48 @@ export function createDingtalkApi({
return request;
}
async function messageFileDownloadUrl({
clientId,
clientSecret,
robotCode,
downloadCode,
signal,
kind,
}) {
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: `${kind === 'image' ? '图片' : '文件'}下载地址`,
},
);
} catch (error) {
if (signal?.aborted) throw error;
throw new DingtalkApiError(
`${kind}-download-address-failed`,
`钉钉${kind === 'image' ? '图片' : '文件'}下载地址获取失败。`,
{ cause: error, status: error?.status, providerCode: error?.providerCode },
);
}
const downloadUrl = nonEmptyString(response?.downloadUrl ?? response?.download_url);
if (!downloadUrl) {
throw new DingtalkApiError(
`invalid-${kind}-download`,
`钉钉服务没有返回${kind === 'image' ? '图片' : '文件'}下载地址。`,
);
}
return secureDingtalkDownloadUrl(downloadUrl);
}
function acquireCardRequestSlot(signal) {
const acquire = async () => {
const waitMs = Math.max(0, nextCardRequestAt - now());
@ -568,36 +610,16 @@ export function createDingtalkApi({
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;
const downloadUrl = await messageFileDownloadUrl({
clientId,
clientSecret,
robotCode,
downloadCode,
signal,
kind: 'image',
});
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), {
return await fetchImageBuffer(downloadUrl, {
fetchImpl,
signal,
maxBytes,
@ -612,6 +634,50 @@ export function createDingtalkApi({
}
},
async downloadFile({
clientId,
clientSecret,
robotCode,
downloadCode,
signal,
}) {
const downloadUrl = await messageFileDownloadUrl({
clientId,
clientSecret,
robotCode,
downloadCode,
signal,
kind: 'file',
});
if (downloadUrl.protocol !== 'https:') {
throw new DingtalkApiError('invalid-file-download', '钉钉服务返回了无效的文件下载地址。');
}
try {
const response = await fetchImpl(downloadUrl, {
method: 'GET',
redirect: 'follow',
signal,
});
if (!response?.ok) {
await response?.body?.cancel?.().catch?.(() => undefined);
throw new DingtalkApiError(
'file-content-download-failed',
`钉钉文件内容下载失败(HTTP ${response?.status ?? 'unknown'})。`,
{ status: response?.status },
);
}
return Buffer.from(await response.arrayBuffer());
} catch (error) {
if (signal?.aborted) throw abortError(signal);
if (error instanceof DingtalkApiError) throw error;
throw new DingtalkApiError(
'file-content-download-failed',
'钉钉文件内容下载失败。',
{ cause: error },
);
}
},
async createAiCard({ clientId, clientSecret, target, initialText, signal }) {
const appKey = nonEmptyString(clientId);
const appSecret = nonEmptyString(clientSecret);

View file

@ -29,6 +29,11 @@ import {
imagePromptUserMessage,
promptContentForMessage,
} from '../shared/image-prompt.mjs';
import {
hasInboundFiles,
inboundFileUserMessage,
prefetchInboundFiles,
} from '../shared/inbound-file.mjs';
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
import {
materializeOutboundArtifact,
@ -48,7 +53,7 @@ const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无
const HELP_TEXT = [
'钉钉机器人已连接 DeepSeek Harness。',
'',
'直接发送文字或图片即可继续当前会话。',
'直接发送文字、图片或文件即可继续当前会话。',
'/new 开启一个全新会话',
'/compact 压缩当前会话的较早上下文',
'/workspace 工作区绝对路径 切换工作区',
@ -175,6 +180,7 @@ export function dingtalkInboundMessage(message, {
if (code) imageCodes.push(code);
}
}
const fileCode = msgtype === 'file' ? downloadCodeFor(content) : null;
return {
content: text,
images: imageCodes.map((downloadCode, index) => ({
@ -193,6 +199,21 @@ export function dingtalkInboundMessage(message, {
});
},
})),
files: fileCode ? [{
name: nonEmptyString(content?.fileName ?? content?.file_name) ?? 'file',
load: ({ signal } = {}) => {
if (typeof api?.downloadFile !== 'function') {
throw new Error('DingTalk API does not support file downloads');
}
return api.downloadFile({
clientId,
clientSecret,
robotCode: message?.robotCode,
downloadCode: fileCode,
signal,
});
},
}] : [],
};
}
@ -393,7 +414,7 @@ export class DingtalkHarnessBridge {
clientSecret: this.#clientSecret,
});
const commandText = nonEmptyString(promptMessage.content) ?? '';
const commandRunner = isControlCommand(commandText)
const commandRunner = hasInboundFiles(promptMessage) ? null : isControlCommand(commandText)
? runControlCommand
: (isModelCommand(commandText)
? runModelCommand
@ -499,10 +520,28 @@ export class DingtalkHarnessBridge {
releaseMessageId = true,
alreadyRecorded = false,
} = {}) {
let hasSafeReplyRoute = false;
try {
normalizeDingtalkSessionWebhook(message.sessionWebhook);
hasSafeReplyRoute = true;
} catch {
// Keep the existing rejection path without downloading an unusable file.
}
const addressed = String(message.conversationType) !== '2' || message.isInAtList === true;
const preparedMessage = hasSafeReplyRoute && addressed
? prefetchInboundFiles(dingtalkInboundMessage(message, {
api: this.#api,
clientId: this.#clientId,
clientSecret: this.#clientSecret,
}), { signal: this.#signal })
: undefined;
const previous = this.#queues.get(key) ?? Promise.resolve();
const current = previous
.catch(() => undefined)
.then(() => this.#process(message, messageId, sender, key, { alreadyRecorded }))
.then(() => this.#process(message, messageId, sender, key, {
alreadyRecorded,
preparedMessage,
}))
.finally(() => {
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
if (this.#queues.get(key) === current) this.#queues.delete(key);
@ -536,6 +575,7 @@ export class DingtalkHarnessBridge {
{
signal: this.#signal,
hasImages: hasInboundImages(prompt),
hasFiles: hasInboundFiles(prompt),
pendingInteraction: this.#pendingInteractions.has(key)
|| this.#approvals.hasPending(key),
control: { owner: this, key },
@ -553,7 +593,10 @@ export class DingtalkHarnessBridge {
this.#status.lastError = null;
}
async #process(message, messageId, sender, key, { alreadyRecorded = false } = {}) {
async #process(message, messageId, sender, key, {
alreadyRecorded = false,
preparedMessage,
} = {}) {
this.#signal?.throwIfAborted();
if (!alreadyRecorded) {
if (this.#state.hasSeen(messageId)) return;
@ -577,38 +620,39 @@ export class DingtalkHarnessBridge {
return;
}
const promptMessage = dingtalkInboundMessage(message, {
const promptMessage = preparedMessage ?? dingtalkInboundMessage(message, {
api: this.#api,
clientId: this.#clientId,
clientSecret: this.#clientSecret,
});
const text = promptMessage.content;
const hasImages = hasInboundImages(promptMessage);
const hasFiles = hasInboundFiles(promptMessage);
const isPlainText = String(message?.msgtype).toLowerCase() === 'text';
let cardStream = null;
let cardStarted = false;
try {
if (!text && !hasImages) {
await this.#send(sessionWebhook, '目前支持文字和图片消息。');
if (!text && !hasImages && !hasFiles) {
await this.#send(sessionWebhook, '目前支持文字、图片和文件消息。');
return;
}
const command = text.toLowerCase();
if (isPlainText && !hasImages && command === '/help') {
if (isPlainText && !hasImages && !hasFiles && command === '/help') {
await this.#send(sessionWebhook, HELP_TEXT);
return;
}
if (isPlainText && !hasImages && command === '/status') {
if (isPlainText && !hasImages && !hasFiles && command === '/status') {
await this.#harness.ensureRunning({ signal: this.#signal });
await this.#send(sessionWebhook, '钉钉机器人与 DeepSeek Harness 连接正常。');
return;
}
if (isPlainText && !hasImages && command === '/new') {
if (isPlainText && !hasImages && !hasFiles && command === '/new') {
await this.#state.clearSession(key);
await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。');
return;
}
const workspaceCommand = isPlainText && !hasImages
const workspaceCommand = isPlainText && !hasImages && !hasFiles
? await runWorkspaceCommand(text, this.#harness, key)
: null;
if (workspaceCommand) {
@ -617,7 +661,7 @@ export class DingtalkHarnessBridge {
}
return;
}
const compactCommand = isPlainText && !hasImages
const compactCommand = isPlainText && !hasImages && !hasFiles
? await runCompactCommand(
text,
this.#harness,
@ -668,6 +712,7 @@ export class DingtalkHarnessBridge {
requiresMention: String(message.conversationType) === '2',
}),
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
files: promptMessage.files,
},
});
const answerText = typeof answer === 'string' && answer.trim()
@ -717,7 +762,9 @@ export class DingtalkHarnessBridge {
safeErrorDiagnostic(error),
);
try {
const errorText = dingtalkImageErrorUserMessage(error) ?? CARD_ERROR_TEXT;
const errorText = inboundFileUserMessage(error)
?? dingtalkImageErrorUserMessage(error)
?? CARD_ERROR_TEXT;
const streamed = cardStarted && await cardStream.finish(errorText);
if (!streamed) await this.#send(sessionWebhook, errorText);
} catch {

View file

@ -1,4 +1,5 @@
import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
import { fetchFileStream } from '../shared/file-download.mjs';
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
import { DiscordApi } from './discord-api.mjs';
import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs';
@ -88,6 +89,25 @@ function discordImageSource(attachment, fetchImpl) {
};
}
function discordFileSource(attachment, fetchImpl) {
if (attachmentMediaType(attachment) || typeof attachment?.url !== 'string' || !attachment.url) {
return null;
}
const mediaType = typeof attachment.content_type === 'string' && attachment.content_type
? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : undefined;
return {
name: typeof attachment.filename === 'string' && attachment.filename
? attachment.filename : String(attachment.id ?? 'discord-file'),
...(mediaType ? { mediaType } : {}),
size: attachmentSize(attachment.size),
load: ({ signal } = {}) => fetchFileStream(attachment.url, {
fetchImpl,
signal,
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;
@ -103,6 +123,9 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
images: Array.isArray(message.attachments)
? message.attachments.map((attachment) => discordImageSource(attachment, fetchImpl)).filter(Boolean)
: [],
files: Array.isArray(message.attachments)
? message.attachments.map((attachment) => discordFileSource(attachment, fetchImpl)).filter(Boolean)
: [],
addressed,
replyTarget: {
channelId: String(message.channel_id),

View file

@ -12,6 +12,10 @@ import {
imagePromptUserMessage,
promptContentForMessage,
} from '../shared/image-prompt.mjs';
import {
hasInboundFiles,
inboundFileUserMessage,
} from '../shared/inbound-file.mjs';
import {
harnessAnswerForQuestion,
harnessQuestionText,
@ -91,7 +95,7 @@ const REPAIR_URL_HOSTS = new Set([
const HELP_TEXT = [
'北汇星河 AIOS 已连接 DeepSeek Harness。',
'',
'直接发送文字或图片即可继续当前会话。',
'直接发送文字、图片或文件即可继续当前会话。',
'/new 开启一个全新会话',
'/compact 压缩当前会话的较早上下文',
'/workspace 工作区绝对路径 切换工作区',
@ -431,7 +435,7 @@ export class FeishuHarnessBridge {
const processingReaction = this.#addReaction(messageId, 'OnIt');
const commandMessage = extractInboundMessage(event, this.#client);
const commandText = nonEmptyString(commandMessage.content) ?? '';
const commandRunner = isControlCommand(commandText)
const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
? runControlCommand
: (isModelCommand(commandText)
? runModelCommand
@ -610,7 +614,8 @@ export class FeishuHarnessBridge {
await this.#finishReaction(messageId, processingReaction, 'ERROR');
await this.#send(
event.message.chat_id,
imagePromptUserMessage(error)
inboundFileUserMessage(error)
?? imagePromptUserMessage(error)
?? '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
).catch(() => undefined);
}
@ -641,6 +646,7 @@ export class FeishuHarnessBridge {
{
signal: this.#signal,
hasImages: hasInboundImages(message),
hasFiles: hasInboundFiles(message),
pendingInteraction: this.#pendingInteractions.has(key)
|| this.#approvals.hasPending(key),
control: { owner: this, key },
@ -671,9 +677,10 @@ export class FeishuHarnessBridge {
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, '目前支持文字和图片消息。');
const hasFiles = hasInboundFiles(message);
const commandText = event.message.message_type === 'text' && !hasImages && !hasFiles ? text : null;
if (!text && !hasImages && !hasFiles) {
await this.#send(event.message.chat_id, '目前支持文字、图片和文件消息。');
return;
}
@ -1603,7 +1610,7 @@ export class FeishuHarnessBridge {
}
}
#interactionAskOptions(event, key) {
#interactionAskOptions(event, key, files) {
return {
timeoutMs: this.#replyTimeoutMs,
signal: this.#signal,
@ -1615,6 +1622,7 @@ export class FeishuHarnessBridge {
requiresMention: event.message.chat_type !== 'p2p',
}),
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
files,
};
}
@ -1709,7 +1717,7 @@ export class FeishuHarnessBridge {
content,
createOptions: { signal: this.#signal },
existsOptions: { signal: this.#signal },
askOptions: this.#interactionAskOptions(event, key),
askOptions: this.#interactionAskOptions(event, key, message.files),
});
let textReceipt;
let textSendError = null;
@ -1749,7 +1757,7 @@ export class FeishuHarnessBridge {
markdown: async (controller) => {
promptStarted = true;
const askOptions = {
...this.#interactionAskOptions(event, key),
...this.#interactionAskOptions(event, key, message.files),
onUpdate: async (update) => {
await controller.setContent(this.#progressText(update));
this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
@ -1821,7 +1829,7 @@ export class FeishuHarnessBridge {
content,
createOptions: { signal: this.#signal },
existsOptions: { signal: this.#signal },
askOptions: this.#interactionAskOptions(event, key),
askOptions: this.#interactionAskOptions(event, key, message.files),
});
let textReceipt;
let textSendError = null;

View file

@ -125,6 +125,31 @@ async function readBoundedStream(stream, { signal, maxBytes }) {
}
}
async function readStream(stream, { signal }) {
if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
throw new Error('Feishu file download returned no readable stream');
}
signal?.throwIfAborted();
const abort = () => stream.destroy?.(
signal.reason ?? new DOMException('Feishu file download aborted', 'AbortError'),
);
signal?.addEventListener('abort', abort, { once: true });
const chunks = [];
let size = 0;
try {
for await (const chunk of stream) {
signal?.throwIfAborted();
const data = Buffer.from(chunk);
size += data.length;
chunks.push(data);
}
signal?.throwIfAborted();
return Buffer.concat(chunks, size);
} finally {
signal?.removeEventListener('abort', abort);
}
}
function providerCode(value) {
if (!value || typeof value !== 'object') return null;
const code = value.code ?? value.error?.code;
@ -232,6 +257,26 @@ function feishuImageSource(event, client, key) {
};
}
function feishuFileSource(event, client, file) {
const key = nonEmptyString(file?.file_key);
if (!key) return null;
return {
name: nonEmptyString(file?.file_name) ?? 'file',
async load({ signal } = {}) {
signal?.throwIfAborted();
const resource = await client?.im?.v1?.messageResource?.get?.({
path: {
message_id: event.message.message_id,
file_key: key,
},
params: { type: 'file' },
});
signal?.throwIfAborted();
return readStream(resource?.getReadableStream?.(), { signal });
},
};
}
export function extractInboundMessage(event, client) {
const messageType = event?.message?.message_type;
const parsed = parsedMessageContent(event);
@ -240,9 +285,11 @@ export function extractInboundMessage(event, client) {
? nonEmptyString(parsed?.image_key)
: null;
const imageKeys = standaloneImageKey ? [standaloneImageKey] : post?.imageKeys ?? [];
const file = messageType === 'file' ? feishuFileSource(event, client, parsed) : null;
return {
content: messageType === 'text' ? extractText(event) ?? '' : post?.text ?? '',
images: imageKeys.map((key) => feishuImageSource(event, client, key)),
files: file ? [file] : [],
};
}

View file

@ -26,6 +26,11 @@ import {
imagePromptUserMessage,
promptContentForMessage,
} from '../shared/image-prompt.mjs';
import {
hasInboundFiles,
inboundFileUserMessage,
prefetchInboundFiles,
} from '../shared/inbound-file.mjs';
import {
materializeOutboundArtifact,
releaseOutboundArtifact,
@ -55,7 +60,7 @@ const QQ_IMAGE_FILENAME = /\.(?:gif|jpe?g|png|webp)$/i;
const HELP_TEXT = [
'QQ 机器人已连接 DeepSeek Harness。',
'',
'直接发送文字或图片即可继续当前会话。',
'直接发送文字、图片或文件即可继续当前会话。',
'/new 开启一个全新会话',
'/compact 压缩当前会话的较早上下文',
'/workspace 工作区绝对路径 切换工作区',
@ -100,32 +105,63 @@ function hasQqImageAttachments(message) {
&& message.attachments.some(isQqImageAttachment);
}
function hasQqFileAttachments(message) {
return Array.isArray(message?.attachments)
&& message.attachments.some((attachment) => !isQqImageAttachment(attachment));
}
async function fetchQqFileBuffer(url, { fetchImpl, signal }) {
const normalizedUrl = url.startsWith('//') ? `https:${url}` : url;
const response = await fetchImpl(new URL(normalizedUrl), {
method: 'GET',
signal,
redirect: 'follow',
});
if (!response?.ok) {
await response?.body?.cancel?.().catch?.(() => undefined);
throw new Error(`QQ file download failed with HTTP ${response?.status ?? 'unknown'}`);
}
return Buffer.from(await response.arrayBuffer());
}
/** 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 = [];
const files = [];
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 } : {}),
if (isQqImageAttachment(attachment)) {
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,
});
},
});
continue;
}
files.push({
name: name ?? (files.length === 0 ? 'file' : `file-${files.length + 1}`),
...(mediaType?.includes('/') ? { 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,
});
load: ({ signal } = {}) => {
if (!url) throw new Error('QQ file attachment has no download URL');
return fetchQqFileBuffer(url, { fetchImpl, signal });
},
});
}
return { content: safeText(message), images };
return { content: safeText(message), images, files };
}
function nonEmptyString(value) {
@ -213,6 +249,7 @@ function canClaimInteractionReply(message, pending) {
&& nonEmptyString(message?.senderId) === pending.actor
&& (message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE')
&& !hasQqImageAttachments(message)
&& !hasQqFileAttachments(message)
&& nonEmptyString(safeText(message));
}
@ -303,7 +340,7 @@ export class QqHarnessBridge {
}
const pending = this.#pendingInteractions.get(key);
const commandText = safeText(message);
const commandRunner = isControlCommand(commandText)
const commandRunner = hasQqFileAttachments(message) ? null : isControlCommand(commandText)
? runControlCommand
: (isModelCommand(commandText)
? runModelCommand
@ -336,7 +373,7 @@ export class QqHarnessBridge {
key,
actor: sender,
messageId,
text: hasQqImageAttachments(message) ? '' : safeText(message),
text: hasQqImageAttachments(message) || hasQqFileAttachments(message) ? '' : safeText(message),
addressed: message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE',
hasPendingQuestion: Boolean(pending),
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@ -391,10 +428,19 @@ export class QqHarnessBridge {
releaseMessageId = true,
alreadyRecorded = false,
} = {}) {
const allowed = this.#ownerUserOpenid === '*' || message.senderId === this.#ownerUserOpenid;
const addressed = message.kind !== 'group'
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
const preparedMessage = allowed && addressed
? prefetchInboundFiles(
qqInboundMessage(message, { fetchImpl: this.#fetchImpl }),
{ signal: this.#signal },
)
: undefined;
const previous = this.#queues.get(key) ?? Promise.resolve();
const current = previous
.catch(() => undefined)
.then(() => this.#process(message, key, { alreadyRecorded }))
.then(() => this.#process(message, key, { alreadyRecorded, preparedMessage }))
.finally(() => {
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
if (this.#queues.get(key) === current) this.#queues.delete(key);
@ -423,6 +469,7 @@ export class QqHarnessBridge {
const result = await runner(text, this.#harness, this.#state, key, {
signal: this.#signal,
hasImages: hasQqImageAttachments(message),
hasFiles: hasQqFileAttachments(message),
pendingInteraction: this.#pendingInteractions.has(key)
|| this.#approvals.hasPending(key),
control: { owner: this, key },
@ -520,7 +567,7 @@ export class QqHarnessBridge {
};
}
async #process(message, key, { alreadyRecorded = false } = {}) {
async #process(message, key, { alreadyRecorded = false, preparedMessage } = {}) {
if (this.#signal?.aborted) return;
const messageId = nonEmptyString(message?.messageId);
const sender = nonEmptyString(message?.senderId);
@ -539,35 +586,37 @@ export class QqHarnessBridge {
if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
const target = message.replyTarget;
const promptMessage = qqInboundMessage(message, { fetchImpl: this.#fetchImpl });
const promptMessage = preparedMessage
?? qqInboundMessage(message, { fetchImpl: this.#fetchImpl });
const text = promptMessage.content;
const hasImages = hasInboundImages(promptMessage);
const hasFiles = hasInboundFiles(promptMessage);
let stream = null;
try {
if (!text && !hasImages) {
await this.#bot.sendText(target, '目前支持文字和图片消息。');
if (!text && !hasImages && !hasFiles) {
await this.#bot.sendText(target, '目前支持文字、图片和文件消息。');
await this.#state.markSeen(messageId);
return;
}
const command = text.toLowerCase();
if (!hasImages && command === '/help') {
if (!hasImages && !hasFiles && command === '/help') {
await this.#bot.sendText(target, HELP_TEXT);
await this.#state.markSeen(messageId);
return;
}
if (!hasImages && command === '/status') {
if (!hasImages && !hasFiles && command === '/status') {
await this.#harness.ensureRunning({ signal: this.#signal });
await this.#bot.sendText(target, 'QQ 机器人与 DeepSeek Harness 连接正常。');
await this.#state.markSeen(messageId);
return;
}
if (!hasImages && command === '/new') {
if (!hasImages && !hasFiles && command === '/new') {
await this.#state.clearSession(key);
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
await this.#state.markSeen(messageId);
return;
}
const workspaceCommand = hasImages
const workspaceCommand = hasImages || hasFiles
? null
: await runWorkspaceCommand(text, this.#harness, key);
if (workspaceCommand) {
@ -577,7 +626,7 @@ export class QqHarnessBridge {
await this.#state.markSeen(messageId);
return;
}
const compactCommand = hasImages
const compactCommand = hasImages || hasFiles
? null
: await runCompactCommand(
text,
@ -632,6 +681,7 @@ export class QqHarnessBridge {
requiresMention: message.kind === 'group',
}),
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
files: promptMessage.files,
},
}));
} finally {
@ -708,7 +758,9 @@ export class QqHarnessBridge {
try {
await this.#bot.sendText(
target,
imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。',
inboundFileUserMessage(error)
?? imagePromptUserMessage(error)
?? '消息处理失败,请稍后重试。',
);
await this.#state.markSeen(messageId);
} catch (sendError) {
@ -734,7 +786,7 @@ export class QqHarnessBridge {
if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
const text = nonEmptyString(safeText(message));
if (!text || hasQqImageAttachments(message)) {
if (!text || hasQqImageAttachments(message) || hasQqFileAttachments(message)) {
await this.#bot.sendText(message.replyTarget, '请用文字回答当前问题。');
return;
}

View file

@ -0,0 +1,64 @@
async function cancelResponseBody(response) {
try {
await response?.body?.cancel?.();
} catch {
// Preserve the original download failure.
}
}
function hostedByMessagingPlatform(target, allowedHosts) {
return !Array.isArray(allowedHosts) || allowedHosts.some((rule) => (
typeof rule === 'string'
&& (target.hostname === rule
|| (rule.startsWith('.')
&& (target.hostname === rule.slice(1) || target.hostname.endsWith(rule))))
));
}
/**
* Open a channel-hosted ordinary file as a stream.
*
* This deliberately has no plugin-defined size, type, count, or download-time
* limit. The caller owns cancellation through its AbortSignal and the channel
* remains the authority for its own file limits.
*/
export async function fetchFileStream(url, {
fetchImpl = fetch,
headers,
signal,
allowedHosts,
} = {}) {
const target = new URL(url);
if (target.protocol !== 'https:') throw new Error('File download URL must use HTTPS');
if (!hostedByMessagingPlatform(target, allowedHosts)) {
throw new Error('File download URL is not hosted by the messaging platform');
}
const response = await fetchImpl(target, {
method: 'GET',
headers,
signal,
redirect: 'manual',
});
if (Number.isInteger(response?.status) && response.status >= 300 && response.status < 400) {
await cancelResponseBody(response);
const error = new Error(`File download redirect was blocked (HTTP ${response.status})`);
error.code = 'file-redirect-blocked';
throw error;
}
if (!response?.ok) {
await cancelResponseBody(response);
const error = new Error(`File download failed with HTTP ${response?.status ?? 'unknown'}`);
error.code = 'file-http-error';
error.status = response?.status;
throw error;
}
if (response.body?.[Symbol.asyncIterator]) return { stream: response.body };
if (typeof response.arrayBuffer === 'function') {
const data = Buffer.from(await response.arrayBuffer());
return {
stream: (async function* fileBody() { yield data; }()),
};
}
throw new Error('File download returned no readable body');
}

View file

@ -3,6 +3,10 @@ import { randomUUID } from 'node:crypto';
import { isAbsolute } from 'node:path';
import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs';
import {
appendInboundFilesToPrompt,
InboundFileError,
} from './inbound-file.mjs';
import { outboundArtifactRegistry } from './semantic/artifact.mjs';
// Every channel plugin runs in the same Host process. Sharing ownership by
@ -466,6 +470,7 @@ export class HarnessClient {
#commandExecutor;
#controlExecutor;
#sessionMaintenanceExecutor;
#fileIngressExecutor;
#managedProcess = null;
#interactionRegistry;
#interactionOwnerships;
@ -486,6 +491,7 @@ export class HarnessClient {
commandExecutor,
controlExecutor,
sessionMaintenanceExecutor,
fileIngressExecutor,
}) {
if (typeof createWebSocket !== 'function') {
throw new TypeError('createWebSocket must be a function');
@ -509,6 +515,9 @@ export class HarnessClient {
&& typeof sessionMaintenanceExecutor !== 'function') {
throw new TypeError('sessionMaintenanceExecutor must be a function');
}
if (fileIngressExecutor !== undefined && typeof fileIngressExecutor !== 'function') {
throw new TypeError('fileIngressExecutor must be a function');
}
this.#baseUrl = new URL(baseUrl);
this.#workspace = workspace;
// Keep an omitted preset absent so session.create resolves the Host's current default.
@ -523,6 +532,7 @@ export class HarnessClient {
this.#commandExecutor = commandExecutor;
this.#controlExecutor = controlExecutor;
this.#sessionMaintenanceExecutor = sessionMaintenanceExecutor;
this.#fileIngressExecutor = fileIngressExecutor;
this.#interactionRegistry = interactionRegistry(this.#baseUrl.origin);
this.#interactionOwnerships = this.#interactionRegistry.ownerships;
this.#interactionClaims = this.#interactionRegistry.claims;
@ -1034,6 +1044,7 @@ export class HarnessClient {
? options.onInteractionResolved
: undefined;
const control = normalizeControl(options.control);
const inboundFiles = Array.isArray(options.files) ? options.files.filter(Boolean) : [];
await this.ensureRunning({ signal });
const before = await this.rpc(
'session.history',
@ -1075,6 +1086,9 @@ export class HarnessClient {
let interactionTask = null;
let artifactsDelivered = false;
let deliveredArtifactCount = 0;
let stagedInboundFiles = null;
let promptAccepted = false;
let turnFinished = false;
const deliverArtifacts = async () => {
if (!onArtifact || artifactsDelivered || tracker.turn === null) {
@ -1103,6 +1117,30 @@ export class HarnessClient {
const closeArtifactConsumer = outboundArtifactRegistry.openConsumer(sessionId, promptRpcId);
try {
if (inboundFiles.length > 0) {
if (!this.#fileIngressExecutor) {
throw new InboundFileError(
'inbound-file-ingress-unavailable',
'Harness file ingress is unavailable in this Host process.',
);
}
const sessionList = await this.rpc(
'session.list',
{},
30_000,
{ signal },
);
const sessionWorkspace = sessionList?.items?.find(
(item) => item?.sessionId === sessionId,
)?.cwd;
stagedInboundFiles = await this.#fileIngressExecutor({
sessionId,
workspace: sessionWorkspace,
files: inboundFiles,
signal,
});
prompt = appendInboundFilesToPrompt(prompt, stagedInboundFiles);
}
if (interactionSignal) {
let markOpen;
const opened = new Promise((resolve) => { markOpen = resolve; });
@ -1134,6 +1172,7 @@ export class HarnessClient {
content,
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}, 30_000, { rpcId: promptRpcId, signal });
promptAccepted = true;
try {
const deadline = Date.now() + timeoutMs;
@ -1159,6 +1198,7 @@ export class HarnessClient {
}
}
if (!tracker.finished) continue;
turnFinished = true;
// An accepted /stop revokes attachment delivery even when Harness
// preserved a useful partial text answer for the existing UX.
const artifactCount = ownership?.stopRequested
@ -1185,6 +1225,11 @@ export class HarnessClient {
throw turnStoppedError();
}
} finally {
if (stagedInboundFiles && (!promptAccepted || turnFinished)) {
await stagedInboundFiles.cleanup().catch((error) => {
console.warn(`[${this.#logPrefix}] unable to clean inbound files:`, error.message);
});
}
closeArtifactConsumer();
if (ownership) {
this.#unregisterControlOwnership(ownership);

View file

@ -0,0 +1,206 @@
import { createWriteStream } from 'node:fs';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { isAbsolute, join, relative, resolve } from 'node:path';
import { pipeline } from 'node:stream/promises';
const FILES_DIRECTORY = join('.dsh-im', 'inbound');
export class InboundFileError extends Error {
constructor(code, message, userMessage = '文件接收失败,请重新发送后再试。', options = {}) {
super(message, options);
this.name = 'InboundFileError';
this.code = code;
this.userMessage = userMessage;
}
}
function fileSources(message) {
return Array.isArray(message?.files) ? message.files.filter(Boolean) : [];
}
function displayName(value, fallback) {
if (typeof value !== 'string') return fallback;
const cleaned = value
.replaceAll('\\', '/')
.split('/')
.at(-1)
?.replace(/[\u0000-\u001f\u007f]/g, '')
.trim();
return cleaned || fallback;
}
function storageName(value, index) {
const cleaned = displayName(value, 'file')
.replace(/[^\p{L}\p{N}._ -]/gu, '_')
.replace(/^\.+/, '')
.slice(0, 160) || 'file';
return `${String(index + 1).padStart(2, '0')}-${cleaned}`;
}
function loadedFile(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,
mediaType: value?.mediaType ?? value?.mimetype,
};
}
const stream = value?.stream ?? value;
if (stream && typeof stream[Symbol.asyncIterator] === 'function') {
return {
stream,
name: value?.name ?? value?.filename,
mediaType: value?.mediaType ?? value?.mimetype,
};
}
return null;
}
export function hasInboundFiles(message) {
return fileSources(message).length > 0;
}
/** Start provider downloads immediately while preserving the lazy file-source contract. */
export function prefetchInboundFiles(message, { signal } = {}) {
const sources = fileSources(message);
if (sources.length === 0) return message;
return {
...message,
files: sources.map((source) => {
if (source?.data !== undefined || typeof source?.load !== 'function') return source;
let download;
try {
download = Promise.resolve(source.load({ signal }));
} catch (error) {
download = Promise.reject(error);
}
download.catch(() => undefined);
return {
...source,
async load({ signal: loadSignal } = {}) {
loadSignal?.throwIfAborted();
const result = await download;
loadSignal?.throwIfAborted();
return result;
},
};
}),
};
}
export async function stageInboundFiles(message, {
workspace,
signal,
} = {}) {
const sources = fileSources(message);
if (sources.length === 0) return null;
if (typeof workspace !== 'string' || !isAbsolute(workspace)) {
throw new InboundFileError(
'inbound-file-workspace-unavailable',
'The Harness Session workspace is unavailable for inbound files.',
);
}
signal?.throwIfAborted();
const root = resolve(workspace, FILES_DIRECTORY);
await mkdir(root, { recursive: true, mode: 0o700 });
const directory = await mkdtemp(join(root, 'turn-'));
const files = [];
try {
for (const [index, source] of sources.entries()) {
signal?.throwIfAborted();
let value;
try {
value = source?.data === undefined
? await source?.load?.({ signal })
: source.data;
} catch (error) {
if (signal?.aborted) throw error;
throw new InboundFileError(
'inbound-file-download-failed',
`Unable to download inbound file ${index + 1}: ${error?.message ?? String(error)}`,
'文件下载失败,请重新发送后再试。',
{ cause: error },
);
}
const loaded = loadedFile(value);
if (!loaded) {
throw new InboundFileError(
'inbound-file-data-invalid',
`Inbound file ${index + 1} returned no readable data.`,
);
}
const name = displayName(loaded.name ?? source?.name, `file-${index + 1}`);
const path = join(directory, storageName(name, index));
if (loaded.data) {
await writeFile(path, loaded.data, { mode: 0o600, signal });
} else {
try {
await pipeline(
loaded.stream,
createWriteStream(path, { flags: 'wx', mode: 0o600 }),
{ signal },
);
} catch (error) {
if (signal?.aborted) throw error;
throw new InboundFileError(
'inbound-file-download-failed',
`Unable to stream inbound file ${index + 1}: ${error?.message ?? String(error)}`,
'文件下载失败,请重新发送后再试。',
{ cause: error },
);
}
}
const relativePath = relative(resolve(workspace), path);
if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) {
throw new InboundFileError(
'inbound-file-path-invalid',
'The staged inbound file escaped the Harness Session workspace.',
);
}
files.push(Object.freeze({
name,
path: relativePath,
...(typeof (loaded.mediaType ?? source?.mediaType) === 'string'
&& (loaded.mediaType ?? source.mediaType).trim()
? { mediaType: (loaded.mediaType ?? source.mediaType).trim() }
: {}),
}));
}
return Object.freeze({
files: Object.freeze(files),
async cleanup() {
await rm(directory, { recursive: true, force: true });
},
});
} catch (error) {
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
throw error;
}
}
export function appendInboundFilesToPrompt(prompt, staged) {
if (!staged?.files?.length) return prompt;
const manifest = [
'<dsh_im_files>',
JSON.stringify({
description: 'Files uploaded with this user message. Paths are relative to the current Harness workspace.',
files: staged.files,
}),
'</dsh_im_files>',
].join('\n');
if (Array.isArray(prompt)) return [...prompt, { type: 'text', text: manifest }];
const text = typeof prompt === 'string' ? prompt.trim() : '';
return text ? `${text}\n\n${manifest}` : manifest;
}
export function inboundFileUserMessage(error) {
return error instanceof InboundFileError ? error.userMessage : null;
}

View file

@ -23,6 +23,10 @@ import {
imagePromptUserMessage,
promptContentForMessage,
} from './image-prompt.mjs';
import {
hasInboundFiles,
inboundFileUserMessage,
} from './inbound-file.mjs';
import {
harnessAnswerForQuestion,
harnessQuestionText,
@ -50,6 +54,7 @@ function canClaimInteractionReply(message, pending, senderId) {
return pending.actor === senderId
&& (message.kind !== 'group' || message.addressed === true)
&& !hasInboundImages(message)
&& !hasInboundFiles(message)
&& Boolean(cleanText(message.content));
}
@ -171,7 +176,7 @@ export class TextHarnessBridge {
const key = `${kind}:${conversationId}`;
const pending = this.#pendingInteractions.get(key);
const text = cleanText(normalized.content);
const commandRunner = isControlCommand(text)
const commandRunner = hasInboundFiles(normalized) ? null : isControlCommand(text)
? runControlCommand
: (isModelCommand(text)
? runModelCommand
@ -194,7 +199,7 @@ export class TextHarnessBridge {
key,
actor: senderId,
messageId,
text: hasInboundImages(normalized) ? '' : normalized.content,
text: hasInboundImages(normalized) || hasInboundFiles(normalized) ? '' : normalized.content,
addressed: normalized.kind !== 'group' || normalized.addressed === true,
hasPendingQuestion: Boolean(pending),
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@ -300,6 +305,7 @@ export class TextHarnessBridge {
{
signal: this.#signal,
hasImages: hasInboundImages(message),
hasFiles: hasInboundFiles(message),
pendingInteraction: this.#pendingInteractions.has(key)
|| this.#approvals.hasPending(key),
control: { owner: this, key },
@ -423,16 +429,17 @@ export class TextHarnessBridge {
return;
}
const hasImages = hasInboundImages(message);
if (!text && !hasImages) {
await this.#bot.sendText(target, '目前支持文字和图片消息。');
const hasFiles = hasInboundFiles(message);
if (!text && !hasImages && !hasFiles) {
await this.#bot.sendText(target, '目前支持文字、图片和文件消息。');
return;
}
const command = text.toLowerCase();
if (!hasImages && command === '/help') {
if (!hasImages && !hasFiles && command === '/help') {
await this.#bot.sendText(target, [
`${this.#descriptor.label}机器人已连接 DeepSeek Harness。`,
'',
'直接发送文字或图片即可继续当前会话。',
'直接发送文字、图片或文件即可继续当前会话。',
'/new 开启一个全新会话',
'/compact 压缩当前会话的较早上下文',
'/workspace 工作区绝对路径 切换工作区',
@ -453,12 +460,12 @@ export class TextHarnessBridge {
].join('\n'));
return;
}
if (!hasImages && command === '/status') {
if (!hasImages && !hasFiles && command === '/status') {
await this.#harness.ensureRunning({ signal: this.#signal });
await this.#bot.sendText(target, `${this.#descriptor.label}机器人与 DeepSeek Harness 连接正常。`);
return;
}
const workspaceCommand = !hasImages
const workspaceCommand = !hasImages && !hasFiles
? await runWorkspaceCommand(text, this.#harness, conversationKey)
: null;
if (workspaceCommand) {
@ -467,12 +474,12 @@ export class TextHarnessBridge {
}
return;
}
if (!hasImages && command === '/new') {
if (!hasImages && !hasFiles && command === '/new') {
await this.#state.clearSession(conversationKey);
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
return;
}
const compactCommand = !hasImages
const compactCommand = !hasImages && !hasFiles
? await runCompactCommand(
text,
this.#harness,
@ -527,6 +534,7 @@ export class TextHarnessBridge {
requiresMention: message.kind === 'group',
}),
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
files: message.files,
},
});
const visibleAnswer = !cleanText(answer) && artifacts.length > 0
@ -600,6 +608,18 @@ export class TextHarnessBridge {
}
return;
}
const fileErrorMessage = inboundFileUserMessage(error);
if (fileErrorMessage) {
try {
await this.#bot.sendText(target, fileErrorMessage);
} catch (sendError) {
this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to send the file error reply:`,
sendError,
);
}
return;
}
this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
try {
await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
@ -642,7 +662,7 @@ export class TextHarnessBridge {
const target = message.replyTarget;
const text = cleanText(message.content);
if (!text || hasInboundImages(message)) {
if (!text || hasInboundImages(message) || hasInboundFiles(message)) {
try {
await this.#bot.sendText(target, '请用文字回答当前问题。');
} catch (error) {

View file

@ -1,3 +1,4 @@
import { fetchFileStream } from '../shared/file-download.mjs';
import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs';
const DEFAULT_BASE_URL = 'https://slack.com/api/';
@ -232,6 +233,18 @@ export class SlackApi {
});
}
async fileInfo({ fileId, signal } = {}) {
const value = await this.#request('files.info', {
tokenKind: 'bot',
signal,
body: { file: slackId(fileId, 'file id') },
});
if (!value?.file || typeof value.file !== 'object' || Array.isArray(value.file)) {
throw new Error('Slack files.info returned no file object');
}
return value.file;
}
postMessage({ channelId, text, threadTs, signal }) {
return this.#request('chat.postMessage', {
tokenKind: 'bot',
@ -396,6 +409,14 @@ export class SlackApi {
}
async downloadFile({ url, signal, maxBytes }) {
return this.#downloadFile({ url, signal, maxBytes, stream: false });
}
async downloadFileStream({ url, signal }) {
return this.#downloadFile({ url, signal, stream: true });
}
async #downloadFile({ url, signal, maxBytes, stream }) {
if (!this.#botToken) throw new TypeError('Slack bot token is required for file download');
const target = secureSlackFileUrl(url);
const fetchSlackFile = async (requestUrl, options) => {
@ -414,13 +435,15 @@ export class SlackApi {
}
return response;
};
return fetchImageBuffer(target, {
const options = {
fetchImpl: fetchSlackFile,
headers: { authorization: `Bearer ${this.#botToken}` },
signal,
maxBytes,
allowedHosts: SLACK_FILE_HOSTS,
});
};
return stream
? fetchFileStream(target, options)
: fetchImageBuffer(target, { ...options, maxBytes });
}
async #request(method, {
@ -432,7 +455,7 @@ export class SlackApi {
}) {
const token = tokenKind === 'app' ? this.#appToken : this.#botToken;
if (!token) throw new TypeError(`Slack ${tokenKind} token is required for ${method}`);
const formEncoded = method === 'files.getUploadURLExternal';
const formEncoded = method === 'files.getUploadURLExternal' || method === 'files.info';
let response;
try {
response = await this.#fetch(new URL(method, this.#baseUrl), {

View file

@ -43,10 +43,14 @@ function stripBotMention(value, botUserId) {
.trim();
}
function slackFileUrl(file) {
return typeof file?.url_private_download === 'string' && file.url_private_download
? file.url_private_download : file?.url_private;
}
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;
const url = slackFileUrl(file);
if (!IMAGE_MEDIA_TYPES.has(mediaType) || typeof url !== 'string' || !url) return null;
return {
name: typeof file.name === 'string' ? file.name : undefined,
@ -56,9 +60,50 @@ function slackImageSource(file, loadFile) {
};
}
export function normalizeSlackEvent(payload, botUserId, { loadFile = async () => {
throw new Error('Slack file downloader is unavailable');
} } = {}) {
function slackFileSource(file, loadFile, loadFileInfo) {
const mediaType = typeof file?.mimetype === 'string' && file.mimetype
? file.mimetype.toLowerCase() : undefined;
const url = slackFileUrl(file);
if (IMAGE_MEDIA_TYPES.has(mediaType)) return null;
const requiresInfo = file?.file_access === 'check_file_info'
&& typeof file?.id === 'string' && file.id;
if ((typeof url !== 'string' || !url) && !requiresInfo) return null;
return {
name: typeof file?.name === 'string' && file.name
? file.name : typeof file?.title === 'string' && file.title
? file.title : requiresInfo ? file.id : 'slack-file',
...(mediaType ? { mediaType } : {}),
size: Number.isSafeInteger(file?.size) && file.size >= 0 ? file.size : undefined,
load: async ({ signal } = {}) => {
if (!requiresInfo) return loadFile(url, { signal });
const resolved = await loadFileInfo(file.id, { signal });
const resolvedUrl = slackFileUrl(resolved);
if (typeof resolvedUrl !== 'string' || !resolvedUrl) {
throw new Error('Slack files.info returned no downloadable URL');
}
const loaded = await loadFile(resolvedUrl, { signal });
const name = typeof resolved?.name === 'string' && resolved.name
? resolved.name : typeof resolved?.title === 'string' && resolved.title
? resolved.title : file.id;
const resolvedMediaType = typeof resolved?.mimetype === 'string' && resolved.mimetype
? resolved.mimetype.toLowerCase() : undefined;
if (Buffer.isBuffer(loaded) || loaded instanceof Uint8Array) {
return { data: loaded, name, ...(resolvedMediaType ? { mediaType: resolvedMediaType } : {}) };
}
return {
...loaded,
name,
...(resolvedMediaType ? { mediaType: resolvedMediaType } : {}),
};
},
};
}
export function normalizeSlackEvent(payload, botUserId, {
loadFile = async () => { throw new Error('Slack file downloader is unavailable'); },
loadFileStream = loadFile,
loadFileInfo = async () => { throw new Error('Slack file metadata loader 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';
@ -76,6 +121,9 @@ export function normalizeSlackEvent(payload, botUserId, { loadFile = async () =>
images: Array.isArray(event.files)
? event.files.map((file) => slackImageSource(file, loadFile)).filter(Boolean)
: [],
files: Array.isArray(event.files)
? event.files.map((file) => slackFileSource(file, loadFileStream, loadFileInfo)).filter(Boolean)
: [],
addressed: direct || mentioned,
replyTarget: {
channelId: String(event.channel),
@ -436,6 +484,8 @@ export class SlackRuntime {
&& packet.payload.api_app_id !== this.#appId) return;
const message = normalizeSlackEvent(packet.payload, this.#config.platformId.split(':')[1], {
loadFile: (url, options) => this.#api.downloadFile({ url, ...options }),
loadFileStream: (url, options) => this.#api.downloadFileStream({ url, ...options }),
loadFileInfo: (fileId, options) => this.#api.fileInfo({ fileId, ...options }),
});
const bridge = this.#bridge;
if (message && bridge) {

View file

@ -1,3 +1,4 @@
import { fetchFileStream } from '../shared/file-download.mjs';
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
const DEFAULT_BASE_URL = 'https://api.telegram.org/';
@ -131,6 +132,25 @@ export class TelegramApi {
}
async downloadFile({ fileId, signal, maxBytes } = {}) {
const url = await this.#downloadUrl(fileId, signal);
return fetchImageBuffer(url, {
fetchImpl: this.#fetch,
signal,
maxBytes,
allowedHosts: TELEGRAM_FILE_HOSTS,
});
}
async downloadFileStream({ fileId, signal } = {}) {
const url = await this.#downloadUrl(fileId, signal);
return fetchFileStream(url, {
fetchImpl: this.#fetch,
signal,
allowedHosts: TELEGRAM_FILE_HOSTS,
});
}
async #downloadUrl(fileId, signal) {
const file = await this.getFile({ fileId, signal });
const filePath = cleanString(file?.file_path);
if (!filePath || filePath.startsWith('/') || filePath.includes('\\')
@ -148,12 +168,7 @@ export class TelegramApi {
}
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,
});
return url;
}
async sendMessage({ chatId, text, replyToMessageId, messageThreadId, signal }) {

View file

@ -100,9 +100,27 @@ function telegramImageSource(message, loadFile) {
};
}
export function normalizeTelegramUpdate(update, { botId, username, loadFile = async () => {
throw new Error('Telegram file downloader is unavailable');
} }) {
function telegramFileSource(message, loadFile) {
const file = message?.document;
if (!file || imageTypeForDocument(file)
|| typeof file.file_id !== 'string' || !file.file_id) return null;
const mediaType = typeof file.mime_type === 'string' && file.mime_type
? file.mime_type.toLowerCase() : undefined;
return {
name: typeof file.file_name === 'string' && file.file_name
? file.file_name : String(file.file_unique_id ?? file.file_id),
...(mediaType ? { mediaType } : {}),
size: fileSize(file.file_size),
load: ({ signal } = {}) => loadFile(file.file_id, { signal }),
};
}
export function normalizeTelegramUpdate(update, {
botId,
username,
loadFile = async () => { throw new Error('Telegram file downloader is unavailable'); },
loadFileStream = loadFile,
}) {
const message = update?.message;
const chatId = message?.chat?.id;
const senderId = message?.from?.id;
@ -117,6 +135,7 @@ export function normalizeTelegramUpdate(update, { botId, username, loadFile = as
const messageThreadId = Number.isSafeInteger(message.message_thread_id)
? message.message_thread_id : undefined;
const image = telegramImageSource(message, loadFile);
const file = telegramFileSource(message, loadFileStream);
return {
messageId: String(update.update_id),
senderId: String(senderId),
@ -126,6 +145,7 @@ export function normalizeTelegramUpdate(update, { botId, username, loadFile = as
? String(chatId) : `${chatId}:${messageThreadId}`,
content: withoutBotMention(message.text ?? message.caption ?? '', username),
images: image ? [image] : [],
files: file ? [file] : [],
addressed,
replyTarget: {
chatId,
@ -381,6 +401,7 @@ export class TelegramRuntime {
botId: this.#config.platformId,
username: this.#config.username,
loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
loadFileStream: (fileId, options) => this.#api.downloadFileStream({ fileId, ...options }),
});
if (message && telegramInboundAllowed(message, {
accessMode: this.#accessMode,

View file

@ -26,6 +26,10 @@ import {
imagePromptUserMessage,
promptContentForMessage,
} from '../shared/image-prompt.mjs';
import {
hasInboundFiles,
inboundFileUserMessage,
} from '../shared/inbound-file.mjs';
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
import {
materializeOutboundArtifact,
@ -43,7 +47,7 @@ const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
const HELP_TEXT = [
'企业微信机器人已连接 DeepSeek Harness。',
'',
'直接发送文字或图片即可继续当前会话。',
'直接发送文字、图片或文件即可继续当前会话。',
'/new 开启一个全新会话',
'/compact 压缩当前会话的较早上下文',
'/workspace 工作区绝对路径 切换工作区',
@ -110,6 +114,13 @@ function imageContents(frame) {
.map((item) => item.image);
}
function fileContents(frame) {
const body = bodyOf(frame);
return body.msgtype === 'file' && body.file && typeof body.file === 'object'
? [body.file]
: [];
}
function imageSource(client, image) {
const url = nonEmptyString(image?.url);
if (!url) return null;
@ -139,10 +150,56 @@ function imageSource(client, image) {
};
}
function fileSource(client, file) {
const url = nonEmptyString(file?.url);
if (!url) return null;
const aeskey = nonEmptyString(file?.aeskey) ?? undefined;
return {
name: nonEmptyString(file?.filename ?? file?.file_name ?? file?.name) ?? 'file',
async load({ signal } = {}) {
signal?.throwIfAborted();
if (typeof client?.downloadFile !== 'function') {
throw new Error('Enterprise WeChat file download is unavailable');
}
const result = await client.downloadFile(url, aeskey);
signal?.throwIfAborted();
const raw = result?.buffer ?? result?.data;
if (!Buffer.isBuffer(raw) && !(raw instanceof Uint8Array)) {
throw new Error('Enterprise WeChat file download returned no data');
}
return {
data: Buffer.from(raw),
...(nonEmptyString(result?.filename) ? { name: result.filename.trim() } : {}),
};
},
};
}
export function wecomInboundMessage(frame, client) {
return {
content: messageText(frame),
images: imageContents(frame).map((image) => imageSource(client, image)).filter(Boolean),
files: fileContents(frame).map((file) => fileSource(client, file)).filter(Boolean),
};
}
function prefetchInboundFiles(message, signal) {
if (!Array.isArray(message?.files) || message.files.length === 0) return message;
return {
...message,
files: message.files.map((source) => {
const download = source.load({ signal });
download.catch(() => undefined);
return {
...source,
async load({ signal: loadSignal } = {}) {
loadSignal?.throwIfAborted();
const result = await download;
loadSignal?.throwIfAborted();
return result;
},
};
}),
};
}
@ -400,7 +457,7 @@ export class WecomHarnessBridge {
const pending = this.#pendingInteractions.get(key);
const commandMessage = wecomInboundMessage(frame, this.#client);
const commandText = nonEmptyString(commandMessage.content) ?? '';
const commandRunner = isControlCommand(commandText)
const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
? runControlCommand
: (isModelCommand(commandText)
? runModelCommand
@ -510,6 +567,7 @@ export class WecomHarnessBridge {
preparedMessage = imageQueueFullMessage(inboundMessage);
}
}
preparedMessage = prefetchInboundFiles(preparedMessage, this.#signal);
const previous = this.#queues.get(key) ?? Promise.resolve();
const current = previous
.catch(() => undefined)
@ -543,6 +601,7 @@ export class WecomHarnessBridge {
const result = await runner(message.content, this.#harness, this.#state, key, {
signal: this.#signal,
hasImages: hasInboundImages(message),
hasFiles: hasInboundFiles(message),
pendingInteraction: this.#pendingInteractions.has(key)
|| this.#approvals.hasPending(key),
control: { owner: this, key },
@ -701,34 +760,35 @@ export class WecomHarnessBridge {
const message = preparedMessage ?? wecomInboundMessage(frame, this.#client);
const text = message.content;
const hasImages = hasInboundImages(message);
const hasFiles = hasInboundFiles(message);
const key = conversationKey(frame);
let streamId = null;
let streamStarted = false;
try {
if (!text && !hasImages) {
await this.#sendImmediate(frame, chatId, '目前支持文字、图片和语音转写消息。');
if (!text && !hasImages && !hasFiles) {
await this.#sendImmediate(frame, chatId, '目前支持文字、图片、文件和语音转写消息。');
await this.#state.markSeen(messageId);
return;
}
const command = text.toLowerCase();
if (!hasImages && command === '/help') {
if (!hasImages && !hasFiles && command === '/help') {
await this.#sendImmediate(frame, chatId, HELP_TEXT);
await this.#state.markSeen(messageId);
return;
}
if (!hasImages && command === '/status') {
if (!hasImages && !hasFiles && command === '/status') {
await this.#harness.ensureRunning({ signal: this.#signal });
await this.#sendImmediate(frame, chatId, '企业微信机器人与 DeepSeek Harness 连接正常。');
await this.#state.markSeen(messageId);
return;
}
if (!hasImages && command === '/new') {
if (!hasImages && !hasFiles && command === '/new') {
await this.#state.clearSession(key);
await this.#sendImmediate(frame, chatId, '已开启新会话。请发送你的问题。');
await this.#state.markSeen(messageId);
return;
}
const workspaceCommand = hasImages
const workspaceCommand = hasImages || hasFiles
? null
: await runWorkspaceCommand(text, this.#harness, key);
if (workspaceCommand) {
@ -738,7 +798,7 @@ export class WecomHarnessBridge {
await this.#state.markSeen(messageId);
return;
}
const compactCommand = hasImages
const compactCommand = hasImages || hasFiles
? null
: await runCompactCommand(
text,
@ -789,6 +849,7 @@ export class WecomHarnessBridge {
requiresMention: body.chattype === 'group',
}),
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
files: message.files,
},
});
@ -862,7 +923,9 @@ 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) ?? '消息处理失败,请稍后重试。';
const errorText = inboundFileUserMessage(error)
?? imagePromptUserMessage(error)
?? '消息处理失败,请稍后重试。';
try {
if (streamStarted && streamId) {
await this.#client.replyStream(frame, streamId, errorText, true);

View file

@ -196,6 +196,47 @@ export function extractWeixinImages(message, { fetchImpl = fetch } = {}) {
return images;
}
async function fetchWeixinFileCiphertext(url, { fetchImpl, signal }) {
const response = await fetchImpl(new URL(url), {
method: 'GET',
signal,
redirect: 'manual',
});
if (!response?.ok) {
await response?.body?.cancel?.().catch?.(() => undefined);
throw new WeixinApiError(
'file-download-failed',
`微信文件下载失败(HTTP ${response?.status ?? 'unknown'})。`,
{ status: response?.status },
);
}
return Buffer.from(await response.arrayBuffer());
}
/** Convert native iLink file items into lazily downloaded, decrypted file references. */
export function extractWeixinFiles(message, { fetchImpl = fetch } = {}) {
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
const files = [];
for (const item of message?.item_list ?? []) {
const fileItem = item?.file_item;
if (!fileItem || typeof fileItem !== 'object') continue;
const declaredSize = Number(fileItem.len);
files.push({
name: nonEmptyString(fileItem.file_name) ?? (files.length === 0 ? 'file' : `file-${files.length + 1}`),
...(Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}),
load: async ({ signal } = {}) => {
signal?.throwIfAborted();
const key = parseWeixinImageAesKey(fileItem);
const url = weixinImageDownloadUrl(fileItem.media);
const ciphertext = await fetchWeixinFileCiphertext(url, { fetchImpl, signal });
signal?.throwIfAborted();
return decryptWeixinImage(ciphertext, key);
},
});
}
return files;
}
function isWeixinHost(hostname) {
const normalized = hostname.toLowerCase().replace(/\.$/, '');
return normalized === 'weixin.qq.com' || normalized.endsWith('.weixin.qq.com');
@ -421,6 +462,10 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
return extractWeixinImages(message, { fetchImpl });
},
inboundFiles(message) {
return extractWeixinFiles(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 {
extractWeixinFiles,
extractWeixinImages,
extractWeixinText,
splitWeixinText,
@ -31,6 +32,11 @@ import {
imagePromptUserMessage,
promptContentForMessage,
} from '../shared/image-prompt.mjs';
import {
hasInboundFiles,
inboundFileUserMessage,
prefetchInboundFiles,
} from '../shared/inbound-file.mjs';
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
import {
materializeOutboundArtifact,
@ -49,7 +55,7 @@ const GENERIC_PROCESSING_ERROR = '消息处理失败,请稍后重试。';
const HELP_TEXT = [
'微信已连接 DeepSeek Harness。',
'',
'直接发送文字、图片或带文字识别结果的语音即可继续当前会话。',
'直接发送文字、图片、文件或带文字识别结果的语音即可继续当前会话。',
'/new 开启一个全新会话',
'/compact 压缩当前会话的较早上下文',
'/workspace 工作区绝对路径 切换工作区',
@ -77,15 +83,33 @@ function nonEmptyString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
export function weixinInboundMessage(message, api) {
return {
content: extractWeixinText(message) ?? '',
images: typeof api?.inboundImages === 'function'
? api.inboundImages(message)
: extractWeixinImages(message),
files: typeof api?.inboundFiles === 'function'
? api.inboundFiles(message)
: extractWeixinFiles(message),
};
}
function hasWeixinImageItems(message) {
return Array.isArray(message?.item_list)
&& message.item_list.some((item) => item?.image_item && typeof item.image_item === 'object');
}
function hasWeixinFileItems(message) {
return Array.isArray(message?.item_list)
&& message.item_list.some((item) => item?.file_item && typeof item.file_item === 'object');
}
function canClaimInteractionReply(message, pending) {
return pending.questions[pending.index]
&& nonEmptyString(message?.from_user_id) === pending.actor
&& !hasWeixinImageItems(message)
&& !hasWeixinFileItems(message)
&& nonEmptyString(extractWeixinText(message));
}
@ -204,7 +228,7 @@ export class WeixinHarnessBridge {
const runId = nonEmptyString(message?.run_id) ?? undefined;
const pending = this.#pendingInteractions.get(key);
const commandText = nonEmptyString(extractWeixinText(message)) ?? '';
const commandRunner = isControlCommand(commandText)
const commandRunner = hasWeixinFileItems(message) ? null : isControlCommand(commandText)
? runControlCommand
: (isModelCommand(commandText)
? runModelCommand
@ -238,7 +262,9 @@ export class WeixinHarnessBridge {
key,
actor: sender,
messageId,
text: hasWeixinImageItems(message) ? '' : extractWeixinText(message),
text: hasWeixinImageItems(message) || hasWeixinFileItems(message)
? ''
: extractWeixinText(message),
addressed: true,
hasPendingQuestion: Boolean(pending),
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@ -290,10 +316,16 @@ export class WeixinHarnessBridge {
releaseMessageId = true,
alreadyRecorded = false,
} = {}) {
const preparedMessage = message.from_user_id === this.#ownerUserId
? prefetchInboundFiles(
weixinInboundMessage(message, this.#api),
{ signal: this.#signal },
)
: undefined;
const previous = this.#queues.get(key) ?? Promise.resolve();
const current = previous
.catch(() => undefined)
.then(() => this.#process(message, key, { alreadyRecorded }))
.then(() => this.#process(message, key, { alreadyRecorded, preparedMessage }))
.finally(() => {
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
if (this.#queues.get(key) === current) this.#queues.delete(key);
@ -331,6 +363,7 @@ export class WeixinHarnessBridge {
const result = await runner(text, this.#harness, this.#state, key, {
signal: this.#signal,
hasImages: hasWeixinImageItems(message),
hasFiles: hasWeixinFileItems(message),
pendingInteraction: this.#pendingInteractions.has(key)
|| this.#approvals.hasPending(key),
control: { owner: this, key },
@ -348,7 +381,7 @@ export class WeixinHarnessBridge {
this.#status.lastMessageError = null;
}
async #process(message, key, { alreadyRecorded = false } = {}) {
async #process(message, key, { alreadyRecorded = false, preparedMessage } = {}) {
this.#signal?.throwIfAborted();
const messageId = weixinMessageId(message);
const sender = nonEmptyString(message?.from_user_id);
@ -366,38 +399,36 @@ 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) ?? '';
try {
const images = typeof this.#api.inboundImages === 'function'
? this.#api.inboundImages(message)
: extractWeixinImages(message);
const promptMessage = { content: text, images };
const promptMessage = preparedMessage ?? weixinInboundMessage(message, this.#api);
const text = promptMessage.content;
const hasImages = hasInboundImages(promptMessage);
if (!text && !hasImages) {
await this.#send(sender, '目前支持文字、图片,以及微信已转成文字的语音消息。', contextToken, runId);
const hasFiles = hasInboundFiles(promptMessage);
if (!text && !hasImages && !hasFiles) {
await this.#send(sender, '目前支持文字、图片、文件,以及微信已转成文字的语音消息。', contextToken, runId);
await this.#state.markSeen(messageId);
return;
}
const command = text.trim().toLowerCase();
if (!hasImages && command === '/help') {
if (!hasImages && !hasFiles && command === '/help') {
await this.#send(sender, HELP_TEXT, contextToken, runId);
await this.#state.markSeen(messageId);
return;
}
if (!hasImages && command === '/status') {
if (!hasImages && !hasFiles && command === '/status') {
await this.#harness.ensureRunning({ signal: this.#signal });
await this.#send(sender, '微信与 DeepSeek Harness 连接正常。', contextToken, runId);
await this.#state.markSeen(messageId);
return;
}
if (!hasImages && command === '/new') {
if (!hasImages && !hasFiles && command === '/new') {
await this.#state.clearSession(key);
await this.#send(sender, '已开启新会话。请发送你的问题。', contextToken, runId);
await this.#state.markSeen(messageId);
return;
}
const workspaceCommand = hasImages
const workspaceCommand = hasImages || hasFiles
? null
: await runWorkspaceCommand(text, this.#harness, key);
if (workspaceCommand) {
@ -407,7 +438,7 @@ export class WeixinHarnessBridge {
await this.#state.markSeen(messageId);
return;
}
const compactCommand = hasImages
const compactCommand = hasImages || hasFiles
? null
: await runCompactCommand(
text,
@ -446,6 +477,7 @@ export class WeixinHarnessBridge {
runId,
}),
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
files: promptMessage.files,
},
}));
} finally {
@ -490,7 +522,9 @@ export class WeixinHarnessBridge {
}
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
const userMessage = imagePromptUserMessage(error) ?? GENERIC_PROCESSING_ERROR;
const userMessage = inboundFileUserMessage(error)
?? imagePromptUserMessage(error)
?? GENERIC_PROCESSING_ERROR;
this.#status.lastMessageError = safeMessageError(error, userMessage);
this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
try {

View file

@ -170,6 +170,39 @@ function whatsappImageSource(message, content, download, { viewOnce = false } =
};
}
function whatsappFileSource(message, content, download) {
const media = content?.documentMessage;
if (!media) return null;
const mediaType = typeof media.mimetype === 'string' && media.mimetype
? media.mimetype.toLowerCase() : undefined;
if (IMAGE_MEDIA_TYPES.has(mediaType)) return null;
return {
name: typeof media.fileName === 'string' && media.fileName
? media.fileName : 'whatsapp-file',
...(mediaType ? { mediaType } : {}),
size: mediaSize(media.fileLength),
async load({ signal } = {}) {
signal?.throwIfAborted();
const stream = await download(message, 'stream', { options: { signal } });
signal?.throwIfAborted();
return { stream };
},
};
}
export function createWhatsappMediaDownloader({
socket,
logger = console,
download = downloadMediaMessage,
} = {}) {
if (typeof download !== 'function') throw new TypeError('WhatsApp media downloader is required');
if (typeof socket?.updateMediaMessage !== 'function') return download;
return (message, type, options) => download(message, type, options, {
logger,
reuploadRequest: (candidate) => socket.updateMediaMessage(candidate),
});
}
export function normalizeWhatsappMessage(message, accountJid, {
download = downloadMediaMessage,
} = {}) {
@ -195,6 +228,7 @@ export function normalizeWhatsappMessage(message, accountJid, {
const replyToSelf = typeof context?.participant === 'string'
&& areJidsSameUser(context.participant, accountJid);
const image = whatsappImageSource(message, content, download, { viewOnce });
const file = whatsappFileSource(message, content, download);
return {
messageId: `${remoteJid}:${messageId}`,
providerMessageId: messageId,
@ -205,6 +239,7 @@ export function normalizeWhatsappMessage(message, accountJid, {
conversationId: remoteJid,
content: messageText(content),
images: image ? [image] : [],
files: file ? [file] : [],
addressed: !group || mentioned || replyToSelf,
selfChat,
replyTarget: { jid: remoteJid, quoted: message, selfChat },
@ -495,8 +530,13 @@ export class WhatsappRuntime {
new Error('WhatsApp linked-device session must be scanned again'),
{ code: 'relink-required' },
)),
onMessage: async (raw) => {
const message = normalizeWhatsappMessage(raw, this.#config.accountJid);
onMessage: async (raw, context) => {
const message = normalizeWhatsappMessage(raw, this.#config.accountJid, {
download: createWhatsappMediaDownloader({
socket: context?.socket,
logger: this.#logger,
}),
});
if (!message || outboundIds.has(message.providerMessageId) || !this.#bridge) return;
this.#status.lastCheckedAt = Date.now();
if (!whatsappInboundAllowed(message, {

View file

@ -190,7 +190,7 @@ export async function createWhatsappWebSession({
const timestamp = messageTimestampMs(message?.messageTimestamp);
if (timestamp === null || timestamp < sessionStartedAt - APPEND_RECENT_GRACE_MS) continue;
}
Promise.resolve(onMessage(message)).catch(() => {
Promise.resolve(onMessage(message, { socket: nextSocket })).catch(() => {
logger.error?.('[dsh-im:whatsapp] failed to process an inbound WhatsApp message');
});
}