@mulmobridge/telegram 0.1.1 → 0.1.2

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/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,13 @@ 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"
78
+ ? fallbackMime
79
+ : mimeFromExtension(ext, fallbackMime);
58
80
  return buildDataUrl(mediaType, b64);
59
81
  },
60
82
  };
package/dist/index.js CHANGED
@@ -81,6 +81,10 @@ async function main() {
81
81
  allowlist,
82
82
  sendToMulmo: (chatId, text, attachments) => client.send(chatId, text, attachments),
83
83
  });
84
+ // Server → Telegram streaming text chunks (Phase C of #268).
85
+ client.onTextChunk((chunk) => {
86
+ router.handleTextChunk(chunk);
87
+ });
84
88
  // Server → Telegram async push (Phase B of #268).
85
89
  client.onPush((ev) => {
86
90
  router
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
@@ -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
+ }
38
112
  }
39
- return { attachments: [], failed: true };
113
+ if (attachments.length === 0 && anyFailed) {
114
+ return { attachments: [], failed: true };
115
+ }
116
+ return { attachments, failed: false };
40
117
  }
41
118
  async function sendReply(chatId, ack) {
42
119
  if (ack.ok) {
@@ -50,23 +127,42 @@ 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) ||
131
+ msg.document !== undefined;
132
+ if (text.trim().length === 0 && !hasMedia)
55
133
  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.
134
+ const mediaLabel = buildMediaLabel(msg);
135
+ log.info(`[telegram] accepted chat=${chatId} user=@${userLabel(msg)} len=${text.length}${mediaLabel}`);
136
+ const { attachments, failed } = await tryDownloadAttachments(msg);
137
+ // Attachment-only message where download failed: bail out instead
138
+ // of sending a query without the actual file.
60
139
  if (failed && text.trim().length === 0) {
61
140
  await api
62
- .sendMessage(chatId, "Sorry, I could not download the photo. Please try again.")
141
+ .sendMessage(chatId, "Sorry, I could not download the attachment. Please try again.")
63
142
  .catch(() => { });
64
143
  return;
65
144
  }
66
- // Surface the photo-drop so the user knows the image was lost.
145
+ // Surface the attachment failure so the user knows the file was lost.
67
146
  const messageText = resolveMessageText(text, failed);
147
+ // Start streaming: chunks will arrive via handleTextChunk and
148
+ // be sent/edited in the Telegram chat in real time.
149
+ startStream(chatId);
68
150
  const ack = await sendToMulmo(String(chatId), messageText, attachments.length > 0 ? attachments : undefined);
69
- await sendReply(chatId, ack);
151
+ // Final flush + capture the message id before stopStream clears it.
152
+ flushStream();
153
+ const sentMsgId = streamMessageId;
154
+ stopStream();
155
+ // If streaming sent the text, do a final edit with the full ack
156
+ // to catch any trailing chunks. If no streaming happened,
157
+ // send the full reply as a new message.
158
+ if (sentMsgId !== null && ack.ok) {
159
+ await api
160
+ .editMessageText(chatId, sentMsgId, ack.reply ?? "")
161
+ .catch(() => { });
162
+ }
163
+ else if (sentMsgId === null) {
164
+ await sendReply(chatId, ack);
165
+ }
70
166
  }
71
167
  async function handleDenied(msg) {
72
168
  const chatId = msg.chat.id;
@@ -107,6 +203,12 @@ export function createMessageRouter(deps) {
107
203
  log.error(`[telegram] push sendMessage failed: ${String(err)}`);
108
204
  }
109
205
  },
206
+ handleTextChunk(chunk) {
207
+ if (streamChatId === null)
208
+ return;
209
+ streamAccumulated += chunk;
210
+ streamDirty = true;
211
+ },
110
212
  deniedAlreadyNotified() {
111
213
  return deniedAlreadyNotified;
112
214
  },
@@ -121,14 +223,23 @@ async function sendChunked(api, chatId, text) {
121
223
  await api.sendMessage(chatId, text.slice(i, i + TELEGRAM_MAX_MESSAGE_CHARS));
122
224
  }
123
225
  }
124
- function resolveMessageText(text, photoFailed) {
226
+ function resolveMessageText(text, attachmentFailed) {
125
227
  const hasText = text.trim().length > 0;
126
- if (photoFailed && hasText) {
127
- return `${text}\n\n(note: attached photo could not be downloaded)`;
228
+ if (attachmentFailed && hasText) {
229
+ return `${text}\n\n(note: attached file could not be downloaded)`;
128
230
  }
129
231
  if (hasText)
130
232
  return text;
131
- return "What is this image?";
233
+ return "Describe / analyze this file.";
234
+ }
235
+ function buildMediaLabel(msg) {
236
+ if (msg.document) {
237
+ const hint = msg.document.file_name ?? msg.document.mime_type ?? "unknown";
238
+ return ` +file(${hint})`;
239
+ }
240
+ if (Array.isArray(msg.photo) && msg.photo.length > 0)
241
+ return " +photo";
242
+ return "";
132
243
  }
133
244
  function userLabel(msg) {
134
245
  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.2",
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
  },
@@ -27,7 +35,7 @@
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
  }