@unlinearity/cli-wechat-bridge 1.2.2 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,13 +5,17 @@
5
5
  // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
6
6
  // must be released under AGPL-3.0-or-later with full source code; see
7
7
  // LICENSE.txt. Network services built on it must offer source to users.
8
+ import fs from "node:fs";
8
9
  import net from "node:net";
9
10
  import path from "node:path";
10
11
  import { spawn } from "node:child_process";
11
12
  import { fileURLToPath } from "node:url";
12
- import { BRIDGE_LOG_FILE } from "../wechat/channel-config.js";
13
+ import { BRIDGE_LOG_FILE, getWorkspaceChannelPaths } from "../wechat/channel-config.js";
14
+ import { resolveCodexDaemonControlSocketPath, } from "../bridge/bridge-adapters.codex.js";
13
15
  import { ensureWechatCredentials } from "../wechat/setup.js";
14
16
  import { ensureWecomAccount } from "../channels/wecom/setup.js";
17
+ import { ensureTelegramAccount } from "../channels/telegram/setup.js";
18
+ import { getBridgeChannelDisplayName, isBridgeChannelId, normalizeBridgeChannelId, } from "../core/channel-types.js";
15
19
  import { isDirectModuleRun } from "../core/direct-run.js";
16
20
  import { readBridgeLockFile, shouldAutoReclaimBridgeLock, } from "../bridge/bridge-state.js";
17
21
  import { clearLocalCompanionOccupancy, clearLocalCompanionEndpoint, readLocalCompanionEndpoint, } from "./local-companion-link.js";
@@ -23,8 +27,8 @@ const MODULE_DIR = path.dirname(MODULE_FILE);
23
27
  const RUNTIME_ENTRY_EXTENSION = path.extname(MODULE_FILE) === ".ts" ? ".ts" : ".js";
24
28
  const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
25
29
  const DEFAULT_ADAPTER = "codex";
