@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 +16 -2
- package/dist/api.js +24 -2
- package/dist/index.js +4 -0
- package/dist/router.d.ts +2 -0
- package/dist/router.js +140 -29
- package/package.json +11 -3
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
|
-
|
|
48
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
//
|
|
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
|
|
141
|
+
.sendMessage(chatId, "Sorry, I could not download the attachment. Please try again.")
|
|
63
142
|
.catch(() => { });
|
|
64
143
|
return;
|
|
65
144
|
}
|
|
66
|
-
// Surface the
|
|
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
|
-
|
|
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,
|
|
226
|
+
function resolveMessageText(text, attachmentFailed) {
|
|
125
227
|
const hasText = text.trim().length > 0;
|
|
126
|
-
if (
|
|
127
|
-
return `${text}\n\n(note: attached
|
|
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 "
|
|
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.
|
|
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": "^
|
|
31
|
-
"typescript": "^
|
|
38
|
+
"@types/node": "^25.6.0",
|
|
39
|
+
"typescript": "^6.0.3"
|
|
32
40
|
}
|
|
33
41
|
}
|