opencode-copilot-account-switcher 0.14.2 → 0.14.3

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.
@@ -19,6 +19,7 @@ type WechatBindFlowInput = {
19
19
  resetOperatorBinding?: typeof resetOperatorBinding;
20
20
  readCommonSettings: () => Promise<CommonSettingsStore>;
21
21
  writeCommonSettings: (settings: CommonSettingsStore) => Promise<void>;
22
+ writeLine?: (line: string) => Promise<void>;
22
23
  now?: () => number;
23
24
  };
24
25
  export declare function runWechatBindFlow(input: WechatBindFlowInput): Promise<WechatBindFlowResult>;
@@ -1,6 +1,7 @@
1
1
  import { bindOperator, readOperatorBinding, rebindOperator, resetOperatorBinding } from "./operator-store.js";
2
2
  import { loadOpenClawWeixinPublicHelpers } from "./compat/openclaw-public-helpers.js";
3
3
  import { buildOpenClawMenuAccount } from "./openclaw-account-adapter.js";
4
+ const DEFAULT_QR_WAIT_TIMEOUT_MS = 480000;
4
5
  function pickFirstNonEmptyString(...values) {
5
6
  for (const value of values) {
6
7
  if (typeof value === "string" && value.trim().length > 0) {
@@ -15,6 +16,22 @@ function toErrorMessage(error) {
15
16
  }
16
17
  return String(error);
17
18
  }
19
+ function pickQrTerminal(value) {
20
+ return pickFirstNonEmptyString(value?.terminalQr, value?.qrTerminal, value?.qrText, value?.asciiQr);
21
+ }
22
+ function pickQrUrl(value) {
23
+ return pickFirstNonEmptyString(value?.qrDataUrl, value?.qrUrl, value?.url, value?.loginUrl);
24
+ }
25
+ function isTimeoutWaitResult(value) {
26
+ return Boolean(value && typeof value === "object" && "status" in value && String(value.status) === "timeout");
27
+ }
28
+ async function rollbackBinding(action, previousOperatorBinding, persistOperatorRebinding, clearOperatorBinding) {
29
+ if (action === "wechat-rebind" && previousOperatorBinding) {
30
+ await persistOperatorRebinding(previousOperatorBinding).catch(() => { });
31
+ return;
32
+ }
33
+ await clearOperatorBinding().catch(() => { });
34
+ }
18
35
  export async function runWechatBindFlow(input) {
19
36
  const now = input.now ?? Date.now;
20
37
  const loadPublicHelpers = input.loadPublicHelpers ?? loadOpenClawWeixinPublicHelpers;
@@ -22,12 +39,33 @@ export async function runWechatBindFlow(input) {
22
39
  const persistOperatorRebinding = input.rebindOperator ?? rebindOperator;
23
40
  const loadOperatorBinding = input.readOperatorBinding ?? readOperatorBinding;
24
41
  const clearOperatorBinding = input.resetOperatorBinding ?? resetOperatorBinding;
42
+ const writeLine = input.writeLine ?? (async (line) => {
43
+ process.stdout.write(`${line}\n`);
44
+ });
25
45
  try {
26
46
  const helpers = await loadPublicHelpers();
27
47
  const started = await Promise.resolve(helpers.qrGateway.loginWithQrStart({ source: "menu", action: input.action }));
28
- const sessionKey = pickFirstNonEmptyString(started?.sessionKey, started?.key);
29
- const waited = await Promise.resolve(helpers.qrGateway.loginWithQrWait({ timeoutMs: 120000, sessionKey }));
30
- const accountId = pickFirstNonEmptyString(helpers.latestAccountState?.accountId, waited?.accountId, (await helpers.accountHelpers.listAccountIds()).at(-1));
48
+ const qrTerminal = pickQrTerminal(started);
49
+ const qrUrl = pickQrUrl(started);
50
+ const qrStartMessage = pickFirstNonEmptyString(started?.message, started?.detail, started?.reason);
51
+ const sessionKey = pickFirstNonEmptyString(started?.sessionKey, started?.key, started?.accountId);
52
+ if (qrTerminal) {
53
+ await writeLine(qrTerminal);
54
+ }
55
+ else if (qrUrl) {
56
+ await writeLine(`QR URL fallback: ${qrUrl}`);
57
+ }
58
+ else {
59
+ throw new Error(qrStartMessage || "invalid qr login result: missing qr code or qr url");
60
+ }
61
+ const waited = await Promise.resolve(helpers.qrGateway.loginWithQrWait({ timeoutMs: DEFAULT_QR_WAIT_TIMEOUT_MS, sessionKey }));
62
+ if (isTimeoutWaitResult(waited)) {
63
+ throw new Error("qr login timed out before completion");
64
+ }
65
+ if (waited && typeof waited === "object" && "connected" in waited && waited.connected === false) {
66
+ throw new Error("qr login did not complete");
67
+ }
68
+ const accountId = pickFirstNonEmptyString(waited?.accountId, helpers.latestAccountState?.accountId, (await helpers.accountHelpers.listAccountIds()).at(-1));
31
69
  const userId = pickFirstNonEmptyString(waited?.userId, waited?.openid, waited?.uid);
32
70
  if (!accountId) {
33
71
  throw new Error("missing accountId after qr login");
@@ -42,45 +80,53 @@ export async function runWechatBindFlow(input) {
42
80
  boundAt,
43
81
  };
44
82
  const previousOperatorBinding = input.action === "wechat-rebind" ? await loadOperatorBinding() : undefined;
45
- if (input.action === "wechat-rebind") {
46
- await persistOperatorRebinding(operatorBinding);
47
- }
48
- else {
49
- await persistOperatorBinding(operatorBinding);
50
- }
51
- const menuAccount = await buildOpenClawMenuAccount({
52
- latestAccountState: helpers.latestAccountState,
53
- accountHelpers: helpers.accountHelpers,
54
- });
55
- const settings = await input.readCommonSettings();
56
- const notifications = settings.wechat?.notifications ?? {
57
- enabled: true,
58
- question: true,
59
- permission: true,
60
- sessionError: true,
61
- };
62
- settings.wechat = {
63
- ...settings.wechat,
64
- notifications,
65
- primaryBinding: {
66
- accountId,
67
- userId,
68
- name: menuAccount?.name,
69
- enabled: menuAccount?.enabled,
70
- configured: menuAccount?.configured,
71
- boundAt,
72
- },
73
- };
83
+ let menuAccount;
74
84
  try {
75
- await input.writeCommonSettings(settings);
76
- }
77
- catch (error) {
78
- if (input.action === "wechat-rebind" && previousOperatorBinding) {
79
- await persistOperatorRebinding(previousOperatorBinding).catch(() => { });
85
+ if (input.action === "wechat-rebind") {
86
+ await persistOperatorRebinding(operatorBinding);
80
87
  }
81
88
  else {
82
- await clearOperatorBinding().catch(() => { });
89
+ await persistOperatorBinding(operatorBinding);
83
90
  }
91
+ const menuAccountState = accountId
92
+ ? {
93
+ ...(helpers.latestAccountState ?? {
94
+ accountId,
95
+ token: "",
96
+ baseUrl: "https://ilinkai.weixin.qq.com",
97
+ }),
98
+ accountId,
99
+ userId,
100
+ boundAt,
101
+ }
102
+ : helpers.latestAccountState;
103
+ menuAccount = await buildOpenClawMenuAccount({
104
+ latestAccountState: menuAccountState,
105
+ accountHelpers: helpers.accountHelpers,
106
+ });
107
+ const settings = await input.readCommonSettings();
108
+ const notifications = settings.wechat?.notifications ?? {
109
+ enabled: true,
110
+ question: true,
111
+ permission: true,
112
+ sessionError: true,
113
+ };
114
+ settings.wechat = {
115
+ ...settings.wechat,
116
+ notifications,
117
+ primaryBinding: {
118
+ accountId,
119
+ userId,
120
+ name: menuAccount?.name,
121
+ enabled: menuAccount?.enabled,
122
+ configured: menuAccount?.configured,
123
+ boundAt,
124
+ },
125
+ };
126
+ await input.writeCommonSettings(settings);
127
+ }
128
+ catch (error) {
129
+ await rollbackBinding(input.action, previousOperatorBinding, persistOperatorRebinding, clearOperatorBinding);
84
130
  throw error;
85
131
  }
86
132
  return {
@@ -1,4 +1,6 @@
1
+ import { type QuestionAnswer } from "@opencode-ai/sdk/v2";
1
2
  import { type WechatStatusRuntime } from "./wechat-status-runtime.js";
3
+ import type { WechatSlashCommand } from "./command-parser.js";
2
4
  type BrokerWechatStatusRuntimeLifecycle = {
3
5
  start: () => Promise<void>;
4
6
  close: () => Promise<void>;
@@ -13,5 +15,45 @@ type BrokerWechatStatusRuntimeLifecycleDeps = {
13
15
  onRuntimeError?: (error: unknown) => void;
14
16
  };
15
17
  export declare function shouldEnableBrokerWechatStatusRuntime(env?: NodeJS.ProcessEnv): boolean;
18
+ type BrokerWechatSlashHandlerClient = {
19
+ question?: {
20
+ list?: (input?: {
21
+ directory?: string;
22
+ }) => Promise<{
23
+ data?: Array<{
24
+ id?: string;
25
+ }>;
26
+ } | Array<{
27
+ id?: string;
28
+ }> | undefined>;
29
+ reply?: (input: {
30
+ requestID: string;
31
+ directory?: string;
32
+ answers?: Array<QuestionAnswer>;
33
+ }) => Promise<unknown>;
34
+ };
35
+ permission?: {
36
+ list?: (input?: {
37
+ directory?: string;
38
+ }) => Promise<{
39
+ data?: Array<{
40
+ id?: string;
41
+ }>;
42
+ } | Array<{
43
+ id?: string;
44
+ }> | undefined>;
45
+ reply?: (input: {
46
+ requestID: string;
47
+ directory?: string;
48
+ reply?: "once" | "always" | "reject";
49
+ message?: string;
50
+ }) => Promise<unknown>;
51
+ };
52
+ };
53
+ export declare function createBrokerWechatSlashCommandHandler(input: {
54
+ handleStatusCommand: () => Promise<string>;
55
+ client?: BrokerWechatSlashHandlerClient;
56
+ directory?: string;
57
+ }): (command: WechatSlashCommand) => Promise<string>;
16
58
  export declare function createBrokerWechatStatusRuntimeLifecycle(deps?: BrokerWechatStatusRuntimeLifecycleDeps): BrokerWechatStatusRuntimeLifecycle;
17
59
  export {};
@@ -3,9 +3,11 @@ import process from "node:process";
3
3
  import { readFileSync, rmSync } from "node:fs";
4
4
  import { mkdir, readFile, writeFile } from "node:fs/promises";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { createOpencodeClient as createOpencodeClientV2 } from "@opencode-ai/sdk/v2";
6
7
  import { startBrokerServer } from "./broker-server.js";
7
8
  import { WECHAT_FILE_MODE, wechatStateRoot } from "./state-paths.js";
8
9
  import { createWechatStatusRuntime } from "./wechat-status-runtime.js";
10
+ const BROKER_WECHAT_RUNTIME_AUTOSTART_DELAY_MS = 1_000;
9
11
  async function readPackageVersion() {
10
12
  const packageJsonPath = new URL("../../package.json", import.meta.url);
11
13
  return readFile(packageJsonPath, "utf8")
@@ -51,17 +53,65 @@ async function writeBrokerState(state, stateRoot) {
51
53
  await writeFile(filePath, JSON.stringify(state, null, 2), { mode: WECHAT_FILE_MODE });
52
54
  }
53
55
  export function shouldEnableBrokerWechatStatusRuntime(env = process.env) {
54
- return env.WECHAT_BROKER_ENABLE_STATUS_RUNTIME === "1";
56
+ void env;
57
+ return true;
58
+ }
59
+ function unwrapDataArray(value) {
60
+ if (Array.isArray(value)) {
61
+ return value;
62
+ }
63
+ return Array.isArray(value?.data) ? value.data : [];
64
+ }
65
+ function withOptionalDirectory(input, directory) {
66
+ if (typeof directory === "string" && directory.trim().length > 0) {
67
+ return {
68
+ ...input,
69
+ directory,
70
+ };
71
+ }
72
+ return input;
73
+ }
74
+ export function createBrokerWechatSlashCommandHandler(input) {
75
+ return async (command) => {
76
+ if (command.type === "status") {
77
+ return input.handleStatusCommand();
78
+ }
79
+ if (command.type === "reply") {
80
+ const questions = unwrapDataArray(await input.client?.question?.list?.(withOptionalDirectory({}, input.directory)));
81
+ const requestID = typeof questions[0]?.id === "string" ? questions[0].id : undefined;
82
+ if (!requestID) {
83
+ return "当前没有待回复问题";
84
+ }
85
+ await input.client?.question?.reply?.(withOptionalDirectory({
86
+ requestID,
87
+ answers: [[command.text]],
88
+ }, input.directory));
89
+ return `已回复问题:${requestID}`;
90
+ }
91
+ const permissions = unwrapDataArray(await input.client?.permission?.list?.(withOptionalDirectory({}, input.directory)));
92
+ const requestID = typeof permissions[0]?.id === "string" ? permissions[0].id : undefined;
93
+ if (!requestID) {
94
+ return "当前没有待处理权限请求";
95
+ }
96
+ await input.client?.permission?.reply?.(withOptionalDirectory({
97
+ requestID,
98
+ reply: command.reply,
99
+ ...(command.message ? { message: command.message } : {}),
100
+ }, input.directory));
101
+ return `已处理权限请求:${requestID} (${command.reply})`;
102
+ };
55
103
  }
56
104
  export function createBrokerWechatStatusRuntimeLifecycle(deps = {}) {
57
105
  const onRuntimeError = deps.onRuntimeError ?? ((error) => console.error(error));
58
- const handleWechatSlashCommand = deps.handleWechatSlashCommand ??
59
- (async (command) => {
60
- if (command.type === "status") {
61
- return "命令暂未实现:/status";
62
- }
63
- return `命令暂未实现:/${command.command}`;
64
- });
106
+ const v2Client = createOpencodeClientV2({
107
+ baseUrl: "http://localhost:4096",
108
+ directory: process.cwd(),
109
+ });
110
+ const handleWechatSlashCommand = deps.handleWechatSlashCommand ?? createBrokerWechatSlashCommandHandler({
111
+ handleStatusCommand: async () => "命令暂未实现:/status",
112
+ client: v2Client,
113
+ directory: process.cwd(),
114
+ });
65
115
  const createStatusRuntime = deps.createStatusRuntime ??
66
116
  ((statusRuntimeDeps) => createWechatStatusRuntime({
67
117
  onSlashCommand: async ({ command }) => statusRuntimeDeps.onSlashCommand({ command }),
@@ -124,10 +174,19 @@ async function run() {
124
174
  };
125
175
  await writeBrokerState(state, stateRoot);
126
176
  const wechatRuntimeLifecycle = createBrokerWechatStatusRuntimeLifecycle({
127
- handleWechatSlashCommand: server.handleWechatSlashCommand,
177
+ handleWechatSlashCommand: createBrokerWechatSlashCommandHandler({
178
+ handleStatusCommand: async () => server.handleWechatSlashCommand({ type: "status" }),
179
+ client: createOpencodeClientV2({
180
+ baseUrl: "http://localhost:4096",
181
+ directory: stateRoot,
182
+ }),
183
+ directory: stateRoot,
184
+ }),
128
185
  });
129
186
  if (shouldEnableBrokerWechatStatusRuntime()) {
130
- await wechatRuntimeLifecycle.start();
187
+ setTimeout(() => {
188
+ void wechatRuntimeLifecycle.start();
189
+ }, BROKER_WECHAT_RUNTIME_AUTOSTART_DELAY_MS);
131
190
  }
132
191
  const ownership = {
133
192
  pid: state.pid,
@@ -506,7 +506,10 @@ export async function startBrokerServer(endpoint) {
506
506
  const result = await collectStatus();
507
507
  return result.reply;
508
508
  }
509
- return `命令暂未实现:/${command.command}`;
509
+ if (command.type === "reply") {
510
+ return "命令暂未实现:/reply";
511
+ }
512
+ return "命令暂未实现:/allow";
510
513
  };
511
514
  const close = async () => {
512
515
  if (closed) {
@@ -1,7 +1,11 @@
1
1
  export type WechatSlashCommand = {
2
2
  type: "status";
3
3
  } | {
4
- type: "unimplemented";
5
- command: string;
4
+ type: "reply";
5
+ text: string;
6
+ } | {
7
+ type: "allow";
8
+ reply: "once" | "always" | "reject";
9
+ message?: string;
6
10
  };
7
11
  export declare function parseWechatSlashCommand(input: string): WechatSlashCommand | null;
@@ -6,11 +6,26 @@ export function parseWechatSlashCommand(input) {
6
6
  if (normalized === "/status") {
7
7
  return { type: "status" };
8
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 };
9
+ if (normalized.startsWith("/reply")) {
10
+ const text = normalized.slice("/reply".length).trim();
11
+ if (!text) {
12
+ return null;
13
13
  }
14
+ return { type: "reply", text };
15
+ }
16
+ if (normalized.startsWith("/allow")) {
17
+ const rest = normalized.slice("/allow".length).trim();
18
+ if (!rest) {
19
+ return null;
20
+ }
21
+ const [rawReply, ...messageParts] = rest.split(/\s+/);
22
+ if (rawReply !== "once" && rawReply !== "always" && rawReply !== "reject") {
23
+ return null;
24
+ }
25
+ const message = messageParts.join(" ").trim();
26
+ return message.length > 0
27
+ ? { type: "allow", reply: rawReply, message }
28
+ : { type: "allow", reply: rawReply };
14
29
  }
15
30
  return null;
16
31
  }
@@ -15,6 +15,12 @@ type CreateWechatStatusRuntimeInput = {
15
15
  onRuntimeError?: (error: unknown) => void;
16
16
  retryDelayMs?: number;
17
17
  longPollTimeoutMs?: number;
18
+ shouldReloadState?: (state: {
19
+ accountId: string;
20
+ baseUrl: string;
21
+ token: string;
22
+ getUpdatesBuf: string;
23
+ }) => boolean;
18
24
  };
19
25
  export type WechatStatusRuntime = {
20
26
  start: () => Promise<void>;
@@ -97,6 +97,7 @@ export function createWechatStatusRuntime(input = {}) {
97
97
  const onRuntimeError = input.onRuntimeError ?? (() => { });
98
98
  const retryDelayMs = normalizePositiveInteger(input.retryDelayMs, DEFAULT_RETRY_DELAY_MS);
99
99
  const longPollTimeoutMs = normalizePositiveInteger(input.longPollTimeoutMs, DEFAULT_LONG_POLL_TIMEOUT_MS);
100
+ const shouldReloadState = input.shouldReloadState ?? (() => false);
100
101
  let started = false;
101
102
  let closed = false;
102
103
  let stopController = null;
@@ -105,6 +106,7 @@ export function createWechatStatusRuntime(input = {}) {
105
106
  let initialized = null;
106
107
  while (!signal.aborted) {
107
108
  try {
109
+ let justInitialized = false;
108
110
  if (!initialized) {
109
111
  const helpers = await withAbort(loadPublicHelpers(input.publicHelpersOptions), signal);
110
112
  const latestAccountState = helpers.latestAccountState;
@@ -118,6 +120,16 @@ export function createWechatStatusRuntime(input = {}) {
118
120
  token: latestAccountState.token,
119
121
  getUpdatesBuf: typeof latestAccountState.getUpdatesBuf === "string" ? latestAccountState.getUpdatesBuf : "",
120
122
  };
123
+ justInitialized = true;
124
+ }
125
+ if (!justInitialized && initialized && shouldReloadState({
126
+ accountId: initialized.accountId,
127
+ baseUrl: initialized.baseUrl,
128
+ token: initialized.token,
129
+ getUpdatesBuf: initialized.getUpdatesBuf,
130
+ })) {
131
+ initialized = null;
132
+ continue;
121
133
  }
122
134
  const response = await withAbort(initialized.helpers.getUpdates({
123
135
  baseUrl: initialized.baseUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-copilot-account-switcher",
3
- "version": "0.14.2",
3
+ "version": "0.14.3",
4
4
  "description": "GitHub Copilot account switcher plugin for OpenCode",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",