vibe-coding-master 0.2.5 → 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 CHANGED
@@ -16,6 +16,7 @@ Each role runs as a real Claude Code process inside an embedded terminal. The GU
16
16
  - GUI-first task workspace.
17
17
  - Collapsible sidebar with repository connection, settings, harness status, task creation, and task list.
18
18
  - Recent repository path dropdown, stored locally with the five most recent paths.
19
+ - Connected repository status for the base repo, including branch, upstream status, commit hash, dirty state, and fast-forward-only pull.
19
20
  - Embedded Claude Code terminals powered by `node-pty` and `xterm.js`.
20
21
  - One Claude Code session per role, with role tabs in the task header.
21
22
  - Role session recovery through persisted Claude session ids and `claude --resume`.
@@ -28,6 +29,7 @@ Each role runs as a real Claude Code process inside an embedded terminal. The GU
28
29
  - VCM-managed root rules, four role agents, repo-local VCM skills, Claude Code hooks, generated-context tools, and PR template.
29
30
  - Rust generated context for module indexing and crate-external public surface indexing.
30
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.
31
33
  - Durable task state, session state, raw terminal logs, handoff artifacts, and message history.
32
34
 
33
35
  ## Requirements
@@ -156,6 +158,26 @@ project-manager
156
158
  -> project-manager final acceptance, commit, and PR
157
159
  ```
158
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
+
159
181
  ## Task Worktree Management
160
182
 
161
183
  VCM uses task-level worktree management by default:
@@ -186,15 +208,139 @@ When a task is complete, VCM provides a red `Close Task` action. Closing a task
186
208
  The left sidebar is intentionally compact and collapsible:
187
209
 
188
210
  - `Repository Path`: path input on one row; `Recent` and `Connect` on the next row.
189
- - `Repository`: connected path, branch, and working tree state. `Working tree: uncommitted changes` means `git status --porcelain` is not empty.
211
+ - `Connected Repository`: connected base repo path, branch, upstream/ahead-behind status, commit hash, working tree state, and a `Pull` button.
190
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.
191
214
  - `VCM Harness`: fixed-install status, bootstrap completion checks, and the bootstrap terminal when one is running.
192
215
  - `New Task`: one `task name` input.
193
216
  - `Tasks`: task list and task status.
194
217
 
195
218
  All sidebar sections are collapsed by default. When no task is selected, `Repository Path` opens by default.
196
219
 
197
- 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 task title, task status, start time, total elapsed time, total flow-round count, and Claude Code active runtime. If a flow round is currently active or settling, the dock also shows that round's start time and Claude Code active runtime.
220
+ Opening `Connected Repository` refreshes the base repo status through the
221
+ backend. VCM does not poll it continuously. The `Pull` button runs
222
+ `git pull --ff-only` against the connected base repo only. It is disabled when
223
+ the base repo has uncommitted changes, when the current branch has no upstream,
224
+ or when the active task is an inline task using the base repo directly. It does
225
+ not stash, merge, or mutate task worktrees.
226
+
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.
198
344
 
199
345
  ## Translation
200
346
 
@@ -212,7 +358,9 @@ The same file stores recent repository paths. The translation API key is stored
212
358
 
213
359
  The sidebar `Settings` section also stores the UI theme preference in this file. The default is `system`, which follows the OS/browser color-scheme preference; users can cycle between `System`, `Light`, and `Dark`.
214
360
 
215
- 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 10 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.
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.
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.
216
364
 
217
365
  Translation behavior:
218
366
 
@@ -378,13 +526,15 @@ The implementation keeps only the active manual/auto orchestration mode. It does
378
526
 
379
527
  ## Flow Pause Alerts
380
528
 
381
- VCM detects flow pauses from Claude Code hook events, not PTY silence, message history, or pending route files. `UserPromptSubmit` starts a new flow round when none is active, or continues the current round when it fires within the settle window.
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`.
382
532
 
383
- When a `Stop` hook fires, VCM marks the round as settling for 10 seconds. If another `UserPromptSubmit` fires inside that window, the same round continues and VCM increments the prompt-submit count. If no new prompt is accepted before the deadline, VCM marks the round as `paused`.
533
+ The normal path is timer-driven: `Stop` starts a backend timer, and `UserPromptSubmit` cancels it.
384
534
 
