cli-wechat-bridge 1.2.1 → 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.
Files changed (54) hide show
  1. package/README.md +146 -73
  2. package/README_EN.md +544 -0
  3. package/bin/telegram-check-update.mjs +10 -0
  4. package/bin/telegram-claude.mjs +10 -0
  5. package/bin/telegram-codex.mjs +10 -0
  6. package/bin/telegram-daemon.mjs +10 -0
  7. package/bin/telegram-opencode.mjs +10 -0
  8. package/bin/telegram-pi.mjs +10 -0
  9. package/bin/telegram-setup.mjs +10 -0
  10. package/dist/bridge/adapter-control.js +19 -12
  11. package/dist/bridge/bridge-adapters.claude.js +219 -115
  12. package/dist/bridge/bridge-adapters.codex.js +532 -99
  13. package/dist/bridge/bridge-adapters.core.js +84 -7
  14. package/dist/bridge/bridge-adapters.opencode.js +202 -55
  15. package/dist/bridge/bridge-adapters.pi.js +23 -18
  16. package/dist/bridge/bridge-adapters.shared.js +9 -9
  17. package/dist/bridge/bridge-session-resume.js +13 -6
  18. package/dist/bridge/bridge-state.js +3 -1
  19. package/dist/bridge/bridge-utils.js +94 -144
  20. package/dist/bridge/claude-hooks.js +60 -3
  21. package/dist/bridge/claude-native-controls.js +19 -12
  22. package/dist/bridge/language-control.js +33 -0
  23. package/dist/bridge/native-terminal-control.js +26 -10
  24. package/dist/bridge/opencode-v2-compat.js +11 -3
  25. package/dist/bridge/wechat-bridge.js +154 -72
  26. package/dist/channels/channel-driver-factory.js +56 -0
  27. package/dist/channels/local/local-channel-port.js +40 -0
  28. package/dist/channels/local/local-driver.js +217 -0
  29. package/dist/channels/telegram/setup-cli.js +12 -0
  30. package/dist/channels/telegram/setup.js +163 -0
  31. package/dist/channels/telegram/telegram-channel-port.js +47 -0
  32. package/dist/channels/telegram/telegram-config.js +66 -0
  33. package/dist/channels/telegram/telegram-driver.js +106 -0
  34. package/dist/channels/telegram/telegram-message.js +41 -0
  35. package/dist/channels/telegram/telegram-transport.js +378 -0
  36. package/dist/channels/wechat/wechat-driver.js +11 -2
  37. package/dist/channels/wecom/wecom-driver.js +8 -1
  38. package/dist/companion/local-companion-start.js +180 -61
  39. package/dist/companion/local-companion.js +27 -7
  40. package/dist/companion/pi-tui-bridge-extension.js +10 -4
  41. package/dist/core/bridge-control.js +3 -0
  42. package/dist/core/bridge-defer.js +3 -2
  43. package/dist/core/bridge-event-forwarder.js +3 -0
  44. package/dist/core/channel-types.js +13 -1
  45. package/dist/daemon/wechat-daemon.js +239 -126
  46. package/dist/i18n/index.js +25 -11
  47. package/dist/i18n/messages-en.js +245 -1
  48. package/dist/i18n/messages-zh.js +245 -1
  49. package/dist/runtime/legacy-adapter-runtime.js +7 -4
  50. package/dist/wechat/channel-config.js +3 -2
  51. package/package.json +17 -5
  52. package/skills/codex-bridge/SKILL.md +35 -0
  53. package/skills/codex-bridge/agents/openai.yaml +4 -0
  54. package/skills/codex-bridge/scripts/install.mjs +28 -0
