@mulmobridge/telegram 1.2.0 → 1.3.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
@@ -61,6 +61,7 @@ The bridge enforces a **chat-ID allowlist**. Only messages from listed chat IDs
61
61
  - Long-polling (no webhook setup needed)
62
62
  - Photo attachment support (downloads and forwards to Claude vision)
63
63
  - Long-message chunking (splits replies > 4096 chars)
64
+ - "typing…" while MulmoClaude works on a reply, from receipt until the final reply is sent (including before the first streamed text and while tools run)
64
65
  - Server push delivery (Phase B of #268)
65
66
  - Graceful shutdown on SIGINT
66
67
 
package/dist/api.d.ts CHANGED
@@ -50,15 +50,20 @@ export interface GetUpdatesOptions {
50
50
  timeoutSec?: number | undefined;
51
51
  signal?: AbortSignal | undefined;
52
52
  }
53
+ export type TelegramChatAction = "typing";
53
54
  export interface TelegramApi {
54
55
  getUpdates(opts?: GetUpdatesOptions): Promise<TelegramUpdate[]>;
55
56
  /** Send a message and return its message_id (needed for editMessageText). */
56
57
  sendMessage(chatId: number, text: string): Promise<number>;
57
58
  /** Edit an existing message in place. Used for streaming text updates. */
58
59
  editMessageText(chatId: number, messageId: number, text: string): Promise<void>;
60
+ /** Show a chat action such as "typing…" until the next message or about 5 s. */
61
+ sendChatAction(chatId: number, action: TelegramChatAction): Promise<void>;
59
62
  /** Download any file by file_id via the Telegram getFile API.
60
63
  * Returns a data URL (`data:<mime>;base64,...`).
61
64
  * Works for photos, documents, audio, etc. */
62
65
  downloadFile(fileId: string, fallbackMime?: string): Promise<string>;
63
66
  }
64
67
  export declare function createTelegramApi(opts: TelegramApiOptions): TelegramApi;
68
+ /** The description of a `{ ok: false }` Bot API body, or null when the body does not report a failure. */
69
+ export declare function apiFailureDescription(body: unknown): string | null;
package/dist/api.js CHANGED
@@ -54,6 +54,20 @@ export function createTelegramApi(opts) {
54
54
  return;
55
55
  }
56
56
  },
57
+ async sendChatAction(chatId, action) {
58
+ const res = await fetchImpl(`${base}/sendChatAction`, {
59
+ method: "POST",
60
+ headers: { "Content-Type": "application/json" },
61
+ body: JSON.stringify({ chat_id: chatId, action }),
62
+ });
63
+ if (!res.ok) {
64
+ throw new Error(`sendChatAction failed: ${res.status} ${await safeText(res)}`);
65
+ }
66
+ const failure = apiFailureDescription(await res.json().catch(() => null));
67
+ if (failure !== null) {
68
+ throw new Error(`sendChatAction API error: ${failure}`);
69
+ }
70
+ },
57
71
  async downloadFile(fileId, fallbackMime = "application/octet-stream") {
58
72
  const getFileRes = await fetchImpl(`${base}/getFile?file_id=${encodeURIComponent(fileId)}`);
59
73
  if (!getFileRes.ok) {
@@ -79,6 +93,12 @@ export function createTelegramApi(opts) {
79
93
  },
80
94
  };
81
95
  }
96
+ /** The description of a `{ ok: false }` Bot API body, or null when the body does not report a failure. */
97
+ export function apiFailureDescription(body) {
98
+ if (typeof body !== "object" || body === null || !("ok" in body) || body.ok !== false)
99
+ return null;
100
+ return "description" in body && typeof body.description === "string" ? body.description : "unknown";
101
+ }
82
102
  async function safeText(res) {
83
103
  try {
84
104
  return await res.text();
package/dist/router.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // Telegram API, no real MulmoClaude socket, no env reads). The
4
4
  // entrypoint in index.ts is then just: read env → wire real deps →
5
5
  // drive the polling loop.
6
- import { parseDataUrl } from "@mulmobridge/client";
6
+ import { parseDataUrl, replyAfterProcessingIndicator } from "@mulmobridge/client";
7
7
  // Telegram caps a single message at 4096 chars. We split long
8
8
  // replies naively; pretty formatting (preserve markdown, break on
9
9
  // sentence boundaries) is a follow-up.
@@ -17,6 +17,8 @@ const defaultLog = {
17
17
  // rate limits. 1 second is conservative; Telegram allows ~30 edits
18
18
  // per minute per chat.
19
19
  const STREAM_EDIT_INTERVAL_MS = 1000;
20
+ // Telegram shows a chat action for about 5 s, or until the next message.
21
+ const TYPING_REFRESH_MS = 4_000;
20
22
  export function createMessageRouter(deps) {
21
23
  const { api, allowlist, sendToMulmo } = deps;
22
24
  const log = deps.log ?? defaultLog;
@@ -132,12 +134,18 @@ export function createMessageRouter(deps) {
132
134
  return;
133
135
  const mediaLabel = buildMediaLabel(msg);
134
136
  log.info(`[telegram] accepted chat=${chatId} user=@${userLabel(msg)} len=${text.length}${mediaLabel}`);
137
+ await replyAfterProcessingIndicator(() => api.sendChatAction(chatId, "typing"), { produce: () => relay(msg, chatId, text), post: (deliver) => deliver() }, { refreshMs: TYPING_REFRESH_MS, label: "telegram", log });
138
+ }
139
+ /** Everything up to the final reply, run while "typing…" shows; returns the
140
+ * step that delivers the reply, which runs once the indicator is down. */
141
+ async function relay(msg, chatId, text) {
135
142
  const { attachments, failed } = await tryDownloadAttachments(msg);
136
143
  // Attachment-only message where download failed: bail out instead
137
144
  // of sending a query without the actual file.
138
145
  if (failed && text.trim().length === 0) {
139
- await api.sendMessage(chatId, "Sorry, I could not download the attachment. Please try again.").catch(() => { });
140
- return;
146
+ return async () => {
147
+ await api.sendMessage(chatId, "Sorry, I could not download the attachment. Please try again.").catch(() => { });
148
+ };
141
149
  }
142
150
  // Surface the attachment failure so the user knows the file was lost.
143
151
  const messageText = resolveMessageText(text, failed);
@@ -149,9 +157,12 @@ export function createMessageRouter(deps) {
149
157
  flushStream();
150
158
  const sentMsgId = streamMessageId;
151
159
  stopStream();
152
- // If streaming sent the text, do a final edit with the full ack
153
- // to catch any trailing chunks. If no streaming happened,
154
- // send the full reply as a new message.
160
+ return () => deliverFinal(chatId, sentMsgId, ack);
161
+ }
162
+ // If streaming sent the text, do a final edit with the full ack
163
+ // to catch any trailing chunks. If no streaming happened,
164
+ // send the full reply as a new message.
165
+ async function deliverFinal(chatId, sentMsgId, ack) {
155
166
  if (sentMsgId !== null && ack.ok) {
156
167
  await api.editMessageText(chatId, sentMsgId, ack.reply ?? "").catch(() => { });
157
168
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmobridge/telegram",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Telegram bridge for MulmoBridge — connect a Telegram bot to MulmoClaude",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -29,19 +29,19 @@
29
29
  "scripts": {
30
30
  "build": "tsc",
31
31
  "prepack": "yarn build",
32
- "typecheck": "tsc --noEmit",
32
+ "typecheck": "tsc -p tsconfig.typecheck.json",
33
33
  "test": "tsx --test test/test_*.ts",
34
34
  "lint": "eslint src test"
35
35
  },
36
36
  "license": "MIT",
37
37
  "author": "Receptron Team",
38
38
  "dependencies": {
39
- "@mulmobridge/client": "^1.3.0",
40
- "@mulmobridge/protocol": "^1.0.1",
41
- "dotenv": "^17.0.0"
39
+ "@mulmobridge/client": "^1.5.0",
40
+ "@mulmobridge/protocol": "^1.1.0",
41
+ "dotenv": "^18.0.8"
42
42
  },
43
43
  "devDependencies": {
44
- "@types/node": "^26.5.1",
44
+ "@types/node": "^26.6.5",
45
45
  "typescript": "^6.0.3"
46
46
  },
47
47
  "homepage": "https://github.com/receptron/mulmoclaude/tree/main/packages/bridges/telegram#readme",