385
- The normal path is timer-driven: `Stop` starts a backend settle timer, and `UserPromptSubmit` cancels it. Reading the round state also checks expired deadlines as a recovery fallback after process restarts, sleep, or missed timers.
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.
386
536
 
387
- When `Flow pause alert` is enabled, the frontend polls the task round state and deduplicates each paused round so the same paused state does not alert on every poll. Flow duration is measured from the first `UserPromptSubmit` to `pausedAt`, falling back to the last `Stop` when needed. If the paused flow lasted less than 10 minutes, it plays the local chime 3 times at 1.4 second intervals. If the paused flow lasted 10 minutes or longer, it shows a modal `Flow paused` alert and repeats the local chime until the user clicks `Confirm`. A pause 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.
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.
388
538
 
389
539
  ## Resume Behavior
390
540
 
@@ -21,6 +21,51 @@ export function createGitAdapter(runner) {
21
21
  }
22
22
  return result.stdout.trim() || "detached";
23
23
  },
24
+ async getHeadCommit(repoRoot) {
25
+ const result = await runGit(runner, repoRoot, ["rev-parse", "HEAD"]);
26
+ if (result.exitCode !== 0) {
27
+ throw new VcmError({
28
+ code: "GIT_ERROR",
29
+ message: "Unable to read current Git commit.",
30
+ statusCode: 400,
31
+ hint: result.stderr
32
+ });
33
+ }
34
+ return result.stdout.trim();
35
+ },
36
+ async getUpstreamBranch(repoRoot) {
37
+ const result = await runGit(runner, repoRoot, [
38
+ "rev-parse",
39
+ "--abbrev-ref",
40
+ "--symbolic-full-name",
41
+ "@{u}"
42
+ ]);
43
+ if (result.exitCode !== 0) {
44
+ return null;
45
+ }
46
+ return result.stdout.trim() || null;
47
+ },
48
+ async getAheadBehind(repoRoot, upstreamBranch) {
49
+ const result = await runGit(runner, repoRoot, [
50
+ "rev-list",
51
+ "--left-right",
52
+ "--count",
53
+ `HEAD...${upstreamBranch}`
54
+ ]);
55
+ if (result.exitCode !== 0) {
56
+ throw new VcmError({
57
+ code: "GIT_ERROR",
58
+ message: "Unable to read Git ahead/behind status.",
59
+ statusCode: 400,
60
+ hint: result.stderr
61
+ });
62
+ }
63
+ const [aheadValue, behindValue] = result.stdout.trim().split(/\s+/).map((value) => Number.parseInt(value, 10));
64
+ return {
65
+ ahead: Number.isFinite(aheadValue) ? aheadValue : 0,
66
+ behind: Number.isFinite(behindValue) ? behindValue : 0
67
+ };
68
+ },
24
69
  async isDirty(repoRoot) {
25
70
  return (await this.getStatusPorcelain(repoRoot)).trim().length > 0;
26
71
  },
@@ -110,6 +155,21 @@ export function createGitAdapter(runner) {
110
155
  hint: result.stderr
111
156
  });
112
157
  }
158
+ },
159
+ async pullFastForward(repoRoot) {
160
+ const result = await runGit(runner, repoRoot, ["pull", "--ff-only"]);
161
+ if (result.exitCode !== 0) {
162
+ throw new VcmError({
163
+ code: "GIT_PULL_FAILED",
164
+ message: "Unable to pull connected repository with fast-forward only.",
165
+ statusCode: 409,
166
+ hint: result.stderr || result.stdout
167
+ });
168
+ }
169
+ return {
170
+ stdout: result.stdout,
171
+ stderr: result.stderr
172
+ };
113
173
  }
114
174
  };
115
175
  }
@@ -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
+ }
@@ -9,4 +9,7 @@ export function registerProjectRoutes(app, deps) {
9
9
  app.get("/api/projects/current", async () => {
10
10
  return deps.projectService.getCurrentProject();
11
11
  });
12
+ app.post("/api/projects/current/pull", async () => {
13
+ return deps.projectService.pullCurrentProject();
14
+ });
12
15
  }
@@ -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.getTaskRoundState({
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
+ }