vibe-coding-master 0.2.6 → 0.2.7
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 +147 -5
- package/dist/backend/api/gateway-routes.js +17 -0
- package/dist/backend/api/round-routes.js +1 -1
- package/dist/backend/gateway/channels/weixin-ilink-channel.js +304 -0
- package/dist/backend/gateway/gateway-audit-log.js +39 -0
- package/dist/backend/gateway/gateway-command-parser.js +77 -0
- package/dist/backend/gateway/gateway-service.js +848 -0
- package/dist/backend/gateway/gateway-settings-service.js +214 -0
- package/dist/backend/server.js +40 -2
- package/dist/backend/services/claude-hook-service.js +13 -4
- package/dist/backend/services/round-service.js +110 -64
- package/dist/backend/services/session-service.js +10 -4
- package/dist/backend/services/task-service.js +32 -3
- package/dist/backend/services/translation-service.js +15 -0
- package/dist/shared/types/gateway.js +1 -0
- package/dist-frontend/assets/{index-CPXFnxAY.css → index-7lq6YPCq.css} +1 -1
- package/dist-frontend/assets/index-DHuS-DYr.js +92 -0
- package/dist-frontend/index.html +2 -2
- package/docs/gateway-design.md +200 -27
- package/docs/product-design.md +34 -13
- package/docs/v0.2-implementation-plan.md +22 -7
- package/package.json +2 -1
- package/dist-frontend/assets/index-D0_02lmQ.js +0 -90
package/README.md
CHANGED
|
@@ -29,6 +29,7 @@ Each role runs as a real Claude Code process inside an embedded terminal. The GU
|
|
|
29
29
|
- VCM-managed root rules, four role agents, repo-local VCM skills, Claude Code hooks, generated-context tools, and PR template.
|
|
30
30
|
- Rust generated context for module indexing and crate-external public surface indexing.
|
|
31
31
|
- Translation panel powered by an OpenAI-compatible low-cost model.
|
|
32
|
+
- Mobile Gateway through Tencent iLink Bot API / Weixin DM, for talking to PM and managing tasks from Weixin.
|
|
32
33
|
- Durable task state, session state, raw terminal logs, handoff artifacts, and message history.
|
|
33
34
|
|
|
34
35
|
## Requirements
|
|
@@ -157,6 +158,26 @@ project-manager
|
|
|
157
158
|
-> project-manager final acceptance, commit, and PR
|
|
158
159
|
```
|
|
159
160
|
|
|
161
|
+
## VCM Runtime Terminology
|
|
162
|
+
|
|
163
|
+
VCM statistics use three nested terms:
|
|
164
|
+
|
|
165
|
+
```text
|
|
166
|
+
Session = n x Round = m x Turn
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
- `Session`: the whole VCM task. It aggregates all statistics for that task, from task creation until the task is closed.
|
|
170
|
+
- `Round`: one user-facing VCM conversation cycle. It starts when a user prompt or VCM-delivered prompt is accepted, continues through automatic role orchestration, and normally ends when the final `project-manager` result has stopped and no next role turn starts inside the 10 second stop window.
|
|
171
|
+
- `Turn`: one role-level Claude Code conversation. A turn starts when VCM or the user submits a prompt to one role session and ends when that Claude Code process emits `Stop`.
|
|
172
|
+
|
|
173
|
+
Turns inside one Round are strictly sequential. VCM should finish one role Turn before starting the next role Turn in that Round.
|
|
174
|
+
|
|
175
|
+
Session state is intentionally small: `created` means no Round has started yet, `running` means there is a current running Round, and `stopped` means there is no current running Round after at least one Round has stopped. Starting Claude Code role sessions does not make the VCM Session `running`.
|
|
176
|
+
|
|
177
|
+
Round state is only `running` or `stopped`. After a `Stop` hook, the 10 second stop window still counts as `running`; the Round becomes `stopped` only when the timer expires without another `UserPromptSubmit`.
|
|
178
|
+
|
|
179
|
+
In this terminology, `Session` is a VCM statistics term. It is different from a Claude Code role session. VCM still runs one Claude Code role session per role, but the sidebar task statistics treat the task itself as the VCM Session.
|
|
180
|
+
|
|
160
181
|
## Task Worktree Management
|
|
161
182
|
|
|
162
183
|
VCM uses task-level worktree management by default:
|
|
@@ -189,6 +210,7 @@ The left sidebar is intentionally compact and collapsible:
|
|
|
189
210
|
- `Repository Path`: path input on one row; `Recent` and `Connect` on the next row.
|
|
190
211
|
- `Connected Repository`: connected base repo path, branch, upstream/ahead-behind status, commit hash, working tree state, and a `Pull` button.
|
|
191
212
|
- `Settings`: `Theme`, `Flow pause alert`, `Try alert`, `Messages`, and `Events`.
|
|
213
|
+
- `Gateway`: Weixin iLink binding, Gateway on/off, Gateway translation, QR login, and recent Gateway status.
|
|
192
214
|
- `VCM Harness`: fixed-install status, bootstrap completion checks, and the bootstrap terminal when one is running.
|
|
193
215
|
- `New Task`: one `task name` input.
|
|
194
216
|
- `Tasks`: task list and task status.
|
|
@@ -202,7 +224,123 @@ the base repo has uncommitted changes, when the current branch has no upstream,
|
|
|
202
224
|
or when the active task is an inline task using the base repo directly. It does
|
|
203
225
|
not stash, merge, or mutate task worktrees.
|
|
204
226
|
|
|
205
|
-
When VCM is connected to an active task, the bottom of the sidebar shows a task status dock. It stays outside the collapsible groups and shows the
|
|
227
|
+
When VCM is connected to an active task, the bottom of the sidebar shows a task status dock. It stays outside the collapsible groups and shows the active VCM Session title, task status, start time, total elapsed time, total Round count, and Claude Code active runtime. If a Round is currently running, the dock also shows the Current Round start time, total elapsed time, Claude Code active runtime, and Turn count.
|
|
228
|
+
|
|
229
|
+
## Mobile Gateway
|
|
230
|
+
|
|
231
|
+
VCM Gateway lets one Weixin DM identity bind to one desktop VCM instance. It is a mobile control surface for the current desktop VCM, not a remote terminal and not a group-chat bot.
|
|
232
|
+
|
|
233
|
+
Gateway rules:
|
|
234
|
+
|
|
235
|
+
- DM only; group chat is not supported.
|
|
236
|
+
- One phone identity binds to one desktop VCM instance.
|
|
237
|
+
- The phone can manage projects and tasks available to that desktop VCM instance.
|
|
238
|
+
- When the desktop UI has an active task selected, Gateway uses that task automatically.
|
|
239
|
+
- Plain text messages go only to the current task's `project-manager`.
|
|
240
|
+
- Gateway never sends directly to `architect`, `coder`, or `reviewer`.
|
|
241
|
+
- Gateway credentials and audit logs stay in local app state, not in connected repositories.
|
|
242
|
+
|
|
243
|
+
Gateway state is stored locally under:
|
|
244
|
+
|
|
245
|
+
```text
|
|
246
|
+
~/.vcm/gateway/settings.json
|
|
247
|
+
~/.vcm/gateway/audit.jsonl
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Bind Weixin
|
|
251
|
+
|
|
252
|
+
1. Start VCM and open the GUI.
|
|
253
|
+
2. Open the sidebar `Gateway` section.
|
|
254
|
+
3. Click `Start QR Login`.
|
|
255
|
+
4. VCM opens a global `Weixin Gateway Login` dialog with a QR code.
|
|
256
|
+
5. Scan the QR code with Weixin and confirm login on the phone.
|
|
257
|
+
6. Click `Check QR` in the dialog.
|
|
258
|
+
7. After binding succeeds, close the dialog and turn `Gateway` on in the sidebar.
|
|
259
|
+
|
|
260
|
+
`Start QR Login` creates a new Tencent iLink QR login session. `Check QR` asks iLink whether the QR code has been scanned and confirmed. If the QR code expires, click `Start QR Login` again.
|
|
261
|
+
|
|
262
|
+
The `Gateway` toggle is disabled until a QR login has produced a usable iLink token. `Reset Binding` clears the stored token and bound Weixin identity so the desktop VCM can bind again.
|
|
263
|
+
|
|
264
|
+
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 Weixin.
|
|
265
|
+
|
|
266
|
+
### Translation
|
|
267
|
+
|
|
268
|
+
The Gateway section has its own `Translation` toggle.
|
|
269
|
+
|
|
270
|
+
When Gateway translation is on:
|
|
271
|
+
|
|
272
|
+
- Chinese Weixin input is translated to English before being submitted to PM.
|
|
273
|
+
- The prompt sent to PM includes only the translated English text with a `[VCM Gateway]` marker.
|
|
274
|
+
- The original Chinese text is not included in the PM prompt.
|
|
275
|
+
- PM replies are translated back to Chinese before VCM sends them to Weixin.
|
|
276
|
+
|
|
277
|
+
When Gateway translation is off, plain Weixin text is sent to PM as-is.
|
|
278
|
+
|
|
279
|
+
### Commands
|
|
280
|
+
|
|
281
|
+
After Gateway is bound and turned on, send commands in the bound Weixin DM:
|
|
282
|
+
|
|
283
|
+
```text
|
|
284
|
+
/help
|
|
285
|
+
/status
|
|
286
|
+
/projects
|
|
287
|
+
/use-project <index-or-path>
|
|
288
|
+
/pull-current
|
|
289
|
+
/tasks
|
|
290
|
+
/use-task <index-or-task-slug>
|
|
291
|
+
/create-task <task-slug> [title]
|
|
292
|
+
/close-task
|
|
293
|
+
/close-task confirm <task-slug>
|
|
294
|
+
/translate on
|
|
295
|
+
/translate off
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Plain text that does not start with `/` is sent as a message to the current task's PM session.
|
|
299
|
+
|
|
300
|
+
In normal use, VCM runs one project and one task at a time. If the desktop UI has a task selected, turning Gateway on syncs that project/task into Gateway automatically. `/status` also refreshes this context, so the phone usually does not need to run `/tasks` and `/use-task` before sending a PM message.
|
|
301
|
+
|
|
302
|
+
Typical mobile flow:
|
|
303
|
+
|
|
304
|
+
```text
|
|
305
|
+
/projects
|
|
306
|
+
/use-project 1
|
|
307
|
+
/pull-current
|
|
308
|
+
/create-task mobile-demo "Implement mobile gateway smoke test"
|
|
309
|
+
/status
|
|
310
|
+
继续推进这个任务,先让 PM 安排下一步。
|
|
311
|
+
/close-task
|
|
312
|
+
/close-task confirm mobile-demo
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### Command Behavior
|
|
316
|
+
|
|
317
|
+
- `/status`: shows Gateway, binding, translation, current project, current task, and last poll status.
|
|
318
|
+
- `/status` also adopts the current desktop project/task when one is selected.
|
|
319
|
+
- `/projects`: lists the current/recent repositories known by the desktop VCM.
|
|
320
|
+
- `/use-project <index-or-path>`: selects the Gateway's current project context.
|
|
321
|
+
- `/pull-current`: runs the same fast-forward-only connected repository pull as the desktop `Pull` button.
|
|
322
|
+
- `/tasks`: lists tasks for the selected project.
|
|
323
|
+
- `/use-task <index-or-task-slug>`: selects the Gateway's current task context.
|
|
324
|
+
- `/create-task <task-slug> [title]`: creates a worktree-backed task and starts the four role sessions using the saved launch template.
|
|
325
|
+
- `/close-task`: starts a destructive close confirmation for the current task.
|
|
326
|
+
- `/close-task confirm <task-slug>`: closes the task through VCM cleanup after exact slug confirmation.
|
|
327
|
+
- `/translate on` and `/translate off`: changes Gateway translation for mobile messages.
|
|
328
|
+
|
|
329
|
+
`/pull-current` only pulls the connected base repository. It does not pull task worktrees, stash local changes, merge divergent branches, or run arbitrary shell commands.
|
|
330
|
+
|
|
331
|
+
`/create-task` uses the saved launch template from the desktop settings. The template controls permission mode, model, auto orchestration, and translation defaults for the four role sessions.
|
|
332
|
+
|
|
333
|
+
`/close-task` is destructive. It stops VCM-managed role sessions and removes task-owned worktree/branch state according to the same cleanup behavior as the desktop `Close Task` action.
|
|
334
|
+
|
|
335
|
+
### Troubleshooting
|
|
336
|
+
|
|
337
|
+
- If the QR dialog does not appear, refresh the page and click `Start QR Login` again.
|
|
338
|
+
- If the QR status stays `wait`, confirm the login on the phone and click `Check QR` again.
|
|
339
|
+
- If the QR code expires, start a new QR login.
|
|
340
|
+
- If `Gateway` cannot be enabled, bind Weixin first.
|
|
341
|
+
- If messages are not received, check that Gateway is on, the iLink token has not expired, and the Weixin DM is the bound identity.
|
|
342
|
+
- 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.
|
|
343
|
+
- If PM replies are not pushed, check that Gateway is on and the PM session is producing normal Claude transcript output.
|
|
206
344
|
|
|
207
345
|
## Translation
|
|
208
346
|
|
|
@@ -222,6 +360,8 @@ The sidebar `Settings` section also stores the UI theme preference in this file.
|
|
|
222
360
|
|
|
223
361
|
The same sidebar also has a `Flow pause alert` toggle. It is on by default and controls the local alert that fires when VCM detects that the current role flow has stopped advancing. Short flows use a weak reminder: the soft two-note chime plays 3 times, 1.4 seconds apart. Flows lasting 2 minutes or longer use a strong reminder: VCM shows an alert dialog and repeats the chime until the user confirms it. The alert sound reuses one browser audio context so repeated reminders remain reliable in stricter browsers such as Safari. Safari users may still need to manually set `Safari > Website Settings > Auto-Play > Allow All Auto-Play`; Chrome is recommended for the most reliable alert sound behavior. The `Try alert` button always triggers the strong reminder for testing.
|
|
224
362
|
|
|
363
|
+
When Gateway is on, `Flow pause alert` is forced off because mobile notifications are delivered through Weixin and browser alerts can block normal workflow progress.
|
|
364
|
+
|
|
225
365
|
Translation behavior:
|
|
226
366
|
|
|
227
367
|
- Provider type is OpenAI-compatible chat completions.
|
|
@@ -386,13 +526,15 @@ The implementation keeps only the active manual/auto orchestration mode. It does
|
|
|
386
526
|
|
|
387
527
|
## Flow Pause Alerts
|
|
388
528
|
|
|
389
|
-
VCM detects flow pauses from Claude Code hook events, not PTY silence, message history, or pending route files. `UserPromptSubmit` starts a new
|
|
529
|
+
VCM detects flow pauses from Claude Code hook events, not PTY silence, message history, or pending route files. `UserPromptSubmit` starts a new Round when there is no current running Round, or continues the current Round when it fires within the 10 second stop window.
|
|
530
|
+
|
|
531
|
+
When a `Stop` hook fires, VCM ends the current Turn and starts a 10 second timer. During that timer, the Round is still `running`. If another `UserPromptSubmit` fires inside that window, the same Round continues and VCM increments the Turn count. If no new prompt is accepted before the deadline, VCM marks the Round as `stopped`.
|
|
390
532
|
|
|
391
|
-
|
|
533
|
+
The normal path is timer-driven: `Stop` starts a backend timer, and `UserPromptSubmit` cancels it.
|
|
392
534
|
|
|
393
|
-
|
|
535
|
+
When `Flow pause alert` is enabled, the frontend polls the task Round state and deduplicates each stopped Round so the same stopped state does not alert on every poll. Flow duration is measured from the first `UserPromptSubmit` to `stoppedAt`, falling back to the last `Stop` when needed. If the flow lasted less than 2 minutes, it plays the local chime 3 times at 1.4 second intervals. If the flow lasted 2 minutes or longer, it shows a modal alert and repeats the local chime until the user clicks `Confirm`. A stopped Round can mean normal completion, user decision needed, dispatch failure, or another workflow interruption; the point is to get the user to look with the right amount of urgency.
|
|
394
536
|
|
|
395
|
-
|
|
537
|
+
The Current Round dock separates wall-clock Round duration from Claude Code active runtime. `Total` is `now - Round.startedAt`; `CC runtime` is only the accumulated time between each Turn's `UserPromptSubmit` and `Stop`; `Turn count` is the number of accepted prompts inside the current Round.
|
|
396
538
|
|
|
397
539
|
## Resume Behavior
|
|
398
540
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function registerGatewayRoutes(app, deps) {
|
|
2
|
+
app.get("/api/gateway/status", async () => {
|
|
3
|
+
return deps.gatewayService.getStatus();
|
|
4
|
+
});
|
|
5
|
+
app.put("/api/gateway/settings", async (request) => {
|
|
6
|
+
return deps.gatewayService.updateSettings(request.body);
|
|
7
|
+
});
|
|
8
|
+
app.post("/api/gateway/qr/start", async () => {
|
|
9
|
+
return deps.gatewayService.startQrLogin();
|
|
10
|
+
});
|
|
11
|
+
app.post("/api/gateway/qr/check", async (request) => {
|
|
12
|
+
return deps.gatewayService.checkQrLogin(request.body);
|
|
13
|
+
});
|
|
14
|
+
app.post("/api/gateway/binding/reset", async () => {
|
|
15
|
+
return deps.gatewayService.resetBinding();
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -6,7 +6,7 @@ export function registerRoundRoutes(app, deps) {
|
|
|
6
6
|
const config = await deps.projectService.loadConfig(project.repoRoot);
|
|
7
7
|
const task = await deps.taskService.loadTask(project.repoRoot, request.params.taskSlug);
|
|
8
8
|
const taskRepoRoot = getTaskRuntimeRepoRoot(task);
|
|
9
|
-
return deps.roundService.
|
|
9
|
+
return deps.roundService.getSessionRoundState({
|
|
10
10
|
stateRepoRoot: taskRepoRoot,
|
|
11
11
|
stateRoot: config.stateRoot,
|
|
12
12
|
taskSlug: request.params.taskSlug
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
3
|
+
const DEFAULT_BOT_TYPE = "3";
|
|
4
|
+
const DEFAULT_CHANNEL_VERSION = "2.4.3";
|
|
5
|
+
const DEFAULT_API_TIMEOUT_MS = 15_000;
|
|
6
|
+
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
7
|
+
const SESSION_EXPIRED_ERRCODE = -14;
|
|
8
|
+
const MESSAGE_TYPE_USER = 1;
|
|
9
|
+
const MESSAGE_TYPE_BOT = 2;
|
|
10
|
+
const MESSAGE_STATE_FINISH = 2;
|
|
11
|
+
const MESSAGE_ITEM_TEXT = 1;
|
|
12
|
+
export function createWeixinIlinkChannel(options = {}) {
|
|
13
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
14
|
+
const baseUrl = normalizeBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
|
|
15
|
+
const channelVersion = options.channelVersion ?? DEFAULT_CHANNEL_VERSION;
|
|
16
|
+
const botAgent = options.botAgent ?? "vcm-gateway/0.1.0";
|
|
17
|
+
const appId = options.appId ?? "bot";
|
|
18
|
+
const apiTimeoutMs = options.apiTimeoutMs ?? DEFAULT_API_TIMEOUT_MS;
|
|
19
|
+
function buildBaseInfo() {
|
|
20
|
+
return {
|
|
21
|
+
channel_version: channelVersion,
|
|
22
|
+
bot_agent: botAgent
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function buildIlinkAppHeaders() {
|
|
26
|
+
const headers = {
|
|
27
|
+
"iLink-App-Id": appId,
|
|
28
|
+
"iLink-App-ClientVersion": String(buildClientVersion(channelVersion))
|
|
29
|
+
};
|
|
30
|
+
if (options.routeTag) {
|
|
31
|
+
headers.SKRouteTag = options.routeTag;
|
|
32
|
+
}
|
|
33
|
+
return headers;
|
|
34
|
+
}
|
|
35
|
+
function buildJsonHeaders(token) {
|
|
36
|
+
const headers = {
|
|
37
|
+
"Content-Type": "application/json",
|
|
38
|
+
AuthorizationType: "ilink_bot_token",
|
|
39
|
+
"X-WECHAT-UIN": randomWechatUin(),
|
|
40
|
+
...buildIlinkAppHeaders()
|
|
41
|
+
};
|
|
42
|
+
if (token?.trim()) {
|
|
43
|
+
headers.Authorization = `Bearer ${token.trim()}`;
|
|
44
|
+
}
|
|
45
|
+
return headers;
|
|
46
|
+
}
|
|
47
|
+
async function fetchJson(input) {
|
|
48
|
+
const controller = input.timeoutMs !== undefined ? new AbortController() : undefined;
|
|
49
|
+
const timeout = controller ? setTimeout(() => controller.abort(), input.timeoutMs) : undefined;
|
|
50
|
+
const signal = combineSignals(controller?.signal, input.signal);
|
|
51
|
+
const url = new URL(input.endpoint, ensureTrailingSlash(input.requestBaseUrl));
|
|
52
|
+
try {
|
|
53
|
+
const response = await fetchImpl(url, {
|
|
54
|
+
method: input.method,
|
|
55
|
+
headers: input.jsonHeaders === false ? buildIlinkAppHeaders() : buildJsonHeaders(input.token),
|
|
56
|
+
body: input.body === undefined ? undefined : JSON.stringify(input.body),
|
|
57
|
+
signal
|
|
58
|
+
});
|
|
59
|
+
const rawText = await response.text();
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
throw new Error(`${input.label} HTTP ${response.status}: ${rawText}`);
|
|
62
|
+
}
|
|
63
|
+
return rawText ? JSON.parse(rawText) : {};
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
if (timeout) {
|
|
67
|
+
clearTimeout(timeout);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function post(input) {
|
|
72
|
+
return fetchJson({
|
|
73
|
+
method: "POST",
|
|
74
|
+
requestBaseUrl: input.requestBaseUrl,
|
|
75
|
+
endpoint: input.endpoint,
|
|
76
|
+
token: input.token,
|
|
77
|
+
body: input.body,
|
|
78
|
+
timeoutMs: input.timeoutMs ?? apiTimeoutMs,
|
|
79
|
+
label: input.label,
|
|
80
|
+
signal: input.signal
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
async function get(input) {
|
|
84
|
+
return fetchJson({
|
|
85
|
+
method: "GET",
|
|
86
|
+
requestBaseUrl: input.requestBaseUrl,
|
|
87
|
+
endpoint: input.endpoint,
|
|
88
|
+
timeoutMs: input.timeoutMs ?? apiTimeoutMs,
|
|
89
|
+
label: input.label,
|
|
90
|
+
jsonHeaders: false
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
async startQrLogin(input) {
|
|
95
|
+
const payload = await post({
|
|
96
|
+
requestBaseUrl: baseUrl,
|
|
97
|
+
endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(input.botType ?? DEFAULT_BOT_TYPE)}`,
|
|
98
|
+
body: {
|
|
99
|
+
local_token_list: input.localTokenList ?? []
|
|
100
|
+
},
|
|
101
|
+
label: "get_bot_qrcode"
|
|
102
|
+
});
|
|
103
|
+
const qrcode = stringOrUndefined(payload.qrcode);
|
|
104
|
+
const qrcodeUrl = stringOrUndefined(payload.qrcode_img_content);
|
|
105
|
+
if (!qrcode || !qrcodeUrl) {
|
|
106
|
+
throw new Error("get_bot_qrcode did not return qrcode/qrcode_img_content.");
|
|
107
|
+
}
|
|
108
|
+
return { qrcode, qrcodeUrl };
|
|
109
|
+
},
|
|
110
|
+
async checkQrLogin(input) {
|
|
111
|
+
let endpoint = `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(input.qrcode)}`;
|
|
112
|
+
if (input.verifyCode?.trim()) {
|
|
113
|
+
endpoint += `&verify_code=${encodeURIComponent(input.verifyCode.trim())}`;
|
|
114
|
+
}
|
|
115
|
+
const payload = await get({
|
|
116
|
+
requestBaseUrl: input.baseUrl,
|
|
117
|
+
endpoint,
|
|
118
|
+
timeoutMs: 35_000,
|
|
119
|
+
label: "get_qrcode_status"
|
|
120
|
+
});
|
|
121
|
+
const status = normalizeQrStatus(payload.status);
|
|
122
|
+
return {
|
|
123
|
+
status,
|
|
124
|
+
redirectHost: stringOrUndefined(payload.redirect_host),
|
|
125
|
+
accountId: stringOrUndefined(payload.ilink_bot_id),
|
|
126
|
+
token: stringOrUndefined(payload.bot_token),
|
|
127
|
+
baseUrl: stringOrUndefined(payload.baseurl),
|
|
128
|
+
loginUserId: stringOrUndefined(payload.ilink_user_id),
|
|
129
|
+
raw: payload
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
async getUpdates(input) {
|
|
133
|
+
const payload = await post({
|
|
134
|
+
requestBaseUrl: input.account.baseUrl,
|
|
135
|
+
endpoint: "ilink/bot/getupdates",
|
|
136
|
+
token: input.account.token,
|
|
137
|
+
timeoutMs: input.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS,
|
|
138
|
+
label: "getupdates",
|
|
139
|
+
signal: input.signal,
|
|
140
|
+
body: {
|
|
141
|
+
get_updates_buf: input.cursor ?? "",
|
|
142
|
+
base_info: buildBaseInfo()
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
assertIlinkOk(payload, "getupdates");
|
|
146
|
+
const cursor = stringOrUndefined(payload.get_updates_buf) ?? input.cursor ?? "";
|
|
147
|
+
const timeoutMs = typeof payload.longpolling_timeout_ms === "number"
|
|
148
|
+
? payload.longpolling_timeout_ms
|
|
149
|
+
: undefined;
|
|
150
|
+
const messages = Array.isArray(payload.msgs) ? payload.msgs : [];
|
|
151
|
+
return {
|
|
152
|
+
cursor,
|
|
153
|
+
timeoutMs,
|
|
154
|
+
updates: messages.flatMap(parseUpdate)
|
|
155
|
+
};
|
|
156
|
+
},
|
|
157
|
+
async sendText(input) {
|
|
158
|
+
const clientId = `vcm-gateway-${randomUUID()}`;
|
|
159
|
+
const payload = await post({
|
|
160
|
+
requestBaseUrl: input.account.baseUrl,
|
|
161
|
+
endpoint: "ilink/bot/sendmessage",
|
|
162
|
+
token: input.account.token,
|
|
163
|
+
label: "sendmessage",
|
|
164
|
+
body: {
|
|
165
|
+
msg: {
|
|
166
|
+
from_user_id: "",
|
|
167
|
+
to_user_id: input.toUserId,
|
|
168
|
+
client_id: clientId,
|
|
169
|
+
message_type: MESSAGE_TYPE_BOT,
|
|
170
|
+
message_state: MESSAGE_STATE_FINISH,
|
|
171
|
+
item_list: [
|
|
172
|
+
{
|
|
173
|
+
type: MESSAGE_ITEM_TEXT,
|
|
174
|
+
text_item: {
|
|
175
|
+
text: input.text
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
],
|
|
179
|
+
context_token: input.contextToken || undefined
|
|
180
|
+
},
|
|
181
|
+
base_info: buildBaseInfo()
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
assertIlinkOk(payload, "sendmessage");
|
|
185
|
+
return clientId;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function parseUpdate(raw) {
|
|
190
|
+
if (!raw || typeof raw !== "object") {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
const message = raw;
|
|
194
|
+
const fromUserId = stringOrUndefined(message.from_user_id);
|
|
195
|
+
if (!fromUserId) {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
if (message.message_type === MESSAGE_TYPE_BOT) {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
if (message.message_type !== undefined && message.message_type !== MESSAGE_TYPE_USER) {
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
const text = extractText(message);
|
|
205
|
+
if (!text.trim()) {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
const messageId = String(message.message_id ?? message.client_id ?? `${fromUserId}:${String(message.create_time_ms ?? "")}`);
|
|
209
|
+
const createdAt = typeof message.create_time_ms === "number"
|
|
210
|
+
? new Date(message.create_time_ms).toISOString()
|
|
211
|
+
: undefined;
|
|
212
|
+
return [{
|
|
213
|
+
messageId,
|
|
214
|
+
fromUserId,
|
|
215
|
+
text,
|
|
216
|
+
contextToken: stringOrUndefined(message.context_token),
|
|
217
|
+
createdAt,
|
|
218
|
+
raw: message
|
|
219
|
+
}];
|
|
220
|
+
}
|
|
221
|
+
function extractText(message) {
|
|
222
|
+
const parts = [];
|
|
223
|
+
const items = Array.isArray(message.item_list) ? message.item_list : [];
|
|
224
|
+
for (const item of items) {
|
|
225
|
+
if (!item || typeof item !== "object") {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const candidate = item;
|
|
229
|
+
const textItem = candidate.text_item;
|
|
230
|
+
const voiceItem = candidate.voice_item;
|
|
231
|
+
const text = stringOrUndefined(textItem?.text) ?? stringOrUndefined(voiceItem?.text);
|
|
232
|
+
if (text) {
|
|
233
|
+
parts.push(text);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return parts.join("\n").trim();
|
|
237
|
+
}
|
|
238
|
+
function assertIlinkOk(payload, label) {
|
|
239
|
+
const ret = typeof payload.ret === "number" ? payload.ret : undefined;
|
|
240
|
+
const errcode = typeof payload.errcode === "number" ? payload.errcode : undefined;
|
|
241
|
+
if ((ret !== undefined && ret !== 0) || (errcode !== undefined && errcode !== 0)) {
|
|
242
|
+
const code = ret ?? errcode;
|
|
243
|
+
const message = stringOrUndefined(payload.errmsg) ?? `${label} failed`;
|
|
244
|
+
if (code === SESSION_EXPIRED_ERRCODE) {
|
|
245
|
+
throw new Error(`iLink session expired: ${message}`);
|
|
246
|
+
}
|
|
247
|
+
throw new Error(`${label} failed ret=${String(ret)} errcode=${String(errcode)} ${message}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function normalizeQrStatus(value) {
|
|
251
|
+
switch (value) {
|
|
252
|
+
case "wait":
|
|
253
|
+
case "scaned":
|
|
254
|
+
case "need_verifycode":
|
|
255
|
+
case "verify_code_blocked":
|
|
256
|
+
case "expired":
|
|
257
|
+
case "scaned_but_redirect":
|
|
258
|
+
case "binded_redirect":
|
|
259
|
+
case "confirmed":
|
|
260
|
+
return value;
|
|
261
|
+
default:
|
|
262
|
+
return "failed";
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function normalizeBaseUrl(input) {
|
|
266
|
+
const trimmed = input.trim();
|
|
267
|
+
if (!trimmed) {
|
|
268
|
+
return DEFAULT_BASE_URL;
|
|
269
|
+
}
|
|
270
|
+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
|
271
|
+
return trimmed.replace(/\/+$/, "");
|
|
272
|
+
}
|
|
273
|
+
return `https://${trimmed.replace(/\/+$/, "")}`;
|
|
274
|
+
}
|
|
275
|
+
function ensureTrailingSlash(input) {
|
|
276
|
+
return input.endsWith("/") ? input : `${input}/`;
|
|
277
|
+
}
|
|
278
|
+
function buildClientVersion(version) {
|
|
279
|
+
const [major = 0, minor = 0, patch = 0] = version.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
280
|
+
return ((major & 0xff) << 16) | ((minor & 0xff) << 8) | (patch & 0xff);
|
|
281
|
+
}
|
|
282
|
+
function randomWechatUin() {
|
|
283
|
+
const value = randomBytes(4).readUInt32BE(0);
|
|
284
|
+
return Buffer.from(String(value), "utf8").toString("base64");
|
|
285
|
+
}
|
|
286
|
+
function combineSignals(first, second) {
|
|
287
|
+
if (!first) {
|
|
288
|
+
return second;
|
|
289
|
+
}
|
|
290
|
+
if (!second) {
|
|
291
|
+
return first;
|
|
292
|
+
}
|
|
293
|
+
const controller = new AbortController();
|
|
294
|
+
const abort = () => controller.abort();
|
|
295
|
+
first.addEventListener("abort", abort, { once: true });
|
|
296
|
+
second.addEventListener("abort", abort, { once: true });
|
|
297
|
+
if (first.aborted || second.aborted) {
|
|
298
|
+
controller.abort();
|
|
299
|
+
}
|
|
300
|
+
return controller.signal;
|
|
301
|
+
}
|
|
302
|
+
function stringOrUndefined(value) {
|
|
303
|
+
return typeof value === "string" ? value : undefined;
|
|
304
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function createGatewayAuditLog(deps) {
|
|
2
|
+
const now = deps.now ?? (() => new Date().toISOString());
|
|
3
|
+
return {
|
|
4
|
+
async record(input) {
|
|
5
|
+
await deps.fs.appendText(deps.auditPath, `${JSON.stringify(redactEvent({
|
|
6
|
+
...input,
|
|
7
|
+
createdAt: now()
|
|
8
|
+
}))}\n`);
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function redactEvent(input) {
|
|
13
|
+
return {
|
|
14
|
+
...input,
|
|
15
|
+
preview: input.preview ? input.preview.slice(0, 160) : undefined,
|
|
16
|
+
error: input.error ? redactSecrets(input.error).slice(0, 500) : undefined,
|
|
17
|
+
metadata: input.metadata ? redactObject(input.metadata) : undefined
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function redactObject(input) {
|
|
21
|
+
const out = {};
|
|
22
|
+
for (const [key, value] of Object.entries(input)) {
|
|
23
|
+
if (/token|authorization|qrcode|secret|apiKey/i.test(key)) {
|
|
24
|
+
out[key] = "[redacted]";
|
|
25
|
+
}
|
|
26
|
+
else if (typeof value === "string") {
|
|
27
|
+
out[key] = redactSecrets(value).slice(0, 500);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
out[key] = value;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
function redactSecrets(input) {
|
|
36
|
+
return input
|
|
37
|
+
.replace(/Bearer\s+[A-Za-z0-9._-]+/g, "Bearer [redacted]")
|
|
38
|
+
.replace(/bot_token["'\s:=]+[A-Za-z0-9._-]+/gi, "bot_token=[redacted]");
|
|
39
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export function parseGatewayCommand(input) {
|
|
2
|
+
const text = input.trim();
|
|
3
|
+
if (!text.startsWith("/")) {
|
|
4
|
+
return { kind: "plain", text };
|
|
5
|
+
}
|
|
6
|
+
const [rawName = "", ...rest] = splitArgs(text);
|
|
7
|
+
const name = rawName.toLowerCase();
|
|
8
|
+
switch (name) {
|
|
9
|
+
case "/help":
|
|
10
|
+
return { kind: "help" };
|
|
11
|
+
case "/status":
|
|
12
|
+
return { kind: "status" };
|
|
13
|
+
case "/projects":
|
|
14
|
+
return { kind: "projects" };
|
|
15
|
+
case "/use-project":
|
|
16
|
+
return rest[0] ? { kind: "use-project", selector: rest.join(" ") } : { kind: "unknown", name };
|
|
17
|
+
case "/pull-current":
|
|
18
|
+
return { kind: "pull-current" };
|
|
19
|
+
case "/tasks":
|
|
20
|
+
return { kind: "tasks" };
|
|
21
|
+
case "/use-task":
|
|
22
|
+
return rest[0] ? { kind: "use-task", selector: rest[0] } : { kind: "unknown", name };
|
|
23
|
+
case "/create-task": {
|
|
24
|
+
const [taskSlug, ...titleParts] = rest;
|
|
25
|
+
return taskSlug
|
|
26
|
+
? { kind: "create-task", taskSlug, title: titleParts.join(" ").trim() || undefined }
|
|
27
|
+
: { kind: "unknown", name };
|
|
28
|
+
}
|
|
29
|
+
case "/close-task":
|
|
30
|
+
if (rest[0]?.toLowerCase() === "confirm" && rest[1]) {
|
|
31
|
+
return { kind: "close-task-confirm", taskSlug: rest[1] };
|
|
32
|
+
}
|
|
33
|
+
return { kind: "close-task" };
|
|
34
|
+
case "/translate":
|
|
35
|
+
if (rest[0]?.toLowerCase() === "on") {
|
|
36
|
+
return { kind: "translate", enabled: true };
|
|
37
|
+
}
|
|
38
|
+
if (rest[0]?.toLowerCase() === "off") {
|
|
39
|
+
return { kind: "translate", enabled: false };
|
|
40
|
+
}
|
|
41
|
+
return { kind: "unknown", name };
|
|
42
|
+
default:
|
|
43
|
+
return { kind: "unknown", name };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function splitArgs(input) {
|
|
47
|
+
const out = [];
|
|
48
|
+
let current = "";
|
|
49
|
+
let quote = null;
|
|
50
|
+
for (const char of input) {
|
|
51
|
+
if (quote) {
|
|
52
|
+
if (char === quote) {
|
|
53
|
+
quote = null;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
current += char;
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (char === "\"" || char === "'") {
|
|
61
|
+
quote = char;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (/\s/.test(char)) {
|
|
65
|
+
if (current) {
|
|
66
|
+
out.push(current);
|
|
67
|
+
current = "";
|
|
68
|
+
}
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
current += char;
|
|
72
|
+
}
|
|
73
|
+
if (current) {
|
|
74
|
+
out.push(current);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|