opencode-copilot-account-switcher 0.14.28 → 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.
@@ -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
  };
@@ -6,26 +6,37 @@ export function parseWechatSlashCommand(input) {
6
6
  if (normalized === "/status") {
7
7
  return { type: "status" };
8
8
  }
9
- if (normalized.startsWith("/reply")) {
10
- const text = normalized.slice("/reply".length).trim();
11
- if (!text) {
9
+ const parts = normalized.split(/\s+/);
10
+ const command = parts[0];
11
+ if (command === "/reply") {
12
+ if (parts.length < 3) {
12
13
  return null;
13
14
  }
14
- return { type: "reply", text };
15
+ const handle = parts[1];
16
+ const textParts = parts.slice(2);
17
+ const text = textParts.join(" ").trim();
18
+ if (!handle || !text) {
19
+ return null;
20
+ }
21
+ return { type: "reply", handle, text };
15
22
  }
16
- if (normalized.startsWith("/allow")) {
17
- const rest = normalized.slice("/allow".length).trim();
18
- if (!rest) {
23
+ if (command === "/allow") {
24
+ if (parts.length < 3) {
25
+ return null;
26
+ }
27
+ const handle = parts[1];
28
+ const rawReply = parts[2];
29
+ const messageParts = parts.slice(3);
30
+ if (!handle || !rawReply) {
19
31
  return null;
20
32
  }
21
- const [rawReply, ...messageParts] = rest.split(/\s+/);
22
33
  if (rawReply !== "once" && rawReply !== "always" && rawReply !== "reject") {
23
34
  return null;
24
35
  }
25
36
  const message = messageParts.join(" ").trim();
26
37
  return message.length > 0
27
- ? { type: "allow", reply: rawReply, message }
28
- : { type: "allow", reply: rawReply };
38
+ ? { type: "allow", handle, reply: rawReply, message }
39
+ : { type: "allow", handle, reply: rawReply };
29
40
  }
30
41
  return null;
31
42
  }
@@ -2,6 +2,7 @@ import type { WechatRequestKind } from "./state-paths.js";
2
2
  export declare function createRouteKey(input: {
3
3
  kind: WechatRequestKind;
4
4
  requestID: string;
5
+ scopeKey?: string;
5
6
  }): string;
6
7
  export declare function normalizeHandle(input: string): string;
7
8
  export declare function assertValidHandleInput(input: string): void;
@@ -8,7 +8,12 @@ function normalizeRequestID(requestID) {
8
8
  }
9
9
  export function createRouteKey(input) {
10
10
  const normalized = normalizeRequestID(input.requestID);
11
- const digest = crypto.createHash("sha1").update(`${input.kind}:${normalized}`).digest("hex").slice(0, 12);
11
+ const normalizedScope = typeof input.scopeKey === "string" ? input.scopeKey.trim().toLowerCase() : "";
12
+ const digest = crypto
13
+ .createHash("sha1")
14
+ .update(`${input.kind}:${normalized}:${normalizedScope}`)
15
+ .digest("hex")
16
+ .slice(0, 12);
12
17
  return `${input.kind}-${digest}`;
13
18
  }
14
19
  export function normalizeHandle(input) {
@@ -0,0 +1,12 @@
1
+ export type WechatNotificationSendInput = {
2
+ to: string;
3
+ text: string;
4
+ };
5
+ type CreateWechatNotificationDispatcherInput = {
6
+ sendMessage: (input: WechatNotificationSendInput) => Promise<unknown>;
7
+ };
8
+ type WechatNotificationDispatcher = {
9
+ drainOutboundMessages: () => Promise<void>;
10
+ };
11
+ export declare function createWechatNotificationDispatcher(input: CreateWechatNotificationDispatcherInput): WechatNotificationDispatcher;
12
+ export {};
@@ -0,0 +1,143 @@
1
+ import { readCommonSettingsStore } from "../common-settings-store.js";
2
+ import { listPendingNotifications, markNotificationResolved, markNotificationFailed, markNotificationSent, purgeTerminalNotificationsBefore, } from "./notification-store.js";
3
+ import { formatWechatNotificationText } from "./notification-format.js";
4
+ import { findRequestByRouteKey } from "./request-store.js";
5
+ const DEFAULT_NOTIFICATION_TERMINAL_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
6
+ function toPositiveNumber(rawValue, fallback) {
7
+ if (typeof rawValue !== "string" || rawValue.trim().length === 0) {
8
+ return fallback;
9
+ }
10
+ const parsed = Number(rawValue);
11
+ if (!Number.isFinite(parsed) || parsed <= 0) {
12
+ return fallback;
13
+ }
14
+ return parsed;
15
+ }
16
+ function shouldSendKind(kind, notifications) {
17
+ if (!notifications.enabled) {
18
+ return false;
19
+ }
20
+ if (kind === "question") {
21
+ return notifications.question;
22
+ }
23
+ if (kind === "permission") {
24
+ return notifications.permission;
25
+ }
26
+ return notifications.sessionError;
27
+ }
28
+ function toErrorMessage(error) {
29
+ if (error instanceof Error) {
30
+ return error.message;
31
+ }
32
+ if (typeof error === "string") {
33
+ return error;
34
+ }
35
+ return String(error);
36
+ }
37
+ function isNotPendingStateError(error) {
38
+ if (!(error instanceof Error)) {
39
+ return false;
40
+ }
41
+ return /not pending/i.test(error.message);
42
+ }
43
+ function isNotSuppressibleStateError(error) {
44
+ if (!(error instanceof Error)) {
45
+ return false;
46
+ }
47
+ return /not pending|neither pending nor sent/i.test(error.message);
48
+ }
49
+ async function shouldSuppressPendingNotification(record) {
50
+ if (record.kind === "sessionError") {
51
+ return false;
52
+ }
53
+ if (typeof record.routeKey !== "string" || record.routeKey.trim().length === 0) {
54
+ return false;
55
+ }
56
+ const request = await findRequestByRouteKey({
57
+ kind: record.kind,
58
+ routeKey: record.routeKey,
59
+ });
60
+ if (!request) {
61
+ return false;
62
+ }
63
+ return request.status !== "open";
64
+ }
65
+ export function createWechatNotificationDispatcher(input) {
66
+ return {
67
+ drainOutboundMessages: async () => {
68
+ const retentionMs = toPositiveNumber(process.env.WECHAT_NOTIFICATION_TERMINAL_RETENTION_MS, DEFAULT_NOTIFICATION_TERMINAL_RETENTION_MS);
69
+ await purgeTerminalNotificationsBefore({
70
+ cutoffAt: Date.now() - retentionMs,
71
+ });
72
+ const settings = await readCommonSettingsStore();
73
+ const notifications = settings.wechat?.notifications;
74
+ const targetUserId = settings.wechat?.primaryBinding?.userId;
75
+ const targetAccountId = settings.wechat?.primaryBinding?.accountId;
76
+ if (!notifications) {
77
+ return;
78
+ }
79
+ if (typeof targetUserId !== "string" || targetUserId.trim().length === 0) {
80
+ return;
81
+ }
82
+ if (typeof targetAccountId !== "string" || targetAccountId.trim().length === 0) {
83
+ return;
84
+ }
85
+ const pending = await listPendingNotifications();
86
+ for (const record of pending) {
87
+ if (await shouldSuppressPendingNotification(record)) {
88
+ try {
89
+ await markNotificationResolved({
90
+ idempotencyKey: record.idempotencyKey,
91
+ resolvedAt: Date.now(),
92
+ suppressed: true,
93
+ });
94
+ }
95
+ catch (error) {
96
+ if (!isNotSuppressibleStateError(error)) {
97
+ throw error;
98
+ }
99
+ }
100
+ continue;
101
+ }
102
+ if (!shouldSendKind(record.kind, notifications)) {
103
+ continue;
104
+ }
105
+ if (record.userId !== targetUserId || record.wechatAccountId !== targetAccountId) {
106
+ continue;
107
+ }
108
+ try {
109
+ await input.sendMessage({
110
+ to: targetUserId,
111
+ text: formatWechatNotificationText(record),
112
+ });
113
+ }
114
+ catch (error) {
115
+ try {
116
+ await markNotificationFailed({
117
+ idempotencyKey: record.idempotencyKey,
118
+ failedAt: Date.now(),
119
+ reason: toErrorMessage(error),
120
+ });
121
+ }
122
+ catch (markError) {
123
+ if (!isNotPendingStateError(markError)) {
124
+ throw markError;
125
+ }
126
+ }
127
+ continue;
128
+ }
129
+ try {
130
+ await markNotificationSent({
131
+ idempotencyKey: record.idempotencyKey,
132
+ sentAt: Date.now(),
133
+ });
134
+ }
135
+ catch (error) {
136
+ if (!isNotPendingStateError(error)) {
137
+ throw error;
138
+ }
139
+ }
140
+ }
141
+ },
142
+ };
143
+ }
@@ -0,0 +1,2 @@
1
+ import type { NotificationRecord } from "./notification-types.js";
2
+ export declare function formatWechatNotificationText(record: NotificationRecord): string;
@@ -0,0 +1,17 @@
1
+ function formatHandle(handle, fallback) {
2
+ if (typeof handle === "string" && handle.trim().length > 0) {
3
+ return handle;
4
+ }
5
+ return fallback;
6
+ }
7
+ export function formatWechatNotificationText(record) {
8
+ if (record.kind === "question") {
9
+ const handle = formatHandle(record.handle, "q?");
10
+ return `收到新的问题请求(${handle}),请在 OpenCode 中处理。`;
11
+ }
12
+ if (record.kind === "permission") {
13
+ const handle = formatHandle(record.handle, "p?");
14
+ return `收到新的权限请求(${handle}),请在 OpenCode 中处理。`;
15
+ }
16
+ return "检测到会话异常(retry),请在 OpenCode 中检查并处理。";
17
+ }
@@ -0,0 +1,25 @@
1
+ import { type NotificationKind, type NotificationRecord } from "./notification-types.js";
2
+ export declare function upsertNotification(input: Omit<NotificationRecord, "status" | "sentAt" | "resolvedAt" | "failedAt" | "suppressedAt" | "failureReason">): Promise<NotificationRecord>;
3
+ export declare function markNotificationSent(input: {
4
+ idempotencyKey: string;
5
+ sentAt: number;
6
+ }): Promise<NotificationRecord>;
7
+ export declare function markNotificationResolved(input: {
8
+ idempotencyKey: string;
9
+ resolvedAt: number;
10
+ suppressed?: boolean;
11
+ }): Promise<NotificationRecord>;
12
+ export declare function markNotificationFailed(input: {
13
+ idempotencyKey: string;
14
+ failedAt: number;
15
+ reason: string;
16
+ }): Promise<NotificationRecord>;
17
+ export declare function listPendingNotifications(): Promise<NotificationRecord[]>;
18
+ export declare function findSentNotificationByRequest(input: {
19
+ kind: Exclude<NotificationKind, "sessionError">;
20
+ routeKey: string;
21
+ handle: string;
22
+ }): Promise<NotificationRecord | undefined>;
23
+ export declare function purgeTerminalNotificationsBefore(input: {
24
+ cutoffAt: number;
25
+ }): Promise<number>;