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.
@@ -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
+ }
@@ -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;
@@ -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 collectStatusSnapshot = async () => {
151
- const startedAt = Date.now();
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 [sessionListResult, statusResult, questionResult, permissionResult] = await Promise.allSettled([
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
- return send({
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 questions = unwrapDataArray(await input.client?.question?.list?.(withOptionalDirectory({}, input.directory)));
97
- const requestID = typeof questions[0]?.id === "string" ? questions[0].id : undefined;
98
- if (!requestID) {
99
- return "当前没有待回复问题";
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
- return `已回复问题:${requestID}`;
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 permissions = unwrapDataArray(await input.client?.permission?.list?.(withOptionalDirectory({}, input.directory)));
108
- const requestID = typeof permissions[0]?.id === "string" ? permissions[0].id : undefined;
109
- if (!requestID) {
110
- return "当前没有待处理权限请求";
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
- return `已处理权限请求:${requestID} (${command.reply})`;
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);
@@ -2,9 +2,11 @@ export type WechatSlashCommand = {
2
2
  type: "status";
3
3
  } | {
4
4
  type: "reply";
5
+ handle: string;
5
6
  text: string;
6
7
  } | {
7
8
  type: "allow";
9
+ handle: string;
8
10
  reply: "once" | "always" | "reject";
9
11
  message?: string;
10
12
  };