@mulmobridge/telegram 0.1.1 → 0.1.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.
package/README.md CHANGED
@@ -46,13 +46,43 @@ The bridge enforces a **chat-ID allowlist**. Only messages from listed chat IDs
46
46
 
47
47
  ## Ecosystem
48
48
 
49
- Part of the `@mulmobridge/*` package family:
50
-
51
- - **@mulmobridge/protocol** — shared types and constants
52
- - **@mulmobridge/client** — socket.io client library
53
- - **@mulmobridge/cli** — interactive terminal bridge
54
- - **@mulmobridge/telegram** — this package
55
- - **@mulmobridge/chat-service** — server-side chat service
49
+ Part of the [`@mulmobridge/*`](https://www.npmjs.com/~mulmobridge) package family.
50
+
51
+ **Shared libraries:**
52
+
53
+ - [`@mulmobridge/client`](https://www.npmjs.com/package/@mulmobridge/client) — socket.io client library used by every bridge below
54
+ - [`@mulmobridge/protocol`](https://www.npmjs.com/package/@mulmobridge/protocol) — wire types and constants
55
+ - [`@mulmobridge/chat-service`](https://www.npmjs.com/package/@mulmobridge/chat-service) — server-side relay + session store
56
+ - [`@mulmobridge/relay`](https://www.npmjs.com/package/@mulmobridge/relay) — Cloudflare Workers webhook proxy
57
+ - [`@mulmobridge/mock-server`](https://www.npmjs.com/package/@mulmobridge/mock-server) — mock server for local bridge development
58
+
59
+ **Bridges** (one npm package per platform):
60
+
61
+ - [`@mulmobridge/bluesky`](https://www.npmjs.com/package/@mulmobridge/bluesky) — Bluesky DMs over atproto
62
+ - [`@mulmobridge/chatwork`](https://www.npmjs.com/package/@mulmobridge/chatwork) — Chatwork (Japanese business chat)
63
+ - [`@mulmobridge/cli`](https://www.npmjs.com/package/@mulmobridge/cli) — interactive terminal bridge
64
+ - [`@mulmobridge/discord`](https://www.npmjs.com/package/@mulmobridge/discord) — Discord bot via Gateway
65
+ - [`@mulmobridge/email`](https://www.npmjs.com/package/@mulmobridge/email) — IMAP poll + SMTP reply, threading preserved
66
+ - [`@mulmobridge/google-chat`](https://www.npmjs.com/package/@mulmobridge/google-chat) — Google Chat via MulmoBridge relay
67
+ - [`@mulmobridge/irc`](https://www.npmjs.com/package/@mulmobridge/irc) — IRC (Libera, Freenode, custom)
68
+ - [`@mulmobridge/line`](https://www.npmjs.com/package/@mulmobridge/line) — LINE Messaging API via MulmoBridge relay
69
+ - [`@mulmobridge/line-works`](https://www.npmjs.com/package/@mulmobridge/line-works) — LINE Works (enterprise LINE)
70
+ - [`@mulmobridge/mastodon`](https://www.npmjs.com/package/@mulmobridge/mastodon) — Mastodon DMs + mentions
71
+ - [`@mulmobridge/matrix`](https://www.npmjs.com/package/@mulmobridge/matrix) — Matrix / Element
72
+ - [`@mulmobridge/mattermost`](https://www.npmjs.com/package/@mulmobridge/mattermost) — Mattermost
73
+ - [`@mulmobridge/messenger`](https://www.npmjs.com/package/@mulmobridge/messenger) — Facebook Messenger via MulmoBridge relay
74
+ - [`@mulmobridge/nostr`](https://www.npmjs.com/package/@mulmobridge/nostr) — Nostr NIP-04 encrypted DMs
75
+ - [`@mulmobridge/rocketchat`](https://www.npmjs.com/package/@mulmobridge/rocketchat) — Rocket.Chat
76
+ - [`@mulmobridge/signal`](https://www.npmjs.com/package/@mulmobridge/signal) — Signal via signal-cli-rest-api
77
+ - [`@mulmobridge/slack`](https://www.npmjs.com/package/@mulmobridge/slack) — Slack Socket Mode
78
+ - [`@mulmobridge/teams`](https://www.npmjs.com/package/@mulmobridge/teams) — Microsoft Teams via Bot Framework
79
+ - [`@mulmobridge/telegram`](https://www.npmjs.com/package/@mulmobridge/telegram) — Telegram bot ← **this package**
80
+ - [`@mulmobridge/twilio-sms`](https://www.npmjs.com/package/@mulmobridge/twilio-sms) — SMS via Twilio Programmable Messaging
81
+ - [`@mulmobridge/viber`](https://www.npmjs.com/package/@mulmobridge/viber) — Viber Public Account bots
82
+ - [`@mulmobridge/webhook`](https://www.npmjs.com/package/@mulmobridge/webhook) — generic HTTP webhook bridge
83
+ - [`@mulmobridge/whatsapp`](https://www.npmjs.com/package/@mulmobridge/whatsapp) — WhatsApp Cloud API via MulmoBridge relay
84
+ - [`@mulmobridge/xmpp`](https://www.npmjs.com/package/@mulmobridge/xmpp) — XMPP / Jabber
85
+ - [`@mulmobridge/zulip`](https://www.npmjs.com/package/@mulmobridge/zulip) — Zulip
56
86
 
57
87
  ## License
58
88
 
package/dist/api.d.ts CHANGED
@@ -9,12 +9,20 @@ export interface TelegramPhotoSize {
9
9
  height: number;
10
10
  file_size?: number;
11
11
  }
12
+ export interface TelegramDocument {
13
+ file_id: string;
14
+ file_unique_id: string;
15
+ file_name?: string;
16
+ mime_type?: string;
17
+ file_size?: number;
18
+ }
12
19
  export interface TelegramMessage {
13
20
  message_id: number;
14
21
  chat: TelegramChat;
15
22
  from?: TelegramUser;
16
23
  text?: string;
17
24
  photo?: TelegramPhotoSize[];
25
+ document?: TelegramDocument;
18
26
  caption?: string;
19
27
  date: number;
20
28
  }
@@ -44,7 +52,13 @@ export interface GetUpdatesOptions {
44
52
  }
45
53
  export interface TelegramApi {
46
54
  getUpdates(opts?: GetUpdatesOptions): Promise<TelegramUpdate[]>;
47
- sendMessage(chatId: number, text: string): Promise<void>;
48
- downloadPhoto(fileId: string): Promise<string>;
55
+ /** Send a message and return its message_id (needed for editMessageText). */
56
+ sendMessage(chatId: number, text: string): Promise<number>;
57
+ /** Edit an existing message in place. Used for streaming text updates. */
58
+ editMessageText(chatId: number, messageId: number, text: string): Promise<void>;
59
+ /** Download any file by file_id via the Telegram getFile API.
60
+ * Returns a data URL (`data:<mime>;base64,...`).
61
+ * Works for photos, documents, audio, etc. */
62
+ downloadFile(fileId: string, fallbackMime?: string): Promise<string>;
49
63
  }
50
64
  export declare function createTelegramApi(opts: TelegramApiOptions): TelegramApi;
package/dist/api.js CHANGED
@@ -36,8 +36,25 @@ export function createTelegramApi(opts) {
36
36
  if (!body.ok) {
37
37
  throw new Error(`sendMessage API error: ${body.description ?? "unknown"}`);
38
38
  }
39
+ return body.result?.message_id ?? 0;
39
40
  },
40
- async downloadPhoto(fileId) {
41
+ async editMessageText(chatId, messageId, text) {
42
+ const res = await fetchImpl(`${base}/editMessageText`, {
43
+ method: "POST",
44
+ headers: { "Content-Type": "application/json" },
45
+ body: JSON.stringify({
46
+ chat_id: chatId,
47
+ message_id: messageId,
48
+ text,
49
+ }),
50
+ });
51
+ if (!res.ok) {
52
+ // editMessageText fails if the text is unchanged (Telegram
53
+ // returns 400 "message is not modified"). Swallow silently.
54
+ return;
55
+ }
56
+ },
57
+ async downloadFile(fileId, fallbackMime = "application/octet-stream") {
41
58
  const getFileRes = await fetchImpl(`${base}/getFile?file_id=${encodeURIComponent(fileId)}`);
42
59
  if (!getFileRes.ok) {
43
60
  throw new Error(`getFile failed: ${getFileRes.status} ${await safeText(getFileRes)}`);
@@ -53,8 +70,11 @@ export function createTelegramApi(opts) {
53
70
  }
54
71
  const buffer = await fileRes.arrayBuffer();
55
72
  const b64 = Buffer.from(buffer).toString("base64");
73
+ // Prefer the caller-supplied MIME (Telegram's doc.mime_type is
74
+ // authoritative). Fall back to extension-based inference only
75
+ // when the caller passed the generic default.
56
76
  const ext = getFileBody.result.file_path.split(".").pop() ?? "";
57
- const mediaType = mimeFromExtension(ext, "image/jpeg");
77
+ const mediaType = fallbackMime !== "application/octet-stream" ? fallbackMime : mimeFromExtension(ext, fallbackMime);
58
78
  return buildDataUrl(mediaType, b64);
59
79
  },
60
80
  };
package/dist/index.js CHANGED
@@ -71,9 +71,7 @@ function delay(ms) {
71
71
  async function main() {
72
72
  const { botToken, allowlist, pollTimeoutSec } = readEnv();
73
73
  console.log("MulmoClaude Telegram bridge");
74
- console.log(`Allowlist: ${allowlist.size() > 0
75
- ? allowlist.snapshot().join(", ")
76
- : "(empty — all chats will be denied)"}`);
74
+ console.log(`Allowlist: ${allowlist.size() > 0 ? allowlist.snapshot().join(", ") : "(empty — all chats will be denied)"}`);
77
75
  const api = createTelegramApi({ botToken });
78
76
  const client = createBridgeClient({ transportId: TRANSPORT_ID });
79
77
  const router = createMessageRouter({
@@ -81,11 +79,13 @@ async function main() {
81
79
  allowlist,
82
80
  sendToMulmo: (chatId, text, attachments) => client.send(chatId, text, attachments),
83
81
  });
82
+ // Server → Telegram streaming text chunks (Phase C of #268).
83
+ client.onTextChunk((chunk) => {
84
+ router.handleTextChunk(chunk);
85
+ });
84
86
  // Server → Telegram async push (Phase B of #268).
85
87
  client.onPush((ev) => {
86
- router
87
- .handlePush(ev)
88
- .catch((err) => console.error(`[telegram] handlePush failed: ${String(err)}`));
88
+ router.handlePush(ev).catch((err) => console.error(`[telegram] handlePush failed: ${String(err)}`));
89
89
  });
90
90
  const abortController = new AbortController();
91
91
  process.on("SIGINT", () => {
package/dist/router.d.ts CHANGED
@@ -18,6 +18,8 @@ export interface RouterDeps {
18
18
  export interface MessageRouter {
19
19
  handleMessage(msg: TelegramMessage): Promise<void>;
20
20
  handlePush(ev: PushEvent): Promise<void>;
21
+ /** Called for each streaming text chunk from the server. */
22
+ handleTextChunk(chunk: string): void;
21
23
  /** For tests / debugging: chat IDs we've already sent the
22
24
  * access-denied notice to. */
23
25
  deniedAlreadyNotified(): ReadonlySet<number>;
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 } 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.
@@ -13,30 +13,107 @@ const defaultLog = {
13
13
  warn: (m) => console.warn(m),
14
14
  error: (m) => console.error(m),
15
15
  };
16
+ // Minimum interval between editMessageText calls to avoid Telegram
17
+ // rate limits. 1 second is conservative; Telegram allows ~30 edits
18
+ // per minute per chat.
19
+ const STREAM_EDIT_INTERVAL_MS = 1000;
16
20
  export function createMessageRouter(deps) {
17
21
  const { api, allowlist, sendToMulmo } = deps;
18
22
  const log = deps.log ?? defaultLog;
19
23
  // One denial reply per chat per bridge lifetime — restart clears.
20
24
  const deniedAlreadyNotified = new Set();
21
- async function tryDownloadPhoto(msg) {
22
- const hasPhoto = Array.isArray(msg.photo) && msg.photo.length > 0;
23
- if (!hasPhoto)
24
- return { attachments: [], failed: false };
25
- const largest = msg.photo[msg.photo.length - 1];
26
- try {
27
- const dataUrl = await api.downloadPhoto(largest.file_id);
28
- const parsed = parseDataUrl(dataUrl);
29
- if (parsed) {
30
- return {
31
- attachments: [{ mimeType: parsed.mimeType, data: parsed.data }],
32
- failed: false,
33
- };
25
+ // Streaming state: while a relay is in flight, chunks accumulate
26
+ // here. A timer periodically flushes them via editMessageText.
27
+ let streamChatId = null;
28
+ let streamMessageId = null;
29
+ let streamAccumulated = "";
30
+ let streamDirty = false;
31
+ let streamTimer = null;
32
+ function startStream(chatId) {
33
+ streamChatId = chatId;
34
+ streamMessageId = null;
35
+ streamAccumulated = "";
36
+ streamDirty = false;
37
+ streamTimer = setInterval(flushStream, STREAM_EDIT_INTERVAL_MS);
38
+ }
39
+ function stopStream() {
40
+ if (streamTimer !== null) {
41
+ clearInterval(streamTimer);
42
+ streamTimer = null;
43
+ }
44
+ streamChatId = null;
45
+ streamMessageId = null;
46
+ streamAccumulated = "";
47
+ streamDirty = false;
48
+ }
49
+ function flushStream() {
50
+ if (!streamDirty || streamChatId === null)
51
+ return;
52
+ streamDirty = false;
53
+ const chatId = streamChatId;
54
+ const text = streamAccumulated || "…";
55
+ if (streamMessageId === null) {
56
+ // First flush: send a new message
57
+ api
58
+ .sendMessage(chatId, text)
59
+ .then((msgId) => {
60
+ streamMessageId = msgId;
61
+ })
62
+ .catch((err) => {
63
+ log.error(`[telegram] stream sendMessage failed: ${String(err)}`);
64
+ });
65
+ }
66
+ else {
67
+ // Subsequent flushes: edit existing message
68
+ api.editMessageText(chatId, streamMessageId, text).catch((err) => {
69
+ log.error(`[telegram] stream editMessage failed: ${String(err)}`);
70
+ });
71
+ }
72
+ }
73
+ // Download all media from a message. Telegram messages are usually
74
+ // exclusive (photo OR document), but forwarded messages or future
75
+ // API changes could carry both. We collect all available attachments
76
+ // rather than picking one.
77
+ async function tryDownloadAttachments(msg) {
78
+ const attachments = [];
79
+ let anyFailed = false;
80
+ if (Array.isArray(msg.photo) && msg.photo.length > 0) {
81
+ const largest = msg.photo[msg.photo.length - 1];
82
+ try {
83
+ const dataUrl = await api.downloadFile(largest.file_id, "image/jpeg");
84
+ const parsed = parseDataUrl(dataUrl);
85
+ if (parsed) {
86
+ attachments.push({ mimeType: parsed.mimeType, data: parsed.data });
87
+ }
88
+ }
89
+ catch (err) {
90
+ log.error(`[telegram] photo download failed: ${String(err)}`);
91
+ anyFailed = true;
34
92
  }
35
93
  }
36
- catch (err) {
37
- log.error(`[telegram] photo download failed: ${String(err)}`);
94
+ if (msg.document) {
95
+ const doc = msg.document;
96
+ const fallbackMime = doc.mime_type ?? "application/octet-stream";
97
+ try {
98
+ const dataUrl = await api.downloadFile(doc.file_id, fallbackMime);
99
+ const parsed = parseDataUrl(dataUrl);
100
+ if (parsed) {
101
+ attachments.push({
102
+ mimeType: parsed.mimeType,
103
+ data: parsed.data,
104
+ filename: doc.file_name,
105
+ });
106
+ }
107
+ }
108
+ catch (err) {
109
+ log.error(`[telegram] document download failed: ${String(err)}`);
110
+ anyFailed = true;
111
+ }
112
+ }
113
+ if (attachments.length === 0 && anyFailed) {
114
+ return { attachments: [], failed: true };
38
115
  }
39
- return { attachments: [], failed: true };
116
+ return { attachments, failed: false };
40
117
  }
41
118
  async function sendReply(chatId, ack) {
42
119
  if (ack.ok) {
@@ -50,23 +127,37 @@ export function createMessageRouter(deps) {
50
127
  async function handleAllowed(msg) {
51
128
  const chatId = msg.chat.id;
52
129
  const text = msg.text ?? msg.caption ?? "";
53
- const hasPhoto = Array.isArray(msg.photo) && msg.photo.length > 0;
54
- if (text.trim().length === 0 && !hasPhoto)
130
+ const hasMedia = (Array.isArray(msg.photo) && msg.photo.length > 0) || msg.document !== undefined;
131
+ if (text.trim().length === 0 && !hasMedia)
55
132
  return;
56
- log.info(`[telegram] accepted chat=${chatId} user=@${userLabel(msg)} len=${text.length}${hasPhoto ? " +photo" : ""}`);
57
- const { attachments, failed } = await tryDownloadPhoto(msg);
58
- // Photo-only message where download failed: bail out instead
59
- // of sending "What is this image?" without the actual image.
133
+ const mediaLabel = buildMediaLabel(msg);
134
+ log.info(`[telegram] accepted chat=${chatId} user=@${userLabel(msg)} len=${text.length}${mediaLabel}`);
135
+ const { attachments, failed } = await tryDownloadAttachments(msg);
136
+ // Attachment-only message where download failed: bail out instead
137
+ // of sending a query without the actual file.
60
138
  if (failed && text.trim().length === 0) {
61
- await api
62
- .sendMessage(chatId, "Sorry, I could not download the photo. Please try again.")
63
- .catch(() => { });
139
+ await api.sendMessage(chatId, "Sorry, I could not download the attachment. Please try again.").catch(() => { });
64
140
  return;
65
141
  }
66
- // Surface the photo-drop so the user knows the image was lost.
142
+ // Surface the attachment failure so the user knows the file was lost.
67
143
  const messageText = resolveMessageText(text, failed);
144
+ // Start streaming: chunks will arrive via handleTextChunk and
145
+ // be sent/edited in the Telegram chat in real time.
146
+ startStream(chatId);
68
147
  const ack = await sendToMulmo(String(chatId), messageText, attachments.length > 0 ? attachments : undefined);
69
- await sendReply(chatId, ack);
148
+ // Final flush + capture the message id before stopStream clears it.
149
+ flushStream();
150
+ const sentMsgId = streamMessageId;
151
+ 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.
155
+ if (sentMsgId !== null && ack.ok) {
156
+ await api.editMessageText(chatId, sentMsgId, ack.reply ?? "").catch(() => { });
157
+ }
158
+ else if (sentMsgId === null) {
159
+ await sendReply(chatId, ack);
160
+ }
70
161
  }
71
162
  async function handleDenied(msg) {
72
163
  const chatId = msg.chat.id;
@@ -107,6 +198,12 @@ export function createMessageRouter(deps) {
107
198
  log.error(`[telegram] push sendMessage failed: ${String(err)}`);
108
199
  }
109
200
  },
201
+ handleTextChunk(chunk) {
202
+ if (streamChatId === null)
203
+ return;
204
+ streamAccumulated += chunk;
205
+ streamDirty = true;
206
+ },
110
207
  deniedAlreadyNotified() {
111
208
  return deniedAlreadyNotified;
112
209
  },
@@ -121,14 +218,23 @@ async function sendChunked(api, chatId, text) {
121
218
  await api.sendMessage(chatId, text.slice(i, i + TELEGRAM_MAX_MESSAGE_CHARS));
122
219
  }
123
220
  }
124
- function resolveMessageText(text, photoFailed) {
221
+ function resolveMessageText(text, attachmentFailed) {
125
222
  const hasText = text.trim().length > 0;
126
- if (photoFailed && hasText) {
127
- return `${text}\n\n(note: attached photo could not be downloaded)`;
223
+ if (attachmentFailed && hasText) {
224
+ return `${text}\n\n(note: attached file could not be downloaded)`;
128
225
  }
129
226
  if (hasText)
130
227
  return text;
131
- return "What is this image?";
228
+ return "Describe / analyze this file.";
229
+ }
230
+ function buildMediaLabel(msg) {
231
+ if (msg.document) {
232
+ const hint = msg.document.file_name ?? msg.document.mime_type ?? "unknown";
233
+ return ` +file(${hint})`;
234
+ }
235
+ if (Array.isArray(msg.photo) && msg.photo.length > 0)
236
+ return " +photo";
237
+ return "";
132
238
  }
133
239
  function userLabel(msg) {
134
240
  return msg.from?.username ?? msg.from?.first_name ?? "unknown";
package/package.json CHANGED
@@ -1,10 +1,18 @@
1
1
  {
2
2
  "name": "@mulmobridge/telegram",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Telegram bridge for MulmoBridge — connect a Telegram bot to MulmoClaude",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "require": "./dist/index.js",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
8
16
  "bin": {
9
17
  "mulmobridge-telegram": "./dist/index.js"
10
18
  },
@@ -22,12 +30,12 @@
22
30
  "license": "MIT",
23
31
  "author": "Receptron Team",
24
32
  "dependencies": {
25
- "@mulmobridge/client": "^0.1.0",
26
- "@mulmobridge/protocol": "^0.1.0",
33
+ "@mulmobridge/client": "^0.1.4",
34
+ "@mulmobridge/protocol": "^0.1.4",
27
35
  "dotenv": "^17.0.0"
28
36
  },
29
37
  "devDependencies": {
30
- "@types/node": "^22.0.0",
31
- "typescript": "^5.8.0"
38
+ "@types/node": "^25.6.0",
39
+ "typescript": "^6.0.3"
32
40
  }
33
41
  }