opencode-copilot-account-switcher 0.13.6 → 0.14.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/dist/common-settings-actions.d.ts +1 -1
- package/dist/common-settings-actions.js +36 -0
- package/dist/common-settings-store.d.ts +4 -0
- package/dist/common-settings-store.js +26 -0
- package/dist/menu-runtime.js +12 -1
- package/dist/plugin-hooks.d.ts +8 -0
- package/dist/plugin-hooks.js +96 -0
- package/dist/providers/codex-menu-adapter.js +8 -1
- package/dist/providers/copilot-menu-adapter.js +7 -0
- package/dist/store-paths.d.ts +1 -0
- package/dist/store-paths.js +3 -0
- package/dist/ui/menu.d.ts +33 -0
- package/dist/ui/menu.js +85 -0
- package/dist/wechat/bridge.d.ts +69 -0
- package/dist/wechat/bridge.js +180 -0
- package/dist/wechat/broker-client.d.ts +33 -0
- package/dist/wechat/broker-client.js +257 -0
- package/dist/wechat/broker-entry.d.ts +17 -0
- package/dist/wechat/broker-entry.js +182 -0
- package/dist/wechat/broker-launcher.d.ts +27 -0
- package/dist/wechat/broker-launcher.js +191 -0
- package/dist/wechat/broker-server.d.ts +25 -0
- package/dist/wechat/broker-server.js +540 -0
- package/dist/wechat/command-parser.d.ts +7 -0
- package/dist/wechat/command-parser.js +16 -0
- package/dist/wechat/compat/openclaw-guided-smoke.d.ts +178 -0
- package/dist/wechat/compat/openclaw-guided-smoke.js +1133 -0
- package/dist/wechat/compat/openclaw-public-helpers.d.ts +101 -0
- package/dist/wechat/compat/openclaw-public-helpers.js +207 -0
- package/dist/wechat/compat/openclaw-smoke.d.ts +48 -0
- package/dist/wechat/compat/openclaw-smoke.js +100 -0
- package/dist/wechat/compat/slash-guard.d.ts +11 -0
- package/dist/wechat/compat/slash-guard.js +24 -0
- package/dist/wechat/handle.d.ts +8 -0
- package/dist/wechat/handle.js +46 -0
- package/dist/wechat/ipc-auth.d.ts +6 -0
- package/dist/wechat/ipc-auth.js +39 -0
- package/dist/wechat/operator-store.d.ts +8 -0
- package/dist/wechat/operator-store.js +59 -0
- package/dist/wechat/protocol.d.ts +29 -0
- package/dist/wechat/protocol.js +75 -0
- package/dist/wechat/request-store.d.ts +41 -0
- package/dist/wechat/request-store.js +215 -0
- package/dist/wechat/session-digest.d.ts +41 -0
- package/dist/wechat/session-digest.js +134 -0
- package/dist/wechat/state-paths.d.ts +14 -0
- package/dist/wechat/state-paths.js +45 -0
- package/dist/wechat/status-format.d.ts +14 -0
- package/dist/wechat/status-format.js +174 -0
- package/dist/wechat/token-store.d.ts +18 -0
- package/dist/wechat/token-store.js +100 -0
- package/dist/wechat/wechat-status-runtime.d.ts +24 -0
- package/dist/wechat/wechat-status-runtime.js +238 -0
- package/package.json +8 -3
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { chmod, mkdir, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { registerConnection, revokeSessionToken, validateSessionToken } from "./ipc-auth.js";
|
|
5
|
+
import { createErrorEnvelope, parseEnvelopeLine, serializeEnvelope, } from "./protocol.js";
|
|
6
|
+
import { WECHAT_DIR_MODE, WECHAT_FILE_MODE, instanceStatePath, instancesDir } from "./state-paths.js";
|
|
7
|
+
import { formatAggregatedStatusReply } from "./status-format.js";
|
|
8
|
+
const FUTURE_MESSAGE_TYPES = new Set([
|
|
9
|
+
"collectStatus",
|
|
10
|
+
"replyQuestion",
|
|
11
|
+
"rejectQuestion",
|
|
12
|
+
"replyPermission",
|
|
13
|
+
"showFallbackToast",
|
|
14
|
+
]);
|
|
15
|
+
export const DEFAULT_HEARTBEAT_TIMEOUT_MS = 30_000;
|
|
16
|
+
const DEFAULT_HEARTBEAT_SCAN_INTERVAL_MS = 1_000;
|
|
17
|
+
export const DEFAULT_STATUS_COLLECT_WINDOW_MS = 1_500;
|
|
18
|
+
function isNonEmptyString(value) {
|
|
19
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
20
|
+
}
|
|
21
|
+
function getRequestId(envelope) {
|
|
22
|
+
return envelope.id;
|
|
23
|
+
}
|
|
24
|
+
function writeEnvelope(socket, envelope) {
|
|
25
|
+
socket.write(serializeEnvelope(envelope));
|
|
26
|
+
}
|
|
27
|
+
function writeError(socket, code, message, requestId) {
|
|
28
|
+
writeEnvelope(socket, createErrorEnvelope(code, message, requestId));
|
|
29
|
+
}
|
|
30
|
+
function requireAuthorized(envelope) {
|
|
31
|
+
const instanceID = envelope.instanceID;
|
|
32
|
+
const sessionToken = envelope.sessionToken;
|
|
33
|
+
if (!isNonEmptyString(instanceID) || !isNonEmptyString(sessionToken)) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return validateSessionToken(instanceID, sessionToken);
|
|
37
|
+
}
|
|
38
|
+
const registrationByInstanceID = new Map();
|
|
39
|
+
const instanceIDsBySocket = new Map();
|
|
40
|
+
const snapshotByInstanceID = new Map();
|
|
41
|
+
const snapshotPersistQueueByInstanceID = new Map();
|
|
42
|
+
const pendingCollectStatusByRequestId = new Map();
|
|
43
|
+
function clearRuntimeState() {
|
|
44
|
+
for (const instanceID of registrationByInstanceID.keys()) {
|
|
45
|
+
revokeSessionToken(instanceID);
|
|
46
|
+
}
|
|
47
|
+
registrationByInstanceID.clear();
|
|
48
|
+
instanceIDsBySocket.clear();
|
|
49
|
+
snapshotByInstanceID.clear();
|
|
50
|
+
snapshotPersistQueueByInstanceID.clear();
|
|
51
|
+
pendingCollectStatusByRequestId.clear();
|
|
52
|
+
}
|
|
53
|
+
function toPositiveNumber(rawValue, fallback) {
|
|
54
|
+
if (!isNonEmptyString(rawValue)) {
|
|
55
|
+
return fallback;
|
|
56
|
+
}
|
|
57
|
+
const parsed = Number(rawValue);
|
|
58
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
59
|
+
return fallback;
|
|
60
|
+
}
|
|
61
|
+
return parsed;
|
|
62
|
+
}
|
|
63
|
+
function asObject(value) {
|
|
64
|
+
if (typeof value !== "object" || value === null) {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
function isFiniteNumber(value) {
|
|
70
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
71
|
+
}
|
|
72
|
+
function hasCollectStatusPayload(payload) {
|
|
73
|
+
return asObject(payload).requestId !== undefined && isNonEmptyString(asObject(payload).requestId);
|
|
74
|
+
}
|
|
75
|
+
function hasStatusSnapshotPayload(payload) {
|
|
76
|
+
const record = asObject(payload);
|
|
77
|
+
return isNonEmptyString(record.requestId) && "snapshot" in record;
|
|
78
|
+
}
|
|
79
|
+
function isSafeInstanceID(instanceID) {
|
|
80
|
+
if (!isNonEmptyString(instanceID)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
if (instanceID.includes("/") || instanceID.includes("\\")) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
if (instanceID.includes("..")) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
function makeConnectedSnapshot(instanceID, payload, now) {
|
|
92
|
+
const record = asObject(payload);
|
|
93
|
+
return {
|
|
94
|
+
instanceID,
|
|
95
|
+
pid: isFiniteNumber(record.pid) ? record.pid : process.pid,
|
|
96
|
+
displayName: isNonEmptyString(record.displayName) ? record.displayName : "",
|
|
97
|
+
projectDir: isNonEmptyString(record.projectDir) ? record.projectDir : "",
|
|
98
|
+
connectedAt: now,
|
|
99
|
+
lastHeartbeatAt: now,
|
|
100
|
+
status: "connected",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function serializeSnapshot(snapshot) {
|
|
104
|
+
if (snapshot.status === "stale") {
|
|
105
|
+
return {
|
|
106
|
+
instanceID: snapshot.instanceID,
|
|
107
|
+
pid: snapshot.pid,
|
|
108
|
+
displayName: snapshot.displayName,
|
|
109
|
+
projectDir: snapshot.projectDir,
|
|
110
|
+
connectedAt: snapshot.connectedAt,
|
|
111
|
+
lastHeartbeatAt: snapshot.lastHeartbeatAt,
|
|
112
|
+
status: snapshot.status,
|
|
113
|
+
staleSince: snapshot.staleSince,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
instanceID: snapshot.instanceID,
|
|
118
|
+
pid: snapshot.pid,
|
|
119
|
+
displayName: snapshot.displayName,
|
|
120
|
+
projectDir: snapshot.projectDir,
|
|
121
|
+
connectedAt: snapshot.connectedAt,
|
|
122
|
+
lastHeartbeatAt: snapshot.lastHeartbeatAt,
|
|
123
|
+
status: snapshot.status,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
async function persistInstanceSnapshot(snapshot) {
|
|
127
|
+
await mkdir(instancesDir(), { recursive: true, mode: WECHAT_DIR_MODE });
|
|
128
|
+
await writeFile(instanceStatePath(snapshot.instanceID), JSON.stringify(serializeSnapshot(snapshot), null, 2), {
|
|
129
|
+
mode: WECHAT_FILE_MODE,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
function queuePersistSnapshot(snapshot) {
|
|
133
|
+
const currentChain = snapshotPersistQueueByInstanceID.get(snapshot.instanceID) ?? Promise.resolve();
|
|
134
|
+
const nextWrite = currentChain.then(() => persistInstanceSnapshot(snapshot));
|
|
135
|
+
const queueTail = nextWrite.catch(() => { });
|
|
136
|
+
snapshotPersistQueueByInstanceID.set(snapshot.instanceID, queueTail);
|
|
137
|
+
return nextWrite;
|
|
138
|
+
}
|
|
139
|
+
async function upsertConnectedSnapshot(instanceID, payload, now) {
|
|
140
|
+
const next = makeConnectedSnapshot(instanceID, payload, now);
|
|
141
|
+
snapshotByInstanceID.set(instanceID, next);
|
|
142
|
+
await queuePersistSnapshot(next);
|
|
143
|
+
return next;
|
|
144
|
+
}
|
|
145
|
+
async function recoverSnapshotFromHeartbeat(instanceID, now) {
|
|
146
|
+
const current = snapshotByInstanceID.get(instanceID);
|
|
147
|
+
if (!current) {
|
|
148
|
+
const fallback = {
|
|
149
|
+
instanceID,
|
|
150
|
+
pid: process.pid,
|
|
151
|
+
displayName: "",
|
|
152
|
+
projectDir: "",
|
|
153
|
+
connectedAt: now,
|
|
154
|
+
lastHeartbeatAt: now,
|
|
155
|
+
status: "connected",
|
|
156
|
+
};
|
|
157
|
+
snapshotByInstanceID.set(instanceID, fallback);
|
|
158
|
+
await queuePersistSnapshot(fallback);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const next = {
|
|
162
|
+
instanceID: current.instanceID,
|
|
163
|
+
pid: current.pid,
|
|
164
|
+
displayName: current.displayName,
|
|
165
|
+
projectDir: current.projectDir,
|
|
166
|
+
connectedAt: current.connectedAt,
|
|
167
|
+
lastHeartbeatAt: now,
|
|
168
|
+
status: "connected",
|
|
169
|
+
};
|
|
170
|
+
snapshotByInstanceID.set(instanceID, next);
|
|
171
|
+
await queuePersistSnapshot(next);
|
|
172
|
+
}
|
|
173
|
+
async function markStaleSnapshots(now, heartbeatTimeoutMs) {
|
|
174
|
+
for (const [instanceID, snapshot] of snapshotByInstanceID.entries()) {
|
|
175
|
+
if (snapshot.status !== "connected") {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (now - snapshot.lastHeartbeatAt < heartbeatTimeoutMs) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const staleSnapshot = {
|
|
182
|
+
instanceID: snapshot.instanceID,
|
|
183
|
+
pid: snapshot.pid,
|
|
184
|
+
displayName: snapshot.displayName,
|
|
185
|
+
projectDir: snapshot.projectDir,
|
|
186
|
+
connectedAt: snapshot.connectedAt,
|
|
187
|
+
lastHeartbeatAt: snapshot.lastHeartbeatAt,
|
|
188
|
+
status: "stale",
|
|
189
|
+
staleSince: now,
|
|
190
|
+
};
|
|
191
|
+
snapshotByInstanceID.set(instanceID, staleSnapshot);
|
|
192
|
+
await queuePersistSnapshot(staleSnapshot);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function bindSocketInstance(socket, instanceID) {
|
|
196
|
+
const set = instanceIDsBySocket.get(socket) ?? new Set();
|
|
197
|
+
set.add(instanceID);
|
|
198
|
+
instanceIDsBySocket.set(socket, set);
|
|
199
|
+
}
|
|
200
|
+
function unbindSocketInstance(socket, instanceID) {
|
|
201
|
+
const set = instanceIDsBySocket.get(socket);
|
|
202
|
+
if (!set) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
set.delete(instanceID);
|
|
206
|
+
if (set.size === 0) {
|
|
207
|
+
instanceIDsBySocket.delete(socket);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function cleanupSocketRegistrations(socket) {
|
|
211
|
+
const set = instanceIDsBySocket.get(socket);
|
|
212
|
+
if (!set) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
for (const instanceID of set) {
|
|
216
|
+
const current = registrationByInstanceID.get(instanceID);
|
|
217
|
+
if (current?.socket === socket) {
|
|
218
|
+
registrationByInstanceID.delete(instanceID);
|
|
219
|
+
revokeSessionToken(instanceID);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
instanceIDsBySocket.delete(socket);
|
|
223
|
+
}
|
|
224
|
+
function finalizePendingCollectStatus(requestId) {
|
|
225
|
+
const pending = pendingCollectStatusByRequestId.get(requestId);
|
|
226
|
+
if (!pending) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
clearTimeout(pending.timer);
|
|
230
|
+
pendingCollectStatusByRequestId.delete(requestId);
|
|
231
|
+
const instances = [];
|
|
232
|
+
for (const instanceID of pending.requestedInstanceIDs) {
|
|
233
|
+
if (pending.snapshotsByInstanceID.has(instanceID)) {
|
|
234
|
+
instances.push({
|
|
235
|
+
instanceID,
|
|
236
|
+
status: "ok",
|
|
237
|
+
snapshot: pending.snapshotsByInstanceID.get(instanceID),
|
|
238
|
+
});
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
instances.push({
|
|
242
|
+
instanceID,
|
|
243
|
+
status: "timeout/unreachable",
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
pending.resolve({
|
|
247
|
+
requestId,
|
|
248
|
+
instances,
|
|
249
|
+
reply: formatAggregatedStatusReply({
|
|
250
|
+
requestId,
|
|
251
|
+
instances,
|
|
252
|
+
}),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
async function handleMessage(envelope, socket) {
|
|
256
|
+
const requestId = getRequestId(envelope);
|
|
257
|
+
if (envelope.type === "ping") {
|
|
258
|
+
writeEnvelope(socket, {
|
|
259
|
+
id: `pong-${requestId}`,
|
|
260
|
+
type: "pong",
|
|
261
|
+
payload: { message: "pong" },
|
|
262
|
+
});
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (envelope.type === "registerInstance") {
|
|
266
|
+
if (!isSafeInstanceID(envelope.instanceID ?? "")) {
|
|
267
|
+
writeError(socket, "invalidMessage", "instanceID is required", requestId);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const instanceID = envelope.instanceID;
|
|
271
|
+
const existing = registrationByInstanceID.get(instanceID);
|
|
272
|
+
if (existing && existing.socket === socket) {
|
|
273
|
+
try {
|
|
274
|
+
await upsertConnectedSnapshot(instanceID, envelope.payload, Date.now());
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
writeError(socket, "brokerUnavailable", "failed to persist instance snapshot", requestId);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
writeEnvelope(socket, {
|
|
281
|
+
id: `registerAck-${requestId}`,
|
|
282
|
+
type: "registerAck",
|
|
283
|
+
instanceID,
|
|
284
|
+
payload: {
|
|
285
|
+
sessionToken: existing.sessionToken,
|
|
286
|
+
registeredAt: existing.registeredAt,
|
|
287
|
+
brokerPid: existing.brokerPid,
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const registeredAt = Date.now();
|
|
293
|
+
try {
|
|
294
|
+
await upsertConnectedSnapshot(instanceID, envelope.payload, registeredAt);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
writeError(socket, "brokerUnavailable", "failed to persist instance snapshot", requestId);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const sessionToken = registerConnection(instanceID, { socket });
|
|
301
|
+
const nextRecord = {
|
|
302
|
+
socket,
|
|
303
|
+
sessionToken,
|
|
304
|
+
registeredAt,
|
|
305
|
+
brokerPid: process.pid,
|
|
306
|
+
};
|
|
307
|
+
registrationByInstanceID.set(instanceID, nextRecord);
|
|
308
|
+
bindSocketInstance(socket, instanceID);
|
|
309
|
+
if (existing && existing.socket !== socket) {
|
|
310
|
+
unbindSocketInstance(existing.socket, instanceID);
|
|
311
|
+
}
|
|
312
|
+
writeEnvelope(socket, {
|
|
313
|
+
id: `registerAck-${requestId}`,
|
|
314
|
+
type: "registerAck",
|
|
315
|
+
instanceID,
|
|
316
|
+
payload: {
|
|
317
|
+
sessionToken,
|
|
318
|
+
registeredAt: nextRecord.registeredAt,
|
|
319
|
+
brokerPid: nextRecord.brokerPid,
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (envelope.type === "heartbeat") {
|
|
325
|
+
if (!requireAuthorized(envelope)) {
|
|
326
|
+
writeError(socket, "unauthorized", "session token is invalid", requestId);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
await recoverSnapshotFromHeartbeat(envelope.instanceID, Date.now());
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
writeError(socket, "brokerUnavailable", "failed to persist instance snapshot", requestId);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
writeEnvelope(socket, {
|
|
337
|
+
id: `pong-${requestId}`,
|
|
338
|
+
type: "pong",
|
|
339
|
+
payload: { message: "pong" },
|
|
340
|
+
});
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (envelope.type === "statusSnapshot") {
|
|
344
|
+
if (!requireAuthorized(envelope)) {
|
|
345
|
+
writeError(socket, "unauthorized", "session token is invalid", requestId);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const payload = envelope.payload;
|
|
349
|
+
if (!hasStatusSnapshotPayload(payload)) {
|
|
350
|
+
writeError(socket, "invalidMessage", "statusSnapshot payload is invalid", requestId);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const pending = pendingCollectStatusByRequestId.get(payload.requestId);
|
|
354
|
+
if (!pending) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const sourceInstanceID = envelope.instanceID;
|
|
358
|
+
if (!isNonEmptyString(sourceInstanceID)) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (!pending.requestedInstanceIDs.has(sourceInstanceID)) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
pending.snapshotsByInstanceID.set(sourceInstanceID, payload.snapshot);
|
|
365
|
+
if (pending.snapshotsByInstanceID.size >= pending.requestedInstanceIDs.size) {
|
|
366
|
+
finalizePendingCollectStatus(payload.requestId);
|
|
367
|
+
}
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (FUTURE_MESSAGE_TYPES.has(envelope.type)) {
|
|
371
|
+
if (!requireAuthorized(envelope)) {
|
|
372
|
+
writeError(socket, "unauthorized", "session token is invalid", requestId);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
writeError(socket, "notImplemented", "future message is not implemented", requestId);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
writeError(socket, "notImplemented", `${envelope.type} is not implemented`, requestId);
|
|
379
|
+
}
|
|
380
|
+
async function tightenEndpointPermission(endpoint) {
|
|
381
|
+
if (process.platform === "win32") {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
await chmod(endpoint, WECHAT_FILE_MODE);
|
|
385
|
+
const info = await stat(endpoint);
|
|
386
|
+
if ((info.mode & 0o777) !== WECHAT_FILE_MODE) {
|
|
387
|
+
throw new Error("failed to enforce broker endpoint permission");
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
async function ensureCurrentUserCanAccess(endpoint) {
|
|
391
|
+
await new Promise((resolve, reject) => {
|
|
392
|
+
const probe = net.createConnection(endpoint);
|
|
393
|
+
probe.once("connect", () => {
|
|
394
|
+
probe.end();
|
|
395
|
+
resolve();
|
|
396
|
+
});
|
|
397
|
+
probe.once("error", reject);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
async function prepareEndpoint(endpoint) {
|
|
401
|
+
if (process.platform === "win32") {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
await mkdir(path.dirname(endpoint), { recursive: true, mode: WECHAT_DIR_MODE });
|
|
405
|
+
await rm(endpoint, { force: true });
|
|
406
|
+
}
|
|
407
|
+
export async function startBrokerServer(endpoint) {
|
|
408
|
+
await prepareEndpoint(endpoint);
|
|
409
|
+
const heartbeatTimeoutMs = toPositiveNumber(process.env.WECHAT_BROKER_HEARTBEAT_TIMEOUT_MS, DEFAULT_HEARTBEAT_TIMEOUT_MS);
|
|
410
|
+
const heartbeatScanIntervalMs = toPositiveNumber(process.env.WECHAT_BROKER_HEARTBEAT_SCAN_INTERVAL_MS, DEFAULT_HEARTBEAT_SCAN_INTERVAL_MS);
|
|
411
|
+
const server = net.createServer((socket) => {
|
|
412
|
+
let buffer = "";
|
|
413
|
+
let messageChain = Promise.resolve();
|
|
414
|
+
socket.on("close", () => {
|
|
415
|
+
cleanupSocketRegistrations(socket);
|
|
416
|
+
});
|
|
417
|
+
socket.on("error", () => {
|
|
418
|
+
cleanupSocketRegistrations(socket);
|
|
419
|
+
});
|
|
420
|
+
socket.on("data", (chunk) => {
|
|
421
|
+
buffer += chunk.toString("utf8");
|
|
422
|
+
while (true) {
|
|
423
|
+
const newlineIndex = buffer.indexOf("\n");
|
|
424
|
+
if (newlineIndex === -1) {
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
const line = buffer.slice(0, newlineIndex);
|
|
428
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
429
|
+
try {
|
|
430
|
+
const envelope = parseEnvelopeLine(`${line}\n`);
|
|
431
|
+
messageChain = messageChain.then(() => handleMessage(envelope, socket)).catch(() => {
|
|
432
|
+
// errors are converted to response envelopes in handleMessage
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
writeError(socket, "invalidMessage", "invalid message line", "unknown");
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
await new Promise((resolve, reject) => {
|
|
442
|
+
server.once("error", reject);
|
|
443
|
+
server.listen(endpoint, () => {
|
|
444
|
+
server.off("error", reject);
|
|
445
|
+
resolve();
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
try {
|
|
449
|
+
await tightenEndpointPermission(endpoint);
|
|
450
|
+
await ensureCurrentUserCanAccess(endpoint);
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
await new Promise((resolve) => {
|
|
454
|
+
server.close(() => resolve());
|
|
455
|
+
});
|
|
456
|
+
throw error;
|
|
457
|
+
}
|
|
458
|
+
const staleScanTimer = setInterval(() => {
|
|
459
|
+
void markStaleSnapshots(Date.now(), heartbeatTimeoutMs).catch((error) => {
|
|
460
|
+
console.error("[wechat-broker] failed to persist stale snapshot", error);
|
|
461
|
+
});
|
|
462
|
+
}, heartbeatScanIntervalMs);
|
|
463
|
+
let closed = false;
|
|
464
|
+
const collectStatus = async () => {
|
|
465
|
+
const requestId = `collect-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
466
|
+
const requestedInstanceIDs = new Set();
|
|
467
|
+
for (const [instanceID, record] of registrationByInstanceID.entries()) {
|
|
468
|
+
if (record.socket.destroyed) {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
requestedInstanceIDs.add(instanceID);
|
|
472
|
+
writeEnvelope(record.socket, {
|
|
473
|
+
id: `collectStatus-${requestId}-${instanceID}`,
|
|
474
|
+
type: "collectStatus",
|
|
475
|
+
instanceID,
|
|
476
|
+
sessionToken: record.sessionToken,
|
|
477
|
+
payload: {
|
|
478
|
+
requestId,
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
if (requestedInstanceIDs.size === 0) {
|
|
483
|
+
return {
|
|
484
|
+
requestId,
|
|
485
|
+
instances: [],
|
|
486
|
+
reply: formatAggregatedStatusReply({
|
|
487
|
+
requestId,
|
|
488
|
+
instances: [],
|
|
489
|
+
}),
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
return new Promise((resolve) => {
|
|
493
|
+
const timer = setTimeout(() => {
|
|
494
|
+
finalizePendingCollectStatus(requestId);
|
|
495
|
+
}, DEFAULT_STATUS_COLLECT_WINDOW_MS);
|
|
496
|
+
pendingCollectStatusByRequestId.set(requestId, {
|
|
497
|
+
requestedInstanceIDs,
|
|
498
|
+
snapshotsByInstanceID: new Map(),
|
|
499
|
+
resolve,
|
|
500
|
+
timer,
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
};
|
|
504
|
+
const handleWechatSlashCommand = async (command) => {
|
|
505
|
+
if (command.type === "status") {
|
|
506
|
+
const result = await collectStatus();
|
|
507
|
+
return result.reply;
|
|
508
|
+
}
|
|
509
|
+
return `命令暂未实现:/${command.command}`;
|
|
510
|
+
};
|
|
511
|
+
const close = async () => {
|
|
512
|
+
if (closed) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
closed = true;
|
|
516
|
+
clearInterval(staleScanTimer);
|
|
517
|
+
for (const requestId of pendingCollectStatusByRequestId.keys()) {
|
|
518
|
+
finalizePendingCollectStatus(requestId);
|
|
519
|
+
}
|
|
520
|
+
for (const record of registrationByInstanceID.values()) {
|
|
521
|
+
if (!record.socket.destroyed) {
|
|
522
|
+
record.socket.destroy();
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
await new Promise((resolve) => {
|
|
526
|
+
server.close(() => resolve());
|
|
527
|
+
});
|
|
528
|
+
if (process.platform !== "win32") {
|
|
529
|
+
await rm(endpoint, { force: true });
|
|
530
|
+
}
|
|
531
|
+
clearRuntimeState();
|
|
532
|
+
};
|
|
533
|
+
return {
|
|
534
|
+
endpoint,
|
|
535
|
+
startedAt: Date.now(),
|
|
536
|
+
collectStatus,
|
|
537
|
+
handleWechatSlashCommand,
|
|
538
|
+
close,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function parseWechatSlashCommand(input) {
|
|
2
|
+
if (typeof input !== "string") {
|
|
3
|
+
return null;
|
|
4
|
+
}
|
|
5
|
+
const normalized = input.trim();
|
|
6
|
+
if (normalized === "/status") {
|
|
7
|
+
return { type: "status" };
|
|
8
|
+
}
|
|
9
|
+
if (normalized.startsWith("/")) {
|
|
10
|
+
const command = normalized.slice(1).split(/\s+/, 1)[0];
|
|
11
|
+
if (command === "reply" || command === "allow") {
|
|
12
|
+
return { type: "unimplemented", command };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|