vibe-coding-master 0.4.34 → 0.4.36
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/README.md +16 -16
- package/dist/backend/api/gateway-routes.js +0 -3
- package/dist/backend/gateway/channels/lark-channel.js +3 -2
- package/dist/backend/gateway/channels/lark-registration.js +151 -96
- package/dist/backend/gateway/gateway-service.js +17 -88
- package/dist/backend/gateway/gateway-settings-service.js +1 -6
- package/dist-frontend/assets/{index-B0hNcYoB.js → index-XvzXPPEy.js} +42 -42
- package/dist-frontend/index.html +1 -1
- package/docs/gateway-design.md +41 -39
- package/docs/product-design.md +20 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -221,7 +221,7 @@ The left sidebar is intentionally compact and collapsible:
|
|
|
221
221
|
- `Settings`: `Theme`, `Flow pause alert`, `Try alert`, `Messages`, and `Events`.
|
|
222
222
|
- `Translation`: global conversation translation, auto-send, target language, output scope, file translation, bootstrap, memory update, session status, and Translator session access.
|
|
223
223
|
- `Gate Review Gates`: global gate switches for architecture plan, validation adequacy, and final diff.
|
|
224
|
-
- `Gateway`: Weixin iLink or Lark
|
|
224
|
+
- `Gateway`: Weixin iLink or Lark setup, Gateway on/off, Gateway translation, and QR login/setup.
|
|
225
225
|
- `VCM Harness`: fixed-install status, bootstrap completion checks, and the bootstrap terminal when one is running.
|
|
226
226
|
- `New Task`: one `task name` input.
|
|
227
227
|
- `Tasks`: task list and task status.
|
|
@@ -238,15 +238,16 @@ When VCM is connected to an active task, the bottom of the sidebar shows a task
|
|
|
238
238
|
|
|
239
239
|
## Mobile Gateway
|
|
240
240
|
|
|
241
|
-
VCM Gateway lets
|
|
241
|
+
VCM Gateway lets mobile chat clients talk to one desktop VCM instance. It supports Weixin iLink and Lark. It is a mobile control surface for the current desktop VCM, not a remote terminal.
|
|
242
242
|
|
|
243
243
|
Gateway rules:
|
|
244
244
|
|
|
245
245
|
- Weixin is DM only; Lark can receive group messages only when the bot is mentioned.
|
|
246
|
-
-
|
|
247
|
-
-
|
|
246
|
+
- Weixin binds one phone identity to one desktop VCM instance.
|
|
247
|
+
- Lark accepts any DM or @mention from a chat that can reach the bot; the most recent active Lark chat becomes the PM reply target.
|
|
248
|
+
- The active mobile chat can manage projects and tasks available to that desktop VCM instance.
|
|
248
249
|
- When the desktop UI has an active task selected, Gateway uses that task automatically.
|
|
249
|
-
- After
|
|
250
|
+
- After setup, VCM keeps a lightweight Gateway connection for `/start` and read-only commands even when the `Gateway` toggle is off.
|
|
250
251
|
- VCM caches the latest PM reply for each task locally, so `/start` can immediately return the current task's latest PM status when available.
|
|
251
252
|
- Plain text messages go only to the current task's `project-manager`.
|
|
252
253
|
- Gateway never sends directly to `architect`, `coder`, or `reviewer`.
|
|
@@ -279,11 +280,10 @@ The `Gateway` toggle is disabled until a QR login has produced a usable iLink to
|
|
|
279
280
|
2. Click `Start QR Setup`.
|
|
280
281
|
3. Scan the QR code with Lark and approve bot creation.
|
|
281
282
|
4. Click `Confirm` in the setup dialog.
|
|
282
|
-
5.
|
|
283
|
-
6. Send
|
|
284
|
-
7. After binding succeeds, turn `Gateway` on in the sidebar.
|
|
283
|
+
5. Turn `Gateway` on in the sidebar.
|
|
284
|
+
6. Send a DM to the bot, or @mention it from a group.
|
|
285
285
|
|
|
286
|
-
Lark QR setup creates/configures the bot app and stores the resulting App ID/App Secret in local VCM state. The App Secret is not shown in the UI.
|
|
286
|
+
Lark QR setup creates/configures the bot app and stores the resulting App ID/App Secret in local VCM state. The App Secret is not shown in the UI. Any Lark chat that can message the bot can control Gateway; VCM always treats the most recent active Lark chat as the PM reply target.
|
|
287
287
|
|
|
288
288
|
When Gateway is turned on, VCM automatically turns off the browser `Flow pause alert` and disables `Try alert`. Gateway becomes the notification path, so the browser should not show blocking flow-pause dialogs while the user is managing the task from the phone.
|
|
289
289
|
|
|
@@ -296,14 +296,14 @@ When Gateway translation is on:
|
|
|
296
296
|
- Mobile input is translated to English before being submitted to PM.
|
|
297
297
|
- The prompt sent to PM includes only the translated English text with a `[VCM Gateway]` marker.
|
|
298
298
|
- The original Chinese text is not included in the PM prompt.
|
|
299
|
-
- PM replies are translated before VCM sends them to the
|
|
299
|
+
- PM replies are translated before VCM sends them to the active mobile chat.
|
|
300
300
|
- If PM reply translation fails or times out, VCM sends a translation failure notice instead of the English source. The user can send `/retry` to retry the latest failed Gateway output translation.
|
|
301
301
|
|
|
302
302
|
When Gateway translation is off, plain mobile text is sent to PM as-is.
|
|
303
303
|
|
|
304
304
|
### Commands
|
|
305
305
|
|
|
306
|
-
After Gateway is
|
|
306
|
+
After Gateway is configured, send commands in the active mobile conversation.
|
|
307
307
|
|
|
308
308
|
When `Gateway` is off, only these commands are accepted:
|
|
309
309
|
|
|
@@ -353,9 +353,9 @@ Typical mobile flow:
|
|
|
353
353
|
|
|
354
354
|
### Command Behavior
|
|
355
355
|
|
|
356
|
-
- `/status`: shows Gateway,
|
|
356
|
+
- `/status`: shows Gateway, active chat, translation, current project, current task, and last poll status.
|
|
357
357
|
- `/status` also adopts the current desktop project/task when one is selected.
|
|
358
|
-
- `/start`: turns Gateway on from the
|
|
358
|
+
- `/start`: turns Gateway on from the active mobile conversation so full mobile task operations and PM messages are allowed. If the current task has a cached latest PM reply, `/start` includes it in the response.
|
|
359
359
|
- `/retry`: retries the latest failed Gateway output translation in the current VCM process.
|
|
360
360
|
- `/projects`: lists the current/recent repositories known by the desktop VCM.
|
|
361
361
|
- `/use-project <index-or-path>`: selects the Gateway's current project context.
|
|
@@ -378,12 +378,12 @@ Typical mobile flow:
|
|
|
378
378
|
- If the QR dialog does not appear, refresh the page and click `Start QR Login` again.
|
|
379
379
|
- If the QR status stays `wait`, confirm the login on the phone and click `Confirm` again.
|
|
380
380
|
- If the QR code expires, start a new QR login.
|
|
381
|
-
- If `Gateway` cannot be enabled, bind Weixin or complete Lark QR setup
|
|
382
|
-
- If `/start` or read-only commands do not receive replies, check that the selected channel is connected and
|
|
381
|
+
- If `Gateway` cannot be enabled, bind Weixin or complete Lark QR setup first.
|
|
382
|
+
- If `/start` or read-only commands do not receive replies, check that the selected channel is connected and that Lark messages are sent as DM or group @mentions.
|
|
383
383
|
- If PM messages or task-changing commands are rejected, check that Gateway is on.
|
|
384
384
|
- If plain text cannot be sent to PM, select a project and task first, and make sure the task's PM session is running and idle.
|
|
385
385
|
- If PM replies are not pushed, check that Gateway is on and the PM session is producing normal Claude transcript output.
|
|
386
|
-
- If PM reply translation fails, send `/retry` from the
|
|
386
|
+
- If PM reply translation fails, send `/retry` from the active mobile conversation. Retry state is memory-only and is cleared when VCM restarts.
|
|
387
387
|
|
|
388
388
|
## Translation
|
|
389
389
|
|
|
@@ -8,9 +8,6 @@ export function registerGatewayRoutes(app, deps) {
|
|
|
8
8
|
app.post("/api/gateway/qr/start", async () => {
|
|
9
9
|
return deps.gatewayService.startQrLogin();
|
|
10
10
|
});
|
|
11
|
-
app.post("/api/gateway/pairing-code", async () => {
|
|
12
|
-
return deps.gatewayService.createPairingCode();
|
|
13
|
-
});
|
|
14
11
|
app.post("/api/gateway/qr/check", async (request) => {
|
|
15
12
|
return deps.gatewayService.checkQrLogin(request.body);
|
|
16
13
|
});
|
|
@@ -21,10 +21,11 @@ export function createLarkChannel(options = {}) {
|
|
|
21
21
|
ready: Promise.resolve(),
|
|
22
22
|
error: null
|
|
23
23
|
};
|
|
24
|
+
const sdkDomain = input.domain === "lark" ? Lark.Domain.Lark : Lark.Domain.Feishu;
|
|
24
25
|
const client = new Lark.Client({
|
|
25
26
|
appId: input.appId,
|
|
26
27
|
appSecret: input.appSecret,
|
|
27
|
-
domain:
|
|
28
|
+
domain: sdkDomain
|
|
28
29
|
});
|
|
29
30
|
const dispatcher = new Lark.EventDispatcher({}).register({
|
|
30
31
|
"im.message.receive_v1": async (data) => {
|
|
@@ -40,7 +41,7 @@ export function createLarkChannel(options = {}) {
|
|
|
40
41
|
const wsClient = new Lark.WSClient({
|
|
41
42
|
appId: input.appId,
|
|
42
43
|
appSecret: input.appSecret,
|
|
43
|
-
domain:
|
|
44
|
+
domain: sdkDomain,
|
|
44
45
|
source: "vcm-gateway",
|
|
45
46
|
autoReconnect: true,
|
|
46
47
|
loggerLevel: options.loggerLevel,
|
|
@@ -1,115 +1,146 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
const ACCOUNTS_HOSTS = {
|
|
3
|
+
feishu: "accounts.feishu.cn",
|
|
4
|
+
lark: "accounts.larksuite.com"
|
|
4
5
|
};
|
|
5
6
|
const OPEN_BASE_URLS = {
|
|
6
7
|
feishu: "https://open.feishu.cn",
|
|
7
8
|
lark: "https://open.larksuite.com"
|
|
8
9
|
};
|
|
9
|
-
const REGISTRATION_PATH = "/oauth/v1/app/registration";
|
|
10
10
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
11
|
+
const DEFAULT_REGISTRATION_EXPIRES_IN_SECONDS = 600;
|
|
12
|
+
const DEFAULT_REGISTRATION_INTERVAL_SECONDS = 5;
|
|
11
13
|
export function createLarkRegistrationClient(deps = {}) {
|
|
12
14
|
const fetchImpl = deps.fetch ?? fetch;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"
|
|
19
|
-
}
|
|
20
|
-
|
|
15
|
+
let registerAppPromise = deps.registerApp ? Promise.resolve(deps.registerApp) : null;
|
|
16
|
+
let active = null;
|
|
17
|
+
async function getRegisterApp() {
|
|
18
|
+
registerAppPromise ??= import("@larksuiteoapi/node-sdk").then((sdk) => {
|
|
19
|
+
if (typeof sdk.registerApp !== "function") {
|
|
20
|
+
throw new Error("Lark SDK does not expose registerApp.");
|
|
21
|
+
}
|
|
22
|
+
return sdk.registerApp;
|
|
21
23
|
});
|
|
22
|
-
return
|
|
24
|
+
return registerAppPromise;
|
|
23
25
|
}
|
|
24
26
|
return {
|
|
25
|
-
async init(
|
|
26
|
-
|
|
27
|
-
const data = responseData(payload);
|
|
28
|
-
const methods = Array.isArray(data.supported_auth_methods)
|
|
29
|
-
? data.supported_auth_methods.filter((value) => typeof value === "string")
|
|
30
|
-
: [];
|
|
31
|
-
if (!methods.includes("client_secret")) {
|
|
32
|
-
throw new Error(`Lark QR setup does not support client_secret auth. Supported methods: ${methods.join(", ") || "none"}.`);
|
|
33
|
-
}
|
|
27
|
+
async init() {
|
|
28
|
+
await getRegisterApp();
|
|
34
29
|
},
|
|
35
30
|
async begin(domain) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
31
|
+
active?.controller.abort();
|
|
32
|
+
const registerApp = await getRegisterApp();
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
const id = `lark-registration-${Date.now()}-${randomUUID()}`;
|
|
35
|
+
const session = {
|
|
36
|
+
id,
|
|
37
|
+
domain,
|
|
38
|
+
controller,
|
|
39
|
+
expiresAtMs: Date.now() + DEFAULT_REGISTRATION_EXPIRES_IN_SECONDS * 1000,
|
|
40
|
+
qrUrl: "",
|
|
41
|
+
intervalSeconds: DEFAULT_REGISTRATION_INTERVAL_SECONDS,
|
|
42
|
+
result: null,
|
|
43
|
+
message: undefined,
|
|
44
|
+
promise: Promise.resolve()
|
|
45
|
+
};
|
|
46
|
+
active = session;
|
|
47
|
+
let qrReadySettled = false;
|
|
48
|
+
let resolveQrReady = () => undefined;
|
|
49
|
+
let rejectQrReady = () => undefined;
|
|
50
|
+
const qrReady = new Promise((resolve, reject) => {
|
|
51
|
+
resolveQrReady = resolve;
|
|
52
|
+
rejectQrReady = reject;
|
|
41
53
|
});
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
session.promise = (async () => {
|
|
55
|
+
try {
|
|
56
|
+
const result = await registerApp({
|
|
57
|
+
domain: ACCOUNTS_HOSTS[domain],
|
|
58
|
+
larkDomain: ACCOUNTS_HOSTS.lark,
|
|
59
|
+
source: "vcm",
|
|
60
|
+
signal: controller.signal,
|
|
61
|
+
onQRCodeReady(info) {
|
|
62
|
+
const qrUrl = info.url;
|
|
63
|
+
const expireIn = positiveNumberOr(info.expireIn, DEFAULT_REGISTRATION_EXPIRES_IN_SECONDS);
|
|
64
|
+
session.qrUrl = qrUrl;
|
|
65
|
+
session.expiresAtMs = Date.now() + expireIn * 1000;
|
|
66
|
+
if (!qrReadySettled) {
|
|
67
|
+
qrReadySettled = true;
|
|
68
|
+
resolveQrReady({ url: qrUrl, expireIn });
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
onStatusChange(info) {
|
|
72
|
+
session.message = formatSdkStatus(info);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
const appId = stringOrNull(result.client_id);
|
|
76
|
+
const appSecret = stringOrNull(result.client_secret);
|
|
77
|
+
const resultDomain = result.user_info?.tenant_brand === "lark" ? "lark" : domain;
|
|
78
|
+
if (!appId || !appSecret) {
|
|
79
|
+
session.result = {
|
|
80
|
+
status: "failed",
|
|
81
|
+
message: "Lark QR setup completed without app credentials."
|
|
82
|
+
};
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const bot = await probeBot(fetchImpl, {
|
|
86
|
+
appId,
|
|
87
|
+
appSecret,
|
|
88
|
+
domain: resultDomain
|
|
89
|
+
});
|
|
90
|
+
session.result = {
|
|
91
|
+
status: "confirmed",
|
|
92
|
+
appId,
|
|
93
|
+
appSecret,
|
|
94
|
+
domain: resultDomain,
|
|
95
|
+
openId: stringOrNull(result.user_info?.open_id),
|
|
96
|
+
botName: bot.botName,
|
|
97
|
+
botOpenId: bot.botOpenId
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
const mapped = mapSdkError(error);
|
|
102
|
+
session.result = mapped;
|
|
103
|
+
if (!qrReadySettled) {
|
|
104
|
+
qrReadySettled = true;
|
|
105
|
+
rejectQrReady(new Error(mapped.message ?? "Lark QR setup failed."));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
})();
|
|
109
|
+
const qrInfo = await qrReady;
|
|
51
110
|
return {
|
|
52
111
|
domain,
|
|
53
|
-
deviceCode,
|
|
54
|
-
qrUrl,
|
|
55
|
-
userCode:
|
|
56
|
-
intervalSeconds:
|
|
57
|
-
expiresInSeconds:
|
|
112
|
+
deviceCode: id,
|
|
113
|
+
qrUrl: qrInfo.url,
|
|
114
|
+
userCode: extractUserCode(qrInfo.url),
|
|
115
|
+
intervalSeconds: session.intervalSeconds,
|
|
116
|
+
expiresInSeconds: qrInfo.expireIn
|
|
58
117
|
};
|
|
59
118
|
},
|
|
60
119
|
async poll(input) {
|
|
61
|
-
|
|
62
|
-
action: "poll",
|
|
63
|
-
device_code: input.deviceCode,
|
|
64
|
-
tp: "ob_app"
|
|
65
|
-
});
|
|
66
|
-
const data = responseData(payload);
|
|
67
|
-
const userInfo = isObject(data.user_info) ? data.user_info : {};
|
|
68
|
-
const tenantBrand = stringOrNull(userInfo.tenant_brand);
|
|
69
|
-
const domain = tenantBrand === "lark" ? "lark" : input.domain;
|
|
70
|
-
const appId = stringOrNull(data.client_id);
|
|
71
|
-
const appSecret = stringOrNull(data.client_secret);
|
|
72
|
-
if (appId && appSecret) {
|
|
73
|
-
const bot = await probeBot(fetchImpl, {
|
|
74
|
-
appId,
|
|
75
|
-
appSecret,
|
|
76
|
-
domain
|
|
77
|
-
});
|
|
120
|
+
if (!active || active.id !== input.deviceCode) {
|
|
78
121
|
return {
|
|
79
|
-
status: "
|
|
80
|
-
|
|
81
|
-
appSecret,
|
|
82
|
-
domain,
|
|
83
|
-
openId: stringOrNull(userInfo.open_id),
|
|
84
|
-
botName: bot.botName,
|
|
85
|
-
botOpenId: bot.botOpenId
|
|
122
|
+
status: "expired",
|
|
123
|
+
message: "Lark QR setup session is no longer active. Start a new setup."
|
|
86
124
|
};
|
|
87
125
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return { status: "expired", message: "Lark QR setup expired. Start a new setup." };
|
|
126
|
+
if (active.result) {
|
|
127
|
+
return active.result;
|
|
91
128
|
}
|
|
92
|
-
if (
|
|
93
|
-
|
|
129
|
+
if (Date.now() > active.expiresAtMs) {
|
|
130
|
+
active.controller.abort();
|
|
131
|
+
active.result = {
|
|
132
|
+
status: "expired",
|
|
133
|
+
message: "Lark QR setup expired. Start a new setup."
|
|
134
|
+
};
|
|
135
|
+
return active.result;
|
|
94
136
|
}
|
|
95
|
-
const errorDescription = stringOrNull(data.error_description)
|
|
96
|
-
?? stringOrNull(payload.error_description)
|
|
97
|
-
?? stringOrNull(data.message)
|
|
98
|
-
?? stringOrNull(payload.message)
|
|
99
|
-
?? stringOrNull(data.msg)
|
|
100
|
-
?? stringOrNull(payload.msg);
|
|
101
137
|
return {
|
|
102
138
|
status: "wait",
|
|
103
|
-
message:
|
|
104
|
-
? [error, errorDescription].filter(Boolean).join(": ")
|
|
105
|
-
: undefined
|
|
139
|
+
message: active.message
|
|
106
140
|
};
|
|
107
141
|
}
|
|
108
142
|
};
|
|
109
143
|
}
|
|
110
|
-
function responseData(payload) {
|
|
111
|
-
return isObject(payload.data) ? payload.data : payload;
|
|
112
|
-
}
|
|
113
144
|
async function probeBot(fetchImpl, input) {
|
|
114
145
|
try {
|
|
115
146
|
const tokenPayload = await fetchJson(fetchImpl, `${OPEN_BASE_URLS[input.domain]}/open-apis/auth/v3/tenant_access_token/internal`, {
|
|
@@ -184,29 +215,53 @@ async function fetchJson(fetchImpl, url, init = {}) {
|
|
|
184
215
|
}
|
|
185
216
|
}
|
|
186
217
|
function formatPayloadError(payload) {
|
|
187
|
-
const data = responseData(payload);
|
|
188
218
|
const message = [
|
|
189
|
-
stringOrNull(
|
|
190
|
-
stringOrNull(
|
|
191
|
-
stringOrNull(
|
|
192
|
-
stringOrNull(
|
|
193
|
-
typeof data.code === "number" || typeof data.code === "string" ? `code ${data.code}` : null,
|
|
219
|
+
stringOrNull(payload.error),
|
|
220
|
+
stringOrNull(payload.error_description),
|
|
221
|
+
stringOrNull(payload.message),
|
|
222
|
+
stringOrNull(payload.msg),
|
|
194
223
|
typeof payload.code === "number" || typeof payload.code === "string" ? `code ${payload.code}` : null
|
|
195
224
|
].filter(Boolean).join(": ");
|
|
196
225
|
return message ? ` (${message})` : "";
|
|
197
226
|
}
|
|
198
|
-
function
|
|
199
|
-
|
|
200
|
-
|
|
227
|
+
function mapSdkError(error) {
|
|
228
|
+
const code = isObject(error) ? stringOrNull(error.code) : null;
|
|
229
|
+
const description = isObject(error) ? stringOrNull(error.description) : null;
|
|
230
|
+
if (code === "expired_token") {
|
|
231
|
+
return { status: "expired", message: "Lark QR setup expired. Start a new setup." };
|
|
232
|
+
}
|
|
233
|
+
if (code === "access_denied") {
|
|
234
|
+
return { status: "failed", message: "Lark QR setup was denied." };
|
|
201
235
|
}
|
|
236
|
+
if (code === "abort") {
|
|
237
|
+
return { status: "failed", message: "Lark QR setup was cancelled." };
|
|
238
|
+
}
|
|
239
|
+
if (error instanceof Error) {
|
|
240
|
+
return { status: "failed", message: error.message };
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
status: "failed",
|
|
244
|
+
message: description ?? (code ? `Lark QR setup failed: ${code}` : "Lark QR setup failed.")
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function formatSdkStatus(info) {
|
|
248
|
+
if (info.status === "domain_switched") {
|
|
249
|
+
return "Detected a Lark tenant; continuing setup on the Lark domain.";
|
|
250
|
+
}
|
|
251
|
+
if (info.status === "slow_down") {
|
|
252
|
+
return info.interval
|
|
253
|
+
? `Lark requested slower setup polling; retrying every ${info.interval} seconds.`
|
|
254
|
+
: "Lark requested slower setup polling.";
|
|
255
|
+
}
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
function extractUserCode(qrUrl) {
|
|
202
259
|
try {
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
url.searchParams.set("tp", "hermes");
|
|
206
|
-
return url.toString();
|
|
260
|
+
const value = new URL(qrUrl).searchParams.get("user_code");
|
|
261
|
+
return stringOrNull(value);
|
|
207
262
|
}
|
|
208
263
|
catch {
|
|
209
|
-
return
|
|
264
|
+
return null;
|
|
210
265
|
}
|
|
211
266
|
}
|
|
212
267
|
function positiveNumberOr(input, fallback) {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { randomInt } from "node:crypto";
|
|
2
1
|
import { readFile } from "node:fs/promises";
|
|
3
2
|
import { CORE_VCM_ROLE_DEFINITIONS, GATE_REVIEWER_ROLE_DEFINITION, VCM_ROLE_NAMES } from "../../shared/constants.js";
|
|
4
3
|
import { VcmError } from "../errors.js";
|
|
@@ -8,7 +7,6 @@ import { parseAssistantContent, resolveExistingClaudeTranscriptPath } from "../s
|
|
|
8
7
|
import { parseGatewayCommand } from "./gateway-command-parser.js";
|
|
9
8
|
import { createLarkRegistrationClient } from "./channels/lark-registration.js";
|
|
10
9
|
const QR_LOGIN_TTL_MS = 8 * 60 * 1000;
|
|
11
|
-
const PAIRING_CODE_TTL_MS = 10 * 60 * 1000;
|
|
12
10
|
const CLOSE_CONFIRM_TTL_MS = 10 * 60 * 1000;
|
|
13
11
|
const POLL_ERROR_BACKOFF_MS = 2_000;
|
|
14
12
|
const POLL_LONG_BACKOFF_MS = 30_000;
|
|
@@ -203,37 +201,10 @@ export function createGatewayService(deps) {
|
|
|
203
201
|
});
|
|
204
202
|
return;
|
|
205
203
|
}
|
|
206
|
-
|
|
207
|
-
settings
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
await reply(settings, update.fromUserId, "Lark Gateway is not paired. Generate a pairing code in desktop VCM, then send /bind CODE here.");
|
|
211
|
-
await recordMessageStatus("inbound", "ignored", update.text, "lark gateway not paired");
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
await deps.settings.saveSettings({
|
|
215
|
-
...settings,
|
|
216
|
-
binding: {
|
|
217
|
-
...settings.binding,
|
|
218
|
-
boundUserId: update.fromUserId,
|
|
219
|
-
loginUserId: update.fromUserId,
|
|
220
|
-
pairingCode: null,
|
|
221
|
-
pairingCodeExpiresAt: null,
|
|
222
|
-
chatIds: update.chatId
|
|
223
|
-
? {
|
|
224
|
-
...settings.binding.chatIds,
|
|
225
|
-
[update.fromUserId]: update.chatId
|
|
226
|
-
}
|
|
227
|
-
: settings.binding.chatIds
|
|
228
|
-
},
|
|
229
|
-
updatedAt: now()
|
|
230
|
-
});
|
|
231
|
-
await reply(await deps.settings.loadSettings(), update.fromUserId, "Lark Gateway bound. Send /help for available commands.");
|
|
232
|
-
await recordMessageStatus("inbound", "ok", update.text, undefined, "bind");
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
settings = await saveInboundMetadata(settings, update, { bindIfMissing: true });
|
|
236
|
-
if (settings.binding.boundUserId !== update.fromUserId) {
|
|
204
|
+
settings = await saveInboundMetadata(settings, update, {
|
|
205
|
+
bind: settings.channel === "lark" ? "always" : "if-missing"
|
|
206
|
+
});
|
|
207
|
+
if (settings.channel !== "lark" && settings.binding.boundUserId !== update.fromUserId) {
|
|
237
208
|
await reply(settings, update.fromUserId, "This VCM gateway is already bound to another user.");
|
|
238
209
|
await recordMessageStatus("inbound", "ignored", update.text, "unbound user");
|
|
239
210
|
return;
|
|
@@ -268,11 +239,19 @@ export function createGatewayService(deps) {
|
|
|
268
239
|
}
|
|
269
240
|
}
|
|
270
241
|
async function saveInboundMetadata(settings, update, options = {}) {
|
|
242
|
+
const bindMode = options.bind ?? "never";
|
|
271
243
|
return deps.settings.saveSettings({
|
|
272
244
|
...settings,
|
|
273
245
|
binding: {
|
|
274
246
|
...settings.binding,
|
|
275
|
-
boundUserId:
|
|
247
|
+
boundUserId: bindMode === "always"
|
|
248
|
+
? update.fromUserId
|
|
249
|
+
: bindMode === "if-missing"
|
|
250
|
+
? settings.binding.boundUserId ?? update.fromUserId
|
|
251
|
+
: settings.binding.boundUserId,
|
|
252
|
+
loginUserId: bindMode === "always"
|
|
253
|
+
? update.fromUserId
|
|
254
|
+
: settings.binding.loginUserId,
|
|
276
255
|
contextTokens: update.contextToken
|
|
277
256
|
? {
|
|
278
257
|
...settings.binding.contextTokens,
|
|
@@ -732,24 +711,18 @@ export function createGatewayService(deps) {
|
|
|
732
711
|
async function statusText(settings) {
|
|
733
712
|
const synced = await syncDesktopContext(settings);
|
|
734
713
|
const project = await deps.projectService.getCurrentProject();
|
|
714
|
+
const bindingLine = synced.channel === "lark"
|
|
715
|
+
? `Active Lark chat: ${synced.binding.boundUserId ? "yes" : "none"}`
|
|
716
|
+
: `Binding: ${synced.binding.boundUserId ? "bound" : "not bound"}`;
|
|
735
717
|
return [
|
|
736
718
|
`Gateway: ${synced.enabled ? "on" : "off"}${isRunning() ? " / polling" : ""}`,
|
|
737
|
-
|
|
719
|
+
bindingLine,
|
|
738
720
|
`Translation: ${synced.translationEnabled ? "on" : "off"}`,
|
|
739
721
|
`Project: ${project?.repoRoot ?? synced.currentProjectId ?? "none"}`,
|
|
740
722
|
`Task: ${synced.currentTaskSlug ?? "none"}`,
|
|
741
723
|
`Last poll: ${synced.lastPollStatus.state}${synced.lastPollStatus.error ? ` (${synced.lastPollStatus.error})` : ""}`
|
|
742
724
|
].join("\n");
|
|
743
725
|
}
|
|
744
|
-
function parseLarkPairingCommand(text) {
|
|
745
|
-
const match = text.trim().match(/^\/bind\s+([A-Z0-9]{6,12})$/i);
|
|
746
|
-
return match?.[1]?.toUpperCase() ?? null;
|
|
747
|
-
}
|
|
748
|
-
function isValidPairingCode(settings, code) {
|
|
749
|
-
const expected = settings.binding.pairingCode?.toUpperCase();
|
|
750
|
-
const expiresAt = settings.binding.pairingCodeExpiresAt;
|
|
751
|
-
return Boolean(expected && expected === code.toUpperCase() && expiresAt && Date.parse(expiresAt) > Date.now());
|
|
752
|
-
}
|
|
753
726
|
return {
|
|
754
727
|
async start() {
|
|
755
728
|
await ensurePolling();
|
|
@@ -789,40 +762,6 @@ export function createGatewayService(deps) {
|
|
|
789
762
|
const settings = await deps.settings.resetBinding();
|
|
790
763
|
return deps.settings.expose(settings, isRunning());
|
|
791
764
|
},
|
|
792
|
-
async createPairingCode() {
|
|
793
|
-
const settings = await deps.settings.loadSettings();
|
|
794
|
-
if (settings.channel !== "lark") {
|
|
795
|
-
throw new VcmError({
|
|
796
|
-
code: "GATEWAY_PAIRING_UNSUPPORTED",
|
|
797
|
-
message: "Pairing codes are only available for Lark Gateway.",
|
|
798
|
-
statusCode: 409
|
|
799
|
-
});
|
|
800
|
-
}
|
|
801
|
-
if (!settings.binding.appId || !settings.binding.appSecret) {
|
|
802
|
-
throw new VcmError({
|
|
803
|
-
code: "GATEWAY_LARK_CONFIG_MISSING",
|
|
804
|
-
message: "Complete Lark QR Setup before creating a pairing code.",
|
|
805
|
-
statusCode: 409
|
|
806
|
-
});
|
|
807
|
-
}
|
|
808
|
-
const code = randomPairingCode();
|
|
809
|
-
const expiresAt = new Date(Date.now() + PAIRING_CODE_TTL_MS).toISOString();
|
|
810
|
-
const nextSettings = await deps.settings.saveSettings({
|
|
811
|
-
...settings,
|
|
812
|
-
binding: {
|
|
813
|
-
...settings.binding,
|
|
814
|
-
pairingCode: code,
|
|
815
|
-
pairingCodeExpiresAt: expiresAt
|
|
816
|
-
},
|
|
817
|
-
updatedAt: now()
|
|
818
|
-
});
|
|
819
|
-
await ensurePolling();
|
|
820
|
-
return {
|
|
821
|
-
code,
|
|
822
|
-
expiresAt,
|
|
823
|
-
status: deps.settings.expose(nextSettings, isRunning())
|
|
824
|
-
};
|
|
825
|
-
},
|
|
826
765
|
async startQrLogin() {
|
|
827
766
|
const settings = await deps.settings.loadSettings();
|
|
828
767
|
const channel = resolveChannel(settings);
|
|
@@ -979,8 +918,6 @@ export function createGatewayService(deps) {
|
|
|
979
918
|
larkOpenId: result.openId ?? null,
|
|
980
919
|
larkBotName: result.botName ?? null,
|
|
981
920
|
larkBotOpenId: result.botOpenId ?? null,
|
|
982
|
-
pairingCode: null,
|
|
983
|
-
pairingCodeExpiresAt: null,
|
|
984
921
|
getUpdatesBuf: ""
|
|
985
922
|
},
|
|
986
923
|
lastPollStatus: {
|
|
@@ -1322,14 +1259,6 @@ function formatAheadBehind(project) {
|
|
|
1322
1259
|
}
|
|
1323
1260
|
return ahead > 0 ? `ahead ${ahead}` : `behind ${behind}`;
|
|
1324
1261
|
}
|
|
1325
|
-
function randomPairingCode() {
|
|
1326
|
-
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
1327
|
-
let out = "";
|
|
1328
|
-
for (let index = 0; index < 8; index += 1) {
|
|
1329
|
-
out += alphabet[randomInt(alphabet.length)];
|
|
1330
|
-
}
|
|
1331
|
-
return out;
|
|
1332
|
-
}
|
|
1333
1262
|
function sleep(ms, signal) {
|
|
1334
1263
|
return new Promise((resolve) => {
|
|
1335
1264
|
if (signal.aborted) {
|
|
@@ -101,8 +101,7 @@ export function createGatewaySettingsService(deps) {
|
|
|
101
101
|
larkOpenId: settings.binding.larkOpenId,
|
|
102
102
|
larkBotName: settings.binding.larkBotName,
|
|
103
103
|
larkBotOpenId: settings.binding.larkBotOpenId,
|
|
104
|
-
homeChatId: settings.binding.homeChatId
|
|
105
|
-
pairingCodeExpiresAt: settings.binding.pairingCodeExpiresAt
|
|
104
|
+
homeChatId: settings.binding.homeChatId
|
|
106
105
|
},
|
|
107
106
|
pendingConfirmations: settings.pendingConfirmations,
|
|
108
107
|
lastPollStatus: settings.lastPollStatus,
|
|
@@ -152,8 +151,6 @@ export function normalizeSettings(input, timestamp, options = {
|
|
|
152
151
|
larkBotName: normalizeNullableString(bindingInput.larkBotName),
|
|
153
152
|
larkBotOpenId: normalizeNullableString(bindingInput.larkBotOpenId),
|
|
154
153
|
homeChatId: normalizeNullableString(bindingInput.homeChatId),
|
|
155
|
-
pairingCode: normalizeNullableString(bindingInput.pairingCode),
|
|
156
|
-
pairingCodeExpiresAt: normalizeNullableString(bindingInput.pairingCodeExpiresAt),
|
|
157
154
|
getUpdatesBuf: typeof bindingInput.getUpdatesBuf === "string" ? bindingInput.getUpdatesBuf : "",
|
|
158
155
|
contextTokens: isObject(bindingInput.contextTokens)
|
|
159
156
|
? normalizeStringRecord(bindingInput.contextTokens)
|
|
@@ -191,8 +188,6 @@ function createDefaultBinding(defaultBaseUrl = DEFAULT_BASE_URL) {
|
|
|
191
188
|
larkBotName: null,
|
|
192
189
|
larkBotOpenId: null,
|
|
193
190
|
homeChatId: null,
|
|
194
|
-
pairingCode: null,
|
|
195
|
-
pairingCodeExpiresAt: null,
|
|
196
191
|
getUpdatesBuf: "",
|
|
197
192
|
contextTokens: {},
|
|
198
193
|
chatIds: {}
|