@xmanrui/dsh-im 0.16.0 → 0.17.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/lib/index.js +132 -131
- package/package.json +1 -1
- 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/weixin/weixin-api.mjs +1 -1
|
@@ -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
|
}
|