dsh-plugin-weapp-gateway 0.8.12 → 0.8.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROTOCOL.md +6 -0
- package/README.md +9 -2
- package/THIRD-PARTY-NOTICES/clawbot.txt +31 -0
- package/bin/setup-ip.mjs +0 -0
- package/lib/clawbot/contracts.mjs +8 -0
- package/lib/clawbot/dsh-host.mjs +560 -0
- package/lib/clawbot/host-admission.mjs +31 -0
- package/lib/clawbot/ilink-media.mjs +321 -0
- package/lib/clawbot/ilink-types.mjs +13 -0
- package/lib/clawbot/ilink.mjs +489 -0
- package/lib/clawbot/native-peer.mjs +29 -0
- package/lib/clawbot/reply-text.mjs +29 -0
- package/lib/clawbot/runtime.mjs +974 -0
- package/lib/clawbot/secure-file.mjs +86 -0
- package/lib/clawbot/service.mjs +81 -0
- package/lib/clawbot/storage.mjs +108 -0
- package/lib/completion-notifications.mjs +86 -7
- package/lib/dsh-host-adapter.mjs +0 -6
- package/lib/index.mjs +12 -7
- package/lib/plugin-updates.mjs +103 -10
- package/package.json +54 -51
package/PROTOCOL.md
CHANGED
|
@@ -1213,6 +1213,12 @@ Host 的 `plugin-manager/changed` 转为 `plugins-changed`(含 `reason`),`
|
|
|
1213
1213
|
失败返回 `kind:error`、`requestType:session-create` 和原始 `requestId`。
|
|
1214
1214
|
客户端不应自动重试超时的创建请求,以免重复创建会话。
|
|
1215
1215
|
|
|
1216
|
+
### 实验性微信 ClawBot(`wechat-clawbot-control-v2`)
|
|
1217
|
+
|
|
1218
|
+
此能力由网关在 DSH 进程内直接运行,不再转发到其他插件服务。小程序需使用已配对的 control 连接;所有操作响应都回显 `requestId`。微信凭据与收件状态保存在当前 DSH profile 的本地状态目录中,不经过小程序。
|
|
1219
|
+
|
|
1220
|
+
请求类型为 `clawbot-control`,`action` 仅接受 `status`、`sessions`、`route`、`enabled`、`login.start`、`login.poll`、`login.cancel`、`disconnect`。有状态变更的路由、启停与解绑操作用 `expectedRevision` 做冲突检查;会话列表只暴露可选的普通 DSH 会话。状态目录有单实例锁;升级切换时如果仍被旧服务占用,网关会拒绝启动 ClawBot,避免同一微信账号被两个轮询器同时消费。
|
|
1221
|
+
|
|
1216
1222
|
|
|
1217
1223
|
### Independent conversation and control connections
|
|
1218
1224
|
|
package/README.md
CHANGED
|
@@ -4,11 +4,18 @@
|
|
|
4
4
|
|
|
5
5
|
# dsh-plugin-weapp-gateway
|
|
6
6
|
|
|
7
|
+
### v0.8.13 · 网关内置微信 ClawBot
|
|
8
|
+
|
|
9
|
+
- 将 ClawBot 控制、iLink 收发和目标会话接入直接放进网关;不再调用 `dsh-wechat-remote` 的控制 RPC,也不要求安装该插件。
|
|
10
|
+
- 小程序「设置 → 实验性功能」管理微信绑定、接入会话和启停。微信凭据及消息收件状态留在电脑本地,移动端只通过配对网关发出控制请求。
|
|
11
|
+
- 复用本机已有 ClawBot 状态目录并加单实例锁;若旧服务仍占用状态,先停止旧的私聊接线服务并重启 DSH,再由网关接管,避免同一账号被两个轮询器同时处理。
|
|
12
|
+
- 新增 `wechat-clawbot-control-v2` 能力标记;升级网关后重新连接小程序。
|
|
13
|
+
|
|
7
14
|
### v0.8.12 · 显示插件升级失败详情
|
|
8
15
|
|
|
9
16
|
- 安装器返回结构化错误时提取可读信息,不再显示 `[object Object]`;错误序列化会省略凭据、令牌和堆栈字段。
|
|
10
17
|
|
|
11
|
-
### v0.8.11 · 小程序 ClawBot
|
|
18
|
+
### v0.8.11 · 小程序 ClawBot 私聊接线控制(旧版代理,已由 v0.8.13 取代)
|
|
12
19
|
|
|
13
20
|
- 在已配对的小程序控制通道中增加受限的 ClawBot 控制 RPC,复用 `dsh-wechat-remote` 自身的访问授权、状态冲突检查和本地凭据存储。
|
|
14
21
|
- 支持读取状态、绑定流程、选择普通 DSH 会话、启停与移除本地绑定;微信凭据不传回小程序。
|
|
@@ -114,7 +121,7 @@ endpoints:
|
|
|
114
121
|
notificationServiceUrl: https://dsh.ahwe.top
|
|
115
122
|
```
|
|
116
123
|
|
|
117
|
-
小程序的「订阅通知」偏好开启后,网关会在对应的 `turn/end`
|
|
124
|
+
小程序的「订阅通知」偏好开启后,网关会在对应的 `turn/end` 回调已授权的一次性通知票据,并附上可用的会话标题、回合耗时、Token 用量和缓存命中率。Token 用量来自本回合 Assistant usage 的输入与输出总量;缓存命中率使用 `cacheReadTokens / (uncachedInputTokens + cacheReadTokens)`,缺少未缓存输入时回退到 `inputTokens`。通知不包含任务正文或模型回复。若希望 Token 显示为 `12.3K`,微信模板对应字段须为 `character_string`;`number` 类型只能显示纯数字。自托管公网服务时,将 `notificationServiceUrl` 改为小程序 `access-config.js` 中相同的 HTTPS 服务根地址。
|
|
118
125
|
|
|
119
126
|
运行模式的优先级:**已保存的界面选择 > `gatewayMode` > 旧 `gatewayEnabled`**。没有保存选择时,旧配置 `gatewayEnabled: true` 对应常驻,false 对应关闭。
|
|
120
127
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
This notice applies to the gateway-owned ClawBot integration under lib/clawbot/.
|
|
2
|
+
|
|
3
|
+
1. ClawBot runtime, DSH host adapter, contracts, storage, reply handling and
|
|
4
|
+
service structure are adapted from dsh-wechat-remote 1.7.14.
|
|
5
|
+
Copyright (c) 2026 martinbear1. Licensed under the MIT License.
|
|
6
|
+
Source project: https://github.com/martinbear1/dsh-wechat-remote
|
|
7
|
+
|
|
8
|
+
2. iLink request and media protocol portions are adapted from Tencent's
|
|
9
|
+
openclaw-weixin at commit 24de5c9.
|
|
10
|
+
Copyright (C) 2026 Tencent. Licensed under the MIT License.
|
|
11
|
+
Source project: https://github.com/Tencent/openclaw-weixin
|
|
12
|
+
|
|
13
|
+
MIT License
|
|
14
|
+
|
|
15
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
16
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
17
|
+
in the Software without restriction, including without limitation the rights
|
|
18
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
19
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
20
|
+
furnished to do so, subject to the following conditions:
|
|
21
|
+
|
|
22
|
+
The above copyright notice and this permission notice shall be included in all
|
|
23
|
+
copies or substantial portions of the Software.
|
|
24
|
+
|
|
25
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
26
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
27
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
28
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
29
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
30
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
31
|
+
SOFTWARE.
|
package/bin/setup-ip.mjs
CHANGED
|
File without changes
|
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { ClawbotError } from './contracts.mjs';
|
|
3
|
+
import { assertColdSessionPresetAvailable } from './host-admission.mjs';
|
|
4
|
+
import { withNativePeer } from './native-peer.mjs';
|
|
5
|
+
import { REPLY_TEXT_MAX_BYTES } from './reply-text.mjs';
|
|
6
|
+
/** A receipt for observing a run, never authority to replay its prompt. */
|
|
7
|
+
export class DshClawbotHostError extends ClawbotError {
|
|
8
|
+
submission;
|
|
9
|
+
constructor(code, message, submission) {
|
|
10
|
+
super('clawbot-host/' + code, message);
|
|
11
|
+
this.submission = submission;
|
|
12
|
+
this.name = 'DshClawbotHostError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const row = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
16
|
+
const id = (value) => typeof value === 'string' && value.length > 0
|
|
17
|
+
&& value.length <= 256 && !/[\u0000-\u001f]/.test(value);
|
|
18
|
+
const fail = (code, message, submission = 'not-submitted') => new DshClawbotHostError(code, message, submission);
|
|
19
|
+
const MAX_MEDIA_BYTES = 10 * 1024 * 1024;
|
|
20
|
+
const MAX_INPUT_BYTES = 30 * 1024 * 1024;
|
|
21
|
+
const MAX_OBSERVATION_MS = 15 * 60 * 1000;
|
|
22
|
+
const imageType = (data) => {
|
|
23
|
+
const bytes = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
|
24
|
+
if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
|
|
25
|
+
return 'image/png';
|
|
26
|
+
if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255)
|
|
27
|
+
return 'image/jpeg';
|
|
28
|
+
if (/^GIF8[79]a$/.test(bytes.subarray(0, 6).toString('ascii')))
|
|
29
|
+
return 'image/gif';
|
|
30
|
+
if (bytes.length >= 12 && bytes.subarray(0, 4).toString('ascii') === 'RIFF'
|
|
31
|
+
&& bytes.subarray(8, 12).toString('ascii') === 'WEBP')
|
|
32
|
+
return 'image/webp';
|
|
33
|
+
};
|
|
34
|
+
const nativeImage = (value, name) => row(value) && value.name === name
|
|
35
|
+
&& /^sha256:[a-f0-9]{64}$/.test(value.attachmentId) && ['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(value.mediaType)
|
|
36
|
+
&& [value.bytes, value.width, value.height].every(number => Number.isSafeInteger(number) && number > 0);
|
|
37
|
+
const nativeFile = (value) => row(value) && /^sha256:[a-f0-9]{64}$/.test(value.attachmentId)
|
|
38
|
+
&& typeof value.name === 'string' && !!value.name && !/[\\/\u0000-\u001f]/.test(value.name)
|
|
39
|
+
&& Number.isSafeInteger(value.bytes) && value.bytes > 0 && value.bytes <= MAX_MEDIA_BYTES;
|
|
40
|
+
/**
|
|
41
|
+
* Native contracts inspected in installed DSH 0.2.0-rc.1:
|
|
42
|
+
* dsh-api-session-controller/lib/types/commands.js:290-351,547-560:
|
|
43
|
+
* prompt resumes the ordinary Agent, stores requestId as user source.rpcId,
|
|
44
|
+
* and acknowledges admission (not completion). No source credential is added.
|
|
45
|
+
* dsh-agent-loop/lib/index.js:943,1061,1144,1027: committed turn/start,
|
|
46
|
+
* user/message, assistant/message, turn/end are the correlation boundaries.
|
|
47
|
+
* dsh-session/lib/index.js:1400-1473: seq is the next append offset and
|
|
48
|
+
* session/event observers run synchronously after the event enters the log.
|
|
49
|
+
* This adapter owns no Agent/preset/tool; it never opens the phone mux/$events
|
|
50
|
+
* or registers a user-questions/approval answerer. Native cards keep ownership.
|
|
51
|
+
*/
|
|
52
|
+
export class DshClawbotHost {
|
|
53
|
+
ctx;
|
|
54
|
+
assertAccess;
|
|
55
|
+
running = new Set();
|
|
56
|
+
constructor(ctx, assertAccess) {
|
|
57
|
+
this.ctx = ctx;
|
|
58
|
+
this.assertAccess = assertAccess;
|
|
59
|
+
}
|
|
60
|
+
gateway() {
|
|
61
|
+
const gateway = this.ctx.get('typertGateway');
|
|
62
|
+
if (typeof gateway?.invoke !== 'function')
|
|
63
|
+
throw fail('unavailable', '当前节点的原生会话接口尚未就绪。');
|
|
64
|
+
return { invoke: (request) => withNativePeer(this.ctx, request.signal, lease => gateway.invoke(lease.peer ? { ...request, peer: lease.peer, signal: lease.signal } : request)) };
|
|
65
|
+
}
|
|
66
|
+
archived() {
|
|
67
|
+
const values = this.ctx.get('workspaceRegistry')?.archivedSessionIds;
|
|
68
|
+
if (!Array.isArray(values) || !values.every(id))
|
|
69
|
+
throw fail('unavailable', '无法核对原生会话归档状态。');
|
|
70
|
+
return new Set(values);
|
|
71
|
+
}
|
|
72
|
+
/** Selectable ordinary sessions, including cold ones; admission decides writability. */
|
|
73
|
+
async listSessions(signal) {
|
|
74
|
+
signal.throwIfAborted();
|
|
75
|
+
const result = await this.gateway().invoke({ namespace: 'session', method: 'list', args: { _request: {} }, signal });
|
|
76
|
+
signal.throwIfAborted();
|
|
77
|
+
const archived = this.archived();
|
|
78
|
+
if (!Array.isArray(result?.items))
|
|
79
|
+
throw fail('unavailable', '无法读取原生会话列表。');
|
|
80
|
+
const seen = new Set(), items = [];
|
|
81
|
+
const registry = this.ctx.get('workspaceRegistry');
|
|
82
|
+
const workspaces = typeof registry?.list === 'function' ? registry.list() : [];
|
|
83
|
+
for (const item of result.items) {
|
|
84
|
+
if (!row(item) || !id(item.sessionId) || seen.has(item.sessionId))
|
|
85
|
+
throw fail('unavailable', '原生会话列表不完整,请刷新后重试。');
|
|
86
|
+
seen.add(item.sessionId);
|
|
87
|
+
if (item.origin === 'subagent' || archived.has(item.sessionId) || typeof item.cwd !== 'string' || !item.cwd)
|
|
88
|
+
continue;
|
|
89
|
+
const title = item.projections?.values?.title;
|
|
90
|
+
const matches = Array.isArray(workspaces) ? workspaces.filter(workspace => row(workspace) && id(workspace.id)
|
|
91
|
+
&& Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(item.sessionId)) : [];
|
|
92
|
+
const workspace = matches.length === 1 ? matches[0] : undefined;
|
|
93
|
+
const workspaceTitle = workspace && (typeof workspace.name === 'string' && workspace.name.trim() ? workspace.name
|
|
94
|
+
: typeof workspace.path === 'string' ? workspace.path.replace(/[\\/]+$/, '').split(/[\\/]/).at(-1) : undefined);
|
|
95
|
+
items.push({ id: item.sessionId, title: typeof title === 'string' && title.trim() ? title.slice(0, 200) : '未命名会话',
|
|
96
|
+
...(workspace && workspaceTitle ? { workspaceId: workspace.id, workspaceTitle: workspaceTitle.slice(0, 200) } : {}) });
|
|
97
|
+
}
|
|
98
|
+
return items;
|
|
99
|
+
}
|
|
100
|
+
async metadata(sessionId, signal) {
|
|
101
|
+
const query = this.ctx.get('sessionQuery');
|
|
102
|
+
if (typeof query?.observeSession !== 'function')
|
|
103
|
+
throw fail('unavailable', '当前宿主缺少可核对的原生会话观察接口。');
|
|
104
|
+
// Metadata only: selecting a cold Session must not read or expose its transcript.
|
|
105
|
+
const observation = await query.observeSession(sessionId, { signal, projectionMode: 'none' });
|
|
106
|
+
try {
|
|
107
|
+
const header = observation?.header;
|
|
108
|
+
if (!row(header) || header.id !== sessionId || header.origin === 'subagent'
|
|
109
|
+
|| !Number.isSafeInteger(header.createdAt) || typeof header.cwd !== 'string' || !header.cwd
|
|
110
|
+
|| !Number.isSafeInteger(observation.cursor) || observation.cursor < -1
|
|
111
|
+
|| typeof observation[Symbol.dispose] !== 'function')
|
|
112
|
+
throw fail('unavailable', '无法核对本次会话身份。');
|
|
113
|
+
return { generation: header.createdAt, cursor: observation.cursor };
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
observation?.[Symbol.dispose]?.();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async describeSession(sessionId, signal) {
|
|
120
|
+
if (!id(sessionId))
|
|
121
|
+
throw fail('invalid-input', '会话标识无效。');
|
|
122
|
+
const selected = (await this.listSessions(signal)).find(item => item.id === sessionId);
|
|
123
|
+
if (!selected)
|
|
124
|
+
throw fail('not-found', '此会话已不存在、已归档或属于子代理,请重新选择普通会话。');
|
|
125
|
+
await this.checkPreset(sessionId, signal);
|
|
126
|
+
const { generation } = await this.metadata(sessionId, signal);
|
|
127
|
+
signal.throwIfAborted();
|
|
128
|
+
if (this.archived().has(sessionId))
|
|
129
|
+
throw fail('not-found', '此会话已归档,请重新选择。');
|
|
130
|
+
return { ...selected, generation };
|
|
131
|
+
}
|
|
132
|
+
async checkPreset(sessionId, signal) {
|
|
133
|
+
try {
|
|
134
|
+
await assertColdSessionPresetAvailable(this.ctx, sessionId, signal);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
signal.throwIfAborted();
|
|
138
|
+
if (row(error) && error.code === 'adapter/session-preset-unavailable')
|
|
139
|
+
throw fail('preset-unavailable', '此会话的原模式已停用或不可用,本条消息未发送。请在电脑恢复原模式,或选择其他可用会话。');
|
|
140
|
+
throw fail('unavailable', '暂时无法核对会话模式,本条消息未发送,请在电脑或小程序核对。');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async run(input, signal, onAttention) {
|
|
144
|
+
if (!id(input.sessionId) || !id(input.requestId) || typeof input.text !== 'string'
|
|
145
|
+
|| !Number.isSafeInteger(input.expectedGeneration)
|
|
146
|
+
|| Buffer.byteLength(input.text) > REPLY_TEXT_MAX_BYTES || input.attachments !== undefined && !Array.isArray(input.attachments)
|
|
147
|
+
|| (input.attachments?.length ?? 0) > 6 || !input.text.trim() && !input.attachments?.length)
|
|
148
|
+
throw fail('invalid-input', '消息或会话标识无效。');
|
|
149
|
+
let total = Buffer.byteLength(input.text);
|
|
150
|
+
for (const part of input.attachments ?? []) {
|
|
151
|
+
if (!row(part) || !['image', 'file', 'voice', 'video'].includes(part.kind)
|
|
152
|
+
|| typeof part.name !== 'string' || !part.name.trim() || Buffer.byteLength(part.name) > 240 || /[\\/\u0000-\u001f]/.test(part.name)
|
|
153
|
+
|| !(part.data instanceof Uint8Array) || part.data.byteLength < 1 || part.data.byteLength > MAX_MEDIA_BYTES
|
|
154
|
+
|| (total += part.data.byteLength) > MAX_INPUT_BYTES || part.kind === 'image' && !imageType(part.data))
|
|
155
|
+
throw fail('invalid-input', '附件格式或大小不符合要求:最多 6 个,单个 10 MB、合计 30 MB。');
|
|
156
|
+
}
|
|
157
|
+
// Own one bounded immutable snapshot across asynchronous admission checks.
|
|
158
|
+
input = { ...input, attachments: input.attachments?.map(part => ({ ...part, data: Uint8Array.from(part.data) })) };
|
|
159
|
+
signal.throwIfAborted();
|
|
160
|
+
if (this.running.has(input.sessionId))
|
|
161
|
+
throw fail('busy', '此会话仍在处理上一条渠道消息,请先在电脑或小程序核对。');
|
|
162
|
+
this.running.add(input.sessionId);
|
|
163
|
+
try {
|
|
164
|
+
if (!(await this.listSessions(signal)).some(item => item.id === input.sessionId)) {
|
|
165
|
+
throw fail('not-found', '此会话已不存在、已归档或属于子代理,请重新选择普通会话。');
|
|
166
|
+
}
|
|
167
|
+
const agents = this.ctx.get('agents'), sessions = this.ctx.get('sessions');
|
|
168
|
+
if (typeof agents?.get !== 'function'
|
|
169
|
+
|| typeof sessions?.get !== 'function')
|
|
170
|
+
throw fail('unavailable', '当前宿主缺少可核对的原生会话观察接口。');
|
|
171
|
+
const { generation, cursor } = await this.metadata(input.sessionId, signal);
|
|
172
|
+
if (generation !== input.expectedGeneration)
|
|
173
|
+
throw fail('session-changed', '所选会话已重置,本条旧消息没有发送;请重新选择会话。');
|
|
174
|
+
signal.throwIfAborted();
|
|
175
|
+
if (this.archived().has(input.sessionId))
|
|
176
|
+
throw fail('not-found', '此会话已归档,请重新选择。');
|
|
177
|
+
await this.checkPreset(input.sessionId, signal);
|
|
178
|
+
signal.throwIfAborted();
|
|
179
|
+
const prepared = input.attachments?.length ? await this.prepareInput(input, generation, signal)
|
|
180
|
+
: { content: [{ type: 'text', text: input.text }] };
|
|
181
|
+
const result = await this.observeAndSubmit(input, generation, cursor, prepared, signal, onAttention);
|
|
182
|
+
return await this.readOutputs(input.sessionId, generation, result, signal, input.checkAdmission);
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
this.running.delete(input.sessionId);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Read only this completed turn's native presentation declarations. Paths
|
|
189
|
+
* never come from model prose or tool reads and never leave this adapter. */
|
|
190
|
+
async readOutputs(sessionId, generation, result, signal, checkAdmission) {
|
|
191
|
+
signal = AbortSignal.any([signal, AbortSignal.timeout(60_000)]);
|
|
192
|
+
const attachments = [];
|
|
193
|
+
let omitted = result.omitted, total = 0;
|
|
194
|
+
const check = () => {
|
|
195
|
+
signal.throwIfAborted();
|
|
196
|
+
checkAdmission?.();
|
|
197
|
+
this.assertAccess?.();
|
|
198
|
+
const session = this.ctx.get('sessions')?.get(sessionId);
|
|
199
|
+
if (session !== result.session || session.header?.createdAt !== generation || this.archived().has(sessionId))
|
|
200
|
+
throw fail('session-changed', '会话已变化,文件未回传;请在原会话查看。', 'accepted');
|
|
201
|
+
};
|
|
202
|
+
const append = (name, bytes) => {
|
|
203
|
+
if (typeof name !== 'string' || !name.trim() || /[\\/\u0000-\u001f]/.test(name)
|
|
204
|
+
|| !(bytes instanceof Uint8Array) || bytes.byteLength < 1 || bytes.byteLength > MAX_MEDIA_BYTES
|
|
205
|
+
|| attachments.length >= 6 || total + bytes.byteLength > MAX_INPUT_BYTES)
|
|
206
|
+
throw Error('output limit');
|
|
207
|
+
const data = Buffer.from(bytes), image = imageType(data);
|
|
208
|
+
const mp4 = /\.mp4$/i.test(name) && data.length >= 16 && data.readUInt32BE(0) >= 16
|
|
209
|
+
&& data.readUInt32BE(0) <= data.length && data.subarray(4, 8).toString('ascii') === 'ftyp'
|
|
210
|
+
&& /^(?:isom|iso[2-9]|mp4[12]|avc1|M4V )$/.test(data.subarray(8, 12).toString('ascii'));
|
|
211
|
+
total += data.length;
|
|
212
|
+
attachments.push({ kind: image ? 'image' : mp4 ? 'video' : 'file', name, data });
|
|
213
|
+
};
|
|
214
|
+
for (const reference of result.files) {
|
|
215
|
+
let transferId, resources;
|
|
216
|
+
try {
|
|
217
|
+
check();
|
|
218
|
+
resources = this.ctx.get('agentResources');
|
|
219
|
+
if (!['resolve', 'prepare', 'chunk', 'release'].every(key => typeof resources?.[key] === 'function'))
|
|
220
|
+
throw Error('no native resource reader');
|
|
221
|
+
const unpack = (value) => {
|
|
222
|
+
if (!value?.ok || typeof value.valueJson !== 'string')
|
|
223
|
+
throw Error('resource unavailable');
|
|
224
|
+
const parsed = JSON.parse(value.valueJson);
|
|
225
|
+
if (!row(parsed))
|
|
226
|
+
throw Error('resource unavailable');
|
|
227
|
+
return parsed;
|
|
228
|
+
};
|
|
229
|
+
const resolved = unpack(await resources.resolve({ scope: sessionId, reference }, signal));
|
|
230
|
+
check();
|
|
231
|
+
if (resolved.kind !== 'file' || typeof resolved.id !== 'string' || !resolved.id || resolved.id.length > 12000
|
|
232
|
+
|| !Number.isSafeInteger(resolved.bytes) || resolved.bytes < 1 || resolved.bytes > MAX_MEDIA_BYTES
|
|
233
|
+
|| total + resolved.bytes > MAX_INPUT_BYTES || attachments.length >= 6)
|
|
234
|
+
throw Error('output limit');
|
|
235
|
+
const prepared = unpack(await resources.prepare({ scope: sessionId, id: resolved.id, delivery: 'chunks' }, signal));
|
|
236
|
+
transferId = prepared.transferId;
|
|
237
|
+
check();
|
|
238
|
+
if (!id(transferId) || prepared.delivery !== 'chunks' || !Number.isSafeInteger(prepared.bytes)
|
|
239
|
+
|| prepared.bytes < 1 || prepared.bytes > MAX_MEDIA_BYTES || total + prepared.bytes > MAX_INPUT_BYTES
|
|
240
|
+
|| !Number.isSafeInteger(prepared.chunkBytes) || prepared.chunkBytes < 1 || prepared.chunkBytes > 1024 * 1024
|
|
241
|
+
|| !/^[a-f0-9]{64}$/.test(prepared.sha256))
|
|
242
|
+
throw Error('invalid native snapshot');
|
|
243
|
+
const bytes = Buffer.alloc(prepared.bytes);
|
|
244
|
+
for (let offset = 0; offset < bytes.length; offset += prepared.chunkBytes) {
|
|
245
|
+
const chunk = unpack(await resources.chunk({ scope: sessionId, transferId, offset }, signal));
|
|
246
|
+
check();
|
|
247
|
+
if (chunk.offset !== offset || typeof chunk.data !== 'string')
|
|
248
|
+
throw Error('invalid native chunk');
|
|
249
|
+
const data = Buffer.from(chunk.data, 'base64');
|
|
250
|
+
if (data.toString('base64') !== chunk.data || data.length !== Math.min(prepared.chunkBytes, bytes.length - offset))
|
|
251
|
+
throw Error('invalid native chunk');
|
|
252
|
+
data.copy(bytes, offset);
|
|
253
|
+
}
|
|
254
|
+
if (createHash('sha256').update(bytes).digest('hex') !== prepared.sha256)
|
|
255
|
+
throw Error('invalid native snapshot');
|
|
256
|
+
append(prepared.name, bytes);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
omitted = true;
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
if (id(transferId)) {
|
|
263
|
+
try {
|
|
264
|
+
await resources?.release({ scope: sessionId, transferId });
|
|
265
|
+
}
|
|
266
|
+
catch { /* bounded native snapshot expires */ }
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
for (const ref of result.images) {
|
|
271
|
+
try {
|
|
272
|
+
check();
|
|
273
|
+
if (attachments.length >= 6 || ref.bytes > MAX_MEDIA_BYTES || total + ref.bytes > MAX_INPUT_BYTES)
|
|
274
|
+
throw Error('output limit');
|
|
275
|
+
const value = await this.ctx.get('attachments')?.readImage(ref, signal);
|
|
276
|
+
check();
|
|
277
|
+
if (!(value?.data instanceof Uint8Array) || value.data.byteLength !== ref.bytes
|
|
278
|
+
|| 'sha256:' + createHash('sha256').update(value.data).digest('hex') !== ref.attachmentId)
|
|
279
|
+
throw Error('image unavailable');
|
|
280
|
+
append(ref.name, value.data);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
omitted = true;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// Missing, moved or oversized files do not turn a completed DSH task into
|
|
287
|
+
// an error or cause replay. The final text remains usable on every host.
|
|
288
|
+
const notices = [...result.notices, ...(omitted ? ['部分文件未能回传微信,请在电脑或小程序查看。'] : [])];
|
|
289
|
+
return { text: result.text, ...(attachments.length ? { attachments } : {}), ...(notices.length ? { notices } : {}) };
|
|
290
|
+
}
|
|
291
|
+
async prepareInput(input, generation, signal) {
|
|
292
|
+
const content = input.text.trim() ? [{ type: 'text', text: input.text }] : [];
|
|
293
|
+
let session, agent;
|
|
294
|
+
const check = () => {
|
|
295
|
+
signal.throwIfAborted();
|
|
296
|
+
input.checkAdmission?.();
|
|
297
|
+
this.assertAccess?.();
|
|
298
|
+
if (this.archived().has(input.sessionId))
|
|
299
|
+
throw fail('not-found', '此会话已归档,本条消息未发送。');
|
|
300
|
+
const live = this.ctx.get('sessions')?.get(input.sessionId), current = this.ctx.get('agents')?.get(input.sessionId);
|
|
301
|
+
if (live && (live.header?.createdAt !== generation || live.header?.origin === 'subagent')
|
|
302
|
+
|| session && session !== live || agent && agent !== current || current?.session && current.session !== live)
|
|
303
|
+
throw fail('session-changed', '准备附件时会话已变化,本条消息未发送。');
|
|
304
|
+
session ??= live;
|
|
305
|
+
agent ??= current;
|
|
306
|
+
};
|
|
307
|
+
check();
|
|
308
|
+
for (const part of input.attachments ?? []) {
|
|
309
|
+
check();
|
|
310
|
+
if (part.kind === 'image') {
|
|
311
|
+
content.push({ type: 'image', mediaType: imageType(part.data), data: Buffer.from(part.data).toString('base64'), name: part.name });
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
const uploads = this.ctx.get('fileUploads');
|
|
315
|
+
if (typeof uploads?.uploadStream !== 'function')
|
|
316
|
+
throw fail('unavailable', '此节点尚不支持原生文件附件,请更新电脑端后重试。');
|
|
317
|
+
let saved;
|
|
318
|
+
try {
|
|
319
|
+
saved = await uploads.uploadStream({ sessionId: input.sessionId, name: part.name, signal,
|
|
320
|
+
data: (async function* () { check(); yield part.data; })() });
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
signal.throwIfAborted();
|
|
324
|
+
throw fail('attachment-unavailable', '附件未能保存到原会话,本条消息未发送。');
|
|
325
|
+
}
|
|
326
|
+
check();
|
|
327
|
+
if (!id(saved?.receiptId) || !nativeFile(saved?.file) || saved.file.bytes !== part.data.byteLength
|
|
328
|
+
|| saved.file.attachmentId !== 'sha256:' + createHash('sha256').update(part.data).digest('hex'))
|
|
329
|
+
throw fail('attachment-unavailable', '无法确认原生附件保存结果,本条消息未发送。');
|
|
330
|
+
content.push({ type: 'file', receiptId: saved.receiptId });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const current = await this.metadata(input.sessionId, signal);
|
|
334
|
+
if (current.generation !== generation)
|
|
335
|
+
throw fail('session-changed', '准备附件时会话已重置,本条消息未发送。');
|
|
336
|
+
check();
|
|
337
|
+
// Native uploads own their durable objects and staged receipts. Never delete
|
|
338
|
+
// shared attachment files or edit the host's receipt table on a failed send.
|
|
339
|
+
return { content, session };
|
|
340
|
+
}
|
|
341
|
+
observeAndSubmit(input, generation, cursor, prepared, signal, onAttention) {
|
|
342
|
+
return new Promise((resolve, reject) => {
|
|
343
|
+
let settled = false, dispatched = false, accepted = false, currentTurn;
|
|
344
|
+
let matchedTurn, finalText, completion;
|
|
345
|
+
let observedSession = prepared.session, eventCount = 0;
|
|
346
|
+
const files = new Set();
|
|
347
|
+
let images = [], omitted = false, finalInterrupted = false;
|
|
348
|
+
const notices = [];
|
|
349
|
+
const attention = new Set();
|
|
350
|
+
const offs = [];
|
|
351
|
+
const submission = () => accepted ? 'accepted' : dispatched ? 'unknown' : 'not-submitted';
|
|
352
|
+
const finish = (error, text) => {
|
|
353
|
+
if (settled)
|
|
354
|
+
return;
|
|
355
|
+
settled = true;
|
|
356
|
+
clearTimeout(timer);
|
|
357
|
+
signal.removeEventListener('abort', abort);
|
|
358
|
+
for (const off of offs.splice(0)) {
|
|
359
|
+
try {
|
|
360
|
+
off();
|
|
361
|
+
}
|
|
362
|
+
catch { /* no observer may remain */ }
|
|
363
|
+
}
|
|
364
|
+
if (error)
|
|
365
|
+
reject(error);
|
|
366
|
+
else
|
|
367
|
+
resolve({ text: text, session: observedSession, files: [...files], images, omitted, notices });
|
|
368
|
+
};
|
|
369
|
+
const stop = (code, message) => finish(fail(code, message, submission()));
|
|
370
|
+
const notify = (kind) => {
|
|
371
|
+
if (!onAttention)
|
|
372
|
+
return stop(kind + '-required', kind === 'approval'
|
|
373
|
+
? '此任务需要你的原生审批,请回电脑或小程序处理;任务未被取消。'
|
|
374
|
+
: '此任务需要回答问题,请回电脑或小程序处理;任务未被取消。');
|
|
375
|
+
if (attention.has(kind))
|
|
376
|
+
return;
|
|
377
|
+
attention.add(kind);
|
|
378
|
+
// The durable matching input proves ownership. The notice does not
|
|
379
|
+
// decide an approval, answer a question or end this run's observation.
|
|
380
|
+
try {
|
|
381
|
+
onAttention(kind);
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
stop('attention-unavailable', '未能保存待处理提醒。任务未被取消,请在电脑或小程序处理并查看结果。');
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
const abort = () => stop('observation-stopped', dispatched
|
|
388
|
+
? '已停止等待回复;电脑端任务可能仍在继续,请在电脑或小程序核对,勿重复发送。'
|
|
389
|
+
: '已停止发送。');
|
|
390
|
+
const timer = setTimeout(() => stop('observation-timeout', '等待本次回复超时;任务可能仍在继续,请在电脑或小程序核对,勿重复发送。'), MAX_OBSERVATION_MS);
|
|
391
|
+
timer.unref();
|
|
392
|
+
const checkSubject = (session) => {
|
|
393
|
+
if (session?.id !== input.sessionId)
|
|
394
|
+
return false;
|
|
395
|
+
if (session.header?.createdAt !== generation || session.header?.origin === 'subagent'
|
|
396
|
+
|| this.ctx.get('sessions')?.get(input.sessionId) !== session
|
|
397
|
+
|| observedSession && observedSession !== session) {
|
|
398
|
+
stop('session-changed', '会话实例已变化,未转发无法确认归属的回复;请在电脑或小程序核对。');
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
observedSession = session;
|
|
402
|
+
return true;
|
|
403
|
+
};
|
|
404
|
+
try {
|
|
405
|
+
offs.push(this.ctx.on('session/event', (session, event) => {
|
|
406
|
+
if (settled || completion !== undefined || !dispatched || !checkSubject(session) || !row(event))
|
|
407
|
+
return;
|
|
408
|
+
if (!Number.isSafeInteger(event.seq) || event.seq <= cursor)
|
|
409
|
+
return;
|
|
410
|
+
if (++eventCount > 100_000)
|
|
411
|
+
return stop('output-unavailable', '本次任务事件过多,请在电脑或小程序查看结果。');
|
|
412
|
+
const data = event.data;
|
|
413
|
+
if (!row(data))
|
|
414
|
+
return;
|
|
415
|
+
if (event.type === 'turn/start') {
|
|
416
|
+
if (matchedTurn !== undefined)
|
|
417
|
+
return stop('ambiguous-turn', '本次轮次边界发生变化,未转发无法确认归属的回复。');
|
|
418
|
+
currentTurn = Number.isSafeInteger(data.turn) ? data.turn : undefined;
|
|
419
|
+
finalText = undefined;
|
|
420
|
+
}
|
|
421
|
+
else if (event.type === 'user/message') {
|
|
422
|
+
// The host's committed request receipt, not a second comparison of
|
|
423
|
+
// normalized prompt content, identifies our turn. Native context,
|
|
424
|
+
// skill/subagent messages and the owner's same-session follow-ups
|
|
425
|
+
// are ordinary inputs to that turn, not a reason to hide its reply.
|
|
426
|
+
if (data.source?.kind !== 'user' || data.source.rpcId !== input.requestId)
|
|
427
|
+
return;
|
|
428
|
+
if (matchedTurn !== undefined || currentTurn === undefined)
|
|
429
|
+
return stop('ambiguous-turn', '无法确认本次消息的轮次,请在原会话查看。');
|
|
430
|
+
matchedTurn = currentTurn;
|
|
431
|
+
}
|
|
432
|
+
else if (matchedTurn !== undefined && currentTurn === matchedTurn) {
|
|
433
|
+
if (event.type === 'deliverables/presented' && data.turn === matchedTurn && id(data.callId) && Array.isArray(data.files)) {
|
|
434
|
+
for (const file of data.files) {
|
|
435
|
+
if (!row(file) || typeof file.path !== 'string' || !file.path.trim() || file.path.length > 4096 || /[\u0000-\u001f]/.test(file.path)) {
|
|
436
|
+
omitted = true;
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (files.has(file.path))
|
|
440
|
+
continue;
|
|
441
|
+
if (files.size >= 6) {
|
|
442
|
+
omitted = true;
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
files.add(file.path);
|
|
446
|
+
}
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (event.type === 'approval/asked')
|
|
450
|
+
return notify('approval');
|
|
451
|
+
if (event.type === 'tool/call' && data.name === 'ask_user_question') {
|
|
452
|
+
return notify('question');
|
|
453
|
+
}
|
|
454
|
+
if (event.type === 'assistant/message' && data.turn === matchedTurn) {
|
|
455
|
+
const content = data.message?.content;
|
|
456
|
+
finalText = undefined;
|
|
457
|
+
images = [];
|
|
458
|
+
finalInterrupted = data.interrupted === true;
|
|
459
|
+
if (!Array.isArray(content))
|
|
460
|
+
return;
|
|
461
|
+
const texts = content.filter((part) => part?.type === 'text' && typeof part.text === 'string').map((part) => part.text);
|
|
462
|
+
const text = texts.join('');
|
|
463
|
+
if (Buffer.byteLength(text) > REPLY_TEXT_MAX_BYTES)
|
|
464
|
+
return stop('output-unavailable', '回复超过本地传输上限,请在原会话查看。');
|
|
465
|
+
if (text)
|
|
466
|
+
finalText = text;
|
|
467
|
+
for (const part of content)
|
|
468
|
+
if (part?.type === 'image') {
|
|
469
|
+
const ref = part.attachment;
|
|
470
|
+
if (!nativeImage(ref, ref?.name) || images.length >= 6) {
|
|
471
|
+
omitted = true;
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
const suffix = ref.mediaType === 'image/jpeg' ? 'jpg' : ref.mediaType.slice(6);
|
|
475
|
+
images.push({ ...ref, name: typeof ref.name === 'string' && ref.name.trim() ? ref.name : `image-${images.length + 1}.${suffix}` });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
else if (event.type === 'turn/end' && data.turn === matchedTurn) {
|
|
479
|
+
const interrupted = data.reason?.kind !== 'completed' || finalInterrupted;
|
|
480
|
+
if (interrupted) {
|
|
481
|
+
const message = data.reason?.kind === 'max-tokens' ? '本轮回复达到长度上限。'
|
|
482
|
+
: ['aborted', 'cancelled'].includes(data.reason?.kind) || finalInterrupted ? '本轮已停止。'
|
|
483
|
+
: '本轮未正常完成,请在原会话查看状态。';
|
|
484
|
+
if (!finalText && !files.size && !images.length)
|
|
485
|
+
return stop('interrupted', message);
|
|
486
|
+
// DSH displays durable partial text/images too, with a separate
|
|
487
|
+
// stop marker. Preserve that content instead of replacing it.
|
|
488
|
+
notices.push(message);
|
|
489
|
+
}
|
|
490
|
+
if (!finalText && !files.size && !images.length)
|
|
491
|
+
return stop('output-unavailable', '本次任务已结束,但没有可确认的最终文字回复,请在电脑或小程序查看。');
|
|
492
|
+
completion = finalText || '';
|
|
493
|
+
if (accepted)
|
|
494
|
+
finish(undefined, completion);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (event.type === 'turn/end' && data.turn === currentTurn && matchedTurn === undefined)
|
|
498
|
+
currentTurn = undefined;
|
|
499
|
+
}, { global: true }));
|
|
500
|
+
offs.push(this.ctx.on('session/disposed', (session) => {
|
|
501
|
+
if (!settled && session?.id === input.sessionId)
|
|
502
|
+
stop('session-changed', '会话已卸载,无法确认本次回复,请在电脑或小程序核对。');
|
|
503
|
+
}, { global: true }));
|
|
504
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
505
|
+
if (signal.aborted) {
|
|
506
|
+
abort();
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
// Native prompt checks its signal only before admission. Deliberately
|
|
510
|
+
// detach this signal from the external observer once dispatch starts.
|
|
511
|
+
// No cancel, preset selection, ownerInput proof or fallback retry exists.
|
|
512
|
+
const gateway = this.gateway();
|
|
513
|
+
if (this.archived().has(input.sessionId)) {
|
|
514
|
+
stop('not-found', '此会话已归档,本条消息未发送。');
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const live = this.ctx.get('sessions')?.get(input.sessionId);
|
|
518
|
+
if (live && !checkSubject(live))
|
|
519
|
+
return;
|
|
520
|
+
// Queue through the host even while an earlier turn is running. The
|
|
521
|
+
// native inbox owns scheduling; only our committed rpcId starts reply
|
|
522
|
+
// collection, so an earlier or unrelated turn is never forwarded.
|
|
523
|
+
// The installed native prompt has no generation precondition. This
|
|
524
|
+
// immediate check plus event identity checks fail closed for observed
|
|
525
|
+
// resets; they cannot provide CAS inside the native async dispatcher.
|
|
526
|
+
input.checkAdmission?.();
|
|
527
|
+
this.assertAccess?.();
|
|
528
|
+
dispatched = true;
|
|
529
|
+
void Promise.resolve(gateway.invoke({ namespace: 'session', method: 'prompt', args: { request: {
|
|
530
|
+
sessionId: input.sessionId, requestId: input.requestId, mode: 'queue', content: prepared.content,
|
|
531
|
+
} }, signal: new AbortController().signal })).then((receipt) => {
|
|
532
|
+
if (settled)
|
|
533
|
+
return;
|
|
534
|
+
if (!row(receipt) || receipt.accepted !== true)
|
|
535
|
+
return stop('delivery-unknown', '无法确认电脑是否接收本次消息,请在电脑或小程序核对,勿重复发送。');
|
|
536
|
+
accepted = true;
|
|
537
|
+
if (completion !== undefined)
|
|
538
|
+
finish(undefined, completion);
|
|
539
|
+
}, (error) => {
|
|
540
|
+
if (settled)
|
|
541
|
+
return;
|
|
542
|
+
const code = row(error) ? error.code : undefined;
|
|
543
|
+
if (code === 'session/agent-busy' || code === 'session/writer-held') {
|
|
544
|
+
stop('busy', '原生会话当前被占用或不能接收此消息,请在电脑或小程序核对;没有自动重发。');
|
|
545
|
+
}
|
|
546
|
+
else if (code === 'session/attachment-invalid') {
|
|
547
|
+
finish(fail('attachment-rejected', '当前模型或原生会话不支持此附件,本条消息未发送。请在电脑或小程序检查附件和所选模型。', 'not-submitted'));
|
|
548
|
+
}
|
|
549
|
+
else
|
|
550
|
+
stop('delivery-unknown', '本次发送未获得可确认的接收结果,请在电脑或小程序核对,勿重复发送。');
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
stop('delivery-unknown', dispatched
|
|
555
|
+
? '本次发送结果不明,请在电脑或小程序核对,勿重复发送。'
|
|
556
|
+
: '未能建立本次回复观察,请稍后在电脑或小程序核对。');
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
}
|