mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +08:00
feat: add native result-file delivery
This commit is contained in:
parent
2803bbcbab
commit
d472dd5a75
65 changed files with 10205 additions and 255 deletions
73
CONTEXT.md
Normal file
73
CONTEXT.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# DeepSeek Harness IM
|
||||
|
||||
本上下文描述 dsh-im 如何把不同即时通信平台中的用户意图和 DeepSeek Harness 的工作过程连接起来。项目的核心价值不是渠道数量,而是让用户在所在渠道中自然、可靠地完成 Harness 任务。
|
||||
|
||||
## Language
|
||||
|
||||
**语义消息(Semantic Message)**:
|
||||
不依赖具体 IM 平台、能够完整表达用户输入及其上下文的一条消息,包括内容、引用关系和会话位置。
|
||||
_Avoid_: 统一文本、通用消息体
|
||||
|
||||
**语义交互(Semantic Interaction)**:
|
||||
Harness 等待用户完成的一次有状态操作,例如审批、单选、多选或补充输入。
|
||||
_Avoid_: 按钮事件、命令回复
|
||||
|
||||
**产物(Artifact)**:
|
||||
Harness 任务读取或产生、需要在 Harness 与用户所在渠道之间安全传递的文件或媒体。
|
||||
_Avoid_: 附件路径、下载地址
|
||||
|
||||
**产物来源(Artifact Provenance)**:
|
||||
证明一个现有或新建的出站产物由当前 Harness Session/Turn 通过受信工具结果显式登记的可验证记录;它证明交付意图和路由,不要求文件必须由当前 Turn 创建。
|
||||
_Avoid_: 回答里的路径、最新文件
|
||||
|
||||
**会话路由(Conversation Route)**:
|
||||
唯一确定消息所属机器人、聊天及话题或线程的会话位置。
|
||||
_Avoid_: Chat ID、会话 Key
|
||||
|
||||
**渠道能力(Channel Capability)**:
|
||||
某个机器人实例在当前权限和运行条件下可以可靠提供的原生输入、交互或呈现能力。
|
||||
_Avoid_: 平台支持、SDK 功能
|
||||
|
||||
**原生呈现(Native Presentation)**:
|
||||
渠道依据自身交互习惯呈现同一语义,例如卡片、按钮、消息编辑、流式回复或输入状态。
|
||||
_Avoid_: 特殊适配、渠道特例
|
||||
|
||||
**呈现意图(Presentation Intent)**:
|
||||
Harness 输出在进入渠道前必须保留的内容结构和格式含义,例如纯文本、Markdown、进度、交互或产物;它不指定某个平台的控件或语法。
|
||||
_Avoid_: 回答字符串、Telegram Rich Message、统一富文本
|
||||
|
||||
**渠道原生动作(Native Channel Action)**:
|
||||
渠道为承载一次语义任务而执行的平台专属操作,例如创建 Discord Thread、更新飞书卡片或上传附件;它属于渠道边界,不要求其他渠道提供同名操作。
|
||||
_Avoid_: 公共命令、通用渠道方法
|
||||
|
||||
**选择呈现(Selection Presentation)**:
|
||||
单选或多选在渠道中的实际实现形态,只能表述为原生控件、组合交互或文字降级;判断能力时必须同时说明呈现形式和已经验证的范围。
|
||||
_Avoid_: 支持多选、能力允许时、统一选择器
|
||||
|
||||
**明确降级(Explicit Fallback)**:
|
||||
渠道缺少所需能力时,保持业务语义并向用户说明限制的替代体验。
|
||||
_Avoid_: 兼容模式、静默忽略
|
||||
|
||||
**能力切片(Capability Slice)**:
|
||||
围绕一个完整用户价值,从统一语义、安全策略到各渠道原生呈现和验收的端到端建设单元。
|
||||
_Avoid_: 渠道任务、接口改造
|
||||
|
||||
**标杆渠道(Reference Channel)**:
|
||||
一项能力切片中最先完成真实客户端闭环、其原生机制最适合验证该语义和体验的渠道。标杆渠道按能力选择,不是永久主渠道。
|
||||
_Avoid_: 主渠道、默认渠道
|
||||
|
||||
**用户价值优先级(User Value Priority)**:
|
||||
依据核心任务完成度、语义准确性、移动端操作成本以及安全与可靠性,决定能力切片的建设先后顺序。
|
||||
_Avoid_: 功能数量排序、渠道用户量排序
|
||||
|
||||
**渠道适配性(Channel Fit)**:
|
||||
某项语义能力与渠道原生机制、权限覆盖、接口稳定性和可测试性之间的匹配程度,用于选择标杆渠道并决定原生实现或明确降级。
|
||||
_Avoid_: 渠道排名、平台先进程度
|
||||
|
||||
**渠道行为基线(Channel Behavior Baseline)**:
|
||||
语义迁移开始前,某个渠道已经向用户提供的任务流程、控制能力、状态语义和原生体验的可验证集合;后续实现可以改善它,但不能使其中任何能力消失或退化。
|
||||
_Avoid_: 当前代码、旧实现、测试现状
|
||||
|
||||
**等价接管(Parity Cutover)**:
|
||||
新语义路径在覆盖渠道行为基线、通过回归并具备回滚能力后,才取得某项消息或能力的唯一处理权。
|
||||
_Avoid_: 直接替换、重写完成
|
||||
18
README.en.md
18
README.en.md
|
|
@ -59,6 +59,24 @@ Other IM platforms can be added through the same channel-adapter structure.
|
|||
|
||||
All nine built-in channels can send JPEG, PNG, and WebP images, plus GIFs sent as image files, with optional captions to Harness. Each image is limited to 5 MB, and images in one message are limited to 20 MB in total.
|
||||
|
||||
### Result-file delivery
|
||||
|
||||
All nine built-in channels can return any file readable by Harness as a native channel attachment. Existing files and files created by the current task can both be sent directly. The capability is available to every connected bot by default, with no switch or per-bot allowlist, while existing text, image, streaming, command, and Session behavior remains unchanged.
|
||||
|
||||
After the model calls the file-return tool, the plugin hands the specified file to the channel's native attachment API. The plugin adds no rules for file origin, creation time, workspace boundary, extension, content, count, size, or lifetime; the file only needs to exist and be readable. A channel may still reject delivery according to its own permissions, quota, file capability, or account tier, and the plugin reports that provider result.
|
||||
|
||||
| Channel | Platform requirements |
|
||||
| --- | --- |
|
||||
| WeChat | The current binding protocol and conversation must support native file messages; the WeChat API response determines the actual range. |
|
||||
| Feishu | Feishu's file-upload API requires a non-empty file no larger than the platform's 30 MB limit. The app needs the `im:resource` tenant scope (**Read and upload images or other files**). Apps created through the built-in QR flow request it by default; existing or manually connected apps still need it added and approved. The Feishu developer console currently has no separate `im:resource:upload` scope. |
|
||||
| DingTalk | The app needs `qyapi_base`, and the bot must support file messages. The current OAPI and bot capability determine the accepted formats and sizes. |
|
||||
| WeCom | The app needs media-upload and file-message capability; the WeCom API response determines the actual range. |
|
||||
| QQ | The bot needs file-message capability and remains subject to QQ's daily upload quota; the bot reports when the quota is exhausted. |
|
||||
| Slack | The Bot Token needs `files:write`; the Workspace's current policy determines the actual size limit. After changing scopes, re-authorize/reinstall the App and reconnect the bot. |
|
||||
| Telegram | The bot must be allowed to send documents in the current chat; the Bot API response determines the actual range. |
|
||||
| Discord | The bot needs **Send Messages**, **Attach Files**, and **Read Message History**. The current account and server capability determine the actual attachment allowance. |
|
||||
| WhatsApp | The linked session must support Document Messages; the WhatsApp/Baileys response determines the actual range. |
|
||||
|
||||
## AI Office Connector
|
||||
|
||||
The **AI Office** page lets the local Harness connect outward to a public Office. The machine needs no public IP, forwarded port, or WebSocket server. The Device Token is written only to the Harness credential provider; the ordinary config file contains only the device ID, Office origin, workspace aliases, and instruction-preset aliases. Office selects aliases and never receives local absolute paths.
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -62,6 +62,24 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
|
|||
|
||||
九个内置渠道均支持把 JPEG、PNG、WebP 图片,以及以图片文件方式发送的 GIF,连同可选文字说明发送给 Harness;单张图片上限为 5 MB,单条消息中的图片总大小上限为 20 MB。
|
||||
|
||||
### 结果文件回传
|
||||
|
||||
九个内置渠道均已实现把 Harness 可读取的文件作为渠道原生附件回传。已有文件和当前任务新生成的文件都可以直接发送;该能力对所有已连接机器人默认可用,无需开关或机器人白名单,原有文字、图片、流式回复、命令和会话行为保持不变。
|
||||
|
||||
模型调用文件回传工具后,插件把指定文件交给当前渠道的原生附件接口。插件不额外设置文件来源、创建时间、工作区边界、扩展名、内容、数量、大小或有效期规则;文件只需真实存在且可读取。渠道平台仍可能依据自身权限、配额、文件能力或账号等级拒绝发送,插件会按平台返回结果提示。
|
||||
|
||||
| 渠道 | 平台要求 |
|
||||
| --- | --- |
|
||||
| 微信 | 当前绑定协议和会话需支持原生文件消息,实际可发送范围以微信接口返回为准。 |
|
||||
| 飞书 | 飞书文件上传接口要求文件非空且不超过平台 30 MB;应用需有租户权限 `im:resource`(“读取与上传图片或文件资源”)。内置扫码流程新建应用时默认申请该权限;已有或手动绑定的应用仍需在开发者后台添加并完成必要审批。飞书开发者后台当前没有单独的 `im:resource:upload` 权限。 |
|
||||
| 钉钉 | 应用需开通 `qyapi_base`,机器人需具备文件消息能力;实际格式和大小以当前 OAPI 与机器人能力返回为准。 |
|
||||
| 企业微信 | 应用需具备素材上传和文件消息能力,实际可发送范围以企业微信接口返回为准。 |
|
||||
| QQ | 机器人需具备文件消息能力,并受 QQ 当日文件上传配额约束;额度耗尽时会明确提示稍后重试。 |
|
||||
| Slack | Bot Token 需有 `files:write`;实际大小上限由 Workspace 当前策略决定。已有 App 新增或变更 Scope 后,必须重新授权/安装 App 并重新连接机器人。 |
|
||||
| Telegram | 机器人必须能在当前聊天发送文档,实际可发送范围以 Bot API 返回为准。 |
|
||||
| Discord | 机器人需有 **Send Messages**、**Attach Files** 和 **Read Message History** 权限;实际附件额度由当前账号与服务器能力决定。 |
|
||||
| WhatsApp | 当前绑定会话需支持 Document Message,实际可发送范围以 WhatsApp/Baileys 返回为准。 |
|
||||
|
||||
## AI Office Connector
|
||||
|
||||
「AI Office」页让本机 Harness 主动连接公网 Office,本机无需公网 IP、端口转发或 WebSocket 服务。Device Token 只写入 Harness 凭据存储;普通配置文件仅保存设备 ID、Office Origin、工作区 alias 和 Instruction Preset alias。Office 只能选择 alias,不会收到本机绝对路径。
|
||||
|
|
|
|||
31
docs/adr/0001-semantic-core-native-channel-adapters.md
Normal file
31
docs/adr/0001-semantic-core-native-channel-adapters.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
status: accepted
|
||||
date: 2026-08-21
|
||||
updated: 2026-08-23
|
||||
---
|
||||
|
||||
# 统一语义核心与渠道原生适配
|
||||
|
||||
dsh-im 采用“横向建设统一语义,纵向逐个渠道打磨;按用户价值推进,按渠道特性落地”的架构与产品策略。核心层表达消息、引用、会话、交互、产物和进度等业务语义;渠道适配器依据机器人实例的实际能力进行原生呈现,不支持时执行明确降级。迁移采用增量包裹和等价接管:现有渠道行为构成最低基线,新路径可以改善体验,但不得通过删除、降级或转移既有能力来换取新能力。项目不再以 `content + images + sendText` 作为长期公共边界,也不为每个渠道复制 Harness 业务流程。
|
||||
|
||||
## Considered Options
|
||||
|
||||
- **最低公分母模型**:扩展快,但持续丢失按钮、引用、文件、语音、线程和富呈现等高价值能力。
|
||||
- **每个渠道独立实现**:单点体验可控,但会复制会话、审批、安全和错误处理逻辑,长期无法保持一致性。
|
||||
- **按渠道用户量排序**:看似容易生成开发顺序,但本地开源项目缺少可靠且必要的跨用户统计;现有使用量也无法代表尚未被满足的用户价值,容易让长尾渠道和高价值能力长期得不到验证。
|
||||
- **直接替换旧桥接层**:代码收敛更快,但容易同时改变命令、会话、权限、队列、流式和异常处理等已经可用的行为,且发生回归时难以定位和回滚。
|
||||
- **统一语义 + 原生适配**:公共业务规则只实现一次,渠道差异保留在边界内;需要维护能力矩阵和适配器契约,但最符合产品差异化目标。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 新能力必须先定义用户语义、降级规则和验收标准,再进入渠道 SDK 实现。
|
||||
- 真实 Issue 作为能力切片的需求入口;每个 Issue 只落地其闭环所需的最小公共语义,同时完成对应渠道的原生实现、降级和验收,不先建设整套大一统框架,也不留下脱离公共语义的临时补丁。
|
||||
- 能力切片依据用户核心任务价值排序,不依据渠道用户量排序。
|
||||
- 每项能力选择渠道适配性最高的平台作为标杆渠道,用于验证统一语义和状态机;其他渠道再依据自身原生机制、权限和稳定性决定原生实现或明确降级。
|
||||
- 渠道能力按机器人实例和权限动态判断,不能仅凭平台名称宣称支持。
|
||||
- 出站产物必须由受信工具显式登记并绑定 Session/Turn;现有文件和新建文件语义相同,不要求当前 Turn 创建。助手回答中的路径、Markdown 本地链接和工作区扫描结果不能自动触发文件发送。
|
||||
- 每个渠道必须先建立可追溯的行为基线;新路径达到语义等价或更优、全量回归通过且可以回滚后,才能接管生产消息。
|
||||
- 明确降级只适用于原本不具备的新能力或运行时临时故障,不能作为迁移时撤掉既有原生能力的理由。
|
||||
- 可以在等价接管稳定后删除重复实现,但不得删除用户可观察功能、控制命令、状态语义或安全边界。
|
||||
- 在高优先级能力完成前,不以继续增加渠道数量作为主要目标。
|
||||
- 当前共享桥接器、九渠道命令与会话能力、飞书专属卡片能力以及 AI Office Connector 都是迁移资产;新语义路径只能增量包裹并等价接管,不能把成熟实现当作可直接替换的早期原型。
|
||||
1202
docs/方案/渠道原生能力建设方案.md
Normal file
1202
docs/方案/渠道原生能力建设方案.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -6804,6 +6804,7 @@ oauth_config:
|
|||
- app_mentions:read
|
||||
- chat:write
|
||||
- files:read
|
||||
- files:write
|
||||
- im:history
|
||||
settings:
|
||||
event_subscriptions:
|
||||
|
|
|
|||
326
lib/index.js
326
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -8,9 +8,15 @@ import { apply as applyTelegram } from './channels/telegram/index.mjs';
|
|||
import { apply as applyWecom } from './channels/wecom/index.mjs';
|
||||
import { apply as applyWeixin } from './channels/weixin/index.mjs';
|
||||
import { apply as applyWhatsapp } from './channels/whatsapp/index.mjs';
|
||||
import { installOutboundArtifactTool } from '../../src/channels/shared/semantic/artifact.mjs';
|
||||
|
||||
export const name = 'dsh-im-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer', 'typertGateway'];
|
||||
export const inject = [
|
||||
'connection',
|
||||
'credentials',
|
||||
'webServer',
|
||||
'typertGateway',
|
||||
];
|
||||
|
||||
function channelConfig(config, name) {
|
||||
const channel = config[name] ?? {};
|
||||
|
|
@ -34,6 +40,13 @@ export function createImHostPlugin(internals = {}) {
|
|||
name,
|
||||
inject,
|
||||
async apply(ctx, config = {}) {
|
||||
if (typeof ctx?.inject === 'function') {
|
||||
ctx.inject(['tools', 'systemPrompt'], (artifactCtx) => {
|
||||
installOutboundArtifactTool(artifactCtx);
|
||||
});
|
||||
} else {
|
||||
installOutboundArtifactTool(ctx);
|
||||
}
|
||||
await startFeishu(ctx, channelConfig(config, 'feishu'));
|
||||
await startWeixin(ctx, channelConfig(config, 'weixin'));
|
||||
await startDingtalk(ctx, channelConfig(config, 'dingtalk'));
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { extname } from 'node:path';
|
||||
|
||||
import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs';
|
||||
|
||||
|
|
@ -25,8 +26,67 @@ function nonEmptyString(value) {
|
|||
}
|
||||
|
||||
function safeProviderCode(value) {
|
||||
const code = nonEmptyString(value);
|
||||
return code && /^[A-Za-z0-9_.:-]{1,160}$/.test(code) ? code : undefined;
|
||||
const code = value === undefined || value === null ? null : String(value).trim();
|
||||
return code && /^-?[A-Za-z0-9_.:-]{1,160}$/.test(code) ? code : undefined;
|
||||
}
|
||||
|
||||
function preserveArtifactMetadata(target, source) {
|
||||
if (Number.isInteger(source?.status)) target.status = source.status;
|
||||
if (source?.providerCode !== undefined) target.providerCode = source.providerCode;
|
||||
return target;
|
||||
}
|
||||
|
||||
function dingtalkArtifactError(cause, { fallback = 'artifact-provider-rejected' } = {}) {
|
||||
if (cause?.code?.startsWith?.('artifact-')) return cause;
|
||||
const status = Number(cause?.status);
|
||||
const providerCode = safeProviderCode(cause?.providerCode);
|
||||
const providerText = providerCode ?? '';
|
||||
let code = fallback;
|
||||
let message = 'DingTalk could not prepare the file for delivery.';
|
||||
if (status === 401 || status === 403 || providerCode === '401' || providerCode === '403'
|
||||
|| /(?:permission|forbidden|unauthor|access.?denied|\.auth(?:\.|$))/i.test(providerText)) {
|
||||
code = 'artifact-permission-required';
|
||||
message = 'DingTalk denied permission to send the file.';
|
||||
} else if (status === 413 || providerCode === '413'
|
||||
|| /(?:too.?large|size.?limit)/i.test(providerText)) {
|
||||
code = 'artifact-too-large';
|
||||
message = 'The file exceeds DingTalk\'s size limit.';
|
||||
} else if (status === 429 || providerCode === '429'
|
||||
|| /(?:rate.?limit|too.?many|throttl)/i.test(providerText)) {
|
||||
code = 'artifact-rate-limited';
|
||||
message = 'DingTalk rate-limited file delivery.';
|
||||
} else if (fallback === 'artifact-provider-rejected') {
|
||||
message = 'DingTalk rejected the file message.';
|
||||
}
|
||||
const error = new Error(message, { cause });
|
||||
error.code = code;
|
||||
return preserveArtifactMetadata(error, cause);
|
||||
}
|
||||
|
||||
function uncertainDingtalkDelivery(cause) {
|
||||
const error = new Error('DingTalk file delivery result is uncertain', { cause });
|
||||
error.code = 'artifact-delivery-uncertain';
|
||||
return preserveArtifactMetadata(error, cause);
|
||||
}
|
||||
|
||||
function rejectedProviderResponse(value) {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
for (const field of ['errcode', 'code']) {
|
||||
if (value[field] !== undefined && value[field] !== 0 && value[field] !== '0') {
|
||||
return safeProviderCode(value[field]) ?? 'rejected';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function classifyDingtalkFinalDeliveryError(error, signal) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
const status = Number(error?.status);
|
||||
if (error?.code === 'network-error' || error?.code === 'timeout'
|
||||
|| error?.code === 'invalid-response' || (status >= 500 && status < 600)) {
|
||||
return uncertainDingtalkDelivery(error);
|
||||
}
|
||||
return dingtalkArtifactError(error);
|
||||
}
|
||||
|
||||
function secureDingtalkDownloadUrl(value) {
|
||||
|
|
@ -174,6 +234,74 @@ async function requestJson(fetchImpl, url, {
|
|||
}
|
||||
}
|
||||
|
||||
async function requestMultipart(fetchImpl, url, { body, signal, timeoutMs = 60_000 } = {}) {
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const onAbort = () => controller.abort(signal?.reason);
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
method: 'POST',
|
||||
body,
|
||||
signal: controller.signal,
|
||||
redirect: 'error',
|
||||
});
|
||||
let value;
|
||||
let parseError;
|
||||
try {
|
||||
value = await response.json();
|
||||
} catch (error) {
|
||||
parseError = error;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new DingtalkApiError(
|
||||
'http-error',
|
||||
`钉钉服务请求失败(HTTP ${response.status})。`,
|
||||
{ status: response.status, providerCode: safeProviderCode(value?.code ?? value?.errcode) },
|
||||
);
|
||||
}
|
||||
if (parseError) {
|
||||
throw new DingtalkApiError(
|
||||
'invalid-response',
|
||||
'钉钉服务返回了无法解析的响应。',
|
||||
{ cause: parseError },
|
||||
);
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
if (timedOut) throw new DingtalkApiError('timeout', '钉钉服务请求超时。', { cause: error });
|
||||
if (error instanceof DingtalkApiError) throw error;
|
||||
throw new DingtalkApiError('network-error', '暂时无法完成钉钉文件上传请求。', { cause: error });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFileTarget(target) {
|
||||
const robotCode = nonEmptyString(target?.robotCode);
|
||||
if (!robotCode) throw new TypeError('DingTalk robotCode is required');
|
||||
if (target?.type === 'group') {
|
||||
const openConversationId = nonEmptyString(target.openConversationId);
|
||||
if (openConversationId) return { type: 'group', robotCode, openConversationId };
|
||||
}
|
||||
if (target?.type === 'user') {
|
||||
const userId = nonEmptyString(target.userId);
|
||||
if (userId) return { type: 'user', robotCode, userId };
|
||||
}
|
||||
throw new TypeError('DingTalk file target is invalid');
|
||||
}
|
||||
|
||||
function dingtalkFileType(fileName) {
|
||||
return extname(fileName).slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeCardTarget(target) {
|
||||
if (target?.type === 'user') {
|
||||
const userId = nonEmptyString(target.userId);
|
||||
|
|
@ -648,6 +776,91 @@ export function createDingtalkApi({
|
|||
return true;
|
||||
},
|
||||
|
||||
async sendFile({ clientId, clientSecret, target, file, signal }) {
|
||||
if (!file || typeof file !== 'object'
|
||||
|| typeof file.fileName !== 'string' || !file.fileName
|
||||
|| !Buffer.isBuffer(file.bytes)) {
|
||||
throw new TypeError('A DingTalk file is required');
|
||||
}
|
||||
const normalizedTarget = normalizeFileTarget(target);
|
||||
const fileType = dingtalkFileType(file.fileName);
|
||||
let token;
|
||||
try {
|
||||
token = await accessToken({ clientId, clientSecret, signal });
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
const status = Number(error?.status);
|
||||
const fallback = error?.code === 'http-error' && status >= 400 && status < 500
|
||||
? 'artifact-provider-rejected'
|
||||
: 'artifact-provider-failed';
|
||||
throw dingtalkArtifactError(error, { fallback });
|
||||
}
|
||||
const uploadUrl = new URL('media/upload', DINGTALK_REGISTRATION_BASE_URL);
|
||||
uploadUrl.searchParams.set('access_token', token);
|
||||
uploadUrl.searchParams.set('type', 'file');
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'media',
|
||||
new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
|
||||
file.fileName,
|
||||
);
|
||||
let uploaded;
|
||||
try {
|
||||
uploaded = await requestMultipart(fetchImpl, uploadUrl, { body: form, signal });
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
const status = Number(error?.status);
|
||||
const fallback = error?.code === 'http-error' && status >= 400 && status < 500
|
||||
? 'artifact-provider-rejected'
|
||||
: 'artifact-provider-failed';
|
||||
throw dingtalkArtifactError(error, { fallback });
|
||||
}
|
||||
const uploadRejection = rejectedProviderResponse(uploaded);
|
||||
if (uploadRejection || !nonEmptyString(uploaded?.media_id)) {
|
||||
throw dingtalkArtifactError(new DingtalkApiError(
|
||||
'upload-rejected',
|
||||
'钉钉服务拒绝了文件上传。',
|
||||
{ providerCode: uploadRejection ?? 'missing-media-id' },
|
||||
));
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const messageBody = {
|
||||
robotCode: normalizedTarget.robotCode,
|
||||
msgKey: 'sampleFile',
|
||||
msgParam: JSON.stringify({
|
||||
mediaId: uploaded.media_id,
|
||||
fileName: file.fileName,
|
||||
fileType,
|
||||
}),
|
||||
...(normalizedTarget.type === 'group'
|
||||
? { openConversationId: normalizedTarget.openConversationId }
|
||||
: { userIds: [normalizedTarget.userId] }),
|
||||
};
|
||||
const pathname = normalizedTarget.type === 'group'
|
||||
? 'v1.0/robot/groupMessages/send'
|
||||
: 'v1.0/robot/oToMessages/batchSend';
|
||||
let response;
|
||||
try {
|
||||
response = await requestJson(fetchImpl, endpoint(apiBase, pathname), {
|
||||
body: messageBody,
|
||||
headers: { 'x-acs-dingtalk-access-token': token },
|
||||
signal,
|
||||
action: '文件消息发送',
|
||||
});
|
||||
} catch (error) {
|
||||
throw classifyDingtalkFinalDeliveryError(error, signal);
|
||||
}
|
||||
const sendRejection = rejectedProviderResponse(response);
|
||||
if (sendRejection) {
|
||||
throw dingtalkArtifactError(new DingtalkApiError(
|
||||
'send-rejected',
|
||||
'钉钉服务拒绝了文件消息。',
|
||||
{ providerCode: sendRejection },
|
||||
));
|
||||
}
|
||||
return response;
|
||||
},
|
||||
|
||||
clearAccessToken(clientId) {
|
||||
const appKey = nonEmptyString(clientId);
|
||||
if (appKey) tokenCache.delete(appKey);
|
||||
|
|
|
|||
|
|
@ -30,6 +30,16 @@ import {
|
|||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
||||
import {
|
||||
materializeOutboundArtifact,
|
||||
releaseOutboundArtifact,
|
||||
} from '../shared/semantic/artifact.mjs';
|
||||
import {
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
providerMessageIdsFor,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
|
||||
const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
|
||||
const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。';
|
||||
|
|
@ -62,6 +72,13 @@ function nonEmptyString(value) {
|
|||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function dingtalkFileProviderIds(result) {
|
||||
const ids = providerMessageIdsFor(result);
|
||||
const processQueryKey = nonEmptyString(result?.processQueryKey);
|
||||
if (processQueryKey && !ids.includes(processQueryKey)) ids.push(processQueryKey);
|
||||
return ids;
|
||||
}
|
||||
|
||||
function safeErrorDiagnostic(error) {
|
||||
const chain = [];
|
||||
const seen = new Set();
|
||||
|
|
@ -195,6 +212,40 @@ function cardTarget(message, sender) {
|
|||
return { type: 'user', userId: sender };
|
||||
}
|
||||
|
||||
function fileTarget(message, sender, clientId) {
|
||||
const robotCode = nonEmptyString(message?.robotCode) ?? clientId;
|
||||
if (String(message?.conversationType) === '2') {
|
||||
return {
|
||||
type: 'group',
|
||||
openConversationId: nonEmptyString(message?.conversationId),
|
||||
robotCode,
|
||||
};
|
||||
}
|
||||
return { type: 'user', userId: sender, robotCode };
|
||||
}
|
||||
|
||||
function artifactFailureText(fileName, error) {
|
||||
const name = String(fileName ?? '结果文件').replace(/[\r\n]+/g, ' ').trim() || '结果文件';
|
||||
switch (error?.code) {
|
||||
case 'artifact-delivery-uncertain':
|
||||
return `结果文件「${name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
|
||||
case 'artifact-permission-required':
|
||||
return `结果文件「${name}」已生成,但钉钉应用或机器人缺少文件消息权限。请开通应用 qyapi_base 权限,并确认机器人具备文件消息发送能力。`;
|
||||
case 'artifact-too-large':
|
||||
return `结果文件「${name}」超过当前钉钉机器人可发送的文件大小,未发送。`;
|
||||
case 'artifact-rate-limited':
|
||||
return `结果文件「${name}」暂时被钉钉限流,未能发送,请稍后重试。`;
|
||||
case 'artifact-provider-rejected':
|
||||
return `结果文件「${name}」已生成,但钉钉拒绝了该文件消息,请检查文件类型和机器人文件消息配置。`;
|
||||
case 'artifact-invalid':
|
||||
case 'artifact-changed':
|
||||
case 'artifact-unavailable':
|
||||
return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
|
||||
default:
|
||||
return `结果文件「${name}」已生成,但暂时未能通过钉钉发送,请稍后重试。`;
|
||||
}
|
||||
}
|
||||
|
||||
function progressText(update) {
|
||||
if (update?.type === 'text' && nonEmptyString(update.text)) return update.text;
|
||||
if (update?.type === 'tool') {
|
||||
|
|
@ -596,7 +647,7 @@ export class DingtalkHarnessBridge {
|
|||
});
|
||||
cardStarted = await cardStream.start(CARD_INITIAL_TEXT);
|
||||
}
|
||||
const { answer } = await askInWorkspaceSession({
|
||||
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
|
|
@ -619,11 +670,41 @@ export class DingtalkHarnessBridge {
|
|||
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
||||
},
|
||||
});
|
||||
const streamed = cardStarted && await cardStream.finish(answer);
|
||||
if (!streamed) await this.#send(sessionWebhook, answer);
|
||||
const answerText = typeof answer === 'string' && answer.trim()
|
||||
? answer
|
||||
: artifacts.length > 0 ? '结果文件已生成。' : answer;
|
||||
let textDeliveryError = null;
|
||||
let textReceipt = null;
|
||||
let streamed = false;
|
||||
try {
|
||||
streamed = cardStarted && await cardStream.finish(answerText);
|
||||
if (streamed) {
|
||||
textReceipt = createDeliveryReceipt({
|
||||
deliveryId: messageId,
|
||||
presentation: 'dingtalk-card',
|
||||
});
|
||||
} else {
|
||||
textReceipt = createDeliveryReceipt({
|
||||
deliveryId: messageId,
|
||||
presentation: 'dingtalk-text',
|
||||
providerMessageIds: await this.#send(sessionWebhook, answerText),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
textDeliveryError = error;
|
||||
}
|
||||
const delivery = await this.#deliverArtifacts(
|
||||
fileTarget(message, sender, this.#clientId),
|
||||
sessionWebhook,
|
||||
messageId,
|
||||
artifacts,
|
||||
textReceipt,
|
||||
);
|
||||
if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
|
||||
increment(this.#status, 'messagesReplied');
|
||||
this.#status.lastReplyAt = new Date().toISOString();
|
||||
this.#status.lastError = null;
|
||||
return delivery.receipt;
|
||||
} catch (error) {
|
||||
if (error?.code === 'turn-stopped') {
|
||||
if (cardStarted) await cardStream.finish('已停止。').catch(() => undefined);
|
||||
|
|
@ -940,16 +1021,86 @@ export class DingtalkHarnessBridge {
|
|||
}
|
||||
|
||||
async #send(sessionWebhook, text) {
|
||||
const providerMessageIds = [];
|
||||
for (const chunk of splitDingtalkText(text, this.#maxMessageChars)) {
|
||||
this.#signal?.throwIfAborted();
|
||||
await this.#api.sendText({
|
||||
const result = await this.#api.sendText({
|
||||
clientId: this.#clientId,
|
||||
clientSecret: this.#clientSecret,
|
||||
sessionWebhook,
|
||||
text: chunk,
|
||||
signal: this.#signal,
|
||||
});
|
||||
providerMessageIds.push(...providerMessageIdsFor(result));
|
||||
}
|
||||
return providerMessageIds;
|
||||
}
|
||||
|
||||
async #deliverArtifacts(target, sessionWebhook, replyTo, artifacts, baseReceipt) {
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
let userVisible = Boolean(baseReceipt);
|
||||
for (const artifact of artifacts) {
|
||||
this.#signal?.throwIfAborted();
|
||||
try {
|
||||
if (typeof this.#api.sendFile !== 'function') {
|
||||
const unavailable = new Error('DingTalk file delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
const file = await materializeOutboundArtifact(artifact, {
|
||||
signal: this.#signal,
|
||||
});
|
||||
const result = await this.#api.sendFile({
|
||||
clientId: this.#clientId,
|
||||
clientSecret: this.#clientSecret,
|
||||
target,
|
||||
file,
|
||||
signal: this.#signal,
|
||||
});
|
||||
receipts.push(createDeliveryReceipt({
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'dingtalk-file',
|
||||
providerMessageIds: dingtalkFileProviderIds(result),
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
}));
|
||||
userVisible = true;
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw error;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
||||
this.#logger.warn?.(
|
||||
`[dsh-dingtalk] result file delivery failed (${error?.code ?? 'unknown'})`,
|
||||
);
|
||||
let noticeSent = false;
|
||||
const providerMessageIds = await this.#send(
|
||||
sessionWebhook,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
).then((ids) => {
|
||||
noticeSent = true;
|
||||
return ids;
|
||||
}).catch(() => []);
|
||||
const failureReceipt = createArtifactFailureReceipt({
|
||||
artifactId: artifact?.artifactId ?? 'unknown',
|
||||
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
||||
error,
|
||||
providerMessageIds,
|
||||
});
|
||||
receipts.push(failureReceipt);
|
||||
if (noticeSent || failureReceipt.artifacts[0]?.outcome === 'unknown') userVisible = true;
|
||||
} finally {
|
||||
releaseOutboundArtifact(artifact);
|
||||
}
|
||||
}
|
||||
const receipt = receipts.length === 0
|
||||
? null
|
||||
: receipts.length === 1
|
||||
? receipts[0]
|
||||
: mergeDeliveryReceipts({
|
||||
deliveryId: replyTo,
|
||||
presentation: baseReceipt ? 'dingtalk-text-and-files' : 'dingtalk-files',
|
||||
receipts,
|
||||
});
|
||||
return { receipt, userVisible };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://discord.com/api/v10/';
|
||||
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
||||
const DISCORD_PERMISSION_ERRORS = new Set([50001, 50013]);
|
||||
const DISCORD_TOO_LARGE_ERRORS = new Set([40005]);
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
|
|
@ -9,6 +14,58 @@ function requestSignal(signal, timeoutMs) {
|
|||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
}
|
||||
|
||||
function abortReason(signal) {
|
||||
return signal?.reason instanceof Error
|
||||
? signal.reason
|
||||
: new DOMException('The operation was aborted', 'AbortError');
|
||||
}
|
||||
|
||||
function positiveTimeout(value, name) {
|
||||
if (!Number.isInteger(value) || value < 1) throw new TypeError(`${name} must be a positive integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function preserveProviderMetadata(target, source) {
|
||||
if (source?.providerCode !== undefined) target.providerCode = source.providerCode;
|
||||
if (source?.retry_after !== undefined) {
|
||||
target.retry_after = source.retry_after;
|
||||
target.retryAfter = source.retry_after;
|
||||
}
|
||||
if (Number.isInteger(source?.status)) target.status = source.status;
|
||||
return target;
|
||||
}
|
||||
|
||||
function discordArtifactProviderError(cause) {
|
||||
const providerCode = Number(cause?.providerCode);
|
||||
const status = Number(cause?.status);
|
||||
const message = cleanString(cause?.message) ?? '';
|
||||
let code = 'artifact-provider-rejected';
|
||||
let summary = 'Discord rejected the attachment.';
|
||||
if (status === 401 || status === 403 || DISCORD_PERMISSION_ERRORS.has(providerCode)) {
|
||||
code = 'artifact-permission-required';
|
||||
summary = 'Discord denied permission to send the attachment.';
|
||||
} else if (status === 413 || DISCORD_TOO_LARGE_ERRORS.has(providerCode)
|
||||
|| /(?:request|attachment|file).{0,24}too large/i.test(message)) {
|
||||
code = 'artifact-too-large';
|
||||
summary = 'The attachment exceeds Discord\'s size limit.';
|
||||
} else if (status === 429) {
|
||||
code = 'artifact-rate-limited';
|
||||
summary = 'Discord rate-limited attachment delivery.';
|
||||
} else if (status >= 500) {
|
||||
code = 'artifact-delivery-uncertain';
|
||||
summary = 'Discord attachment delivery result is uncertain.';
|
||||
}
|
||||
const error = new Error(summary, { cause });
|
||||
error.code = code;
|
||||
return preserveProviderMetadata(error, cause);
|
||||
}
|
||||
|
||||
function uncertainDiscordDelivery(cause) {
|
||||
const error = new Error('Discord attachment delivery result is uncertain', { cause });
|
||||
error.code = 'artifact-delivery-uncertain';
|
||||
return preserveProviderMetadata(error, cause);
|
||||
}
|
||||
|
||||
function delay(ms, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
|
|
@ -39,13 +96,20 @@ export class DiscordApi {
|
|||
#token;
|
||||
#fetch;
|
||||
#baseUrl;
|
||||
#fileUploadTimeoutMs;
|
||||
|
||||
constructor({ token, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) {
|
||||
constructor({
|
||||
token,
|
||||
fetchImpl = fetch,
|
||||
baseUrl = DEFAULT_BASE_URL,
|
||||
fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
|
||||
}) {
|
||||
if (!validDiscordToken(token)) throw new TypeError('Discord Bot Token is invalid');
|
||||
if (typeof fetchImpl !== 'function') throw new TypeError('DiscordApi requires fetch');
|
||||
this.#token = token.trim();
|
||||
this.#fetch = fetchImpl;
|
||||
this.#baseUrl = new URL(baseUrl);
|
||||
this.#fileUploadTimeoutMs = positiveTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
|
||||
}
|
||||
|
||||
getCurrentUser(options = {}) {
|
||||
|
|
@ -74,6 +138,54 @@ export class DiscordApi {
|
|||
});
|
||||
}
|
||||
|
||||
async createFileMessage({ channelId, file, replyToMessageId, signal }) {
|
||||
if (!file || typeof file !== 'object'
|
||||
|| typeof file.fileName !== 'string' || !file.fileName
|
||||
|| !Buffer.isBuffer(file.bytes)) {
|
||||
throw new TypeError('A Discord attachment is required');
|
||||
}
|
||||
const deliverySeed = cleanString(file.deliveryKey) ?? cleanString(file.artifactId);
|
||||
const nonce = deliverySeed
|
||||
? createHash('sha256').update(deliverySeed).digest('hex').slice(0, 25)
|
||||
: undefined;
|
||||
const payload = new FormData();
|
||||
payload.append('payload_json', JSON.stringify({
|
||||
allowed_mentions: { parse: [], replied_user: false },
|
||||
attachments: [{ id: 0, filename: file.fileName }],
|
||||
...(nonce ? { nonce, enforce_nonce: true } : {}),
|
||||
...(replyToMessageId ? {
|
||||
message_reference: {
|
||||
message_id: snowflake(replyToMessageId, 'message id'),
|
||||
channel_id: snowflake(channelId, 'channel id'),
|
||||
fail_if_not_exists: false,
|
||||
},
|
||||
} : {}),
|
||||
}));
|
||||
payload.append(
|
||||
'files[0]',
|
||||
new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
|
||||
file.fileName,
|
||||
);
|
||||
const targetChannelId = snowflake(channelId, 'channel id');
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
const uploadSignal = requestSignal(signal, this.#fileUploadTimeoutMs);
|
||||
try {
|
||||
return await this.#request(`channels/${targetChannelId}/messages`, {
|
||||
method: 'POST',
|
||||
signal: uploadSignal,
|
||||
timeoutMs: this.#fileUploadTimeoutMs,
|
||||
body: payload,
|
||||
multipart: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
if (error?.code?.startsWith?.('discord-')) {
|
||||
throw discordArtifactProviderError(error);
|
||||
}
|
||||
throw uncertainDiscordDelivery(error);
|
||||
}
|
||||
}
|
||||
|
||||
editMessage({ channelId, messageId, content, signal }) {
|
||||
return this.#request(
|
||||
`channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}`,
|
||||
|
|
@ -100,6 +212,7 @@ export class DiscordApi {
|
|||
timeoutMs = 15_000,
|
||||
expectBody = true,
|
||||
retry = true,
|
||||
multipart = false,
|
||||
}) {
|
||||
let response;
|
||||
try {
|
||||
|
|
@ -107,10 +220,10 @@ export class DiscordApi {
|
|||
method,
|
||||
headers: {
|
||||
authorization: `Bot ${this.#token}`,
|
||||
'content-type': 'application/json',
|
||||
...(multipart ? {} : { 'content-type': 'application/json' }),
|
||||
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 1.0.2)',
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
...(body === undefined ? {} : { body: multipart ? body : JSON.stringify(body) }),
|
||||
signal: requestSignal(signal, timeoutMs),
|
||||
redirect: 'error',
|
||||
});
|
||||
|
|
@ -124,17 +237,32 @@ export class DiscordApi {
|
|||
try {
|
||||
parsed = await response.json();
|
||||
} catch {
|
||||
if (expectBody) throw new Error(`Discord ${method} returned invalid JSON`);
|
||||
if (expectBody) {
|
||||
const error = new Error(`Discord ${method} returned invalid JSON`);
|
||||
error.status = response?.status;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (response.status === 429 && retry) {
|
||||
const retryAfterMs = Math.min(10_000, Math.max(50, Number(parsed?.retry_after) * 1_000 || 1_000));
|
||||
await delay(retryAfterMs, signal);
|
||||
return this.#request(path, { method, body, signal, timeoutMs, expectBody, retry: false });
|
||||
return this.#request(path, {
|
||||
method, body, signal, timeoutMs, expectBody, retry: false, multipart,
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = new Error(cleanString(parsed?.message) ?? `Discord API failed with HTTP ${response.status}`);
|
||||
error.code = `discord-${response.status}`;
|
||||
error.status = response.status;
|
||||
if (Number.isInteger(parsed?.code) || typeof parsed?.code === 'string') {
|
||||
error.providerCode = parsed.code;
|
||||
}
|
||||
const retryAfter = Number(parsed?.retry_after);
|
||||
if (Number.isFinite(retryAfter) && retryAfter >= 0) {
|
||||
error.retry_after = retryAfter;
|
||||
error.retryAfter = retryAfter;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return expectBody ? parsed : null;
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
|
|||
};
|
||||
}
|
||||
|
||||
class DiscordBotClient {
|
||||
export class DiscordBotClient {
|
||||
#api;
|
||||
#signal;
|
||||
|
||||
|
|
@ -123,22 +123,32 @@ class DiscordBotClient {
|
|||
|
||||
async sendText(target, text) {
|
||||
const chunks = splitMessageText(text, 1_900);
|
||||
let result = null;
|
||||
const providerMessageIds = [];
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
result = await this.#api.createMessage({
|
||||
const result = await this.#api.createMessage({
|
||||
channelId: target.channelId,
|
||||
content: chunk,
|
||||
replyToMessageId: index === 0 ? target.replyToMessageId : undefined,
|
||||
signal: this.#signal,
|
||||
});
|
||||
if (typeof result?.id === 'string' && result.id) providerMessageIds.push(result.id);
|
||||
}
|
||||
return result;
|
||||
return { providerMessageIds };
|
||||
}
|
||||
|
||||
sendTyping(target) {
|
||||
return this.#api.sendTyping({ channelId: target.channelId, signal: this.#signal });
|
||||
}
|
||||
|
||||
sendFile(target, file) {
|
||||
return this.#api.createFileMessage({
|
||||
channelId: target.channelId,
|
||||
file,
|
||||
replyToMessageId: target.replyToMessageId,
|
||||
signal: this.#signal,
|
||||
});
|
||||
}
|
||||
|
||||
async openStream(target) {
|
||||
const stream = createEditableMessageStream({
|
||||
limit: 1_900,
|
||||
|
|
@ -162,6 +172,7 @@ class DiscordBotClient {
|
|||
content,
|
||||
signal: this.#signal,
|
||||
}),
|
||||
messageIdForResult: (message) => message?.id,
|
||||
});
|
||||
return stream.start();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,15 @@ import {
|
|||
} from '../shared/preset-command.mjs';
|
||||
import { runWorkspaceCommand, resolveSessionListWorkspace, workspacePathSnapshot } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
import {
|
||||
materializeOutboundArtifact,
|
||||
releaseOutboundArtifact,
|
||||
} from '../shared/semantic/artifact.mjs';
|
||||
import {
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
import {
|
||||
MENU_PAGE_SIZE,
|
||||
completionCard,
|
||||
|
|
@ -126,6 +135,33 @@ function safeErrorText(error) {
|
|||
}
|
||||
}
|
||||
|
||||
function artifactFailureText(fileName, error) {
|
||||
const name = String(fileName ?? '结果文件').replace(/[\r\n]+/g, ' ').trim() || '结果文件';
|
||||
switch (error?.code) {
|
||||
case 'artifact-permission-required':
|
||||
return `结果文件「${name}」已生成,但机器人缺少飞书文件上传权限。请为应用添加 im:resource 并完成必要审批后重试。`;
|
||||
case 'artifact-too-large':
|
||||
return `结果文件「${name}」超过飞书 30 MB 上限,未发送。`;
|
||||
case 'artifact-empty':
|
||||
return `结果文件「${name}」为空,飞书不允许发送空文件。`;
|
||||
case 'artifact-changed':
|
||||
case 'artifact-invalid':
|
||||
case 'artifact-unavailable':
|
||||
return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
|
||||
case 'artifact-rate-limited':
|
||||
return `结果文件「${name}」暂时被飞书限流,未能发送,请稍后重试。`;
|
||||
case 'artifact-delivery-uncertain':
|
||||
return `结果文件「${name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
|
||||
default:
|
||||
return `结果文件「${name}」已生成,但暂时未能发送,请稍后重试。`;
|
||||
}
|
||||
}
|
||||
|
||||
function answerTextForDelivery(answer, artifacts) {
|
||||
if (typeof answer === 'string' && answer.trim()) return answer;
|
||||
return artifacts.length > 0 ? '结果文件已生成。' : answer;
|
||||
}
|
||||
|
||||
function nonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
|
@ -540,7 +576,10 @@ export class FeishuHarnessBridge {
|
|||
.then(() => this.#handle(event, key, { alreadyRecorded }));
|
||||
const settled = finalize
|
||||
? work
|
||||
.then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
|
||||
.then(async (receipt) => {
|
||||
await this.#finishReaction(messageId, processingReaction, 'DONE');
|
||||
return receipt;
|
||||
})
|
||||
.catch((error) => this.#handleMessageFailure(
|
||||
event,
|
||||
messageId,
|
||||
|
|
@ -736,10 +775,11 @@ export class FeishuHarnessBridge {
|
|||
|
||||
this.#logger.info?.(`[dsh-feishu] processing ${event.message.chat_type} message ${messageId}`);
|
||||
try {
|
||||
await this.#answerWithStream(event, key, message);
|
||||
const receipt = await this.#answerWithStream(event, key, message);
|
||||
this.#status.messagesReplied += 1;
|
||||
this.#status.lastReplyAt = new Date().toISOString();
|
||||
this.#status.lastError = null;
|
||||
return receipt;
|
||||
} finally {
|
||||
await this.#cancelPendingInteraction(key);
|
||||
await this.#approvals.closeRoute(key);
|
||||
|
|
@ -1578,6 +1618,81 @@ export class FeishuHarnessBridge {
|
|||
};
|
||||
}
|
||||
|
||||
async #sendAnswerText(chatId, answer, { deliveryId, presentation }) {
|
||||
const providerMessageIds = [];
|
||||
for (const chunk of splitText(answer)) {
|
||||
this.#signal?.throwIfAborted();
|
||||
const messageId = await this.#send(chatId, chunk);
|
||||
if (messageId) providerMessageIds.push(messageId);
|
||||
}
|
||||
return createDeliveryReceipt({
|
||||
deliveryId,
|
||||
presentation,
|
||||
providerMessageIds,
|
||||
});
|
||||
}
|
||||
|
||||
async #deliverArtifacts(chatId, replyTo, artifacts = [], baseReceipt) {
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
let failureNoticeVisible = false;
|
||||
for (const artifact of artifacts) {
|
||||
this.#signal?.throwIfAborted();
|
||||
try {
|
||||
if (typeof this.#channel?.sendFile !== 'function') {
|
||||
const unavailable = new Error('Feishu file delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
const file = await materializeOutboundArtifact(artifact, {
|
||||
signal: this.#signal,
|
||||
});
|
||||
receipts.push(await this.#channel.sendFile(chatId, file, {
|
||||
replyTo,
|
||||
signal: this.#signal,
|
||||
}));
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw error;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
||||
this.#logger.warn?.(
|
||||
`[dsh-feishu] result file delivery failed (${error?.code ?? 'unknown'})`,
|
||||
);
|
||||
let noticeMessageId = null;
|
||||
try {
|
||||
noticeMessageId = await this.#send(chatId, artifactFailureText(artifact?.fileName, error));
|
||||
failureNoticeVisible = true;
|
||||
} catch {
|
||||
this.#logger.warn?.('[dsh-feishu] unable to send the safe result-file failure notice');
|
||||
}
|
||||
receipts.push(createArtifactFailureReceipt({
|
||||
artifactId: artifact?.artifactId ?? 'unknown',
|
||||
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
||||
error,
|
||||
providerMessageIds: noticeMessageId ? [noticeMessageId] : [],
|
||||
}));
|
||||
} finally {
|
||||
releaseOutboundArtifact(artifact);
|
||||
}
|
||||
}
|
||||
if (receipts.length === 0) {
|
||||
return {
|
||||
receipt: createDeliveryReceipt({
|
||||
deliveryId: replyTo,
|
||||
presentation: 'feishu-files',
|
||||
}),
|
||||
failureNoticeVisible,
|
||||
};
|
||||
}
|
||||
const receipt = receipts.length === 1
|
||||
? receipts[0]
|
||||
: mergeDeliveryReceipts({
|
||||
deliveryId: baseReceipt?.deliveryId ?? artifacts[0]?.deliveryKey ?? replyTo,
|
||||
presentation: baseReceipt ? 'feishu-text-and-files' : 'feishu-files',
|
||||
receipts,
|
||||
});
|
||||
return { receipt, failureNoticeVisible };
|
||||
}
|
||||
|
||||
async #answerWithStream(event, key, message) {
|
||||
const chatId = event.message.chat_id;
|
||||
const messageId = event.message.message_id;
|
||||
|
|
@ -1586,7 +1701,7 @@ export class FeishuHarnessBridge {
|
|||
? await promptContentForMessage(message, { signal: this.#signal })
|
||||
: undefined;
|
||||
if (!this.#channel?.stream) {
|
||||
const { answer } = await askInWorkspaceSession({
|
||||
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
|
|
@ -1596,15 +1711,41 @@ export class FeishuHarnessBridge {
|
|||
existsOptions: { signal: this.#signal },
|
||||
askOptions: this.#interactionAskOptions(event, key),
|
||||
});
|
||||
for (const chunk of splitText(answer)) await this.#send(chatId, chunk);
|
||||
let textReceipt;
|
||||
let textSendError = null;
|
||||
try {
|
||||
textReceipt = await this.#sendAnswerText(
|
||||
chatId,
|
||||
answerTextForDelivery(answer, artifacts),
|
||||
{
|
||||
deliveryId: messageId,
|
||||
presentation: 'feishu-text',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
textSendError = error;
|
||||
this.#logger.warn?.(
|
||||
'[dsh-feishu] final text delivery failed; continuing with result files:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
const delivery = await this.#deliverArtifacts(chatId, messageId, artifacts, textReceipt);
|
||||
const artifactDispatched = delivery.receipt.artifacts.some(
|
||||
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
|
||||
);
|
||||
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
|
||||
throw textSendError;
|
||||
}
|
||||
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
||||
return;
|
||||
return delivery.receipt;
|
||||
}
|
||||
|
||||
let promptStarted = false;
|
||||
let completedAnswer = '';
|
||||
let completedArtifacts = [];
|
||||
let stream;
|
||||
try {
|
||||
await this.#channel.stream(chatId, {
|
||||
stream = await this.#channel.stream(chatId, {
|
||||
markdown: async (controller) => {
|
||||
promptStarted = true;
|
||||
const askOptions = {
|
||||
|
|
@ -1614,7 +1755,7 @@ export class FeishuHarnessBridge {
|
|||
this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
|
||||
},
|
||||
};
|
||||
({ answer: completedAnswer } = await askInWorkspaceSession({
|
||||
const completed = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
|
|
@ -1623,26 +1764,56 @@ export class FeishuHarnessBridge {
|
|||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions,
|
||||
}));
|
||||
await controller.setContent(completedAnswer);
|
||||
});
|
||||
completedAnswer = completed.answer;
|
||||
completedArtifacts = completed.artifacts ?? [];
|
||||
await controller.setContent(answerTextForDelivery(completedAnswer, completedArtifacts));
|
||||
},
|
||||
}, { replyTo: messageId });
|
||||
this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
|
||||
} catch (error) {
|
||||
this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1;
|
||||
if (completedAnswer) {
|
||||
if (completedAnswer || completedArtifacts.length > 0) {
|
||||
this.#logger.warn?.(
|
||||
'[dsh-feishu] native stream failed after generation; sending final text:',
|
||||
error.message,
|
||||
);
|
||||
for (const chunk of splitText(completedAnswer)) await this.#send(chatId, chunk);
|
||||
let textReceipt;
|
||||
let textSendError = null;
|
||||
try {
|
||||
textReceipt = await this.#sendAnswerText(
|
||||
chatId,
|
||||
answerTextForDelivery(completedAnswer, completedArtifacts),
|
||||
{
|
||||
deliveryId: messageId,
|
||||
presentation: 'feishu-text-fallback',
|
||||
},
|
||||
);
|
||||
} catch (fallbackError) {
|
||||
textSendError = fallbackError;
|
||||
this.#logger.warn?.(
|
||||
'[dsh-feishu] fallback text delivery failed; continuing with result files:',
|
||||
fallbackError,
|
||||
);
|
||||
}
|
||||
const delivery = await this.#deliverArtifacts(
|
||||
chatId,
|
||||
messageId,
|
||||
completedArtifacts,
|
||||
textReceipt,
|
||||
);
|
||||
const artifactDispatched = delivery.receipt.artifacts.some(
|
||||
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
|
||||
);
|
||||
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
|
||||
throw textSendError;
|
||||
}
|
||||
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
||||
return;
|
||||
return delivery.receipt;
|
||||
}
|
||||
if (promptStarted) throw error;
|
||||
|
||||
this.#logger.warn?.('[dsh-feishu] native stream unavailable; using text fallback:', error.message);
|
||||
const { answer } = await askInWorkspaceSession({
|
||||
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
|
|
@ -1652,9 +1823,46 @@ export class FeishuHarnessBridge {
|
|||
existsOptions: { signal: this.#signal },
|
||||
askOptions: this.#interactionAskOptions(event, key),
|
||||
});
|
||||
for (const chunk of splitText(answer)) await this.#send(chatId, chunk);
|
||||
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
||||
let textReceipt;
|
||||
let textSendError = null;
|
||||
try {
|
||||
textReceipt = await this.#sendAnswerText(
|
||||
chatId,
|
||||
answerTextForDelivery(answer, artifacts),
|
||||
{
|
||||
deliveryId: messageId,
|
||||
presentation: 'feishu-text-fallback',
|
||||
},
|
||||
);
|
||||
} catch (fallbackError) {
|
||||
textSendError = fallbackError;
|
||||
this.#logger.warn?.(
|
||||
'[dsh-feishu] fallback text delivery failed; continuing with result files:',
|
||||
fallbackError,
|
||||
);
|
||||
}
|
||||
const delivery = await this.#deliverArtifacts(chatId, messageId, artifacts, textReceipt);
|
||||
const artifactDispatched = delivery.receipt.artifacts.some(
|
||||
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
|
||||
);
|
||||
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
|
||||
throw textSendError;
|
||||
}
|
||||
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
||||
return delivery.receipt;
|
||||
}
|
||||
const delivery = await this.#deliverArtifacts(
|
||||
chatId,
|
||||
messageId,
|
||||
completedArtifacts,
|
||||
createDeliveryReceipt({
|
||||
deliveryId: messageId,
|
||||
presentation: 'feishu-cardkit',
|
||||
providerMessageIds: stream?.messageId ? [stream.messageId] : [],
|
||||
}),
|
||||
);
|
||||
this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
|
||||
return delivery.receipt;
|
||||
}
|
||||
|
||||
async #processInteractionReply(event, messageId, key, expected, processingReaction) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,22 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
|
||||
import { createDeliveryReceipt } from '../shared/semantic/delivery.mjs';
|
||||
|
||||
const STREAM_ELEMENT_ID = 'stream_md';
|
||||
const DEFAULT_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
|
||||
const MAX_STREAM_CHARS = 28000;
|
||||
const MAX_FILE_OPERATION_TIMEOUT_MS = 120_000;
|
||||
|
||||
const FILE_DELIVERY_ERRORS = new Map([
|
||||
[99991672, ['artifact-permission-required', 'Feishu file delivery requires the im:resource permission.']],
|
||||
[234006, ['artifact-too-large', 'The result file exceeds Feishu\'s size limit.']],
|
||||
[234010, ['artifact-empty', 'Feishu does not accept empty files.']],
|
||||
[230017, ['artifact-provider-rejected', 'Feishu rejected the uploaded file ownership.']],
|
||||
[230020, ['artifact-rate-limited', 'Feishu temporarily rate-limited file delivery.']],
|
||||
[230049, ['artifact-delivery-uncertain', 'Feishu could not confirm the file message result.']],
|
||||
[230055, ['artifact-provider-rejected', 'Feishu rejected the file message type.']],
|
||||
]);
|
||||
|
||||
function assertApiSuccess(operation, response) {
|
||||
if (response?.code && response.code !== 0) {
|
||||
|
|
@ -9,6 +25,103 @@ function assertApiSuccess(operation, response) {
|
|||
return response;
|
||||
}
|
||||
|
||||
function providerErrorCode(cause) {
|
||||
const pending = [cause];
|
||||
const seen = new Set();
|
||||
let fallback;
|
||||
while (pending.length > 0) {
|
||||
const value = pending.shift();
|
||||
if (!value || seen.has(value)) continue;
|
||||
if (typeof value === 'object') seen.add(value);
|
||||
if (Array.isArray(value)) {
|
||||
pending.push(...value);
|
||||
continue;
|
||||
}
|
||||
const code = Number(value?.code);
|
||||
if (Number.isFinite(code) && code !== 0) {
|
||||
if (FILE_DELIVERY_ERRORS.has(code)) return code;
|
||||
fallback ??= code;
|
||||
}
|
||||
pending.push(value?.response?.data, value?.data, value?.error, value?.cause);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function fileDeliveryError(stage, cause, providerCode, { uncertain = false } = {}) {
|
||||
const explicitCode = providerCode === undefined || providerCode === null
|
||||
? undefined
|
||||
: Number(providerCode);
|
||||
const code = Number.isFinite(explicitCode) && explicitCode !== 0
|
||||
? explicitCode
|
||||
: providerErrorCode(cause);
|
||||
const fallback = Number.isFinite(code)
|
||||
? ['artifact-provider-rejected', `Feishu rejected file ${stage}.`]
|
||||
: uncertain
|
||||
? ['artifact-delivery-uncertain', 'Feishu could not confirm the file message result.']
|
||||
: ['artifact-provider-failed', `Feishu file ${stage} failed.`];
|
||||
const [errorCode, message] = FILE_DELIVERY_ERRORS.get(code) ?? fallback;
|
||||
const error = new Error(message, { cause });
|
||||
error.code = errorCode;
|
||||
if (Number.isFinite(code)) error.providerCode = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function boundedFileTimeout(value, name) {
|
||||
if (!Number.isInteger(value) || value < 1 || value > MAX_FILE_OPERATION_TIMEOUT_MS) {
|
||||
throw new TypeError(`${name} must be an integer between 1 and ${MAX_FILE_OPERATION_TIMEOUT_MS}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function abortReason(signal) {
|
||||
return signal?.reason ?? new DOMException('The operation was aborted', 'AbortError');
|
||||
}
|
||||
|
||||
function operationTimeout(stage) {
|
||||
const error = new Error(`Feishu file ${stage} timed out.`);
|
||||
error.code = 'provider-timeout';
|
||||
return error;
|
||||
}
|
||||
|
||||
function waitForFileOperation(operation, { signal, timeoutMs, stage }) {
|
||||
signal?.throwIfAborted();
|
||||
const deadline = new AbortController();
|
||||
const operationSignal = signal
|
||||
? AbortSignal.any([signal, deadline.signal])
|
||||
: deadline.signal;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
operationSignal.removeEventListener('abort', onAbort);
|
||||
callback(value);
|
||||
};
|
||||
const onAbort = () => finish(
|
||||
reject,
|
||||
signal?.aborted ? abortReason(signal) : operationTimeout(stage),
|
||||
);
|
||||
const timer = setTimeout(() => deadline.abort(), timeoutMs);
|
||||
operationSignal.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
Promise.resolve().then(() => operation(operationSignal)).then(
|
||||
(value) => finish(resolve, value),
|
||||
(error) => finish(reject, error),
|
||||
);
|
||||
if (operationSignal.aborted) onAbort();
|
||||
});
|
||||
}
|
||||
|
||||
function deliveryUuid(file, chatId) {
|
||||
const digest = createHash('sha256')
|
||||
.update(`${file.deliveryKey}\u0000${chatId}`)
|
||||
.digest('hex')
|
||||
.slice(0, 40);
|
||||
return `dshim_${digest}`;
|
||||
}
|
||||
|
||||
function summaryOf(text) {
|
||||
const summary = String(text ?? '').replace(/\s+/g, ' ').trim();
|
||||
return summary.length <= 50 ? summary : `${summary.slice(0, 49)}…`;
|
||||
|
|
@ -39,10 +152,19 @@ function streamingCard(initialText) {
|
|||
export class VerifiedFeishuChannel {
|
||||
#client;
|
||||
#initialText;
|
||||
#fileUploadTimeoutMs;
|
||||
#fileMessageTimeoutMs;
|
||||
|
||||
constructor({ client, initialText = DEFAULT_INITIAL_TEXT }) {
|
||||
constructor({
|
||||
client,
|
||||
initialText = DEFAULT_INITIAL_TEXT,
|
||||
fileUploadTimeoutMs = MAX_FILE_OPERATION_TIMEOUT_MS,
|
||||
fileMessageTimeoutMs = MAX_FILE_OPERATION_TIMEOUT_MS,
|
||||
}) {
|
||||
this.#client = client;
|
||||
this.#initialText = initialText;
|
||||
this.#fileUploadTimeoutMs = boundedFileTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
|
||||
this.#fileMessageTimeoutMs = boundedFileTimeout(fileMessageTimeoutMs, 'fileMessageTimeoutMs');
|
||||
}
|
||||
|
||||
async stream(chatId, input, options = {}) {
|
||||
|
|
@ -107,6 +229,110 @@ export class VerifiedFeishuChannel {
|
|||
}
|
||||
}
|
||||
|
||||
async sendFile(chatId, file, { replyTo, signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
if (typeof chatId !== 'string' || !chatId) throw new TypeError('chatId is required');
|
||||
if (!file || typeof file !== 'object'
|
||||
|| typeof file.artifactId !== 'string' || !file.artifactId
|
||||
|| typeof file.deliveryKey !== 'string' || !file.deliveryKey
|
||||
|| typeof file.fileName !== 'string' || !file.fileName
|
||||
|| !Buffer.isBuffer(file.bytes)) {
|
||||
throw new TypeError('A materialized result file is required');
|
||||
}
|
||||
let uploaded;
|
||||
try {
|
||||
uploaded = await waitForFileOperation((operationSignal) => {
|
||||
operationSignal.throwIfAborted();
|
||||
const pending = this.#client.im.v1.file.create({
|
||||
data: {
|
||||
file_type: 'stream',
|
||||
file_name: file.fileName,
|
||||
file: file.bytes,
|
||||
},
|
||||
});
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
return pending;
|
||||
}, {
|
||||
signal,
|
||||
timeoutMs: this.#fileUploadTimeoutMs,
|
||||
stage: 'upload',
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
throw fileDeliveryError('upload', error);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const fileKey = uploaded?.file_key;
|
||||
if (typeof fileKey !== 'string' || !fileKey) {
|
||||
throw fileDeliveryError('upload', undefined, uploaded?.code);
|
||||
}
|
||||
|
||||
const uuid = deliveryUuid(file, chatId);
|
||||
const content = JSON.stringify({ file_key: fileKey });
|
||||
const request = replyTo
|
||||
? {
|
||||
path: { message_id: replyTo },
|
||||
data: { msg_type: 'file', content, uuid },
|
||||
}
|
||||
: {
|
||||
params: { receive_id_type: 'chat_id' },
|
||||
data: { receive_id: chatId, msg_type: 'file', content, uuid },
|
||||
};
|
||||
const send = () => {
|
||||
const pending = replyTo
|
||||
? this.#client.im.v1.message.reply(request)
|
||||
: this.#client.im.v1.message.create(request);
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
return pending;
|
||||
};
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await waitForFileOperation(async (operationSignal) => {
|
||||
operationSignal.throwIfAborted();
|
||||
let result;
|
||||
try {
|
||||
result = await send();
|
||||
} catch (error) {
|
||||
if (providerErrorCode(error) !== 230049) throw error;
|
||||
result = { code: 230049 };
|
||||
}
|
||||
operationSignal.throwIfAborted();
|
||||
|
||||
// Feishu documents 230049 as an uncertain asynchronous send result.
|
||||
// Reuse the same file_key and UUID once so the provider can deduplicate.
|
||||
if (Number(result?.code) === 230049) {
|
||||
result = await send();
|
||||
operationSignal.throwIfAborted();
|
||||
}
|
||||
return result;
|
||||
}, {
|
||||
signal,
|
||||
timeoutMs: this.#fileMessageTimeoutMs,
|
||||
stage: 'message send',
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
throw fileDeliveryError('message send', error, undefined, { uncertain: true });
|
||||
}
|
||||
if (Number.isFinite(Number(response?.code)) && Number(response.code) !== 0) {
|
||||
throw fileDeliveryError('message send', undefined, response.code, { uncertain: true });
|
||||
}
|
||||
const messageId = response?.data?.message_id;
|
||||
if (typeof messageId !== 'string' || !messageId) {
|
||||
throw fileDeliveryError('message send', undefined, undefined, { uncertain: true });
|
||||
}
|
||||
return createDeliveryReceipt({
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: [messageId],
|
||||
artifacts: [{
|
||||
artifactId: file.artifactId,
|
||||
outcome: 'sent',
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
async #sendCard(chatId, cardId, replyTo) {
|
||||
const content = JSON.stringify({ type: 'card', data: { card_id: cardId } });
|
||||
const response = replyTo
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export const REQUIRED_TENANT_SCOPES = Object.freeze([
|
|||
'im:message:send_as_bot',
|
||||
'im:message.reactions:write_only',
|
||||
'im:message:recall',
|
||||
'im:resource',
|
||||
'cardkit:card:write',
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -26,8 +26,20 @@ import {
|
|||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
import {
|
||||
materializeOutboundArtifact,
|
||||
releaseOutboundArtifact,
|
||||
trackOutboundArtifactProviderPromise,
|
||||
} from '../shared/semantic/artifact.mjs';
|
||||
import {
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
providerMessageIdsFor,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
|
||||
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
||||
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
||||
|
||||
export const QQ_IMAGE_HOSTS = Object.freeze([
|
||||
'.myqcloud.com',
|
||||
|
|
@ -120,6 +132,82 @@ function nonEmptyString(value) {
|
|||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function artifactFailureText(fileName, error) {
|
||||
const name = String(fileName ?? '结果文件').replace(/[\r\n]+/g, ' ').trim() || '结果文件';
|
||||
if (error?.name === 'UploadDailyLimitExceededError') {
|
||||
return `结果文件「${name}」已生成,但 QQ 今日文件上传额度已用完,请稍后重试。`;
|
||||
}
|
||||
switch (error?.code) {
|
||||
case 'artifact-delivery-uncertain':
|
||||
return `结果文件「${name}」的发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
|
||||
case 'artifact-permission-required':
|
||||
return `结果文件「${name}」已生成,但当前 QQ 机器人没有文件消息权限。`;
|
||||
case 'artifact-too-large':
|
||||
return `结果文件「${name}」超过当前 QQ 机器人可发送的文件大小,未发送。`;
|
||||
case 'artifact-empty':
|
||||
return `结果文件「${name}」为空,QQ 不允许发送空文件。`;
|
||||
case 'artifact-changed':
|
||||
case 'artifact-invalid':
|
||||
case 'artifact-unavailable':
|
||||
return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
|
||||
case 'artifact-rate-limited':
|
||||
return `结果文件「${name}」暂时被 QQ 限流,未能发送,请稍后重试。`;
|
||||
case 'artifact-provider-rejected':
|
||||
return `结果文件「${name}」已生成,但 QQ 拒绝了该文件或文件消息。`;
|
||||
default:
|
||||
return `结果文件「${name}」已生成,但暂时未能通过 QQ 发送,请稍后重试。`;
|
||||
}
|
||||
}
|
||||
|
||||
function answerTextForDelivery(answer, artifacts) {
|
||||
if (typeof answer === 'string' && answer.trim()) return answer;
|
||||
return artifacts.length > 0 ? '结果文件已生成。' : answer;
|
||||
}
|
||||
|
||||
function qqArtifactError(error, { dispatched = false } = {}) {
|
||||
if (error?.code?.startsWith?.('artifact-') || error?.name === 'UploadDailyLimitExceededError') {
|
||||
return error;
|
||||
}
|
||||
const status = Number(error?.httpStatus);
|
||||
const wrapped = new Error('QQ file delivery failed', { cause: error });
|
||||
if (status === 401 || status === 403) wrapped.code = 'artifact-permission-required';
|
||||
else if (status === 413) wrapped.code = 'artifact-too-large';
|
||||
else if (status === 429) wrapped.code = 'artifact-rate-limited';
|
||||
else if (status === 400 || status === 404) {
|
||||
wrapped.code = 'artifact-provider-rejected';
|
||||
} else {
|
||||
wrapped.code = dispatched ? 'artifact-delivery-uncertain' : 'artifact-provider-failed';
|
||||
}
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
function abortReason(signal) {
|
||||
return signal?.reason instanceof Error
|
||||
? signal.reason
|
||||
: new DOMException('The operation was aborted', 'AbortError');
|
||||
}
|
||||
|
||||
function waitWithSignal(promise, signal) {
|
||||
if (!signal) return promise;
|
||||
signal.throwIfAborted();
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
callback(value);
|
||||
};
|
||||
const onAbort = () => finish(reject, abortReason(signal));
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
Promise.resolve(promise).then(
|
||||
(value) => finish(resolve, value),
|
||||
(error) => finish(reject, error),
|
||||
);
|
||||
if (signal.aborted) onAbort();
|
||||
});
|
||||
}
|
||||
|
||||
function canClaimInteractionReply(message, pending) {
|
||||
return pending.questions[pending.index]
|
||||
&& nonEmptyString(message?.senderId) === pending.actor
|
||||
|
|
@ -133,6 +221,8 @@ export function createQqBridgeStatus() {
|
|||
messagesReceived: 0,
|
||||
messagesReplied: 0,
|
||||
messagesRejected: 0,
|
||||
artifactsSent: 0,
|
||||
artifactSendErrors: 0,
|
||||
lastMessageAt: null,
|
||||
lastReplyAt: null,
|
||||
lastRejectedAt: null,
|
||||
|
|
@ -150,6 +240,7 @@ export class QqHarnessBridge {
|
|||
#replyTimeoutMs;
|
||||
#signal;
|
||||
#fetchImpl;
|
||||
#fileUploadTimeoutMs;
|
||||
#queues = new Map();
|
||||
#pendingInteractions = new Map();
|
||||
#interactionKeys = new Map();
|
||||
|
|
@ -168,11 +259,15 @@ export class QqHarnessBridge {
|
|||
replyTimeoutMs = 600_000,
|
||||
signal,
|
||||
fetchImpl = fetch,
|
||||
fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
|
||||
}) {
|
||||
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');
|
||||
if (!Number.isInteger(fileUploadTimeoutMs) || fileUploadTimeoutMs < 1) {
|
||||
throw new TypeError('fileUploadTimeoutMs must be a positive integer');
|
||||
}
|
||||
this.#bot = bot;
|
||||
this.#ownerUserOpenid = ownerUserOpenid;
|
||||
this.#harness = harness;
|
||||
|
|
@ -182,6 +277,7 @@ export class QqHarnessBridge {
|
|||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#signal = signal;
|
||||
this.#fetchImpl = fetchImpl;
|
||||
this.#fileUploadTimeoutMs = Math.min(fileUploadTimeoutMs, DEFAULT_FILE_UPLOAD_TIMEOUT_MS);
|
||||
this.#approvals = new HarnessApprovalQueue({ label: 'qq', logger });
|
||||
}
|
||||
|
||||
|
|
@ -343,6 +439,87 @@ export class QqHarnessBridge {
|
|||
this.#status.lastError = null;
|
||||
}
|
||||
|
||||
async #deliverArtifacts(target, replyTo, artifacts = [], baseReceipt = null) {
|
||||
if (artifacts.length === 0) {
|
||||
return { receipt: baseReceipt, failureNoticeVisible: false };
|
||||
}
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
let failureNoticeVisible = false;
|
||||
for (const artifact of artifacts) {
|
||||
this.#signal?.throwIfAborted();
|
||||
try {
|
||||
if (typeof this.#bot.sendFile !== 'function') {
|
||||
const unavailable = new Error('QQ file delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
const file = await materializeOutboundArtifact(artifact, {
|
||||
signal: this.#signal,
|
||||
});
|
||||
this.#signal?.throwIfAborted();
|
||||
let result;
|
||||
try {
|
||||
const timeout = AbortSignal.timeout(this.#fileUploadTimeoutMs);
|
||||
const waitSignal = this.#signal ? AbortSignal.any([this.#signal, timeout]) : timeout;
|
||||
const pending = this.#bot.sendFile(
|
||||
target,
|
||||
{ buffer: file.bytes },
|
||||
{
|
||||
fileName: file.fileName,
|
||||
onProgress: () => this.#signal?.throwIfAborted(),
|
||||
},
|
||||
);
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
result = await waitWithSignal(pending, waitSignal);
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw abortReason(this.#signal);
|
||||
throw qqArtifactError(error, { dispatched: true });
|
||||
}
|
||||
this.#signal?.throwIfAborted();
|
||||
const messageId = nonEmptyString(result?.message?.id);
|
||||
receipts.push(createDeliveryReceipt({
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'qq-file',
|
||||
providerMessageIds: messageId ? [messageId] : [],
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
}));
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
||||
} catch (rawError) {
|
||||
if (this.#signal?.aborted) throw rawError;
|
||||
const error = qqArtifactError(rawError);
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:qq] result file delivery failed (${error?.code ?? error?.name ?? 'unknown'})`,
|
||||
);
|
||||
let providerMessageIds = [];
|
||||
try {
|
||||
const notice = await this.#bot.sendText(target, artifactFailureText(artifact?.fileName, error));
|
||||
failureNoticeVisible = true;
|
||||
providerMessageIds = providerMessageIdsFor(notice);
|
||||
} catch (noticeError) {
|
||||
if (this.#signal?.aborted) throw noticeError;
|
||||
this.#logger.warn?.('[dsh-im:qq] unable to send the safe result-file failure notice');
|
||||
}
|
||||
receipts.push(createArtifactFailureReceipt({
|
||||
artifactId: artifact?.artifactId ?? 'unknown',
|
||||
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
||||
error,
|
||||
providerMessageIds,
|
||||
}));
|
||||
} finally {
|
||||
releaseOutboundArtifact(artifact);
|
||||
}
|
||||
}
|
||||
return {
|
||||
receipt: mergeDeliveryReceipts({
|
||||
deliveryId: replyTo,
|
||||
presentation: baseReceipt ? 'qq-text-and-files' : 'qq-files',
|
||||
receipts,
|
||||
}),
|
||||
failureNoticeVisible,
|
||||
};
|
||||
}
|
||||
|
||||
async #process(message, key, { alreadyRecorded = false } = {}) {
|
||||
if (this.#signal?.aborted) return;
|
||||
const messageId = nonEmptyString(message?.messageId);
|
||||
|
|
@ -427,8 +604,9 @@ export class QqHarnessBridge {
|
|||
}
|
||||
}
|
||||
let answer;
|
||||
let artifacts = [];
|
||||
try {
|
||||
({ answer } = await askInWorkspaceSession({
|
||||
({ answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
|
|
@ -462,21 +640,50 @@ export class QqHarnessBridge {
|
|||
this.#approvals.closeRoute(key),
|
||||
]);
|
||||
}
|
||||
this.#signal?.throwIfAborted();
|
||||
const displayAnswer = answerTextForDelivery(answer, artifacts);
|
||||
let textReceipt = null;
|
||||
let textSendError = null;
|
||||
try {
|
||||
if (stream) {
|
||||
try {
|
||||
await stream.update(answer);
|
||||
await stream.update(displayAnswer);
|
||||
await stream.complete();
|
||||
streamFinished = true;
|
||||
textReceipt = createDeliveryReceipt({
|
||||
deliveryId: messageId,
|
||||
presentation: 'qq-text',
|
||||
providerMessageIds: providerMessageIdsFor(stream),
|
||||
});
|
||||
} catch (error) {
|
||||
stream.cancel?.();
|
||||
this.#logger.warn?.('[dsh-im:qq] QQ stream finalization failed; using a text reply:', error);
|
||||
}
|
||||
}
|
||||
if (!streamFinished) await this.#bot.sendText(target, answer);
|
||||
if (!streamFinished) {
|
||||
const sent = await this.#bot.sendText(target, displayAnswer);
|
||||
textReceipt = createDeliveryReceipt({
|
||||
deliveryId: messageId,
|
||||
presentation: 'qq-text',
|
||||
providerMessageIds: providerMessageIdsFor(sent),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
textSendError = error;
|
||||
this.#logger.warn?.('[dsh-im:qq] final text delivery failed; continuing with result files:', error);
|
||||
}
|
||||
const delivery = await this.#deliverArtifacts(target, messageId, artifacts, textReceipt);
|
||||
const artifactDispatched = delivery.receipt?.artifacts?.some(
|
||||
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
|
||||
);
|
||||
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
|
||||
throw textSendError;
|
||||
}
|
||||
await this.#state.markSeen(messageId);
|
||||
this.#status.messagesReplied += 1;
|
||||
this.#status.lastReplyAt = new Date().toISOString();
|
||||
this.#status.lastError = null;
|
||||
return delivery.receipt;
|
||||
} catch (error) {
|
||||
if (error?.code === 'turn-stopped') {
|
||||
if (stream) {
|
||||
|
|
|
|||
|
|
@ -21,9 +21,11 @@ export function createEditableMessageStream({
|
|||
create,
|
||||
edit,
|
||||
sendRemainder,
|
||||
messageIdForResult = () => null,
|
||||
logger = console,
|
||||
}) {
|
||||
let messageId;
|
||||
const providerMessageIds = [];
|
||||
let pending = null;
|
||||
let timer = null;
|
||||
let inFlight = null;
|
||||
|
|
@ -49,8 +51,17 @@ export function createEditableMessageStream({
|
|||
};
|
||||
|
||||
return {
|
||||
get messageId() {
|
||||
return messageId;
|
||||
},
|
||||
get providerMessageIds() {
|
||||
return [...providerMessageIds];
|
||||
},
|
||||
async start() {
|
||||
messageId = await create(initialText);
|
||||
if ((typeof messageId === 'string' && messageId.trim()) || Number.isSafeInteger(messageId)) {
|
||||
providerMessageIds.push(String(messageId));
|
||||
}
|
||||
return this;
|
||||
},
|
||||
update(text) {
|
||||
|
|
@ -69,7 +80,13 @@ export function createEditableMessageStream({
|
|||
const first = chunks[0] ?? '处理完成。';
|
||||
if (first !== lastSent) await edit(messageId, first);
|
||||
lastSent = first;
|
||||
for (const chunk of chunks.slice(1)) await sendRemainder(chunk);
|
||||
for (const chunk of chunks.slice(1)) {
|
||||
const result = await sendRemainder(chunk);
|
||||
const id = messageIdForResult(result);
|
||||
if ((typeof id === 'string' && id.trim()) || Number.isSafeInteger(id)) {
|
||||
providerMessageIds.push(String(id));
|
||||
}
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
closed = true;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
|
|||
import { isAbsolute } from 'node:path';
|
||||
|
||||
import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs';
|
||||
import { outboundArtifactRegistry } from './semantic/artifact.mjs';
|
||||
|
||||
// Every channel plugin runs in the same Host process. Sharing ownership by
|
||||
// Harness origin prevents two channel-specific clients bound to one Session
|
||||
|
|
@ -1025,6 +1026,7 @@ export class HarnessClient {
|
|||
const timeoutMs = options.timeoutMs ?? 600_000;
|
||||
const signal = options.signal;
|
||||
const onUpdate = typeof options.onUpdate === 'function' ? options.onUpdate : null;
|
||||
const onArtifact = typeof options.onArtifact === 'function' ? options.onArtifact : null;
|
||||
const onInteraction = typeof options.onInteraction === 'function'
|
||||
? options.onInteraction
|
||||
: undefined;
|
||||
|
|
@ -1071,11 +1073,34 @@ export class HarnessClient {
|
|||
}
|
||||
: null;
|
||||
let interactionTask = null;
|
||||
let artifactsDelivered = false;
|
||||
let deliveredArtifactCount = 0;
|
||||
|
||||
const deliverArtifacts = async () => {
|
||||
if (!onArtifact || artifactsDelivered || tracker.turn === null) {
|
||||
return deliveredArtifactCount;
|
||||
}
|
||||
artifactsDelivered = true;
|
||||
const artifacts = outboundArtifactRegistry.take(sessionId, tracker.turn, { signal });
|
||||
for (const artifact of artifacts) {
|
||||
try {
|
||||
await onArtifact(artifact);
|
||||
deliveredArtifactCount += 1;
|
||||
} catch (error) {
|
||||
outboundArtifactRegistry.release(artifact);
|
||||
console.warn(`[${this.#logPrefix}] ignored an artifact handoff failure:`, error.message);
|
||||
}
|
||||
}
|
||||
return deliveredArtifactCount;
|
||||
};
|
||||
|
||||
if (ownership) {
|
||||
this.#registerInteractionOwnership(sessionId, ownership);
|
||||
this.#registerControlOwnership(ownership);
|
||||
}
|
||||
// This is resource ownership, not a feature Gate: it lets the Host retain
|
||||
// this Turn's snapshots until the channel has polled and claimed them.
|
||||
const closeArtifactConsumer = outboundArtifactRegistry.openConsumer(sessionId, promptRpcId);
|
||||
|
||||
try {
|
||||
if (interactionSignal) {
|
||||
|
|
@ -1134,7 +1159,15 @@ export class HarnessClient {
|
|||
}
|
||||
}
|
||||
if (!tracker.finished) continue;
|
||||
if (tracker.answer) return tracker.answer;
|
||||
// An accepted /stop revokes attachment delivery even when Harness
|
||||
// preserved a useful partial text answer for the existing UX.
|
||||
const artifactCount = ownership?.stopRequested
|
||||
? 0
|
||||
: await deliverArtifacts();
|
||||
if (tracker.answer) {
|
||||
return tracker.answer;
|
||||
}
|
||||
if (artifactCount > 0) return '';
|
||||
if (ownership?.stopRequested) throw turnStoppedError();
|
||||
throw new Error(
|
||||
`Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ''}`,
|
||||
|
|
@ -1145,15 +1178,19 @@ export class HarnessClient {
|
|||
// Once cancellation was accepted, transport/poll failures and timeouts
|
||||
// describe the convergence of that stop, not an unrelated ask failure.
|
||||
if (!ownership?.stopRequested) throw error;
|
||||
if (tracker.answer) return tracker.answer;
|
||||
if (tracker.answer) {
|
||||
return tracker.answer;
|
||||
}
|
||||
if (error?.code === 'turn-stopped') throw error;
|
||||
throw turnStoppedError();
|
||||
}
|
||||
} finally {
|
||||
closeArtifactConsumer();
|
||||
if (ownership) {
|
||||
this.#unregisterControlOwnership(ownership);
|
||||
this.#unregisterInteractionOwnership(sessionId, ownership);
|
||||
}
|
||||
if (tracker.turn !== null) outboundArtifactRegistry.discard(sessionId, tracker.turn);
|
||||
interactionController?.abort(new DOMException('Harness turn finished', 'AbortError'));
|
||||
if (interactionTask) await interactionTask.catch(() => undefined);
|
||||
}
|
||||
|
|
|
|||
748
src/channels/shared/semantic/artifact.mjs
Normal file
748
src/channels/shared/semantic/artifact.mjs
Normal file
|
|
@ -0,0 +1,748 @@
|
|||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import { copyFile, lstat, mkdtemp, open, realpath, unlink } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, extname, isAbsolute, join, resolve } from 'node:path';
|
||||
|
||||
export const OUTBOUND_ARTIFACT_TOOL = 'dsh_im_return_file';
|
||||
|
||||
const ARTIFACT_KIND = 'dsh-im-outbound-artifact';
|
||||
const ARTIFACT_READ_CHUNK_BYTES = 64 * 1024;
|
||||
const MIME_BY_EXTENSION = new Map([
|
||||
['.csv', 'text/csv'],
|
||||
['.doc', 'application/msword'],
|
||||
['.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
||||
['.gif', 'image/gif'],
|
||||
['.html', 'text/html'],
|
||||
['.jpeg', 'image/jpeg'],
|
||||
['.jpg', 'image/jpeg'],
|
||||
['.json', 'application/json'],
|
||||
['.md', 'text/markdown'],
|
||||
['.pdf', 'application/pdf'],
|
||||
['.png', 'image/png'],
|
||||
['.rar', 'application/vnd.rar'],
|
||||
['.txt', 'text/plain'],
|
||||
['.webp', 'image/webp'],
|
||||
['.xls', 'application/vnd.ms-excel'],
|
||||
['.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
||||
['.xml', 'application/xml'],
|
||||
['.zip', 'application/zip'],
|
||||
]);
|
||||
|
||||
const artifactStorage = new WeakMap();
|
||||
const materializedArtifactSources = new WeakMap();
|
||||
const artifactProviderSettlements = new WeakMap();
|
||||
let managedSnapshotDirectoryPromise;
|
||||
|
||||
function managedSnapshotDirectory() {
|
||||
managedSnapshotDirectoryPromise ??= mkdtemp(join(tmpdir(), 'dsh-im-outbound-'));
|
||||
return managedSnapshotDirectoryPromise;
|
||||
}
|
||||
|
||||
function artifactError(code, message) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function currentTurn(agent) {
|
||||
const events = agent?.session?.events;
|
||||
if (!Array.isArray(events)) return null;
|
||||
let turn = null;
|
||||
for (const event of events) {
|
||||
if (event?.type === 'turn/start') {
|
||||
turn = Number.isInteger(event.data?.turn) ? event.data.turn : null;
|
||||
} else if (event?.type === 'turn/end' && event.data?.turn === turn) {
|
||||
turn = null;
|
||||
}
|
||||
}
|
||||
return Number.isInteger(turn) && turn >= 0 ? turn : null;
|
||||
}
|
||||
|
||||
function sessionIdOf(session) {
|
||||
const sessionId = session?.id ?? session?.header?.id;
|
||||
return typeof sessionId === 'string' && sessionId ? sessionId : null;
|
||||
}
|
||||
|
||||
function turnKey(sessionId, turn) {
|
||||
return `${sessionId}\u0000${turn}`;
|
||||
}
|
||||
|
||||
function promptKey(sessionId, promptRpcId) {
|
||||
return `${sessionId}\u0000${promptRpcId}`;
|
||||
}
|
||||
|
||||
function safeFileName(value) {
|
||||
const cleaned = String(value ?? '')
|
||||
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
||||
.replace(/\p{Cf}/gu, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!cleaned) return 'result.bin';
|
||||
return [...cleaned].slice(0, 255).join('');
|
||||
}
|
||||
|
||||
function mediaTypeFor(name) {
|
||||
return MIME_BY_EXTENSION.get(extname(name).toLowerCase()) ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
function sha256(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
function sameIdentity(left, right) {
|
||||
return typeof left?.dev === 'bigint'
|
||||
&& typeof left?.ino === 'bigint'
|
||||
&& typeof left?.size === 'bigint'
|
||||
&& typeof left?.mtimeNs === 'bigint'
|
||||
&& typeof left?.ctimeNs === 'bigint'
|
||||
&& left.dev === right?.dev
|
||||
&& left.ino === right?.ino
|
||||
&& left.size === right?.size
|
||||
&& left.mtimeNs === right?.mtimeNs
|
||||
&& left.ctimeNs === right?.ctimeNs;
|
||||
}
|
||||
|
||||
async function hashFile(path, signal) {
|
||||
let handle;
|
||||
try {
|
||||
handle = await open(path, fsConstants.O_RDONLY);
|
||||
const hash = createHash('sha256');
|
||||
const stream = handle.createReadStream({
|
||||
autoClose: false,
|
||||
highWaterMark: ARTIFACT_READ_CHUNK_BYTES,
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
for await (const chunk of stream) {
|
||||
signal?.throwIfAborted();
|
||||
hash.update(chunk);
|
||||
}
|
||||
return hash.digest('hex');
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read exactly the observed file size and prove EOF. No project-level size or time limit applies. */
|
||||
export async function readExactArtifactFile(handle, expectedSize, {
|
||||
signal,
|
||||
errorCode = 'artifact-changed',
|
||||
errorMessage = 'The result file changed while it was being read.',
|
||||
} = {}) {
|
||||
const changed = () => artifactError(errorCode, errorMessage);
|
||||
if (!Number.isSafeInteger(expectedSize) || expectedSize < 0) throw changed();
|
||||
signal?.throwIfAborted();
|
||||
|
||||
let bytes;
|
||||
try {
|
||||
bytes = Buffer.allocUnsafe(expectedSize);
|
||||
} catch {
|
||||
throw changed();
|
||||
}
|
||||
|
||||
if (typeof handle?.createReadStream === 'function') {
|
||||
const stream = handle.createReadStream({
|
||||
autoClose: false,
|
||||
start: 0,
|
||||
// Node's end offset is inclusive, so this reads one byte beyond the
|
||||
// observed size when the file grows and catches the change.
|
||||
end: expectedSize,
|
||||
highWaterMark: ARTIFACT_READ_CHUNK_BYTES,
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
let offset = 0;
|
||||
for await (const chunk of stream) {
|
||||
signal?.throwIfAborted();
|
||||
const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
if (offset + part.length > expectedSize) throw changed();
|
||||
part.copy(bytes, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
if (offset !== expectedSize) throw changed();
|
||||
return bytes;
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
while (offset < expectedSize) {
|
||||
signal?.throwIfAborted();
|
||||
const length = Math.min(ARTIFACT_READ_CHUNK_BYTES, expectedSize - offset);
|
||||
const result = await handle.read(bytes, offset, length, offset);
|
||||
const bytesRead = Number(result?.bytesRead ?? 0);
|
||||
if (!Number.isInteger(bytesRead) || bytesRead <= 0 || bytesRead > length) throw changed();
|
||||
offset += bytesRead;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const probe = Buffer.allocUnsafe(1);
|
||||
if ((await handle.read(probe, 0, 1, expectedSize))?.bytesRead !== 0) throw changed();
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function snapshotFile(workspace, requestedPath, signal) {
|
||||
signal?.throwIfAborted();
|
||||
let storagePath;
|
||||
try {
|
||||
const candidate = isAbsolute(requestedPath)
|
||||
? requestedPath
|
||||
: resolve(workspace, requestedPath);
|
||||
const canonicalPath = await realpath(candidate);
|
||||
const source = await lstat(canonicalPath, { bigint: true });
|
||||
if (!source.isFile()) {
|
||||
throw artifactError('artifact-not-file', 'The requested path is not a file.');
|
||||
}
|
||||
|
||||
const directory = await managedSnapshotDirectory();
|
||||
storagePath = join(directory, `${randomUUID()}.artifact`);
|
||||
await copyFile(canonicalPath, storagePath, fsConstants.COPYFILE_EXCL);
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const snapshot = await lstat(storagePath, { bigint: true });
|
||||
const size = Number(snapshot.size);
|
||||
if (!snapshot.isFile() || !Number.isSafeInteger(size) || size < 0) {
|
||||
throw artifactError('artifact-unavailable', 'The file could not be prepared for delivery.');
|
||||
}
|
||||
const fileName = safeFileName(basename(candidate));
|
||||
return Object.freeze({
|
||||
fileName,
|
||||
mediaType: mediaTypeFor(fileName),
|
||||
size,
|
||||
digest: await hashFile(storagePath, signal),
|
||||
storagePath,
|
||||
});
|
||||
} catch (error) {
|
||||
if (storagePath) await unlink(storagePath).catch(() => undefined);
|
||||
if (error?.code?.startsWith?.('artifact-')) throw error;
|
||||
if (signal?.aborted) throw signal.reason ?? error;
|
||||
throw artifactError('artifact-unavailable', 'The requested file is unavailable.');
|
||||
}
|
||||
}
|
||||
|
||||
function publicArtifact(artifact) {
|
||||
return Object.freeze({
|
||||
artifactId: artifact.artifactId,
|
||||
fileName: artifact.fileName,
|
||||
size: artifact.size,
|
||||
});
|
||||
}
|
||||
|
||||
function artifactKey(artifact) {
|
||||
return artifact.artifactId;
|
||||
}
|
||||
|
||||
function cleanupArtifactStorage(artifact) {
|
||||
const storage = artifactStorage.get(artifact);
|
||||
if (!storage) return;
|
||||
storage.releaseRequested = true;
|
||||
if (storage.materializing > 0) {
|
||||
storage.cleanupRequested = true;
|
||||
return;
|
||||
}
|
||||
const pending = [...artifactProviderSettlements.get(artifact) ?? []];
|
||||
if (pending.length > 0) {
|
||||
if (!storage.cleanupDeferred) {
|
||||
storage.cleanupDeferred = true;
|
||||
void Promise.allSettled(pending).finally(() => {
|
||||
storage.cleanupDeferred = false;
|
||||
cleanupArtifactStorage(artifact);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
artifactStorage.delete(artifact);
|
||||
artifactProviderSettlements.delete(artifact);
|
||||
storage.onCleanup?.();
|
||||
void unlink(storage.path).catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds successful tool results until the channel that owns the Session Turn
|
||||
* collects them. It does not decide which files a user may send.
|
||||
*/
|
||||
export class OutboundArtifactRegistry {
|
||||
#turns = new Map();
|
||||
#stagedTurns = new Map();
|
||||
#claimedTurns = new Map();
|
||||
#claimSignals = new Map();
|
||||
#signalClaims = new Map();
|
||||
#consumersByPrompt = new Map();
|
||||
#consumersByTurn = new Map();
|
||||
#openTurns = new Map();
|
||||
#uuid;
|
||||
|
||||
constructor({ uuid = randomUUID } = {}) {
|
||||
if (typeof uuid !== 'function') throw new TypeError('uuid must be a function');
|
||||
this.#uuid = uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind one channel request to the Turn it starts. This owns cleanup only:
|
||||
* it never changes tool visibility or decides whether a file may be sent.
|
||||
*/
|
||||
openConsumer(sessionId, promptRpcId) {
|
||||
if (typeof sessionId !== 'string' || !sessionId
|
||||
|| typeof promptRpcId !== 'string' || !promptRpcId) {
|
||||
throw new TypeError('sessionId and promptRpcId are required');
|
||||
}
|
||||
const key = promptKey(sessionId, promptRpcId);
|
||||
const consumer = {
|
||||
sessionId,
|
||||
promptRpcId,
|
||||
turn: null,
|
||||
released: false,
|
||||
};
|
||||
this.#consumersByPrompt.set(key, consumer);
|
||||
return () => {
|
||||
if (consumer.released) return;
|
||||
consumer.released = true;
|
||||
if (this.#consumersByPrompt.get(key) === consumer) {
|
||||
this.#consumersByPrompt.delete(key);
|
||||
}
|
||||
if (consumer.turn !== null) {
|
||||
const keyForTurn = turnKey(sessionId, consumer.turn);
|
||||
if (this.#consumersByTurn.get(keyForTurn) === consumer) {
|
||||
this.#consumersByTurn.delete(keyForTurn);
|
||||
}
|
||||
// Claimed artifacts have already crossed into the provider pipeline and
|
||||
// are released there. discard() only removes unclaimed work.
|
||||
this.discard(sessionId, consumer.turn);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Observe durable Session events solely to terminate unclaimed snapshots. */
|
||||
observeSessionEvent(session, event) {
|
||||
const sessionId = sessionIdOf(session);
|
||||
if (!sessionId || !event || typeof event !== 'object') return;
|
||||
if (event.type === 'turn/start') {
|
||||
const turn = event.data?.turn;
|
||||
if (Number.isInteger(turn) && turn >= 0) this.#openTurns.set(sessionId, turn);
|
||||
return;
|
||||
}
|
||||
if (event.type === 'user/message') {
|
||||
const rpcId = event.data?.source?.rpcId;
|
||||
const turn = this.#openTurns.get(sessionId) ?? currentTurn({ session });
|
||||
if (typeof rpcId !== 'string' || !rpcId || !Number.isInteger(turn)) return;
|
||||
this.#openTurns.set(sessionId, turn);
|
||||
const consumer = this.#consumersByPrompt.get(promptKey(sessionId, rpcId));
|
||||
if (!consumer || consumer.released) return;
|
||||
consumer.turn = turn;
|
||||
this.#consumersByTurn.set(turnKey(sessionId, turn), consumer);
|
||||
return;
|
||||
}
|
||||
if (event.type !== 'turn/end') return;
|
||||
const turn = event.data?.turn;
|
||||
if (!Number.isInteger(turn) || turn < 0) return;
|
||||
if (this.#openTurns.get(sessionId) === turn) this.#openTurns.delete(sessionId);
|
||||
const consumer = this.#consumersByTurn.get(turnKey(sessionId, turn));
|
||||
if (!consumer || consumer.released) this.discard(sessionId, turn);
|
||||
}
|
||||
|
||||
/** A disposed Session cannot have another channel consumer claim its files. */
|
||||
disposeSession(session) {
|
||||
const sessionId = sessionIdOf(session);
|
||||
if (!sessionId) return;
|
||||
const prefix = `${sessionId}\u0000`;
|
||||
const artifacts = new Set();
|
||||
for (const entries of [this.#turns, this.#stagedTurns]) {
|
||||
for (const [key, turnArtifacts] of entries) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
for (const artifact of turnArtifacts.values()) artifacts.add(artifact);
|
||||
entries.delete(key);
|
||||
}
|
||||
}
|
||||
for (const [key, consumer] of this.#consumersByPrompt) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
consumer.released = true;
|
||||
this.#consumersByPrompt.delete(key);
|
||||
}
|
||||
for (const key of this.#consumersByTurn.keys()) {
|
||||
if (key.startsWith(prefix)) this.#consumersByTurn.delete(key);
|
||||
}
|
||||
this.#openTurns.delete(sessionId);
|
||||
for (const artifact of artifacts) cleanupArtifactStorage(artifact);
|
||||
}
|
||||
|
||||
async stage(args, exec) {
|
||||
const requestedPath = args?.path;
|
||||
if (typeof requestedPath !== 'string' || !requestedPath.trim()) {
|
||||
throw new TypeError('A file path is required.');
|
||||
}
|
||||
const agent = exec?.agent;
|
||||
const sessionId = agent?.session?.header?.id;
|
||||
const workspace = agent?.session?.header?.cwd;
|
||||
const turn = currentTurn(agent);
|
||||
if (typeof sessionId !== 'string' || !sessionId
|
||||
|| typeof workspace !== 'string' || !workspace || turn === null) {
|
||||
throw artifactError(
|
||||
'artifact-context-required',
|
||||
'A live Harness Session is required to return a file.',
|
||||
);
|
||||
}
|
||||
|
||||
const snapshot = await snapshotFile(workspace, requestedPath, exec?.signal);
|
||||
const { storagePath, ...snapshotMetadata } = snapshot;
|
||||
const artifact = Object.freeze({
|
||||
kind: ARTIFACT_KIND,
|
||||
schemaVersion: 1,
|
||||
artifactId: this.#uuid(),
|
||||
deliveryKey: this.#uuid(),
|
||||
...snapshotMetadata,
|
||||
source: 'managed-temp',
|
||||
registeredBy: Object.freeze({
|
||||
kind: 'tool-result',
|
||||
eventId: typeof exec?.callId === 'string' ? exec.callId : 'unknown',
|
||||
toolName: OUTBOUND_ARTIFACT_TOOL,
|
||||
}),
|
||||
origin: Object.freeze({
|
||||
sessionId,
|
||||
turn,
|
||||
callId: typeof exec?.callId === 'string' ? exec.callId : null,
|
||||
}),
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
artifactStorage.set(artifact, {
|
||||
path: storagePath,
|
||||
materializing: 0,
|
||||
materialized: false,
|
||||
releaseRequested: false,
|
||||
cleanupRequested: false,
|
||||
cleanupDeferred: false,
|
||||
onCleanup: () => this.#forgetArtifact(artifact),
|
||||
});
|
||||
const keyForTurn = turnKey(sessionId, turn);
|
||||
let staged = this.#stagedTurns.get(keyForTurn);
|
||||
if (!staged) {
|
||||
staged = new Map();
|
||||
this.#stagedTurns.set(keyForTurn, staged);
|
||||
}
|
||||
staged.set(artifactKey(artifact), artifact);
|
||||
return artifact;
|
||||
}
|
||||
|
||||
commit(artifact) {
|
||||
if (artifact?.kind !== ARTIFACT_KIND || !artifactStorage.has(artifact)) return null;
|
||||
const keyForTurn = turnKey(artifact.origin.sessionId, artifact.origin.turn);
|
||||
const key = artifactKey(artifact);
|
||||
const staged = this.#stagedTurns.get(keyForTurn);
|
||||
if (staged?.get(key) !== artifact) return null;
|
||||
staged.delete(key);
|
||||
if (staged.size === 0) this.#stagedTurns.delete(keyForTurn);
|
||||
let committed = this.#turns.get(keyForTurn);
|
||||
if (!committed) {
|
||||
committed = new Map();
|
||||
this.#turns.set(keyForTurn, committed);
|
||||
}
|
||||
committed.set(key, artifact);
|
||||
return publicArtifact(artifact);
|
||||
}
|
||||
|
||||
release(artifact) {
|
||||
if (artifact?.kind !== ARTIFACT_KIND) return;
|
||||
const keyForTurn = turnKey(artifact.origin.sessionId, artifact.origin.turn);
|
||||
const key = artifactKey(artifact);
|
||||
const staged = this.#stagedTurns.get(keyForTurn);
|
||||
if (staged?.get(key) === artifact) staged.delete(key);
|
||||
if (staged?.size === 0) this.#stagedTurns.delete(keyForTurn);
|
||||
cleanupArtifactStorage(artifact);
|
||||
}
|
||||
|
||||
take(sessionId, turn, { signal } = {}) {
|
||||
const keyForTurn = turnKey(sessionId, turn);
|
||||
const committed = this.#turns.get(keyForTurn);
|
||||
this.#turns.delete(keyForTurn);
|
||||
if (!committed) return [];
|
||||
if (signal?.aborted) {
|
||||
for (const artifact of committed.values()) cleanupArtifactStorage(artifact);
|
||||
return [];
|
||||
}
|
||||
const claimed = this.#claimedTurns.get(keyForTurn) ?? new Map();
|
||||
this.#claimedTurns.set(keyForTurn, claimed);
|
||||
const artifacts = [];
|
||||
for (const [key, artifact] of committed) {
|
||||
if (!artifactStorage.has(artifact)) continue;
|
||||
claimed.set(key, artifact);
|
||||
artifacts.push(artifact);
|
||||
}
|
||||
if (claimed.size === 0) this.#claimedTurns.delete(keyForTurn);
|
||||
else if (!this.#bindClaimSignal(keyForTurn, signal)) return [];
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
discard(sessionId, turn) {
|
||||
if (typeof sessionId !== 'string' || !Number.isInteger(turn)) return;
|
||||
const keyForTurn = turnKey(sessionId, turn);
|
||||
const artifacts = new Set([
|
||||
...this.#turns.get(keyForTurn)?.values() ?? [],
|
||||
...this.#stagedTurns.get(keyForTurn)?.values() ?? [],
|
||||
]);
|
||||
this.#turns.delete(keyForTurn);
|
||||
this.#stagedTurns.delete(keyForTurn);
|
||||
for (const artifact of artifacts) cleanupArtifactStorage(artifact);
|
||||
}
|
||||
|
||||
clear() {
|
||||
const artifacts = new Set();
|
||||
for (const entries of this.#turns.values()) {
|
||||
for (const artifact of entries.values()) artifacts.add(artifact);
|
||||
}
|
||||
for (const entries of this.#stagedTurns.values()) {
|
||||
for (const artifact of entries.values()) artifacts.add(artifact);
|
||||
}
|
||||
for (const entries of this.#claimedTurns.values()) {
|
||||
for (const artifact of entries.values()) artifacts.add(artifact);
|
||||
}
|
||||
for (const artifact of artifacts) cleanupArtifactStorage(artifact);
|
||||
this.#turns.clear();
|
||||
this.#stagedTurns.clear();
|
||||
this.#claimedTurns.clear();
|
||||
for (const turnKey of this.#claimSignals.keys()) this.#releaseClaimSignal(turnKey);
|
||||
for (const consumer of this.#consumersByPrompt.values()) consumer.released = true;
|
||||
this.#consumersByPrompt.clear();
|
||||
this.#consumersByTurn.clear();
|
||||
this.#openTurns.clear();
|
||||
}
|
||||
|
||||
#bindClaimSignal(turnKey, signal) {
|
||||
if (!signal) return true;
|
||||
this.#releaseClaimSignal(turnKey);
|
||||
let claim = this.#signalClaims.get(signal);
|
||||
if (!claim) {
|
||||
claim = { turnKeys: new Set(), onAbort: null };
|
||||
claim.onAbort = () => {
|
||||
for (const claimedTurnKey of [...claim.turnKeys]) {
|
||||
for (const artifact of this.#claimedTurns.get(claimedTurnKey)?.values() ?? []) {
|
||||
cleanupArtifactStorage(artifact);
|
||||
}
|
||||
}
|
||||
};
|
||||
this.#signalClaims.set(signal, claim);
|
||||
signal.addEventListener('abort', claim.onAbort, { once: true });
|
||||
}
|
||||
claim.turnKeys.add(turnKey);
|
||||
this.#claimSignals.set(turnKey, signal);
|
||||
if (signal.aborted) {
|
||||
claim.onAbort();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#releaseClaimSignal(turnKey) {
|
||||
const signal = this.#claimSignals.get(turnKey);
|
||||
if (!signal) return;
|
||||
this.#claimSignals.delete(turnKey);
|
||||
const claim = this.#signalClaims.get(signal);
|
||||
claim?.turnKeys.delete(turnKey);
|
||||
if (claim?.turnKeys.size === 0) {
|
||||
signal.removeEventListener('abort', claim.onAbort);
|
||||
this.#signalClaims.delete(signal);
|
||||
}
|
||||
}
|
||||
|
||||
#forgetArtifact(artifact) {
|
||||
const keyForTurn = turnKey(artifact.origin.sessionId, artifact.origin.turn);
|
||||
const key = artifactKey(artifact);
|
||||
const committed = this.#turns.get(keyForTurn);
|
||||
if (committed?.get(key) === artifact) committed.delete(key);
|
||||
if (committed?.size === 0) this.#turns.delete(keyForTurn);
|
||||
const staged = this.#stagedTurns.get(keyForTurn);
|
||||
if (staged?.get(key) === artifact) staged.delete(key);
|
||||
if (staged?.size === 0) this.#stagedTurns.delete(keyForTurn);
|
||||
const claimed = this.#claimedTurns.get(keyForTurn);
|
||||
if (claimed?.get(key) === artifact) claimed.delete(key);
|
||||
if (claimed?.size === 0) {
|
||||
this.#claimedTurns.delete(keyForTurn);
|
||||
this.#releaseClaimSignal(keyForTurn);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const outboundArtifactRegistry = new OutboundArtifactRegistry();
|
||||
|
||||
/**
|
||||
* Build a two-phase tool: execute stages a file; the authoritative tools/result
|
||||
* observer commits only a successful native call or successful Code Mode parent.
|
||||
*/
|
||||
export function createOutboundArtifactTool({ registry = outboundArtifactRegistry } = {}) {
|
||||
const staged = new WeakMap();
|
||||
const pendingByParent = new Map();
|
||||
const appendPending = (parent, artifact) => {
|
||||
let pending = pendingByParent.get(parent);
|
||||
if (!pending) {
|
||||
pending = { artifacts: [] };
|
||||
pendingByParent.set(parent, pending);
|
||||
}
|
||||
pending.artifacts.push(artifact);
|
||||
};
|
||||
const definition = Object.freeze({
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
description: 'Send a readable file to the user through the current conversation. Existing and newly created files are both valid.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
description: 'Absolute path, or a path relative to the current workspace.',
|
||||
},
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
artifactId: { type: 'string' },
|
||||
fileName: { type: 'string' },
|
||||
size: { type: 'number' },
|
||||
},
|
||||
required: ['artifactId', 'fileName', 'size'],
|
||||
},
|
||||
render: (_args, value) => [{
|
||||
type: 'text',
|
||||
text: `Registered ${value.fileName} (${value.size} bytes) for IM delivery.`,
|
||||
}],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const artifact = await registry.stage(args, exec);
|
||||
staged.set(exec, artifact);
|
||||
return publicArtifact(artifact);
|
||||
},
|
||||
});
|
||||
|
||||
const onResult = (exec, result) => {
|
||||
if (exec?.name === OUTBOUND_ARTIFACT_TOOL) {
|
||||
const artifact = staged.get(exec);
|
||||
if (!artifact) return;
|
||||
staged.delete(exec);
|
||||
if (result?.isError) {
|
||||
registry.release(artifact);
|
||||
return;
|
||||
}
|
||||
if (exec.parent === undefined) registry.commit(artifact);
|
||||
else appendPending(exec.parent, artifact);
|
||||
return;
|
||||
}
|
||||
const pending = pendingByParent.get(exec?.token);
|
||||
if (!pending) return;
|
||||
pendingByParent.delete(exec.token);
|
||||
if (result?.isError) {
|
||||
for (const artifact of pending.artifacts) registry.release(artifact);
|
||||
return;
|
||||
}
|
||||
if (exec.parent !== undefined) {
|
||||
for (const artifact of pending.artifacts) appendPending(exec.parent, artifact);
|
||||
return;
|
||||
}
|
||||
for (const artifact of pending.artifacts) registry.commit(artifact);
|
||||
};
|
||||
|
||||
return Object.freeze({ definition, onResult });
|
||||
}
|
||||
|
||||
/** Register the file-return tool without a per-request Gate. */
|
||||
export function installOutboundArtifactTool(ctx, { registry = outboundArtifactRegistry } = {}) {
|
||||
if (typeof ctx?.tools?.register !== 'function'
|
||||
|| typeof ctx?.systemPrompt?.section !== 'function'
|
||||
|| typeof ctx?.on !== 'function') return false;
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
ctx.tools.register(tool.definition);
|
||||
ctx.on('tools/result', tool.onResult);
|
||||
ctx.on('session/event', (session, event) => registry.observeSessionEvent(session, event));
|
||||
ctx.on('session/disposed', (session) => registry.disposeSession(session));
|
||||
ctx.systemPrompt.section({
|
||||
name: 'dsh-im:return-file',
|
||||
order: 115,
|
||||
text: `When the user asks to receive a file, call ${OUTBOUND_ARTIFACT_TOOL} with its path. Existing files can be sent directly; do not recreate or rename a file solely for delivery.`,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Materialize the registered snapshot for the channel provider. */
|
||||
export async function materializeOutboundArtifact(artifact, {
|
||||
signal,
|
||||
} = {}) {
|
||||
if (signal?.aborted) {
|
||||
cleanupArtifactStorage(artifact);
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
if (artifact?.kind !== ARTIFACT_KIND || artifact.schemaVersion !== 1
|
||||
|| !artifactStorage.has(artifact)
|
||||
|| typeof artifact.digest !== 'string'
|
||||
|| !Number.isSafeInteger(artifact.size) || artifact.size < 0) {
|
||||
throw artifactError('artifact-invalid', 'The file registration is invalid.');
|
||||
}
|
||||
const storage = artifactStorage.get(artifact);
|
||||
storage.materializing += 1;
|
||||
let handle;
|
||||
let materialized = false;
|
||||
try {
|
||||
const noFollow = Number.isInteger(fsConstants.O_NOFOLLOW) ? fsConstants.O_NOFOLLOW : 0;
|
||||
handle = await open(storage.path, fsConstants.O_RDONLY | noFollow);
|
||||
const before = await handle.stat({ bigint: true });
|
||||
if (!before.isFile() || before.size !== BigInt(artifact.size)) {
|
||||
throw artifactError('artifact-invalid', 'The file registration is invalid.');
|
||||
}
|
||||
const bytes = await readExactArtifactFile(handle, artifact.size, {
|
||||
signal,
|
||||
errorCode: 'artifact-invalid',
|
||||
errorMessage: 'The file registration is invalid.',
|
||||
});
|
||||
const after = await handle.stat({ bigint: true });
|
||||
if (!sameIdentity(before, after)
|
||||
|| bytes.byteLength !== artifact.size
|
||||
|| sha256(bytes) !== artifact.digest) {
|
||||
throw artifactError('artifact-invalid', 'The file registration is invalid.');
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
materialized = true;
|
||||
const file = Object.freeze({
|
||||
artifactId: artifact.artifactId,
|
||||
deliveryKey: artifact.deliveryKey,
|
||||
fileName: artifact.fileName,
|
||||
mediaType: artifact.mediaType,
|
||||
size: artifact.size,
|
||||
bytes,
|
||||
});
|
||||
materializedArtifactSources.set(file, artifact);
|
||||
storage.materialized = true;
|
||||
return file;
|
||||
} catch (error) {
|
||||
if (error?.code?.startsWith?.('artifact-')) throw error;
|
||||
if (signal?.aborted) throw signal.reason ?? error;
|
||||
throw artifactError('artifact-invalid', 'The file registration is invalid.');
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
storage.materializing -= 1;
|
||||
if (!materialized || storage.cleanupRequested) {
|
||||
cleanupArtifactStorage(artifact);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep a materialized snapshot alive until an unabortable provider call settles. */
|
||||
export function trackOutboundArtifactProviderPromise(file, promise) {
|
||||
const artifact = materializedArtifactSources.get(file);
|
||||
if (!artifact || !artifactStorage.has(artifact)
|
||||
|| !promise || typeof promise.then !== 'function') return promise;
|
||||
const settlements = artifactProviderSettlements.get(artifact) ?? new Set();
|
||||
const settlement = Promise.resolve(promise).then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
settlements.add(settlement);
|
||||
artifactProviderSettlements.set(artifact, settlements);
|
||||
void settlement.finally(() => {
|
||||
settlements.delete(settlement);
|
||||
if (settlements.size === 0) artifactProviderSettlements.delete(artifact);
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Release a claimed snapshot after its provider send reaches a terminal result. */
|
||||
export function releaseOutboundArtifact(artifact) {
|
||||
const storage = artifactStorage.get(artifact);
|
||||
if (storage) storage.releaseRequested = true;
|
||||
cleanupArtifactStorage(artifact);
|
||||
}
|
||||
153
src/channels/shared/semantic/delivery.mjs
Normal file
153
src/channels/shared/semantic/delivery.mjs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
export const DELIVERY_RECEIPT_SCHEMA_VERSION = 1;
|
||||
|
||||
const ARTIFACT_OUTCOMES = new Set(['sent', 'rejected', 'failed', 'unknown']);
|
||||
const REJECTED_ARTIFACT_ERRORS = new Set([
|
||||
'artifact-changed',
|
||||
'artifact-context-required',
|
||||
'artifact-empty',
|
||||
'artifact-invalid',
|
||||
'artifact-not-file',
|
||||
'artifact-permission-required',
|
||||
'artifact-provider-rejected',
|
||||
'artifact-too-large',
|
||||
'artifact-unavailable',
|
||||
]);
|
||||
|
||||
export function providerMessageIdsFor(value) {
|
||||
if (!value || typeof value !== 'object') return [];
|
||||
const ids = Array.isArray(value.providerMessageIds)
|
||||
? value.providerMessageIds
|
||||
.filter((candidate) => (
|
||||
(typeof candidate === 'string' && candidate.trim())
|
||||
|| Number.isSafeInteger(candidate)
|
||||
))
|
||||
.map(String)
|
||||
: [];
|
||||
const candidates = [
|
||||
value.message_id,
|
||||
value.messageId,
|
||||
value.id,
|
||||
value.ts,
|
||||
value.message?.message_id,
|
||||
value.message?.messageId,
|
||||
value.message?.id,
|
||||
value.message?.ts,
|
||||
value.key?.id,
|
||||
value.data?.message_id,
|
||||
];
|
||||
const id = candidates.find((candidate) => (
|
||||
(typeof candidate === 'string' && candidate.trim())
|
||||
|| Number.isSafeInteger(candidate)
|
||||
));
|
||||
if (id !== undefined) ids.push(String(id));
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
function requiredString(value, name) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new TypeError(`${name} must be a non-empty string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function providerIds(values) {
|
||||
if (!Array.isArray(values)) throw new TypeError('providerMessageIds must be an array');
|
||||
const ids = [];
|
||||
const seen = new Set();
|
||||
for (const value of values) {
|
||||
const id = requiredString(value, 'providerMessageId');
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
return Object.freeze(ids);
|
||||
}
|
||||
|
||||
function artifactResults(values) {
|
||||
if (!Array.isArray(values)) throw new TypeError('artifacts must be an array');
|
||||
return Object.freeze(values.map((value) => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new TypeError('artifact result must be an object');
|
||||
}
|
||||
const artifactId = requiredString(value.artifactId, 'artifactId');
|
||||
if (!ARTIFACT_OUTCOMES.has(value.outcome)) {
|
||||
throw new TypeError('artifact outcome must be sent, rejected, failed, or unknown');
|
||||
}
|
||||
const reason = value.reason === undefined
|
||||
? undefined
|
||||
: requiredString(value.reason, 'artifact reason');
|
||||
return Object.freeze({
|
||||
artifactId,
|
||||
outcome: value.outcome,
|
||||
...(reason === undefined ? {} : { reason }),
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
export function artifactOutcomeForError(error) {
|
||||
const code = typeof error === 'string' ? error : error?.code;
|
||||
if (code === 'artifact-delivery-uncertain') return 'unknown';
|
||||
if (REJECTED_ARTIFACT_ERRORS.has(code)) return 'rejected';
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
export function createDeliveryReceipt({
|
||||
deliveryId,
|
||||
presentation,
|
||||
providerMessageIds = [],
|
||||
artifacts = [],
|
||||
}) {
|
||||
return Object.freeze({
|
||||
schemaVersion: DELIVERY_RECEIPT_SCHEMA_VERSION,
|
||||
deliveryId: requiredString(deliveryId, 'deliveryId'),
|
||||
presentation: requiredString(presentation, 'presentation'),
|
||||
providerMessageIds: providerIds(providerMessageIds),
|
||||
artifacts: artifactResults(artifacts),
|
||||
});
|
||||
}
|
||||
|
||||
export function createArtifactFailureReceipt({
|
||||
artifactId,
|
||||
deliveryId,
|
||||
error,
|
||||
presentation = 'text-fallback',
|
||||
providerMessageIds = [],
|
||||
}) {
|
||||
const code = typeof error === 'string' ? error : error?.code;
|
||||
const reason = typeof code === 'string' && code
|
||||
? code
|
||||
: 'artifact-provider-failed';
|
||||
return createDeliveryReceipt({
|
||||
deliveryId,
|
||||
presentation,
|
||||
providerMessageIds,
|
||||
artifacts: [{
|
||||
artifactId,
|
||||
outcome: artifactOutcomeForError(error),
|
||||
reason,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeDeliveryReceipts({ deliveryId, presentation, receipts }) {
|
||||
if (!Array.isArray(receipts) || receipts.length === 0) {
|
||||
throw new TypeError('receipts must contain at least one delivery receipt');
|
||||
}
|
||||
const messageIds = [];
|
||||
const artifacts = new Map();
|
||||
for (const receipt of receipts) {
|
||||
if (!receipt || receipt.schemaVersion !== DELIVERY_RECEIPT_SCHEMA_VERSION) {
|
||||
throw new TypeError('receipt must use DeliveryReceipt schema version 1');
|
||||
}
|
||||
messageIds.push(...(receipt.providerMessageIds ?? []));
|
||||
for (const artifact of receipt.artifacts ?? []) {
|
||||
artifacts.set(artifact.artifactId, artifact);
|
||||
}
|
||||
}
|
||||
return createDeliveryReceipt({
|
||||
deliveryId,
|
||||
presentation,
|
||||
providerMessageIds: messageIds,
|
||||
artifacts: [...artifacts.values()],
|
||||
});
|
||||
}
|
||||
|
|
@ -28,8 +28,19 @@ import {
|
|||
harnessQuestionText,
|
||||
validHarnessQuestion,
|
||||
} from './harness-question.mjs';
|
||||
import {
|
||||
materializeOutboundArtifact,
|
||||
releaseOutboundArtifact,
|
||||
} from './semantic/artifact.mjs';
|
||||
import {
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
providerMessageIdsFor,
|
||||
} from './semantic/delivery.mjs';
|
||||
|
||||
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
||||
const FILE_ONLY_COMPLETION_TEXT = '任务已完成。';
|
||||
|
||||
function cleanText(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
|
|
@ -42,6 +53,42 @@ function canClaimInteractionReply(message, pending, senderId) {
|
|||
&& Boolean(cleanText(message.content));
|
||||
}
|
||||
|
||||
function artifactFailureText(fileName, error, descriptor) {
|
||||
const name = String(fileName ?? '结果文件')
|
||||
.replace(/[\r\n]+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 255) || '结果文件';
|
||||
switch (error?.code) {
|
||||
case 'artifact-delivery-uncertain':
|
||||
return `结果文件「${name}」的发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
|
||||
case 'artifact-permission-required':
|
||||
if (descriptor?.key === 'slack') {
|
||||
return `结果文件「${name}」已生成,但 Slack 应用缺少 files:write 权限。请更新 Manifest、重新安装应用并重新连接机器人后重试。`;
|
||||
}
|
||||
if (descriptor?.key === 'discord') {
|
||||
return `结果文件「${name}」已生成,但机器人缺少 Discord 的 Send Messages、Attach Files 或 Read Message History 权限。`;
|
||||
}
|
||||
if (descriptor?.key === 'telegram') {
|
||||
return `结果文件「${name}」已生成,但 Telegram 不允许机器人在当前聊天发送文档,请检查聊天权限。`;
|
||||
}
|
||||
return `结果文件「${name}」已生成,但当前机器人没有文件发送权限,请检查渠道权限。`;
|
||||
case 'artifact-too-large':
|
||||
return `结果文件「${name}」超过当前渠道大小上限,未发送。`;
|
||||
case 'artifact-empty':
|
||||
return `结果文件「${name}」为空,未发送。`;
|
||||
case 'artifact-invalid':
|
||||
case 'artifact-changed':
|
||||
case 'artifact-unavailable':
|
||||
return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
|
||||
case 'artifact-rate-limited':
|
||||
return `结果文件「${name}」暂时被当前渠道限流,未能发送,请稍后重试。`;
|
||||
case 'artifact-provider-rejected':
|
||||
return `结果文件「${name}」已生成,但当前渠道拒绝了该文件或文件消息。`;
|
||||
default:
|
||||
return `结果文件「${name}」已生成,但当前渠道暂时未能发送,请稍后重试。`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createTextBridgeStatus() {
|
||||
return {
|
||||
messagesReceived: 0,
|
||||
|
|
@ -285,6 +332,76 @@ export class TextHarnessBridge {
|
|||
});
|
||||
}
|
||||
|
||||
async #deliverArtifacts(target, replyTo, artifacts = [], baseReceipt) {
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
let userVisible = Boolean(baseReceipt);
|
||||
for (const artifact of artifacts) {
|
||||
this.#signal?.throwIfAborted();
|
||||
try {
|
||||
if (typeof this.#bot.sendFile !== 'function') {
|
||||
const unavailable = new Error('Native file delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
const file = await materializeOutboundArtifact(artifact, {
|
||||
signal: this.#signal,
|
||||
});
|
||||
this.#signal?.throwIfAborted();
|
||||
const result = await this.#bot.sendFile(target, file);
|
||||
receipts.push(createDeliveryReceipt({
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: `${this.#descriptor.key}-file`,
|
||||
providerMessageIds: providerMessageIdsFor(result),
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
}));
|
||||
userVisible = true;
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw error;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:${this.#descriptor.key}] result file delivery failed (${error?.code ?? 'unknown'})`,
|
||||
);
|
||||
let providerMessageIds = [];
|
||||
let noticeSent = false;
|
||||
try {
|
||||
const notice = await this.#bot.sendText(
|
||||
target,
|
||||
artifactFailureText(artifact?.fileName, error, this.#descriptor),
|
||||
);
|
||||
providerMessageIds = providerMessageIdsFor(notice);
|
||||
noticeSent = true;
|
||||
} catch {
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:${this.#descriptor.key}] unable to send the safe result-file failure notice`,
|
||||
);
|
||||
}
|
||||
const failureReceipt = createArtifactFailureReceipt({
|
||||
artifactId: artifact?.artifactId ?? 'unknown',
|
||||
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
||||
error,
|
||||
providerMessageIds,
|
||||
});
|
||||
receipts.push(failureReceipt);
|
||||
if (noticeSent || failureReceipt.artifacts[0]?.outcome === 'unknown') userVisible = true;
|
||||
} finally {
|
||||
releaseOutboundArtifact(artifact);
|
||||
}
|
||||
}
|
||||
const receipt = receipts.length === 0
|
||||
? null
|
||||
: receipts.length === 1
|
||||
? receipts[0]
|
||||
: mergeDeliveryReceipts({
|
||||
deliveryId: replyTo,
|
||||
presentation: baseReceipt
|
||||
? `${this.#descriptor.key}-text-and-files`
|
||||
: `${this.#descriptor.key}-files`,
|
||||
receipts,
|
||||
});
|
||||
return { receipt, userVisible };
|
||||
}
|
||||
|
||||
async #process(message, messageId, senderId, conversationKey, {
|
||||
alreadyRecorded = false,
|
||||
} = {}) {
|
||||
|
|
@ -386,7 +503,7 @@ export class TextHarnessBridge {
|
|||
const content = hasImages
|
||||
? await promptContentForMessage(message, { signal: this.#signal })
|
||||
: undefined;
|
||||
const { answer } = await askInWorkspaceSession({
|
||||
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key: conversationKey,
|
||||
|
|
@ -412,10 +529,23 @@ export class TextHarnessBridge {
|
|||
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
||||
},
|
||||
});
|
||||
const visibleAnswer = !cleanText(answer) && artifacts.length > 0
|
||||
? FILE_ONLY_COMPLETION_TEXT
|
||||
: answer;
|
||||
let textDeliveryError = null;
|
||||
let textReceipt = null;
|
||||
if (stream) {
|
||||
try {
|
||||
await stream.finish(answer);
|
||||
const result = await stream.finish(visibleAnswer);
|
||||
streamFinished = true;
|
||||
textReceipt = createDeliveryReceipt({
|
||||
deliveryId: messageId,
|
||||
presentation: `${this.#descriptor.key}-stream`,
|
||||
providerMessageIds: [
|
||||
...providerMessageIdsFor(stream),
|
||||
...providerMessageIdsFor(result),
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
stream.cancel?.();
|
||||
this.#logger.warn?.(
|
||||
|
|
@ -424,10 +554,26 @@ export class TextHarnessBridge {
|
|||
);
|
||||
}
|
||||
}
|
||||
if (!streamFinished) await this.#bot.sendText(target, answer);
|
||||
if (!streamFinished) {
|
||||
try {
|
||||
const result = await this.#bot.sendText(target, visibleAnswer);
|
||||
textReceipt = createDeliveryReceipt({
|
||||
deliveryId: messageId,
|
||||
presentation: `${this.#descriptor.key}-text`,
|
||||
providerMessageIds: providerMessageIdsFor(result),
|
||||
});
|
||||
} catch (error) {
|
||||
textDeliveryError = error;
|
||||
}
|
||||
}
|
||||
// A failed final text must not discard an already registered result file.
|
||||
// Settle the independent attachment path before surfacing the text error.
|
||||
const delivery = await this.#deliverArtifacts(target, messageId, artifacts, textReceipt);
|
||||
if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
|
||||
this.#status.messagesReplied += 1;
|
||||
this.#status.lastReplyAt = new Date().toISOString();
|
||||
this.#status.lastError = null;
|
||||
return delivery.receipt;
|
||||
} catch (error) {
|
||||
if (error?.code === 'turn-stopped') {
|
||||
if (stream) {
|
||||
|
|
|
|||
|
|
@ -59,9 +59,23 @@ export async function askInWorkspaceSession({
|
|||
return { sessionId, session };
|
||||
});
|
||||
if (!binding) continue;
|
||||
const artifacts = [];
|
||||
const originalOnArtifact = typeof askOptions === 'object'
|
||||
&& typeof askOptions?.onArtifact === 'function'
|
||||
? askOptions.onArtifact
|
||||
: null;
|
||||
const artifactOptions = typeof askOptions === 'number'
|
||||
? { timeoutMs: askOptions }
|
||||
: { ...askOptions };
|
||||
artifactOptions.onArtifact = async (artifact) => {
|
||||
artifacts.push(artifact);
|
||||
await originalOnArtifact?.(artifact);
|
||||
};
|
||||
const answer = await binding.session.ask(content ?? text, artifactOptions);
|
||||
return {
|
||||
sessionId: binding.sessionId,
|
||||
answer: await binding.session.ask(content ?? text, askOptions),
|
||||
answer,
|
||||
...(artifacts.length > 0 ? { artifacts } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.code !== WORKSPACE_SESSION_STALE) throw error;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ oauth_config:
|
|||
- app_mentions:read
|
||||
- chat:write
|
||||
- files:read
|
||||
- files:write
|
||||
- im:history
|
||||
settings:
|
||||
event_subscriptions:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ 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]);
|
||||
const SLACK_UPLOAD_PATH_PREFIX = '/upload/';
|
||||
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
|
|
@ -15,6 +17,47 @@ function requestSignal(signal, timeoutMs) {
|
|||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
}
|
||||
|
||||
function abortReason(signal) {
|
||||
return signal?.reason instanceof Error
|
||||
? signal.reason
|
||||
: new DOMException('The operation was aborted', 'AbortError');
|
||||
}
|
||||
|
||||
function positiveTimeout(value, name) {
|
||||
if (!Number.isInteger(value) || value < 1) throw new TypeError(`${name} must be a positive integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function preserveProviderMetadata(target, source) {
|
||||
if (source?.providerCode !== undefined) target.providerCode = source.providerCode;
|
||||
if (Number.isInteger(source?.status)) target.status = source.status;
|
||||
return target;
|
||||
}
|
||||
|
||||
function slackArtifactPreparationError(cause) {
|
||||
let code = 'artifact-provider-failed';
|
||||
let message = 'Slack file upload preparation failed';
|
||||
if (cause?.code === 'slack-file_upload_size_restricted') {
|
||||
code = 'artifact-too-large';
|
||||
message = 'Slack rejected the result file because it is too large';
|
||||
} else if (cause?.code === 'slack-missing-scope') {
|
||||
code = 'artifact-permission-required';
|
||||
message = 'Slack file delivery requires the files:write scope';
|
||||
} else if (cause?.code?.startsWith?.('slack-')) {
|
||||
code = 'artifact-provider-rejected';
|
||||
message = 'Slack rejected file upload preparation';
|
||||
}
|
||||
const error = new Error(message, { cause });
|
||||
error.code = code;
|
||||
return preserveProviderMetadata(error, cause);
|
||||
}
|
||||
|
||||
function uncertainSlackDelivery(cause) {
|
||||
const error = new Error('Slack file completion result is uncertain', { cause });
|
||||
error.code = 'artifact-delivery-uncertain';
|
||||
return preserveProviderMetadata(error, cause);
|
||||
}
|
||||
|
||||
function isRedirectStatus(status) {
|
||||
return Number.isInteger(status) && status >= 300 && status < 400;
|
||||
}
|
||||
|
|
@ -43,6 +86,17 @@ function secureSlackFileUrl(value) {
|
|||
return url;
|
||||
}
|
||||
|
||||
function secureSlackUploadUrl(value) {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== 'https:' || url.username || url.password
|
||||
|| (url.port && url.port !== '443') || url.hostname !== SLACK_FILE_HOST
|
||||
|| !url.pathname.startsWith(SLACK_UPLOAD_PATH_PREFIX)) {
|
||||
throw new Error('Slack returned an unsafe file upload URL');
|
||||
}
|
||||
url.hash = '';
|
||||
return url;
|
||||
}
|
||||
|
||||
function redirectUrl(response, source) {
|
||||
const location = response?.headers?.get?.('location');
|
||||
if (!location) return null;
|
||||
|
|
@ -111,6 +165,7 @@ function safeOutgoingText(value, { trim = true } = {}) {
|
|||
function apiFailure(method, payload, tokenKind) {
|
||||
const reason = cleanString(payload?.error) ?? 'unknown_error';
|
||||
const error = new Error(`Slack ${method} failed: ${reason.replaceAll('_', ' ')}`);
|
||||
error.providerCode = reason;
|
||||
if (['invalid_auth', 'not_authed', 'token_revoked', 'account_inactive'].includes(reason)) {
|
||||
error.code = tokenKind === 'app' ? 'slack-invalid-app-token' : 'slack-invalid-bot-token';
|
||||
} else if (reason === 'missing_scope') {
|
||||
|
|
@ -141,8 +196,15 @@ export class SlackApi {
|
|||
#fetch;
|
||||
#baseUrl;
|
||||
#botScopes = null;
|
||||
#fileUploadTimeoutMs;
|
||||
|
||||
constructor({ botToken, appToken, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) {
|
||||
constructor({
|
||||
botToken,
|
||||
appToken,
|
||||
fetchImpl = fetch,
|
||||
baseUrl = DEFAULT_BASE_URL,
|
||||
fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
|
||||
}) {
|
||||
if (botToken !== undefined && !validSlackBotToken(botToken)) {
|
||||
throw new TypeError('Slack Bot Token is invalid');
|
||||
}
|
||||
|
|
@ -155,6 +217,7 @@ export class SlackApi {
|
|||
this.#appToken = appToken?.trim();
|
||||
this.#fetch = fetchImpl;
|
||||
this.#baseUrl = new URL(baseUrl);
|
||||
this.#fileUploadTimeoutMs = positiveTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
|
||||
}
|
||||
|
||||
authTest(options = {}) {
|
||||
|
|
@ -239,6 +302,99 @@ export class SlackApi {
|
|||
});
|
||||
}
|
||||
|
||||
async uploadFile({ channelId, threadTs, file, signal }) {
|
||||
if (!file || typeof file !== 'object'
|
||||
|| typeof file.fileName !== 'string' || !file.fileName
|
||||
|| !Buffer.isBuffer(file.bytes)) {
|
||||
throw new TypeError('A Slack file is required');
|
||||
}
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
const uploadSignal = requestSignal(signal, this.#fileUploadTimeoutMs);
|
||||
let ticket;
|
||||
try {
|
||||
ticket = await this.#request('files.getUploadURLExternal', {
|
||||
tokenKind: 'bot',
|
||||
signal: uploadSignal,
|
||||
timeoutMs: this.#fileUploadTimeoutMs,
|
||||
body: { filename: file.fileName, length: file.bytes.byteLength },
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
throw slackArtifactPreparationError(error);
|
||||
}
|
||||
let fileId;
|
||||
let uploadUrl;
|
||||
try {
|
||||
fileId = requiredString(ticket?.file_id, 'file id');
|
||||
uploadUrl = secureSlackUploadUrl(ticket?.upload_url);
|
||||
} catch (error) {
|
||||
throw slackArtifactPreparationError(error);
|
||||
}
|
||||
let uploaded;
|
||||
try {
|
||||
uploaded = await this.#fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': file.mediaType ?? 'application/octet-stream' },
|
||||
body: file.bytes,
|
||||
signal: uploadSignal,
|
||||
redirect: 'error',
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
throw slackArtifactPreparationError(error);
|
||||
}
|
||||
if (!Number.isInteger(uploaded?.status)) {
|
||||
throw slackArtifactPreparationError(new Error('Slack file upload returned an invalid response'));
|
||||
}
|
||||
if (uploaded.status !== 200) {
|
||||
await cancelResponseBody(uploaded);
|
||||
const error = new Error(`Slack file upload failed with HTTP ${uploaded.status}`);
|
||||
error.status = uploaded.status;
|
||||
if (uploaded.status === 413) {
|
||||
error.code = 'artifact-too-large';
|
||||
} else if (uploaded.status === 429) {
|
||||
error.code = 'artifact-rate-limited';
|
||||
} else {
|
||||
error.code = 'artifact-provider-rejected';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await cancelResponseBody(uploaded);
|
||||
|
||||
let completionBody;
|
||||
try {
|
||||
completionBody = {
|
||||
files: [{ id: fileId, title: file.fileName }],
|
||||
channel_id: slackId(channelId, 'channel id'),
|
||||
...(threadTs ? { thread_ts: requiredString(threadTs, 'thread timestamp') } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
throw slackArtifactPreparationError(error);
|
||||
}
|
||||
if (uploadSignal.aborted) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
throw slackArtifactPreparationError(uploadSignal.reason);
|
||||
}
|
||||
try {
|
||||
const completed = await this.#request('files.completeUploadExternal', {
|
||||
tokenKind: 'bot',
|
||||
signal: uploadSignal,
|
||||
timeoutMs: this.#fileUploadTimeoutMs,
|
||||
retry: false,
|
||||
body: completionBody,
|
||||
});
|
||||
if (!Array.isArray(completed?.files)
|
||||
|| !completed.files.some((entry) => cleanString(entry?.id) === fileId)) {
|
||||
throw new Error('Slack file completion did not confirm the uploaded file');
|
||||
}
|
||||
return completed;
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
if (error?.code === 'slack-missing-scope') throw slackArtifactPreparationError(error);
|
||||
throw uncertainSlackDelivery(error);
|
||||
}
|
||||
}
|
||||
|
||||
async downloadFile({ url, signal, maxBytes }) {
|
||||
if (!this.#botToken) throw new TypeError('Slack bot token is required for file download');
|
||||
const target = secureSlackFileUrl(url);
|
||||
|
|
@ -276,18 +432,21 @@ 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';
|
||||
let response;
|
||||
try {
|
||||
response = await this.#fetch(new URL(method, this.#baseUrl), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': body === undefined
|
||||
'content-type': body === undefined || formEncoded
|
||||
? 'application/x-www-form-urlencoded;charset=utf-8'
|
||||
: 'application/json;charset=utf-8',
|
||||
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.2.2)',
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
...(body === undefined ? {} : {
|
||||
body: formEncoded ? new URLSearchParams(body).toString() : JSON.stringify(body),
|
||||
}),
|
||||
signal: requestSignal(signal, timeoutMs),
|
||||
redirect: 'error',
|
||||
});
|
||||
|
|
@ -312,7 +471,11 @@ export class SlackApi {
|
|||
await delay(Math.min(10_000, Math.max(100, seconds * 1_000)), signal);
|
||||
return this.#request(method, { tokenKind, body, signal, timeoutMs, retry: false });
|
||||
}
|
||||
if (!response.ok || payload?.ok !== true) throw apiFailure(method, payload, tokenKind);
|
||||
if (!response.ok || payload?.ok !== true) {
|
||||
const error = apiFailure(method, payload, tokenKind);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ async function createSlackMessageStream({ api, target, signal, logger }) {
|
|||
let inFlight = null;
|
||||
let broken = false;
|
||||
let closed = false;
|
||||
const providerMessageIds = [ts];
|
||||
|
||||
const appendLatest = async (text) => {
|
||||
const next = splitMessageText(text, SLACK_MESSAGE_LIMIT)[0] ?? '';
|
||||
|
|
@ -150,6 +151,10 @@ async function createSlackMessageStream({ api, target, signal, logger }) {
|
|||
};
|
||||
|
||||
return {
|
||||
messageId: ts,
|
||||
get providerMessageIds() {
|
||||
return [...providerMessageIds];
|
||||
},
|
||||
update(text) {
|
||||
if (closed || broken || typeof text !== 'string' || !text.trim() || isToolProgress(text)) return;
|
||||
pending = text;
|
||||
|
|
@ -173,12 +178,13 @@ async function createSlackMessageStream({ api, target, signal, logger }) {
|
|||
await api.updateMessage({ channelId: target.channelId, ts, text: first, signal });
|
||||
}
|
||||
for (const chunk of chunks.slice(1)) {
|
||||
await api.postMessage({
|
||||
const result = await api.postMessage({
|
||||
channelId: target.channelId,
|
||||
threadTs: target.threadTs,
|
||||
text: chunk,
|
||||
signal,
|
||||
});
|
||||
if (typeof result?.ts === 'string' && result.ts) providerMessageIds.push(result.ts);
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
|
|
@ -191,7 +197,7 @@ async function createSlackMessageStream({ api, target, signal, logger }) {
|
|||
};
|
||||
}
|
||||
|
||||
class SlackBotClient {
|
||||
export class SlackBotClient {
|
||||
#api;
|
||||
#signal;
|
||||
#logger;
|
||||
|
|
@ -204,16 +210,17 @@ class SlackBotClient {
|
|||
|
||||
async sendText(target, text) {
|
||||
const chunks = splitMessageText(text, SLACK_MESSAGE_LIMIT);
|
||||
let result = null;
|
||||
const providerMessageIds = [];
|
||||
for (const chunk of chunks) {
|
||||
result = await this.#api.postMessage({
|
||||
const result = await this.#api.postMessage({
|
||||
channelId: target.channelId,
|
||||
threadTs: target.threadTs,
|
||||
text: chunk,
|
||||
signal: this.#signal,
|
||||
});
|
||||
if (typeof result?.ts === 'string' && result.ts) providerMessageIds.push(result.ts);
|
||||
}
|
||||
return result;
|
||||
return { providerMessageIds };
|
||||
}
|
||||
|
||||
openStream(target) {
|
||||
|
|
@ -224,6 +231,15 @@ class SlackBotClient {
|
|||
logger: this.#logger,
|
||||
});
|
||||
}
|
||||
|
||||
sendFile(target, file) {
|
||||
return this.#api.uploadFile({
|
||||
channelId: target.channelId,
|
||||
threadTs: target.threadTs,
|
||||
file,
|
||||
signal: this.#signal,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createSlackRuntimeStatus() {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,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']);
|
||||
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
|
|
@ -12,6 +13,58 @@ function requestSignal(signal, timeoutMs) {
|
|||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
}
|
||||
|
||||
function abortReason(signal) {
|
||||
return signal?.reason instanceof Error
|
||||
? signal.reason
|
||||
: new DOMException('The operation was aborted', 'AbortError');
|
||||
}
|
||||
|
||||
function positiveTimeout(value, name) {
|
||||
if (!Number.isInteger(value) || value < 1) throw new TypeError(`${name} must be a positive integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function preserveProviderMetadata(target, source) {
|
||||
if (source?.providerCode !== undefined) target.providerCode = source.providerCode;
|
||||
if (source?.retry_after !== undefined) {
|
||||
target.retry_after = source.retry_after;
|
||||
target.retryAfter = source.retry_after;
|
||||
}
|
||||
if (Number.isInteger(source?.status)) target.status = source.status;
|
||||
return target;
|
||||
}
|
||||
|
||||
function telegramArtifactProviderError(cause) {
|
||||
const providerCode = Number(cause?.providerCode);
|
||||
const status = Number(cause?.status);
|
||||
const message = cleanString(cause?.message) ?? '';
|
||||
let code = 'artifact-provider-rejected';
|
||||
let summary = 'Telegram rejected the document.';
|
||||
if (providerCode === 401 || providerCode === 403 || status === 401 || status === 403) {
|
||||
code = 'artifact-permission-required';
|
||||
summary = 'Telegram denied permission to send the document.';
|
||||
} else if (providerCode === 413 || status === 413
|
||||
|| /(?:file|request|entity).{0,20}(?:too (?:big|large)|size limit)|too (?:big|large)/i.test(message)) {
|
||||
code = 'artifact-too-large';
|
||||
summary = 'The document exceeds Telegram\'s size limit.';
|
||||
} else if (providerCode === 429 || status === 429) {
|
||||
code = 'artifact-rate-limited';
|
||||
summary = 'Telegram rate-limited document delivery.';
|
||||
} else if (providerCode >= 500 || status >= 500) {
|
||||
code = 'artifact-delivery-uncertain';
|
||||
summary = 'Telegram document delivery result is uncertain.';
|
||||
}
|
||||
const error = new Error(summary, { cause });
|
||||
error.code = code;
|
||||
return preserveProviderMetadata(error, cause);
|
||||
}
|
||||
|
||||
function uncertainTelegramDelivery(cause) {
|
||||
const error = new Error('Telegram document delivery result is uncertain', { cause });
|
||||
error.code = 'artifact-delivery-uncertain';
|
||||
return preserveProviderMetadata(error, cause);
|
||||
}
|
||||
|
||||
export function validTelegramToken(value) {
|
||||
return typeof value === 'string' && /^\d{5,20}:[A-Za-z0-9_-]{20,}$/.test(value.trim());
|
||||
}
|
||||
|
|
@ -32,13 +85,20 @@ export class TelegramApi {
|
|||
#token;
|
||||
#fetch;
|
||||
#baseUrl;
|
||||
#fileUploadTimeoutMs;
|
||||
|
||||
constructor({ token, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) {
|
||||
constructor({
|
||||
token,
|
||||
fetchImpl = fetch,
|
||||
baseUrl = DEFAULT_BASE_URL,
|
||||
fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
|
||||
}) {
|
||||
if (!validTelegramToken(token)) throw new TypeError('Telegram Bot Token is invalid');
|
||||
if (typeof fetchImpl !== 'function') throw new TypeError('TelegramApi requires fetch');
|
||||
this.#token = token.trim();
|
||||
this.#fetch = fetchImpl;
|
||||
this.#baseUrl = new URL(baseUrl);
|
||||
this.#fileUploadTimeoutMs = positiveTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
|
||||
}
|
||||
|
||||
async getMe(options = {}) {
|
||||
|
|
@ -108,6 +168,43 @@ export class TelegramApi {
|
|||
}, { signal });
|
||||
}
|
||||
|
||||
async sendDocument({ chatId, file, replyToMessageId, messageThreadId, signal }) {
|
||||
if (!file || typeof file !== 'object'
|
||||
|| typeof file.fileName !== 'string' || !file.fileName
|
||||
|| !Buffer.isBuffer(file.bytes)) {
|
||||
throw new TypeError('A Telegram document is required');
|
||||
}
|
||||
const payload = new FormData();
|
||||
payload.append('chat_id', String(chatId));
|
||||
payload.append(
|
||||
'document',
|
||||
new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
|
||||
file.fileName,
|
||||
);
|
||||
if (replyToMessageId) {
|
||||
payload.append('reply_parameters', JSON.stringify({
|
||||
message_id: replyToMessageId,
|
||||
allow_sending_without_reply: true,
|
||||
}));
|
||||
}
|
||||
if (messageThreadId) payload.append('message_thread_id', String(messageThreadId));
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
const uploadSignal = requestSignal(signal, this.#fileUploadTimeoutMs);
|
||||
try {
|
||||
return await this.#call('sendDocument', payload, {
|
||||
signal: uploadSignal,
|
||||
timeoutMs: this.#fileUploadTimeoutMs,
|
||||
multipart: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
if (error?.code?.startsWith?.('telegram-')) {
|
||||
throw telegramArtifactProviderError(error);
|
||||
}
|
||||
throw uncertainTelegramDelivery(error);
|
||||
}
|
||||
}
|
||||
|
||||
async editMessageText({ chatId, messageId, text, signal }) {
|
||||
return this.#call('editMessageText', {
|
||||
chat_id: chatId,
|
||||
|
|
@ -144,15 +241,15 @@ export class TelegramApi {
|
|||
return this.#call('setChatMenuButton', { menu_button: menuButton }, { signal });
|
||||
}
|
||||
|
||||
async #call(method, payload, { signal, timeoutMs = 15_000 } = {}) {
|
||||
async #call(method, payload, { signal, timeoutMs = 15_000, multipart = false } = {}) {
|
||||
const url = new URL(this.#baseUrl);
|
||||
url.pathname = `${url.pathname.replace(/\/$/, '')}/bot${this.#token}/${method}`;
|
||||
let response;
|
||||
try {
|
||||
response = await this.#fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
...(multipart ? {} : { headers: { 'content-type': 'application/json' } }),
|
||||
body: multipart ? payload : JSON.stringify(payload),
|
||||
signal: requestSignal(signal, timeoutMs),
|
||||
redirect: 'error',
|
||||
});
|
||||
|
|
@ -164,12 +261,21 @@ export class TelegramApi {
|
|||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new Error(`Telegram ${method} returned invalid JSON`);
|
||||
const error = new Error(`Telegram ${method} returned invalid JSON`);
|
||||
error.status = response?.status;
|
||||
throw error;
|
||||
}
|
||||
if (!response.ok || body?.ok !== true) {
|
||||
const description = cleanString(body?.description);
|
||||
const error = new Error(description ?? `Telegram ${method} failed`);
|
||||
error.code = Number.isInteger(body?.error_code) ? `telegram-${body.error_code}` : 'telegram-api-error';
|
||||
error.status = response.status;
|
||||
if (Number.isInteger(body?.error_code)) error.providerCode = body.error_code;
|
||||
const retryAfter = Number(body?.parameters?.retry_after);
|
||||
if (Number.isFinite(retryAfter) && retryAfter >= 0) {
|
||||
error.retry_after = retryAfter;
|
||||
error.retryAfter = retryAfter;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return body.result;
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ export function telegramInboundAllowed(message, {
|
|||
&& allowedPrivateUserIds.has(String(message.senderId));
|
||||
}
|
||||
|
||||
class TelegramBotClient {
|
||||
export class TelegramBotClient {
|
||||
#api;
|
||||
#signal;
|
||||
|
||||
|
|
@ -157,17 +157,20 @@ class TelegramBotClient {
|
|||
|
||||
async sendText(target, text) {
|
||||
const chunks = splitMessageText(text, 4_000);
|
||||
let result = null;
|
||||
const providerMessageIds = [];
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
result = await this.#api.sendMessage({
|
||||
const result = await this.#api.sendMessage({
|
||||
chatId: target.chatId,
|
||||
text: chunk,
|
||||
replyToMessageId: index === 0 ? target.replyToMessageId : undefined,
|
||||
messageThreadId: target.messageThreadId,
|
||||
signal: this.#signal,
|
||||
});
|
||||
if (Number.isSafeInteger(result?.message_id)) {
|
||||
providerMessageIds.push(String(result.message_id));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return { providerMessageIds };
|
||||
}
|
||||
|
||||
sendTyping(target) {
|
||||
|
|
@ -178,6 +181,16 @@ class TelegramBotClient {
|
|||
});
|
||||
}
|
||||
|
||||
sendFile(target, file) {
|
||||
return this.#api.sendDocument({
|
||||
chatId: target.chatId,
|
||||
file,
|
||||
replyToMessageId: target.replyToMessageId,
|
||||
messageThreadId: target.messageThreadId,
|
||||
signal: this.#signal,
|
||||
});
|
||||
}
|
||||
|
||||
async openStream(target) {
|
||||
const stream = createEditableMessageStream({
|
||||
limit: 4_000,
|
||||
|
|
@ -203,6 +216,7 @@ class TelegramBotClient {
|
|||
messageThreadId: target.messageThreadId,
|
||||
signal: this.#signal,
|
||||
}),
|
||||
messageIdForResult: (message) => message?.message_id,
|
||||
});
|
||||
return stream.start();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,18 @@ import {
|
|||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
||||
import {
|
||||
materializeOutboundArtifact,
|
||||
releaseOutboundArtifact,
|
||||
trackOutboundArtifactProviderPromise,
|
||||
} from '../shared/semantic/artifact.mjs';
|
||||
import {
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
|
||||
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
||||
|
||||
const HELP_TEXT = [
|
||||
'企业微信机器人已连接 DeepSeek Harness。',
|
||||
|
|
@ -212,6 +224,88 @@ function progressText(update) {
|
|||
return update?.text;
|
||||
}
|
||||
|
||||
function artifactFailureText(fileName, error) {
|
||||
const name = String(fileName ?? '结果文件').replace(/[\r\n]+/g, ' ').trim() || '结果文件';
|
||||
switch (error?.code) {
|
||||
case 'artifact-delivery-uncertain':
|
||||
return `结果文件「${name}」的发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
|
||||
case 'artifact-permission-required':
|
||||
return `结果文件「${name}」已生成,但企业微信智能机器人缺少素材上传或文件消息能力,请检查机器人权限。`;
|
||||
case 'artifact-too-large':
|
||||
return `结果文件「${name}」超过当前企业微信机器人可发送的文件大小,未发送。`;
|
||||
case 'artifact-empty':
|
||||
return `结果文件「${name}」为空,企业微信不允许发送空文件。`;
|
||||
case 'artifact-changed':
|
||||
case 'artifact-invalid':
|
||||
case 'artifact-unavailable':
|
||||
return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
|
||||
case 'artifact-rate-limited':
|
||||
return `结果文件「${name}」暂时被企业微信限流,未能发送,请稍后重试。`;
|
||||
case 'artifact-provider-rejected':
|
||||
return `结果文件「${name}」已生成,但企业微信拒绝了该文件或文件消息。`;
|
||||
default:
|
||||
return `结果文件「${name}」已生成,但暂时未能通过企业微信发送,请稍后重试。`;
|
||||
}
|
||||
}
|
||||
|
||||
function abortReason(signal) {
|
||||
return signal?.reason instanceof Error
|
||||
? signal.reason
|
||||
: new DOMException('The operation was aborted', 'AbortError');
|
||||
}
|
||||
|
||||
function waitWithSignal(promise, signal) {
|
||||
if (!signal) return promise;
|
||||
signal.throwIfAborted();
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
callback(value);
|
||||
};
|
||||
const onAbort = () => finish(reject, abortReason(signal));
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
Promise.resolve(promise).then(
|
||||
(value) => finish(resolve, value),
|
||||
(error) => finish(reject, error),
|
||||
);
|
||||
if (signal.aborted) onAbort();
|
||||
});
|
||||
}
|
||||
|
||||
function wecomArtifactError(error, { dispatched = false } = {}) {
|
||||
if (error?.code?.startsWith?.('artifact-')) return error;
|
||||
const status = Number(error?.httpStatus ?? error?.status ?? error?.response?.status);
|
||||
const providerCode = Number(error?.providerCode ?? error?.errcode ?? error?.body?.errcode);
|
||||
const wrapped = new Error('Enterprise WeChat file delivery failed', { cause: error });
|
||||
if (status === 401 || status === 403 || providerCode === 48002) {
|
||||
wrapped.code = 'artifact-permission-required';
|
||||
} else if (status === 413) {
|
||||
wrapped.code = 'artifact-too-large';
|
||||
} else if (status === 429 || providerCode === 45009) {
|
||||
wrapped.code = 'artifact-rate-limited';
|
||||
} else if (Number.isFinite(providerCode) && providerCode !== 0) {
|
||||
wrapped.code = 'artifact-provider-rejected';
|
||||
} else {
|
||||
wrapped.code = dispatched ? 'artifact-delivery-uncertain' : 'artifact-provider-failed';
|
||||
}
|
||||
if (Number.isFinite(status)) wrapped.status = status;
|
||||
if (Number.isFinite(providerCode)) wrapped.providerCode = providerCode;
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
function answerTextForDelivery(answer, artifacts) {
|
||||
if (typeof answer === 'string' && answer.trim()) return answer;
|
||||
return artifacts.length > 0 ? '结果文件已生成。' : '任务已完成,但没有生成可显示的文本。';
|
||||
}
|
||||
|
||||
function providerMessageId(result) {
|
||||
return nonEmptyString(result?.body?.msgid)
|
||||
?? nonEmptyString(result?.body?.message_id);
|
||||
}
|
||||
|
||||
function canClaimInteractionReply(frame, pending) {
|
||||
return pending.questions[pending.index]
|
||||
&& nonEmptyString(bodyOf(frame).from?.userid) === pending.actor
|
||||
|
|
@ -223,6 +317,8 @@ export function createWecomBridgeStatus() {
|
|||
messagesReceived: 0,
|
||||
messagesReplied: 0,
|
||||
messagesRejected: 0,
|
||||
artifactsSent: 0,
|
||||
artifactSendErrors: 0,
|
||||
lastMessageAt: null,
|
||||
lastReplyAt: null,
|
||||
lastRejectedAt: null,
|
||||
|
|
@ -239,6 +335,7 @@ export class WecomHarnessBridge {
|
|||
#replyTimeoutMs;
|
||||
#generateReqId;
|
||||
#signal;
|
||||
#fileUploadTimeoutMs;
|
||||
#queues = new Map();
|
||||
#pendingInteractions = new Map();
|
||||
#interactionKeys = new Map();
|
||||
|
|
@ -256,12 +353,16 @@ export class WecomHarnessBridge {
|
|||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
generateStreamId = generateReqId,
|
||||
fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
|
||||
signal,
|
||||
}) {
|
||||
if (!client || typeof client.replyStream !== 'function' || typeof client.sendMessage !== 'function') {
|
||||
throw new TypeError('Enterprise WeChat client is required');
|
||||
}
|
||||
if (!harness || !state) throw new TypeError('Harness client and state store are required');
|
||||
if (!Number.isInteger(fileUploadTimeoutMs) || fileUploadTimeoutMs < 1) {
|
||||
throw new TypeError('fileUploadTimeoutMs must be a positive integer');
|
||||
}
|
||||
this.#client = client;
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
|
|
@ -269,6 +370,7 @@ export class WecomHarnessBridge {
|
|||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#generateReqId = generateStreamId;
|
||||
this.#fileUploadTimeoutMs = Math.min(fileUploadTimeoutMs, DEFAULT_FILE_UPLOAD_TIMEOUT_MS);
|
||||
this.#signal = signal;
|
||||
this.#approvals = new HarnessApprovalQueue({ label: 'wecom', logger });
|
||||
}
|
||||
|
|
@ -458,10 +560,17 @@ export class WecomHarnessBridge {
|
|||
}
|
||||
|
||||
async #sendActive(chatId, text) {
|
||||
const providerMessageIds = [];
|
||||
for (const chunk of splitUtf8(text)) {
|
||||
this.#signal?.throwIfAborted();
|
||||
await this.#client.sendMessage(chatId, { msgtype: 'markdown', markdown: { content: chunk } });
|
||||
const result = await this.#client.sendMessage(
|
||||
chatId,
|
||||
{ msgtype: 'markdown', markdown: { content: chunk } },
|
||||
);
|
||||
const messageId = providerMessageId(result);
|
||||
if (messageId) providerMessageIds.push(messageId);
|
||||
}
|
||||
return providerMessageIds;
|
||||
}
|
||||
|
||||
async #sendImmediate(frame, chatId, text) {
|
||||
|
|
@ -478,6 +587,105 @@ export class WecomHarnessBridge {
|
|||
}
|
||||
}
|
||||
|
||||
async #deliverArtifacts(chatId, replyTo, artifacts = [], baseReceipt = null) {
|
||||
if (artifacts.length === 0) {
|
||||
return { receipt: baseReceipt, failureNoticeVisible: false };
|
||||
}
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
let failureNoticeVisible = false;
|
||||
for (const artifact of artifacts) {
|
||||
this.#signal?.throwIfAborted();
|
||||
try {
|
||||
if (typeof this.#client.uploadMedia !== 'function'
|
||||
|| typeof this.#client.sendMediaMessage !== 'function') {
|
||||
const unavailable = new Error('Enterprise WeChat file delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
const file = await materializeOutboundArtifact(artifact, {
|
||||
signal: this.#signal,
|
||||
});
|
||||
this.#signal?.throwIfAborted();
|
||||
const timeout = AbortSignal.timeout(this.#fileUploadTimeoutMs);
|
||||
const waitSignal = this.#signal ? AbortSignal.any([this.#signal, timeout]) : timeout;
|
||||
let uploaded;
|
||||
try {
|
||||
const pending = this.#client.uploadMedia(file.bytes, {
|
||||
type: 'file',
|
||||
filename: file.fileName,
|
||||
});
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
uploaded = await waitWithSignal(pending, waitSignal);
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw abortReason(this.#signal);
|
||||
throw wecomArtifactError(error);
|
||||
}
|
||||
this.#signal?.throwIfAborted();
|
||||
const mediaId = nonEmptyString(uploaded?.media_id);
|
||||
if (!mediaId) {
|
||||
const rejected = new Error('Enterprise WeChat upload returned no media id');
|
||||
rejected.code = 'artifact-provider-rejected';
|
||||
throw rejected;
|
||||
}
|
||||
let sent;
|
||||
try {
|
||||
const pending = this.#client.sendMediaMessage(chatId, 'file', mediaId);
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
sent = await waitWithSignal(pending, waitSignal);
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw abortReason(this.#signal);
|
||||
throw wecomArtifactError(error, { dispatched: true });
|
||||
}
|
||||
this.#signal?.throwIfAborted();
|
||||
const providerCode = Number(sent?.body?.errcode ?? sent?.errcode);
|
||||
if (Number.isFinite(providerCode) && providerCode !== 0) {
|
||||
throw wecomArtifactError({ providerCode });
|
||||
}
|
||||
const messageId = providerMessageId(sent);
|
||||
receipts.push(createDeliveryReceipt({
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'wecom-file',
|
||||
providerMessageIds: messageId ? [messageId] : [],
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
}));
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw error;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:wecom] result file delivery failed (${error?.code ?? 'unknown'})`,
|
||||
);
|
||||
let providerMessageIds = [];
|
||||
try {
|
||||
providerMessageIds = await this.#sendActive(
|
||||
chatId,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
);
|
||||
failureNoticeVisible = true;
|
||||
} catch (noticeError) {
|
||||
if (this.#signal?.aborted) throw noticeError;
|
||||
this.#logger.warn?.('[dsh-im:wecom] unable to send the safe result-file failure notice');
|
||||
}
|
||||
receipts.push(createArtifactFailureReceipt({
|
||||
artifactId: artifact?.artifactId ?? 'unknown',
|
||||
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
||||
error,
|
||||
providerMessageIds,
|
||||
}));
|
||||
} finally {
|
||||
releaseOutboundArtifact(artifact);
|
||||
}
|
||||
}
|
||||
return {
|
||||
receipt: mergeDeliveryReceipts({
|
||||
deliveryId: replyTo,
|
||||
presentation: baseReceipt ? 'wecom-text-and-files' : 'wecom-files',
|
||||
receipts,
|
||||
}),
|
||||
failureNoticeVisible,
|
||||
};
|
||||
}
|
||||
|
||||
async #process(frame, { alreadyRecorded = false, preparedMessage } = {}) {
|
||||
if (this.#signal?.aborted) return;
|
||||
const body = bodyOf(frame);
|
||||
|
|
@ -556,7 +764,7 @@ export class WecomHarnessBridge {
|
|||
const content = hasImages
|
||||
? await promptContentForMessage(message, { signal: this.#signal })
|
||||
: undefined;
|
||||
const { answer } = await askInWorkspaceSession({
|
||||
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
|
|
@ -584,24 +792,64 @@ export class WecomHarnessBridge {
|
|||
},
|
||||
});
|
||||
|
||||
const chunks = splitUtf8(answer || '任务已完成,但没有生成可显示的文本。');
|
||||
this.#signal?.throwIfAborted();
|
||||
const displayAnswer = answerTextForDelivery(answer, artifacts);
|
||||
const chunks = splitUtf8(displayAnswer);
|
||||
let finalSent = false;
|
||||
let textReceipt = null;
|
||||
let textSendError = null;
|
||||
try {
|
||||
if (streamStarted && chunks.length > 0) {
|
||||
try {
|
||||
await this.#client.replyStream(frame, streamId, chunks[0], true);
|
||||
const providerMessageIds = [];
|
||||
const streamed = await this.#client.replyStream(frame, streamId, chunks[0], true);
|
||||
const streamedMessageId = providerMessageId(streamed);
|
||||
if (streamedMessageId) providerMessageIds.push(streamedMessageId);
|
||||
for (const chunk of chunks.slice(1)) {
|
||||
await this.#client.sendMessage(chatId, { msgtype: 'markdown', markdown: { content: chunk } });
|
||||
const sent = await this.#client.sendMessage(
|
||||
chatId,
|
||||
{ msgtype: 'markdown', markdown: { content: chunk } },
|
||||
);
|
||||
const messageId = providerMessageId(sent);
|
||||
if (messageId) providerMessageIds.push(messageId);
|
||||
}
|
||||
finalSent = true;
|
||||
textReceipt = createDeliveryReceipt({
|
||||
deliveryId: messageId,
|
||||
presentation: 'wecom-text',
|
||||
providerMessageIds,
|
||||
});
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-im:wecom] stream finalization failed; using an active reply:', error);
|
||||
}
|
||||
}
|
||||
if (!finalSent) await this.#sendActive(chatId, answer);
|
||||
if (!finalSent) {
|
||||
const providerMessageIds = await this.#sendActive(chatId, displayAnswer);
|
||||
textReceipt = createDeliveryReceipt({
|
||||
deliveryId: messageId,
|
||||
presentation: 'wecom-text',
|
||||
providerMessageIds,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
textSendError = error;
|
||||
this.#logger.warn?.(
|
||||
'[dsh-im:wecom] final text delivery failed; continuing with result files:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
const delivery = await this.#deliverArtifacts(chatId, messageId, artifacts, textReceipt);
|
||||
const artifactDispatched = delivery.receipt?.artifacts?.some(
|
||||
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
|
||||
);
|
||||
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
|
||||
throw textSendError;
|
||||
}
|
||||
await this.#state.markSeen(messageId);
|
||||
this.#status.messagesReplied += 1;
|
||||
this.#status.lastReplyAt = new Date().toISOString();
|
||||
this.#status.lastError = null;
|
||||
return delivery.receipt;
|
||||
} catch (error) {
|
||||
if (error?.code === 'turn-stopped') {
|
||||
if (streamStarted && streamId) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
randomUUID,
|
||||
} from 'node:crypto';
|
||||
|
||||
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
|
||||
|
||||
|
|
@ -13,6 +19,7 @@ const ILINK_APP_ID = 'bot';
|
|||
const ILINK_CLIENT_VERSION = (2 << 16) | (4 << 8) | 6;
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
||||
const WEIXIN_CDN_UPLOAD_RETRIES = 3;
|
||||
const LOGIN_STATUSES = new Set([
|
||||
'wait',
|
||||
'scaned',
|
||||
|
|
@ -30,6 +37,7 @@ export class WeixinApiError extends Error {
|
|||
this.name = 'WeixinApiError';
|
||||
this.code = code;
|
||||
this.status = options.status;
|
||||
this.providerCode = options.providerCode;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -37,6 +45,70 @@ function nonEmptyString(value) {
|
|||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function safeProviderCode(value) {
|
||||
const code = value === undefined || value === null ? null : String(value).trim();
|
||||
return code && /^-?[A-Za-z0-9_.:-]{1,160}$/.test(code) ? code : undefined;
|
||||
}
|
||||
|
||||
function preserveArtifactMetadata(target, source) {
|
||||
if (Number.isInteger(source?.status)) target.status = source.status;
|
||||
if (source?.providerCode !== undefined) target.providerCode = source.providerCode;
|
||||
return target;
|
||||
}
|
||||
|
||||
function weixinArtifactError(cause, { fallback = 'artifact-provider-rejected' } = {}) {
|
||||
if (cause?.code?.startsWith?.('artifact-')) return cause;
|
||||
const status = Number(cause?.status);
|
||||
const providerCode = safeProviderCode(cause?.providerCode);
|
||||
const providerText = providerCode ?? '';
|
||||
let code = fallback;
|
||||
let message = 'Weixin could not prepare the file for delivery.';
|
||||
if (status === 401 || status === 403 || providerCode === '401' || providerCode === '403'
|
||||
|| /(?:permission|forbidden|unauthor|access.?denied)/i.test(providerText)) {
|
||||
code = 'artifact-permission-required';
|
||||
message = 'Weixin denied permission to send the file.';
|
||||
} else if (status === 413 || providerCode === '413'
|
||||
|| /(?:too.?large|size.?limit)/i.test(providerText)) {
|
||||
code = 'artifact-too-large';
|
||||
message = 'The file exceeds Weixin\'s size limit.';
|
||||
} else if (status === 429 || providerCode === '429'
|
||||
|| /(?:rate.?limit|too.?many)/i.test(providerText)) {
|
||||
code = 'artifact-rate-limited';
|
||||
message = 'Weixin rate-limited file delivery.';
|
||||
} else if (fallback === 'artifact-provider-rejected') {
|
||||
message = 'Weixin rejected the file message.';
|
||||
}
|
||||
const error = new Error(message, { cause });
|
||||
error.code = code;
|
||||
return preserveArtifactMetadata(error, cause);
|
||||
}
|
||||
|
||||
function uncertainWeixinDelivery(cause) {
|
||||
const error = new Error('Weixin file delivery result is uncertain', { cause });
|
||||
error.code = 'artifact-delivery-uncertain';
|
||||
return preserveArtifactMetadata(error, cause);
|
||||
}
|
||||
|
||||
function rejectedProviderResponse(value) {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
for (const field of ['ret', 'errcode']) {
|
||||
if (value[field] !== undefined && value[field] !== 0 && value[field] !== '0') {
|
||||
return safeProviderCode(value[field]) ?? 'rejected';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function classifyWeixinFinalDeliveryError(error, signal) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
const status = Number(error?.status);
|
||||
if (error?.code === 'network-error' || error?.code === 'timeout'
|
||||
|| error?.code === 'invalid-response' || (status >= 500 && status < 600)) {
|
||||
return uncertainWeixinDelivery(error);
|
||||
}
|
||||
return weixinArtifactError(error);
|
||||
}
|
||||
|
||||
function strictBase64(value) {
|
||||
const text = nonEmptyString(value);
|
||||
if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)) return null;
|
||||
|
|
@ -188,6 +260,89 @@ function baseInfo() {
|
|||
};
|
||||
}
|
||||
|
||||
function aesEcbPaddedSize(size) {
|
||||
return Math.ceil((size + 1) / 16) * 16;
|
||||
}
|
||||
|
||||
function trustedWeixinCdnUploadUrl(value) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new WeixinApiError('invalid-upload-url', '微信服务返回了无效的文件上传地址。');
|
||||
}
|
||||
if (url.protocol !== 'https:' || url.hostname !== WEIXIN_CDN_HOST
|
||||
|| (url.port && url.port !== '443') || url.pathname !== '/c2c/upload'
|
||||
|| url.username || url.password) {
|
||||
throw new WeixinApiError('untrusted-upload-url', '微信服务返回了不受信任的文件上传地址。');
|
||||
}
|
||||
url.hash = '';
|
||||
return url;
|
||||
}
|
||||
|
||||
function weixinCdnUploadUrl(response, fileKey) {
|
||||
const fullUrl = nonEmptyString(response?.upload_full_url);
|
||||
if (fullUrl) return trustedWeixinCdnUploadUrl(fullUrl);
|
||||
const uploadParam = nonEmptyString(response?.upload_param);
|
||||
if (!uploadParam) {
|
||||
throw new WeixinApiError('missing-upload-url', '微信服务没有返回文件上传地址。');
|
||||
}
|
||||
const url = new URL(`${WEIXIN_CDN_BASE_URL}/upload`);
|
||||
url.searchParams.set('encrypted_query_param', uploadParam);
|
||||
url.searchParams.set('filekey', fileKey);
|
||||
return trustedWeixinCdnUploadUrl(url);
|
||||
}
|
||||
|
||||
function encryptWeixinUpload(bytes, key) {
|
||||
const cipher = createCipheriv('aes-128-ecb', key, null);
|
||||
return Buffer.concat([cipher.update(bytes), cipher.final()]);
|
||||
}
|
||||
|
||||
async function uploadWeixinCdn(fetchImpl, url, ciphertext, { signal } = {}) {
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= WEIXIN_CDN_UPLOAD_RETRIES; attempt += 1) {
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/octet-stream' },
|
||||
body: ciphertext,
|
||||
signal: signal
|
||||
? AbortSignal.any([signal, AbortSignal.timeout(60_000)])
|
||||
: AbortSignal.timeout(60_000),
|
||||
redirect: 'error',
|
||||
});
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
throw new WeixinApiError(
|
||||
'upload-rejected',
|
||||
`微信文件上传被拒绝(HTTP ${response.status})。`,
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
if (response.status !== 200) {
|
||||
throw new WeixinApiError(
|
||||
'upload-failed',
|
||||
`微信文件上传失败(HTTP ${response.status})。`,
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
const downloadParam = nonEmptyString(response.headers.get('x-encrypted-param'));
|
||||
await response.body?.cancel?.().catch(() => undefined);
|
||||
if (!downloadParam) {
|
||||
throw new WeixinApiError('invalid-upload-response', '微信文件上传响应缺少下载参数。');
|
||||
}
|
||||
return downloadParam;
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
if (error instanceof WeixinApiError
|
||||
&& (error.code === 'upload-rejected' || error.status < 500)) throw error;
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
if (lastError instanceof WeixinApiError) throw lastError;
|
||||
throw new WeixinApiError('upload-failed', '微信文件上传失败。', { cause: lastError });
|
||||
}
|
||||
|
||||
function abortError(signal) {
|
||||
if (signal?.reason instanceof Error) return signal.reason;
|
||||
return new DOMException('The operation was aborted', 'AbortError');
|
||||
|
|
@ -349,6 +504,117 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
|||
return true;
|
||||
},
|
||||
|
||||
async sendFile({ baseUrl, token, toUserId, file, contextToken, runId, signal }) {
|
||||
const recipient = nonEmptyString(toUserId);
|
||||
if (!recipient || !file || typeof file !== 'object'
|
||||
|| typeof file.fileName !== 'string' || !file.fileName
|
||||
|| !Buffer.isBuffer(file.bytes)) {
|
||||
throw new TypeError('toUserId and a file are required');
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const fileKey = randomBytes(16).toString('hex');
|
||||
const aesKey = randomBytes(16);
|
||||
const rawMd5 = createHash('md5').update(file.bytes).digest('hex');
|
||||
let upload;
|
||||
try {
|
||||
upload = await requestJson(fetchImpl, {
|
||||
method: 'POST',
|
||||
baseUrl,
|
||||
endpoint: 'ilink/bot/getuploadurl',
|
||||
token,
|
||||
signal,
|
||||
body: {
|
||||
filekey: fileKey,
|
||||
media_type: 3,
|
||||
to_user_id: recipient,
|
||||
rawsize: file.bytes.byteLength,
|
||||
rawfilemd5: rawMd5,
|
||||
filesize: aesEcbPaddedSize(file.bytes.byteLength),
|
||||
no_need_thumb: true,
|
||||
aeskey: aesKey.toString('hex'),
|
||||
base_info: baseInfo(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
const status = Number(error?.status);
|
||||
const fallback = error?.code === 'http-error' && status >= 400 && status < 500
|
||||
? 'artifact-provider-rejected'
|
||||
: 'artifact-provider-failed';
|
||||
throw weixinArtifactError(error, { fallback });
|
||||
}
|
||||
const uploadRejection = rejectedProviderResponse(upload);
|
||||
if (uploadRejection) {
|
||||
throw weixinArtifactError(new WeixinApiError(
|
||||
'upload-url-rejected',
|
||||
'微信服务拒绝了文件上传请求。',
|
||||
{ providerCode: uploadRejection },
|
||||
));
|
||||
}
|
||||
const uploadUrl = weixinCdnUploadUrl(upload, fileKey);
|
||||
const ciphertext = encryptWeixinUpload(file.bytes, aesKey);
|
||||
let downloadParam;
|
||||
try {
|
||||
downloadParam = await uploadWeixinCdn(fetchImpl, uploadUrl, ciphertext, { signal });
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
const status = Number(error?.status);
|
||||
const fallback = error?.code === 'upload-rejected' || (status >= 400 && status < 500)
|
||||
? 'artifact-provider-rejected'
|
||||
: 'artifact-provider-failed';
|
||||
throw weixinArtifactError(error, { fallback });
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const deliverySeed = nonEmptyString(file.deliveryKey) ?? nonEmptyString(file.artifactId)
|
||||
?? randomUUID();
|
||||
const clientId = `dsh-weixin-${createHash('sha256').update(deliverySeed).digest('hex').slice(0, 32)}`;
|
||||
let response;
|
||||
try {
|
||||
response = await requestJson(fetchImpl, {
|
||||
method: 'POST',
|
||||
baseUrl,
|
||||
endpoint: 'ilink/bot/sendmessage',
|
||||
token,
|
||||
signal,
|
||||
body: {
|
||||
msg: {
|
||||
from_user_id: '',
|
||||
to_user_id: recipient,
|
||||
client_id: clientId,
|
||||
message_type: 2,
|
||||
message_state: 2,
|
||||
item_list: [{
|
||||
type: 4,
|
||||
file_item: {
|
||||
media: {
|
||||
encrypt_query_param: downloadParam,
|
||||
aes_key: Buffer.from(aesKey.toString('hex')).toString('base64'),
|
||||
encrypt_type: 1,
|
||||
},
|
||||
file_name: file.fileName,
|
||||
len: String(file.bytes.byteLength),
|
||||
},
|
||||
}],
|
||||
...(nonEmptyString(contextToken) ? { context_token: contextToken.trim() } : {}),
|
||||
...(nonEmptyString(runId) ? { run_id: runId.trim() } : {}),
|
||||
},
|
||||
base_info: baseInfo(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw classifyWeixinFinalDeliveryError(error, signal);
|
||||
}
|
||||
const sendRejection = rejectedProviderResponse(response);
|
||||
if (sendRejection) {
|
||||
throw weixinArtifactError(new WeixinApiError(
|
||||
'send-rejected',
|
||||
'微信服务拒绝了文件消息。',
|
||||
{ providerCode: sendRejection },
|
||||
));
|
||||
}
|
||||
return { messageId: clientId };
|
||||
},
|
||||
|
||||
async notifyStart({ baseUrl, token, signal }) {
|
||||
const response = await requestJson(fetchImpl, {
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -32,6 +32,16 @@ import {
|
|||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
||||
import {
|
||||
materializeOutboundArtifact,
|
||||
releaseOutboundArtifact,
|
||||
} from '../shared/semantic/artifact.mjs';
|
||||
import {
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
providerMessageIdsFor,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
|
||||
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
||||
const GENERIC_PROCESSING_ERROR = '消息处理失败,请稍后重试。';
|
||||
|
|
@ -89,6 +99,28 @@ function safeMessageError(error, userMessage = GENERIC_PROCESSING_ERROR) {
|
|||
};
|
||||
}
|
||||
|
||||
function artifactFailureText(fileName, error) {
|
||||
const name = String(fileName ?? '结果文件').replace(/[\r\n]+/g, ' ').trim() || '结果文件';
|
||||
switch (error?.code) {
|
||||
case 'artifact-delivery-uncertain':
|
||||
return `结果文件「${name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
|
||||
case 'artifact-permission-required':
|
||||
return `结果文件「${name}」已生成,但微信机器人当前没有文件消息发送权限,请检查机器人文件消息能力。`;
|
||||
case 'artifact-too-large':
|
||||
return `结果文件「${name}」超过当前微信会话可发送的文件大小,未发送。`;
|
||||
case 'artifact-rate-limited':
|
||||
return `结果文件「${name}」暂时被微信限流,未能发送,请稍后重试。`;
|
||||
case 'artifact-provider-rejected':
|
||||
return `结果文件「${name}」已生成,但微信拒绝了该文件消息。`;
|
||||
case 'artifact-invalid':
|
||||
case 'artifact-changed':
|
||||
case 'artifact-unavailable':
|
||||
return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
|
||||
default:
|
||||
return `结果文件「${name}」已生成,但暂时未能通过微信发送,请稍后重试。`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createWeixinBridgeStatus() {
|
||||
return {
|
||||
messagesReceived: 0,
|
||||
|
|
@ -394,8 +426,9 @@ export class WeixinHarnessBridge {
|
|||
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
||||
: undefined;
|
||||
let answer;
|
||||
let artifacts = [];
|
||||
try {
|
||||
({ answer } = await askInWorkspaceSession({
|
||||
({ answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
|
|
@ -421,12 +454,35 @@ export class WeixinHarnessBridge {
|
|||
this.#approvals.closeRoute(key),
|
||||
]);
|
||||
}
|
||||
await this.#send(sender, answer, contextToken, runId);
|
||||
const answerText = typeof answer === 'string' && answer.trim()
|
||||
? answer
|
||||
: artifacts.length > 0 ? '结果文件已生成。' : answer;
|
||||
let textDeliveryError = null;
|
||||
let textReceipt = null;
|
||||
try {
|
||||
textReceipt = createDeliveryReceipt({
|
||||
deliveryId: messageId,
|
||||
presentation: 'weixin-text',
|
||||
providerMessageIds: await this.#send(sender, answerText, contextToken, runId),
|
||||
});
|
||||
} catch (error) {
|
||||
textDeliveryError = error;
|
||||
}
|
||||
const delivery = await this.#deliverArtifacts(
|
||||
sender,
|
||||
messageId,
|
||||
artifacts,
|
||||
contextToken,
|
||||
runId,
|
||||
textReceipt,
|
||||
);
|
||||
if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
|
||||
await this.#state.markSeen(messageId);
|
||||
this.#status.messagesReplied += 1;
|
||||
this.#status.lastReplyAt = new Date().toISOString();
|
||||
this.#status.lastError = null;
|
||||
this.#status.lastMessageError = null;
|
||||
return delivery.receipt;
|
||||
} catch (error) {
|
||||
if (error?.code === 'turn-stopped') {
|
||||
await this.#state.markSeen(messageId);
|
||||
|
|
@ -762,15 +818,90 @@ export class WeixinHarnessBridge {
|
|||
}
|
||||
|
||||
async #send(toUserId, text, contextToken, runId) {
|
||||
const providerMessageIds = [];
|
||||
for (const chunk of splitWeixinText(text, this.#maxMessageChars)) {
|
||||
await this.#api.sendText({
|
||||
const result = await this.#api.sendText({
|
||||
baseUrl: this.#baseUrl,
|
||||
token: this.#token,
|
||||
toUserId,
|
||||
text: chunk,
|
||||
contextToken,
|
||||
runId,
|
||||
signal: this.#signal,
|
||||
});
|
||||
providerMessageIds.push(...providerMessageIdsFor(result));
|
||||
}
|
||||
return providerMessageIds;
|
||||
}
|
||||
|
||||
async #deliverArtifacts(toUserId, replyTo, artifacts, contextToken, runId, baseReceipt) {
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
let userVisible = Boolean(baseReceipt);
|
||||
for (const artifact of artifacts) {
|
||||
this.#signal?.throwIfAborted();
|
||||
try {
|
||||
if (typeof this.#api.sendFile !== 'function') {
|
||||
const unavailable = new Error('Weixin file delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
const file = await materializeOutboundArtifact(artifact, {
|
||||
signal: this.#signal,
|
||||
});
|
||||
const result = await this.#api.sendFile({
|
||||
baseUrl: this.#baseUrl,
|
||||
token: this.#token,
|
||||
toUserId,
|
||||
file,
|
||||
contextToken,
|
||||
runId,
|
||||
signal: this.#signal,
|
||||
});
|
||||
receipts.push(createDeliveryReceipt({
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'weixin-file',
|
||||
providerMessageIds: providerMessageIdsFor(result),
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
}));
|
||||
userVisible = true;
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw error;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
||||
this.#logger.warn?.(
|
||||
`[dsh-weixin] result file delivery failed (${error?.code ?? 'unknown'})`,
|
||||
);
|
||||
let noticeSent = false;
|
||||
const providerMessageIds = await this.#send(
|
||||
toUserId,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
contextToken,
|
||||
runId,
|
||||
).then((ids) => {
|
||||
noticeSent = true;
|
||||
return ids;
|
||||
}).catch(() => []);
|
||||
const failureReceipt = createArtifactFailureReceipt({
|
||||
artifactId: artifact?.artifactId ?? 'unknown',
|
||||
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
||||
error,
|
||||
providerMessageIds,
|
||||
});
|
||||
receipts.push(failureReceipt);
|
||||
if (noticeSent || failureReceipt.artifacts[0]?.outcome === 'unknown') userVisible = true;
|
||||
} finally {
|
||||
releaseOutboundArtifact(artifact);
|
||||
}
|
||||
}
|
||||
const receipt = receipts.length === 0
|
||||
? null
|
||||
: receipts.length === 1
|
||||
? receipts[0]
|
||||
: mergeDeliveryReceipts({
|
||||
deliveryId: replyTo,
|
||||
presentation: baseReceipt ? 'weixin-text-and-files' : 'weixin-files',
|
||||
receipts,
|
||||
});
|
||||
return { receipt, userVisible };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
areJidsSameUser,
|
||||
downloadMediaMessage,
|
||||
|
|
@ -6,12 +8,14 @@ import {
|
|||
|
||||
import { splitMessageText } from '../shared/editable-message-stream.mjs';
|
||||
import { ImagePromptError } from '../shared/image-prompt.mjs';
|
||||
import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.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 WHATSAPP_MEDIA_UPLOAD_TIMEOUT_MS = 120_000;
|
||||
const MESSAGE_WRAPPER_KEYS = [
|
||||
'ephemeralMessage',
|
||||
'viewOnceMessage',
|
||||
|
|
@ -225,27 +229,114 @@ class RecentWhatsappOutboundIds {
|
|||
}
|
||||
}
|
||||
|
||||
class WhatsappBotClient {
|
||||
function abortReason(signal) {
|
||||
return signal?.reason ?? new DOMException('The operation was aborted.', 'AbortError');
|
||||
}
|
||||
|
||||
function waitWithSignal(promise, signal) {
|
||||
if (!signal) return promise;
|
||||
signal.throwIfAborted();
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
callback(value);
|
||||
};
|
||||
const onAbort = () => finish(reject, abortReason(signal));
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
Promise.resolve(promise).then(
|
||||
(value) => finish(resolve, value),
|
||||
(error) => finish(reject, error),
|
||||
);
|
||||
if (signal.aborted) onAbort();
|
||||
});
|
||||
}
|
||||
|
||||
function uncertainArtifactDelivery(error) {
|
||||
if (error?.code === 'artifact-delivery-uncertain') return error;
|
||||
const uncertain = new Error('WhatsApp could not confirm result file delivery.');
|
||||
uncertain.code = 'artifact-delivery-uncertain';
|
||||
uncertain.cause = error;
|
||||
return uncertain;
|
||||
}
|
||||
|
||||
export class WhatsappBotClient {
|
||||
#socket;
|
||||
#outboundIds;
|
||||
#signal;
|
||||
#mediaUploadTimeoutMs;
|
||||
#typingTimers = new Map();
|
||||
|
||||
constructor(socket, outboundIds) {
|
||||
constructor(socket, outboundIds, {
|
||||
signal,
|
||||
mediaUploadTimeoutMs = WHATSAPP_MEDIA_UPLOAD_TIMEOUT_MS,
|
||||
} = {}) {
|
||||
this.#socket = socket;
|
||||
this.#outboundIds = outboundIds;
|
||||
this.#signal = signal;
|
||||
this.#mediaUploadTimeoutMs = mediaUploadTimeoutMs;
|
||||
}
|
||||
|
||||
async sendText(target, text) {
|
||||
await this.#stopTyping(target.jid);
|
||||
let result = null;
|
||||
const providerMessageIds = [];
|
||||
for (const [index, chunk] of splitMessageText(text, 4_000).entries()) {
|
||||
result = await this.#socket.sendMessage(
|
||||
const result = await this.#socket.sendMessage(
|
||||
target.jid,
|
||||
{ text: chunk },
|
||||
index === 0 && target.quoted ? { quoted: target.quoted } : undefined,
|
||||
);
|
||||
this.#outboundIds.remember(result?.key?.id);
|
||||
if (typeof result?.key?.id === 'string' && result.key.id) {
|
||||
providerMessageIds.push(result.key.id);
|
||||
}
|
||||
}
|
||||
return { providerMessageIds };
|
||||
}
|
||||
|
||||
async sendFile(target, file) {
|
||||
this.#signal?.throwIfAborted();
|
||||
await this.#stopTyping(target.jid);
|
||||
this.#signal?.throwIfAborted();
|
||||
const deliverySeed = typeof file.deliveryKey === 'string' && file.deliveryKey
|
||||
? file.deliveryKey
|
||||
: file.artifactId;
|
||||
const messageId = typeof deliverySeed === 'string' && deliverySeed
|
||||
? createHash('sha256').update(deliverySeed).digest('hex').slice(0, 20).toUpperCase()
|
||||
: undefined;
|
||||
const options = {
|
||||
...(target.quoted ? { quoted: target.quoted } : {}),
|
||||
...(messageId ? { messageId } : {}),
|
||||
mediaUploadTimeoutMs: this.#mediaUploadTimeoutMs,
|
||||
};
|
||||
// Baileys can emit a self-chat echo before sendMessage settles. Reserve the
|
||||
// deterministic id before dispatch so that echo cannot re-enter the bridge.
|
||||
this.#outboundIds.remember(messageId);
|
||||
let result;
|
||||
try {
|
||||
const pending = this.#socket.sendMessage(
|
||||
target.jid,
|
||||
{
|
||||
document: file.bytes,
|
||||
mimetype: file.mediaType ?? 'application/octet-stream',
|
||||
fileName: file.fileName,
|
||||
},
|
||||
options,
|
||||
);
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
const timeout = AbortSignal.timeout(this.#mediaUploadTimeoutMs);
|
||||
const waitSignal = this.#signal
|
||||
? AbortSignal.any([this.#signal, timeout])
|
||||
: timeout;
|
||||
result = await waitWithSignal(pending, waitSignal);
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw abortReason(this.#signal);
|
||||
throw uncertainArtifactDelivery(error);
|
||||
}
|
||||
this.#signal?.throwIfAborted();
|
||||
this.#outboundIds.remember(result?.key?.id);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -298,6 +389,7 @@ export class WhatsappRuntime {
|
|||
#logger;
|
||||
#replyTimeoutMs;
|
||||
#connectTimeoutMs;
|
||||
#mediaUploadTimeoutMs;
|
||||
#createSession;
|
||||
#status = createWhatsappRuntimeStatus();
|
||||
#abortController = null;
|
||||
|
|
@ -314,6 +406,7 @@ export class WhatsappRuntime {
|
|||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
connectTimeoutMs = 30_000,
|
||||
mediaUploadTimeoutMs = WHATSAPP_MEDIA_UPLOAD_TIMEOUT_MS,
|
||||
createSession = createWhatsappWebSession,
|
||||
}) {
|
||||
if (!config || !authDir || !harness || !state || typeof createSession !== 'function') {
|
||||
|
|
@ -326,6 +419,13 @@ export class WhatsappRuntime {
|
|||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#connectTimeoutMs = connectTimeoutMs;
|
||||
if (!Number.isSafeInteger(mediaUploadTimeoutMs) || mediaUploadTimeoutMs <= 0) {
|
||||
throw new TypeError('mediaUploadTimeoutMs must be a positive safe integer');
|
||||
}
|
||||
this.#mediaUploadTimeoutMs = Math.min(
|
||||
mediaUploadTimeoutMs,
|
||||
WHATSAPP_MEDIA_UPLOAD_TIMEOUT_MS,
|
||||
);
|
||||
this.#createSession = createSession;
|
||||
}
|
||||
|
||||
|
|
@ -390,7 +490,10 @@ export class WhatsappRuntime {
|
|||
if (!areJidsSameUser(identity.accountJid, this.#config.accountJid)) {
|
||||
throw new Error('WhatsApp linked account does not match the saved bot');
|
||||
}
|
||||
const client = new WhatsappBotClient(session.socket, outboundIds);
|
||||
const client = new WhatsappBotClient(session.socket, outboundIds, {
|
||||
signal: controller.signal,
|
||||
mediaUploadTimeoutMs: this.#mediaUploadTimeoutMs,
|
||||
});
|
||||
this.#client = client;
|
||||
this.#bridge = new WhatsappHarnessBridge({
|
||||
bot: client,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
createDingtalkApi,
|
||||
DINGTALK_AI_CARD_TEMPLATE_ID,
|
||||
DINGTALK_API_BASE_URL,
|
||||
DingtalkApiError,
|
||||
normalizeDingtalkCardMarkdown,
|
||||
normalizeDingtalkSessionWebhook,
|
||||
splitDingtalkText,
|
||||
|
|
@ -113,6 +114,231 @@ test('session replies use the fixed token endpoint, reject redirects, and cache
|
|||
});
|
||||
});
|
||||
|
||||
test('DingTalk uploads and sends a native file message to the exact robot conversation', async () => {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url, options) => {
|
||||
calls.push({ url: url.toString(), options });
|
||||
if (url.pathname.endsWith('/oauth2/accessToken')) {
|
||||
return jsonResponse({ accessToken: 'file-access-token', expireIn: 7_200 });
|
||||
}
|
||||
if (url.pathname.endsWith('/media/upload')) {
|
||||
return jsonResponse({ errcode: 0, media_id: '@media-one', type: 'file' });
|
||||
}
|
||||
return jsonResponse({ processQueryKey: 'query-one' });
|
||||
};
|
||||
const api = createDingtalkApi({ fetchImpl });
|
||||
const response = await api.sendFile({
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-only-secret',
|
||||
target: {
|
||||
type: 'group',
|
||||
robotCode: 'robot-code',
|
||||
openConversationId: 'cid-one',
|
||||
},
|
||||
file: {
|
||||
fileName: 'result.pdf',
|
||||
mediaType: 'application/pdf',
|
||||
bytes: Buffer.from('dingtalk-result'),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.processQueryKey, 'query-one');
|
||||
const uploadUrl = new URL(calls[1].url);
|
||||
assert.equal(uploadUrl.origin, 'https://oapi.dingtalk.com');
|
||||
assert.equal(uploadUrl.pathname, '/media/upload');
|
||||
assert.equal(uploadUrl.searchParams.get('access_token'), 'file-access-token');
|
||||
assert.equal(uploadUrl.searchParams.get('type'), 'file');
|
||||
assert.ok(calls[1].options.body instanceof FormData);
|
||||
const media = calls[1].options.body.get('media');
|
||||
assert.equal(media.name, 'result.pdf');
|
||||
assert.equal(media.type, 'application/pdf');
|
||||
assert.equal(Buffer.from(await media.arrayBuffer()).toString(), 'dingtalk-result');
|
||||
assert.equal(calls[1].options.headers, undefined);
|
||||
|
||||
assert.equal(
|
||||
calls[2].url,
|
||||
`${DINGTALK_API_BASE_URL}v1.0/robot/groupMessages/send`,
|
||||
);
|
||||
assert.equal(calls[2].options.headers['x-acs-dingtalk-access-token'], 'file-access-token');
|
||||
const sent = JSON.parse(calls[2].options.body);
|
||||
assert.deepEqual(sent, {
|
||||
robotCode: 'robot-code',
|
||||
msgKey: 'sampleFile',
|
||||
msgParam: JSON.stringify({
|
||||
mediaId: '@media-one',
|
||||
fileName: 'result.pdf',
|
||||
fileType: 'pdf',
|
||||
}),
|
||||
openConversationId: 'cid-one',
|
||||
});
|
||||
});
|
||||
|
||||
function dingtalkFileRequest(overrides = {}) {
|
||||
return {
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-only-secret',
|
||||
target: {
|
||||
type: 'group',
|
||||
robotCode: 'robot-code',
|
||||
openConversationId: 'cid-error-case',
|
||||
},
|
||||
file: {
|
||||
fileName: 'result.pdf',
|
||||
mediaType: 'application/pdf',
|
||||
bytes: Buffer.from('dingtalk-error-case'),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function dingtalkFileFetch(finalResponse) {
|
||||
return async (url, options) => {
|
||||
if (url.pathname.endsWith('/oauth2/accessToken')) {
|
||||
return jsonResponse({ accessToken: 'file-access-token', expireIn: 7_200 });
|
||||
}
|
||||
if (url.pathname.endsWith('/media/upload')) {
|
||||
return jsonResponse({ errcode: 0, media_id: '@media-error-case', type: 'file' });
|
||||
}
|
||||
return finalResponse(url, options);
|
||||
};
|
||||
}
|
||||
|
||||
test('DingTalk marks every ambiguous robot file send result as uncertain', async (t) => {
|
||||
const cases = [
|
||||
{
|
||||
name: 'network failure',
|
||||
finalResponse: async () => { throw new TypeError('private socket detail'); },
|
||||
},
|
||||
{
|
||||
name: 'timeout',
|
||||
finalResponse: async () => {
|
||||
throw new DingtalkApiError('timeout', 'private timeout detail');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'invalid JSON',
|
||||
finalResponse: async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => { throw new SyntaxError('private invalid JSON detail'); },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'HTTP 5xx',
|
||||
finalResponse: async () => jsonResponse({ code: 'InternalError' }, { status: 503 }),
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of cases) {
|
||||
await t.test(scenario.name, async () => {
|
||||
const api = createDingtalkApi({
|
||||
fetchImpl: dingtalkFileFetch(scenario.finalResponse),
|
||||
});
|
||||
await assert.rejects(
|
||||
api.sendFile(dingtalkFileRequest()),
|
||||
(error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& !error.message.includes('private'),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('DingTalk maps definitive robot file rejection statuses without treating them as uncertain', async (t) => {
|
||||
const cases = [
|
||||
{ name: 'permission', status: 403, code: 'artifact-permission-required' },
|
||||
{ name: 'too large', status: 413, code: 'artifact-too-large' },
|
||||
{ name: 'rate limited', status: 429, code: 'artifact-rate-limited' },
|
||||
{ name: 'provider rejected', status: 400, code: 'artifact-provider-rejected' },
|
||||
];
|
||||
|
||||
for (const scenario of cases) {
|
||||
await t.test(scenario.name, async () => {
|
||||
const api = createDingtalkApi({
|
||||
fetchImpl: dingtalkFileFetch(async () => jsonResponse(
|
||||
{ code: scenario.status },
|
||||
{ status: scenario.status },
|
||||
)),
|
||||
});
|
||||
await assert.rejects(
|
||||
api.sendFile(dingtalkFileRequest()),
|
||||
(error) => error.code === scenario.code,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const permissionApi = createDingtalkApi({
|
||||
fetchImpl: dingtalkFileFetch(async () => jsonResponse({
|
||||
code: 'Forbidden.AccessDenied',
|
||||
})),
|
||||
});
|
||||
await assert.rejects(
|
||||
permissionApi.sendFile(dingtalkFileRequest()),
|
||||
(error) => error.code === 'artifact-permission-required'
|
||||
&& error.providerCode === 'Forbidden.AccessDenied',
|
||||
);
|
||||
|
||||
const rejectedApi = createDingtalkApi({
|
||||
fetchImpl: dingtalkFileFetch(async () => jsonResponse({ code: 'InvalidParameter' })),
|
||||
});
|
||||
await assert.rejects(
|
||||
rejectedApi.sendFile(dingtalkFileRequest()),
|
||||
(error) => error.code === 'artifact-provider-rejected'
|
||||
&& error.providerCode === 'InvalidParameter',
|
||||
);
|
||||
});
|
||||
|
||||
test('DingTalk preserves caller abort and never marks a pre-send upload failure uncertain', async () => {
|
||||
let uploadCalls = 0;
|
||||
const uploadApi = createDingtalkApi({
|
||||
fetchImpl: async (url) => {
|
||||
uploadCalls += 1;
|
||||
if (url.pathname.endsWith('/oauth2/accessToken')) {
|
||||
return jsonResponse({ accessToken: 'file-access-token', expireIn: 7_200 });
|
||||
}
|
||||
throw new TypeError('private upload transport failure');
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
uploadApi.sendFile(dingtalkFileRequest()),
|
||||
(error) => error.code === 'artifact-provider-failed'
|
||||
&& error.code !== 'artifact-delivery-uncertain',
|
||||
);
|
||||
assert.equal(uploadCalls, 2);
|
||||
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('caller stopped the turn');
|
||||
const abortApi = createDingtalkApi({
|
||||
fetchImpl: dingtalkFileFetch(async () => {
|
||||
controller.abort(reason);
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}),
|
||||
});
|
||||
await assert.rejects(
|
||||
abortApi.sendFile(dingtalkFileRequest({ signal: controller.signal })),
|
||||
(error) => error === reason && error.code !== 'artifact-delivery-uncertain',
|
||||
);
|
||||
});
|
||||
|
||||
test('DingTalk lets the provider decide whether a robot file type is supported', async () => {
|
||||
let providerCalls = 0;
|
||||
const api = createDingtalkApi({
|
||||
fetchImpl: dingtalkFileFetch(async () => {
|
||||
providerCalls += 1;
|
||||
return jsonResponse({ code: 400 }, { status: 400 });
|
||||
}),
|
||||
});
|
||||
await assert.rejects(
|
||||
api.sendFile({
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-only-secret',
|
||||
target: { type: 'user', robotCode: 'robot-code', userId: 'user-one' },
|
||||
file: { fileName: 'result.html', bytes: Buffer.from('<h1>result</h1>') },
|
||||
}),
|
||||
(error) => error.code === 'artifact-provider-rejected',
|
||||
);
|
||||
assert.equal(providerCalls, 1);
|
||||
});
|
||||
|
||||
test('DingTalk image downloads exchange downloadCode with the callback robotCode and do not forward auth', async () => {
|
||||
const imageBytes = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
|
|
@ -6,6 +9,12 @@ import {
|
|||
DingtalkHarnessBridge,
|
||||
} from '../../../src/channels/dingtalk/dingtalk-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
releaseOutboundArtifact,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -82,6 +91,37 @@ const PNG_BYTES = Buffer.from([
|
|||
0x01, 0x02, 0x03,
|
||||
]);
|
||||
|
||||
async function committedArtifact(t, fileName, content) {
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-dingtalk-artifact-'));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
const registry = new OutboundArtifactRegistry({ uuid: () => 'dingtalk-artifact-one' });
|
||||
t.after(() => registry.clear());
|
||||
const fileContent = fileName.endsWith('.pdf') ? `%PDF-1.7\n${content}` : content;
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: 'artifact-session', cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'user/message', data: { turn: 1, source: { rpcId: 'artifact-rpc' } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
await writeFile(join(workspace, fileName), fileContent);
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: 'dingtalk-artifact-call',
|
||||
rootCallId: 'dingtalk-artifact-call',
|
||||
token: Symbol('dingtalk-artifact-call'),
|
||||
agent,
|
||||
};
|
||||
await tool.definition.execute({ path: fileName }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
const artifact = registry.take('artifact-session', 1)[0];
|
||||
t.after(() => releaseOutboundArtifact(artifact));
|
||||
return artifact;
|
||||
}
|
||||
|
||||
test('DingTalk remembers any private inbound session webhook for connection tests', async () => {
|
||||
const privateFixture = stateFixture();
|
||||
const privateSent = [];
|
||||
|
|
@ -114,6 +154,161 @@ test('DingTalk remembers any private inbound session webhook for connection test
|
|||
assert.equal(connectionTestTarget(groupFixture.state), null);
|
||||
});
|
||||
|
||||
test('DingTalk returns a registered result file through the native robot conversation', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'result.pdf', 'dingtalk-result');
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', 'session-artifact');
|
||||
const order = [];
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
sendText: async ({ text }) => {
|
||||
order.push(`text:${text}`);
|
||||
return { messageId: 'dingtalk-text-one' };
|
||||
},
|
||||
sendFile: async (request) => {
|
||||
order.push(`file:${request.file.fileName}`);
|
||||
assert.deepEqual(request.target, {
|
||||
type: 'user', userId: 'staff-approved', robotCode: 'robot-code',
|
||||
});
|
||||
assert.equal(request.file.bytes.toString(), '%PDF-1.7\ndingtalk-result');
|
||||
return { processQueryKey: 'dingtalk-file-query-one' };
|
||||
},
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message(
|
||||
'dingtalk-artifact',
|
||||
'生成文件',
|
||||
{ robotCode: 'robot-code' },
|
||||
));
|
||||
|
||||
assert.deepEqual(order, ['text:结果文件已生成。', 'file:result.pdf']);
|
||||
assert.equal(bridge.status.artifactsSent, 1);
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: 'dingtalk-artifact',
|
||||
presentation: 'dingtalk-text-and-files',
|
||||
providerMessageIds: ['dingtalk-text-one', 'dingtalk-file-query-one'],
|
||||
artifacts: [{ artifactId: 'dingtalk-artifact-one', outcome: 'sent' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('DingTalk still attempts a registered file when the final text transport fails', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'dingtalk-text-failed.pdf', 'dingtalk-file');
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', 'session-artifact-text-failed');
|
||||
const files = [];
|
||||
let textAttempts = 0;
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
sendText: async () => {
|
||||
textAttempts += 1;
|
||||
throw new Error('private text failure');
|
||||
},
|
||||
sendFile: async ({ file }) => {
|
||||
files.push(file.fileName);
|
||||
return { messageId: 'dingtalk-file-after-text-failure' };
|
||||
},
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字回答';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message('dingtalk-artifact-text-failed', '生成文件', {
|
||||
robotCode: 'robot-code',
|
||||
}));
|
||||
|
||||
assert.deepEqual(files, ['dingtalk-text-failed.pdf']);
|
||||
assert.equal(textAttempts, 1, 'must not send a generic retry notice after the file succeeds');
|
||||
assert.equal(bridge.status.artifactsSent, 1);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['dingtalk-file-after-text-failure']);
|
||||
assert.deepEqual(receipt.artifacts, [{
|
||||
artifactId: 'dingtalk-artifact-one',
|
||||
outcome: 'sent',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('DingTalk gives safe, actionable file delivery failure guidance', async (t) => {
|
||||
const cases = [
|
||||
{
|
||||
name: 'uncertain delivery',
|
||||
code: 'artifact-delivery-uncertain',
|
||||
fileName: 'dingtalk-uncertain.pdf',
|
||||
expected: '结果文件「dingtalk-uncertain.pdf」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。',
|
||||
},
|
||||
{
|
||||
name: 'missing permission',
|
||||
code: 'artifact-permission-required',
|
||||
fileName: 'dingtalk-permission.pdf',
|
||||
expected: '结果文件「dingtalk-permission.pdf」已生成,但钉钉应用或机器人缺少文件消息权限。请开通应用 qyapi_base 权限,并确认机器人具备文件消息发送能力。',
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of cases) {
|
||||
await t.test(scenario.name, async (subtest) => {
|
||||
const artifact = await committedArtifact(subtest, scenario.fileName, 'dingtalk-file');
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', `session-${scenario.name}`);
|
||||
const sent = [];
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
sendText: async ({ text }) => {
|
||||
sent.push(text);
|
||||
return { messageId: `dingtalk-text-${sent.length}` };
|
||||
},
|
||||
sendFile: async () => {
|
||||
const error = new Error('private provider rejection detail');
|
||||
error.code = scenario.code;
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message(`dingtalk-artifact-${scenario.name}`, '生成文件', {
|
||||
robotCode: 'robot-code',
|
||||
}));
|
||||
|
||||
assert.equal(sent.at(-1), scenario.expected);
|
||||
assert.doesNotMatch(sent.join('\n'), /private provider rejection detail/);
|
||||
assert.equal(bridge.status.artifactSendErrors, 1);
|
||||
assert.deepEqual(receipt.artifacts, [{
|
||||
artifactId: 'dingtalk-artifact-one',
|
||||
outcome: scenario.code === 'artifact-delivery-uncertain' ? 'unknown' : 'rejected',
|
||||
reason: scenario.code,
|
||||
}]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('DingTalk resolves picture downloadCode lazily and sends image-only content to Harness', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', 'session-image');
|
||||
|
|
|
|||
|
|
@ -66,7 +66,14 @@ test('production assembly keeps secrets in credentials and creates per-bot runti
|
|||
});
|
||||
assert.ok(runtime instanceof Runtime);
|
||||
assert.equal(seen.runtimeOptions.clientSecret, 'host-only-secret');
|
||||
assert.equal(Object.hasOwn(seen.runtimeOptions, 'outboundArtifactsEnabled'), false);
|
||||
assert.match(seen.statePath, /dt_abc\/state\.json$/);
|
||||
await seen.controllerOptions.createRuntime({
|
||||
botId: 'dt_disabled',
|
||||
config: { botId: 'dt_disabled', clientId: 'dingdisabled' },
|
||||
clientSecret: 'host-only-secret',
|
||||
});
|
||||
assert.equal(Object.hasOwn(seen.runtimeOptions, 'outboundArtifactsEnabled'), false);
|
||||
|
||||
await production.close();
|
||||
assert.equal(seen.supervisorClosed, true);
|
||||
|
|
@ -93,6 +100,7 @@ test('production assembly keeps secrets in credentials and creates per-bot runti
|
|||
config: { botId: 'dt_new', clientId: 'dingnew' },
|
||||
clientSecret: 'host-only-secret',
|
||||
});
|
||||
assert.equal(Object.hasOwn(seen.runtimeOptions, 'outboundArtifactsEnabled'), false);
|
||||
const stored = JSON.parse(await readFile(join(directory, 'workspaces.json'), 'utf8'));
|
||||
assert.equal(stored.agentPresets.dt_new, 'router-standard');
|
||||
assert.equal(Object.hasOwn(stored.agentPresets, 'dt_abc'), false);
|
||||
|
|
|
|||
|
|
@ -116,6 +116,162 @@ test('Discord API retries one rate-limited message request', async () => {
|
|||
assert.equal(attempts, 2);
|
||||
});
|
||||
|
||||
test('Discord API uploads a result file as a native attachment and preserves the reply', async () => {
|
||||
let request;
|
||||
const api = new DiscordApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
request = { url, options };
|
||||
return jsonResponse({ id: '987654321012345679', attachments: [{ id: '1' }] });
|
||||
},
|
||||
});
|
||||
const result = await api.createFileMessage({
|
||||
channelId: '123456789012345678',
|
||||
replyToMessageId: '123456789012345679',
|
||||
file: {
|
||||
artifactId: 'artifact-discord-one',
|
||||
deliveryKey: 'session:turn:artifact-discord-one',
|
||||
fileName: 'result.html',
|
||||
mediaType: 'text/html',
|
||||
bytes: Buffer.from('<p>discord-result</p>'),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.id, '987654321012345679');
|
||||
assert.match(request.url.pathname, /channels\/123456789012345678\/messages$/);
|
||||
assert.equal(request.options.headers['content-type'], undefined);
|
||||
assert.ok(request.options.body instanceof FormData);
|
||||
const payload = JSON.parse(request.options.body.get('payload_json'));
|
||||
assert.deepEqual(payload.attachments, [{ id: 0, filename: 'result.html' }]);
|
||||
assert.match(payload.nonce, /^[0-9a-f]{25}$/);
|
||||
assert.equal(payload.enforce_nonce, true);
|
||||
assert.equal(payload.message_reference.message_id, '123456789012345679');
|
||||
assert.deepEqual(payload.allowed_mentions, { parse: [], replied_user: false });
|
||||
const attachment = request.options.body.get('files[0]');
|
||||
assert.equal(attachment.name, 'result.html');
|
||||
assert.equal(attachment.type, 'text/html');
|
||||
assert.equal(Buffer.from(await attachment.arrayBuffer()).toString(), '<p>discord-result</p>');
|
||||
});
|
||||
|
||||
test('Discord attachment retry reuses one FormData body and one stable nonce', async () => {
|
||||
const bodies = [];
|
||||
const nonces = [];
|
||||
let attempts = 0;
|
||||
const api = new DiscordApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async (_url, options) => {
|
||||
attempts += 1;
|
||||
bodies.push(options.body);
|
||||
nonces.push(JSON.parse(options.body.get('payload_json')).nonce);
|
||||
return attempts === 1
|
||||
? jsonResponse({ code: 20028, message: 'rate limited', retry_after: 0.001 }, 429)
|
||||
: jsonResponse({ id: '987654321012345680', attachments: [{ id: '1' }] });
|
||||
},
|
||||
});
|
||||
const result = await api.createFileMessage({
|
||||
channelId: '123456789012345678',
|
||||
file: {
|
||||
deliveryKey: 'session:turn:artifact-retry',
|
||||
fileName: 'result.txt',
|
||||
bytes: Buffer.from('retry-safe'),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.id, '987654321012345680');
|
||||
assert.equal(attempts, 2);
|
||||
assert.equal(bodies[0], bodies[1]);
|
||||
assert.equal(nonces[0], nonces[1]);
|
||||
assert.match(nonces[0], /^[0-9a-f]{25}$/);
|
||||
});
|
||||
|
||||
test('Discord attachment errors retain provider details and use stable artifact reasons', async () => {
|
||||
const cases = [{
|
||||
body: { code: 50013, message: 'Missing Permissions' },
|
||||
status: 403,
|
||||
code: 'artifact-permission-required',
|
||||
}, {
|
||||
body: { code: 40005, message: 'Request entity too large' },
|
||||
status: 413,
|
||||
code: 'artifact-too-large',
|
||||
}, {
|
||||
body: { code: 20028, message: 'rate limited', retry_after: 0.001 },
|
||||
status: 429,
|
||||
code: 'artifact-rate-limited',
|
||||
retryAfter: 0.001,
|
||||
}, {
|
||||
body: { code: 50035, message: 'Invalid Form Body' },
|
||||
status: 400,
|
||||
code: 'artifact-provider-rejected',
|
||||
}, {
|
||||
body: { code: 0, message: 'Internal Server Error' },
|
||||
status: 500,
|
||||
code: 'artifact-delivery-uncertain',
|
||||
}];
|
||||
|
||||
for (const entry of cases) {
|
||||
const api = new DiscordApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async () => jsonResponse(entry.body, entry.status),
|
||||
});
|
||||
await assert.rejects(() => api.createFileMessage({
|
||||
channelId: '123456789012345678',
|
||||
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
|
||||
}), (error) => {
|
||||
assert.equal(error.code, entry.code);
|
||||
assert.equal(error.providerCode, entry.body.code);
|
||||
assert.equal(error.status, entry.status);
|
||||
assert.equal(error.retry_after, entry.retryAfter);
|
||||
assert.equal(error.retryAfter, entry.retryAfter);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Discord attachment delivery marks post-dispatch failures uncertain but preserves caller aborts', async () => {
|
||||
for (const fetchImpl of [
|
||||
async () => { throw new TypeError('socket reset'); },
|
||||
async () => new Response('not-json', { status: 200 }),
|
||||
]) {
|
||||
const api = new DiscordApi({ token: TOKEN, fetchImpl });
|
||||
await assert.rejects(() => api.createFileMessage({
|
||||
channelId: '123456789012345678',
|
||||
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-delivery-uncertain');
|
||||
}
|
||||
|
||||
const timeoutApi = new DiscordApi({
|
||||
token: TOKEN,
|
||||
fileUploadTimeoutMs: 10,
|
||||
fetchImpl: async (_url, { signal }) => new Promise((resolve, reject) => {
|
||||
if (signal.aborted) reject(signal.reason);
|
||||
else signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
}),
|
||||
});
|
||||
await assert.rejects(() => timeoutApi.createFileMessage({
|
||||
channelId: '123456789012345678',
|
||||
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& error.cause?.name === 'TimeoutError');
|
||||
|
||||
const caller = new AbortController();
|
||||
const reason = new DOMException('caller stopped', 'AbortError');
|
||||
caller.abort(reason);
|
||||
let calls = 0;
|
||||
const cancelledApi = new DiscordApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return jsonResponse({ id: '987654321012345680' });
|
||||
},
|
||||
});
|
||||
await assert.rejects(() => cancelledApi.createFileMessage({
|
||||
channelId: '123456789012345678',
|
||||
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
|
||||
signal: caller.signal,
|
||||
}), (error) => error === reason && error.code !== 'artifact-delivery-uncertain');
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('Discord controller persists a credential reference and exposes only masked identity', async (t) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-discord-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
|
|
|
|||
64
test/channels/discord/production.test.mjs
Normal file
64
test/channels/discord/production.test.mjs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createProductionController } from '../../../plugin-src/host/channels/discord/production.mjs';
|
||||
|
||||
test('Discord production has no per-bot result-file Gate', async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-discord-production-artifacts-'));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const runtimes = [];
|
||||
let controllerOptions;
|
||||
|
||||
class ConfigStore {
|
||||
async load() { return this; }
|
||||
list() { return []; }
|
||||
}
|
||||
class StateStore {
|
||||
async load() { return this; }
|
||||
}
|
||||
class Harness {
|
||||
stopManagedProcess() {}
|
||||
}
|
||||
class Runtime {
|
||||
constructor(options) { runtimes.push(options); }
|
||||
}
|
||||
class Controller {
|
||||
constructor(options) { controllerOptions = options; }
|
||||
async close() {}
|
||||
}
|
||||
const supervisor = {
|
||||
ready: Promise.resolve(),
|
||||
start() { return this; },
|
||||
async close() {},
|
||||
};
|
||||
const internals = {
|
||||
ConfigStore,
|
||||
StateStore,
|
||||
HarnessClient: Harness,
|
||||
Controller,
|
||||
Runtime,
|
||||
createConnectionSupervisor: () => supervisor,
|
||||
};
|
||||
const ctx = {
|
||||
credentials: {},
|
||||
webServer: { port: 3080 },
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
};
|
||||
const createRuntime = (botId) => controllerOptions.createRuntime({
|
||||
botId,
|
||||
config: { botId },
|
||||
token: 'host-only',
|
||||
});
|
||||
|
||||
const production = await createProductionController(ctx, {
|
||||
dataDir: join(root, 'default'),
|
||||
}, internals);
|
||||
await createRuntime('discord_enabled');
|
||||
await createRuntime('discord_not_listed');
|
||||
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
|
||||
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
|
||||
await production.close();
|
||||
});
|
||||
|
|
@ -2,11 +2,17 @@ import test from 'node:test';
|
|||
import assert from 'node:assert/strict';
|
||||
import { Readable } from 'node:stream';
|
||||
import { mkdirSync, realpathSync } from 'node:fs';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { FeishuHarnessBridge } from '../../../src/channels/feishu/bridge.mjs';
|
||||
import { DEFAULT_IMAGE_PROMPT } from '../../../src/channels/shared/image-prompt.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
|
||||
const PNG_1X1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
|
|
@ -90,6 +96,41 @@ function textClient(sendText) {
|
|||
};
|
||||
}
|
||||
|
||||
async function committedArtifact(t, fileName, content, suffix = '') {
|
||||
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-feishu-artifact-${suffix}`));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
let nextId = 0;
|
||||
const registry = new OutboundArtifactRegistry({
|
||||
uuid: () => `${suffix || 'file'}-${++nextId}`,
|
||||
});
|
||||
t.after(() => registry.clear());
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: `session-${suffix || 'file'}`, cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{
|
||||
type: 'user/message',
|
||||
data: { turn: 1, source: { rpcId: `rpc-${suffix || 'file'}` } },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: `call-${suffix || 'file'}`,
|
||||
rootCallId: `call-${suffix || 'file'}`,
|
||||
token: Symbol(`call-${suffix || 'file'}`),
|
||||
agent,
|
||||
};
|
||||
await writeFile(join(workspace, fileName), content);
|
||||
await tool.definition.execute({ path: fileName }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
const [artifact] = registry.take(agent.session.header.id, 1);
|
||||
return artifact;
|
||||
}
|
||||
|
||||
test('Feishu remembers any authorized private inbound message as a connection-test target', async () => {
|
||||
const groupFixture = stateFixture();
|
||||
const groupBridge = new FeishuHarnessBridge({
|
||||
|
|
@ -1879,6 +1920,380 @@ test('reaction failures do not block streaming replies', async () => {
|
|||
assert.equal(status.streamResponses, 1);
|
||||
});
|
||||
|
||||
test('Feishu finalizes the answer card before delivering registered result files and reports partial failure', async (t) => {
|
||||
const html = await committedArtifact(t, 'result.html', '<h1>result</h1>', 'html');
|
||||
const generic = await committedArtifact(t, 'notes.txt', 'notes', 'notes');
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-artifacts']]);
|
||||
const order = [];
|
||||
const delivered = [];
|
||||
const notices = [];
|
||||
const status = bridgeStatus();
|
||||
const abort = new AbortController();
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => notices.push(text)),
|
||||
channel: {
|
||||
addReaction: async () => 'reaction',
|
||||
removeReaction: async () => undefined,
|
||||
stream: async (_chatId, input) => {
|
||||
await input.markdown({ setContent: async () => undefined });
|
||||
order.push('card-finalized');
|
||||
return { messageId: 'om-card' };
|
||||
},
|
||||
sendFile: async (chatId, file, options) => {
|
||||
order.push(`file:${file.fileName}`);
|
||||
delivered.push({ chatId, file, options });
|
||||
if (file.fileName === 'notes.txt') {
|
||||
const error = new Error('provider detail must stay private');
|
||||
error.code = 'artifact-rate-limited';
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: ['om-file'],
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
};
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(html);
|
||||
await options.onArtifact(generic);
|
||||
return '两个结果文件已经生成。';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status,
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
bridge.accept(event('om_artifacts', '生成 HTML 和说明文件并发给我'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.deepEqual(order, ['card-finalized', 'file:result.html', 'file:notes.txt']);
|
||||
assert.equal(delivered[0].chatId, 'oc_chat');
|
||||
assert.deepEqual(delivered[0].options, {
|
||||
replyTo: 'om_artifacts',
|
||||
signal: abort.signal,
|
||||
});
|
||||
assert.equal(delivered[0].file.bytes.toString(), '<h1>result</h1>');
|
||||
assert.equal(delivered[1].file.bytes.toString(), 'notes');
|
||||
assert.equal(status.artifactsSent, 1);
|
||||
assert.equal(status.artifactSendErrors, 1);
|
||||
assert.equal(notices.length, 1);
|
||||
assert.match(notices[0], /notes\.txt.*限流/);
|
||||
assert.doesNotMatch(notices[0], /provider detail/);
|
||||
});
|
||||
|
||||
test('Feishu tells users to check the chat before retrying an uncertain file delivery', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'uncertain.txt', 'uncertain result', 'uncertain');
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-uncertain-artifact']]);
|
||||
const notices = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => notices.push(text)),
|
||||
channel: {
|
||||
stream: async (_chatId, input) => {
|
||||
await input.markdown({ setContent: async () => undefined });
|
||||
return { messageId: 'om-uncertain-card' };
|
||||
},
|
||||
sendFile: async () => {
|
||||
const error = new Error('private transport detail');
|
||||
error.code = 'artifact-delivery-uncertain';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '结果已生成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
bridge.accept(event('om_uncertain_artifact', '生成并发送文件'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.deepEqual(notices, [
|
||||
'结果文件「uncertain.txt」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Feishu delivers a file-only Turn with a neutral final card', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'file-only.txt', 'file only', 'file-only');
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-file-only']]);
|
||||
const cardContents = [];
|
||||
const files = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async () => undefined),
|
||||
channel: {
|
||||
stream: async (_chatId, input) => {
|
||||
await input.markdown({ setContent: async (content) => cardContents.push(content) });
|
||||
return { messageId: 'om-file-only-card' };
|
||||
},
|
||||
sendFile: async (_chatId, file) => {
|
||||
files.push(file.fileName);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: ['om-file-only'],
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
};
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
bridge.accept(event('om_file_only', '只发送结果文件'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.deepEqual(cardContents, ['结果文件已生成。']);
|
||||
assert.deepEqual(files, ['file-only.txt']);
|
||||
});
|
||||
|
||||
test('a CardKit finalization failure falls back to text and delivers each artifact once without a second prompt', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'fallback.txt', 'fallback result', 'fallback');
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-fallback-artifact']]);
|
||||
const sent = [];
|
||||
const files = [];
|
||||
let asks = 0;
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => sent.push(text)),
|
||||
channel: {
|
||||
stream: async (_chatId, input) => {
|
||||
await input.markdown({ setContent: async () => undefined });
|
||||
throw new Error('card finalization failed');
|
||||
},
|
||||
sendFile: async (_chatId, file) => {
|
||||
files.push(file.fileName);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: ['om-fallback-file'],
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
};
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
asks += 1;
|
||||
await options.onArtifact(artifact);
|
||||
return '回答已生成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
bridge.accept(event('om_artifact_card_fallback', '生成并发送文件'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(asks, 1);
|
||||
assert.deepEqual(sent, ['回答已生成']);
|
||||
assert.deepEqual(files, ['fallback.txt']);
|
||||
});
|
||||
|
||||
test('Feishu still delivers a file-only result when CardKit and fallback text both fail', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'survives-text-failure.txt', 'file bytes', 'text-failure');
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-text-failure']]);
|
||||
const files = [];
|
||||
let fallbackTextAttempts = 0;
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async () => {
|
||||
fallbackTextAttempts += 1;
|
||||
throw new Error('text transport unavailable');
|
||||
}),
|
||||
channel: {
|
||||
stream: async (_chatId, input) => {
|
||||
await input.markdown({ setContent: async () => undefined });
|
||||
throw new Error('card finalization unavailable');
|
||||
},
|
||||
sendFile: async (_chatId, file) => {
|
||||
files.push(file.fileName);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: ['om-file-after-text-failure'],
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
};
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
bridge.accept(event('om_file_after_text_failure', '只生成文件'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.deepEqual(files, ['survives-text-failure.txt']);
|
||||
assert.equal(fallbackTextAttempts, 1, 'must not append a generic retry after file success');
|
||||
});
|
||||
|
||||
test('Feishu returns the receipt after reaction finalization and one safe notice when text and file delivery fail', async (t) => {
|
||||
for (const errorCode of ['artifact-invalid', 'artifact-unavailable']) {
|
||||
await t.test(errorCode, async (subtest) => {
|
||||
const artifact = await committedArtifact(subtest, `${errorCode}.txt`, 'file bytes', errorCode);
|
||||
const attemptedTexts = [];
|
||||
const visibleTexts = [];
|
||||
const reactions = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: {
|
||||
im: { v1: { message: { create: async (request) => {
|
||||
const text = JSON.parse(request.data.content).text;
|
||||
attemptedTexts.push(text);
|
||||
if (text === '文字结果') throw new Error('text transport unavailable');
|
||||
visibleTexts.push(text);
|
||||
return { code: 0, data: {} };
|
||||
} } } },
|
||||
},
|
||||
channel: {
|
||||
addReaction: async (_messageId, emoji) => {
|
||||
reactions.push(emoji);
|
||||
return `reaction-${emoji}`;
|
||||
},
|
||||
removeReaction: async () => undefined,
|
||||
sendFile: async () => {
|
||||
const error = new Error('unsafe result file');
|
||||
error.code = errorCode;
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字结果';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['p2p:ou_user', `session-${errorCode}`]]).state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(event(`om-${errorCode}`, '生成并发送文件'));
|
||||
|
||||
assert.equal(attemptedTexts.length, 2, 'must not append a generic error after the safe notice');
|
||||
assert.equal(visibleTexts.length, 1);
|
||||
assert.match(visibleTexts[0], /暂时无法读取或准备发送.*仍可访问/);
|
||||
assert.deepEqual(reactions, ['OnIt', 'DONE']);
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: artifact.deliveryKey,
|
||||
presentation: 'text-fallback',
|
||||
providerMessageIds: [],
|
||||
artifacts: [{
|
||||
artifactId: artifact.artifactId,
|
||||
outcome: 'rejected',
|
||||
reason: errorCode,
|
||||
}],
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Feishu keeps the generic error when no answer or file failure notice is visible', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'unavailable.txt', 'file bytes', 'no-visible-failure');
|
||||
const attemptedTexts = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => {
|
||||
attemptedTexts.push(text);
|
||||
if (attemptedTexts.length < 3) throw new Error('text transport unavailable');
|
||||
}),
|
||||
channel: {
|
||||
sendFile: async () => {
|
||||
const error = new Error('file transport unavailable');
|
||||
error.code = 'artifact-provider-failed';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字结果';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['p2p:ou_user', 'session-no-visible-failure']]).state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(event('om-no-visible-failure', '生成并发送文件'));
|
||||
|
||||
assert.equal(attemptedTexts.length, 3);
|
||||
assert.match(attemptedTexts.at(-1), /^处理失败,请稍后重试/);
|
||||
});
|
||||
|
||||
test('Feishu does not repeat finalized card text when cancellation happens before file delivery', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'cancel-after-card.txt', 'file bytes', 'cancel-after-card');
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-cancel-after-card']]);
|
||||
const controller = new AbortController();
|
||||
const fallbackTexts = [];
|
||||
let files = 0;
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => fallbackTexts.push(text)),
|
||||
channel: {
|
||||
stream: async (_chatId, input) => {
|
||||
await input.markdown({ setContent: async () => undefined });
|
||||
controller.abort(new DOMException('runtime stopped', 'AbortError'));
|
||||
return { messageId: 'om-final-card' };
|
||||
},
|
||||
sendFile: async () => {
|
||||
files += 1;
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '卡片已经完成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
signal: controller.signal,
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
bridge.accept(event('om_cancel_after_card', '生成文件'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(files, 0);
|
||||
assert.deepEqual(fallbackTexts, []);
|
||||
});
|
||||
|
||||
test('a stream finalization failure falls back to text without repeating the prompt', async () => {
|
||||
const seen = new Set();
|
||||
const sent = [];
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ function fakeClient(overrides = {}) {
|
|||
recalls: [],
|
||||
reactionsAdded: [],
|
||||
reactionsRemoved: [],
|
||||
fileUploads: [],
|
||||
};
|
||||
const client = {
|
||||
cardkit: { v1: {
|
||||
|
|
@ -28,6 +29,12 @@ function fakeClient(overrides = {}) {
|
|||
},
|
||||
} },
|
||||
im: { v1: {
|
||||
file: {
|
||||
create: async (request) => {
|
||||
calls.fileUploads.push(request);
|
||||
return { file_key: 'file-key-test' };
|
||||
},
|
||||
},
|
||||
message: {
|
||||
reply: async (request) => {
|
||||
calls.replies.push(request);
|
||||
|
|
@ -54,6 +61,9 @@ function fakeClient(overrides = {}) {
|
|||
|
||||
if (overrides.updateContent) client.cardkit.v1.cardElement.content = overrides.updateContent;
|
||||
if (overrides.finishCard) client.cardkit.v1.card.settings = overrides.finishCard;
|
||||
if (overrides.uploadFile) client.im.v1.file.create = overrides.uploadFile;
|
||||
if (overrides.replyMessage) client.im.v1.message.reply = overrides.replyMessage;
|
||||
if (overrides.createMessage) client.im.v1.message.create = overrides.createMessage;
|
||||
return { client, calls };
|
||||
}
|
||||
|
||||
|
|
@ -129,3 +139,393 @@ test('VerifiedFeishuChannel checks reaction API results', async () => {
|
|||
assert.equal(calls.reactionsAdded[0].data.reaction_type.emoji_type, 'OnIt');
|
||||
assert.equal(calls.reactionsRemoved[0].path.reaction_id, 'reaction-test');
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel uploads a materialized result and replies with a native file message', async () => {
|
||||
const { client, calls } = fakeClient();
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
const file = {
|
||||
artifactId: 'artifact-html',
|
||||
deliveryKey: 'delivery-html',
|
||||
fileName: 'result.html',
|
||||
mediaType: 'text/html',
|
||||
size: 19,
|
||||
bytes: Buffer.from('<h1>result</h1>'),
|
||||
};
|
||||
|
||||
const receipt = await channel.sendFile('oc_chat', file, { replyTo: 'om_user' });
|
||||
|
||||
assert.equal(calls.fileUploads.length, 1);
|
||||
assert.equal(calls.fileUploads[0].data.file_type, 'stream');
|
||||
assert.equal(calls.fileUploads[0].data.file_name, 'result.html');
|
||||
assert.equal(calls.fileUploads[0].data.file, file.bytes);
|
||||
assert.equal(calls.replies.length, 1);
|
||||
assert.equal(calls.replies[0].path.message_id, 'om_user');
|
||||
assert.equal(calls.replies[0].data.msg_type, 'file');
|
||||
assert.deepEqual(JSON.parse(calls.replies[0].data.content), { file_key: 'file-key-test' });
|
||||
assert.match(calls.replies[0].data.uuid, /^dshim_[a-f0-9]{40}$/);
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: 'delivery-html',
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: ['om-stream'],
|
||||
artifacts: [{ artifactId: 'artifact-html', outcome: 'sent' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel sends to the current chat when no reply target exists', async () => {
|
||||
const { client, calls } = fakeClient({
|
||||
createMessage: async (request) => {
|
||||
calls.replies.push(request);
|
||||
return { code: 0, data: { message_id: 'om-created-file' } };
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
await channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-generic',
|
||||
deliveryKey: 'delivery-generic',
|
||||
fileName: 'result.bin',
|
||||
bytes: Buffer.from('generic'),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls.replies[0].params, { receive_id_type: 'chat_id' });
|
||||
assert.equal(calls.replies[0].data.receive_id, 'oc_chat');
|
||||
assert.equal(calls.replies[0].data.msg_type, 'file');
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel retries uncertain message delivery without uploading twice', async () => {
|
||||
const requests = [];
|
||||
let attempts = 0;
|
||||
const { client, calls } = fakeClient({
|
||||
replyMessage: async (request) => {
|
||||
requests.push(structuredClone(request));
|
||||
attempts += 1;
|
||||
return attempts === 1
|
||||
? { code: 230049, msg: 'still sending' }
|
||||
: { code: 0, data: { message_id: 'om-retried-file' } };
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
const receipt = await channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-retry',
|
||||
deliveryKey: 'delivery-retry',
|
||||
fileName: 'retry.txt',
|
||||
bytes: Buffer.from('retry'),
|
||||
}, { replyTo: 'om_user' });
|
||||
|
||||
assert.equal(calls.fileUploads.length, 1);
|
||||
assert.equal(requests.length, 2);
|
||||
assert.deepEqual(requests[1], requests[0]);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['om-retried-file']);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel stops after one 230049 retry and never uploads the file twice', async () => {
|
||||
const requests = [];
|
||||
const { client, calls } = fakeClient({
|
||||
replyMessage: async (request) => {
|
||||
requests.push(structuredClone(request));
|
||||
return { code: 230049, msg: 'still sending' };
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
await assert.rejects(channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-still-uncertain',
|
||||
deliveryKey: 'delivery-still-uncertain',
|
||||
fileName: 'uncertain.txt',
|
||||
bytes: Buffer.from('uncertain'),
|
||||
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& error.providerCode === 230049);
|
||||
|
||||
assert.equal(calls.fileUploads.length, 1);
|
||||
assert.equal(requests.length, 2);
|
||||
assert.deepEqual(requests[1], requests[0]);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel retries an SDK-thrown 230049 with the same file key and UUID', async () => {
|
||||
const requests = [];
|
||||
const { client, calls } = fakeClient({
|
||||
replyMessage: async (request) => {
|
||||
requests.push(structuredClone(request));
|
||||
if (requests.length === 1) {
|
||||
const error = new Error('still sending');
|
||||
error.code = 230049;
|
||||
throw error;
|
||||
}
|
||||
return { code: 0, data: { message_id: 'om-after-thrown-230049' } };
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
const receipt = await channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-thrown-retry',
|
||||
deliveryKey: 'delivery-thrown-retry',
|
||||
fileName: 'thrown-retry.txt',
|
||||
bytes: Buffer.from('retry'),
|
||||
}, { replyTo: 'om_user' });
|
||||
|
||||
assert.equal(calls.fileUploads.length, 1);
|
||||
assert.equal(requests.length, 2);
|
||||
assert.deepEqual(requests[1], requests[0]);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['om-after-thrown-230049']);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel bounds upload and message waits independently', async () => {
|
||||
const uploadFixture = fakeClient({ uploadFile: async () => new Promise(() => {}) });
|
||||
const uploadChannel = new VerifiedFeishuChannel({
|
||||
client: uploadFixture.client,
|
||||
fileUploadTimeoutMs: 10,
|
||||
fileMessageTimeoutMs: 100,
|
||||
});
|
||||
|
||||
await assert.rejects(uploadChannel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-upload-timeout',
|
||||
deliveryKey: 'delivery-upload-timeout',
|
||||
fileName: 'upload-timeout.txt',
|
||||
bytes: Buffer.from('timeout'),
|
||||
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-provider-failed'
|
||||
&& error.cause?.code === 'provider-timeout');
|
||||
assert.equal(uploadFixture.calls.replies.length, 0);
|
||||
|
||||
let messageCalls = 0;
|
||||
const messageFixture = fakeClient({
|
||||
replyMessage: async () => {
|
||||
messageCalls += 1;
|
||||
return new Promise(() => {});
|
||||
},
|
||||
});
|
||||
const messageChannel = new VerifiedFeishuChannel({
|
||||
client: messageFixture.client,
|
||||
fileUploadTimeoutMs: 100,
|
||||
fileMessageTimeoutMs: 10,
|
||||
});
|
||||
|
||||
await assert.rejects(messageChannel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-message-timeout',
|
||||
deliveryKey: 'delivery-message-timeout',
|
||||
fileName: 'message-timeout.txt',
|
||||
bytes: Buffer.from('timeout'),
|
||||
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& error.cause?.code === 'provider-timeout');
|
||||
assert.equal(messageFixture.calls.fileUploads.length, 1);
|
||||
assert.equal(messageCalls, 1);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel rejects waits longer than the 120 second operation timeout', () => {
|
||||
const { client } = fakeClient();
|
||||
assert.throws(
|
||||
() => new VerifiedFeishuChannel({ client, fileUploadTimeoutMs: 120_001 }),
|
||||
/fileUploadTimeoutMs.*120000/,
|
||||
);
|
||||
assert.throws(
|
||||
() => new VerifiedFeishuChannel({ client, fileMessageTimeoutMs: 120_001 }),
|
||||
/fileMessageTimeoutMs.*120000/,
|
||||
);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel stops after an in-flight upload when file delivery is cancelled', async () => {
|
||||
let uploadStarted;
|
||||
const started = new Promise((resolve) => { uploadStarted = resolve; });
|
||||
const { client, calls } = fakeClient({
|
||||
uploadFile: async () => {
|
||||
uploadStarted();
|
||||
return new Promise(() => {});
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
const abort = new AbortController();
|
||||
const reason = new DOMException('runtime stopped', 'AbortError');
|
||||
|
||||
const sending = channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-cancelled-upload',
|
||||
deliveryKey: 'delivery-cancelled-upload',
|
||||
fileName: 'cancelled.txt',
|
||||
bytes: Buffer.from('cancelled'),
|
||||
}, { replyTo: 'om_user', signal: abort.signal });
|
||||
|
||||
await started;
|
||||
abort.abort(reason);
|
||||
|
||||
await assert.rejects(sending, (error) => error === reason);
|
||||
assert.equal(calls.replies.length, 0);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel immediately preserves caller abort during an in-flight message send', async () => {
|
||||
let messageStarted;
|
||||
const started = new Promise((resolve) => { messageStarted = resolve; });
|
||||
const { client, calls } = fakeClient({
|
||||
replyMessage: async () => {
|
||||
messageStarted();
|
||||
return new Promise(() => {});
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
const abort = new AbortController();
|
||||
const reason = new DOMException('runtime stopped', 'AbortError');
|
||||
|
||||
const sending = channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-cancelled-message',
|
||||
deliveryKey: 'delivery-cancelled-message',
|
||||
fileName: 'cancelled-message.txt',
|
||||
bytes: Buffer.from('cancelled'),
|
||||
}, { replyTo: 'om_user', signal: abort.signal });
|
||||
|
||||
await started;
|
||||
abort.abort(reason);
|
||||
|
||||
await assert.rejects(sending, (error) => error === reason
|
||||
&& error.code !== 'artifact-delivery-uncertain');
|
||||
assert.equal(calls.fileUploads.length, 1);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel does not retry an uncertain file message after cancellation', async () => {
|
||||
const abort = new AbortController();
|
||||
const reason = new DOMException('runtime stopped', 'AbortError');
|
||||
const requests = [];
|
||||
const { client } = fakeClient({
|
||||
replyMessage: async (request) => {
|
||||
requests.push(request);
|
||||
abort.abort(reason);
|
||||
return { code: 230049, msg: 'still sending' };
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
await assert.rejects(channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-cancelled-retry',
|
||||
deliveryKey: 'delivery-cancelled-retry',
|
||||
fileName: 'cancelled-retry.txt',
|
||||
bytes: Buffer.from('cancelled retry'),
|
||||
}, { replyTo: 'om_user', signal: abort.signal }), (error) => error === reason);
|
||||
|
||||
assert.equal(requests.length, 1);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel lets Feishu decide empty and oversize file outcomes', async () => {
|
||||
const uploads = [];
|
||||
const providerCodes = [234010, 234006, 0];
|
||||
const { client } = fakeClient({
|
||||
uploadFile: async (request) => {
|
||||
uploads.push(request);
|
||||
const code = providerCodes.shift();
|
||||
return code === 0 ? {} : { code };
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
const base = {
|
||||
artifactId: 'artifact-invalid',
|
||||
deliveryKey: 'delivery-invalid',
|
||||
fileName: 'invalid.bin',
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
channel.sendFile('oc_chat', { ...base, bytes: Buffer.alloc(0) }),
|
||||
(error) => error.code === 'artifact-empty' && error.providerCode === 234010,
|
||||
);
|
||||
await assert.rejects(
|
||||
channel.sendFile('oc_chat', { ...base, bytes: Buffer.from('provider decides') }),
|
||||
(error) => error.code === 'artifact-too-large' && error.providerCode === 234006,
|
||||
);
|
||||
await assert.rejects(
|
||||
channel.sendFile('oc_chat', { ...base, bytes: Buffer.from('valid bytes') }),
|
||||
(error) => error.code === 'artifact-provider-failed' && !error.message.includes('undefined'),
|
||||
);
|
||||
assert.deepEqual(uploads.map((request) => request.data.file.byteLength), [0, 16, 11]);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel distinguishes upload failure from uncertain final delivery', async () => {
|
||||
const uploadFixture = fakeClient({
|
||||
uploadFile: async () => { throw new Error('upload transport closed'); },
|
||||
});
|
||||
const uploadChannel = new VerifiedFeishuChannel({ client: uploadFixture.client });
|
||||
|
||||
await assert.rejects(uploadChannel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-upload-transport',
|
||||
deliveryKey: 'delivery-upload-transport',
|
||||
fileName: 'upload.txt',
|
||||
bytes: Buffer.from('upload'),
|
||||
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-provider-failed');
|
||||
assert.equal(uploadFixture.calls.replies.length, 0);
|
||||
|
||||
for (const replyMessage of [
|
||||
async () => { throw new Error('message transport closed'); },
|
||||
async () => ({ code: 0, data: {} }),
|
||||
]) {
|
||||
const messageFixture = fakeClient({ replyMessage });
|
||||
const messageChannel = new VerifiedFeishuChannel({ client: messageFixture.client });
|
||||
await assert.rejects(messageChannel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-uncertain-message',
|
||||
deliveryKey: 'delivery-uncertain-message',
|
||||
fileName: 'message.txt',
|
||||
bytes: Buffer.from('message'),
|
||||
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-delivery-uncertain');
|
||||
assert.equal(messageFixture.calls.fileUploads.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel keeps explicit final provider codes out of the uncertain bucket', async () => {
|
||||
const { client, calls } = fakeClient({
|
||||
replyMessage: async () => ({ code: 99991672, msg: 'missing scope' }),
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
await assert.rejects(channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-message-permission',
|
||||
deliveryKey: 'delivery-message-permission',
|
||||
fileName: 'permission.txt',
|
||||
bytes: Buffer.from('permission'),
|
||||
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-permission-required'
|
||||
&& error.providerCode === 99991672);
|
||||
assert.equal(calls.fileUploads.length, 1);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel classifies missing file permission without exposing provider details', async () => {
|
||||
const { client } = fakeClient({
|
||||
uploadFile: async () => {
|
||||
const error = new Error('raw tenant detail and private diagnostic');
|
||||
error.code = 99991672;
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
await assert.rejects(
|
||||
channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-permission',
|
||||
deliveryKey: 'delivery-permission',
|
||||
fileName: 'result.html',
|
||||
bytes: Buffer.from('result'),
|
||||
}),
|
||||
(error) => error.code === 'artifact-permission-required'
|
||||
&& error.message.includes('im:resource permission')
|
||||
&& !error.message.includes('im:resource:upload')
|
||||
&& !error.message.includes('private diagnostic'),
|
||||
);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel recognizes the SDK array-shaped permission error', async () => {
|
||||
const { client } = fakeClient({
|
||||
uploadFile: async () => {
|
||||
const transport = new Error('raw transport diagnostic');
|
||||
transport.code = 'ERR_BAD_REQUEST';
|
||||
throw [transport, {
|
||||
code: 99991672,
|
||||
msg: 'raw provider permission URL',
|
||||
}];
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
await assert.rejects(
|
||||
channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-array-permission',
|
||||
deliveryKey: 'delivery-array-permission',
|
||||
fileName: 'result.txt',
|
||||
bytes: Buffer.from('result'),
|
||||
}),
|
||||
(error) => error.code === 'artifact-permission-required'
|
||||
&& error.providerCode === 99991672
|
||||
&& !error.message.includes('provider permission URL'),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,19 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
HarnessClient,
|
||||
HarnessReplyTracker,
|
||||
} from '../../../src/channels/feishu/harness-client.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
createOutboundArtifactTool,
|
||||
materializeOutboundArtifact,
|
||||
outboundArtifactRegistry,
|
||||
releaseOutboundArtifact,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -582,6 +592,124 @@ test('HarnessClient reads the nested workspace.create response used by DSH rc.6'
|
|||
assert.deepEqual(methods, ['workspace.list', 'workspace.create']);
|
||||
});
|
||||
|
||||
test('HarnessClient asks do not control file-return tool availability', async () => {
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/dsh-feishu-workspace',
|
||||
});
|
||||
client.ensureRunning = async () => undefined;
|
||||
let promptRpcId;
|
||||
let prompted = false;
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: 'session-artifact-availability', cwd: '/tmp/dsh-feishu-workspace' },
|
||||
events: [],
|
||||
},
|
||||
};
|
||||
client.rpc = async (method, _payload, _timeoutMs, options) => {
|
||||
if (method === 'session.history' && !prompted) return { events: [] };
|
||||
if (method === 'session.prompt') {
|
||||
prompted = true;
|
||||
promptRpcId = options.rpcId;
|
||||
agent.session.events = [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'user/message', data: { turn: 1, source: { rpcId: promptRpcId } } },
|
||||
];
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
events: [
|
||||
{ event: { type: 'turn/start', seq: 1, data: { turn: 1 } } },
|
||||
{
|
||||
event: {
|
||||
type: 'user/message',
|
||||
seq: 2,
|
||||
data: { turn: 1, source: { rpcId: promptRpcId } },
|
||||
},
|
||||
},
|
||||
{
|
||||
event: {
|
||||
type: 'assistant/message',
|
||||
seq: 3,
|
||||
data: { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'done' }] } },
|
||||
},
|
||||
},
|
||||
{ event: { type: 'turn/end', seq: 4, data: { turn: 1, reason: { kind: 'completed' } } } },
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(await client.ask('session-artifact-availability', 'create a file', {
|
||||
onArtifact: async () => undefined,
|
||||
}), 'done');
|
||||
});
|
||||
|
||||
test('HarnessClient delivers an existing file-only Turn directly', async (t) => {
|
||||
outboundArtifactRegistry.clear();
|
||||
t.after(() => outboundArtifactRegistry.clear());
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-file-only-'));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace,
|
||||
});
|
||||
client.ensureRunning = async () => undefined;
|
||||
const tool = createOutboundArtifactTool({ registry: outboundArtifactRegistry });
|
||||
const delivered = [];
|
||||
await writeFile(join(workspace, 'file-only.txt'), 'file only');
|
||||
let promptRpcId;
|
||||
let prompted = false;
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: 'session-file-only', cwd: workspace },
|
||||
events: [],
|
||||
},
|
||||
};
|
||||
client.rpc = async (method, _payload, _timeoutMs, options) => {
|
||||
if (method === 'session.history' && !prompted) return { events: [] };
|
||||
if (method === 'session.prompt') {
|
||||
prompted = true;
|
||||
promptRpcId = options.rpcId;
|
||||
agent.session.events = [
|
||||
{ type: 'turn/start', data: { turn: 2 } },
|
||||
{ type: 'user/message', data: { turn: 2, source: { rpcId: promptRpcId } } },
|
||||
];
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: 'file-only-call',
|
||||
token: Symbol('file-only-call'),
|
||||
agent,
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
await tool.definition.execute({ path: 'file-only.txt' }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
events: [
|
||||
{ event: { type: 'turn/start', seq: 1, data: { turn: 2 } } },
|
||||
{
|
||||
event: {
|
||||
type: 'user/message',
|
||||
seq: 2,
|
||||
data: { turn: 2, source: { rpcId: promptRpcId } },
|
||||
},
|
||||
},
|
||||
{ event: { type: 'turn/end', seq: 3, data: { turn: 2, reason: { kind: 'completed' } } } },
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const answer = await client.ask('session-file-only', 'create and return a file', {
|
||||
onArtifact: async (artifact) => delivered.push(artifact),
|
||||
});
|
||||
assert.equal(answer, '');
|
||||
assert.equal(delivered.length, 1);
|
||||
const file = await materializeOutboundArtifact(delivered[0]);
|
||||
assert.equal(file.bytes.toString(), 'file only');
|
||||
releaseOutboundArtifact(delivered[0]);
|
||||
});
|
||||
|
||||
test('HarnessReplyTracker correlates the prompt and emits only answer text', () => {
|
||||
const tracker = new HarnessReplyTracker({ promptRpcId: 'prompt-1', afterSeq: 10 });
|
||||
|
||||
|
|
|
|||
|
|
@ -180,6 +180,8 @@ test('QR registration separates events from card callbacks', async () => {
|
|||
const run = fx.registrationRuns.shift();
|
||||
assert.deepEqual(run.options.addons.events.items.tenant, ['im.message.receive_v1']);
|
||||
assert.deepEqual(run.options.addons.callbacks.items, ['card.action.trigger']);
|
||||
assert.ok(run.options.addons.scopes.tenant.includes('im:resource'));
|
||||
assert.equal(run.options.addons.scopes.tenant.includes('im:resource:upload'), false);
|
||||
run.options.onQRCodeReady({ url: 'https://accounts.feishu.cn/callbacks', expireIn: 60 });
|
||||
run.resolve({
|
||||
client_id: 'cli_callbacks', client_secret: 'callbacks-secret',
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ test('QR success stores the secret off-config and becomes immediately chat-ready
|
|||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message.p2p_msg:readonly'));
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message:readonly'));
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message:send_as_bot'));
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:resource'));
|
||||
assert.equal(fx.getSdkOptions().addons.scopes.tenant.includes('im:resource:upload'), false);
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('cardkit:card:write'));
|
||||
fx.getSdkOptions().onQRCodeReady({ url: 'https://accounts.feishu.cn/qr', expireIn: 600 });
|
||||
fixture.resolveRegistration({
|
||||
|
|
|
|||
|
|
@ -1223,6 +1223,7 @@ test('production assembly needs only ctx credentials and the active DSH webServe
|
|||
const alphaState = constructed.runtime.state;
|
||||
assert.equal(constructed.runtime.botId, 'bot_alpha');
|
||||
assert.equal(constructed.runtime.repair, repair);
|
||||
assert.equal(Object.hasOwn(constructed.runtime, 'outboundArtifactsEnabled'), false);
|
||||
await constructed.controller.createRuntime({
|
||||
botId: 'bot_beta',
|
||||
config: {
|
||||
|
|
@ -1235,6 +1236,7 @@ test('production assembly needs only ctx credentials and the active DSH webServe
|
|||
appSecret: 'beta-secret',
|
||||
});
|
||||
const betaState = constructed.runtime.state;
|
||||
assert.equal(Object.hasOwn(constructed.runtime, 'outboundArtifactsEnabled'), false);
|
||||
assert.notEqual(alphaState, betaState);
|
||||
assert.ok(constructed.statePaths.some((path) => /bots\/bot_alpha\/state\.json$/.test(path)));
|
||||
assert.ok(constructed.statePaths.some((path) => /bots\/bot_beta\/state\.json$/.test(path)));
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { QqHarnessBridge } from '../../../src/channels/qq/qq-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -52,6 +60,37 @@ function message(overrides = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
async function committedArtifact(t, fileName, content, suffix) {
|
||||
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-qq-artifact-${suffix}-`));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
let nextId = 0;
|
||||
const registry = new OutboundArtifactRegistry({ uuid: () => `${suffix}-${++nextId}` });
|
||||
t.after(() => registry.clear());
|
||||
const rpcId = `rpc-${suffix}`;
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: `session-${suffix}`, cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'user/message', data: { turn: 1, source: { rpcId } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: `call-${suffix}`,
|
||||
rootCallId: `call-${suffix}`,
|
||||
token: Symbol(`call-${suffix}`),
|
||||
agent,
|
||||
};
|
||||
await writeFile(join(workspace, fileName), content);
|
||||
await tool.definition.execute({ path: fileName }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
const [artifact] = registry.take(agent.session.header.id, 1);
|
||||
return artifact;
|
||||
}
|
||||
|
||||
const PNG_BYTES = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
|
|
@ -1419,3 +1458,311 @@ test('QQ propagates the stop signal and cancels its pending question on abort',
|
|||
assert.notEqual(cancellationSignal, controller.signal);
|
||||
assert.equal(cancellationSignal.aborted, false);
|
||||
});
|
||||
|
||||
test('QQ sends registered files after text with the native SDK and continues after one file fails', async (t) => {
|
||||
const first = await committedArtifact(t, 'first.txt', 'first bytes', 'partial-first');
|
||||
const second = await committedArtifact(t, 'second.html', '<h1>second</h1>', 'partial-second');
|
||||
const order = [];
|
||||
const sentTexts = [];
|
||||
const files = [];
|
||||
const status = {
|
||||
messagesReceived: 0,
|
||||
messagesReplied: 0,
|
||||
messagesRejected: 0,
|
||||
artifactsSent: 0,
|
||||
artifactSendErrors: 0,
|
||||
lastMessageAt: null,
|
||||
lastReplyAt: null,
|
||||
lastRejectedAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
const target = { scope: 'c2c', targetId: 'owner-openid', msgId: 'qq-artifact-partial' };
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async (replyTarget, text) => {
|
||||
sentTexts.push({ replyTarget, text });
|
||||
order.push(`text:${text}`);
|
||||
return { id: `text-${sentTexts.length}` };
|
||||
},
|
||||
sendFile: async (replyTarget, source, options) => {
|
||||
files.push({ replyTarget, source, options });
|
||||
order.push(`file:${options.fileName}`);
|
||||
if (options.fileName === 'first.txt') {
|
||||
const error = new Error('private quota detail');
|
||||
error.name = 'UploadDailyLimitExceededError';
|
||||
throw error;
|
||||
}
|
||||
return { message: { id: 'qq-file-2' } };
|
||||
},
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(first);
|
||||
await options.onArtifact(second);
|
||||
return '文件处理完成。';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['c2c:owner-openid', 'session-artifacts']]).state,
|
||||
status,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message({
|
||||
messageId: 'qq-artifact-partial',
|
||||
replyTarget: target,
|
||||
}));
|
||||
|
||||
assert.deepEqual(order.map((entry) => entry.split(':', 1)[0]), [
|
||||
'text', 'file', 'text', 'file',
|
||||
]);
|
||||
assert.equal(files[0].replyTarget, target);
|
||||
assert.equal(files[0].source.buffer.toString(), 'first bytes');
|
||||
assert.equal(files[0].options.fileName, 'first.txt');
|
||||
assert.equal(typeof files[0].options.onProgress, 'function');
|
||||
assert.equal(files[1].source.buffer.toString(), '<h1>second</h1>');
|
||||
assert.equal(files[1].options.fileName, 'second.html');
|
||||
assert.match(sentTexts[1].text, /first\.txt.*上传额度/);
|
||||
assert.doesNotMatch(sentTexts[1].text, /private quota detail/);
|
||||
assert.equal(status.artifactsSent, 1);
|
||||
assert.equal(status.artifactSendErrors, 1);
|
||||
});
|
||||
|
||||
test('QQ still delivers registered files when every final text delivery attempt fails', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'survives-text-failure.txt', 'file bytes', 'text-failure');
|
||||
const files = [];
|
||||
let textAttempts = 0;
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async () => {
|
||||
textAttempts += 1;
|
||||
throw new Error('text transport unavailable');
|
||||
},
|
||||
sendFile: async (_target, source, options) => {
|
||||
files.push({ bytes: Buffer.from(source.buffer), fileName: options.fileName });
|
||||
return { message: { id: 'qq-file-after-text-failure' } };
|
||||
},
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字结果';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['c2c:owner-openid', 'session-text-failure']]).state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message({ messageId: 'qq-text-failure' }));
|
||||
|
||||
assert.deepEqual(files, [{ bytes: Buffer.from('file bytes'), fileName: 'survives-text-failure.txt' }]);
|
||||
assert.equal(textAttempts, 1, 'must not send a generic retry notice after the file succeeds');
|
||||
});
|
||||
|
||||
test('QQ returns the authoritative receipt and sends one safe notice when text and file delivery fail', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'mismatch.txt', 'file bytes', 'all-fail');
|
||||
const attemptedTexts = [];
|
||||
const visibleTexts = [];
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async (_target, text) => {
|
||||
attemptedTexts.push(text);
|
||||
if (text === '文字结果') throw new Error('text transport unavailable');
|
||||
visibleTexts.push(text);
|
||||
return undefined;
|
||||
},
|
||||
sendFile: async () => {
|
||||
const error = new Error('mismatched file signature');
|
||||
error.code = 'artifact-invalid';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字结果';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['c2c:owner-openid', 'session-all-fail']]).state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message({ messageId: 'qq-all-fail' }));
|
||||
|
||||
assert.equal(attemptedTexts.length, 2, 'must not append a generic error after the safe notice');
|
||||
assert.equal(visibleTexts.length, 1);
|
||||
assert.match(visibleTexts[0], /暂时无法读取或准备发送.*仍可访问/);
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: 'qq-all-fail',
|
||||
presentation: 'qq-files',
|
||||
providerMessageIds: [],
|
||||
artifacts: [{
|
||||
artifactId: artifact.artifactId,
|
||||
outcome: 'rejected',
|
||||
reason: 'artifact-invalid',
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
test('QQ keeps the generic error when neither the answer nor the file failure notice is visible', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'unavailable.txt', 'file bytes', 'no-visible-failure');
|
||||
const attemptedTexts = [];
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async (_target, text) => {
|
||||
attemptedTexts.push(text);
|
||||
if (attemptedTexts.length < 3) throw new Error('text transport unavailable');
|
||||
return { id: 'qq-generic-error' };
|
||||
},
|
||||
sendFile: async () => {
|
||||
const error = new Error('file transport unavailable');
|
||||
error.code = 'artifact-provider-failed';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字结果';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['c2c:owner-openid', 'session-no-visible-failure']]).state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message({ messageId: 'qq-no-visible-failure' }));
|
||||
|
||||
assert.equal(attemptedTexts.length, 3);
|
||||
assert.equal(attemptedTexts.at(-1), '消息处理失败,请稍后重试。');
|
||||
});
|
||||
|
||||
test('QQ reports an unacknowledged native file send as uncertain instead of inviting a blind retry', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'uncertain.txt', 'file bytes', 'uncertain');
|
||||
const texts = [];
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async (_target, text) => texts.push(text),
|
||||
sendFile: async () => new Promise(() => {}),
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文件已生成。';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['c2c:owner-openid', 'session-uncertain']]).state,
|
||||
fileUploadTimeoutMs: 20,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message({ messageId: 'qq-uncertain-file' }));
|
||||
|
||||
assert.match(texts[1], /发送结果未能确认.*先检查聊天内是否已收到.*不要立即重试/);
|
||||
});
|
||||
|
||||
test('QQ runtime cancellation interrupts an in-flight file send and skips later files and notices', async (t) => {
|
||||
const first = await committedArtifact(t, 'first.txt', 'first', 'abort-first');
|
||||
const second = await committedArtifact(t, 'second.txt', 'second', 'abort-second');
|
||||
const started = deferred();
|
||||
const controller = new AbortController();
|
||||
const texts = [];
|
||||
const files = [];
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async (_target, text) => texts.push(text),
|
||||
sendFile: async (_target, _source, options) => {
|
||||
files.push(options.fileName);
|
||||
started.resolve();
|
||||
return new Promise(() => {});
|
||||
},
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(first);
|
||||
await options.onArtifact(second);
|
||||
return '文件如下。';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['c2c:owner-openid', 'session-abort-files']]).state,
|
||||
signal: controller.signal,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const processing = bridge.accept(message({ messageId: 'qq-abort-file' }));
|
||||
await started.promise;
|
||||
controller.abort(new DOMException('runtime stopped', 'AbortError'));
|
||||
await Promise.race([
|
||||
processing,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('QQ abort timed out')), 500)),
|
||||
]);
|
||||
|
||||
assert.deepEqual(files, ['first.txt']);
|
||||
assert.equal(texts.some((text) => text.includes('发送结果未能确认')), false);
|
||||
assert.equal(texts.some((text) => text === '消息处理失败,请稍后重试。'), false);
|
||||
});
|
||||
|
||||
test('QQ uses a neutral final text for a file-only Turn', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'only.txt', 'only bytes', 'file-only');
|
||||
const texts = [];
|
||||
const files = [];
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async (_target, text) => texts.push(text),
|
||||
sendFile: async (_target, source, options) => {
|
||||
files.push({ bytes: Buffer.from(source.buffer), fileName: options.fileName });
|
||||
return { message: { id: 'qq-file-only' } };
|
||||
},
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['c2c:owner-openid', 'session-file-only']]).state,
|
||||
});
|
||||
|
||||
await bridge.accept(message({ messageId: 'qq-file-only' }));
|
||||
|
||||
assert.deepEqual(texts, ['结果文件已生成。']);
|
||||
assert.deepEqual(files, [{ bytes: Buffer.from('only bytes'), fileName: 'only.txt' }]);
|
||||
});
|
||||
|
||||
test('QQ cancellation prevents SDK sendFile', async (t) => {
|
||||
let fileCalls = 0;
|
||||
const artifact = await committedArtifact(t, 'cancelled.txt', 'cancelled bytes', 'cancelled');
|
||||
const controller = new AbortController();
|
||||
const cancelledBridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async () => {},
|
||||
sendFile: async () => { fileCalls += 1; },
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
controller.abort(new DOMException('stopped', 'AbortError'));
|
||||
return '停止前的回答';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['c2c:owner-openid', 'session-cancelled']]).state,
|
||||
signal: controller.signal,
|
||||
});
|
||||
await cancelledBridge.accept(message({ messageId: 'qq-artifact-cancelled' }));
|
||||
assert.equal(fileCalls, 0);
|
||||
});
|
||||
|
|
|
|||
72
test/channels/qq/production.test.mjs
Normal file
72
test/channels/qq/production.test.mjs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createProductionController } from '../../../plugin-src/host/channels/qq/production.mjs';
|
||||
|
||||
test('QQ production has no per-bot result-file Gate', async (t) => {
|
||||
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-qq-production-artifacts-'));
|
||||
t.after(() => rm(dataDir, { recursive: true, force: true }));
|
||||
const runtimes = [];
|
||||
let controllerOptions;
|
||||
|
||||
class ConfigStore {
|
||||
async load() { return this; }
|
||||
list() { return []; }
|
||||
}
|
||||
class StateStore {
|
||||
async load() { return this; }
|
||||
}
|
||||
class Harness {
|
||||
stopManagedProcess() {}
|
||||
}
|
||||
class Runtime {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
runtimes.push(options);
|
||||
}
|
||||
}
|
||||
class Controller {
|
||||
constructor(options) {
|
||||
controllerOptions = options;
|
||||
}
|
||||
async close() {}
|
||||
}
|
||||
class QrAuth {}
|
||||
const supervisor = {
|
||||
ready: Promise.resolve(),
|
||||
start() { return this; },
|
||||
async close() {},
|
||||
};
|
||||
|
||||
const production = await createProductionController({
|
||||
credentials: {},
|
||||
webServer: { port: 3080 },
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
}, { dataDir }, {
|
||||
ConfigStore,
|
||||
StateStore,
|
||||
HarnessClient: Harness,
|
||||
Controller,
|
||||
Runtime,
|
||||
QrAuth,
|
||||
createConnectionSupervisor: () => supervisor,
|
||||
});
|
||||
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'qq_enabled',
|
||||
config: { botId: 'qq_enabled', appId: 'app-enabled' },
|
||||
appSecret: 'host-only',
|
||||
});
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'qq_disabled',
|
||||
config: { botId: 'qq_disabled', appId: 'app-disabled' },
|
||||
appSecret: 'host-only',
|
||||
});
|
||||
|
||||
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
|
||||
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
|
||||
await production.close();
|
||||
});
|
||||
|
|
@ -184,3 +184,45 @@ test('QQ runtime aborts an in-flight Harness interaction when stopped', async ()
|
|||
await runtime.stop();
|
||||
assert.equal(askSignal.aborted, true);
|
||||
});
|
||||
|
||||
test('QQ runtime enables result-file delivery without per-bot configuration', async () => {
|
||||
const bot = new FakeBot();
|
||||
const finished = deferred();
|
||||
let onArtifact;
|
||||
const runtime = new QqRuntime({
|
||||
config: { botId: 'qq_bot', appId: 'app', ownerUserOpenid: 'owner' },
|
||||
appSecret: 'secret',
|
||||
harness: {
|
||||
ensureRunning: async () => true,
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
onArtifact = options.onArtifact;
|
||||
return '完成';
|
||||
},
|
||||
},
|
||||
state: {
|
||||
hasSeen: () => false,
|
||||
markSeen: async () => finished.resolve(),
|
||||
sessionFor: () => 'session-existing',
|
||||
setSession: async () => {},
|
||||
clearSession: async () => {},
|
||||
},
|
||||
createBot: () => bot,
|
||||
typingMiddleware: () => 'typing',
|
||||
connectTimeoutMs: 100,
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
bot.emit('message', {}, {
|
||||
kind: 'c2c',
|
||||
rawEventType: 'C2C_MESSAGE_CREATE',
|
||||
senderId: 'owner',
|
||||
senderIsBot: false,
|
||||
content: '生成文件',
|
||||
messageId: 'qq-artifact-gate',
|
||||
replyTarget: { scope: 'c2c', targetId: 'owner', msgId: 'qq-artifact-gate' },
|
||||
});
|
||||
await finished.promise;
|
||||
assert.equal(typeof onArtifact, 'function');
|
||||
await runtime.stop();
|
||||
});
|
||||
|
|
|
|||
222
test/channels/shared/delivery-message-ids.test.mjs
Normal file
222
test/channels/shared/delivery-message-ids.test.mjs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { DiscordBotClient } from '../../../src/channels/discord/discord-runtime.mjs';
|
||||
import { createEditableMessageStream } from '../../../src/channels/shared/editable-message-stream.mjs';
|
||||
import { TextHarnessBridge } from '../../../src/channels/shared/text-harness-bridge.mjs';
|
||||
import { SlackBotClient } from '../../../src/channels/slack/slack-runtime.mjs';
|
||||
import { TelegramBotClient } from '../../../src/channels/telegram/telegram-runtime.mjs';
|
||||
import { WhatsappBotClient } from '../../../src/channels/whatsapp/whatsapp-runtime.mjs';
|
||||
|
||||
function memoryState() {
|
||||
const sessions = new Map();
|
||||
const seen = new Set();
|
||||
return {
|
||||
sessionFor: (key) => sessions.get(key) ?? null,
|
||||
setSession: async (key, sessionId) => { sessions.set(key, sessionId); },
|
||||
clearSession: async (key) => { sessions.delete(key); },
|
||||
hasSeen: (messageId) => seen.has(messageId),
|
||||
markSeen: async (messageId) => { seen.add(messageId); },
|
||||
};
|
||||
}
|
||||
|
||||
function inboundMessage(messageId, replyTarget) {
|
||||
return {
|
||||
messageId,
|
||||
senderId: 'message-id-test-user',
|
||||
senderIsBot: false,
|
||||
kind: 'direct',
|
||||
conversationId: `conversation-${messageId}`,
|
||||
content: '请返回长文本',
|
||||
addressed: true,
|
||||
replyTarget,
|
||||
};
|
||||
}
|
||||
|
||||
async function textReceipt({ key, bot, answer, replyTarget }) {
|
||||
const bridge = new TextHarnessBridge({
|
||||
descriptor: { key, label: key },
|
||||
bot,
|
||||
state: memoryState(),
|
||||
logger: { warn() {}, error() {} },
|
||||
harness: {
|
||||
createSession: async () => `session-${key}`,
|
||||
ask: async () => answer,
|
||||
},
|
||||
});
|
||||
return bridge.accept(inboundMessage(`inbound-${key}`, replyTarget));
|
||||
}
|
||||
|
||||
test('editable message streams retain the initial and every remainder message id', async () => {
|
||||
let nextMessageId = 101;
|
||||
const edited = [];
|
||||
const remainders = [];
|
||||
const stream = createEditableMessageStream({
|
||||
initialText: 'working',
|
||||
limit: 5,
|
||||
create: async () => 100,
|
||||
edit: async (messageId, text) => { edited.push({ messageId, text }); },
|
||||
sendRemainder: async (text) => {
|
||||
remainders.push(text);
|
||||
return { message_id: nextMessageId++ };
|
||||
},
|
||||
messageIdForResult: (result) => result.message_id,
|
||||
});
|
||||
|
||||
await stream.start();
|
||||
await stream.finish('ABCDEFGHIJKLM');
|
||||
|
||||
assert.deepEqual(edited, [{ messageId: 100, text: 'ABCDE' }]);
|
||||
assert.deepEqual(remainders, ['FGHIJ', 'KLM']);
|
||||
assert.deepEqual(stream.providerMessageIds, ['100', '101', '102']);
|
||||
const copy = stream.providerMessageIds;
|
||||
copy.push('not-stored');
|
||||
assert.deepEqual(stream.providerMessageIds, ['100', '101', '102']);
|
||||
});
|
||||
|
||||
test('plain Slack, Telegram, Discord, and WhatsApp receipts retain every split message id', async () => {
|
||||
let slackId = 0;
|
||||
const slack = new SlackBotClient({
|
||||
api: {
|
||||
postMessage: async () => ({ ts: `slack-${++slackId}` }),
|
||||
},
|
||||
});
|
||||
const slackReceipt = await textReceipt({
|
||||
key: 'slack',
|
||||
bot: { sendText: slack.sendText.bind(slack) },
|
||||
answer: 'S'.repeat(38_001),
|
||||
replyTarget: { channelId: 'C-message-ids', threadTs: '1.0' },
|
||||
});
|
||||
assert.deepEqual(slackReceipt.providerMessageIds, ['slack-1', 'slack-2']);
|
||||
|
||||
let telegramId = 200;
|
||||
const telegram = new TelegramBotClient({
|
||||
api: {
|
||||
sendMessage: async () => ({ message_id: ++telegramId }),
|
||||
},
|
||||
});
|
||||
const telegramReceipt = await textReceipt({
|
||||
key: 'telegram',
|
||||
bot: { sendText: telegram.sendText.bind(telegram) },
|
||||
answer: 'T'.repeat(4_001),
|
||||
replyTarget: { chatId: 42, replyToMessageId: 7, messageThreadId: 8 },
|
||||
});
|
||||
assert.deepEqual(telegramReceipt.providerMessageIds, ['201', '202']);
|
||||
|
||||
let discordId = 0;
|
||||
const discord = new DiscordBotClient({
|
||||
api: {
|
||||
createMessage: async () => ({ id: `discord-${++discordId}` }),
|
||||
},
|
||||
});
|
||||
const discordReceipt = await textReceipt({
|
||||
key: 'discord',
|
||||
bot: { sendText: discord.sendText.bind(discord) },
|
||||
answer: 'D'.repeat(1_901),
|
||||
replyTarget: { channelId: 'discord-channel', replyToMessageId: 'discord-inbound' },
|
||||
});
|
||||
assert.deepEqual(discordReceipt.providerMessageIds, ['discord-1', 'discord-2']);
|
||||
|
||||
let whatsappId = 0;
|
||||
const remembered = [];
|
||||
const whatsapp = new WhatsappBotClient({
|
||||
sendPresenceUpdate: async () => {},
|
||||
sendMessage: async () => ({ key: { id: `whatsapp-${++whatsappId}` } }),
|
||||
}, {
|
||||
remember: (messageId) => remembered.push(messageId),
|
||||
});
|
||||
const whatsappReceipt = await textReceipt({
|
||||
key: 'whatsapp',
|
||||
bot: { sendText: whatsapp.sendText.bind(whatsapp) },
|
||||
answer: 'W'.repeat(4_001),
|
||||
replyTarget: { jid: '16505550123@s.whatsapp.net' },
|
||||
});
|
||||
assert.deepEqual(whatsappReceipt.providerMessageIds, ['whatsapp-1', 'whatsapp-2']);
|
||||
assert.deepEqual(remembered, ['whatsapp-1', 'whatsapp-2']);
|
||||
});
|
||||
|
||||
test('Slack, Telegram, and Discord stream receipts retain the initial and remainder ids', async () => {
|
||||
let slackRemainder = 0;
|
||||
const slack = new SlackBotClient({
|
||||
api: {
|
||||
startStream: async () => ({ ts: 'slack-stream-first' }),
|
||||
appendStream: async () => ({ ok: true }),
|
||||
stopStream: async () => ({ ok: true }),
|
||||
updateMessage: async () => ({ ok: true }),
|
||||
postMessage: async () => ({ ts: `slack-stream-rest-${++slackRemainder}` }),
|
||||
},
|
||||
logger: { warn() {} },
|
||||
});
|
||||
const slackReceipt = await textReceipt({
|
||||
key: 'slack-stream',
|
||||
bot: slack,
|
||||
answer: 'S'.repeat(38_001),
|
||||
replyTarget: { channelId: 'C-stream', threadTs: '2.0' },
|
||||
});
|
||||
assert.deepEqual(slackReceipt.providerMessageIds, [
|
||||
'slack-stream-first',
|
||||
'slack-stream-rest-1',
|
||||
]);
|
||||
|
||||
let telegramId = 300;
|
||||
const telegram = new TelegramBotClient({
|
||||
api: {
|
||||
sendChatAction: async () => true,
|
||||
sendMessage: async () => ({ message_id: ++telegramId }),
|
||||
editMessageText: async () => true,
|
||||
},
|
||||
});
|
||||
const telegramReceipt = await textReceipt({
|
||||
key: 'telegram-stream',
|
||||
bot: telegram,
|
||||
answer: 'T'.repeat(4_001),
|
||||
replyTarget: { chatId: 42, replyToMessageId: 7, messageThreadId: 8 },
|
||||
});
|
||||
assert.deepEqual(telegramReceipt.providerMessageIds, ['301', '302']);
|
||||
|
||||
let discordId = 0;
|
||||
const discord = new DiscordBotClient({
|
||||
api: {
|
||||
sendTyping: async () => true,
|
||||
createMessage: async () => ({ id: `discord-stream-${++discordId}` }),
|
||||
editMessage: async () => ({ id: 'ignored-edit-id' }),
|
||||
},
|
||||
});
|
||||
const discordReceipt = await textReceipt({
|
||||
key: 'discord-stream',
|
||||
bot: discord,
|
||||
answer: 'D'.repeat(1_901),
|
||||
replyTarget: { channelId: 'discord-channel', replyToMessageId: 'discord-inbound' },
|
||||
});
|
||||
assert.deepEqual(discordReceipt.providerMessageIds, [
|
||||
'discord-stream-1',
|
||||
'discord-stream-2',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the text bridge preserves stream ids before any ids returned only by finish', async () => {
|
||||
const stream = {
|
||||
messageId: 'stream-first',
|
||||
providerMessageIds: ['stream-first', 'stream-remainder'],
|
||||
update() {},
|
||||
async finish() {
|
||||
return { providerMessageIds: ['stream-remainder', 'finish-only'] };
|
||||
},
|
||||
cancel() {},
|
||||
};
|
||||
const receipt = await textReceipt({
|
||||
key: 'custom-stream',
|
||||
bot: {
|
||||
sendText: async () => assert.fail('stream finalization must not fall back to plain text'),
|
||||
openStream: async () => stream,
|
||||
},
|
||||
answer: 'done',
|
||||
replyTarget: { channelId: 'custom-stream' },
|
||||
});
|
||||
|
||||
assert.deepEqual(receipt.providerMessageIds, [
|
||||
'stream-first',
|
||||
'stream-remainder',
|
||||
'finish-only',
|
||||
]);
|
||||
});
|
||||
|
|
@ -1,8 +1,16 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createBotWorkspaceScope } from '../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { HarnessClient } from '../../../src/channels/shared/harness-client.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
createOutboundArtifactTool,
|
||||
outboundArtifactRegistry,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
import { WORKSPACE_SESSION_STALE } from '../../../src/channels/shared/workspace-session.mjs';
|
||||
|
||||
const CATALOG = {
|
||||
|
|
@ -201,6 +209,7 @@ function controlledTurn({ sessionId, initialEnd = false, controlExecutor } = {})
|
|||
calls,
|
||||
client,
|
||||
admitted: admitted.promise,
|
||||
promptRpcId: () => promptRpcId,
|
||||
setText(text) { answer = text; },
|
||||
failHistory(error = new Error('history unavailable')) { historyFailure = error; },
|
||||
finish({ text = '', reason = 'cancelled' } = {}) {
|
||||
|
|
@ -259,6 +268,96 @@ test('a stopped turn returns partial text instead of a generic failure', async (
|
|||
assert.equal(await asking, 'partial result');
|
||||
});
|
||||
|
||||
test('an accepted stop preserves partial text but never hands off a registered artifact', async (t) => {
|
||||
outboundArtifactRegistry.clear();
|
||||
t.after(() => outboundArtifactRegistry.clear());
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-stopped-artifact-'));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
const turn = controlledTurn({ sessionId: 'session-stopped-artifact' });
|
||||
const control = { owner: {}, key: 'direct:stopped-artifact' };
|
||||
const handedOff = [];
|
||||
const asking = turn.client.ask(turn.id, 'work', {
|
||||
control,
|
||||
timeoutMs: 2_000,
|
||||
onArtifact: async (artifact) => handedOff.push(artifact),
|
||||
});
|
||||
await turn.admitted;
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: turn.id, cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 7 } },
|
||||
{
|
||||
type: 'user/message',
|
||||
data: { turn: 7, source: { rpcId: turn.promptRpcId() } },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
await writeFile(join(workspace, 'must-not-send.txt'), 'cancelled result');
|
||||
const tool = createOutboundArtifactTool({ registry: outboundArtifactRegistry });
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: 'stopped-artifact-call',
|
||||
rootCallId: 'stopped-artifact-call',
|
||||
token: Symbol('stopped-artifact-call'),
|
||||
agent,
|
||||
};
|
||||
await tool.definition.execute({ path: 'must-not-send.txt' }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
|
||||
assert.equal(await turn.client.stopActiveTurn(turn.id, control), true);
|
||||
turn.finish({ text: 'partial result', reason: 'stopped' });
|
||||
assert.equal(await asking, 'partial result');
|
||||
assert.deepEqual(handedOff, []);
|
||||
assert.deepEqual(outboundArtifactRegistry.take(turn.id, 7), []);
|
||||
});
|
||||
|
||||
test('an accepted stop also discards a file-only result instead of handing it off', async (t) => {
|
||||
outboundArtifactRegistry.clear();
|
||||
t.after(() => outboundArtifactRegistry.clear());
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-stopped-file-only-'));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
const turn = controlledTurn({ sessionId: 'session-stopped-file-only' });
|
||||
const control = { owner: {}, key: 'direct:stopped-file-only' };
|
||||
const handedOff = [];
|
||||
const asking = turn.client.ask(turn.id, 'work', {
|
||||
control,
|
||||
timeoutMs: 2_000,
|
||||
onArtifact: async (artifact) => handedOff.push(artifact),
|
||||
});
|
||||
await turn.admitted;
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: turn.id, cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 7 } },
|
||||
{
|
||||
type: 'user/message',
|
||||
data: { turn: 7, source: { rpcId: turn.promptRpcId() } },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
await writeFile(join(workspace, 'must-not-send.txt'), 'cancelled file-only result');
|
||||
const tool = createOutboundArtifactTool({ registry: outboundArtifactRegistry });
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: 'stopped-file-only-call',
|
||||
rootCallId: 'stopped-file-only-call',
|
||||
token: Symbol('stopped-file-only-call'),
|
||||
agent,
|
||||
};
|
||||
await tool.definition.execute({ path: 'must-not-send.txt' }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
|
||||
assert.equal(await turn.client.stopActiveTurn(turn.id, control), true);
|
||||
turn.finish({ reason: 'stopped' });
|
||||
await assert.rejects(asking, (error) => error?.code === 'turn-stopped');
|
||||
assert.deepEqual(handedOff, []);
|
||||
assert.deepEqual(outboundArtifactRegistry.take(turn.id, 7), []);
|
||||
});
|
||||
|
||||
test('accepted stop converts later polling failures to turn-stopped and preserves streamed text', async () => {
|
||||
const withoutText = controlledTurn();
|
||||
const firstControl = { owner: {}, key: 'direct:poll-error' };
|
||||
|
|
|
|||
72
test/channels/shared/production.test.mjs
Normal file
72
test/channels/shared/production.test.mjs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createTokenProductionController } from '../../../plugin-src/host/channels/shared/production.mjs';
|
||||
|
||||
test('token-channel production has no per-bot result-file Gate', async (t) => {
|
||||
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-token-production-artifacts-'));
|
||||
t.after(() => rm(dataDir, { recursive: true, force: true }));
|
||||
const runtimes = [];
|
||||
let controllerOptions;
|
||||
|
||||
class ConfigStore {
|
||||
async load() { return this; }
|
||||
list() { return []; }
|
||||
}
|
||||
class StateStore {
|
||||
async load() { return this; }
|
||||
}
|
||||
class Harness {
|
||||
stopManagedProcess() {}
|
||||
}
|
||||
class Runtime {
|
||||
constructor(options) { runtimes.push(options); }
|
||||
}
|
||||
class Controller {
|
||||
constructor(options) { controllerOptions = options; }
|
||||
async close() {}
|
||||
}
|
||||
const supervisor = {
|
||||
ready: Promise.resolve(),
|
||||
start() { return this; },
|
||||
async close() {},
|
||||
};
|
||||
|
||||
const production = await createTokenProductionController({
|
||||
credentials: {},
|
||||
webServer: { port: 3080 },
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
}, { dataDir }, {
|
||||
ConfigStore,
|
||||
StateStore,
|
||||
HarnessClient: Harness,
|
||||
Controller,
|
||||
Runtime,
|
||||
createConnectionSupervisor: () => supervisor,
|
||||
}, {
|
||||
channel: 'test-token',
|
||||
ConfigStore,
|
||||
StateStore,
|
||||
HarnessClient: Harness,
|
||||
Controller,
|
||||
Runtime,
|
||||
});
|
||||
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'bot_enabled',
|
||||
config: { botId: 'bot_enabled' },
|
||||
token: 'host-only',
|
||||
});
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'bot_disabled',
|
||||
config: { botId: 'bot_disabled' },
|
||||
token: 'host-only',
|
||||
});
|
||||
|
||||
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
|
||||
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
|
||||
await production.close();
|
||||
});
|
||||
|
|
@ -1,8 +1,17 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { DiscordHarnessBridge } from '../../../src/channels/discord/discord-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
releaseOutboundArtifact,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
import { TextHarnessBridge } from '../../../src/channels/shared/text-harness-bridge.mjs';
|
||||
import { SlackHarnessBridge } from '../../../src/channels/slack/slack-bridge.mjs';
|
||||
import { TelegramHarnessBridge } from '../../../src/channels/telegram/telegram-bridge.mjs';
|
||||
|
|
@ -121,7 +130,13 @@ function approvalInteraction({
|
|||
};
|
||||
}
|
||||
|
||||
function createBridge({ harness, state, bot, signal, logger } = {}) {
|
||||
function createBridge({
|
||||
harness,
|
||||
state,
|
||||
bot,
|
||||
signal,
|
||||
logger,
|
||||
} = {}) {
|
||||
return new TextHarnessBridge({
|
||||
descriptor: { key: 'test', label: 'Test' },
|
||||
bot,
|
||||
|
|
@ -132,6 +147,41 @@ function createBridge({ harness, state, bot, signal, logger } = {}) {
|
|||
});
|
||||
}
|
||||
|
||||
async function committedArtifact(t, fileName, content, suffix) {
|
||||
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-text-artifact-${suffix}-`));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
const sessionId = `session-artifact-${suffix}`;
|
||||
const rpcId = `rpc-artifact-${suffix}`;
|
||||
let nextId = 0;
|
||||
const registry = new OutboundArtifactRegistry({
|
||||
uuid: () => `${suffix}-${++nextId}`,
|
||||
});
|
||||
t.after(() => registry.clear());
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: sessionId, cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'user/message', data: { turn: 1, source: { rpcId } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
await writeFile(join(workspace, fileName), content);
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: `call-${suffix}`,
|
||||
rootCallId: `call-${suffix}`,
|
||||
token: Symbol(`call-${suffix}`),
|
||||
agent,
|
||||
};
|
||||
await tool.definition.execute({ path: fileName }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
const artifact = registry.take(sessionId, 1)[0];
|
||||
t.after(() => releaseOutboundArtifact(artifact));
|
||||
return artifact;
|
||||
}
|
||||
|
||||
test('all four shared text channels execute /compact outside the model prompt path', async () => {
|
||||
for (const [name, Bridge] of [
|
||||
['slack', SlackHarnessBridge],
|
||||
|
|
@ -329,6 +379,301 @@ test('a stopped shared-channel turn closes an opened stream instead of leaving a
|
|||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
test('shared text artifact delivery sends text first and each materialized file in order', async (t) => {
|
||||
const first = await committedArtifact(t, 'result.html', '<h1>result</h1>', 'success-html');
|
||||
const second = await committedArtifact(t, 'notes.txt', 'notes', 'success-text');
|
||||
const fixture = stateFixture();
|
||||
const order = [];
|
||||
const files = [];
|
||||
const target = { id: 'artifact-success-target' };
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
bot: {
|
||||
sendText: async (_target, text) => {
|
||||
order.push(`text:${text}`);
|
||||
return { id: 'text-success' };
|
||||
},
|
||||
sendFile: async (receivedTarget, file) => {
|
||||
order.push(`file:${file.fileName}`);
|
||||
files.push({ target: receivedTarget, file });
|
||||
return { message_id: `file-${files.length}` };
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
createSession: async () => 'session-artifact-success',
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(first);
|
||||
await options.onArtifact(second);
|
||||
return '结果文件如下。';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message(
|
||||
'artifact-success',
|
||||
'生成结果文件',
|
||||
{ replyTarget: target },
|
||||
));
|
||||
|
||||
assert.deepEqual(order, [
|
||||
'text:结果文件如下。',
|
||||
'file:result.html',
|
||||
'file:notes.txt',
|
||||
]);
|
||||
assert.equal(files[0].target, target);
|
||||
assert.equal(files[0].file.bytes.toString(), '<h1>result</h1>');
|
||||
assert.equal(files[1].file.bytes.toString(), 'notes');
|
||||
assert.equal(bridge.status.artifactsSent, 2);
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: 'artifact-success',
|
||||
presentation: 'test-text-and-files',
|
||||
providerMessageIds: ['text-success', 'file-1', 'file-2'],
|
||||
artifacts: [
|
||||
{ artifactId: 'success-html-1', outcome: 'sent' },
|
||||
{ artifactId: 'success-text-1', outcome: 'sent' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('a file-only shared text reply shows one neutral completion message before the file', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'only.txt', 'only file', 'file-only');
|
||||
const fixture = stateFixture();
|
||||
const order = [];
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
bot: {
|
||||
sendText: async (_target, text) => order.push(`text:${text}`),
|
||||
sendFile: async (_target, file) => order.push(`file:${file.fileName}`),
|
||||
},
|
||||
harness: {
|
||||
createSession: async () => 'session-file-only',
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return ' ';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept(message('artifact-file-only', '只生成并发送文件'));
|
||||
|
||||
assert.deepEqual(order, ['text:任务已完成。', 'file:only.txt']);
|
||||
});
|
||||
|
||||
test('shared text delivery still attempts registered files when its final text cannot be sent', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'text-failed.txt', 'still delivered', 'text-failed');
|
||||
const fixture = stateFixture();
|
||||
const files = [];
|
||||
const texts = [];
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
bot: {
|
||||
sendText: async (_target, text) => {
|
||||
texts.push(text);
|
||||
if (texts.length === 1) throw new Error('private text transport failure');
|
||||
},
|
||||
sendFile: async (_target, file) => {
|
||||
files.push(file.fileName);
|
||||
return { key: { id: 'file-after-text-failure' } };
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
createSession: async () => 'session-text-failed',
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字回答';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message('artifact-text-failed', '生成文件'));
|
||||
|
||||
assert.deepEqual(files, ['text-failed.txt']);
|
||||
assert.deepEqual(texts, ['文字回答']);
|
||||
assert.equal(bridge.status.artifactsSent, 1);
|
||||
assert.equal(bridge.status.messagesReplied, 1);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['file-after-text-failure']);
|
||||
assert.deepEqual(receipt.artifacts, [{ artifactId: 'text-failed-1', outcome: 'sent' }]);
|
||||
});
|
||||
|
||||
test('shared text artifact provider failures are isolated from text and later files', async (t) => {
|
||||
const providerFailure = await committedArtifact(t, 'provider.txt', 'bad', 'provider-failure');
|
||||
const oversized = await committedArtifact(t, 'oversized.txt', '123456789', 'oversized');
|
||||
const success = await committedArtifact(t, 'success.txt', 'ok', 'partial-success');
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const attempted = [];
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
bot: {
|
||||
sendText: async (_target, text) => {
|
||||
sent.push(text);
|
||||
return { id: `text-partial-${sent.length}` };
|
||||
},
|
||||
sendFile: async (_target, file) => {
|
||||
attempted.push(file.fileName);
|
||||
if (file.fileName === 'provider.txt') {
|
||||
throw new Error('private provider diagnostic');
|
||||
}
|
||||
if (file.fileName === 'oversized.txt') {
|
||||
const error = new Error('provider upload limit');
|
||||
error.code = 'artifact-too-large';
|
||||
throw error;
|
||||
}
|
||||
return { id: 'file-partial-success' };
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
createSession: async () => 'session-artifact-partial',
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(providerFailure);
|
||||
await options.onArtifact(oversized);
|
||||
await options.onArtifact(success);
|
||||
return '三个文件已生成。';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message('artifact-partial', '生成三个文件'));
|
||||
|
||||
assert.deepEqual(attempted, ['provider.txt', 'oversized.txt', 'success.txt']);
|
||||
assert.equal(sent[0], '三个文件已生成。');
|
||||
assert.match(sent[1], /provider\.txt.*暂时未能发送/);
|
||||
assert.doesNotMatch(sent[1], /private provider diagnostic/);
|
||||
assert.match(sent[2], /oversized\.txt.*超过当前渠道大小上限/);
|
||||
assert.equal(bridge.status.artifactsSent, 1);
|
||||
assert.equal(bridge.status.artifactSendErrors, 2);
|
||||
assert.deepEqual(receipt.providerMessageIds, [
|
||||
'text-partial-1',
|
||||
'text-partial-2',
|
||||
'text-partial-3',
|
||||
'file-partial-success',
|
||||
]);
|
||||
assert.deepEqual(receipt.artifacts, [
|
||||
{
|
||||
artifactId: 'provider-failure-1',
|
||||
outcome: 'failed',
|
||||
reason: 'artifact-provider-failed',
|
||||
},
|
||||
{ artifactId: 'oversized-1', outcome: 'rejected', reason: 'artifact-too-large' },
|
||||
{ artifactId: 'partial-success-1', outcome: 'sent' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('shared text bridge tells users to check the chat before retrying an uncertain file send', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'uncertain.txt', 'bytes', 'uncertain');
|
||||
const sent = [];
|
||||
let textAttempts = 0;
|
||||
const bridge = createBridge({
|
||||
state: stateFixture().state,
|
||||
bot: {
|
||||
sendText: async (_target, text) => {
|
||||
textAttempts += 1;
|
||||
if (textAttempts === 1) throw new Error('initial text delivery failed');
|
||||
sent.push(text);
|
||||
return { ts: 'unknown-notice' };
|
||||
},
|
||||
sendFile: async () => {
|
||||
const error = new Error('private timeout detail');
|
||||
error.code = 'artifact-delivery-uncertain';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
createSession: async () => 'session-uncertain',
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文件已生成。';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message('artifact-uncertain', '生成文件'));
|
||||
|
||||
assert.equal(textAttempts, 2, 'must not append a contradictory generic retry notice');
|
||||
assert.match(sent[0], /发送结果未能确认.*先检查聊天内是否已收到.*不要立即重试/);
|
||||
assert.doesNotMatch(sent[0], /private timeout detail/);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['unknown-notice']);
|
||||
assert.deepEqual(receipt.artifacts, [{
|
||||
artifactId: 'uncertain-1',
|
||||
outcome: 'unknown',
|
||||
reason: 'artifact-delivery-uncertain',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('shared text streaming finalizes once before delivering result files', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'stream.txt', 'stream file', 'stream');
|
||||
const fixture = stateFixture();
|
||||
const order = [];
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
bot: {
|
||||
sendText: async (_target, text) => order.push(`text:${text}`),
|
||||
sendTyping: async () => order.push('typing'),
|
||||
openStream: async () => {
|
||||
order.push('open');
|
||||
return {
|
||||
messageId: 'stream-message',
|
||||
update: async (text) => order.push(`update:${text}`),
|
||||
finish: async (text) => order.push(`finish:${text}`),
|
||||
cancel: () => order.push('cancel'),
|
||||
};
|
||||
},
|
||||
sendFile: async (_target, file) => order.push(`file:${file.fileName}`),
|
||||
},
|
||||
harness: {
|
||||
createSession: async () => 'session-artifact-stream',
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onUpdate({ type: 'text', text: '处理中' });
|
||||
await options.onArtifact(artifact);
|
||||
return '流式回答完成';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message('artifact-stream', '流式生成文件'));
|
||||
|
||||
assert.deepEqual(order, [
|
||||
'typing',
|
||||
'open',
|
||||
'update:处理中',
|
||||
'finish:流式回答完成',
|
||||
'file:stream.txt',
|
||||
]);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['stream-message']);
|
||||
});
|
||||
|
||||
test('aborting shared text delivery stops before any registered file is sent', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'cancelled.txt', 'cancelled', 'cancelled');
|
||||
const fixture = stateFixture();
|
||||
const controller = new AbortController();
|
||||
const sent = [];
|
||||
const files = [];
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
signal: controller.signal,
|
||||
bot: {
|
||||
sendText: async (_target, text) => {
|
||||
sent.push(text);
|
||||
controller.abort(new DOMException('runtime stopped', 'AbortError'));
|
||||
},
|
||||
sendFile: async (_target, file) => files.push(file.fileName),
|
||||
},
|
||||
harness: {
|
||||
createSession: async () => 'session-artifact-cancelled',
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '回答完成';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept(message('artifact-cancelled', '生成后取消'));
|
||||
|
||||
assert.deepEqual(sent, ['回答完成']);
|
||||
assert.deepEqual(files, []);
|
||||
});
|
||||
|
||||
test('Slack, Telegram, and Discord remember any valid direct message per bot', async () => {
|
||||
for (const [name, Bridge] of [
|
||||
['slack', SlackHarnessBridge],
|
||||
|
|
|
|||
78
test/channels/slack/production.test.mjs
Normal file
78
test/channels/slack/production.test.mjs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createProductionController } from '../../../plugin-src/host/channels/slack/production.mjs';
|
||||
|
||||
test('Slack production has no per-bot result-file Gate', async (t) => {
|
||||
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-slack-production-artifacts-'));
|
||||
t.after(() => rm(dataDir, { recursive: true, force: true }));
|
||||
const runtimes = [];
|
||||
let controllerOptions;
|
||||
|
||||
class ConfigStore {
|
||||
async load() { return this; }
|
||||
list() { return []; }
|
||||
}
|
||||
class StateStore {
|
||||
async load() { return this; }
|
||||
}
|
||||
class Harness {
|
||||
stopManagedProcess() {}
|
||||
}
|
||||
class Runtime {
|
||||
constructor(options) { runtimes.push(options); }
|
||||
}
|
||||
class Controller {
|
||||
constructor(options) { controllerOptions = options; }
|
||||
async close() {}
|
||||
}
|
||||
const createConnectionSupervisor = () => ({
|
||||
ready: Promise.resolve(),
|
||||
start() { return this; },
|
||||
async close() {},
|
||||
});
|
||||
const internals = {
|
||||
ConfigStore,
|
||||
StateStore,
|
||||
HarnessClient: Harness,
|
||||
Controller,
|
||||
Runtime,
|
||||
createConnectionSupervisor,
|
||||
};
|
||||
const ctx = {
|
||||
credentials: {},
|
||||
webServer: { port: 3080 },
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
};
|
||||
|
||||
const production = await createProductionController(ctx, { dataDir }, internals);
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'slack_enabled',
|
||||
config: { botId: 'slack_enabled' },
|
||||
botToken: 'host-only-bot-token',
|
||||
appToken: 'host-only-app-token',
|
||||
});
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'slack_disabled',
|
||||
config: { botId: 'slack_disabled' },
|
||||
botToken: 'host-only-bot-token',
|
||||
appToken: 'host-only-app-token',
|
||||
});
|
||||
|
||||
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
|
||||
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
|
||||
await production.close();
|
||||
|
||||
const productionWithDefault = await createProductionController(ctx, { dataDir }, internals);
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'slack_default',
|
||||
config: { botId: 'slack_default' },
|
||||
botToken: 'host-only-bot-token',
|
||||
appToken: 'host-only-app-token',
|
||||
});
|
||||
assert.equal(Object.hasOwn(runtimes[2], 'outboundArtifactsEnabled'), false);
|
||||
await productionWithDefault.close();
|
||||
});
|
||||
|
|
@ -134,6 +134,269 @@ test('Slack API uses native streaming methods and suppresses generated mass ment
|
|||
assert.equal(calls[3].body.text, '请通知 @channel 和 @U99999999');
|
||||
});
|
||||
|
||||
test('Slack API completes the native external-upload flow in the original thread', async () => {
|
||||
const calls = [];
|
||||
const api = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
appToken: APP_TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
if (url.pathname.endsWith('/files.getUploadURLExternal')) {
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
upload_url: 'https://files.slack.com/upload/v1/TICKET',
|
||||
file_id: 'F12345678',
|
||||
});
|
||||
}
|
||||
if (url.pathname.startsWith('/upload/')) return new Response('OK', { status: 200 });
|
||||
return jsonResponse({ ok: true, files: [{ id: 'F12345678', title: 'result.txt' }] });
|
||||
},
|
||||
});
|
||||
const result = await api.uploadFile({
|
||||
channelId: 'C12345678',
|
||||
threadTs: '1700000000.001',
|
||||
file: {
|
||||
fileName: 'result.txt',
|
||||
mediaType: 'text/plain',
|
||||
bytes: Buffer.from('slack-result'),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.files[0].id, 'F12345678');
|
||||
assert.deepEqual(calls.map(({ url }) => url.pathname), [
|
||||
'/api/files.getUploadURLExternal',
|
||||
'/upload/v1/TICKET',
|
||||
'/api/files.completeUploadExternal',
|
||||
]);
|
||||
assert.equal(
|
||||
calls[0].options.headers['content-type'],
|
||||
'application/x-www-form-urlencoded;charset=utf-8',
|
||||
);
|
||||
assert.equal(calls[0].options.body, 'filename=result.txt&length=12');
|
||||
assert.equal(calls[1].options.headers.authorization, undefined);
|
||||
assert.equal(Buffer.from(calls[1].options.body).toString(), 'slack-result');
|
||||
assert.equal(calls[2].options.headers['content-type'], 'application/json;charset=utf-8');
|
||||
assert.deepEqual(JSON.parse(calls[2].options.body), {
|
||||
files: [{ id: 'F12345678', title: 'result.txt' }],
|
||||
channel_id: 'C12345678',
|
||||
thread_ts: '1700000000.001',
|
||||
});
|
||||
assert.match(SLACK_APP_MANIFEST_YAML, /\n\s+- files:write\n/);
|
||||
});
|
||||
|
||||
test('Slack file preparation maps missing scope and pre-delivery failures without claiming uncertainty', async () => {
|
||||
const missingScopeApi = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fetchImpl: async () => jsonResponse({ ok: false, error: 'missing_scope' }),
|
||||
});
|
||||
await assert.rejects(() => missingScopeApi.uploadFile({
|
||||
channelId: 'C12345678',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-permission-required'
|
||||
&& error.providerCode === 'missing_scope');
|
||||
|
||||
const invalidArgumentsApi = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fetchImpl: async () => jsonResponse({ ok: false, error: 'invalid_arguments' }),
|
||||
});
|
||||
await assert.rejects(() => invalidArgumentsApi.uploadFile({
|
||||
channelId: 'C12345678',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-provider-rejected'
|
||||
&& error.providerCode === 'invalid_arguments'
|
||||
&& error.status === 200);
|
||||
|
||||
const sizeRestrictedApi = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fetchImpl: async () => jsonResponse({
|
||||
ok: false,
|
||||
error: 'file_upload_size_restricted',
|
||||
}),
|
||||
});
|
||||
await assert.rejects(() => sizeRestrictedApi.uploadFile({
|
||||
channelId: 'C12345678',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-too-large'
|
||||
&& error.providerCode === 'file_upload_size_restricted'
|
||||
&& error.status === 200);
|
||||
|
||||
for (const ticketResponse of [
|
||||
new Response('not-json', { status: 200 }),
|
||||
jsonResponse({
|
||||
ok: true,
|
||||
upload_url: 'https://example.com/untrusted',
|
||||
file_id: 'F12345678',
|
||||
}),
|
||||
]) {
|
||||
const api = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fetchImpl: async () => ticketResponse,
|
||||
});
|
||||
await assert.rejects(() => api.uploadFile({
|
||||
channelId: 'C12345678',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-provider-failed');
|
||||
}
|
||||
|
||||
const rawUploadTimeoutApi = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fileUploadTimeoutMs: 10,
|
||||
fetchImpl: async (url, options) => {
|
||||
if (url.pathname.endsWith('/files.getUploadURLExternal')) {
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
upload_url: 'https://files.slack.com/upload/v1/TICKET',
|
||||
file_id: 'F12345678',
|
||||
});
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
if (options.signal.aborted) reject(options.signal.reason);
|
||||
else options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true });
|
||||
});
|
||||
},
|
||||
});
|
||||
await assert.rejects(() => rawUploadTimeoutApi.uploadFile({
|
||||
channelId: 'C12345678',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-provider-failed'
|
||||
&& error.cause?.name === 'TimeoutError');
|
||||
|
||||
for (const status of [401, 403]) {
|
||||
const expiredTicketApi = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fetchImpl: async (url) => {
|
||||
if (url.pathname.endsWith('/files.getUploadURLExternal')) {
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
upload_url: 'https://files.slack.com/upload/v1/TICKET',
|
||||
file_id: 'F12345678',
|
||||
});
|
||||
}
|
||||
return new Response('', { status });
|
||||
},
|
||||
});
|
||||
await assert.rejects(() => expiredTicketApi.uploadFile({
|
||||
channelId: 'C12345678',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-provider-rejected'
|
||||
&& error.status === status);
|
||||
}
|
||||
});
|
||||
|
||||
test('Slack completion is never retried and all non-permission failures are uncertain', async () => {
|
||||
const completionCases = [{
|
||||
complete: async () => jsonResponse({ ok: false, error: 'ratelimited' }, 429, {
|
||||
'retry-after': '0.001',
|
||||
}),
|
||||
providerCode: 'ratelimited',
|
||||
}, {
|
||||
complete: async () => new Response('not-json', { status: 200 }),
|
||||
}, {
|
||||
complete: async () => { throw new TypeError('socket reset'); },
|
||||
}, {
|
||||
complete: async () => jsonResponse({ ok: true, files: [] }),
|
||||
}, {
|
||||
complete: async () => jsonResponse({
|
||||
ok: true,
|
||||
files: [{ id: 'F87654321', title: 'result.txt' }],
|
||||
}),
|
||||
}];
|
||||
|
||||
for (const entry of completionCases) {
|
||||
let completionCalls = 0;
|
||||
const api = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fetchImpl: async (url) => {
|
||||
if (url.pathname.endsWith('/files.getUploadURLExternal')) {
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
upload_url: 'https://files.slack.com/upload/v1/TICKET',
|
||||
file_id: 'F12345678',
|
||||
});
|
||||
}
|
||||
if (url.pathname.startsWith('/upload/')) return new Response('OK', { status: 200 });
|
||||
completionCalls += 1;
|
||||
return entry.complete();
|
||||
},
|
||||
});
|
||||
await assert.rejects(() => api.uploadFile({
|
||||
channelId: 'C12345678',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& error.providerCode === entry.providerCode);
|
||||
assert.equal(completionCalls, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('Slack completion timeout is uncertain while a caller abort remains an abort', async () => {
|
||||
const createApi = ({ complete, fileUploadTimeoutMs = 120_000 }) => new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fileUploadTimeoutMs,
|
||||
fetchImpl: async (url, options) => {
|
||||
if (url.pathname.endsWith('/files.getUploadURLExternal')) {
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
upload_url: 'https://files.slack.com/upload/v1/TICKET',
|
||||
file_id: 'F12345678',
|
||||
});
|
||||
}
|
||||
if (url.pathname.startsWith('/upload/')) return new Response('OK', { status: 200 });
|
||||
return complete(options.signal);
|
||||
},
|
||||
});
|
||||
const timeoutApi = createApi({
|
||||
fileUploadTimeoutMs: 10,
|
||||
complete: async (signal) => new Promise((resolve, reject) => {
|
||||
if (signal.aborted) reject(signal.reason);
|
||||
else signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
}),
|
||||
});
|
||||
await assert.rejects(() => timeoutApi.uploadFile({
|
||||
channelId: 'C12345678',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& error.cause?.name === 'TimeoutError');
|
||||
|
||||
const caller = new AbortController();
|
||||
const reason = new DOMException('caller stopped', 'AbortError');
|
||||
const cancelledApi = createApi({
|
||||
complete: async () => {
|
||||
caller.abort(reason);
|
||||
throw reason;
|
||||
},
|
||||
});
|
||||
await assert.rejects(() => cancelledApi.uploadFile({
|
||||
channelId: 'C12345678',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
|
||||
signal: caller.signal,
|
||||
}), (error) => error === reason && error.code !== 'artifact-delivery-uncertain');
|
||||
});
|
||||
|
||||
test('Slack completion missing_scope remains a permission error', async () => {
|
||||
let completionCalls = 0;
|
||||
const api = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fetchImpl: async (url) => {
|
||||
if (url.pathname.endsWith('/files.getUploadURLExternal')) {
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
upload_url: 'https://files.slack.com/upload/v1/TICKET',
|
||||
file_id: 'F12345678',
|
||||
});
|
||||
}
|
||||
if (url.pathname.startsWith('/upload/')) return new Response('OK', { status: 200 });
|
||||
completionCalls += 1;
|
||||
return jsonResponse({ ok: false, error: 'missing_scope' });
|
||||
},
|
||||
});
|
||||
await assert.rejects(() => api.uploadFile({
|
||||
channelId: 'C12345678',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-permission-required'
|
||||
&& error.providerCode === 'missing_scope');
|
||||
assert.equal(completionCalls, 1);
|
||||
});
|
||||
|
||||
test('Slack downloads private files with the bot token from Slack hosts only', async () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const calls = [];
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { normalizeTelegramAllowedUsers } from '../../../plugin-src/host/channels/telegram/production.mjs';
|
||||
import {
|
||||
createProductionController,
|
||||
normalizeTelegramAllowedUsers,
|
||||
} from '../../../plugin-src/host/channels/telegram/production.mjs';
|
||||
|
||||
test('Telegram per-bot policy normalizes and validates private-message allowlists', () => {
|
||||
assert.deepEqual(normalizeTelegramAllowedUsers(undefined), []);
|
||||
|
|
@ -18,3 +24,54 @@ test('Telegram per-bot policy normalizes and validates private-message allowlist
|
|||
/invalid Telegram User ID/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Telegram production has no per-bot result-file Gate', async (t) => {
|
||||
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-telegram-production-artifacts-'));
|
||||
t.after(() => rm(dataDir, { recursive: true, force: true }));
|
||||
const runtimes = [];
|
||||
let controllerOptions;
|
||||
|
||||
class ConfigStore {
|
||||
async load() { return this; }
|
||||
list() { return []; }
|
||||
}
|
||||
class StateStore {
|
||||
async load() { return this; }
|
||||
}
|
||||
class Harness {
|
||||
stopManagedProcess() {}
|
||||
}
|
||||
class Runtime {
|
||||
constructor(options) { runtimes.push(options); }
|
||||
}
|
||||
class Controller {
|
||||
constructor(options) { controllerOptions = options; }
|
||||
async close() {}
|
||||
}
|
||||
const supervisor = {
|
||||
ready: Promise.resolve(),
|
||||
start() { return this; },
|
||||
async close() {},
|
||||
};
|
||||
|
||||
const production = await createProductionController({
|
||||
credentials: {},
|
||||
webServer: { port: 3080 },
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
}, { dataDir }, {
|
||||
ConfigStore,
|
||||
StateStore,
|
||||
HarnessClient: Harness,
|
||||
Controller,
|
||||
Runtime,
|
||||
createConnectionSupervisor: () => supervisor,
|
||||
});
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'telegram_default',
|
||||
config: { botId: 'telegram_default' },
|
||||
token: 'host-only',
|
||||
});
|
||||
|
||||
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
|
||||
await production.close();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -200,6 +200,136 @@ test('Telegram API registers the command menu and commands-type menu button', as
|
|||
await assert.rejects(() => api.setChatMenuButton({ menuButton: 'commands' }), /menu button is invalid/);
|
||||
});
|
||||
|
||||
test('Telegram API uploads a result file as a native document in the same topic and reply chain', async () => {
|
||||
let request;
|
||||
const api = new TelegramApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
request = { url, options };
|
||||
return jsonResponse({ ok: true, result: { message_id: 901 } });
|
||||
},
|
||||
});
|
||||
const result = await api.sendDocument({
|
||||
chatId: -100123,
|
||||
replyToMessageId: 44,
|
||||
messageThreadId: 55,
|
||||
file: {
|
||||
fileName: 'result.txt',
|
||||
mediaType: 'text/plain',
|
||||
bytes: Buffer.from('telegram-result'),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.message_id, 901);
|
||||
assert.match(request.url.pathname, /sendDocument$/);
|
||||
assert.equal(request.options.method, 'POST');
|
||||
assert.equal(request.options.headers, undefined);
|
||||
assert.ok(request.options.body instanceof FormData);
|
||||
assert.equal(request.options.body.get('chat_id'), '-100123');
|
||||
assert.equal(request.options.body.get('message_thread_id'), '55');
|
||||
assert.deepEqual(JSON.parse(request.options.body.get('reply_parameters')), {
|
||||
message_id: 44,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
const document = request.options.body.get('document');
|
||||
assert.equal(document.name, 'result.txt');
|
||||
assert.equal(document.type, 'text/plain');
|
||||
assert.equal(Buffer.from(await document.arrayBuffer()).toString(), 'telegram-result');
|
||||
});
|
||||
|
||||
test('Telegram document errors retain provider details and use stable artifact reasons', async () => {
|
||||
const cases = [{
|
||||
body: { ok: false, error_code: 403, description: 'Forbidden: bot was blocked' },
|
||||
status: 403,
|
||||
code: 'artifact-permission-required',
|
||||
}, {
|
||||
body: { ok: false, error_code: 400, description: 'Bad Request: file is too big' },
|
||||
status: 400,
|
||||
code: 'artifact-too-large',
|
||||
}, {
|
||||
body: {
|
||||
ok: false,
|
||||
error_code: 429,
|
||||
description: 'Too Many Requests',
|
||||
parameters: { retry_after: 7 },
|
||||
},
|
||||
status: 429,
|
||||
code: 'artifact-rate-limited',
|
||||
retryAfter: 7,
|
||||
}, {
|
||||
body: { ok: false, error_code: 400, description: 'Bad Request: unsupported document' },
|
||||
status: 400,
|
||||
code: 'artifact-provider-rejected',
|
||||
}, {
|
||||
body: { ok: false, error_code: 500, description: 'Internal Server Error' },
|
||||
status: 500,
|
||||
code: 'artifact-delivery-uncertain',
|
||||
}];
|
||||
|
||||
for (const entry of cases) {
|
||||
const api = new TelegramApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async () => jsonResponse(entry.body, entry.status),
|
||||
});
|
||||
await assert.rejects(() => api.sendDocument({
|
||||
chatId: 123,
|
||||
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
|
||||
}), (error) => {
|
||||
assert.equal(error.code, entry.code);
|
||||
assert.equal(error.providerCode, entry.body.error_code);
|
||||
assert.equal(error.status, entry.status);
|
||||
assert.equal(error.retry_after, entry.retryAfter);
|
||||
assert.equal(error.retryAfter, entry.retryAfter);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Telegram document delivery marks post-dispatch failures uncertain but preserves caller aborts', async () => {
|
||||
for (const fetchImpl of [
|
||||
async () => { throw new TypeError('socket reset'); },
|
||||
async () => new Response('not-json', { status: 200 }),
|
||||
]) {
|
||||
const api = new TelegramApi({ token: TOKEN, fetchImpl });
|
||||
await assert.rejects(() => api.sendDocument({
|
||||
chatId: 123,
|
||||
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-delivery-uncertain');
|
||||
}
|
||||
|
||||
const timeoutApi = new TelegramApi({
|
||||
token: TOKEN,
|
||||
fileUploadTimeoutMs: 10,
|
||||
fetchImpl: async (_url, { signal }) => new Promise((resolve, reject) => {
|
||||
if (signal.aborted) reject(signal.reason);
|
||||
else signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
}),
|
||||
});
|
||||
await assert.rejects(() => timeoutApi.sendDocument({
|
||||
chatId: 123,
|
||||
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& error.cause?.name === 'TimeoutError');
|
||||
|
||||
const caller = new AbortController();
|
||||
const reason = new DOMException('caller stopped', 'AbortError');
|
||||
caller.abort(reason);
|
||||
let calls = 0;
|
||||
const cancelledApi = new TelegramApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return jsonResponse({ ok: true, result: {} });
|
||||
},
|
||||
});
|
||||
await assert.rejects(() => cancelledApi.sendDocument({
|
||||
chatId: 123,
|
||||
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
|
||||
signal: caller.signal,
|
||||
}), (error) => error === reason && error.code !== 'artifact-delivery-uncertain');
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('Telegram config and controller store only a credential reference in bot data', async (t) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
|
|
@ -7,6 +10,11 @@ import {
|
|||
} from '../../../src/channels/wecom/wecom-bridge.mjs';
|
||||
import { DEFAULT_IMAGE_PROMPT } from '../../../src/channels/shared/image-prompt.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
|
||||
const PNG_1X1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
|
|
@ -77,6 +85,37 @@ function testClient() {
|
|||
};
|
||||
}
|
||||
|
||||
async function committedArtifact(t, fileName, content, suffix) {
|
||||
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-wecom-artifact-${suffix}-`));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
let nextId = 0;
|
||||
const registry = new OutboundArtifactRegistry({ uuid: () => `${suffix}-${++nextId}` });
|
||||
t.after(() => registry.clear());
|
||||
const rpcId = `rpc-${suffix}`;
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: `session-${suffix}`, cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'user/message', data: { turn: 1, source: { rpcId } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: `call-${suffix}`,
|
||||
rootCallId: `call-${suffix}`,
|
||||
token: Symbol(`call-${suffix}`),
|
||||
agent,
|
||||
};
|
||||
await writeFile(join(workspace, fileName), content);
|
||||
await tool.definition.execute({ path: fileName }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
const [artifact] = registry.take(agent.session.header.id, 1);
|
||||
return artifact;
|
||||
}
|
||||
|
||||
function questionInteraction({
|
||||
interactionId = 'question-1',
|
||||
sessionId = 'session-existing',
|
||||
|
|
@ -1219,3 +1258,368 @@ test('aborting Enterprise WeChat work cancels its pending question without a fai
|
|||
assert.equal(cancellations[0].responseOptions.signal.aborted, false);
|
||||
assert.equal(transport.streamed.some(({ content }) => content === '消息处理失败,请稍后重试。'), false);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat sends registered files after the final text and continues after one file fails', async (t) => {
|
||||
const first = await committedArtifact(t, 'first.txt', 'first bytes', 'partial-first');
|
||||
const second = await committedArtifact(t, 'second.html', '<h1>second</h1>', 'partial-second');
|
||||
const order = [];
|
||||
const uploads = [];
|
||||
const active = [];
|
||||
const status = {
|
||||
messagesReceived: 0,
|
||||
messagesReplied: 0,
|
||||
messagesRejected: 0,
|
||||
artifactsSent: 0,
|
||||
artifactSendErrors: 0,
|
||||
lastMessageAt: null,
|
||||
lastReplyAt: null,
|
||||
lastRejectedAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
const client = {
|
||||
replyStream: async (_source, _streamId, content, finish) => {
|
||||
if (finish) order.push(`text:${content}`);
|
||||
},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async (chatId, body) => {
|
||||
const content = body.markdown.content;
|
||||
active.push({ chatId, content });
|
||||
order.push(`notice:${content}`);
|
||||
return { body: { msgid: `notice-${active.length}` } };
|
||||
},
|
||||
uploadMedia: async (bytes, options) => {
|
||||
uploads.push({ bytes: Buffer.from(bytes), options });
|
||||
order.push(`upload:${options.filename}`);
|
||||
return { media_id: `media-${options.filename}` };
|
||||
},
|
||||
sendMediaMessage: async (chatId, type, mediaId) => {
|
||||
order.push(`file:${mediaId}`);
|
||||
if (mediaId === 'media-first.txt') {
|
||||
const error = new Error('private provider detail');
|
||||
error.code = 'artifact-provider-failed';
|
||||
throw error;
|
||||
}
|
||||
return { body: { msgid: 'wecom-file-2' }, chatId, type };
|
||||
},
|
||||
};
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client,
|
||||
generateStreamId: () => 'artifact-stream',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(first);
|
||||
await options.onArtifact(second);
|
||||
return '文件处理完成。';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
status,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(frame({ msgid: 'wecom-artifact-partial' }));
|
||||
|
||||
assert.deepEqual(order.map((entry) => entry.split(':', 1)[0]), [
|
||||
'text', 'upload', 'file', 'notice', 'upload', 'file',
|
||||
]);
|
||||
assert.deepEqual(uploads.map(({ options }) => options), [
|
||||
{ type: 'file', filename: 'first.txt' },
|
||||
{ type: 'file', filename: 'second.html' },
|
||||
]);
|
||||
assert.equal(uploads[0].bytes.toString(), 'first bytes');
|
||||
assert.equal(uploads[1].bytes.toString(), '<h1>second</h1>');
|
||||
assert.equal(active.length, 1);
|
||||
assert.match(active[0].content, /first\.txt.*暂时未能/);
|
||||
assert.doesNotMatch(active[0].content, /private provider detail/);
|
||||
assert.equal(status.artifactsSent, 1);
|
||||
assert.equal(status.artifactSendErrors, 1);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat still delivers registered files when final text delivery fails', async (t) => {
|
||||
const artifact = await committedArtifact(
|
||||
t,
|
||||
'survives-text-failure.txt',
|
||||
'file bytes',
|
||||
'text-failure',
|
||||
);
|
||||
const files = [];
|
||||
let finalTextAttempts = 0;
|
||||
let activeTextAttempts = 0;
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async (_source, _streamId, _content, finish) => {
|
||||
if (finish) {
|
||||
finalTextAttempts += 1;
|
||||
throw new Error('stream finalization unavailable');
|
||||
}
|
||||
},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async () => {
|
||||
activeTextAttempts += 1;
|
||||
throw new Error('active text unavailable');
|
||||
},
|
||||
uploadMedia: async () => ({ media_id: 'media-after-text-failure' }),
|
||||
sendMediaMessage: async (chatId, type, mediaId) => {
|
||||
files.push({ chatId, type, mediaId });
|
||||
return { body: { msgid: 'wecom-file-after-text-failure' } };
|
||||
},
|
||||
},
|
||||
generateStreamId: () => 'text-failure-stream',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字结果';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(frame({ msgid: 'wecom-text-failure' }));
|
||||
|
||||
assert.deepEqual(files, [{
|
||||
chatId: 'member-1',
|
||||
type: 'file',
|
||||
mediaId: 'media-after-text-failure',
|
||||
}]);
|
||||
assert.equal(finalTextAttempts, 1, 'must not append a generic retry stream after file success');
|
||||
assert.equal(activeTextAttempts, 1);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat returns the authoritative receipt and one safe notice when text and file delivery fail', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'mismatch.txt', 'file bytes', 'all-fail');
|
||||
const attemptedActiveTexts = [];
|
||||
const visibleActiveTexts = [];
|
||||
const finalStreamTexts = [];
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async (_source, _streamId, content, finish) => {
|
||||
if (finish) {
|
||||
finalStreamTexts.push(content);
|
||||
throw new Error('stream finalization unavailable');
|
||||
}
|
||||
},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async (_chatId, body) => {
|
||||
const text = body.markdown.content;
|
||||
attemptedActiveTexts.push(text);
|
||||
if (text === '文字结果') throw new Error('active text unavailable');
|
||||
visibleActiveTexts.push(text);
|
||||
return {};
|
||||
},
|
||||
uploadMedia: async () => {
|
||||
const error = new Error('mismatched file signature');
|
||||
error.code = 'artifact-invalid';
|
||||
throw error;
|
||||
},
|
||||
sendMediaMessage: async () => assert.fail('a rejected upload must not be sent'),
|
||||
},
|
||||
generateStreamId: () => 'all-fail-stream',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字结果';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(frame({ msgid: 'wecom-all-fail' }));
|
||||
|
||||
assert.deepEqual(finalStreamTexts, ['文字结果']);
|
||||
assert.equal(attemptedActiveTexts.length, 2, 'must not append a generic error after the safe notice');
|
||||
assert.equal(visibleActiveTexts.length, 1);
|
||||
assert.match(visibleActiveTexts[0], /暂时无法读取或准备发送.*仍可访问/);
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: 'wecom-all-fail',
|
||||
presentation: 'wecom-files',
|
||||
providerMessageIds: [],
|
||||
artifacts: [{
|
||||
artifactId: artifact.artifactId,
|
||||
outcome: 'rejected',
|
||||
reason: 'artifact-invalid',
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
test('Enterprise WeChat keeps the generic error when no answer or file failure notice is visible', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'unavailable.txt', 'file bytes', 'no-visible-failure');
|
||||
const attemptedActiveTexts = [];
|
||||
const finalStreamTexts = [];
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async (_source, _streamId, content, finish) => {
|
||||
if (!finish) return;
|
||||
finalStreamTexts.push(content);
|
||||
if (finalStreamTexts.length === 1) throw new Error('stream finalization unavailable');
|
||||
},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async (_chatId, body) => {
|
||||
attemptedActiveTexts.push(body.markdown.content);
|
||||
throw new Error('active text unavailable');
|
||||
},
|
||||
uploadMedia: async () => {
|
||||
const error = new Error('file transport unavailable');
|
||||
error.code = 'artifact-provider-failed';
|
||||
throw error;
|
||||
},
|
||||
sendMediaMessage: async () => assert.fail('a rejected upload must not be sent'),
|
||||
},
|
||||
generateStreamId: () => 'no-visible-failure-stream',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字结果';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(frame({ msgid: 'wecom-no-visible-failure' }));
|
||||
|
||||
assert.equal(attemptedActiveTexts.length, 2);
|
||||
assert.deepEqual(finalStreamTexts, ['文字结果', '消息处理失败,请稍后重试。']);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat reports an unacknowledged file message as uncertain', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'uncertain.txt', 'file bytes', 'uncertain');
|
||||
const active = [];
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async () => {},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async (_chatId, body) => active.push(body.markdown.content),
|
||||
uploadMedia: async () => ({ media_id: 'media-uncertain' }),
|
||||
sendMediaMessage: async () => new Promise(() => {}),
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文件已生成。';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
fileUploadTimeoutMs: 20,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(frame({ msgid: 'wecom-uncertain-file' }));
|
||||
|
||||
assert.match(active[0], /发送结果未能确认.*先检查聊天内是否已收到.*不要立即重试/);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat cancellation interrupts an in-flight file send and skips later files', async (t) => {
|
||||
const first = await committedArtifact(t, 'first.txt', 'first', 'abort-first');
|
||||
const second = await committedArtifact(t, 'second.txt', 'second', 'abort-second');
|
||||
const started = deferred();
|
||||
const controller = new AbortController();
|
||||
const uploads = [];
|
||||
const active = [];
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async () => {},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async (_chatId, body) => active.push(body.markdown.content),
|
||||
uploadMedia: async (_bytes, options) => {
|
||||
uploads.push(options.filename);
|
||||
return { media_id: `media-${options.filename}` };
|
||||
},
|
||||
sendMediaMessage: async () => {
|
||||
started.resolve();
|
||||
return new Promise(() => {});
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(first);
|
||||
await options.onArtifact(second);
|
||||
return '文件如下。';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
signal: controller.signal,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const processing = bridge.accept(frame({ msgid: 'wecom-abort-file' }));
|
||||
await started.promise;
|
||||
controller.abort(new DOMException('runtime stopped', 'AbortError'));
|
||||
await Promise.race([
|
||||
processing,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('WeCom abort timed out')), 500)),
|
||||
]);
|
||||
|
||||
assert.deepEqual(uploads, ['first.txt']);
|
||||
assert.equal(active.some((text) => text.includes('发送结果未能确认')), false);
|
||||
assert.equal(active.some((text) => text === '消息处理失败,请稍后重试。'), false);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat uses a neutral final text for a file-only Turn', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'only.txt', 'only bytes', 'file-only');
|
||||
const finalTexts = [];
|
||||
const files = [];
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async (_source, _streamId, content, finish) => {
|
||||
if (finish) finalTexts.push(content);
|
||||
},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async () => {},
|
||||
uploadMedia: async () => ({ media_id: 'media-only' }),
|
||||
sendMediaMessage: async (chatId, type, mediaId) => {
|
||||
files.push({ chatId, type, mediaId });
|
||||
return {};
|
||||
},
|
||||
},
|
||||
generateStreamId: () => 'file-only-stream',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
});
|
||||
|
||||
await bridge.accept(frame({ msgid: 'wecom-file-only' }));
|
||||
|
||||
assert.deepEqual(finalTexts, ['结果文件已生成。']);
|
||||
assert.deepEqual(files, [{ chatId: 'member-1', type: 'file', mediaId: 'media-only' }]);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat cancellation prevents SDK upload', async (t) => {
|
||||
let uploads = 0;
|
||||
const artifact = await committedArtifact(t, 'cancelled.txt', 'cancelled bytes', 'cancelled');
|
||||
const controller = new AbortController();
|
||||
const cancelledBridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async () => {},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async () => {},
|
||||
uploadMedia: async () => { uploads += 1; return { media_id: 'must-not-upload' }; },
|
||||
sendMediaMessage: async () => {},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
controller.abort(new DOMException('stopped', 'AbortError'));
|
||||
return '停止前的回答';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
await cancelledBridge.accept(frame({ msgid: 'wecom-artifact-cancelled' }));
|
||||
assert.equal(uploads, 0);
|
||||
});
|
||||
|
|
|
|||
72
test/channels/wecom/production.test.mjs
Normal file
72
test/channels/wecom/production.test.mjs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createProductionController } from '../../../plugin-src/host/channels/wecom/production.mjs';
|
||||
|
||||
test('Enterprise WeChat production has no per-bot result-file Gate', async (t) => {
|
||||
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-wecom-production-artifacts-'));
|
||||
t.after(() => rm(dataDir, { recursive: true, force: true }));
|
||||
const runtimes = [];
|
||||
let controllerOptions;
|
||||
|
||||
class ConfigStore {
|
||||
async load() { return this; }
|
||||
list() { return []; }
|
||||
}
|
||||
class StateStore {
|
||||
async load() { return this; }
|
||||
}
|
||||
class Harness {
|
||||
stopManagedProcess() {}
|
||||
}
|
||||
class Runtime {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
runtimes.push(options);
|
||||
}
|
||||
}
|
||||
class Controller {
|
||||
constructor(options) {
|
||||
controllerOptions = options;
|
||||
}
|
||||
async close() {}
|
||||
}
|
||||
class QrAuth {}
|
||||
const supervisor = {
|
||||
ready: Promise.resolve(),
|
||||
start() { return this; },
|
||||
async close() {},
|
||||
};
|
||||
|
||||
const production = await createProductionController({
|
||||
credentials: {},
|
||||
webServer: { port: 3080 },
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
}, { dataDir }, {
|
||||
ConfigStore,
|
||||
StateStore,
|
||||
HarnessClient: Harness,
|
||||
Controller,
|
||||
Runtime,
|
||||
QrAuth,
|
||||
createConnectionSupervisor: () => supervisor,
|
||||
});
|
||||
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'wecom_enabled',
|
||||
config: { botId: 'wecom_enabled', remoteBotId: 'remote-enabled' },
|
||||
secret: 'host-only',
|
||||
});
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'wecom_disabled',
|
||||
config: { botId: 'wecom_disabled', remoteBotId: 'remote-disabled' },
|
||||
secret: 'host-only',
|
||||
});
|
||||
|
||||
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
|
||||
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
|
||||
await production.close();
|
||||
});
|
||||
|
|
@ -157,3 +157,45 @@ test('Enterprise WeChat runtime aborts an in-flight Harness interaction when sto
|
|||
await runtime.stop();
|
||||
assert.equal(askSignal.aborted, true);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat runtime enables result-file delivery without per-bot configuration', async () => {
|
||||
const client = new FakeClient();
|
||||
const finished = deferred();
|
||||
let onArtifact;
|
||||
const runtime = new WecomRuntime({
|
||||
config: { botId: 'wecom_bot', remoteBotId: 'remote-bot' },
|
||||
secret: 'private-secret',
|
||||
harness: {
|
||||
ensureRunning: async () => true,
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
onArtifact = options.onArtifact;
|
||||
return '完成';
|
||||
},
|
||||
},
|
||||
state: {
|
||||
hasSeen: () => false,
|
||||
markSeen: async () => finished.resolve(),
|
||||
sessionFor: () => 'session-existing',
|
||||
setSession: async () => {},
|
||||
clearSession: async () => {},
|
||||
},
|
||||
createClient: () => client,
|
||||
connectTimeoutMs: 100,
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
client.emit('message', {
|
||||
headers: { req_id: 'req-artifact-gate' },
|
||||
body: {
|
||||
msgid: 'msg-artifact-gate',
|
||||
chattype: 'single',
|
||||
from: { userid: 'member-1' },
|
||||
msgtype: 'text',
|
||||
text: { content: '生成文件' },
|
||||
},
|
||||
});
|
||||
await finished.promise;
|
||||
assert.equal(typeof onArtifact, 'function');
|
||||
await runtime.stop();
|
||||
});
|
||||
|
|
|
|||
76
test/channels/weixin/production.test.mjs
Normal file
76
test/channels/weixin/production.test.mjs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createProductionController } from '../../../plugin-src/host/channels/weixin/production.mjs';
|
||||
|
||||
test('Weixin production has no per-bot result-file Gate', async (t) => {
|
||||
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-weixin-production-artifacts-'));
|
||||
t.after(() => rm(dataDir, { recursive: true, force: true }));
|
||||
const runtimes = [];
|
||||
let controllerOptions;
|
||||
|
||||
class ConfigStore {
|
||||
async load() { return this; }
|
||||
list() { return []; }
|
||||
}
|
||||
class StateStore {
|
||||
async load() { return this; }
|
||||
}
|
||||
class Harness {
|
||||
stopManagedProcess() {}
|
||||
}
|
||||
class Runtime {
|
||||
constructor(options) { runtimes.push(options); }
|
||||
}
|
||||
class Controller {
|
||||
constructor(options) { controllerOptions = options; }
|
||||
async close() {}
|
||||
}
|
||||
const createConnectionSupervisor = () => ({
|
||||
ready: Promise.resolve(),
|
||||
start() { return this; },
|
||||
async close() {},
|
||||
});
|
||||
const internals = {
|
||||
ConfigStore,
|
||||
StateStore,
|
||||
HarnessClient: Harness,
|
||||
Controller,
|
||||
Runtime,
|
||||
api: {},
|
||||
createConnectionSupervisor,
|
||||
};
|
||||
const ctx = {
|
||||
credentials: {},
|
||||
webServer: { port: 3080 },
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
};
|
||||
|
||||
const production = await createProductionController(ctx, { dataDir }, internals);
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'wx_enabled',
|
||||
config: { botId: 'wx_enabled' },
|
||||
token: 'host-only',
|
||||
});
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'wx_disabled',
|
||||
config: { botId: 'wx_disabled' },
|
||||
token: 'host-only',
|
||||
});
|
||||
|
||||
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
|
||||
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
|
||||
await production.close();
|
||||
|
||||
const productionWithDefault = await createProductionController(ctx, { dataDir }, internals);
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'wx_default',
|
||||
config: { botId: 'wx_default' },
|
||||
token: 'host-only',
|
||||
});
|
||||
assert.equal(Object.hasOwn(runtimes[2], 'outboundArtifactsEnabled'), false);
|
||||
await productionWithDefault.close();
|
||||
});
|
||||
|
|
@ -171,6 +171,243 @@ test('sendText emits the iLink message envelope without reflecting the token in
|
|||
assert.doesNotMatch(calls[0].init.body, /host-only-token/);
|
||||
});
|
||||
|
||||
test('sendFile uses the iLink 2.4.6 encrypted CDN flow and sends a native file item', async () => {
|
||||
const calls = [];
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: url.toString(), init });
|
||||
if (url.pathname.endsWith('/getuploadurl')) {
|
||||
return jsonResponse({
|
||||
ret: 0,
|
||||
upload_full_url: 'https://novac2c.cdn.weixin.qq.com/c2c/upload?ticket=one',
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/c2c/upload') {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'x-encrypted-param': 'download-ticket' },
|
||||
});
|
||||
}
|
||||
return jsonResponse({ ret: 0 });
|
||||
},
|
||||
});
|
||||
const plaintext = Buffer.from('weixin-native-file');
|
||||
const result = await api.sendFile({
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'host-only-token',
|
||||
toUserId: 'wx-user',
|
||||
contextToken: 'message-context',
|
||||
runId: 'run-1',
|
||||
file: {
|
||||
artifactId: 'artifact-one',
|
||||
deliveryKey: 'session:turn:artifact-one',
|
||||
fileName: 'result.txt',
|
||||
mediaType: 'text/plain',
|
||||
bytes: plaintext,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 3);
|
||||
const ticket = JSON.parse(calls[0].init.body);
|
||||
assert.equal(ticket.media_type, 3);
|
||||
assert.equal(ticket.to_user_id, 'wx-user');
|
||||
assert.equal(ticket.rawsize, plaintext.length);
|
||||
assert.equal(ticket.rawfilemd5, 'ae8e1f4207c0468828419884f7329cb8');
|
||||
assert.equal(ticket.filesize, 32);
|
||||
assert.equal(ticket.no_need_thumb, true);
|
||||
assert.match(ticket.aeskey, /^[0-9a-f]{32}$/);
|
||||
|
||||
assert.equal(calls[1].init.headers.Authorization, undefined);
|
||||
assert.equal(calls[1].init.headers['content-type'], 'application/octet-stream');
|
||||
assert.equal(calls[1].init.body.byteLength, 32);
|
||||
assert.equal(
|
||||
decryptWeixinImage(calls[1].init.body, Buffer.from(ticket.aeskey, 'hex')).equals(plaintext),
|
||||
true,
|
||||
);
|
||||
|
||||
const sent = JSON.parse(calls[2].init.body).msg;
|
||||
assert.equal(sent.context_token, 'message-context');
|
||||
assert.equal(sent.run_id, 'run-1');
|
||||
assert.match(sent.client_id, /^dsh-weixin-[0-9a-f]{32}$/);
|
||||
assert.equal(result.messageId, sent.client_id);
|
||||
assert.deepEqual(sent.item_list, [{
|
||||
type: 4,
|
||||
file_item: {
|
||||
media: {
|
||||
encrypt_query_param: 'download-ticket',
|
||||
aes_key: Buffer.from(ticket.aeskey).toString('base64'),
|
||||
encrypt_type: 1,
|
||||
},
|
||||
file_name: 'result.txt',
|
||||
len: String(plaintext.length),
|
||||
},
|
||||
}]);
|
||||
});
|
||||
|
||||
function weixinFileRequest(overrides = {}) {
|
||||
return {
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'host-only-token',
|
||||
toUserId: 'wx-user',
|
||||
file: {
|
||||
artifactId: 'artifact-error-case',
|
||||
deliveryKey: 'session:turn:artifact-error-case',
|
||||
fileName: 'result.txt',
|
||||
mediaType: 'text/plain',
|
||||
bytes: Buffer.from('weixin-error-case'),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function weixinFileFetch(finalResponse) {
|
||||
return async (url, init) => {
|
||||
if (url.pathname.endsWith('/getuploadurl')) {
|
||||
return jsonResponse({
|
||||
ret: 0,
|
||||
upload_full_url: 'https://novac2c.cdn.weixin.qq.com/c2c/upload?ticket=error-case',
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/c2c/upload') {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'x-encrypted-param': 'download-error-case' },
|
||||
});
|
||||
}
|
||||
return finalResponse(url, init);
|
||||
};
|
||||
}
|
||||
|
||||
test('sendFile marks every ambiguous sendmessage result as uncertain', async (t) => {
|
||||
const cases = [
|
||||
{
|
||||
name: 'network failure',
|
||||
finalResponse: async () => { throw new TypeError('private socket detail'); },
|
||||
},
|
||||
{
|
||||
name: 'timeout',
|
||||
finalResponse: async () => {
|
||||
throw new WeixinApiError('timeout', 'private timeout detail');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'invalid JSON',
|
||||
finalResponse: async () => new Response('{not-json', { status: 200 }),
|
||||
},
|
||||
{
|
||||
name: 'HTTP 5xx',
|
||||
finalResponse: async () => jsonResponse({ ret: -1 }, { status: 503 }),
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of cases) {
|
||||
await t.test(scenario.name, async () => {
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: weixinFileFetch(scenario.finalResponse),
|
||||
});
|
||||
await assert.rejects(
|
||||
api.sendFile(weixinFileRequest()),
|
||||
(error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& !error.message.includes('private'),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('sendFile maps definitive sendmessage rejection statuses without treating them as uncertain', async (t) => {
|
||||
const cases = [
|
||||
{ name: 'permission', status: 403, code: 'artifact-permission-required' },
|
||||
{ name: 'too large', status: 413, code: 'artifact-too-large' },
|
||||
{ name: 'rate limited', status: 429, code: 'artifact-rate-limited' },
|
||||
{ name: 'provider rejected', status: 400, code: 'artifact-provider-rejected' },
|
||||
];
|
||||
|
||||
for (const scenario of cases) {
|
||||
await t.test(scenario.name, async () => {
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: weixinFileFetch(async () => jsonResponse(
|
||||
{ ret: scenario.status },
|
||||
{ status: scenario.status },
|
||||
)),
|
||||
});
|
||||
await assert.rejects(
|
||||
api.sendFile(weixinFileRequest()),
|
||||
(error) => error.code === scenario.code,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const permissionApi = createWeixinApi({
|
||||
fetchImpl: weixinFileFetch(async () => jsonResponse({ ret: 403 })),
|
||||
});
|
||||
await assert.rejects(
|
||||
permissionApi.sendFile(weixinFileRequest()),
|
||||
(error) => error.code === 'artifact-permission-required'
|
||||
&& error.providerCode === '403',
|
||||
);
|
||||
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: weixinFileFetch(async () => jsonResponse({ ret: -2001 })),
|
||||
});
|
||||
await assert.rejects(
|
||||
api.sendFile(weixinFileRequest()),
|
||||
(error) => error.code === 'artifact-provider-rejected'
|
||||
&& error.providerCode === '-2001',
|
||||
);
|
||||
});
|
||||
|
||||
test('sendFile preserves caller abort and never marks a pre-send preparation failure uncertain', async () => {
|
||||
let preparationCalls = 0;
|
||||
const preparationApi = createWeixinApi({
|
||||
fetchImpl: async () => {
|
||||
preparationCalls += 1;
|
||||
throw new TypeError('private upload preparation failure');
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
preparationApi.sendFile(weixinFileRequest()),
|
||||
(error) => error.code === 'artifact-provider-failed'
|
||||
&& error.code !== 'artifact-delivery-uncertain',
|
||||
);
|
||||
assert.equal(preparationCalls, 1);
|
||||
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('caller stopped the turn');
|
||||
const abortApi = createWeixinApi({
|
||||
fetchImpl: weixinFileFetch(async () => {
|
||||
controller.abort(reason);
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}),
|
||||
});
|
||||
await assert.rejects(
|
||||
abortApi.sendFile(weixinFileRequest({ signal: controller.signal })),
|
||||
(error) => error === reason && error.code !== 'artifact-delivery-uncertain',
|
||||
);
|
||||
});
|
||||
|
||||
test('sendFile rejects a provider upload URL outside the canonical Weixin CDN', async () => {
|
||||
let calls = 0;
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return jsonResponse({
|
||||
ret: 0,
|
||||
upload_full_url: 'https://attacker.example/c2c/upload?ticket=one',
|
||||
});
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
api.sendFile({
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'host-only-token',
|
||||
toUserId: 'wx-user',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('safe') },
|
||||
}),
|
||||
(error) => error instanceof WeixinApiError && error.code === 'untrusted-upload-url',
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('getUpdates converts its own long-poll timeout into an empty successful poll', async () => {
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: async (_url, init) => new Promise((_resolve, reject) => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createWeixinBridgeStatus, WeixinHarnessBridge } from '../../../src/channels/weixin/weixin-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
releaseOutboundArtifact,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -69,6 +78,36 @@ const PNG_BYTES = Buffer.from([
|
|||
0x01, 0x02, 0x03,
|
||||
]);
|
||||
|
||||
async function committedArtifact(t, fileName, content) {
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-weixin-artifact-'));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
const registry = new OutboundArtifactRegistry({ uuid: () => 'weixin-artifact-one' });
|
||||
t.after(() => registry.clear());
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: 'artifact-session', cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'user/message', data: { turn: 1, source: { rpcId: 'artifact-rpc' } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
await writeFile(join(workspace, fileName), content);
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: 'weixin-artifact-call',
|
||||
rootCallId: 'weixin-artifact-call',
|
||||
token: Symbol('weixin-artifact-call'),
|
||||
agent,
|
||||
};
|
||||
await tool.definition.execute({ path: fileName }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
const artifact = registry.take('artifact-session', 1)[0];
|
||||
t.after(() => releaseOutboundArtifact(artifact));
|
||||
return artifact;
|
||||
}
|
||||
|
||||
test('Weixin remembers any authorized private inbound as a connection-test target', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
|
|
@ -98,6 +137,144 @@ test('Weixin remembers any authorized private inbound as a connection-test targe
|
|||
assert.equal(connectionTestTarget(rejectedFixture.state), null);
|
||||
});
|
||||
|
||||
test('Weixin returns a registered result file with native context after its existing text path', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'result.txt', 'weixin-result');
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-artifact');
|
||||
const order = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
inboundImages: () => [],
|
||||
sendText: async ({ text }) => {
|
||||
order.push(`text:${text}`);
|
||||
return { messageId: 'weixin-text-one' };
|
||||
},
|
||||
sendFile: async (request) => {
|
||||
order.push(`file:${request.file.fileName}`);
|
||||
assert.equal(request.toUserId, 'owner-user');
|
||||
assert.equal(request.contextToken, 'context-weixin-artifact');
|
||||
assert.equal(request.runId, 'run-artifact');
|
||||
assert.equal(request.file.bytes.toString(), 'weixin-result');
|
||||
return { messageId: 'weixin-file-one' };
|
||||
},
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message(
|
||||
'weixin-artifact',
|
||||
'生成文件',
|
||||
{ run_id: 'run-artifact' },
|
||||
));
|
||||
|
||||
assert.deepEqual(order, ['text:结果文件已生成。', 'file:result.txt']);
|
||||
assert.equal(bridge.status.artifactsSent, 1);
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: 'weixin-artifact',
|
||||
presentation: 'weixin-text-and-files',
|
||||
providerMessageIds: ['weixin-text-one', 'weixin-file-one'],
|
||||
artifacts: [{ artifactId: 'weixin-artifact-one', outcome: 'sent' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('Weixin still attempts a registered file when the final text transport fails', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'weixin-text-failed.txt', 'weixin-file');
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-artifact-text-failed');
|
||||
const files = [];
|
||||
let textAttempts = 0;
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
inboundImages: () => [],
|
||||
sendText: async () => {
|
||||
textAttempts += 1;
|
||||
throw new Error('private text failure');
|
||||
},
|
||||
sendFile: async ({ file }) => {
|
||||
files.push(file.fileName);
|
||||
return { messageId: 'weixin-file-after-text-failure' };
|
||||
},
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字回答';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message('weixin-artifact-text-failed', '生成文件'));
|
||||
|
||||
assert.deepEqual(files, ['weixin-text-failed.txt']);
|
||||
assert.equal(textAttempts, 1, 'must not send a generic retry notice after the file succeeds');
|
||||
assert.equal(bridge.status.artifactsSent, 1);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['weixin-file-after-text-failure']);
|
||||
assert.deepEqual(receipt.artifacts, [{ artifactId: 'weixin-artifact-one', outcome: 'sent' }]);
|
||||
});
|
||||
|
||||
test('Weixin tells users to inspect the chat instead of retrying an uncertain file delivery', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'weixin-uncertain.txt', 'weixin-file');
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-artifact-uncertain');
|
||||
const sent = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
inboundImages: () => [],
|
||||
sendText: async ({ text }) => {
|
||||
sent.push(text);
|
||||
return { messageId: `weixin-text-${sent.length}` };
|
||||
},
|
||||
sendFile: async () => {
|
||||
const error = new Error('private provider transport detail');
|
||||
error.code = 'artifact-delivery-uncertain';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message('weixin-artifact-uncertain', '生成文件'));
|
||||
|
||||
assert.equal(
|
||||
sent.at(-1),
|
||||
'结果文件「weixin-uncertain.txt」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。',
|
||||
);
|
||||
assert.doesNotMatch(sent.join('\n'), /private provider transport detail/);
|
||||
assert.equal(bridge.status.artifactSendErrors, 1);
|
||||
assert.deepEqual(receipt.artifacts, [{
|
||||
artifactId: 'weixin-artifact-one',
|
||||
outcome: 'unknown',
|
||||
reason: 'artifact-delivery-uncertain',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('Weixin sends image-only messages to Harness as structured content', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-image');
|
||||
|
|
|
|||
75
test/channels/whatsapp/production.test.mjs
Normal file
75
test/channels/whatsapp/production.test.mjs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createProductionController } from '../../../plugin-src/host/channels/whatsapp/production.mjs';
|
||||
|
||||
test('WhatsApp production has no per-bot result-file Gate', async (t) => {
|
||||
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-whatsapp-production-artifacts-'));
|
||||
t.after(() => rm(dataDir, { recursive: true, force: true }));
|
||||
const runtimes = [];
|
||||
let controllerOptions;
|
||||
|
||||
class ConfigStore {
|
||||
async load() { return this; }
|
||||
list() { return []; }
|
||||
}
|
||||
class StateStore {
|
||||
async load() { return this; }
|
||||
}
|
||||
class Harness {
|
||||
stopManagedProcess() {}
|
||||
}
|
||||
class Runtime {
|
||||
constructor(options) { runtimes.push(options); }
|
||||
}
|
||||
class Controller {
|
||||
constructor(options) { controllerOptions = options; }
|
||||
async close() {}
|
||||
}
|
||||
const createConnectionSupervisor = () => ({
|
||||
ready: Promise.resolve(),
|
||||
start() { return this; },
|
||||
async close() {},
|
||||
});
|
||||
const internals = {
|
||||
ConfigStore,
|
||||
StateStore,
|
||||
HarnessClient: Harness,
|
||||
Controller,
|
||||
Runtime,
|
||||
createSession: async () => ({}),
|
||||
createConnectionSupervisor,
|
||||
};
|
||||
const ctx = {
|
||||
webServer: { port: 3080 },
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
};
|
||||
|
||||
const production = await createProductionController(ctx, { dataDir }, internals);
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'whatsapp_enabled',
|
||||
config: { botId: 'whatsapp_enabled' },
|
||||
authDir: '00000000-0000-4000-8000-000000000001',
|
||||
});
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'whatsapp_disabled',
|
||||
config: { botId: 'whatsapp_disabled' },
|
||||
authDir: '00000000-0000-4000-8000-000000000002',
|
||||
});
|
||||
|
||||
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
|
||||
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
|
||||
await production.close();
|
||||
|
||||
const productionWithDefault = await createProductionController(ctx, { dataDir }, internals);
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'whatsapp_default',
|
||||
config: { botId: 'whatsapp_default' },
|
||||
authDir: '00000000-0000-4000-8000-000000000003',
|
||||
});
|
||||
assert.equal(Object.hasOwn(runtimes[2], 'outboundArtifactsEnabled'), false);
|
||||
await productionWithDefault.close();
|
||||
});
|
||||
|
|
@ -1,12 +1,23 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { mkdtemp, stat } from 'node:fs/promises';
|
||||
import {
|
||||
mkdtemp,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { DisconnectReason } from '@whiskeysockets/baileys';
|
||||
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
import {
|
||||
WhatsappConfigStore,
|
||||
deriveWhatsappBotId,
|
||||
|
|
@ -25,6 +36,87 @@ import {
|
|||
const ACCOUNT_JID = '16505550123@s.whatsapp.net';
|
||||
const AUTH_DIRECTORY = '7fe8c17e-4fb7-4c5b-a9dc-c36525575dd1';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function within(promise, timeoutMs, message) {
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function committedArtifact(t, {
|
||||
suffix,
|
||||
fileName = 'result.txt',
|
||||
content = 'WhatsApp result file',
|
||||
} = {}) {
|
||||
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-whatsapp-artifact-${suffix}-`));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
const sessionId = `session-whatsapp-artifact-${suffix}`;
|
||||
const rpcId = `rpc-whatsapp-artifact-${suffix}`;
|
||||
let nextId = 0;
|
||||
const ids = [];
|
||||
const registry = new OutboundArtifactRegistry({
|
||||
uuid: () => {
|
||||
const id = `${suffix}-${++nextId}`;
|
||||
ids.push(id);
|
||||
return id;
|
||||
},
|
||||
});
|
||||
t.after(() => registry.clear());
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: sessionId, cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'user/message', data: { turn: 1, source: { rpcId } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
await writeFile(join(workspace, fileName), content);
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: `call-${suffix}`,
|
||||
rootCallId: `call-${suffix}`,
|
||||
token: Symbol(`call-${suffix}`),
|
||||
agent,
|
||||
};
|
||||
await tool.definition.execute({ path: fileName }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
return {
|
||||
artifact: registry.take(sessionId, 1)[0],
|
||||
deliveryKey: ids[1],
|
||||
};
|
||||
}
|
||||
|
||||
function artifactState(sessionId = 'session-whatsapp-artifact') {
|
||||
const seen = new Set();
|
||||
return {
|
||||
hasSeen: (messageId) => seen.has(messageId),
|
||||
markSeen: async (messageId) => { seen.add(messageId); },
|
||||
sessionFor: () => sessionId,
|
||||
sessionExists: async () => true,
|
||||
setSession: async () => {},
|
||||
clearSession: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function linkedConfig(overrides = {}) {
|
||||
return {
|
||||
botId: deriveWhatsappBotId(ACCOUNT_JID),
|
||||
|
|
@ -376,6 +468,265 @@ test('WhatsApp runtime connects a linked device and replies through Harness', as
|
|||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('WhatsApp runtime sends result files with native metadata, quote, stable id, and upload timeout', async (t) => {
|
||||
const { artifact, deliveryKey } = await committedArtifact(t, {
|
||||
suffix: 'native-file',
|
||||
fileName: 'report.txt',
|
||||
content: 'native WhatsApp artifact',
|
||||
});
|
||||
let callbacks;
|
||||
const calls = [];
|
||||
const socket = {
|
||||
sendPresenceUpdate: async () => {},
|
||||
readMessages: async () => {},
|
||||
sendMessage: async (jid, content, options) => {
|
||||
calls.push({ jid, content, options });
|
||||
return { key: { id: content.document ? 'file-message-1' : 'text-message-1' } };
|
||||
},
|
||||
};
|
||||
const runtime = new WhatsappRuntime({
|
||||
config: linkedConfig(),
|
||||
authDir: '/tmp/test-whatsapp-native-file',
|
||||
harness: {
|
||||
ensureRunning: async () => {},
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
assert.equal(typeof options.onArtifact, 'function');
|
||||
await options.onArtifact(artifact);
|
||||
return '结果文件如下。';
|
||||
},
|
||||
},
|
||||
state: artifactState(),
|
||||
createSession: async (options) => {
|
||||
callbacks = options;
|
||||
return {
|
||||
socket,
|
||||
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
|
||||
close: async () => {},
|
||||
logout: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
t.after(() => runtime.stop());
|
||||
await runtime.start();
|
||||
const inbound = {
|
||||
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'native-file-1', fromMe: false },
|
||||
message: { conversation: '生成结果文件' },
|
||||
};
|
||||
|
||||
await callbacks.onMessage(inbound);
|
||||
|
||||
const textCall = calls.find((call) => call.content.text === '结果文件如下。');
|
||||
const fileCall = calls.find((call) => call.content.document);
|
||||
assert.ok(textCall);
|
||||
assert.ok(fileCall);
|
||||
assert.equal(fileCall.jid, '16505550999@s.whatsapp.net');
|
||||
assert.equal(fileCall.content.document.toString(), 'native WhatsApp artifact');
|
||||
assert.equal(fileCall.content.mimetype, 'text/plain');
|
||||
assert.equal(fileCall.content.fileName, 'report.txt');
|
||||
assert.equal(fileCall.options.quoted, inbound);
|
||||
assert.equal(fileCall.options.mediaUploadTimeoutMs, 120_000);
|
||||
assert.equal(
|
||||
fileCall.options.messageId,
|
||||
createHash('sha256').update(deliveryKey).digest('hex').slice(0, 20).toUpperCase(),
|
||||
);
|
||||
assert.equal(fileCall.options.messageId.length, 20);
|
||||
assert.equal(calls.indexOf(textCall) < calls.indexOf(fileCall), true);
|
||||
});
|
||||
|
||||
test('WhatsApp suppresses a self-chat file echo that arrives before the provider ACK', async (t) => {
|
||||
const { artifact } = await committedArtifact(t, {
|
||||
suffix: 'early-self-file-echo',
|
||||
fileName: 'self-report.txt',
|
||||
content: 'self chat artifact',
|
||||
});
|
||||
let callbacks;
|
||||
let echoTask;
|
||||
let askCount = 0;
|
||||
const sent = [];
|
||||
const socket = {
|
||||
sendPresenceUpdate: async () => {},
|
||||
readMessages: async () => {},
|
||||
sendMessage: async (jid, content, options = {}) => {
|
||||
sent.push({ jid, content, options });
|
||||
if (content.document) {
|
||||
echoTask = callbacks.onMessage({
|
||||
key: { remoteJid: ACCOUNT_JID, id: options.messageId, fromMe: true },
|
||||
message: {
|
||||
documentMessage: {
|
||||
fileName: content.fileName,
|
||||
mimetype: content.mimetype,
|
||||
},
|
||||
},
|
||||
});
|
||||
return { key: { id: options.messageId } };
|
||||
}
|
||||
return { key: { id: `text-${sent.length}` } };
|
||||
},
|
||||
};
|
||||
const runtime = new WhatsappRuntime({
|
||||
config: linkedConfig(),
|
||||
authDir: '/tmp/test-whatsapp-early-self-file-echo',
|
||||
harness: {
|
||||
ensureRunning: async () => {},
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
askCount += 1;
|
||||
await options.onArtifact(artifact);
|
||||
return '结果文件如下。';
|
||||
},
|
||||
},
|
||||
state: artifactState('session-whatsapp-early-self-file-echo'),
|
||||
createSession: async (options) => {
|
||||
callbacks = options;
|
||||
return {
|
||||
socket,
|
||||
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
|
||||
close: async () => {},
|
||||
logout: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
t.after(() => runtime.stop());
|
||||
await runtime.start();
|
||||
|
||||
await callbacks.onMessage({
|
||||
key: { remoteJid: ACCOUNT_JID, id: 'self-file-request-1', fromMe: true },
|
||||
message: { conversation: '生成结果文件' },
|
||||
});
|
||||
await echoTask;
|
||||
|
||||
assert.equal(askCount, 1);
|
||||
assert.equal(sent.filter(({ content }) => content.document).length, 1);
|
||||
assert.equal(sent.some(({ content }) => content.text === '目前支持文字和图片消息。'), false);
|
||||
});
|
||||
|
||||
test('WhatsApp runtime treats a lost file-send response as uncertain delivery', async (t) => {
|
||||
const { artifact } = await committedArtifact(t, {
|
||||
suffix: 'uncertain-file',
|
||||
fileName: 'uncertain.txt',
|
||||
});
|
||||
let callbacks;
|
||||
const warnings = [];
|
||||
const sent = [];
|
||||
const socket = {
|
||||
sendPresenceUpdate: async () => {},
|
||||
readMessages: async () => {},
|
||||
sendMessage: async (_jid, content) => {
|
||||
sent.push(content);
|
||||
if (content.document) return new Promise(() => {});
|
||||
return { key: { id: `text-${sent.length}` } };
|
||||
},
|
||||
};
|
||||
const runtime = new WhatsappRuntime({
|
||||
config: linkedConfig(),
|
||||
authDir: '/tmp/test-whatsapp-uncertain-file',
|
||||
harness: {
|
||||
ensureRunning: async () => {},
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '已生成文件。';
|
||||
},
|
||||
},
|
||||
state: artifactState('session-whatsapp-uncertain'),
|
||||
logger: {
|
||||
warn: (...args) => warnings.push(args.join(' ')),
|
||||
error() {},
|
||||
},
|
||||
mediaUploadTimeoutMs: 20,
|
||||
createSession: async (options) => {
|
||||
callbacks = options;
|
||||
return {
|
||||
socket,
|
||||
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
|
||||
close: async () => {},
|
||||
logout: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
t.after(() => runtime.stop());
|
||||
await runtime.start();
|
||||
|
||||
await callbacks.onMessage({
|
||||
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'uncertain-file-1', fromMe: false },
|
||||
message: { conversation: '生成文件' },
|
||||
});
|
||||
|
||||
assert.equal(sent.filter((content) => content.document).length, 1);
|
||||
assert.equal(warnings.some((warning) => warning.includes('artifact-delivery-uncertain')), true);
|
||||
assert.equal(sent.some((content) => content.text?.includes('发送结果未能确认')), true);
|
||||
});
|
||||
|
||||
test('stopping WhatsApp aborts a pending upload without another file or failure notice', async (t) => {
|
||||
const first = await committedArtifact(t, {
|
||||
suffix: 'cancel-first',
|
||||
fileName: 'first.txt',
|
||||
});
|
||||
const second = await committedArtifact(t, {
|
||||
suffix: 'cancel-second',
|
||||
fileName: 'second.txt',
|
||||
});
|
||||
let callbacks;
|
||||
const uploadStarted = deferred();
|
||||
const uploadResponse = deferred();
|
||||
const sent = [];
|
||||
const socket = {
|
||||
sendPresenceUpdate: async () => {},
|
||||
readMessages: async () => {},
|
||||
sendMessage: async (_jid, content) => {
|
||||
sent.push(content);
|
||||
if (content.document) {
|
||||
uploadStarted.resolve();
|
||||
return uploadResponse.promise;
|
||||
}
|
||||
return { key: { id: `text-${sent.length}` } };
|
||||
},
|
||||
};
|
||||
const runtime = new WhatsappRuntime({
|
||||
config: linkedConfig(),
|
||||
authDir: '/tmp/test-whatsapp-cancel-upload',
|
||||
harness: {
|
||||
ensureRunning: async () => {},
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(first.artifact);
|
||||
await options.onArtifact(second.artifact);
|
||||
return '结果文件如下。';
|
||||
},
|
||||
},
|
||||
state: artifactState('session-whatsapp-cancel'),
|
||||
logger: { warn() {}, error() {} },
|
||||
createSession: async (options) => {
|
||||
callbacks = options;
|
||||
return {
|
||||
socket,
|
||||
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
|
||||
close: async () => {},
|
||||
logout: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
await runtime.start();
|
||||
const processing = callbacks.onMessage({
|
||||
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'cancel-upload-1', fromMe: false },
|
||||
message: { conversation: '生成两个文件' },
|
||||
});
|
||||
await uploadStarted.promise;
|
||||
|
||||
await within(runtime.stop(), 500, 'WhatsApp runtime did not stop a pending upload promptly');
|
||||
await within(processing, 500, 'WhatsApp message processing remained blocked after stop');
|
||||
uploadResponse.resolve({ key: { id: 'late-provider-response' } });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(
|
||||
sent.filter((content) => content.document).map((content) => content.fileName),
|
||||
['first.txt'],
|
||||
);
|
||||
assert.equal(sent.some((content) => content.text?.includes('暂时未能发送')), false);
|
||||
assert.equal(sent.some((content) => content.text === '消息处理失败,请稍后重试。'), false);
|
||||
});
|
||||
|
||||
test('WhatsApp runtime answers self-chat without processing its own reply echo', async () => {
|
||||
let callbacks;
|
||||
let askCount = 0;
|
||||
|
|
|
|||
103
test/delivery-receipt.test.mjs
Normal file
103
test/delivery-receipt.test.mjs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
artifactOutcomeForError,
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
providerMessageIdsFor,
|
||||
} from '../src/channels/shared/semantic/delivery.mjs';
|
||||
|
||||
test('DeliveryReceipt validates and freezes the shared versioned contract', () => {
|
||||
const receipt = createDeliveryReceipt({
|
||||
deliveryId: 'delivery-1',
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: ['om-file', 'om-file'],
|
||||
artifacts: [{ artifactId: 'artifact-1', outcome: 'sent' }],
|
||||
});
|
||||
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: 'delivery-1',
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: ['om-file'],
|
||||
artifacts: [{ artifactId: 'artifact-1', outcome: 'sent' }],
|
||||
});
|
||||
assert.equal(Object.isFrozen(receipt), true);
|
||||
assert.equal(Object.isFrozen(receipt.providerMessageIds), true);
|
||||
assert.equal(Object.isFrozen(receipt.artifacts[0]), true);
|
||||
assert.throws(
|
||||
() => createDeliveryReceipt({
|
||||
deliveryId: 'delivery-invalid',
|
||||
presentation: 'feishu-file',
|
||||
artifacts: [{ artifactId: 'artifact-1', outcome: 'maybe' }],
|
||||
}),
|
||||
/artifact outcome/,
|
||||
);
|
||||
});
|
||||
|
||||
test('artifact failures distinguish policy rejection, retryable failure, and uncertain delivery', () => {
|
||||
assert.equal(artifactOutcomeForError({ code: 'artifact-permission-required' }), 'rejected');
|
||||
assert.equal(artifactOutcomeForError({ code: 'artifact-too-large' }), 'rejected');
|
||||
assert.equal(artifactOutcomeForError({ code: 'artifact-unavailable' }), 'rejected');
|
||||
assert.equal(artifactOutcomeForError({ code: 'artifact-rate-limited' }), 'failed');
|
||||
assert.equal(artifactOutcomeForError(new Error('network unavailable')), 'failed');
|
||||
assert.equal(artifactOutcomeForError({ code: 'artifact-delivery-uncertain' }), 'unknown');
|
||||
|
||||
assert.deepEqual(createArtifactFailureReceipt({
|
||||
artifactId: 'artifact-uncertain',
|
||||
deliveryId: 'delivery-uncertain',
|
||||
error: { code: 'artifact-delivery-uncertain' },
|
||||
}).artifacts, [{
|
||||
artifactId: 'artifact-uncertain',
|
||||
outcome: 'unknown',
|
||||
reason: 'artifact-delivery-uncertain',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('provider message ids are collected only from explicit message-id fields', () => {
|
||||
assert.deepEqual(providerMessageIdsFor({ message_id: 42 }), ['42']);
|
||||
assert.deepEqual(providerMessageIdsFor({ key: { id: 'wamid-one' } }), ['wamid-one']);
|
||||
assert.deepEqual(providerMessageIdsFor({ ts: '123.456' }), ['123.456']);
|
||||
assert.deepEqual(providerMessageIdsFor({
|
||||
providerMessageIds: ['first', 'second', 'first', '', null],
|
||||
}), ['first', 'second']);
|
||||
assert.deepEqual(providerMessageIdsFor({ processQueryKey: 'not-a-message-id' }), []);
|
||||
assert.deepEqual(providerMessageIdsFor({ files: [{ id: 'not-a-message-id' }] }), []);
|
||||
});
|
||||
|
||||
test('text and multiple artifact attempts merge into one authoritative receipt', () => {
|
||||
const text = createDeliveryReceipt({
|
||||
deliveryId: 'turn-message',
|
||||
presentation: 'feishu-cardkit',
|
||||
providerMessageIds: ['om-card'],
|
||||
});
|
||||
const sent = createDeliveryReceipt({
|
||||
deliveryId: 'file-1',
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: ['om-file'],
|
||||
artifacts: [{ artifactId: 'artifact-1', outcome: 'sent' }],
|
||||
});
|
||||
const failed = createArtifactFailureReceipt({
|
||||
artifactId: 'artifact-2',
|
||||
deliveryId: 'file-2',
|
||||
error: { code: 'artifact-rate-limited' },
|
||||
providerMessageIds: ['om-notice'],
|
||||
});
|
||||
|
||||
assert.deepEqual(mergeDeliveryReceipts({
|
||||
deliveryId: 'turn-message',
|
||||
presentation: 'feishu-text-and-files',
|
||||
receipts: [text, sent, failed],
|
||||
}), {
|
||||
schemaVersion: 1,
|
||||
deliveryId: 'turn-message',
|
||||
presentation: 'feishu-text-and-files',
|
||||
providerMessageIds: ['om-card', 'om-file', 'om-notice'],
|
||||
artifacts: [
|
||||
{ artifactId: 'artifact-1', outcome: 'sent' },
|
||||
{ artifactId: 'artifact-2', outcome: 'failed', reason: 'artifact-rate-limited' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
|
@ -35,7 +35,12 @@ test('Host composes nine IM channels and the AI Office connector inside one plug
|
|||
await plugin.apply(ctx, config);
|
||||
|
||||
assert.equal(name, 'dsh-im-host');
|
||||
assert.deepEqual(inject, ['connection', 'credentials', 'webServer', 'typertGateway']);
|
||||
assert.deepEqual(inject, [
|
||||
'connection',
|
||||
'credentials',
|
||||
'webServer',
|
||||
'typertGateway',
|
||||
]);
|
||||
assert.deepEqual(calls, [
|
||||
['feishu', ctx, { ...config.feishu, rpcAuthority: 'trusted-host' }],
|
||||
['weixin', ctx, { ...config.weixin, rpcAuthority: 'trusted-host' }],
|
||||
|
|
|
|||
391
test/outbound-artifact.test.mjs
Normal file
391
test/outbound-artifact.test.mjs
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
installOutboundArtifactTool,
|
||||
materializeOutboundArtifact,
|
||||
readExactArtifactFile,
|
||||
releaseOutboundArtifact,
|
||||
} from '../src/channels/shared/semantic/artifact.mjs';
|
||||
|
||||
async function fixture(t) {
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-artifact-workspace-'));
|
||||
const outside = await mkdtemp(join(tmpdir(), 'dsh-im-artifact-outside-'));
|
||||
t.after(async () => {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(outside, { recursive: true, force: true });
|
||||
});
|
||||
let nextId = 0;
|
||||
const registry = new OutboundArtifactRegistry({
|
||||
uuid: () => `artifact-id-${++nextId}`,
|
||||
});
|
||||
t.after(() => registry.clear());
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: 'session-artifact', cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 7 } },
|
||||
{ type: 'user/message', data: { turn: 7, source: { rpcId: 'rpc-artifact' } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
return { workspace, outside, registry, agent };
|
||||
}
|
||||
|
||||
function execution(agent, callId, overrides = {}) {
|
||||
return {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
agent,
|
||||
callId,
|
||||
rootCallId: callId,
|
||||
token: Symbol(callId),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function execute(tool, args, exec, result = { isError: false }) {
|
||||
const value = await tool.definition.execute(args, exec);
|
||||
tool.onResult(exec, result);
|
||||
return value;
|
||||
}
|
||||
|
||||
async function takeFile(registry, sessionId = 'session-artifact', turn = 7) {
|
||||
const [artifact] = registry.take(sessionId, turn);
|
||||
assert.ok(artifact);
|
||||
const file = await materializeOutboundArtifact(artifact);
|
||||
return { artifact, file };
|
||||
}
|
||||
|
||||
test('an existing file can be sent directly without recreation', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'existing.txt'), 'already here');
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
|
||||
const result = await execute(tool, { path: 'existing.txt' }, execution(fx.agent, 'existing'));
|
||||
|
||||
assert.deepEqual(result, {
|
||||
artifactId: 'artifact-id-1',
|
||||
fileName: 'existing.txt',
|
||||
size: 12,
|
||||
});
|
||||
const { artifact, file } = await takeFile(fx.registry);
|
||||
assert.equal(file.bytes.toString(), 'already here');
|
||||
releaseOutboundArtifact(artifact);
|
||||
});
|
||||
|
||||
test('absolute outside-workspace paths and symbolic links are delivered normally', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
const outsidePath = join(fx.outside, 'outside.txt');
|
||||
await writeFile(outsidePath, 'outside content');
|
||||
await symlink(outsidePath, join(fx.workspace, 'linked.txt'));
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
|
||||
await execute(tool, { path: outsidePath }, execution(fx.agent, 'outside'));
|
||||
await execute(tool, { path: 'linked.txt' }, execution(fx.agent, 'linked'));
|
||||
|
||||
const artifacts = fx.registry.take('session-artifact', 7);
|
||||
assert.equal(artifacts.length, 2);
|
||||
const files = await Promise.all(artifacts.map((artifact) => materializeOutboundArtifact(artifact)));
|
||||
assert.deepEqual(files.map((file) => file.fileName), ['outside.txt', 'linked.txt']);
|
||||
assert.deepEqual(files.map((file) => file.bytes.toString()), ['outside content', 'outside content']);
|
||||
for (const artifact of artifacts) releaseOutboundArtifact(artifact);
|
||||
});
|
||||
|
||||
test('empty, sensitive-looking, and extension-mismatched files are not filtered', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'empty.txt'), '');
|
||||
await writeFile(
|
||||
join(fx.workspace, '.env'),
|
||||
'PASSWORD=example\n-----BEGIN PRIVATE KEY-----\nexample\n',
|
||||
);
|
||||
await writeFile(join(fx.workspace, 'plain.png'), 'not a PNG signature');
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
|
||||
await execute(tool, { path: 'empty.txt' }, execution(fx.agent, 'empty'));
|
||||
await execute(tool, { path: '.env' }, execution(fx.agent, 'sensitive-looking'));
|
||||
await execute(tool, { path: 'plain.png' }, execution(fx.agent, 'extension-mismatch'));
|
||||
|
||||
const artifacts = fx.registry.take('session-artifact', 7);
|
||||
assert.equal(artifacts.length, 3);
|
||||
const files = await Promise.all(artifacts.map((artifact) => materializeOutboundArtifact(artifact)));
|
||||
assert.deepEqual(files.map((file) => file.size), [0, 53, 19]);
|
||||
assert.equal(files[1].bytes.toString().startsWith('PASSWORD='), true);
|
||||
assert.equal(files[2].bytes.toString(), 'not a PNG signature');
|
||||
for (const artifact of artifacts) releaseOutboundArtifact(artifact);
|
||||
});
|
||||
|
||||
test('the registry does not deduplicate or impose project-level file-count quotas', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'same.txt'), 'same');
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
await execute(tool, { path: 'same.txt' }, execution(fx.agent, `same-${index}`));
|
||||
}
|
||||
|
||||
const artifacts = fx.registry.take('session-artifact', 7);
|
||||
assert.equal(artifacts.length, 12);
|
||||
assert.equal(new Set(artifacts.map((artifact) => artifact.artifactId)).size, 12);
|
||||
for (const artifact of artifacts) releaseOutboundArtifact(artifact);
|
||||
});
|
||||
|
||||
test('registration keeps a private snapshot without changing or deleting the source file', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
const path = join(fx.workspace, 'mutable.txt');
|
||||
await writeFile(path, 'first value');
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
await execute(tool, { path }, execution(fx.agent, 'snapshot'));
|
||||
await writeFile(path, 'second value');
|
||||
|
||||
const { artifact, file } = await takeFile(fx.registry);
|
||||
assert.equal(file.bytes.toString(), 'first value');
|
||||
releaseOutboundArtifact(artifact);
|
||||
assert.equal(await readFile(path, 'utf8'), 'second value');
|
||||
});
|
||||
|
||||
test('missing paths and directories fail because they cannot be uploaded as files', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
|
||||
await assert.rejects(
|
||||
tool.definition.execute({ path: 'missing.txt' }, execution(fx.agent, 'missing')),
|
||||
(error) => error.code === 'artifact-unavailable',
|
||||
);
|
||||
await assert.rejects(
|
||||
tool.definition.execute({ path: '.' }, execution(fx.agent, 'directory')),
|
||||
(error) => error.code === 'artifact-not-file',
|
||||
);
|
||||
});
|
||||
|
||||
test('a failed authoritative tool result is not delivered', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'failed.txt'), 'failed');
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
|
||||
await execute(
|
||||
tool,
|
||||
{ path: 'failed.txt' },
|
||||
execution(fx.agent, 'failed'),
|
||||
{ isError: true },
|
||||
);
|
||||
|
||||
assert.deepEqual(fx.registry.take('session-artifact', 7), []);
|
||||
});
|
||||
|
||||
test('Code Mode waits for the outer execution result before delivery', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'nested.txt'), 'nested');
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
const rootToken = Symbol('root');
|
||||
const nested = execution(fx.agent, 'nested', {
|
||||
parent: rootToken,
|
||||
rootCallId: 'root-call',
|
||||
});
|
||||
|
||||
await execute(tool, { path: 'nested.txt' }, nested);
|
||||
assert.deepEqual(fx.registry.take('session-artifact', 7), []);
|
||||
tool.onResult({
|
||||
name: 'run_code',
|
||||
callId: 'root-call',
|
||||
rootCallId: 'root-call',
|
||||
token: rootToken,
|
||||
agent: fx.agent,
|
||||
}, { isError: false });
|
||||
|
||||
const { artifact, file } = await takeFile(fx.registry);
|
||||
assert.equal(file.bytes.toString(), 'nested');
|
||||
releaseOutboundArtifact(artifact);
|
||||
});
|
||||
|
||||
test('Session and Turn ownership routes files only to the originating conversation', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'owned.txt'), 'owned');
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
await execute(tool, { path: 'owned.txt' }, execution(fx.agent, 'owned'));
|
||||
|
||||
assert.deepEqual(fx.registry.take('other-session', 7), []);
|
||||
assert.deepEqual(fx.registry.take('session-artifact', 8), []);
|
||||
const [artifact] = fx.registry.take('session-artifact', 7);
|
||||
assert.ok(artifact);
|
||||
releaseOutboundArtifact(artifact);
|
||||
});
|
||||
|
||||
test('a completed Turn without a channel consumer releases its unclaimed snapshot', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'unclaimed.txt'), 'unclaimed');
|
||||
const artifact = await fx.registry.stage(
|
||||
{ path: 'unclaimed.txt' },
|
||||
execution(fx.agent, 'unclaimed'),
|
||||
);
|
||||
fx.registry.commit(artifact);
|
||||
|
||||
fx.registry.observeSessionEvent(
|
||||
{ id: 'session-artifact' },
|
||||
{ type: 'turn/end', data: { turn: 7 } },
|
||||
);
|
||||
|
||||
assert.deepEqual(fx.registry.take('session-artifact', 7), []);
|
||||
await assert.rejects(
|
||||
materializeOutboundArtifact(artifact),
|
||||
(error) => error.code === 'artifact-invalid',
|
||||
);
|
||||
});
|
||||
|
||||
test('a channel consumer keeps its completed Turn available until polling claims it', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'consumer.txt'), 'consumer');
|
||||
const closeConsumer = fx.registry.openConsumer('session-artifact', 'rpc-artifact');
|
||||
fx.registry.observeSessionEvent(
|
||||
{ id: 'session-artifact' },
|
||||
{ type: 'turn/start', data: { turn: 7 } },
|
||||
);
|
||||
fx.registry.observeSessionEvent(
|
||||
{ id: 'session-artifact' },
|
||||
{ type: 'user/message', data: { source: { rpcId: 'rpc-artifact' } } },
|
||||
);
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
await execute(tool, { path: 'consumer.txt' }, execution(fx.agent, 'consumer'));
|
||||
|
||||
fx.registry.observeSessionEvent(
|
||||
{ id: 'session-artifact' },
|
||||
{ type: 'turn/end', data: { turn: 7 } },
|
||||
);
|
||||
const [artifact] = fx.registry.take('session-artifact', 7);
|
||||
assert.ok(artifact);
|
||||
closeConsumer();
|
||||
|
||||
const file = await materializeOutboundArtifact(artifact);
|
||||
assert.equal(file.bytes.toString(), 'consumer');
|
||||
releaseOutboundArtifact(artifact);
|
||||
});
|
||||
|
||||
test('closing an unclaimed channel consumer releases its completed Turn', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'consumer-closed.txt'), 'consumer closed');
|
||||
const closeConsumer = fx.registry.openConsumer('session-artifact', 'rpc-artifact');
|
||||
fx.registry.observeSessionEvent(
|
||||
{ id: 'session-artifact' },
|
||||
{ type: 'turn/start', data: { turn: 7 } },
|
||||
);
|
||||
fx.registry.observeSessionEvent(
|
||||
{ id: 'session-artifact' },
|
||||
{ type: 'user/message', data: { source: { rpcId: 'rpc-artifact' } } },
|
||||
);
|
||||
const artifact = await fx.registry.stage(
|
||||
{ path: 'consumer-closed.txt' },
|
||||
execution(fx.agent, 'consumer-closed'),
|
||||
);
|
||||
fx.registry.commit(artifact);
|
||||
fx.registry.observeSessionEvent(
|
||||
{ id: 'session-artifact' },
|
||||
{ type: 'turn/end', data: { turn: 7 } },
|
||||
);
|
||||
|
||||
closeConsumer();
|
||||
|
||||
assert.deepEqual(fx.registry.take('session-artifact', 7), []);
|
||||
await assert.rejects(
|
||||
materializeOutboundArtifact(artifact),
|
||||
(error) => error.code === 'artifact-invalid',
|
||||
);
|
||||
});
|
||||
|
||||
test('aborting the owning delivery releases its unhanded snapshot', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'abort.txt'), 'abort');
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
await execute(tool, { path: 'abort.txt' }, execution(fx.agent, 'abort'));
|
||||
const controller = new AbortController();
|
||||
const [artifact] = fx.registry.take('session-artifact', 7, { signal: controller.signal });
|
||||
controller.abort();
|
||||
|
||||
await assert.rejects(
|
||||
materializeOutboundArtifact(artifact),
|
||||
(error) => error.code === 'artifact-invalid',
|
||||
);
|
||||
});
|
||||
|
||||
test('an explicit release also cleans a snapshot that was already claimed', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'handoff-failed.txt'), 'handoff failed');
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
await execute(tool, { path: 'handoff-failed.txt' }, execution(fx.agent, 'handoff-failed'));
|
||||
const [artifact] = fx.registry.take('session-artifact', 7);
|
||||
|
||||
fx.registry.release(artifact);
|
||||
|
||||
await assert.rejects(
|
||||
materializeOutboundArtifact(artifact),
|
||||
(error) => error.code === 'artifact-invalid',
|
||||
);
|
||||
});
|
||||
|
||||
test('cleanup requested during materialization runs as soon as the read completes', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'concurrent-cleanup.txt'), Buffer.alloc(1024 * 1024, 7));
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
await execute(
|
||||
tool,
|
||||
{ path: 'concurrent-cleanup.txt' },
|
||||
execution(fx.agent, 'concurrent-cleanup'),
|
||||
);
|
||||
const [artifact] = fx.registry.take('session-artifact', 7);
|
||||
|
||||
const materializing = materializeOutboundArtifact(artifact);
|
||||
fx.registry.clear();
|
||||
const file = await materializing;
|
||||
|
||||
assert.equal(file.size, 1024 * 1024);
|
||||
await assert.rejects(
|
||||
materializeOutboundArtifact(artifact),
|
||||
(error) => error.code === 'artifact-invalid',
|
||||
);
|
||||
});
|
||||
|
||||
test('exact reads have no independent timeout and preserve caller cancellation', async () => {
|
||||
let read = 0;
|
||||
const handle = {
|
||||
async read(_buffer, _offset, _length, position) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
if (position === 0 && read++ === 0) return { bytesRead: 1 };
|
||||
return { bytesRead: 0 };
|
||||
},
|
||||
};
|
||||
const bytes = await readExactArtifactFile(handle, 1);
|
||||
assert.equal(bytes.byteLength, 1);
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('cancelled'));
|
||||
await assert.rejects(
|
||||
readExactArtifactFile(handle, 1, { signal: controller.signal }),
|
||||
/cancelled/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Host installer always exposes the tool and explicitly permits existing files', () => {
|
||||
let definition;
|
||||
const listeners = new Map();
|
||||
let section;
|
||||
const installed = installOutboundArtifactTool({
|
||||
tools: { register(value) { definition = value; } },
|
||||
on(name, value) { listeners.set(name, value); },
|
||||
systemPrompt: { section(value) { section = value; } },
|
||||
}, { registry: new OutboundArtifactRegistry() });
|
||||
|
||||
assert.equal(installed, true);
|
||||
assert.equal(definition.name, OUTBOUND_ARTIFACT_TOOL);
|
||||
assert.match(definition.description, /Existing and newly created files are both valid/);
|
||||
assert.match(section.text, /Existing files can be sent directly/);
|
||||
assert.equal(typeof listeners.get('tools/result'), 'function');
|
||||
assert.equal(typeof listeners.get('session/event'), 'function');
|
||||
assert.equal(typeof listeners.get('session/disposed'), 'function');
|
||||
assert.equal(listeners.has('agent/inbox/claimed'), false);
|
||||
assert.equal(listeners.has('system-prompt/assemble'), false);
|
||||
assert.equal(installOutboundArtifactTool({}), false);
|
||||
});
|
||||
|
|
@ -52,6 +52,34 @@ async function fixture(t) {
|
|||
return { root, defaultWorkspace, alternateWorkspace, path: join(root, 'workspaces.json') };
|
||||
}
|
||||
|
||||
test('workspace asks collect result files without an explicit Gate', async () => {
|
||||
const observed = [];
|
||||
const harness = {
|
||||
async sessionExists() { return true; },
|
||||
async ask(_sessionId, _text, options) {
|
||||
observed.push(options);
|
||||
await options?.onArtifact?.({ artifactId: 'artifact-one' });
|
||||
return 'answer';
|
||||
},
|
||||
};
|
||||
const state = { sessionFor: () => 'session-existing' };
|
||||
const askOptions = { timeoutMs: 1234 };
|
||||
|
||||
assert.deepEqual(await askInWorkspaceSession({
|
||||
harness,
|
||||
state,
|
||||
key: 'default',
|
||||
text: 'file request',
|
||||
askOptions,
|
||||
}), {
|
||||
sessionId: 'session-existing',
|
||||
answer: 'answer',
|
||||
artifacts: [{ artifactId: 'artifact-one' }],
|
||||
});
|
||||
assert.notEqual(observed[0], askOptions);
|
||||
assert.equal(typeof observed[0].onArtifact, 'function');
|
||||
});
|
||||
|
||||
test('BotWorkspaceStore persists the creation default and keeps bots isolated', async (t) => {
|
||||
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue