@mulmobridge/telegram 1.0.2 → 1.2.0

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
@@ -28,15 +28,17 @@ yarn telegram
28
28
  |---|---|---|---|
29
29
  | `TELEGRAM_BOT_TOKEN` | Yes | BotFather token | - |
30
30
  | `TELEGRAM_ALLOWED_CHAT_IDS` | Yes | Comma-separated integer chat IDs | empty (deny all) |
31
- | `MULMOCLAUDE_API_URL` | No | Server URL | `http://localhost:3001` |
31
+ | `MULMOCLAUDE_API_URL` | No | Server URL | auto (`.server-port`; waits if nothing is published) |
32
32
  | `MULMOCLAUDE_AUTH_TOKEN` | No | Bearer token override | reads from file |
33
33
  | `TELEGRAM_POLL_TIMEOUT_SEC` | No | Long-poll timeout | `25` |
34
34
 
35
35
  ### Auth token persistence across server restarts
36
36
 
37
- The MulmoClaude server regenerates a fresh bearer token on every startup and writes it to `~/mulmoclaude/.session-token`. The bridge reads that file once at launch and keeps the token in memory — so if the server restarts while the bridge is running, the bridge keeps using the **old** token and every API call returns **401**, silently.
37
+ The MulmoClaude server regenerates a fresh bearer token on every startup and writes it to `<workspace>/.session-token` (`$MULMOCLAUDE_WORKSPACE_PATH`, or `~/mulmoclaude` when unset), alongside the port it bound in `.server-port`.
38
38
 