26
- function log(adapter, message, channelId = "wechat") {
27
- process.stderr.write(`[${channelId}-${adapter}] ${message}\n`);
30
+ function log(options, message) {
31
+ process.stderr.write(`[${options.channelId ?? "wechat"}-${options.adapter}] ${message}\n`);
28
32
  }
29
33
  export function normalizeComparablePath(cwd) {
30
34
  const normalized = path.resolve(cwd);
@@ -115,6 +119,8 @@ export function parseCliArgs(argv) {
115
119
  let timeoutMs = DEFAULT_WAIT_TIMEOUT_MS;
116
120
  let sessionStartMode;
117
121
  let channelId = "wechat";
122
+ let attachCurrentSession = false;
123
+ let codexThreadId;
118
124
  const cliArgs = [];
119
125
  for (let i = 0; i < argv.length; i += 1) {
120
126
  const arg = argv[i];
@@ -134,15 +140,14 @@ export function parseCliArgs(argv) {
134
140
  process.stdout.write([
135
141
  `Usage: ${commandName} [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [--session-start-mode <restore|new>] [...${helpAdapter} args]`,
136
142
  "",
137
- `Ensures the ${channelId === "wecom" ? "WeCom" : "WeChat"} runtime for the current directory, then opens or reuses the visible CLI.`,
143
+ `Ensures the ${getBridgeChannelDisplayName(channelId)} runtime for the current directory, then opens or reuses the visible CLI.`,
138
144
  "If a same-workspace daemon is running, the command delegates to that daemon.",
139
- helpAdapter === "codex"
140
- ? "Codex restores the current session by default."
141
- : `${adapterLabel} starts a fresh session by default.`,
142
- helpAdapter === "codex"
143
- ? "Use --session-start-mode new to explicitly start a fresh session."
144
- : "Use --session-start-mode restore to explicitly restore the previous session.",
145
+ `${adapterLabel} starts a fresh session by default.`,
146
+ "Use --session-start-mode restore to explicitly restore the previous session.",
145
147
  "Use --doctor to check the selected adapter and workspace without starting it.",
148
+ helpAdapter === "codex"
149
+ ? `Use --attach-current-session to attach ${getBridgeChannelDisplayName(channelId)} to the live Codex daemon session in this directory (Codex >= 0.162 shared background server), instead of opening a visible client.`
150
+ : "",
146
151
  "Without a daemon, the transient runtime is companion-bound and stops when the visible CLI closes.",
147
152
  "Unknown arguments are forwarded to the visible CLI client.",
148
153
  "",
@@ -153,7 +158,7 @@ export function parseCliArgs(argv) {
153
158
  continue;
154
159
  }
155
160
  if (arg === "--channel") {
156
- if (!next || (next !== "wechat" && next !== "wecom")) {
161
+ if (!isBridgeChannelId(next) || next === "local") {
157
162
  throw new Error(`Invalid channel: ${next ?? "(missing)"}`);
158
163
  }
159
164
  channelId = next;
@@ -204,8 +209,23 @@ export function parseCliArgs(argv) {
204
209
  i += 1;
205
210
  continue;
206
211
  }
212
+ if (arg === "--attach-current-session") {
213
+ attachCurrentSession = true;
214
+ continue;
215
+ }
216
+ if (arg === "--codex-thread-id") {
217
+ if (!next) {
218
+ throw new Error("--codex-thread-id requires a value");
219
+ }
220
+ codexThreadId = next;
221
+ i += 1;
222
+ continue;
223
+ }
207
224
  cliArgs.push(arg);
208
225
  }
226
+ if (attachCurrentSession && adapter !== "codex") {
227
+ throw new Error("--attach-current-session requires the codex adapter");
228
+ }
209
229
  return {
210
230
  adapter,
211
231
  cwd,
@@ -214,6 +234,8 @@ export function parseCliArgs(argv) {
214
234
  sessionStartMode: sessionStartMode ?? defaultSessionStartMode(adapter),
215
235
  cliArgs,
216
236
  channelId,
237
+ attachCurrentSession,
238
+ codexThreadId,
217
239
  };
218
240
  }
219
241
  function isPidAlive(pid) {
@@ -238,9 +260,9 @@ async function waitForProcessExit(pid, timeoutMs) {
238
260
  }
239
261
  return !isPidAlive(pid);
240
262
  }
241
- async function stopExistingBridge(lock, requestedAdapter) {
263
+ async function stopExistingBridge(lock, options) {
242
264
  const { pid, cwd } = lock;
243
- log(requestedAdapter, `Stopping existing bridge for ${cwd} (pid=${pid})...`);
265
+ log(options, `Stopping existing bridge for ${cwd} (pid=${pid})...`);
244
266
  try {
245
267
  process.kill(pid);
246
268
  }
@@ -264,7 +286,7 @@ async function stopExistingBridge(lock, requestedAdapter) {
264
286
  else {
265
287
  clearLocalCompanionEndpoint(cwd);
266
288
  }
267
- log(requestedAdapter, `Cleared stale local companion endpoint for previous workspace ${cwd}.`);
289
+ log(options, `Cleared stale local companion endpoint for previous workspace ${cwd}.`);
268
290
  }
269
291
  async function isEndpointReachable(endpoint) {
270
292
  await new Promise((resolve) => setTimeout(resolve, 0));
@@ -289,7 +311,8 @@ async function isEndpointReachable(endpoint) {
289
311
  socket.once("error", () => finish(false));
290
312
  });
291
313
  }
292
- async function readUsableEndpoint(cwd, adapter) {
314
+ async function readUsableEndpoint(options) {
315
+ const { cwd, adapter } = options;
293
316
  const endpoint = readLocalCompanionEndpoint(cwd, { adapter });
294
317
  if (!endpoint || endpoint.kind !== adapter) {
295
318
  return { endpoint: null };
@@ -298,7 +321,7 @@ async function readUsableEndpoint(cwd, adapter) {
298
321
  return { endpoint };
299
322
  }
300
323
  clearLocalCompanionEndpoint(cwd, endpoint.instanceId, { adapter });
301
- log(adapter, `Removed stale local companion endpoint for ${cwd}.`);
324
+ log(options, `Removed stale local companion endpoint for ${cwd}.`);
302
325
  return { endpoint: null };
303
326
  }
304
327
  function isCompanionAlive(endpoint) {
@@ -314,21 +337,31 @@ function isCompanionAlive(endpoint) {
314
337
  return false;
315
338
  }
316
339
  export function buildBackgroundBridgeArgs(entryPath, options) {
317
- const lifecycle = "companion_bound";
340
+ // The attach bridge outlives this launcher (no visible client keeps the
341
+ // foreground alive), so it must not be companion-bound to the launcher pid.
342
+ const lifecycle = options.attachCurrentSession ? "persistent" : "companion_bound";
318
343
  const args = ["--no-warnings"];
319
344
  if (path.extname(entryPath) === ".ts") {
320
345
  args.push("--experimental-strip-types");
321
346
  }
322
347
  args.push(entryPath, "--adapter", options.adapter, "--cwd", options.cwd, "--lifecycle", lifecycle);
323
- if (options.channelId === "wecom") {
324
- args.push("--channel", "wecom");
348
+ if (options.channelId) {
349
+ args.push("--channel", options.channelId);
325
350
  }
326
- if (options.sessionStartMode !== "restore") {
327
- args.push("--session-start-mode", options.sessionStartMode);
351
+ // Attach discovery owns thread selection; do not restore stale bridge state.
352
+ const sessionStartMode = options.attachCurrentSession ? "new" : options.sessionStartMode;
353
+ if (sessionStartMode !== "restore") {
354
+ args.push("--session-start-mode", sessionStartMode);
328
355
  }
329
356
  if (options.profile) {
330
357
  args.push("--profile", options.profile);
331
358
  }
359
+ if (options.attachCurrentSession) {
360
+ args.push("--codex-attach-daemon");
361
+ if (options.codexThreadId) {
362
+ args.push("--codex-thread-id", options.codexThreadId);
363
+ }
364
+ }
332
365
  return args;
333
366
  }
334
367
  function startBridgeInBackground(options) {
@@ -348,10 +381,11 @@ function startBridgeInBackground(options) {
348
381
  });
349
382
  child.unref();
350
383
  }
351
- async function waitForEndpoint(cwd, adapter, timeoutMs) {
384
+ async function waitForEndpoint(options) {
385
+ const { cwd, adapter, timeoutMs } = options;
352
386
  const deadline = Date.now() + timeoutMs;
353
387
  while (Date.now() < deadline) {
354
- const result = await readUsableEndpoint(cwd, adapter);
388
+ const result = await readUsableEndpoint(options);
355
389
  if (result.endpoint) {
356
390
  return result.endpoint;
357
391
  }
@@ -361,25 +395,25 @@ async function waitForEndpoint(cwd, adapter, timeoutMs) {
361
395
  }
362
396
  async function ensureBridgeReady(options) {
363
397
  // If the lock is absent or the lock-holding process is dead, do NOT trust a
364
- // leftover endpoint. The bridge (WeChat transport) may have crashed while
398
+ // leftover endpoint. The channel transport may have crashed while
365
399
  // the opencode server kept running. Starting only the panel would leave no
366
- // bridge to poll WeChat messages.
400
+ // bridge to receive channel messages.
367
401
  const lock = readBridgeLockFile();
368
402
  const lockProcessAlive = lock ? isPidAlive(lock.pid) : false;
369
403
  if (!lock || !lockProcessAlive) {
370
404
  if (lock && !lockProcessAlive) {
371
- log(options.adapter, `Found stale lock for ${options.cwd} (pid=${lock.pid} dead). Clearing.`);
405
+ log(options, `Found stale lock for ${options.cwd} (pid=${lock.pid} dead). Clearing.`);
372
406
  clearLocalCompanionEndpoint(options.cwd, undefined, {
373
407
  adapter: options.adapter,
374
408
  });
375
409
  }
376
- log(options.adapter, `Starting bridge in background for ${options.cwd}...`);
410
+ log(options, `Starting bridge in background for ${options.cwd}...`);
377
411
  startBridgeInBackground(options);
378
- await waitForEndpoint(options.cwd, options.adapter, options.timeoutMs);
412
+ await waitForEndpoint(options);
379
413
  return { shouldOpenVisibleClient: true };
380
414
  }
381
415
  // Lock is held by a live process; decide whether to reuse, switch, or replace it.
382
- const endpointResult = await readUsableEndpoint(options.cwd, options.adapter);
416
+ const endpointResult = await readUsableEndpoint(options);
383
417
  const decision = decideLaunchAction({
384
418
  requestedAdapter: options.adapter,
385
419
  requestedCwd: options.cwd,
@@ -391,31 +425,31 @@ async function ensureBridgeReady(options) {
391
425
  sessionStartMode: options.sessionStartMode,
392
426
  requestedChannelId: options.channelId,
393
427
  });
394
- log(options.adapter, decision.message);
428
+ log(options, decision.message);
395
429
  if (decision.kind === "already_active") {
396
430
  return { shouldOpenVisibleClient: false };
397
431
  }
398
432
  if (decision.kind === "open_companion") {
399
433
  if (!endpointResult.endpoint) {
400
- await waitForEndpoint(options.cwd, options.adapter, options.timeoutMs);
434
+ await waitForEndpoint(options);
401
435
  }
402
436
  return { shouldOpenVisibleClient: true };
403
437
  }
404
438
  if (decision.kind === "switch_workspace") {
405
439
  try {
406
- await stopExistingBridge(lock, options.adapter);
440
+ await stopExistingBridge(lock, options);
407
441
  }
408
442
  catch (error) {
409
- log(options.adapter, decision.failureMessage);
443
+ log(options, decision.failureMessage);
410
444
  throw error;
411
445
  }
412
446
  }
413
447
  else {
414
- await stopExistingBridge(lock, options.adapter);
448
+ await stopExistingBridge(lock, options);
415
449
  }
416
- log(options.adapter, `Starting replacement bridge in background for ${options.cwd}...`);
450
+ log(options, `Starting replacement bridge in background for ${options.cwd}...`);
417
451
  startBridgeInBackground(options);
418
- await waitForEndpoint(options.cwd, options.adapter, options.timeoutMs);
452
+ await waitForEndpoint(options);
419
453
  return { shouldOpenVisibleClient: true };
420
454
  }
421
455
  // The daemon's ensure_slot can legitimately take a while: opening a visible
@@ -460,10 +494,10 @@ export async function tryDelegateToDaemon(options, deps = {}) {
460
494
  // launch: fall back to a local companion-bound bridge so the user still
461
495
  // gets a working terminal instead of an error for work the daemon may
462
496
  // still complete.
463
- log(options.adapter, `Daemon delegation failed (${response.error ?? "unknown error"}). Falling back to a local bridge.`);
497
+ log(options, `Daemon delegation failed (${response.error ?? "unknown error"}). Falling back to a local bridge.`);
464
498
  return false;
465
499
  }
466
- log(options.adapter, `Delegated to running ${requestedChannel}-daemon for ${options.cwd}.`, requestedChannel);
500
+ log(options, `Delegated to running ${requestedChannel}-daemon for ${options.cwd}.`);
467
501
  return true;
468
502
  }
469
503
  export async function runVisibleClient(options, runners = {}) {
@@ -487,17 +521,30 @@ export async function ensureCompanionStartWechatCredentials(adapter, ensureCrede
487
521
  await ensureCredentials({
488
522
  requireUserId: true,
489
523
  validateExisting: true,
490
- log: (message) => log(adapter, message),
524
+ log: (message) => log({ adapter, channelId: "wechat" }, message),
491
525
  });
492
526
  }
493
- export async function ensureCompanionStartChannelCredentials(options) {
494
- if (options.channelId === "wecom") {
527
+ const channelCredentialSetups = {
528
+ wechat: async (options) => {
529
+ await ensureCompanionStartWechatCredentials(options.adapter);
530
+ },
531
+ wecom: async (options) => {
495
532
  await ensureWecomAccount({
496
- log: (message) => log(options.adapter, message, "wecom"),
533
+ log: (message) => log(options, message),
534
+ });
535
+ },
536
+ telegram: async (options) => {
537
+ await ensureTelegramAccount({
538
+ log: (message) => log(options, message),
497
539
  });
498
- return;
540
+ },
541
+ };
542
+ export async function ensureCompanionStartChannelCredentials(options) {
543
+ const channelId = options.channelId ?? "wechat";
544
+ if (channelId === "local") {
545
+ throw new Error("The local channel does not support companion launchers.");
499
546
  }
500
- await ensureCompanionStartWechatCredentials(options.adapter);
547
+ await channelCredentialSetups[channelId](options);
501
548
  }
502
549
  export async function runLocalCompanionStart(argv = process.argv.slice(2)) {
503
550
  if (argv.includes("--doctor")) {
@@ -507,6 +554,9 @@ export async function runLocalCompanionStart(argv = process.argv.slice(2)) {
507
554
  }
508
555
  const options = parseCliArgs(argv);
509
556
  await ensureCompanionStartChannelCredentials(options);
557
+ if (options.attachCurrentSession) {
558
+ return await runAttachCurrentSession(options);
559
+ }
510
560
  if (await tryDelegateToDaemon(options)) {
511
561
  return 0;
512
562
  }
@@ -516,29 +566,98 @@ export async function runLocalCompanionStart(argv = process.argv.slice(2)) {
516
566
  }
517
567
  return await runVisibleClient(options);
518
568
  }
569
+ export function resolveAttachedBridgeThreadId(state, lock, options) {
570
+ if (!lock || !state || typeof state !== "object") {
571
+ return null;
572
+ }
573
+ const parsed = state;
574
+ const channelId = options.channelId ?? "wechat";
575
+ if (lock.adapter !== "codex" ||
576
+ !isSameWorkspaceCwd(lock.cwd, options.cwd) ||
577
+ (lock.channelId ?? "wechat") !== channelId ||
578
+ parsed.instanceId !== lock.instanceId ||
579
+ parsed.adapter !== "codex" ||
580
+ typeof parsed.cwd !== "string" ||
581
+ !isSameWorkspaceCwd(parsed.cwd, options.cwd) ||
582
+ (parsed.channelId ?? "wechat") !== channelId ||
583
+ typeof parsed.sharedThreadId !== "string" ||
584
+ !parsed.sharedThreadId ||
585
+ (options.codexThreadId && parsed.sharedThreadId !== options.codexThreadId)) {
586
+ return null;
587
+ }
588
+ return parsed.sharedThreadId;
589
+ }
590
+ export function formatAttachedBridgeMessage(options, sharedThreadId) {
591
+ return `Attached Codex daemon thread ${sharedThreadId.slice(0, 8)} for ${options.cwd}. ${getBridgeChannelDisplayName(options.channelId ?? "wechat")} forwarding is active; the visible Codex session keeps running unchanged.`;
592
+ }
593
+ async function waitForAttachedBridgeState(options) {
594
+ const { cwd, timeoutMs } = options;
595
+ const stateFile = getWorkspaceChannelPaths(cwd).stateFile;
596
+ const deadline = Date.now() + timeoutMs;
597
+ while (Date.now() < deadline) {
598
+ try {
599
+ const raw = fs.readFileSync(stateFile, "utf8");
600
+ const parsed = JSON.parse(raw);
601
+ const lock = readBridgeLockFile();
602
+ const sharedThreadId = resolveAttachedBridgeThreadId(parsed, lock, options);
603
+ if (sharedThreadId && lock && isPidAlive(lock.pid)) {
604
+ return { sharedThreadId };
605
+ }
606
+ }
607
+ catch {
608
+ // Not written yet; keep polling until the deadline.
609
+ }
610
+ await new Promise((resolve) => setTimeout(resolve, 250));
611
+ }
612
+ throw new Error(`Timed out waiting for the attach bridge to bind a Codex thread for ${cwd}. Check ${BRIDGE_LOG_FILE}.`);
613
+ }
614
+ /**
615
+ * Attach flow: bind the selected channel to the live Codex shared-daemon session in the
616
+ * workspace. No visible client is opened — the owning Codex TUI stays as-is —
617
+ * so readiness is the persisted bridge state carrying the attached thread id.
618
+ */
619
+ async function runAttachCurrentSession(options) {
620
+ const daemonEndpoint = readDaemonEndpoint();
621
+ if (daemonEndpoint && (await isDaemonEndpointAlive(daemonEndpoint))) {
622
+ const channel = daemonEndpoint.channelId ?? "wechat";
623
+ throw new Error(`A ${channel}-daemon is running for ${daemonEndpoint.cwd}. Attach mode needs the workspace bridge lock; stop the daemon first or use its slots.`);
624
+ }
625
+ const socketPath = resolveCodexDaemonControlSocketPath();
626
+ if (!fs.existsSync(socketPath)) {
627
+ throw new Error(`No shared Codex app-server daemon socket at ${socketPath}. Attach mode requires Codex >= 0.162 with its shared background server (the default). Sessions started with --no-daemon, --oss, or a --profile, and desktop-app embedded sessions, cannot be attached.`);
628
+ }
629
+ const lock = readBridgeLockFile();
630
+ if (lock) {
631
+ if (isPidAlive(lock.pid)) {
632
+ await stopExistingBridge(lock, options);
633
+ }
634
+ else {
635
+ clearLocalCompanionEndpoint(options.cwd, undefined, { adapter: options.adapter });
636
+ log(options, `Cleared stale bridge lock and endpoint for ${lock.cwd}.`);
637
+ }
638
+ }
639
+ log(options, `Starting attach bridge in background for ${options.cwd}...`);
640
+ startBridgeInBackground(options);
641
+ const state = await waitForAttachedBridgeState(options);
642
+ log(options, formatAttachedBridgeMessage(options, state.sharedThreadId));
643
+ return 0;
644
+ }
519
645
  export async function main(argv = process.argv.slice(2)) {
520
646
  try {
521
647
  const exitCode = await runLocalCompanionStart(argv);
522
648
  process.exit(exitCode);
523
649
  }
524
650
  catch (error) {
525
- const adapter = (() => {
526
- try {
527
- return parseCliArgs(argv).adapter;
528
- }
529
- catch {
530
- return DEFAULT_ADAPTER;
531
- }
532
- })();
533
- const channelId = (() => {
534
- try {
535
- return parseCliArgs(argv).channelId ?? "wechat";
536
- }
537
- catch {
538
- return "wechat";
539
- }
540
- })();
541
- log(adapter, error instanceof Error ? error.message : String(error), channelId);
651
+ // Argument validation may fail; retain the wrapper's channel and adapter
652
+ // without running the failing parser again.
653
+ const adapterIndex = argv.lastIndexOf("--adapter");
654
+ const adapterValue = adapterIndex >= 0 ? argv[adapterIndex + 1] : undefined;
655
+ const adapter = adapterValue && ["codex", "claude", "opencode", "pi"].includes(adapterValue)
656
+ ? adapterValue
657
+ : DEFAULT_ADAPTER;
658
+ const channelIndex = argv.lastIndexOf("--channel");
659
+ const channelId = normalizeBridgeChannelId(channelIndex >= 0 ? argv[channelIndex + 1] : undefined);
660
+ log({ adapter, channelId }, error instanceof Error ? error.message : String(error));
542
661
  process.exit(1);
543
662
  }
544
663
  }
@@ -1,7 +1,15 @@
1
+ export const BRIDGE_CHANNEL_DISPLAY_NAMES = {
2
+ wechat: "WeChat",
3
+ wecom: "WeCom",
4
+ telegram: "Telegram",
5
+ local: "Local",
6
+ };
7
+ export function isBridgeChannelId(value) {
8
+ return typeof value === "string" && Object.hasOwn(BRIDGE_CHANNEL_DISPLAY_NAMES, value);
9
+ }
10
+ export function getBridgeChannelDisplayName(channelId) {
11
+ return BRIDGE_CHANNEL_DISPLAY_NAMES[channelId];
12
+ }
1
13
  export function normalizeBridgeChannelId(value) {
2
- if (value === "wecom")
3
- return "wecom";
4
- if (value === "local")
5
- return "local";
6
- return "wechat";
14
+ return isBridgeChannelId(value) ? value : "wechat";
7
15
  }
@@ -39,11 +39,15 @@ import { forwardBridgeEvent } from "../core/bridge-event-forwarder.js";
39
39
  import { isDirectModuleRun } from "../core/direct-run.js";
40
40
  import { WechatChannelPort } from "../channels/wechat/wechat-channel-port.js";
41
41
  import { ensureWecomAccount } from "../channels/wecom/setup.js";
42
+ import { ensureTelegramAccount } from "../channels/telegram/setup.js";
43
+ import { TelegramTransport } from "../channels/telegram/telegram-transport.js";
44
+ import { TelegramChannelPort } from "../channels/telegram/telegram-channel-port.js";
42
45
  import { WecomChannelPort } from "../channels/wecom/wecom-channel-port.js";
43
46
  import { WecomTransport } from "../channels/wecom/wecom-transport.js";
44
47
  import { createChannelDriver } from "../channels/channel-driver-factory.js";
45
48
  import { LocalChannelPort } from "../channels/local/local-channel-port.js";
46
49
  import { getLocalChannelFiles, LOCAL_CHANNEL_OPERATOR_ID, } from "../channels/local/local-driver.js";
50
+ import { getBridgeChannelDisplayName, isBridgeChannelId, normalizeBridgeChannelId, } from "../core/channel-types.js";
47
51
  import { clearLocalCompanionEndpoint, clearLocalCompanionOccupancy, readLocalCompanionEndpoint, } from "../companion/local-companion-link.js";
48
52
  import { attachDaemonRequestListener, buildDaemonToken, clearDaemonEndpoint, DAEMON_PROTOCOL_VERSION, isPidAlive, readDaemonEndpoint, sendDaemonRequest, sendDaemonResponse, writeDaemonEndpoint, } from "./daemon-link.js";
49
53
  const MODULE_FILE = fileURLToPath(import.meta.url);
@@ -65,11 +69,7 @@ const DAEMON_ADAPTERS = ["codex", "claude", "opencode", "pi"];
65
69
  function getCliChannelId(argv = process.argv.slice(2)) {
66
70
  const index = argv.indexOf("--channel");
67
71
  const value = index >= 0 ? argv[index + 1] : undefined;
68
- if (value === "wecom")
69
- return "wecom";
70
- if (value === "local")
71
- return "local";
72
- return "wechat";
72
+ return normalizeBridgeChannelId(value);
73
73
  }
74
74
  function log(message) {
75
75
  process.stderr.write(`[${getCliChannelId()}-daemon] ${message}\n`);
@@ -141,7 +141,7 @@ export function parseDaemonCliArgs(argv) {
141
141
  }
142
142
  const next = argv[i + 1];
143
143
  if (arg === "--help" || arg === "-h") {
144
- const channelDisplayName = channelId === "wecom" ? "WeCom" : channelId === "local" ? "Local" : "WeChat";
144
+ const channelDisplayName = getBridgeChannelDisplayName(channelId);
145
145
  process.stdout.write([
146
146
  `Usage: ${channelId}-daemon [--cwd <path>] [--adapter <codex|claude|opencode|pi>] [--profile <name-or-path>] [--no-open]`,
147
147
  "",
@@ -160,7 +160,7 @@ export function parseDaemonCliArgs(argv) {
160
160
  continue;
161
161
  }
162
162
  if (arg === "--channel") {
163
- if (!next || (next !== "wechat" && next !== "wecom" && next !== "local")) {
163
+ if (!isBridgeChannelId(next)) {
164
164
  throw new Error(`Invalid channel: ${next ?? "(missing)"}`);
165
165
  }
166
166
  channelId = next;
@@ -573,6 +573,7 @@ export class WechatDaemon {
573
573
  channelDriver;
574
574
  channelId;
575
575
  wecomTransport;
576
+ telegramTransport;
576
577
  localFiles;
577
578
  inboundConversationContext = new InboundConversationContext();
578
579
  fallbackConversation;
@@ -601,6 +602,7 @@ export class WechatDaemon {
601
602
  this.transport = params.transport;
602
603
  this.channelId = params.channelId ?? "wechat";
603
604
  this.wecomTransport = params.wecomTransport ?? null;
605
+ this.telegramTransport = params.telegramTransport ?? null;
604
606
  this.localFiles = params.localFiles ?? null;
605
607
  this.deps = params.deps ?? {};
606
608
  this.channelDriver = createChannelDriver({
@@ -609,6 +611,7 @@ export class WechatDaemon {
609
611
  accountId: params.accountId,
610
612
  wechatTransport: this.transport,
611
613
  wecomTransport: this.wecomTransport,
614
+ telegramTransport: this.telegramTransport,
612
615
  localFiles: this.localFiles,
613
616
  buildWechatInboundPrompt: (text, attachments) => buildWechatInboundPrompt(text, attachments.filter((attachment) => attachment.kind === "image" || attachment.kind === "file")),
614
617
  log: (message) => this.daemonLog(message),
@@ -893,6 +896,12 @@ export class WechatDaemon {
893
896
  catch {
894
897
  // Best effort shutdown.
895
898
  }
899
+ try {
900
+ await this.telegramTransport?.stop();
901
+ }
902
+ catch {
903
+ // Best effort shutdown.
904
+ }
896
905
  clearDaemonEndpoint();
897
906
  for (const slot of this.slots.values()) {
898
907
  try {
@@ -1402,6 +1411,16 @@ export class WechatDaemon {
1402
1411
  },
1403
1412
  });
1404
1413
  }
1414
+ if (this.channelId === "telegram") {
1415
+ return new TelegramChannelPort({
1416
+ transport: this.telegramTransport,
1417
+ sendText: (target, text, kind) => this.queueWechatMessage(this.authorizedUserId, text, toWechatSendContext(kind), target),
1418
+ prefixText: (currentAdapter, text) => prefixDaemonAdapterMessage(currentAdapter ?? adapter, text),
1419
+ onEmptyVisibleReply: (currentAdapter, rawText) => {
1420
+ appendDaemonLog(`empty_visible_final_reply: adapter=${currentAdapter ?? adapter} raw=${truncatePreview(rawText)}`);
1421
+ },
1422
+ });
1423
+ }
1405
1424
  if (this.channelId === "local") {
1406
1425
  return new LocalChannelPort({
1407
1426
  sendText: (recipientId, text, context) => this.queueWechatMessage(recipientId, text, context),
@@ -2415,19 +2434,26 @@ export async function runDaemon(options) {
2415
2434
  const wecomAccount = options.channelId === "wecom"
2416
2435
  ? await ensureWecomAccount({ log })
2417
2436
  : null;
2437
+ const telegramAccount = options.channelId === "telegram"
2438
+ ? await ensureTelegramAccount({ log })
2439
+ : null;
2418
2440
  const credentials = wecomAccount
2419
2441
  ? { userId: wecomAccount.operatorUserId }
2420
- : options.channelId === "local"
2421
- ? { userId: LOCAL_CHANNEL_OPERATOR_ID }
2422
- : await ensureWechatCredentials({
2423
- requireUserId: true,
2424
- validateExisting: true,
2425
- log,
2426
- });
2442
+ : telegramAccount
2443
+ ? { userId: telegramAccount.operatorUserId }
2444
+ : options.channelId === "local"
2445
+ ? { userId: LOCAL_CHANNEL_OPERATOR_ID }
2446
+ : await ensureWechatCredentials({
2447
+ requireUserId: true,
2448
+ validateExisting: true,
2449
+ log,
2450
+ });
2427
2451
  if (!credentials.userId) {
2428
2452
  throw new Error(options.channelId === "wecom"
2429
2453
  ? "Saved WeCom credentials are missing operatorUserId."
2430
- : "Saved WeChat credentials are missing userId.");
2454
+ : options.channelId === "telegram"
2455
+ ? "Saved Telegram credentials are missing operatorUserId."
2456
+ : "Saved WeChat credentials are missing userId.");
2431
2457
  }
2432
2458
  const daemon = new WechatDaemon({
2433
2459
  cwd: options.cwd,
@@ -2437,7 +2463,7 @@ export async function runDaemon(options) {
2437
2463
  ? new WeChatTransport({ log, logError })
2438
2464
  : null,
2439
2465
  channelId: options.channelId,
2440
- accountId: wecomAccount?.botId,
2466
+ accountId: wecomAccount?.botId ?? telegramAccount?.botUsername,
2441
2467
  wecomTransport: wecomAccount
2442
2468
  ? new WecomTransport({
2443
2469
  account: wecomAccount,
@@ -2447,6 +2473,15 @@ export async function runDaemon(options) {
2447
2473
  },
2448
2474
  })
2449
2475
  : null,
2476
+ telegramTransport: telegramAccount
2477
+ ? new TelegramTransport({
2478
+ account: telegramAccount,
2479
+ logger: {
2480
+ log: (message) => log(message),
2481
+ error: (message) => logError(message),
2482
+ },
2483
+ })
2484
+ : null,
2450
2485
  localFiles: options.channelId === "local"
2451
2486
  ? getLocalChannelFiles(options.cwd)
2452
2487
  : null,
@@ -337,6 +337,7 @@ export const messages = {
337
337
  "status.questions": "{count} question(s)",
338
338
  "operator.wechat": "the configured WeChat owner",
339
339
  "operator.wecom": "the paired WeCom operator",
340
+ "operator.telegram": "the paired Telegram operator",
340
341
  "operator.local": "the local operator",
341
342
  "status.label.instance_id": "instance_id",
342
343
  "status.label.adapter": "adapter",
@@ -337,6 +337,7 @@ export const messages = {
337
337
  "status.questions": "{count} 个问题",
338
338
  "operator.wechat": "已配置的微信操作者",
339
339
  "operator.wecom": "已配对的企业微信操作者",
340
+ "operator.telegram": "已配对的 Telegram 操作者",
340
341
  "operator.local": "本地操作者",
341
342
  "status.label.instance_id": "实例 ID",
342
343
  "status.label.adapter": "适配器",