opencode-copilot-account-switcher 0.14.29 → 0.14.30
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-store.d.ts +13 -0
- package/dist/common-settings-store.js +14 -0
- package/dist/wechat/bridge.d.ts +2 -0
- package/dist/wechat/bridge.js +122 -8
- package/dist/wechat/broker-client.js +33 -1
- package/dist/wechat/broker-entry.d.ts +9 -18
- package/dist/wechat/broker-entry.js +104 -18
- package/dist/wechat/broker-server.js +106 -0
- package/dist/wechat/command-parser.d.ts +2 -0
- package/dist/wechat/command-parser.js +21 -10
- package/dist/wechat/handle.d.ts +1 -0
- package/dist/wechat/handle.js +6 -1
- package/dist/wechat/notification-dispatcher.d.ts +12 -0
- package/dist/wechat/notification-dispatcher.js +143 -0
- package/dist/wechat/notification-format.d.ts +2 -0
- package/dist/wechat/notification-format.js +17 -0
- package/dist/wechat/notification-store.d.ts +25 -0
- package/dist/wechat/notification-store.js +290 -0
- package/dist/wechat/notification-types.d.ts +17 -0
- package/dist/wechat/notification-types.js +1 -0
- package/dist/wechat/protocol.d.ts +16 -1
- package/dist/wechat/protocol.js +1 -0
- package/dist/wechat/request-store.d.ts +15 -0
- package/dist/wechat/request-store.js +71 -1
- package/dist/wechat/state-paths.d.ts +2 -0
- package/dist/wechat/state-paths.js +7 -0
- package/dist/wechat/wechat-status-runtime.d.ts +9 -0
- package/dist/wechat/wechat-status-runtime.js +27 -0
- package/package.json +1 -1
|
@@ -30,6 +30,15 @@ export type WechatMenuSettings = {
|
|
|
30
30
|
accounts?: WechatBinding[];
|
|
31
31
|
};
|
|
32
32
|
};
|
|
33
|
+
export type WechatNotificationDispatchSettings = {
|
|
34
|
+
targetUserId?: string;
|
|
35
|
+
notifications: {
|
|
36
|
+
enabled: boolean;
|
|
37
|
+
question: boolean;
|
|
38
|
+
permission: boolean;
|
|
39
|
+
sessionError: boolean;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
33
42
|
export declare function parseCommonSettingsStore(raw: string): CommonSettingsStore;
|
|
34
43
|
export declare function commonSettingsPath(): string;
|
|
35
44
|
export declare function readCommonSettingsStore(options?: {
|
|
@@ -43,3 +52,7 @@ export declare function readCommonSettingsStoreSync(options?: {
|
|
|
43
52
|
export declare function writeCommonSettingsStore(store: CommonSettingsStore, options?: {
|
|
44
53
|
filePath?: string;
|
|
45
54
|
}): Promise<void>;
|
|
55
|
+
export declare function readWechatNotificationDispatchSettings(options?: {
|
|
56
|
+
filePath?: string;
|
|
57
|
+
legacyCopilotFilePath?: string;
|
|
58
|
+
}): Promise<WechatNotificationDispatchSettings>;
|
|
@@ -201,3 +201,17 @@ export async function writeCommonSettingsStore(store, options) {
|
|
|
201
201
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
202
202
|
await fs.writeFile(file, JSON.stringify(persisted, null, 2), { mode: 0o600 });
|
|
203
203
|
}
|
|
204
|
+
export async function readWechatNotificationDispatchSettings(options) {
|
|
205
|
+
const settings = await readCommonSettingsStore(options);
|
|
206
|
+
return {
|
|
207
|
+
...(typeof settings.wechat?.primaryBinding?.userId === "string"
|
|
208
|
+
? { targetUserId: settings.wechat.primaryBinding.userId }
|
|
209
|
+
: {}),
|
|
210
|
+
notifications: settings.wechat?.notifications ?? {
|
|
211
|
+
enabled: true,
|
|
212
|
+
question: true,
|
|
213
|
+
permission: true,
|
|
214
|
+
sessionError: true,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
package/dist/wechat/bridge.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Message, Part, PermissionRequest, QuestionRequest, Session, Sessio
|
|
|
2
2
|
import { connect } from "./broker-client.js";
|
|
3
3
|
import { connectOrSpawnBroker } from "./broker-launcher.js";
|
|
4
4
|
import { type SessionDigest } from "./session-digest.js";
|
|
5
|
+
import type { WechatNotificationCandidate } from "./protocol.js";
|
|
5
6
|
type SessionMessages = Array<{
|
|
6
7
|
info: Message;
|
|
7
8
|
parts: Part[];
|
|
@@ -57,6 +58,7 @@ export type WechatBridgeInput = {
|
|
|
57
58
|
};
|
|
58
59
|
export type WechatBridge = {
|
|
59
60
|
collectStatusSnapshot: () => Promise<WechatInstanceStatusSnapshot>;
|
|
61
|
+
collectNotificationCandidates: () => Promise<WechatNotificationCandidate[]>;
|
|
60
62
|
};
|
|
61
63
|
export type WechatBridgeLifecycleInput = {
|
|
62
64
|
client: WechatBridgeClient;
|
package/dist/wechat/bridge.js
CHANGED
|
@@ -5,6 +5,8 @@ import { connect } from "./broker-client.js";
|
|
|
5
5
|
import { connectOrSpawnBroker } from "./broker-launcher.js";
|
|
6
6
|
import { WECHAT_FILE_MODE, wechatBridgeDiagnosticsPath } from "./state-paths.js";
|
|
7
7
|
import { buildSessionDigest, groupPermissionsBySession, groupQuestionsBySession, pickRecentSessions, } from "./session-digest.js";
|
|
8
|
+
import { readOperatorBinding } from "./operator-store.js";
|
|
9
|
+
import { createHandle, createRouteKey } from "./handle.js";
|
|
8
10
|
const DEFAULT_LIVE_READ_TIMEOUT_MS = 2_000;
|
|
9
11
|
const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000;
|
|
10
12
|
const PROCESS_INSTANCE_ID = toSafeInstanceID(`wechat-${process.pid}-${randomUUID().slice(0, 8)}`);
|
|
@@ -147,11 +149,45 @@ function unwrapSdkReadResult(value, name) {
|
|
|
147
149
|
return value.data;
|
|
148
150
|
}
|
|
149
151
|
export function createWechatBridge(input) {
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
+
const retryEventSequenceBySessionID = new Map();
|
|
153
|
+
const retrySignatureBySessionID = new Map();
|
|
154
|
+
const isRetryBySessionID = new Map();
|
|
155
|
+
function toIdempotencyPart(value) {
|
|
156
|
+
const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
157
|
+
return normalized.length > 0 ? normalized : "na";
|
|
158
|
+
}
|
|
159
|
+
function stableStringify(value) {
|
|
160
|
+
if (value === null || typeof value !== "object") {
|
|
161
|
+
return JSON.stringify(value);
|
|
162
|
+
}
|
|
163
|
+
if (Array.isArray(value)) {
|
|
164
|
+
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
165
|
+
}
|
|
166
|
+
const record = value;
|
|
167
|
+
const keys = Object.keys(record).sort();
|
|
168
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
|
|
169
|
+
}
|
|
170
|
+
const collectLiveRead = async () => {
|
|
152
171
|
const liveReadTimeoutMs = typeof input.liveReadTimeoutMs === "number" && Number.isFinite(input.liveReadTimeoutMs)
|
|
153
172
|
? Math.max(1, Math.floor(input.liveReadTimeoutMs))
|
|
154
173
|
: DEFAULT_LIVE_READ_TIMEOUT_MS;
|
|
174
|
+
const onDiagnosticEvent = input.onDiagnosticEvent;
|
|
175
|
+
const [sessionListResult, statusResult, questionResult, permissionResult] = await Promise.allSettled([
|
|
176
|
+
wrapDiagnosticStage({ instanceID: input.instanceID, stage: "session.list", onDiagnosticEvent }, () => withTimeout(async () => unwrapSdkReadResult(await input.client.session.list(), "session.list"), liveReadTimeoutMs, "session.list")),
|
|
177
|
+
wrapDiagnosticStage({ instanceID: input.instanceID, stage: "session.status", onDiagnosticEvent }, () => withTimeout(async () => unwrapSdkReadResult(await input.client.session.status(), "session.status"), liveReadTimeoutMs, "session.status")),
|
|
178
|
+
wrapDiagnosticStage({ instanceID: input.instanceID, stage: "question.list", onDiagnosticEvent }, () => withTimeout(async () => unwrapSdkReadResult(await input.client.question.list(), "question.list"), liveReadTimeoutMs, "question.list")),
|
|
179
|
+
wrapDiagnosticStage({ instanceID: input.instanceID, stage: "permission.list", onDiagnosticEvent }, () => withTimeout(async () => unwrapSdkReadResult(await input.client.permission.list(), "permission.list"), liveReadTimeoutMs, "permission.list")),
|
|
180
|
+
]);
|
|
181
|
+
return {
|
|
182
|
+
liveReadTimeoutMs,
|
|
183
|
+
sessionListResult,
|
|
184
|
+
statusResult,
|
|
185
|
+
questionResult,
|
|
186
|
+
permissionResult,
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
const collectStatusSnapshot = async () => {
|
|
190
|
+
const startedAt = Date.now();
|
|
155
191
|
const unavailable = new Set();
|
|
156
192
|
const onDiagnosticEvent = input.onDiagnosticEvent;
|
|
157
193
|
const activeSessionID = input.getActiveSessionID?.();
|
|
@@ -175,12 +211,7 @@ export function createWechatBridge(input) {
|
|
|
175
211
|
})).catch(() => { });
|
|
176
212
|
return snapshot;
|
|
177
213
|
}
|
|
178
|
-
const
|
|
179
|
-
wrapDiagnosticStage({ instanceID: input.instanceID, stage: "session.list", onDiagnosticEvent }, () => withTimeout(async () => unwrapSdkReadResult(await input.client.session.list(), "session.list"), liveReadTimeoutMs, "session.list")),
|
|
180
|
-
wrapDiagnosticStage({ instanceID: input.instanceID, stage: "session.status", onDiagnosticEvent }, () => withTimeout(async () => unwrapSdkReadResult(await input.client.session.status(), "session.status"), liveReadTimeoutMs, "session.status")),
|
|
181
|
-
wrapDiagnosticStage({ instanceID: input.instanceID, stage: "question.list", onDiagnosticEvent }, () => withTimeout(async () => unwrapSdkReadResult(await input.client.question.list(), "question.list"), liveReadTimeoutMs, "question.list")),
|
|
182
|
-
wrapDiagnosticStage({ instanceID: input.instanceID, stage: "permission.list", onDiagnosticEvent }, () => withTimeout(async () => unwrapSdkReadResult(await input.client.permission.list(), "permission.list"), liveReadTimeoutMs, "permission.list")),
|
|
183
|
-
]);
|
|
214
|
+
const { liveReadTimeoutMs, sessionListResult, statusResult, questionResult, permissionResult, } = await collectLiveRead();
|
|
184
215
|
const sessions = sessionListResult.status === "fulfilled" ? sessionListResult.value : [];
|
|
185
216
|
const recentSessions = isNonEmptyString(activeSessionID)
|
|
186
217
|
? sessions.filter((session) => session.id === activeSessionID).slice(0, 1)
|
|
@@ -236,8 +267,91 @@ export function createWechatBridge(input) {
|
|
|
236
267
|
})).catch(() => { });
|
|
237
268
|
return snapshot;
|
|
238
269
|
};
|
|
270
|
+
const collectNotificationCandidates = async () => {
|
|
271
|
+
const binding = await readOperatorBinding().catch(() => undefined);
|
|
272
|
+
if (!binding) {
|
|
273
|
+
return [];
|
|
274
|
+
}
|
|
275
|
+
const { questionResult, permissionResult, statusResult } = await collectLiveRead();
|
|
276
|
+
const candidates = [];
|
|
277
|
+
const existingHandles = new Set();
|
|
278
|
+
if (questionResult.status === "fulfilled") {
|
|
279
|
+
for (const question of questionResult.value) {
|
|
280
|
+
const routeKey = createRouteKey({
|
|
281
|
+
kind: "question",
|
|
282
|
+
requestID: question.id,
|
|
283
|
+
scopeKey: input.instanceID,
|
|
284
|
+
});
|
|
285
|
+
const handle = createHandle("question", existingHandles);
|
|
286
|
+
existingHandles.add(handle);
|
|
287
|
+
candidates.push({
|
|
288
|
+
idempotencyKey: `question-${toIdempotencyPart(input.instanceID)}-${toIdempotencyPart(question.id)}`,
|
|
289
|
+
kind: "question",
|
|
290
|
+
requestID: question.id,
|
|
291
|
+
createdAt: Date.now(),
|
|
292
|
+
routeKey,
|
|
293
|
+
handle,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (permissionResult.status === "fulfilled") {
|
|
298
|
+
for (const permission of permissionResult.value) {
|
|
299
|
+
const routeKey = createRouteKey({
|
|
300
|
+
kind: "permission",
|
|
301
|
+
requestID: permission.id,
|
|
302
|
+
scopeKey: input.instanceID,
|
|
303
|
+
});
|
|
304
|
+
const handle = createHandle("permission", existingHandles);
|
|
305
|
+
existingHandles.add(handle);
|
|
306
|
+
candidates.push({
|
|
307
|
+
idempotencyKey: `permission-${toIdempotencyPart(input.instanceID)}-${toIdempotencyPart(permission.id)}`,
|
|
308
|
+
kind: "permission",
|
|
309
|
+
requestID: permission.id,
|
|
310
|
+
createdAt: Date.now(),
|
|
311
|
+
routeKey,
|
|
312
|
+
handle,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (statusResult.status === "fulfilled") {
|
|
317
|
+
const seenSessionIDs = new Set();
|
|
318
|
+
for (const [sessionID, status] of Object.entries(statusResult.value)) {
|
|
319
|
+
seenSessionIDs.add(sessionID);
|
|
320
|
+
if (status?.type === "retry") {
|
|
321
|
+
const signature = stableStringify(status);
|
|
322
|
+
const previousWasRetry = isRetryBySessionID.get(sessionID) === true;
|
|
323
|
+
const previousSignature = retrySignatureBySessionID.get(sessionID);
|
|
324
|
+
if (!previousWasRetry || previousSignature !== signature) {
|
|
325
|
+
const nextSequence = (retryEventSequenceBySessionID.get(sessionID) ?? 0) + 1;
|
|
326
|
+
retryEventSequenceBySessionID.set(sessionID, nextSequence);
|
|
327
|
+
}
|
|
328
|
+
isRetryBySessionID.set(sessionID, true);
|
|
329
|
+
retrySignatureBySessionID.set(sessionID, signature);
|
|
330
|
+
const eventSequence = retryEventSequenceBySessionID.get(sessionID) ?? 1;
|
|
331
|
+
candidates.push({
|
|
332
|
+
idempotencyKey: `session-error-${toIdempotencyPart(input.instanceID)}-${toIdempotencyPart(sessionID)}-${eventSequence}`,
|
|
333
|
+
kind: "sessionError",
|
|
334
|
+
createdAt: Date.now(),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
isRetryBySessionID.set(sessionID, false);
|
|
339
|
+
retrySignatureBySessionID.delete(sessionID);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
for (const knownSessionID of isRetryBySessionID.keys()) {
|
|
343
|
+
if (seenSessionIDs.has(knownSessionID)) {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
isRetryBySessionID.set(knownSessionID, false);
|
|
347
|
+
retrySignatureBySessionID.delete(knownSessionID);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return candidates;
|
|
351
|
+
};
|
|
239
352
|
return {
|
|
240
353
|
collectStatusSnapshot,
|
|
354
|
+
collectNotificationCandidates,
|
|
241
355
|
};
|
|
242
356
|
}
|
|
243
357
|
export async function createWechatBridgeLifecycle(input, deps = {}) {
|
|
@@ -127,6 +127,21 @@ export async function connect(endpoint, options = {}) {
|
|
|
127
127
|
};
|
|
128
128
|
socket.write(serializeEnvelope(envelope));
|
|
129
129
|
}
|
|
130
|
+
function sendSyncWechatNotifications(candidates) {
|
|
131
|
+
if (!session || candidates.length === 0) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const envelope = {
|
|
135
|
+
id: nextRequestId("syncWechatNotifications"),
|
|
136
|
+
type: "syncWechatNotifications",
|
|
137
|
+
instanceID: session.instanceID,
|
|
138
|
+
sessionToken: session.sessionToken,
|
|
139
|
+
payload: {
|
|
140
|
+
candidates,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
socket.write(serializeEnvelope(envelope));
|
|
144
|
+
}
|
|
130
145
|
function handleCollectStatus(envelope) {
|
|
131
146
|
const payload = envelope.payload;
|
|
132
147
|
if (!isNonEmptyString(payload.requestId)) {
|
|
@@ -203,6 +218,15 @@ export async function connect(endpoint, options = {}) {
|
|
|
203
218
|
registeredAt: payload.registeredAt,
|
|
204
219
|
brokerPid: payload.brokerPid,
|
|
205
220
|
};
|
|
221
|
+
if (options.bridge?.collectNotificationCandidates) {
|
|
222
|
+
try {
|
|
223
|
+
const candidates = await options.bridge.collectNotificationCandidates();
|
|
224
|
+
sendSyncWechatNotifications(candidates);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
// swallow candidate collection errors to keep register path available
|
|
228
|
+
}
|
|
229
|
+
}
|
|
206
230
|
return {
|
|
207
231
|
sessionToken: session.sessionToken,
|
|
208
232
|
registeredAt: session.registeredAt,
|
|
@@ -213,13 +237,21 @@ export async function connect(endpoint, options = {}) {
|
|
|
213
237
|
if (!session) {
|
|
214
238
|
throw new Error("missing broker session");
|
|
215
239
|
}
|
|
216
|
-
|
|
240
|
+
const response = await send({
|
|
217
241
|
id: nextRequestId("heartbeat"),
|
|
218
242
|
type: "heartbeat",
|
|
219
243
|
instanceID: session.instanceID,
|
|
220
244
|
sessionToken: session.sessionToken,
|
|
221
245
|
payload: {},
|
|
222
246
|
});
|
|
247
|
+
if (options.bridge?.collectNotificationCandidates) {
|
|
248
|
+
void Promise.resolve(options.bridge.collectNotificationCandidates())
|
|
249
|
+
.then((candidates) => {
|
|
250
|
+
sendSyncWechatNotifications(candidates);
|
|
251
|
+
})
|
|
252
|
+
.catch(() => { });
|
|
253
|
+
}
|
|
254
|
+
return response;
|
|
223
255
|
},
|
|
224
256
|
getSessionSnapshot() {
|
|
225
257
|
if (!session) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type QuestionAnswer } from "@opencode-ai/sdk/v2";
|
|
2
2
|
import { type WechatStatusRuntime, type WechatStatusRuntimeDiagnosticEvent } from "./wechat-status-runtime.js";
|
|
3
|
+
import { type WechatNotificationSendInput } from "./notification-dispatcher.js";
|
|
3
4
|
import type { WechatSlashCommand } from "./command-parser.js";
|
|
4
5
|
type BrokerWechatStatusRuntimeLifecycle = {
|
|
5
6
|
start: () => Promise<void>;
|
|
@@ -11,7 +12,15 @@ type BrokerWechatStatusRuntimeLifecycleDeps = {
|
|
|
11
12
|
command: import("./command-parser.js").WechatSlashCommand;
|
|
12
13
|
}) => Promise<string>;
|
|
13
14
|
onDiagnosticEvent: (event: WechatStatusRuntimeDiagnosticEvent) => void | Promise<void>;
|
|
15
|
+
drainOutboundMessages: (input?: {
|
|
16
|
+
sendMessage: (input: WechatNotificationSendInput) => Promise<void>;
|
|
17
|
+
}) => Promise<void>;
|
|
14
18
|
}) => WechatStatusRuntime;
|
|
19
|
+
createNotificationDispatcher?: (input: {
|
|
20
|
+
sendMessage: (input: WechatNotificationSendInput) => Promise<void>;
|
|
21
|
+
}) => {
|
|
22
|
+
drainOutboundMessages: () => Promise<void>;
|
|
23
|
+
};
|
|
15
24
|
handleWechatSlashCommand?: (command: import("./command-parser.js").WechatSlashCommand) => Promise<string>;
|
|
16
25
|
onRuntimeError?: (error: unknown) => void;
|
|
17
26
|
onDiagnosticEvent?: (event: WechatStatusRuntimeDiagnosticEvent) => void | Promise<void>;
|
|
@@ -20,15 +29,6 @@ type BrokerWechatStatusRuntimeLifecycleDeps = {
|
|
|
20
29
|
export declare function shouldEnableBrokerWechatStatusRuntime(env?: NodeJS.ProcessEnv): boolean;
|
|
21
30
|
type BrokerWechatSlashHandlerClient = {
|
|
22
31
|
question?: {
|
|
23
|
-
list?: (input?: {
|
|
24
|
-
directory?: string;
|
|
25
|
-
}) => Promise<{
|
|
26
|
-
data?: Array<{
|
|
27
|
-
id?: string;
|
|
28
|
-
}>;
|
|
29
|
-
} | Array<{
|
|
30
|
-
id?: string;
|
|
31
|
-
}> | undefined>;
|
|
32
32
|
reply?: (input: {
|
|
33
33
|
requestID: string;
|
|
34
34
|
directory?: string;
|
|
@@ -36,15 +36,6 @@ type BrokerWechatSlashHandlerClient = {
|
|
|
36
36
|
}) => Promise<unknown>;
|
|
37
37
|
};
|
|
38
38
|
permission?: {
|
|
39
|
-
list?: (input?: {
|
|
40
|
-
directory?: string;
|
|
41
|
-
}) => Promise<{
|
|
42
|
-
data?: Array<{
|
|
43
|
-
id?: string;
|
|
44
|
-
}>;
|
|
45
|
-
} | Array<{
|
|
46
|
-
id?: string;
|
|
47
|
-
}> | undefined>;
|
|
48
39
|
reply?: (input: {
|
|
49
40
|
requestID: string;
|
|
50
41
|
directory?: string;
|
|
@@ -7,6 +7,9 @@ import { createOpencodeClient as createOpencodeClientV2 } from "@opencode-ai/sdk
|
|
|
7
7
|
import { startBrokerServer } from "./broker-server.js";
|
|
8
8
|
import { WECHAT_FILE_MODE, wechatStateRoot, wechatStatusRuntimeDiagnosticsPath } from "./state-paths.js";
|
|
9
9
|
import { createWechatStatusRuntime, } from "./wechat-status-runtime.js";
|
|
10
|
+
import { createWechatNotificationDispatcher, } from "./notification-dispatcher.js";
|
|
11
|
+
import { findOpenRequestByHandle, markRequestAnswered, markRequestRejected, } from "./request-store.js";
|
|
12
|
+
import { findSentNotificationByRequest, markNotificationResolved, } from "./notification-store.js";
|
|
10
13
|
const BROKER_WECHAT_RUNTIME_AUTOSTART_DELAY_MS = 1_000;
|
|
11
14
|
async function readPackageVersion() {
|
|
12
15
|
const packageJsonPath = new URL("../../package.json", import.meta.url);
|
|
@@ -72,12 +75,6 @@ export function shouldEnableBrokerWechatStatusRuntime(env = process.env) {
|
|
|
72
75
|
void env;
|
|
73
76
|
return true;
|
|
74
77
|
}
|
|
75
|
-
function unwrapDataArray(value) {
|
|
76
|
-
if (Array.isArray(value)) {
|
|
77
|
-
return value;
|
|
78
|
-
}
|
|
79
|
-
return Array.isArray(value?.data) ? value.data : [];
|
|
80
|
-
}
|
|
81
78
|
function withOptionalDirectory(input, directory) {
|
|
82
79
|
if (typeof directory === "string" && directory.trim().length > 0) {
|
|
83
80
|
return {
|
|
@@ -87,34 +84,95 @@ function withOptionalDirectory(input, directory) {
|
|
|
87
84
|
}
|
|
88
85
|
return input;
|
|
89
86
|
}
|
|
87
|
+
function isInvalidHandleError(error) {
|
|
88
|
+
if (!(error instanceof Error)) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return /invalid handle format|raw requestID cannot be used as handle/i.test(error.message);
|
|
92
|
+
}
|
|
90
93
|
export function createBrokerWechatSlashCommandHandler(input) {
|
|
94
|
+
const findOpenRequestSafely = async (input) => {
|
|
95
|
+
try {
|
|
96
|
+
return await findOpenRequestByHandle(input);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (isInvalidHandleError(error)) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const resolveNotificationForOpenRequest = async (request) => {
|
|
106
|
+
try {
|
|
107
|
+
const sentNotification = await findSentNotificationByRequest({
|
|
108
|
+
kind: request.kind,
|
|
109
|
+
routeKey: request.routeKey,
|
|
110
|
+
handle: request.handle,
|
|
111
|
+
});
|
|
112
|
+
if (!sentNotification) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
await markNotificationResolved({
|
|
116
|
+
idempotencyKey: sentNotification.idempotencyKey,
|
|
117
|
+
resolvedAt: Date.now(),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// best-effort only: notification resolve failure should not fail slash reply
|
|
122
|
+
}
|
|
123
|
+
};
|
|
91
124
|
return async (command) => {
|
|
92
125
|
if (command.type === "status") {
|
|
93
126
|
return input.handleStatusCommand();
|
|
94
127
|
}
|
|
95
128
|
if (command.type === "reply") {
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
129
|
+
const openQuestion = await findOpenRequestSafely({
|
|
130
|
+
kind: "question",
|
|
131
|
+
handle: command.handle,
|
|
132
|
+
});
|
|
133
|
+
if (!openQuestion) {
|
|
134
|
+
return `未找到待回复问题:${command.handle}`;
|
|
100
135
|
}
|
|
101
136
|
await input.client?.question?.reply?.(withOptionalDirectory({
|
|
102
|
-
requestID,
|
|
137
|
+
requestID: openQuestion.requestID,
|
|
103
138
|
answers: [[command.text]],
|
|
104
139
|
}, input.directory));
|
|
105
|
-
|
|
140
|
+
await markRequestAnswered({
|
|
141
|
+
kind: "question",
|
|
142
|
+
routeKey: openQuestion.routeKey,
|
|
143
|
+
answeredAt: Date.now(),
|
|
144
|
+
});
|
|
145
|
+
await resolveNotificationForOpenRequest(openQuestion);
|
|
146
|
+
return `已回复问题:${openQuestion.handle}`;
|
|
106
147
|
}
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
148
|
+
const openPermission = await findOpenRequestSafely({
|
|
149
|
+
kind: "permission",
|
|
150
|
+
handle: command.handle,
|
|
151
|
+
});
|
|
152
|
+
if (!openPermission) {
|
|
153
|
+
return `未找到待处理权限请求:${command.handle}`;
|
|
111
154
|
}
|
|
112
155
|
await input.client?.permission?.reply?.(withOptionalDirectory({
|
|
113
|
-
requestID,
|
|
156
|
+
requestID: openPermission.requestID,
|
|
114
157
|
reply: command.reply,
|
|
115
158
|
...(command.message ? { message: command.message } : {}),
|
|
116
159
|
}, input.directory));
|
|
117
|
-
|
|
160
|
+
if (command.reply === "reject") {
|
|
161
|
+
await markRequestRejected({
|
|
162
|
+
kind: "permission",
|
|
163
|
+
routeKey: openPermission.routeKey,
|
|
164
|
+
rejectedAt: Date.now(),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
await markRequestAnswered({
|
|
169
|
+
kind: "permission",
|
|
170
|
+
routeKey: openPermission.routeKey,
|
|
171
|
+
answeredAt: Date.now(),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
await resolveNotificationForOpenRequest(openPermission);
|
|
175
|
+
return `已处理权限请求:${openPermission.handle} (${command.reply})`;
|
|
118
176
|
};
|
|
119
177
|
}
|
|
120
178
|
export function createBrokerWechatStatusRuntimeLifecycle(deps = {}) {
|
|
@@ -135,16 +193,43 @@ export function createBrokerWechatStatusRuntimeLifecycle(deps = {}) {
|
|
|
135
193
|
onSlashCommand: async ({ command }) => statusRuntimeDeps.onSlashCommand({ command }),
|
|
136
194
|
onRuntimeError,
|
|
137
195
|
onDiagnosticEvent: statusRuntimeDeps.onDiagnosticEvent,
|
|
196
|
+
drainOutboundMessages: async (drainInput) => {
|
|
197
|
+
await statusRuntimeDeps.drainOutboundMessages({
|
|
198
|
+
sendMessage: async (message) => {
|
|
199
|
+
await drainInput.sendMessage(message);
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
},
|
|
138
203
|
}));
|
|
204
|
+
const createNotificationDispatcher = deps.createNotificationDispatcher ?? createWechatNotificationDispatcher;
|
|
139
205
|
let runtime = null;
|
|
206
|
+
let dispatcher = null;
|
|
140
207
|
return {
|
|
141
208
|
start: async () => {
|
|
142
209
|
if (runtime) {
|
|
143
210
|
return;
|
|
144
211
|
}
|
|
212
|
+
let runtimeSendMessage = null;
|
|
213
|
+
dispatcher = createNotificationDispatcher({
|
|
214
|
+
sendMessage: async (message) => {
|
|
215
|
+
if (!runtimeSendMessage) {
|
|
216
|
+
throw new Error("wechat runtime send helper unavailable");
|
|
217
|
+
}
|
|
218
|
+
await runtimeSendMessage(message);
|
|
219
|
+
},
|
|
220
|
+
});
|
|
145
221
|
const created = createStatusRuntime({
|
|
146
222
|
onSlashCommand: async ({ command }) => handleWechatSlashCommand(command),
|
|
147
223
|
onDiagnosticEvent,
|
|
224
|
+
drainOutboundMessages: async (runtimeDrainInput) => {
|
|
225
|
+
if (runtimeDrainInput?.sendMessage) {
|
|
226
|
+
runtimeSendMessage = runtimeDrainInput.sendMessage;
|
|
227
|
+
}
|
|
228
|
+
if (!dispatcher) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
await dispatcher.drainOutboundMessages();
|
|
232
|
+
},
|
|
148
233
|
});
|
|
149
234
|
runtime = created;
|
|
150
235
|
try {
|
|
@@ -160,6 +245,7 @@ export function createBrokerWechatStatusRuntimeLifecycle(deps = {}) {
|
|
|
160
245
|
}
|
|
161
246
|
const active = runtime;
|
|
162
247
|
runtime = null;
|
|
248
|
+
dispatcher = null;
|
|
163
249
|
await active.close().catch((error) => {
|
|
164
250
|
onRuntimeError(error);
|
|
165
251
|
});
|
|
@@ -6,6 +6,10 @@ import { registerConnection, revokeSessionToken, validateSessionToken } from "./
|
|
|
6
6
|
import { createErrorEnvelope, parseEnvelopeLine, serializeEnvelope, } from "./protocol.js";
|
|
7
7
|
import { WECHAT_DIR_MODE, WECHAT_FILE_MODE, instanceStatePath, instancesDir } from "./state-paths.js";
|
|
8
8
|
import { formatAggregatedStatusReply } from "./status-format.js";
|
|
9
|
+
import { upsertNotification } from "./notification-store.js";
|
|
10
|
+
import { readOperatorBinding } from "./operator-store.js";
|
|
11
|
+
import { createHandle, createRouteKey } from "./handle.js";
|
|
12
|
+
import { findOpenRequestByIdentity, listActiveRequests, upsertRequest } from "./request-store.js";
|
|
9
13
|
const FUTURE_MESSAGE_TYPES = new Set([
|
|
10
14
|
"collectStatus",
|
|
11
15
|
"replyQuestion",
|
|
@@ -41,6 +45,7 @@ const instanceIDsBySocket = new Map();
|
|
|
41
45
|
const snapshotByInstanceID = new Map();
|
|
42
46
|
const snapshotPersistQueueByInstanceID = new Map();
|
|
43
47
|
const pendingCollectStatusByRequestId = new Map();
|
|
48
|
+
let syncWechatNotificationsChain = Promise.resolve();
|
|
44
49
|
function clearRuntimeState() {
|
|
45
50
|
for (const instanceID of registrationByInstanceID.keys()) {
|
|
46
51
|
revokeSessionToken(instanceID);
|
|
@@ -50,6 +55,7 @@ function clearRuntimeState() {
|
|
|
50
55
|
snapshotByInstanceID.clear();
|
|
51
56
|
snapshotPersistQueueByInstanceID.clear();
|
|
52
57
|
pendingCollectStatusByRequestId.clear();
|
|
58
|
+
syncWechatNotificationsChain = Promise.resolve();
|
|
53
59
|
}
|
|
54
60
|
function toPositiveNumber(rawValue, fallback) {
|
|
55
61
|
if (!isNonEmptyString(rawValue)) {
|
|
@@ -77,6 +83,26 @@ function hasStatusSnapshotPayload(payload) {
|
|
|
77
83
|
const record = asObject(payload);
|
|
78
84
|
return isNonEmptyString(record.requestId) && "snapshot" in record;
|
|
79
85
|
}
|
|
86
|
+
function isWechatNotificationCandidate(value) {
|
|
87
|
+
const record = asObject(value);
|
|
88
|
+
if (!isNonEmptyString(record.idempotencyKey) || !isFiniteNumber(record.createdAt)) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
if (record.kind === "sessionError") {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
if (record.kind === "question" || record.kind === "permission") {
|
|
95
|
+
return isNonEmptyString(record.requestID) && isNonEmptyString(record.routeKey) && isNonEmptyString(record.handle);
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
function hasSyncWechatNotificationsPayload(payload) {
|
|
100
|
+
const record = asObject(payload);
|
|
101
|
+
if (!Array.isArray(record.candidates)) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
return record.candidates.every((candidate) => isWechatNotificationCandidate(candidate));
|
|
105
|
+
}
|
|
80
106
|
function isSafeInstanceID(instanceID) {
|
|
81
107
|
if (!isNonEmptyString(instanceID)) {
|
|
82
108
|
return false;
|
|
@@ -253,6 +279,11 @@ function finalizePendingCollectStatus(requestId) {
|
|
|
253
279
|
}),
|
|
254
280
|
});
|
|
255
281
|
}
|
|
282
|
+
function queueSyncWechatNotifications(task) {
|
|
283
|
+
const next = syncWechatNotificationsChain.then(task);
|
|
284
|
+
syncWechatNotificationsChain = next.catch(() => { });
|
|
285
|
+
return next;
|
|
286
|
+
}
|
|
256
287
|
async function handleMessage(envelope, socket) {
|
|
257
288
|
const requestId = getRequestId(envelope);
|
|
258
289
|
if (envelope.type === "ping") {
|
|
@@ -368,6 +399,81 @@ async function handleMessage(envelope, socket) {
|
|
|
368
399
|
}
|
|
369
400
|
return;
|
|
370
401
|
}
|
|
402
|
+
if (envelope.type === "syncWechatNotifications") {
|
|
403
|
+
if (!requireAuthorized(envelope)) {
|
|
404
|
+
writeError(socket, "unauthorized", "session token is invalid", requestId);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const payload = envelope.payload;
|
|
408
|
+
if (!hasSyncWechatNotificationsPayload(payload)) {
|
|
409
|
+
writeError(socket, "invalidMessage", "syncWechatNotifications payload is invalid", requestId);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const binding = await readOperatorBinding().catch(() => undefined);
|
|
413
|
+
if (!binding) {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
await queueSyncWechatNotifications(async () => {
|
|
417
|
+
for (const candidate of payload.candidates) {
|
|
418
|
+
if (candidate.kind === "sessionError") {
|
|
419
|
+
await upsertNotification({
|
|
420
|
+
idempotencyKey: candidate.idempotencyKey,
|
|
421
|
+
kind: "sessionError",
|
|
422
|
+
wechatAccountId: binding.wechatAccountId,
|
|
423
|
+
userId: binding.userId,
|
|
424
|
+
createdAt: candidate.createdAt,
|
|
425
|
+
});
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const existingOpen = await findOpenRequestByIdentity({
|
|
429
|
+
kind: candidate.kind,
|
|
430
|
+
requestID: candidate.requestID,
|
|
431
|
+
wechatAccountId: binding.wechatAccountId,
|
|
432
|
+
userId: binding.userId,
|
|
433
|
+
scopeKey: envelope.instanceID,
|
|
434
|
+
});
|
|
435
|
+
let canonicalRouteKey;
|
|
436
|
+
let canonicalHandle;
|
|
437
|
+
if (existingOpen) {
|
|
438
|
+
canonicalRouteKey = existingOpen.routeKey;
|
|
439
|
+
canonicalHandle = existingOpen.handle;
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
const activeRequests = await listActiveRequests();
|
|
443
|
+
const existingHandles = activeRequests
|
|
444
|
+
.filter((item) => item.kind === candidate.kind && item.status === "open")
|
|
445
|
+
.map((item) => item.handle);
|
|
446
|
+
const nextRouteKey = createRouteKey({
|
|
447
|
+
kind: candidate.kind,
|
|
448
|
+
requestID: candidate.requestID,
|
|
449
|
+
scopeKey: envelope.instanceID,
|
|
450
|
+
});
|
|
451
|
+
const nextHandle = createHandle(candidate.kind, existingHandles);
|
|
452
|
+
const created = await upsertRequest({
|
|
453
|
+
kind: candidate.kind,
|
|
454
|
+
requestID: candidate.requestID,
|
|
455
|
+
routeKey: nextRouteKey,
|
|
456
|
+
handle: nextHandle,
|
|
457
|
+
wechatAccountId: binding.wechatAccountId,
|
|
458
|
+
userId: binding.userId,
|
|
459
|
+
createdAt: candidate.createdAt,
|
|
460
|
+
});
|
|
461
|
+
canonicalRouteKey = created.routeKey;
|
|
462
|
+
canonicalHandle = created.handle;
|
|
463
|
+
}
|
|
464
|
+
await upsertNotification({
|
|
465
|
+
idempotencyKey: candidate.idempotencyKey,
|
|
466
|
+
kind: candidate.kind,
|
|
467
|
+
wechatAccountId: binding.wechatAccountId,
|
|
468
|
+
userId: binding.userId,
|
|
469
|
+
routeKey: canonicalRouteKey,
|
|
470
|
+
handle: canonicalHandle,
|
|
471
|
+
createdAt: candidate.createdAt,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
371
477
|
if (FUTURE_MESSAGE_TYPES.has(envelope.type)) {
|
|
372
478
|
if (!requireAuthorized(envelope)) {
|
|
373
479
|
writeError(socket, "unauthorized", "session token is invalid", requestId);
|