@xmanrui/dsh-im 4.12.0 → 4.13.0
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/PROACTIVE_DELIVERY.en.md +4 -0
- package/PROACTIVE_DELIVERY.md +4 -0
- package/README.en.md +11 -2
- package/README.md +11 -2
- package/assets/dingtalk-menu-template.json +6 -0
- package/lib/client.js +3 -2
- package/lib/index.js +268 -263
- package/package.json +1 -1
- package/plugin-src/client/styles.js +2 -1
- package/plugin-src/host/harness-command-executor.mjs +13 -6
- package/src/channels/dingtalk/dingtalk-api.mjs +33 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +136 -2
- package/src/channels/dingtalk/dingtalk-menu.mjs +91 -0
- package/src/channels/dingtalk/dingtalk-runtime.mjs +17 -0
- package/src/channels/dingtalk/state-store.mjs +8 -0
- package/src/channels/feishu/bridge.mjs +74 -3
- package/src/channels/feishu/state-store.mjs +11 -0
- package/src/channels/qq/qq-bridge.mjs +121 -1
- package/src/channels/qq/qq-menu.mjs +205 -0
- package/src/channels/qq/state-store.mjs +16 -0
- package/src/channels/shared/bot-workspace-store.mjs +3 -0
- package/src/channels/shared/control-command.mjs +8 -0
- package/src/channels/shared/conversation-state-store.mjs +8 -0
- package/src/channels/shared/deferred-delivery-coordinator.mjs +241 -0
- package/src/channels/shared/deferred-delivery.mjs +161 -0
- package/src/channels/shared/deferred-state.mjs +53 -0
- package/src/channels/shared/harness-client.mjs +21 -1
- package/src/channels/shared/i18n-en/dingtalk.mjs +5 -0
- package/src/channels/shared/i18n-en/feishu.mjs +6 -0
- package/src/channels/shared/i18n-en/qq.mjs +39 -0
- package/src/channels/shared/i18n-en/weixin.mjs +2 -0
- package/src/channels/shared/text-harness-bridge.mjs +15 -0
- package/src/channels/shared/workspace-session.mjs +16 -1
- package/src/channels/wecom/state-store.mjs +8 -0
- package/src/channels/wecom/wecom-bridge.mjs +19 -4
- package/src/channels/weixin/state-store.mjs +64 -1
- package/src/channels/weixin/weixin-bridge.mjs +31 -5
- package/src/channels/weixin/weixin-runtime.mjs +33 -8
|
@@ -15,6 +15,7 @@ function workspaceSession(harness, sessionId) {
|
|
|
15
15
|
isRunning: (...args) => harness.isSessionRunning(sessionId, ...args),
|
|
16
16
|
hasActiveTurn: (...args) => harness.hasActiveTurn(sessionId, ...args),
|
|
17
17
|
stopActiveTurn: (...args) => harness.stopActiveTurn(sessionId, ...args),
|
|
18
|
+
stopDeferredTurn: (...args) => harness.stopDeferredTurn?.(sessionId, ...args) ?? false,
|
|
18
19
|
steerActiveTurn: (...args) => harness.steerActiveTurn(sessionId, ...args),
|
|
19
20
|
ask: (...args) => harness.ask(sessionId, ...args),
|
|
20
21
|
};
|
|
@@ -51,6 +52,7 @@ export async function askInWorkspaceSession({
|
|
|
51
52
|
createOptions,
|
|
52
53
|
existsOptions,
|
|
53
54
|
askOptions,
|
|
55
|
+
deferredDelivery,
|
|
54
56
|
}) {
|
|
55
57
|
const initialTitle = contextEnhanced
|
|
56
58
|
? initialSessionTitle({
|
|
@@ -97,7 +99,20 @@ export async function askInWorkspaceSession({
|
|
|
97
99
|
artifacts.push(artifact);
|
|
98
100
|
await originalOnArtifact?.(artifact);
|
|
99
101
|
};
|
|
100
|
-
|
|
102
|
+
let answer;
|
|
103
|
+
try {
|
|
104
|
+
answer = await binding.session.ask(content ?? text, artifactOptions);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error?.code === 'harness-reply-timeout' && deferredDelivery) {
|
|
107
|
+
try {
|
|
108
|
+
const { coordinator, ...destination } = deferredDelivery();
|
|
109
|
+
await coordinator.trackTimeout(error, { ...destination, key, sessionId: binding.sessionId });
|
|
110
|
+
} catch (registrationError) {
|
|
111
|
+
console.warn('[dsh-im] unable to persist deferred delivery:', registrationError.message);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
101
116
|
return {
|
|
102
117
|
sessionId: binding.sessionId,
|
|
103
118
|
answer,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { deferredStateAccess, normalizeDeferredState } from '../shared/deferred-state.mjs';
|
|
1
2
|
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
2
3
|
import { dirname } from 'node:path';
|
|
3
4
|
|
|
@@ -14,6 +15,7 @@ function normalizeState(value) {
|
|
|
14
15
|
return {
|
|
15
16
|
version: 1,
|
|
16
17
|
sessions,
|
|
18
|
+
...(value.deferred ? { deferred: normalizeDeferredState(value.deferred) } : {}),
|
|
17
19
|
seenMessageIds: Array.isArray(value.seenMessageIds)
|
|
18
20
|
? value.seenMessageIds.filter((id) => typeof id === 'string').slice(-1_000)
|
|
19
21
|
: [],
|
|
@@ -24,6 +26,7 @@ export class WecomStateStore {
|
|
|
24
26
|
#path;
|
|
25
27
|
#state = structuredClone(EMPTY_STATE);
|
|
26
28
|
#writeQueue = Promise.resolve();
|
|
29
|
+
#deferred = deferredStateAccess(() => this.#state, () => this.#persist());
|
|
27
30
|
|
|
28
31
|
constructor(path) {
|
|
29
32
|
this.#path = path;
|
|
@@ -40,6 +43,11 @@ export class WecomStateStore {
|
|
|
40
43
|
return this;
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
deferredEntries() { return this.#deferred.entries(); }
|
|
47
|
+
putDeferred(entry) { return this.#deferred.put(entry); }
|
|
48
|
+
patchDeferred(id, patch) { return this.#deferred.patch(id, patch); }
|
|
49
|
+
removeDeferred(id) { return this.#deferred.remove(id); }
|
|
50
|
+
|
|
43
51
|
sessionFor(key) {
|
|
44
52
|
return this.#state.sessions[key] ?? null;
|
|
45
53
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
|
|
1
2
|
import { generateReqId } from '@wecom/aibot-node-sdk';
|
|
2
3
|
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import {
|
|
@@ -357,15 +358,17 @@ function thinkingProgressText(update) {
|
|
|
357
358
|
}
|
|
358
359
|
|
|
359
360
|
function streamContent(thinkingText, answerText = '', { finish = false } = {}) {
|
|
361
|
+
const answer = String(answerText ?? '').trim();
|
|
362
|
+
// Progress is transient: the final frame must contain only the delivered text.
|
|
363
|
+
if (finish) return answer;
|
|
364
|
+
|
|
360
365
|
const thinking = String(thinkingText ?? '')
|
|
361
366
|
.replace(/<\/?think>/gi, '')
|
|
362
367
|
.trim();
|
|
363
|
-
const answer = String(answerText ?? '').trim();
|
|
364
368
|
if (!thinking) return answer;
|
|
365
|
-
|
|
366
|
-
? `<think>${thinking}</think
|
|
369
|
+
return answer
|
|
370
|
+
? `<think>${thinking}</think>\n${answer}`
|
|
367
371
|
: `<think>${thinking}`;
|
|
368
|
-
return answer ? `${thinkBlock}\n${answer}` : thinkBlock;
|
|
369
372
|
}
|
|
370
373
|
|
|
371
374
|
function artifactFailureText(fileName, error) {
|
|
@@ -541,6 +544,7 @@ export class WecomHarnessBridge {
|
|
|
541
544
|
#client;
|
|
542
545
|
#harness;
|
|
543
546
|
#state;
|
|
547
|
+
#deferred;
|
|
544
548
|
#contextEnhancement;
|
|
545
549
|
#accessPolicy;
|
|
546
550
|
#status;
|
|
@@ -594,6 +598,9 @@ export class WecomHarnessBridge {
|
|
|
594
598
|
this.#generateReqId = generateStreamId;
|
|
595
599
|
this.#fileUploadTimeoutMs = Math.min(fileUploadTimeoutMs, DEFAULT_FILE_UPLOAD_TIMEOUT_MS);
|
|
596
600
|
this.#signal = signal;
|
|
601
|
+
this.#deferred = createDeferredDeliveryCoordinator({ harness, state, signal, logger,
|
|
602
|
+
deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
|
|
603
|
+
});
|
|
597
604
|
this.#approvals = new HarnessApprovalQueue({ label: 'wecom', logger });
|
|
598
605
|
}
|
|
599
606
|
|
|
@@ -1122,6 +1129,11 @@ export class WecomHarnessBridge {
|
|
|
1122
1129
|
return task;
|
|
1123
1130
|
}
|
|
1124
1131
|
|
|
1132
|
+
async #deliverDeferredOutcome(entry, outcome) {
|
|
1133
|
+
await this.#sendActive(entry.target.chatId, deferredOutcomeText(outcome));
|
|
1134
|
+
return true;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1125
1137
|
async waitForIdle() {
|
|
1126
1138
|
await Promise.allSettled([
|
|
1127
1139
|
...this.#queues.values(),
|
|
@@ -1131,6 +1143,7 @@ export class WecomHarnessBridge {
|
|
|
1131
1143
|
...this.#approvalTasks,
|
|
1132
1144
|
...this.#commandTasks,
|
|
1133
1145
|
]);
|
|
1146
|
+
await this.#deferred.whenIdle();
|
|
1134
1147
|
}
|
|
1135
1148
|
|
|
1136
1149
|
async #processFastCommand(frame, messageId, chatId, key, message, runner) {
|
|
@@ -1147,6 +1160,7 @@ export class WecomHarnessBridge {
|
|
|
1147
1160
|
pendingInteraction: this.#pendingInteractions.has(key)
|
|
1148
1161
|
|| this.#approvals.hasPending(key),
|
|
1149
1162
|
control: { owner: this, key },
|
|
1163
|
+
deferredDelivery: this.#deferred,
|
|
1150
1164
|
});
|
|
1151
1165
|
if (result?.stopped) {
|
|
1152
1166
|
await Promise.allSettled([
|
|
@@ -1337,6 +1351,7 @@ export class WecomHarnessBridge {
|
|
|
1337
1351
|
await this.#state.markSeen(messageId);
|
|
1338
1352
|
promptRecorded = true;
|
|
1339
1353
|
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
|
1354
|
+
deferredDelivery: () => ({ coordinator: this.#deferred, target: { chatId } }),
|
|
1340
1355
|
harness: this.#harness,
|
|
1341
1356
|
state: this.#state,
|
|
1342
1357
|
key,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { deferredStateAccess, normalizeDeferredState } from '../shared/deferred-state.mjs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
1
3
|
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
2
4
|
import { dirname } from 'node:path';
|
|
3
5
|
|
|
@@ -15,6 +17,7 @@ export const WEIXIN_RECENT_OUTBOUND_LIMIT = 200;
|
|
|
15
17
|
export const WEIXIN_RECENT_OUTBOUND_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
16
18
|
export const WEIXIN_RECENT_OUTBOUND_TEXT_LIMIT = 8_000;
|
|
17
19
|
export const WEIXIN_RECENT_OUTBOUND_MATCH_TOLERANCE_MS = 15_000;
|
|
20
|
+
const CONTEXT_TOKEN_LIMIT = 1_000;
|
|
18
21
|
|
|
19
22
|
function nonEmptyString(value) {
|
|
20
23
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
@@ -25,6 +28,26 @@ function timestamp(value) {
|
|
|
25
28
|
return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
function sequence(value) {
|
|
32
|
+
const text = typeof value === 'string' ? value : Number.isSafeInteger(value) ? String(value) : '';
|
|
33
|
+
return /^\d+$/.test(text) ? BigInt(text).toString() : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeContextTokens(value) {
|
|
37
|
+
if (!value || typeof value !== 'object' || !/^[a-f0-9]{64}$/.test(value.credentialHash ?? '')) return undefined;
|
|
38
|
+
const users = Object.create(null);
|
|
39
|
+
for (const [userId, entry] of Object.entries(value.users ?? {}).slice(-CONTEXT_TOKEN_LIMIT)) {
|
|
40
|
+
if (!nonEmptyString(userId) || !nonEmptyString(entry?.token)) continue;
|
|
41
|
+
users[userId] = {
|
|
42
|
+
token: entry.token,
|
|
43
|
+
seq: sequence(entry.seq),
|
|
44
|
+
messageTimeMs: timestamp(entry.messageTimeMs),
|
|
45
|
+
receivedAt: timestamp(entry.receivedAt),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return { credentialHash: value.credentialHash, users };
|
|
49
|
+
}
|
|
50
|
+
|
|
28
51
|
function providerMessageIds(value) {
|
|
29
52
|
if (!Array.isArray(value)) return [];
|
|
30
53
|
return [...new Set(value
|
|
@@ -72,14 +95,17 @@ function normalizeState(value) {
|
|
|
72
95
|
}
|
|
73
96
|
}
|
|
74
97
|
}
|
|
98
|
+
const contextTokens = normalizeContextTokens(value.contextTokens);
|
|
75
99
|
return {
|
|
76
100
|
version: 1,
|
|
77
101
|
sessions,
|
|
102
|
+
...(value.deferred ? { deferred: normalizeDeferredState(value.deferred) } : {}),
|
|
78
103
|
seenMessageIds: Array.isArray(value.seenMessageIds)
|
|
79
104
|
? value.seenMessageIds.filter((id) => typeof id === 'string').slice(-1_000)
|
|
80
105
|
: [],
|
|
81
106
|
getUpdatesBuf: typeof value.getUpdatesBuf === 'string' ? value.getUpdatesBuf : '',
|
|
82
107
|
recentOutboundMessages: recentOutboundMessages(value.recentOutboundMessages),
|
|
108
|
+
...(contextTokens ? { contextTokens } : {}),
|
|
83
109
|
};
|
|
84
110
|
}
|
|
85
111
|
|
|
@@ -87,6 +113,7 @@ export class WeixinStateStore {
|
|
|
87
113
|
#path;
|
|
88
114
|
#state = structuredClone(EMPTY_STATE);
|
|
89
115
|
#writeQueue = Promise.resolve();
|
|
116
|
+
#deferred = deferredStateAccess(() => this.#state, () => this.#persist());
|
|
90
117
|
|
|
91
118
|
constructor(path) {
|
|
92
119
|
this.#path = path;
|
|
@@ -103,6 +130,41 @@ export class WeixinStateStore {
|
|
|
103
130
|
return this;
|
|
104
131
|
}
|
|
105
132
|
|
|
133
|
+
deferredEntries() { return this.#deferred.entries(); }
|
|
134
|
+
putDeferred(entry) { return this.#deferred.put(entry); }
|
|
135
|
+
patchDeferred(id, patch) { return this.#deferred.patch(id, patch); }
|
|
136
|
+
removeDeferred(id) { return this.#deferred.remove(id); }
|
|
137
|
+
|
|
138
|
+
// Context belongs to this bot login, not to a Harness session or workspace.
|
|
139
|
+
async bindContextTokens(token) {
|
|
140
|
+
const credentialHash = createHash('sha256').update(token).digest('hex');
|
|
141
|
+
if (this.#state.contextTokens?.credentialHash === credentialHash) return;
|
|
142
|
+
this.#state.contextTokens = { credentialHash, users: Object.create(null) };
|
|
143
|
+
await this.#persist();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
contextTokenFor(userId) {
|
|
147
|
+
return this.#state.contextTokens?.users?.[userId]?.token ?? undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async rememberContextToken({ userId, contextToken, seq, messageTimeMs }) {
|
|
151
|
+
if (!nonEmptyString(userId) || !nonEmptyString(contextToken) || !this.#state.contextTokens) return;
|
|
152
|
+
const users = this.#state.contextTokens.users;
|
|
153
|
+
const previous = users[userId];
|
|
154
|
+
const next = { token: contextToken, seq: sequence(seq), messageTimeMs: timestamp(messageTimeMs), receivedAt: Date.now() };
|
|
155
|
+
// Late/duplicate batches must not replace a newer conversation capability.
|
|
156
|
+
if (previous) {
|
|
157
|
+
if (previous.seq !== null && next.seq !== null && BigInt(next.seq) <= BigInt(previous.seq)) return;
|
|
158
|
+
if (previous.messageTimeMs !== null && next.messageTimeMs !== null
|
|
159
|
+
&& next.messageTimeMs < previous.messageTimeMs) return;
|
|
160
|
+
}
|
|
161
|
+
delete users[userId];
|
|
162
|
+
users[userId] = next;
|
|
163
|
+
const keys = Object.keys(users);
|
|
164
|
+
if (keys.length > CONTEXT_TOKEN_LIMIT) delete users[keys[0]];
|
|
165
|
+
await this.#persist();
|
|
166
|
+
}
|
|
167
|
+
|
|
106
168
|
sessionFor(key) {
|
|
107
169
|
return this.#state.sessions[key] ?? null;
|
|
108
170
|
}
|
|
@@ -201,7 +263,8 @@ export class WeixinStateStore {
|
|
|
201
263
|
}
|
|
202
264
|
|
|
203
265
|
snapshot() {
|
|
204
|
-
|
|
266
|
+
const { contextTokens, ...state } = this.#state;
|
|
267
|
+
return structuredClone(state);
|
|
205
268
|
}
|
|
206
269
|
|
|
207
270
|
async remove() {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
|
|
1
2
|
import {
|
|
2
3
|
DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
|
|
3
4
|
extractWeixinFiles,
|
|
@@ -163,7 +164,7 @@ function createWeixinSendDiagnostic({
|
|
|
163
164
|
].join(' ');
|
|
164
165
|
}
|
|
165
166
|
|
|
166
|
-
function weixinSendError(error, details) {
|
|
167
|
+
export function weixinSendError(error, details) {
|
|
167
168
|
const diagnostic = createWeixinSendDiagnostic({ ...details, error });
|
|
168
169
|
const wrapped = new Error(`Weixin text delivery failed (${diagnostic})`, { cause: error });
|
|
169
170
|
const code = safeDiagnosticToken(error?.code);
|
|
@@ -176,7 +177,7 @@ function weixinSendError(error, details) {
|
|
|
176
177
|
return wrapped;
|
|
177
178
|
}
|
|
178
179
|
|
|
179
|
-
function weixinSendFailureOptions(error) {
|
|
180
|
+
export function weixinSendFailureOptions(error, { proactive = false } = {}) {
|
|
180
181
|
let current = error;
|
|
181
182
|
for (let depth = 0; current && depth < 5; depth += 1) {
|
|
182
183
|
const diagnostic = current[WEIXIN_SEND_DIAGNOSTIC];
|
|
@@ -184,7 +185,12 @@ function weixinSendFailureOptions(error) {
|
|
|
184
185
|
return {
|
|
185
186
|
reason: 'weixin-send-failed',
|
|
186
187
|
userMessage: [
|
|
187
|
-
|
|
188
|
+
proactive
|
|
189
|
+
? t('微信主动消息发送失败。长轮询在线不代表消息可以发送。')
|
|
190
|
+
: t('回复已经生成,但微信发送失败,可能只收到部分内容。请将下面的诊断信息完整反馈给管理员。'),
|
|
191
|
+
...(String(current.providerCode) === '-2' ? [
|
|
192
|
+
t('iLink 拒绝发送,可能涉及会话有效期、发送额度或消息内容。可让接收者发一条消息后重试;不要通过反复发送心跳尝试续期。'),
|
|
193
|
+
] : []),
|
|
188
194
|
t('微信发送诊断:{diagnostic}', { diagnostic }),
|
|
189
195
|
].join('\n'),
|
|
190
196
|
};
|
|
@@ -289,6 +295,7 @@ export class WeixinHarnessBridge {
|
|
|
289
295
|
#ownerUserId;
|
|
290
296
|
#harness;
|
|
291
297
|
#state;
|
|
298
|
+
#deferred;
|
|
292
299
|
#contextEnhancement;
|
|
293
300
|
#accessPolicy;
|
|
294
301
|
#status;
|
|
@@ -351,6 +358,9 @@ export class WeixinHarnessBridge {
|
|
|
351
358
|
this.#maxMessageChars = maxMessageChars;
|
|
352
359
|
this.#typingKeepaliveMs = typingKeepaliveMs;
|
|
353
360
|
this.#signal = signal;
|
|
361
|
+
this.#deferred = createDeferredDeliveryCoordinator({ harness, state, signal, logger,
|
|
362
|
+
deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
|
|
363
|
+
});
|
|
354
364
|
this.#approvals = new HarnessApprovalQueue({ label: 'weixin', logger });
|
|
355
365
|
}
|
|
356
366
|
|
|
@@ -390,6 +400,12 @@ export class WeixinHarnessBridge {
|
|
|
390
400
|
}
|
|
391
401
|
const key = conversationKey(sender);
|
|
392
402
|
const contextToken = nonEmptyString(message?.context_token) ?? undefined;
|
|
403
|
+
void this.#state.rememberContextToken?.({
|
|
404
|
+
userId: sender,
|
|
405
|
+
contextToken,
|
|
406
|
+
seq: message.seq,
|
|
407
|
+
messageTimeMs: message.create_time_ms ?? weixinMessageTimestampMs(messageId),
|
|
408
|
+
}).catch((error) => this.#logger.warn?.('[dsh-weixin] failed to persist conversation context:', error));
|
|
393
409
|
const runId = nonEmptyString(message?.run_id) ?? undefined;
|
|
394
410
|
const pending = this.#pendingInteractions.get(key);
|
|
395
411
|
const batchCommand = isBatchInputCommand(commandText);
|
|
@@ -642,6 +658,11 @@ export class WeixinHarnessBridge {
|
|
|
642
658
|
return task;
|
|
643
659
|
}
|
|
644
660
|
|
|
661
|
+
async #deliverDeferredOutcome(entry, outcome) {
|
|
662
|
+
await this.#send(entry.target.toUserId, deferredOutcomeText(outcome), undefined, undefined, { stopTyping: false });
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
665
|
+
|
|
645
666
|
async waitForIdle() {
|
|
646
667
|
await Promise.allSettled([
|
|
647
668
|
...this.#queues.values(),
|
|
@@ -652,9 +673,11 @@ export class WeixinHarnessBridge {
|
|
|
652
673
|
...this.#commandTasks,
|
|
653
674
|
this.#typingTail,
|
|
654
675
|
]);
|
|
676
|
+
await this.#deferred.whenIdle();
|
|
655
677
|
}
|
|
656
678
|
|
|
657
679
|
async close() {
|
|
680
|
+
this.#deferred.close();
|
|
658
681
|
this.#typingClosed = true;
|
|
659
682
|
await this.#stopTyping({ signal: AbortSignal.timeout(5_000) });
|
|
660
683
|
}
|
|
@@ -682,6 +705,7 @@ export class WeixinHarnessBridge {
|
|
|
682
705
|
pendingInteraction: this.#pendingInteractions.has(key)
|
|
683
706
|
|| this.#approvals.hasPending(key),
|
|
684
707
|
control: { owner: this, key },
|
|
708
|
+
deferredDelivery: this.#deferred,
|
|
685
709
|
});
|
|
686
710
|
if (result?.stopped) {
|
|
687
711
|
await Promise.allSettled([
|
|
@@ -794,6 +818,7 @@ export class WeixinHarnessBridge {
|
|
|
794
818
|
await this.#state.markSeen(messageId);
|
|
795
819
|
promptRecorded = true;
|
|
796
820
|
({ answer, artifacts = [] } = await askInWorkspaceSession({
|
|
821
|
+
deferredDelivery: () => ({ coordinator: this.#deferred, target: { toUserId: sender } }),
|
|
797
822
|
harness: this.#harness,
|
|
798
823
|
state: this.#state,
|
|
799
824
|
key,
|
|
@@ -1225,8 +1250,9 @@ export class WeixinHarnessBridge {
|
|
|
1225
1250
|
).catch(() => undefined);
|
|
1226
1251
|
}
|
|
1227
1252
|
|
|
1228
|
-
async #send(toUserId, text, contextToken, runId) {
|
|
1229
|
-
await this.#stopTyping();
|
|
1253
|
+
async #send(toUserId, text, contextToken, runId, { stopTyping = true } = {}) {
|
|
1254
|
+
if (stopTyping) await this.#stopTyping();
|
|
1255
|
+
contextToken ??= this.#state.contextTokenFor?.(toUserId);
|
|
1230
1256
|
const providerMessageIds = [];
|
|
1231
1257
|
const chunks = splitWeixinText(text, this.#maxMessageChars);
|
|
1232
1258
|
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { DEFAULT_WEIXIN_MAX_MESSAGE_CHARS, WeixinApiError } from './weixin-api.mjs';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createWeixinBridgeStatus, WeixinHarnessBridge, weixinSendError, weixinSendFailureOptions,
|
|
4
|
+
} from './weixin-bridge.mjs';
|
|
3
5
|
import { providerMessageIdsFor } from '../shared/semantic/delivery.mjs';
|
|
6
|
+
import {
|
|
7
|
+
channelDeliveryFailure, clearLastMessageFailure, setLastMessageFailure,
|
|
8
|
+
} from '../shared/message-failure.mjs';
|
|
4
9
|
import {
|
|
5
10
|
connectionTestTarget,
|
|
6
11
|
connectionTestTargetUnavailable,
|
|
@@ -175,6 +180,7 @@ export class WeixinRuntime {
|
|
|
175
180
|
throw harnessHealthError(error);
|
|
176
181
|
}
|
|
177
182
|
this.#status.harnessReachable = true;
|
|
183
|
+
await this.#state.bindContextTokens?.(this.#token);
|
|
178
184
|
await this.#notifyStart();
|
|
179
185
|
this.#abortController = new AbortController();
|
|
180
186
|
const signal = this.#abortController.signal;
|
|
@@ -335,13 +341,32 @@ export class WeixinRuntime {
|
|
|
335
341
|
|
|
336
342
|
async #sendTrackedText({ toUserId, text, signal }) {
|
|
337
343
|
const sentAt = Date.now();
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
344
|
+
const contextToken = this.#state.contextTokenFor?.(toUserId);
|
|
345
|
+
let result;
|
|
346
|
+
try {
|
|
347
|
+
result = await this.#api.sendText({
|
|
348
|
+
baseUrl: this.#config.baseUrl,
|
|
349
|
+
token: this.#token,
|
|
350
|
+
toUserId,
|
|
351
|
+
text,
|
|
352
|
+
contextToken,
|
|
353
|
+
signal,
|
|
354
|
+
});
|
|
355
|
+
} catch (cause) {
|
|
356
|
+
if (signal?.aborted) throw cause;
|
|
357
|
+
const error = weixinSendError(cause, {
|
|
358
|
+
baseUrl: this.#config.baseUrl, text, chunk: text, chunkIndex: 0, chunkCount: 1,
|
|
359
|
+
maxMessageChars: this.#maxMessageChars, contextToken,
|
|
360
|
+
});
|
|
361
|
+
const failure = setLastMessageFailure(this.#status,
|
|
362
|
+
channelDeliveryFailure(error, { uncertain: cause?.code !== 'send-rejected' }),
|
|
363
|
+
weixinSendFailureOptions(error, { proactive: true }));
|
|
364
|
+
this.#logger.warn?.(`[dsh-weixin] outbound delivery failed [${failure.referenceId}]: ${failure.message}`);
|
|
365
|
+
throw error;
|
|
366
|
+
}
|
|
367
|
+
if (this.#status.lastMessageError?.reason === 'WEIXIN_SEND_FAILED') {
|
|
368
|
+
clearLastMessageFailure(this.#status);
|
|
369
|
+
}
|
|
345
370
|
try {
|
|
346
371
|
await this.#state.rememberOutboundMessage?.({
|
|
347
372
|
toUserId,
|