@@ -0,0 +1,217 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
7
+ import { t } from "../../i18n/index.js";
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { getWorkspaceChannelPaths } from "../../wechat/channel-config.js";
11
+ /**
12
+ * Loopback channel for local end-to-end runs. Inbound messages are JSONL
13
+ * lines appended to the inbox file; every outbound text is appended to the
14
+ * transcript file so an external harness can drive and observe the full
15
+ * bridge pipeline without a remote messaging platform.
16
+ */
17
+ export const LOCAL_CHANNEL_OPERATOR_ID = "local-operator";
18
+ export function getLocalChannelFiles(cwd) {
19
+ const workspaceDir = getWorkspaceChannelPaths(cwd).workspaceDir;
20
+ return {
21
+ inboxFile: path.join(workspaceDir, "local-inbox.jsonl"),
22
+ transcriptFile: path.join(workspaceDir, "local-transcript.jsonl"),
23
+ };
24
+ }
25
+ export class LocalChannelDriver {
26
+ id = "local";
27
+ displayName = "Local";
28
+ get operatorDescription() { return t("operator.local"); }
29
+ capabilities = {
30
+ streamingReplies: false,
31
+ outboundAttachments: false,
32
+ multiConversation: false,
33
+ pushInbound: true,
34
+ };
35
+ operatorId;
36
+ inboxFile;
37
+ transcriptFile;
38
+ pollIntervalMs;
39
+ log;
40
+ handlers = null;
41
+ timer = null;
42
+ inboxOffset = 0;
43
+ pendingLine = "";
44
+ messageSeq = 0;
45
+ constructor(options) {
46
+ this.operatorId = options.operatorId;
47
+ this.inboxFile = options.inboxFile;
48
+ this.transcriptFile = options.transcriptFile;
49
+ this.pollIntervalMs = options.pollIntervalMs ?? 200;
50
+ this.log = options.log ?? (() => { });
51
+ }
52
+ defaultConversation() {
53
+ return this.directConversation(this.operatorId);
54
+ }
55
+ directConversation(senderId) {
56
+ return {
57
+ channelId: "local",
58
+ conversationId: senderId,
59
+ recipientId: senderId,
60
+ metadata: { chatType: "direct" },
61
+ };
62
+ }
63
+ start(handlers) {
64
+ this.handlers = handlers;
65
+ fs.mkdirSync(path.dirname(this.inboxFile), { recursive: true });
66
+ // Start at end-of-file so lines left over from earlier runs are ignored.
67
+ this.inboxOffset = this.measureInboxSize();
68
+ this.pendingLine = "";
69
+ this.timer = setInterval(() => {
70
+ void this.pumpInbox();
71
+ }, this.pollIntervalMs);
72
+ this.timer.unref?.();
73
+ this.log("local_channel_started");
74
+ }
75
+ /** Clears the inbox watcher. Not part of ChannelDriver; callers may stop explicitly. */
76
+ stop() {
77
+ if (this.timer) {
78
+ clearInterval(this.timer);
79
+ }
80
+ this.timer = null;
81
+ this.handlers = null;
82
+ }
83
+ waitUntilReady() {
84
+ return Promise.resolve();
85
+ }
86
+ async sendText(params) {
87
+ const { target, text, context, log } = params;
88
+ this.appendTranscript({
89
+ ts: new Date().toISOString(),
90
+ direction: "outbound",
91
+ context,
92
+ conversationId: target.conversationId,
93
+ recipientId: target.recipientId,
94
+ text,
95
+ });
96
+ log(`local_send_completed: context=${context} recipient=${target.recipientId} chars=${Array.from(text).length}`);
97
+ return { status: "sent" };
98
+ }
99
+ buildInboundPrompt(text, attachments) {
100
+ if (attachments.length === 0) {
101
+ return text;
102
+ }
103
+ const lines = attachments.map((attachment) => {
104
+ const label = attachment.fileName ? ` (${attachment.fileName})` : "";
105
+ return `- ${attachment.path}${label}`;
106
+ });
107
+ return `${text}\n\nLocal attachments:\n${lines.join("\n")}`;
108
+ }
109
+ measureInboxSize() {
110
+ try {
111
+ return fs.statSync(this.inboxFile).size;
112
+ }
113
+ catch {
114
+ return 0;
115
+ }
116
+ }
117
+ async pumpInbox() {
118
+ const handlers = this.handlers;
119
+ if (!handlers) {
120
+ return;
121
+ }
122
+ let size;
123
+ try {
124
+ size = fs.statSync(this.inboxFile).size;
125
+ }
126
+ catch {
127
+ return;
128
+ }
129
+ if (size < this.inboxOffset) {
130
+ // The inbox was truncated; restart from the beginning.
131
+ this.inboxOffset = 0;
132
+ this.pendingLine = "";
133
+ }
134
+ if (size === this.inboxOffset) {
135
+ return;
136
+ }
137
+ let chunk;
138
+ try {
139
+ const fd = fs.openSync(this.inboxFile, "r");
140
+ try {
141
+ const buffer = Buffer.alloc(size - this.inboxOffset);
142
+ fs.readSync(fd, buffer, 0, buffer.length, this.inboxOffset);
143
+ chunk = buffer.toString("utf8");
144
+ }
145
+ finally {
146
+ fs.closeSync(fd);
147
+ }
148
+ }
149
+ catch {
150
+ return;
151
+ }
152
+ this.inboxOffset = size;
153
+ const complete = chunk.endsWith("\n");
154
+ const combined = this.pendingLine + chunk;
155
+ const parts = combined.split("\n");
156
+ this.pendingLine = complete ? "" : (parts.pop() ?? "");
157
+ for (const line of parts) {
158
+ const trimmed = line.trim();
159
+ if (!trimmed) {
160
+ continue;
161
+ }
162
+ try {
163
+ await this.dispatchInboxLine(trimmed, handlers);
164
+ }
165
+ catch (error) {
166
+ this.log(`local_inbound_failed: error=${error instanceof Error ? error.message : String(error)}`);
167
+ }
168
+ }
169
+ }
170
+ async dispatchInboxLine(line, handlers) {
171
+ let parsed;
172
+ try {
173
+ parsed = JSON.parse(line);
174
+ }
175
+ catch {
176
+ this.log("local_inbox_invalid_json: skipped one line");
177
+ return;
178
+ }
179
+ if (typeof parsed?.text !== "string" || !parsed.text.trim()) {
180
+ this.log("local_inbox_invalid_line: skipped one line without text");
181
+ return;
182
+ }
183
+ const senderId = typeof parsed.senderId === "string" && parsed.senderId
184
+ ? parsed.senderId
185
+ : this.operatorId;
186
+ const conversationId = typeof parsed.conversationId === "string" && parsed.conversationId
187
+ ? parsed.conversationId
188
+ : senderId;
189
+ const attachments = Array.isArray(parsed.attachments)
190
+ ? parsed.attachments.filter((attachment) => Boolean(attachment) &&
191
+ typeof attachment.path === "string" &&
192
+ typeof attachment.kind === "string")
193
+ : [];
194
+ this.messageSeq += 1;
195
+ const message = {
196
+ id: `local-${Date.now().toString(36)}-${this.messageSeq}`,
197
+ conversation: { ...this.directConversation(senderId), conversationId },
198
+ senderId,
199
+ text: parsed.text,
200
+ attachments,
201
+ createdAt: new Date().toISOString(),
202
+ };
203
+ this.appendTranscript({
204
+ ts: message.createdAt,
205
+ direction: "inbound",
206
+ context: "inbound",
207
+ conversationId,
208
+ recipientId: senderId,
209
+ text: parsed.text,
210
+ });
211
+ await handlers.onInboundMessage(message);
212
+ }
213
+ appendTranscript(record) {
214
+ fs.mkdirSync(path.dirname(this.transcriptFile), { recursive: true });
215
+ fs.appendFileSync(this.transcriptFile, `${JSON.stringify(record)}\n`, "utf8");
216
+ }
217
+ }
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env bun
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
4
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
5
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
6
+ // must be released under AGPL-3.0-or-later with full source code; see
7
+ // LICENSE.txt. Network services built on it must offer source to users.
8
+ import { runTelegramSetup } from "./setup.js";
9
+ runTelegramSetup().catch((error) => {
10
+ console.error(error instanceof Error ? error.message : String(error));
11
+ process.exit(1);
12
+ });
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env bun
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
4
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
5
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
6
+ // must be released under AGPL-3.0-or-later with full source code; see
7
+ // LICENSE.txt. Network services built on it must offer source to users.
8
+ import crypto from "node:crypto";
9
+ import readline from "node:readline";
10
+ import { Writable } from "node:stream";
11
+ import { loadStoredTelegramAccount, resolveTelegramAccount, saveTelegramAccount, } from "./telegram-config.js";
12
+ import { TelegramTransport } from "./telegram-transport.js";
13
+ const PAIRING_TIMEOUT_MS = 10 * 60 * 1000;
14
+ function ask(prompt) {
15
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
16
+ return new Promise((resolve) => {
17
+ rl.question(prompt, (answer) => {
18
+ rl.close();
19
+ resolve(answer.trim());
20
+ });
21
+ });
22
+ }
23
+ function askSecret(prompt) {
24
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
25
+ return ask(prompt);
26
+ }
27
+ let muted = false;
28
+ const output = new Writable({
29
+ write(chunk, _encoding, callback) {
30
+ if (!muted) {
31
+ process.stdout.write(chunk);
32
+ }
33
+ callback();
34
+ },
35
+ });
36
+ const rl = readline.createInterface({
37
+ input: process.stdin,
38
+ output,
39
+ terminal: true,
40
+ });
41
+ return new Promise((resolve) => {
42
+ rl.question(prompt, (answer) => {
43
+ muted = false;
44
+ rl.close();
45
+ process.stdout.write("\n");
46
+ resolve(answer.trim());
47
+ });
48
+ muted = true;
49
+ });
50
+ }
51
+ export function createTelegramPairingCode() {
52
+ return crypto.randomInt(0, 1_000_000).toString().padStart(6, "0");
53
+ }
54
+ export function isTelegramPairingText(text, code) {
55
+ return text.trim() === `/pair ${code}`;
56
+ }
57
+ /**
58
+ * Pair the operator: validate the token, then run a temporary long poll and
59
+ * wait for the operator to send `/pair <code>` from their Telegram account.
60
+ * Records the user id (authorization gate) and the chat id (send target).
61
+ */
62
+ export async function pairTelegramOperator(options) {
63
+ const log = options.log ?? console.log;
64
+ const code = options.code ?? createTelegramPairingCode();
65
+ const transport = new TelegramTransport({
66
+ account: {
67
+ version: 1,
68
+ botToken: options.botToken,
69
+ botUsername: "",
70
+ operatorUserId: "",
71
+ operatorChatId: "",
72
+ pairedAt: new Date().toISOString(),
73
+ },
74
+ fetchImpl: options.fetchImpl,
75
+ });
76
+ const botUsername = await transport.validateToken();
77
+ log(`Telegram bot @${botUsername} authenticated.`);
78
+ try {
79
+ return await new Promise((resolve, reject) => {
80
+ let settled = false;
81
+ const finish = (error, account) => {
82
+ if (settled)
83
+ return;
84
+ settled = true;
85
+ clearTimeout(timer);
86
+ if (error || !account) {
87
+ reject(error ?? new Error("Telegram operator pairing failed."));
88
+ return;
89
+ }
90
+ resolve(account);
91
+ };
92
+ const timer = setTimeout(() => {
93
+ finish(new Error("Telegram operator pairing timed out."));
94
+ }, options.timeoutMs ?? PAIRING_TIMEOUT_MS);
95
+ timer.unref?.();
96
+ transport.setHandlers({
97
+ onMessage: async () => { },
98
+ onConnected: async () => {
99
+ log(`Send /pair ${code} to @${botUsername} from your Telegram account within 10 minutes.`);
100
+ },
101
+ onFatal: async (error) => {
102
+ finish(error);
103
+ },
104
+ onPreAuthMessage: async (message) => {
105
+ if (message.chatType !== "private" || !isTelegramPairingText(message.text, code)) {
106
+ return;
107
+ }
108
+ finish(undefined, {
109
+ version: 1,
110
+ botToken: options.botToken,
111
+ botUsername,
112
+ operatorUserId: message.userId,
113
+ operatorChatId: message.chatId,
114
+ pairedAt: new Date().toISOString(),
115
+ });
116
+ },
117
+ });
118
+ void transport.start();
119
+ });
120
+ }
121
+ finally {
122
+ await transport.stop();
123
+ }
124
+ }
125
+ export async function ensureTelegramAccount(options = {}) {
126
+ const existing = resolveTelegramAccount();
127
+ if (existing) {
128
+ return existing;
129
+ }
130
+ options.log?.("No paired Telegram account found. Starting setup...");
131
+ return (options.setup ?? (() => runTelegramSetup([])))();
132
+ }
133
+ export async function runTelegramSetup(argv = process.argv.slice(2)) {
134
+ if (argv.includes("--help") || argv.includes("-h")) {
135
+ console.log("Usage: telegram-setup [--check]");
136
+ console.log("");
137
+ console.log("Pairs one Telegram operator with the local CLI bridge.");
138
+ console.log("The bot token may also be supplied through TELEGRAM_BOT_TOKEN.");
139
+ process.exit(0);
140
+ }
141
+ const resolved = resolveTelegramAccount();
142
+ if (argv.includes("--check")) {
143
+ if (!resolved) {
144
+ throw new Error("No complete Telegram credentials are configured.");
145
+ }
146
+ const transport = new TelegramTransport({ account: resolved });
147
+ const username = await transport.validateToken();
148
+ console.log(`Telegram credentials are valid. Bot: @${username}, paired operator: ${resolved.operatorUserId}`);
149
+ return resolved;
150
+ }
151
+ const stored = loadStoredTelegramAccount();
152
+ const botToken = process.env.TELEGRAM_BOT_TOKEN?.trim() ||
153
+ await askSecret(`Bot Token${stored?.botToken ? " [press Enter to keep saved value]" : " (from @BotFather)"}: `) ||
154
+ stored?.botToken || "";
155
+ if (!botToken) {
156
+ throw new Error("A Telegram bot token is required.");
157
+ }
158
+ const account = await pairTelegramOperator({ botToken });
159
+ saveTelegramAccount(account);
160
+ console.log("Telegram operator pairing completed.");
161
+ console.log("Start from a project directory with telegram-codex, telegram-claude, telegram-opencode, telegram-pi, or telegram-daemon.");
162
+ return account;
163
+ }
@@ -0,0 +1,47 @@
1
+ import { forwardWecomFinalReply } from "../wecom/bridge-final-reply.js";
2
+ import { formatTelegramVisibleText } from "./telegram-message.js";
3
+ export class TelegramChannelPort {
4
+ channelId = "telegram";
5
+ deps;
6
+ constructor(deps) {
7
+ this.deps = deps;
8
+ }
9
+ sendText(target, text, kind) {
10
+ return this.deps.sendText?.(target, text, kind) ??
11
+ this.deps.transport.sendText(target, text, kind);
12
+ }
13
+ sendAttachment(target, attachment) {
14
+ return this.deps.sendAttachment?.(target, attachment) ??
15
+ this.deps.transport.sendAttachment(target, attachment);
16
+ }
17
+ async send(output) {
18
+ if (output.target.channelId !== this.channelId) {
19
+ throw new Error(`Telegram channel cannot send to ${output.target.channelId}.`);
20
+ }
21
+ const prefix = (text) => formatTelegramVisibleText(this.deps.prefixText?.(output.adapter, text) ?? text);
22
+ if (output.kind === "final_reply") {
23
+ // Final-reply splitting (attachment extraction etc.) is channel-neutral;
24
+ // the WeCom implementation is reused verbatim.
25
+ await forwardWecomFinalReply({
26
+ adapter: output.adapter ?? "codex",
27
+ rawText: output.text ?? "",
28
+ target: output.target,
29
+ onEmptyVisibleReply: (rawText) => {
30
+ this.deps.onEmptyVisibleReply?.(output.adapter, rawText);
31
+ },
32
+ sender: {
33
+ sendText: (text) => this.sendText(output.target, prefix(text), "final_reply"),
34
+ sendAttachment: (attachment) => this.sendAttachment(output.target, attachment),
35
+ },
36
+ });
37
+ return true;
38
+ }
39
+ if (output.attachment) {
40
+ await this.sendAttachment(output.target, output.attachment);
41
+ }
42
+ if (output.text?.trim()) {
43
+ return this.sendText(output.target, prefix(output.text), output.kind);
44
+ }
45
+ return true;
46
+ }
47
+ }
@@ -0,0 +1,66 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { CHANNEL_DATA_DIR, ensureChannelDataDir, } from "../../wechat/channel-config.js";
10
+ export const TELEGRAM_DATA_DIR = path.join(CHANNEL_DATA_DIR, "telegram");
11
+ export const TELEGRAM_CREDENTIALS_FILE = path.join(TELEGRAM_DATA_DIR, "account.json");
12
+ export const TELEGRAM_INBOUND_ATTACHMENTS_DIR = path.join(TELEGRAM_DATA_DIR, "inbound-attachments");
13
+ export const TELEGRAM_INBOUND_MESSAGE_CLAIMS_DIR = path.join(TELEGRAM_DATA_DIR, "inbound-message-claims");
14
+ export function ensureTelegramDataDir() {
15
+ ensureChannelDataDir();
16
+ fs.mkdirSync(TELEGRAM_DATA_DIR, { recursive: true });
17
+ }
18
+ export function normalizeStoredTelegramAccount(parsed) {
19
+ if (parsed.version !== 1 ||
20
+ typeof parsed.botToken !== "string" ||
21
+ !parsed.botToken.trim() ||
22
+ typeof parsed.operatorUserId !== "string" ||
23
+ !parsed.operatorUserId.trim() ||
24
+ typeof parsed.operatorChatId !== "string" ||
25
+ !parsed.operatorChatId.trim() ||
26
+ typeof parsed.pairedAt !== "string") {
27
+ return null;
28
+ }
29
+ return {
30
+ version: 1,
31
+ botToken: parsed.botToken.trim(),
32
+ botUsername: typeof parsed.botUsername === "string" ? parsed.botUsername.trim() : "",
33
+ operatorUserId: parsed.operatorUserId.trim(),
34
+ operatorChatId: parsed.operatorChatId.trim(),
35
+ pairedAt: parsed.pairedAt,
36
+ };
37
+ }
38
+ export function loadStoredTelegramAccount(filePath = TELEGRAM_CREDENTIALS_FILE) {
39
+ try {
40
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
41
+ return normalizeStoredTelegramAccount(parsed);
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ export function saveTelegramAccount(account, filePath = TELEGRAM_CREDENTIALS_FILE) {
48
+ ensureTelegramDataDir();
49
+ fs.writeFileSync(filePath, `${JSON.stringify(account, null, 2)}\n`, "utf-8");
50
+ }
51
+ export function resolveTelegramAccount(env = process.env, stored = loadStoredTelegramAccount()) {
52
+ const botToken = env.TELEGRAM_BOT_TOKEN?.trim() || stored?.botToken || "";
53
+ const operatorUserId = env.TELEGRAM_OPERATOR_USER_ID?.trim() || stored?.operatorUserId || "";
54
+ const operatorChatId = env.TELEGRAM_OPERATOR_CHAT_ID?.trim() || stored?.operatorChatId || "";
55
+ if (!botToken || !operatorUserId || !operatorChatId) {
56
+ return null;
57
+ }
58
+ return {
59
+ version: 1,
60
+ botToken,
61
+ botUsername: env.TELEGRAM_BOT_USERNAME?.trim() || stored?.botUsername || "",
62
+ operatorUserId,
63
+ operatorChatId,
64
+ pairedAt: stored?.pairedAt ?? new Date().toISOString(),
65
+ };
66
+ }
@@ -0,0 +1,106 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
7
+ import { t } from "../../i18n/index.js";
8
+ import { buildTelegramInboundPrompt, formatTelegramVisibleText, toTelegramOutputKind, } from "./telegram-message.js";
9
+ import { truncatePreview } from "../../core/text-utils.js";
10
+ /** ChannelDriver over the official Telegram Bot API long-poll transport. */
11
+ export class TelegramChannelDriver {
12
+ id = "telegram";
13
+ displayName = "Telegram";
14
+ get operatorDescription() { return t("operator.telegram"); }
15
+ capabilities = {
16
+ streamingReplies: false,
17
+ outboundAttachments: true,
18
+ multiConversation: true,
19
+ pushInbound: true,
20
+ };
21
+ transport;
22
+ accountId;
23
+ operatorId;
24
+ logError;
25
+ constructor(options) {
26
+ this.transport = options.transport;
27
+ this.accountId = options.accountId;
28
+ this.operatorId = options.operatorId;
29
+ this.logError = options.logError;
30
+ }
31
+ defaultConversation() {
32
+ return this.directConversation(this.operatorId);
33
+ }
34
+ directConversation(senderId) {
35
+ return {
36
+ channelId: "telegram",
37
+ accountId: this.accountId,
38
+ conversationId: senderId,
39
+ recipientId: senderId,
40
+ metadata: { chatType: "direct" },
41
+ };
42
+ }
43
+ start(handlers) {
44
+ this.transport.setHandlers({
45
+ onMessage: async (message) => {
46
+ await handlers.onInboundMessage(message);
47
+ },
48
+ onUnauthorized: handlers.onUnauthorizedSender
49
+ ? async (senderId, chatType) => {
50
+ await handlers.onUnauthorizedSender(senderId, chatType);
51
+ }
52
+ : undefined,
53
+ onFatal: handlers.onChannelFatal
54
+ ? async (error) => {
55
+ await handlers.onChannelFatal(error);
56
+ }
57
+ : undefined,
58
+ onConnected: handlers.onChannelConnected
59
+ ? async () => {
60
+ await handlers.onChannelConnected();
61
+ }
62
+ : undefined,
63
+ });
64
+ void this.transport.start();
65
+ }
66
+ async waitUntilReady() {
67
+ // The long-poll loop reports readiness through onConnected; there is no
68
+ // separate handshake to await, so readiness is immediate once started.
69
+ await Promise.resolve();
70
+ }
71
+ async sendText(params) {
72
+ const { target, text, context, log } = params;
73
+ const startedAtMs = Date.now();
74
+ log(`telegram_send_started: context=${context} recipient=${target.recipientId} chars=${Array.from(text).length}`);
75
+ try {
76
+ await this.transport.sendText(target, formatTelegramVisibleText(text), toTelegramOutputKind(context));
77
+ log(`telegram_send_completed: context=${context} recipient=${target.recipientId} elapsed_ms=${Date.now() - startedAtMs}`);
78
+ return { status: "sent" };
79
+ }
80
+ catch (error) {
81
+ const message = error instanceof Error ? error.message : String(error);
82
+ log(`telegram_send_failed: context=${context} recipient=${target.recipientId} error=${truncatePreview(message, 400)}`);
83
+ this.logError(`Failed to send Telegram ${context}: ${message}`);
84
+ return {
85
+ status: "failed",
86
+ error,
87
+ target,
88
+ };
89
+ }
90
+ }
91
+ buildInboundPrompt(text, attachments) {
92
+ return buildTelegramInboundPrompt(text, attachments);
93
+ }
94
+ formatVisibleText(text) {
95
+ return formatTelegramVisibleText(text);
96
+ }
97
+ async beginTyping(recipientId) {
98
+ await this.transport.beginTyping(recipientId);
99
+ }
100
+ async endTyping(recipientId) {
101
+ await this.transport.endTyping(recipientId);
102
+ }
103
+ async endAllTyping() {
104
+ this.transport.endAllTyping();
105
+ }
106
+ }
@@ -0,0 +1,41 @@
1
+ export function toTelegramOutputKind(context) {
2
+ const known = new Set([
3
+ "status",
4
+ "notice",
5
+ "thinking",
6
+ "final_reply",
7
+ "approval_required",
8
+ "user_input_required",
9
+ "mirrored_input",
10
+ "task_failed",
11
+ "fatal_error",
12
+ ]);
13
+ return known.has(context)
14
+ ? context
15
+ : context === "mirrored_user_input"
16
+ ? "mirrored_input"
17
+ : "notice";
18
+ }
19
+ export function buildTelegramInboundPrompt(text, attachments = []) {
20
+ const normalizedText = text.trim();
21
+ if (attachments.length === 0) {
22
+ return normalizedText;
23
+ }
24
+ const lines = [
25
+ "[Telegram inbound attachments — ACTION REQUIRED]",
26
+ "Use the local attachment paths below when the user asks you to inspect or process them.",
27
+ ];
28
+ attachments.forEach((attachment, index) => {
29
+ const metadata = [
30
+ `kind=${attachment.kind}`,
31
+ attachment.fileName ? `name=${attachment.fileName}` : "",
32
+ typeof attachment.sizeBytes === "number" ? `bytes=${attachment.sizeBytes}` : "",
33
+ ].filter(Boolean);
34
+ lines.push(`${index + 1}. ${metadata.join(" ")} path=${attachment.path}`);
35
+ });
36
+ return [normalizedText || "Received Telegram attachment(s).", lines.join("\n")].join("\n\n");
37
+ }
38
+ /** Telegram displays plain text; identity keeps one shaping point per channel. */
39
+ export function formatTelegramVisibleText(text) {
40
+ return text;
41
+ }