39
- **Fix**: set `MULMOCLAUDE_AUTH_TOKEN` to the same long random value on **both** the server and the bridge. The server uses it verbatim instead of regenerating, so the token survives restarts and the bridge stays authenticated.
39
+ **The bridge follows a restart on its own.** When the connection fails it re-reads both files, and if the server came back as a different generation — new token, new port, or both — it rebuilds its socket against it (#3078). You do not have to restart the bridge.
40
+
41
+ Pinning the token is still useful when the bridge runs **on a different machine** from the server, where it cannot read the workspace at all: set `MULMOCLAUDE_AUTH_TOKEN` to the same long random value on both sides. The server then uses it verbatim instead of regenerating.
40
42
 
41
43
  ```bash
42
44
  # Server (one-time setup — same value across restarts)
package/dist/index.js CHANGED
@@ -1,9 +1,13 @@
1
1
  #!/usr/bin/env node
2
- // Telegram bridge (issue #321). Polls Telegram for incoming
2
+ // Telegram bridge (issue #321) — the CLI. Polls Telegram for incoming
3
3
  // messages and dispatches them through the shared message router,
4
4
  // which enforces the chat-ID allowlist, forwards to MulmoClaude,
5
5
  // and delivers Phase-B pushes back through sendMessage.
6
6
  //
7
+ // The bridge itself lives in `./start.js` so the server can run it
8
+ // in-process (#3080). This file is the env-reading, `process.exit`-calling
9
+ // half that only a CLI wants.
10
+ //
7
11
  // Env surface (see docs/message_apps/telegram/README.md):
8
12
  // TELEGRAM_BOT_TOKEN — BotFather token (required)
9
13
  // TELEGRAM_ALLOWED_CHAT_IDS — CSV of integer chat IDs (required
@@ -12,92 +16,42 @@
12
16
  // MULMOCLAUDE_AUTH_TOKEN — optional override
13
17
  // TELEGRAM_POLL_TIMEOUT_SEC — optional, default 25
14
18
  import "dotenv/config";
15
- import { createBridgeClient } from "@mulmobridge/client";
16
- import { createTelegramApi } from "./api.js";
17
- import { parseAllowlist } from "./allowlist.js";
18
- import { createMessageRouter } from "./router.js";
19
- const TRANSPORT_ID = "telegram";
20
- function readEnv() {
21
- const botToken = process.env.TELEGRAM_BOT_TOKEN;
22
- if (!botToken || botToken.trim().length === 0) {
23
- console.error("TELEGRAM_BOT_TOKEN is required. See docs/message_apps/telegram/.");
24
- process.exit(1);
25
- }
26
- let allowlist;
19
+ import { createBridgeClient, installProcessGuards } from "@mulmobridge/client";
20
+ import { readTelegramEnv, startTelegramBridge, TELEGRAM_TRANSPORT_ID as TRANSPORT_ID } from "./start.js";
21
+ async function main() {
22
+ // `readTelegramEnv` throws; the CLI turns that into the same message and exit
23
+ // code it printed when the reading lived here.
24
+ let config;
27
25
  try {
28
- allowlist = parseAllowlist(process.env.TELEGRAM_ALLOWED_CHAT_IDS);
26
+ config = readTelegramEnv(process.env);
29
27
  }
30
28
  catch (err) {
31
29
  console.error(err instanceof Error ? err.message : String(err));
32
30
  process.exit(1);
33
31
  }
34
- const pollTimeoutSec = Number(process.env.TELEGRAM_POLL_TIMEOUT_SEC ?? "25");
35
- if (!Number.isInteger(pollTimeoutSec) || pollTimeoutSec < 0) {
36
- console.error("TELEGRAM_POLL_TIMEOUT_SEC must be a non-negative integer");
37
- process.exit(1);
38
- }
39
- return { botToken, allowlist, pollTimeoutSec };
40
- }
41
- async function pollLoop(api, router, opts) {
42
- let offset;
43
- while (!opts.abortSignal.aborted) {
44
- let updates;
45
- try {
46
- updates = await api.getUpdates({
47
- offset,
48
- timeoutSec: opts.pollTimeoutSec,
49
- signal: opts.abortSignal,
50
- });
51
- }
52
- catch (err) {
53
- if (opts.abortSignal.aborted)
54
- return;
55
- console.error(`[telegram] getUpdates error: ${String(err)}`);
56
- // Back off briefly so a broken network doesn't busy-loop.
57
- await delay(1000);
58
- continue;
59
- }
60
- for (const update of updates) {
61
- offset = update.update_id + 1;
62
- if (update.message) {
63
- await router.handleMessage(update.message);
64
- }
65
- }
66
- }
67
- }
68
- function delay(ms) {
69
- return new Promise((r) => setTimeout(r, ms));
70
- }
71
- async function main() {
72
- const { botToken, allowlist, pollTimeoutSec } = readEnv();
32
+ const { botToken, allowlist, pollTimeoutSec } = config;
73
33
  console.log("MulmoClaude Telegram bridge");
74
34
  console.log(`Allowlist: ${allowlist.size() > 0 ? allowlist.snapshot().join(", ") : "(empty — all chats will be denied)"}`);
75
- const api = createTelegramApi({ botToken });
76
- const client = createBridgeClient({ transportId: TRANSPORT_ID });
77
- const router = createMessageRouter({
78
- api,
79
- allowlist,
80
- sendToMulmo: (chatId, text, attachments) => client.send(chatId, text, attachments),
35
+ // Guards BEFORE the bridge starts. Splitting the bridge into a library moved
36
+ // the poll loop's start from `await pollLoop(...)` to inside
37
+ // `startTelegramBridge`, which would otherwise leave a window where the loop
38
+ // is running and a signal is still unhandled. The holder is what lets the
39
+ // shutdown callback reach a handle that does not exist yet.
40
+ const started = { handle: null };
41
+ installProcessGuards({
42
+ name: TRANSPORT_ID,
43
+ onShutdown: () => {
44
+ started.handle?.close();
45
+ },
81
46
  });
82
- // Server → Telegram streaming text chunks (Phase C of #268).
83
- client.onTextChunk((chunk) => {
84
- router.handleTextChunk(chunk);
85
- });
86
- // Server → Telegram async push (Phase B of #268).
87
- client.onPush((ev) => {
88
- router.handlePush(ev).catch((err) => console.error(`[telegram] handlePush failed: ${String(err)}`));
89
- });
90
- const abortController = new AbortController();
91
- process.on("SIGINT", () => {
92
- console.log("\nShutting down...");
93
- abortController.abort();
94
- client.close();
95
- process.exit(0);
96
- });
97
- await pollLoop(api, router, {
47
+ const handle = startTelegramBridge({
48
+ botToken,
49
+ allowlist,
98
50
  pollTimeoutSec,
99
- abortSignal: abortController.signal,
51
+ client: createBridgeClient({ transportId: TRANSPORT_ID }),
100
52
  });
53
+ started.handle = handle;
54
+ await handle.done;
101
55
  }
102
56
  main().catch((err) => {
103
57
  console.error("Fatal:", err);
@@ -0,0 +1,31 @@
1
+ import type { BridgeClient } from "@mulmobridge/client";
2
+ import { type Allowlist } from "./allowlist.js";
3
+ export declare const TELEGRAM_TRANSPORT_ID = "telegram";
4
+ /** Console-compatible shape, matching `RouterDeps["log"]`. */
5
+ export interface BridgeLog {
6
+ info: (msg: string) => void;
7
+ warn: (msg: string) => void;
8
+ error: (msg: string) => void;
9
+ }
10
+ export interface TelegramStartOptions {
11
+ botToken: string;
12
+ allowlist: Allowlist;
13
+ /** Long-poll timeout handed to `getUpdates`. */
14
+ pollTimeoutSec: number;
15
+ /** Supplied by the caller so the CLI can pass a socket client and the server
16
+ * an in-process one. */
17
+ client: BridgeClient;
18
+ log?: BridgeLog;
19
+ }
20
+ export interface BridgeHandle {
21
+ /** Stops polling and releases the client. Idempotent. */
22
+ close: () => void;
23
+ /** Resolves when the poll loop has stopped. The CLI awaits it; a host that
24
+ * starts many bridges attaches a rejection handler instead. */
25
+ done: Promise<void>;
26
+ }
27
+ /** The env this bridge needs, read from an explicit bag rather than
28
+ * `process.env` and THROWING rather than exiting, so the server can start it
29
+ * in-process (#3080) while the CLI keeps its own exit codes. */
30
+ export declare function readTelegramEnv(env: Record<string, string | undefined>): Omit<TelegramStartOptions, "client" | "log">;
31
+ export declare function startTelegramBridge(opts: TelegramStartOptions): BridgeHandle;
package/dist/start.js ADDED
@@ -0,0 +1,82 @@
1
+ // The Telegram bridge as a LIBRARY (#3080): everything the CLI used to do
2
+ // inline, minus the two things a host cannot tolerate — reading `process.env`
3
+ // and calling `process.exit`.
4
+ //
5
+ // Configuration arrives as arguments and failures arrive as thrown errors, so
6
+ // the same code serves `yarn telegram` (which reads env and exits) and the
7
+ // server's in-process registry (which logs and keeps running).
8
+ import { createTelegramApi } from "./api.js";
9
+ import { parseAllowlist } from "./allowlist.js";
10
+ import { createMessageRouter } from "./router.js";
11
+ export const TELEGRAM_TRANSPORT_ID = "telegram";
12
+ const DEFAULT_POLL_TIMEOUT_SEC = 25;
13
+ /** The env this bridge needs, read from an explicit bag rather than
14
+ * `process.env` and THROWING rather than exiting, so the server can start it
15
+ * in-process (#3080) while the CLI keeps its own exit codes. */
16
+ export function readTelegramEnv(env) {
17
+ const botToken = env.TELEGRAM_BOT_TOKEN;
18
+ if (!botToken || botToken.trim().length === 0) {
19
+ throw new Error("TELEGRAM_BOT_TOKEN is required. See docs/message_apps/telegram/.");
20
+ }
21
+ const allowlist = parseAllowlist(env.TELEGRAM_ALLOWED_CHAT_IDS);
22
+ const pollTimeoutSec = Number(env.TELEGRAM_POLL_TIMEOUT_SEC ?? String(DEFAULT_POLL_TIMEOUT_SEC));
23
+ if (!Number.isInteger(pollTimeoutSec) || pollTimeoutSec < 0) {
24
+ throw new Error("TELEGRAM_POLL_TIMEOUT_SEC must be a non-negative integer");
25
+ }
26
+ return { botToken, allowlist, pollTimeoutSec };
27
+ }
28
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
29
+ const POLL_ERROR_BACKOFF_MS = 1000;
30
+ async function pollLoop(api, router, opts) {
31
+ let offset;
32
+ while (!opts.abortSignal.aborted) {
33
+ let updates;
34
+ try {
35
+ updates = await api.getUpdates({ offset, timeoutSec: opts.pollTimeoutSec, signal: opts.abortSignal });
36
+ }
37
+ catch (err) {
38
+ if (opts.abortSignal.aborted)
39
+ return;
40
+ opts.log.error(`[telegram] getUpdates error: ${String(err)}`);
41
+ // Back off briefly so a broken network doesn't busy-loop.
42
+ await delay(POLL_ERROR_BACKOFF_MS);
43
+ continue;
44
+ }
45
+ for (const update of updates) {
46
+ offset = update.update_id + 1;
47
+ if (update.message) {
48
+ await router.handleMessage(update.message);
49
+ }
50
+ }
51
+ }
52
+ }
53
+ export function startTelegramBridge(opts) {
54
+ const log = opts.log ?? console;
55
+ const api = createTelegramApi({ botToken: opts.botToken });
56
+ const router = createMessageRouter({
57
+ api,
58
+ allowlist: opts.allowlist,
59
+ sendToMulmo: (chatId, text, attachments) => opts.client.send(chatId, text, attachments),
60
+ });
61
+ // Server → Telegram streaming text chunks (Phase C of #268).
62
+ opts.client.onTextChunk((chunk) => {
63
+ router.handleTextChunk(chunk);
64
+ });
65
+ // Server → Telegram async push (Phase B of #268).
66
+ opts.client.onPush((event) => {
67
+ router.handlePush(event).catch((err) => log.error(`[telegram] handlePush failed: ${String(err)}`));
68
+ });
69
+ const abortController = new AbortController();
70
+ let closed = false;
71
+ const done = pollLoop(api, router, { pollTimeoutSec: opts.pollTimeoutSec, abortSignal: abortController.signal, log });
72
+ return {
73
+ close() {
74
+ if (closed)
75
+ return;
76
+ closed = true;
77
+ abortController.abort();
78
+ opts.client.close();
79
+ },
80
+ done,
81
+ };
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmobridge/telegram",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "description": "Telegram bridge for MulmoBridge — connect a Telegram bot to MulmoClaude",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -11,6 +11,12 @@
11
11
  "import": "./dist/index.js",
12
12
  "require": "./dist/index.js",
13
13
  "default": "./dist/index.js"
14
+ },
15
+ "./start": {
16
+ "types": "./dist/start.d.ts",
17
+ "import": "./dist/start.js",
18
+ "require": "./dist/start.js",
19
+ "default": "./dist/start.js"
14
20
  }
15
21
  },
16
22
  "bin": {
@@ -30,12 +36,12 @@
30
36
  "license": "MIT",
31
37
  "author": "Receptron Team",
32
38
  "dependencies": {
33
- "@mulmobridge/client": "^1.0.2",
39
+ "@mulmobridge/client": "^1.3.0",
34
40
  "@mulmobridge/protocol": "^1.0.1",
35
41
  "dotenv": "^17.0.0"
36
42
  },
37
43
  "devDependencies": {
38
- "@types/node": "^26.1.2",
44
+ "@types/node": "^26.5.1",
39
45
  "typescript": "^6.0.3"
40
46
  },
41
47
  "homepage": "https://github.com/receptron/mulmoclaude/tree/main/packages/bridges/telegram#readme",