@xmanrui/dsh-im 0.16.0 → 0.17.1
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/lib/client.js +33 -0
- package/lib/index.js +138 -137
- package/package.json +1 -1
- package/plugin-src/client/channels/weixin/api.js +10 -0
- package/plugin-src/client/channels/weixin/index.js +4 -0
- package/plugin-src/client/i18n.js +20 -0
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/feishu/bridge.mjs +367 -10
- package/src/channels/feishu/feishu-cards.mjs +87 -12
- package/src/channels/feishu/state-store.mjs +85 -1
- package/src/channels/shared/harness-client.mjs +119 -0
- package/src/channels/shared/image-prompt.mjs +32 -1
- package/src/channels/weixin/weixin-api.mjs +1 -1
- package/src/channels/weixin/weixin-bridge.mjs +23 -3
- package/src/channels/weixin/weixin-controller.mjs +15 -0
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
* `im.message.create` API expects as `content` for `msg_type: interactive`
|
|
5
5
|
* (card schema 2.0; callback buttons live inside a column_set/column layout).
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* The session list lays each row out as a `column_set` (fixed-width ⭐
|
|
8
|
+
* watch-toggle column + weighted session-button column), which is how V2
|
|
9
|
+
* expresses a row of buttons.
|
|
10
|
+
*
|
|
11
|
+
* Buttons carry a small `{ action }` value object that `card.action.trigger`
|
|
12
|
+
* events echo back (when the app subscribes that event); every numbered
|
|
13
|
+
* button also has a numeric label so the number-reply fallback stays usable
|
|
14
|
+
* without button callbacks.
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
export const MENU_PAGE_SIZE = 10;
|
|
@@ -39,6 +43,17 @@ function button(content, actionValue) {
|
|
|
39
43
|
};
|
|
40
44
|
}
|
|
41
45
|
|
|
46
|
+
/** The raw button element (without the full-width column_set wrapper). */
|
|
47
|
+
function buttonElement(content, actionValue) {
|
|
48
|
+
return {
|
|
49
|
+
tag: 'button',
|
|
50
|
+
text: plainText(content),
|
|
51
|
+
type: 'default',
|
|
52
|
+
width: 'fill',
|
|
53
|
+
behaviors: [{ type: 'callback', value: { action: actionValue } }],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
42
57
|
function safeTitle(value) {
|
|
43
58
|
const title = String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').replace(/\s+/gu, ' ').trim();
|
|
44
59
|
return title || '暂无标题';
|
|
@@ -65,6 +80,7 @@ export function menuCard() {
|
|
|
65
80
|
// have card.action.trigger yet, so rendering it as a callback button would
|
|
66
81
|
// send the user straight back to Feishu's broken callback setup popup.
|
|
67
82
|
{ tag: 'div', text: markdown('**6 · 修复卡片按钮**(请直接回复数字 **6**)') },
|
|
83
|
+
button('7 · 关注列表', 'watchlist'),
|
|
68
84
|
]);
|
|
69
85
|
}
|
|
70
86
|
|
|
@@ -101,23 +117,43 @@ export function cardActionProbeCard(nonce) {
|
|
|
101
117
|
}
|
|
102
118
|
|
|
103
119
|
/**
|
|
104
|
-
* One page of the workspace's sessions. Each row is a
|
|
105
|
-
*
|
|
120
|
+
* One page of the workspace's sessions. Each row is a `column_set` pair:
|
|
121
|
+
* the fixed-width ⭐ watch toggle (`⭐关注` / `⭐取关` for already-watched
|
|
122
|
+
* sessions) followed by the session button that carries the page-local
|
|
123
|
+
* number label (reply-number fallback = bind). Archived sessions are marked
|
|
124
|
+
* in the label. `watchedSessionIds` is a Set-like of ids this conversation
|
|
125
|
+
* already watches.
|
|
106
126
|
*/
|
|
107
|
-
export function sessionListCard(workspace, sessions, page, total) {
|
|
127
|
+
export function sessionListCard(workspace, sessions, page, total, watchedSessionIds = new Set()) {
|
|
108
128
|
const start = page * MENU_PAGE_SIZE;
|
|
109
129
|
const slice = sessions.slice(start, start + MENU_PAGE_SIZE);
|
|
110
130
|
const pageCount = Math.max(1, Math.ceil(total / MENU_PAGE_SIZE));
|
|
131
|
+
const watched = (id) => typeof watchedSessionIds?.has === 'function' && watchedSessionIds.has(id);
|
|
132
|
+
/** One row: fixed 90px watch toggle + the session button filling the rest. */
|
|
133
|
+
const row = (watchButton, sessionButton) => ({
|
|
134
|
+
tag: 'column_set',
|
|
135
|
+
flex_mode: 'none',
|
|
136
|
+
horizontal_spacing: 'default',
|
|
137
|
+
columns: [
|
|
138
|
+
{ tag: 'column', width: '90px', vertical_align: 'center', elements: [watchButton] },
|
|
139
|
+
{ tag: 'column', width: 'weighted', weight: 1, vertical_align: 'center', elements: [sessionButton] },
|
|
140
|
+
],
|
|
141
|
+
});
|
|
111
142
|
const elements = [
|
|
112
143
|
{ tag: 'div', text: markdown(`**工作区**:\`${workspace}\`\n共 **${total}** 个会话${total > MENU_PAGE_SIZE ? `(第 ${page + 1}/${pageCount} 页)` : ''}`) },
|
|
113
|
-
...slice.map((session, offset) =>
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
144
|
+
...slice.map((session, offset) => {
|
|
145
|
+
// Page-local numbering: number replies resolve against this page.
|
|
146
|
+
const label = `${offset + 1}. ${safeTitle(session.title)}${session.archived === true ? '(已归档)' : ''}`;
|
|
147
|
+
const watching = watched(session.sessionId);
|
|
148
|
+
return row(
|
|
149
|
+
buttonElement(watching ? '⭐取关' : '⭐关注', watching ? `unwatch:${session.sessionId}` : `watch:${session.sessionId}`),
|
|
150
|
+
buttonElement(label, `use:${session.sessionId}`),
|
|
151
|
+
);
|
|
152
|
+
}),
|
|
117
153
|
];
|
|
118
154
|
if (page > 0) elements.push(button('◀ 上一页', `sessions:${page - 1}`));
|
|
119
155
|
if (page + 1 < pageCount) elements.push(button('下一页 ▶', `sessions:${page + 1}`));
|
|
120
|
-
elements.push({ tag: 'div', text: markdown('回复数字(1~N
|
|
156
|
+
elements.push({ tag: 'div', text: markdown('回复数字(1~N)绑定本页会话。') });
|
|
121
157
|
return cardWith('📂 会话列表', elements);
|
|
122
158
|
}
|
|
123
159
|
|
|
@@ -146,10 +182,49 @@ export function menuHelpText() {
|
|
|
146
182
|
'4 · /status 连接状态',
|
|
147
183
|
'5 · /help 本帮助',
|
|
148
184
|
'6 · /repair 修复卡片按钮(请回复数字 6)',
|
|
185
|
+
'7 · /watchlist 关注列表',
|
|
149
186
|
'',
|
|
150
187
|
'直接发送文字/图片即继续当前会话。',
|
|
151
188
|
'/session ID 或序号 绑定已有会话',
|
|
189
|
+
'/watch ID 或序号 关注会话(完成后推送)',
|
|
152
190
|
'/compact 压缩上下文',
|
|
153
191
|
'/workspace 绝对路径 切换工作区',
|
|
154
192
|
].join('\n');
|
|
155
193
|
}
|
|
194
|
+
|
|
195
|
+
/** The watch list for one conversation (unwatch buttons + reply fallback). */
|
|
196
|
+
export function watchListCard(entries) {
|
|
197
|
+
const elements = entries.length === 0
|
|
198
|
+
? [{ tag: 'div', text: markdown('当前没有关注的会话。\n`/watch <ID|序号>` 关注后,任务完成会自动推送。') }]
|
|
199
|
+
: [
|
|
200
|
+
{ tag: 'div', text: markdown('任务完成会自动推送,回复数字或点按钮取消关注:') },
|
|
201
|
+
...entries.map((entry, index) => button(
|
|
202
|
+
`${index + 1}. ${safeTitle(entry.title)}`,
|
|
203
|
+
`unwatch:${entry.sessionId}`,
|
|
204
|
+
)),
|
|
205
|
+
];
|
|
206
|
+
return cardWith('👁 关注列表', elements);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The completion push card. `title` is the session title, `reason` the
|
|
211
|
+
* turn-end kind (completed / stopped / aborted).
|
|
212
|
+
*/
|
|
213
|
+
export function completionCard(sessionId, title, reason) {
|
|
214
|
+
const reasonText = reason === 'completed'
|
|
215
|
+
? '已完成'
|
|
216
|
+
: reason === 'stopped'
|
|
217
|
+
? '已停止'
|
|
218
|
+
: reason === 'aborted'
|
|
219
|
+
? '已中止'
|
|
220
|
+
: reason === 'cancelled'
|
|
221
|
+
? '已取消'
|
|
222
|
+
: '已结束';
|
|
223
|
+
return cardWith('✅ 任务完成', [
|
|
224
|
+
{ tag: 'div', text: markdown(`**${safeTitle(title)}**\n\`${sessionId}\``) },
|
|
225
|
+
{ tag: 'div', text: markdown(`**状态**:${reasonText}`) },
|
|
226
|
+
button('打开会话列表', 'sessions'),
|
|
227
|
+
button('工作区', 'workspaces'),
|
|
228
|
+
{ tag: 'div', text: markdown('绑定该会话后可继续追问,输入文字即可。') },
|
|
229
|
+
]);
|
|
230
|
+
}
|
|
@@ -1,7 +1,24 @@
|
|
|
1
1
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
3
|
|
|
4
|
-
const EMPTY_STATE = Object.freeze({
|
|
4
|
+
const EMPTY_STATE = Object.freeze({
|
|
5
|
+
version: 1,
|
|
6
|
+
sessions: {},
|
|
7
|
+
seenMessageIds: [],
|
|
8
|
+
watches: {},
|
|
9
|
+
includeArchivedSessions: false,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
/** One conversation key may watch at most this many sessions. */
|
|
13
|
+
export const MAX_WATCHES_PER_KEY = 20;
|
|
14
|
+
|
|
15
|
+
/** A persisted watch entry: the watched session plus its delivery target. */
|
|
16
|
+
function validWatchEntry(value) {
|
|
17
|
+
return value
|
|
18
|
+
&& typeof value === 'object'
|
|
19
|
+
&& typeof value.sessionId === 'string' && value.sessionId.length > 0
|
|
20
|
+
&& typeof value.chatId === 'string' && value.chatId.length > 0;
|
|
21
|
+
}
|
|
5
22
|
|
|
6
23
|
export class StateStore {
|
|
7
24
|
#path;
|
|
@@ -19,6 +36,10 @@ export class StateStore {
|
|
|
19
36
|
version: 1,
|
|
20
37
|
sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
|
|
21
38
|
seenMessageIds: Array.isArray(parsed.seenMessageIds) ? parsed.seenMessageIds.slice(-1000) : [],
|
|
39
|
+
watches: parsed.watches && typeof parsed.watches === 'object' ? parsed.watches : {},
|
|
40
|
+
includeArchivedSessions: typeof parsed.includeArchivedSessions === 'boolean'
|
|
41
|
+
? parsed.includeArchivedSessions
|
|
42
|
+
: false,
|
|
22
43
|
};
|
|
23
44
|
} catch (error) {
|
|
24
45
|
if (error?.code !== 'ENOENT') throw error;
|
|
@@ -63,6 +84,69 @@ export class StateStore {
|
|
|
63
84
|
return structuredClone(this.#state);
|
|
64
85
|
}
|
|
65
86
|
|
|
87
|
+
// ── Watches (persisted: surviving restarts) ─────────────────────────────
|
|
88
|
+
|
|
89
|
+
watchEntries(key) {
|
|
90
|
+
const list = this.#state.watches[key];
|
|
91
|
+
return Array.isArray(list) ? list.filter(validWatchEntry) : [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
watchEntry(key, sessionId) {
|
|
95
|
+
return this.watchEntries(key).find((entry) => entry.sessionId === sessionId) ?? null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async setWatch(key, entry) {
|
|
99
|
+
const list = this.#state.watches[key] ?? [];
|
|
100
|
+
const index = list.findIndex((existing) => existing.sessionId === entry.sessionId);
|
|
101
|
+
if (index === -1) {
|
|
102
|
+
if (list.length >= MAX_WATCHES_PER_KEY) list.shift();
|
|
103
|
+
list.push(entry);
|
|
104
|
+
} else {
|
|
105
|
+
list[index] = entry;
|
|
106
|
+
}
|
|
107
|
+
this.#state.watches[key] = list;
|
|
108
|
+
await this.#persist();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async removeWatch(key, sessionId) {
|
|
112
|
+
const list = this.#state.watches[key] ?? [];
|
|
113
|
+
this.#state.watches[key] = list.filter((entry) => entry.sessionId !== sessionId);
|
|
114
|
+
await this.#persist();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async clearWatches(key) {
|
|
118
|
+
delete this.#state.watches[key];
|
|
119
|
+
await this.#persist();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Every conversation key currently watching the given session. */
|
|
123
|
+
keysWatching(sessionId) {
|
|
124
|
+
return Object.entries(this.#state.watches)
|
|
125
|
+
.filter(([, list]) => Array.isArray(list) && list.some((entry) => validWatchEntry(entry) && entry.sessionId === sessionId))
|
|
126
|
+
.map(([key]) => key);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Unique watched session ids across all keys (restart compensation). */
|
|
130
|
+
watchedSessionIds() {
|
|
131
|
+
const ids = new Set();
|
|
132
|
+
for (const list of Object.values(this.#state.watches)) {
|
|
133
|
+
if (!Array.isArray(list)) continue;
|
|
134
|
+
for (const entry of list) if (validWatchEntry(entry)) ids.add(entry.sessionId);
|
|
135
|
+
}
|
|
136
|
+
return [...ids];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Session-list archived policy (per bot) ───────────────────
|
|
140
|
+
|
|
141
|
+
includesArchivedSessions() {
|
|
142
|
+
return this.#state.includeArchivedSessions === true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async setIncludeArchivedSessions(include) {
|
|
146
|
+
this.#state.includeArchivedSessions = include === true;
|
|
147
|
+
await this.#persist();
|
|
148
|
+
}
|
|
149
|
+
|
|
66
150
|
async #persist() {
|
|
67
151
|
const snapshot = JSON.stringify(this.#state, null, 2) + '\n';
|
|
68
152
|
this.#writeQueue = this.#writeQueue.then(async () => {
|
|
@@ -1223,6 +1223,125 @@ export class HarnessClient {
|
|
|
1223
1223
|
});
|
|
1224
1224
|
}
|
|
1225
1225
|
|
|
1226
|
+
/**
|
|
1227
|
+
* Watch the global Harness event mux (all sessions) until `signal`
|
|
1228
|
+
* aborts, reconnecting on drop. The Desktop host serves the mux as a
|
|
1229
|
+
* WebSocket downlink; frames are `server-request` envelopes whose payload
|
|
1230
|
+
* is a `session/event` — only those are forwarded. `onReconnect` (when
|
|
1231
|
+
* provided) fires after every (re)connection so callers can compensate
|
|
1232
|
+
* for events missed while offline.
|
|
1233
|
+
*/
|
|
1234
|
+
async watchHarnessEvents({ signal, onSessionEvent, onReconnect } = {}) {
|
|
1235
|
+
if (typeof onSessionEvent !== 'function') {
|
|
1236
|
+
throw new TypeError('watchHarnessEvents requires onSessionEvent');
|
|
1237
|
+
}
|
|
1238
|
+
if (!signal || typeof signal.addEventListener !== 'function') {
|
|
1239
|
+
throw new TypeError('watchHarnessEvents requires an AbortSignal');
|
|
1240
|
+
}
|
|
1241
|
+
if (onReconnect !== undefined && typeof onReconnect !== 'function') {
|
|
1242
|
+
throw new TypeError('onReconnect must be a function');
|
|
1243
|
+
}
|
|
1244
|
+
const url = new URL('/api/events.mux', this.#baseUrl);
|
|
1245
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
1246
|
+
while (!signal.aborted) {
|
|
1247
|
+
try {
|
|
1248
|
+
await this.#watchHarnessEventSocket(url.toString(), {
|
|
1249
|
+
signal,
|
|
1250
|
+
onSessionEvent,
|
|
1251
|
+
onReconnect,
|
|
1252
|
+
});
|
|
1253
|
+
} catch (error) {
|
|
1254
|
+
if (signal.aborted) return;
|
|
1255
|
+
console.warn(`[${this.#logPrefix}] Harness event mux disconnected:`, error.message);
|
|
1256
|
+
}
|
|
1257
|
+
if (signal.aborted) return;
|
|
1258
|
+
try {
|
|
1259
|
+
await sleep(this.#interactionReconnectDelayMs, signal);
|
|
1260
|
+
} catch {
|
|
1261
|
+
if (signal.aborted) return;
|
|
1262
|
+
throw new Error('Harness event mux reconnect wait failed');
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
#watchHarnessEventSocket(url, { signal, onSessionEvent, onReconnect }) {
|
|
1268
|
+
return new Promise((resolve, reject) => {
|
|
1269
|
+
let socket;
|
|
1270
|
+
try {
|
|
1271
|
+
socket = this.#createWebSocket(url);
|
|
1272
|
+
} catch (error) {
|
|
1273
|
+
reject(error);
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
let opened = false;
|
|
1277
|
+
let finished = false;
|
|
1278
|
+
const close = () => {
|
|
1279
|
+
try {
|
|
1280
|
+
socket.close();
|
|
1281
|
+
} catch {
|
|
1282
|
+
// Already closed.
|
|
1283
|
+
}
|
|
1284
|
+
};
|
|
1285
|
+
const finish = (error) => {
|
|
1286
|
+
if (finished) return;
|
|
1287
|
+
finished = true;
|
|
1288
|
+
socket.removeEventListener('open', handleOpen);
|
|
1289
|
+
socket.removeEventListener('message', handleMessage);
|
|
1290
|
+
socket.removeEventListener('close', handleClose);
|
|
1291
|
+
socket.removeEventListener('error', handleError);
|
|
1292
|
+
signal.removeEventListener('abort', handleAbort);
|
|
1293
|
+
if (error) reject(error);
|
|
1294
|
+
else resolve();
|
|
1295
|
+
};
|
|
1296
|
+
const handleOpen = () => {
|
|
1297
|
+
opened = true;
|
|
1298
|
+
try {
|
|
1299
|
+
onReconnect?.();
|
|
1300
|
+
} catch (error) {
|
|
1301
|
+
console.warn(`[${this.#logPrefix}] mux reconnect hook failed:`, error.message);
|
|
1302
|
+
}
|
|
1303
|
+
};
|
|
1304
|
+
const handleMessage = (event) => {
|
|
1305
|
+
try {
|
|
1306
|
+
if (typeof event.data !== 'string') return;
|
|
1307
|
+
const envelope = JSON.parse(event.data);
|
|
1308
|
+
const payload = envelope?.payload;
|
|
1309
|
+
if (envelope?.type !== 'server-request'
|
|
1310
|
+
|| !payload
|
|
1311
|
+
|| typeof payload !== 'object'
|
|
1312
|
+
|| envelope.method !== payload.type
|
|
1313
|
+
|| payload.type !== 'session/event'
|
|
1314
|
+
|| typeof payload.sessionId !== 'string'
|
|
1315
|
+
|| !payload.event
|
|
1316
|
+
|| typeof payload.event !== 'object') return;
|
|
1317
|
+
onSessionEvent({ sessionId: payload.sessionId, event: payload.event });
|
|
1318
|
+
} catch (error) {
|
|
1319
|
+
console.warn(`[${this.#logPrefix}] ignored a malformed global mux frame:`, error.message);
|
|
1320
|
+
}
|
|
1321
|
+
};
|
|
1322
|
+
const handleClose = () => finish(opened ? null : new Error(
|
|
1323
|
+
'Harness event mux WebSocket closed before opening',
|
|
1324
|
+
));
|
|
1325
|
+
const handleError = () => {
|
|
1326
|
+
finish(new Error(opened
|
|
1327
|
+
? 'Harness event mux WebSocket failed'
|
|
1328
|
+
: 'Harness event mux WebSocket failed before opening'));
|
|
1329
|
+
close();
|
|
1330
|
+
};
|
|
1331
|
+
const handleAbort = () => {
|
|
1332
|
+
close();
|
|
1333
|
+
finish();
|
|
1334
|
+
};
|
|
1335
|
+
|
|
1336
|
+
socket.addEventListener('open', handleOpen);
|
|
1337
|
+
socket.addEventListener('message', handleMessage);
|
|
1338
|
+
socket.addEventListener('close', handleClose, { once: true });
|
|
1339
|
+
socket.addEventListener('error', handleError, { once: true });
|
|
1340
|
+
signal.addEventListener('abort', handleAbort, { once: true });
|
|
1341
|
+
if (signal.aborted) handleAbort();
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1226
1345
|
stopManagedProcess() {
|
|
1227
1346
|
if (this.#managedProcess?.exitCode === null) this.#managedProcess.kill('SIGTERM');
|
|
1228
1347
|
}
|
|
@@ -13,6 +13,18 @@ export class ImagePromptError extends Error {
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
const HOST_ATTACHMENT_USER_MESSAGES = Object.freeze({
|
|
17
|
+
MODEL_DOES_NOT_SUPPORT_IMAGES:
|
|
18
|
+
'当前模型不支持图片,请用 /models 查看可用模型,再用 /model <序号> 切换后重发。',
|
|
19
|
+
IMAGE_TOO_LARGE: '图片超过宿主允许的大小,请压缩后重试。',
|
|
20
|
+
IMAGE_TOO_MANY_PIXELS: '图片分辨率过高,请压缩后重试。',
|
|
21
|
+
INVALID_IMAGE: '图片内容无效或格式不受支持,请重新发送。',
|
|
22
|
+
INVALID_IMAGE_BASE64: '未能读取图片内容,请重新发送。',
|
|
23
|
+
IMAGE_TYPE_MISMATCH: '图片格式与实际内容不一致,请重新发送。',
|
|
24
|
+
TOO_MANY_IMAGES: '一次发送的图片数量超过宿主限制,请减少后重试。',
|
|
25
|
+
IMAGES_TOO_LARGE: '图片总大小超过宿主限制,请减少图片或压缩后重试。',
|
|
26
|
+
});
|
|
27
|
+
|
|
16
28
|
function requestSignal(signal, timeoutMs) {
|
|
17
29
|
const timeout = AbortSignal.timeout(timeoutMs);
|
|
18
30
|
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
@@ -263,6 +275,25 @@ export async function promptContentForMessage(message, {
|
|
|
263
275
|
return content;
|
|
264
276
|
}
|
|
265
277
|
|
|
278
|
+
/** Return only allowlisted, user-safe image failure details. */
|
|
279
|
+
export function imagePromptDiagnostic(error) {
|
|
280
|
+
if (error instanceof ImagePromptError) {
|
|
281
|
+
return {
|
|
282
|
+
code: 'image-prompt-error',
|
|
283
|
+
reason: error.code,
|
|
284
|
+
userMessage: error.userMessage,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (error?.code !== 'attachment-error' || typeof error?.details?.reason !== 'string') {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
const reason = error.details.reason;
|
|
291
|
+
const userMessage = Object.hasOwn(HOST_ATTACHMENT_USER_MESSAGES, reason)
|
|
292
|
+
? HOST_ATTACHMENT_USER_MESSAGES[reason]
|
|
293
|
+
: null;
|
|
294
|
+
return userMessage ? { code: 'attachment-error', reason, userMessage } : null;
|
|
295
|
+
}
|
|
296
|
+
|
|
266
297
|
export function imagePromptUserMessage(error) {
|
|
267
|
-
return error
|
|
298
|
+
return imagePromptDiagnostic(error)?.userMessage ?? null;
|
|
268
299
|
}
|
|
@@ -23,12 +23,14 @@ import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
|
|
23
23
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|
24
24
|
import {
|
|
25
25
|
hasInboundImages,
|
|
26
|
+
imagePromptDiagnostic,
|
|
26
27
|
imagePromptUserMessage,
|
|
27
28
|
promptContentForMessage,
|
|
28
29
|
} from '../shared/image-prompt.mjs';
|
|
29
30
|
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
|
30
31
|
|
|
31
32
|
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
|
33
|
+
const GENERIC_PROCESSING_ERROR = '消息处理失败,请稍后重试。';
|
|
32
34
|
|
|
33
35
|
const HELP_TEXT = [
|
|
34
36
|
'微信已连接 DeepSeek Harness。',
|
|
@@ -69,6 +71,16 @@ function canClaimInteractionReply(message, pending) {
|
|
|
69
71
|
&& nonEmptyString(extractWeixinText(message));
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
function safeMessageError(error, userMessage = GENERIC_PROCESSING_ERROR) {
|
|
75
|
+
const diagnostic = imagePromptDiagnostic(error);
|
|
76
|
+
return {
|
|
77
|
+
code: diagnostic?.code ?? 'message-processing-failed',
|
|
78
|
+
reason: diagnostic?.reason ?? 'UNKNOWN',
|
|
79
|
+
message: diagnostic?.userMessage ?? userMessage,
|
|
80
|
+
at: Date.now(),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
72
84
|
export function createWeixinBridgeStatus() {
|
|
73
85
|
return {
|
|
74
86
|
messagesReceived: 0,
|
|
@@ -78,6 +90,7 @@ export function createWeixinBridgeStatus() {
|
|
|
78
90
|
lastReplyAt: null,
|
|
79
91
|
lastRejectedAt: null,
|
|
80
92
|
lastError: null,
|
|
93
|
+
lastMessageError: null,
|
|
81
94
|
};
|
|
82
95
|
}
|
|
83
96
|
|
|
@@ -168,8 +181,9 @@ export class WeixinHarnessBridge {
|
|
|
168
181
|
).catch((error) => {
|
|
169
182
|
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
|
|
170
183
|
this.#status.lastError = error?.message ?? String(error);
|
|
184
|
+
this.#status.lastMessageError = safeMessageError(error);
|
|
171
185
|
this.#logger.error?.('[dsh-weixin] failed to process a command:', error);
|
|
172
|
-
return this.#send(sender,
|
|
186
|
+
return this.#send(sender, GENERIC_PROCESSING_ERROR, contextToken, runId)
|
|
173
187
|
.catch(() => undefined);
|
|
174
188
|
}).finally(() => {
|
|
175
189
|
this.#acceptedMessageIds.delete(messageId);
|
|
@@ -289,6 +303,7 @@ export class WeixinHarnessBridge {
|
|
|
289
303
|
if (reply) await this.#send(sender, reply, contextToken, runId);
|
|
290
304
|
}
|
|
291
305
|
this.#status.lastError = null;
|
|
306
|
+
this.#status.lastMessageError = null;
|
|
292
307
|
}
|
|
293
308
|
|
|
294
309
|
async #process(message, key, { alreadyRecorded = false } = {}) {
|
|
@@ -401,6 +416,7 @@ export class WeixinHarnessBridge {
|
|
|
401
416
|
this.#status.messagesReplied += 1;
|
|
402
417
|
this.#status.lastReplyAt = new Date().toISOString();
|
|
403
418
|
this.#status.lastError = null;
|
|
419
|
+
this.#status.lastMessageError = null;
|
|
404
420
|
} catch (error) {
|
|
405
421
|
if (error?.code === 'turn-stopped') {
|
|
406
422
|
await this.#state.markSeen(messageId);
|
|
@@ -408,11 +424,13 @@ export class WeixinHarnessBridge {
|
|
|
408
424
|
}
|
|
409
425
|
if (this.#signal?.aborted) return;
|
|
410
426
|
this.#status.lastError = error?.message ?? String(error);
|
|
427
|
+
const userMessage = imagePromptUserMessage(error) ?? GENERIC_PROCESSING_ERROR;
|
|
428
|
+
this.#status.lastMessageError = safeMessageError(error, userMessage);
|
|
411
429
|
this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
|
|
412
430
|
try {
|
|
413
431
|
await this.#send(
|
|
414
432
|
sender,
|
|
415
|
-
|
|
433
|
+
userMessage,
|
|
416
434
|
contextToken,
|
|
417
435
|
runId,
|
|
418
436
|
);
|
|
@@ -526,6 +544,7 @@ export class WeixinHarnessBridge {
|
|
|
526
544
|
});
|
|
527
545
|
this.#clearPendingInteraction(key, pending.interactionId);
|
|
528
546
|
this.#status.lastError = null;
|
|
547
|
+
this.#status.lastMessageError = null;
|
|
529
548
|
} catch (error) {
|
|
530
549
|
if (this.#signal?.aborted) return;
|
|
531
550
|
if (error?.code === 'interaction-not-pending') {
|
|
@@ -719,13 +738,14 @@ export class WeixinHarnessBridge {
|
|
|
719
738
|
async #handleInteractionFailure(message, messageId, error) {
|
|
720
739
|
if (this.#signal?.aborted) return;
|
|
721
740
|
this.#status.lastError = error?.message ?? String(error);
|
|
741
|
+
this.#status.lastMessageError = safeMessageError(error);
|
|
722
742
|
this.#logger.error?.('[dsh-weixin] failed to process an interaction reply:', error);
|
|
723
743
|
if (!this.#state.hasSeen(messageId)) {
|
|
724
744
|
await this.#state.markSeen(messageId).catch(() => undefined);
|
|
725
745
|
}
|
|
726
746
|
await this.#send(
|
|
727
747
|
nonEmptyString(message?.from_user_id),
|
|
728
|
-
|
|
748
|
+
GENERIC_PROCESSING_ERROR,
|
|
729
749
|
nonEmptyString(message?.context_token) ?? undefined,
|
|
730
750
|
nonEmptyString(message?.run_id) ?? undefined,
|
|
731
751
|
).catch(() => undefined);
|
|
@@ -62,6 +62,20 @@ function safeAccountError(code, message) {
|
|
|
62
62
|
return Object.freeze({ code, message });
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
function publicMessageError(value) {
|
|
66
|
+
if (!value || typeof value !== 'object'
|
|
67
|
+
|| typeof value.code !== 'string' || !value.code
|
|
68
|
+
|| typeof value.reason !== 'string' || !value.reason
|
|
69
|
+
|| typeof value.message !== 'string' || !value.message
|
|
70
|
+
|| !Number.isFinite(value.at)) return null;
|
|
71
|
+
return {
|
|
72
|
+
code: value.code.slice(0, 64),
|
|
73
|
+
reason: value.reason.slice(0, 128),
|
|
74
|
+
message: value.message.slice(0, 500),
|
|
75
|
+
at: value.at,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
65
79
|
function activationStageError(code, cause) {
|
|
66
80
|
const error = new Error(`Weixin activation failed during ${code}`, { cause });
|
|
67
81
|
error.name = 'WeixinActivationStageError';
|
|
@@ -351,6 +365,7 @@ export class WeixinController {
|
|
|
351
365
|
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
|
352
366
|
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
|
353
367
|
},
|
|
368
|
+
lastMessageError: publicMessageError(runtimeStatus?.lastMessageError),
|
|
354
369
|
error: error ? structuredClone(error) : null,
|
|
355
370
|
};
|
|
356
371
|
});
|