@mulmobridge/telegram 1.2.0 → 1.3.1
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 +1 -0
- package/dist/api.d.ts +5 -0
- package/dist/api.js +20 -0
- package/dist/router.js +38 -68
- package/dist/stream.d.ts +14 -0
- package/dist/stream.js +50 -0
- package/package.json +6 -6
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,8 @@
|
|
|
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 {
|
|
6
|
+
import { createReplyStream } from "./stream.js";
|
|
7
|
+
import { parseDataUrl, replyAfterProcessingIndicator } from "@mulmobridge/client";
|
|
7
8
|
// Telegram caps a single message at 4096 chars. We split long
|
|
8
9
|
// replies naively; pretty formatting (preserve markdown, break on
|
|
9
10
|
// sentence boundaries) is a follow-up.
|
|
@@ -17,57 +18,29 @@ const defaultLog = {
|
|
|
17
18
|
// rate limits. 1 second is conservative; Telegram allows ~30 edits
|
|
18
19
|
// per minute per chat.
|
|
19
20
|
const STREAM_EDIT_INTERVAL_MS = 1000;
|
|
21
|
+
// Telegram shows a chat action for about 5 s, or until the next message.
|
|
22
|
+
const TYPING_REFRESH_MS = 4_000;
|
|
20
23
|
export function createMessageRouter(deps) {
|
|
21
24
|
const { api, allowlist, sendToMulmo } = deps;
|
|
22
25
|
const log = deps.log ?? defaultLog;
|
|
23
26
|
// One denial reply per chat per bridge lifetime — restart clears.
|
|
24
27
|
const deniedAlreadyNotified = new Set();
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
});
|
|
28
|
+
// The reply stream of the request in flight. Chunks carry no chat id; the
|
|
29
|
+
// polling loop handles one message at a time, so they belong to this one.
|
|
30
|
+
const inFlight = { stream: null };
|
|
31
|
+
/** Runs `work` while its streamed chunks are mirrored into the chat. The
|
|
32
|
+
* timer is stopped even when `work` throws. */
|
|
33
|
+
async function streamWhile(chatId, work) {
|
|
34
|
+
const stream = createReplyStream(api, chatId, log, STREAM_EDIT_INTERVAL_MS);
|
|
35
|
+
inFlight.stream = stream;
|
|
36
|
+
try {
|
|
37
|
+
const result = await work();
|
|
38
|
+
return { result, streamedMessageId: await stream.finish() };
|
|
65
39
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
});
|
|
40
|
+
finally {
|
|
41
|
+
stream.stop();
|
|
42
|
+
if (inFlight.stream === stream)
|
|
43
|
+
inFlight.stream = null;
|
|
71
44
|
}
|
|
72
45
|
}
|
|
73
46
|
// Download all media from a message. Telegram messages are usually
|
|
@@ -132,32 +105,32 @@ export function createMessageRouter(deps) {
|
|
|
132
105
|
return;
|
|
133
106
|
const mediaLabel = buildMediaLabel(msg);
|
|
134
107
|
log.info(`[telegram] accepted chat=${chatId} user=@${userLabel(msg)} len=${text.length}${mediaLabel}`);
|
|
108
|
+
await replyAfterProcessingIndicator(() => api.sendChatAction(chatId, "typing"), { produce: () => relay(msg, chatId, text), post: (deliver) => deliver() }, { refreshMs: TYPING_REFRESH_MS, label: "telegram", log });
|
|
109
|
+
}
|
|
110
|
+
/** Everything up to the final reply, run while "typing…" shows; returns the
|
|
111
|
+
* step that delivers the reply, which runs once the indicator is down. */
|
|
112
|
+
async function relay(msg, chatId, text) {
|
|
135
113
|
const { attachments, failed } = await tryDownloadAttachments(msg);
|
|
136
114
|
// Attachment-only message where download failed: bail out instead
|
|
137
115
|
// of sending a query without the actual file.
|
|
138
116
|
if (failed && text.trim().length === 0) {
|
|
139
|
-
|
|
140
|
-
|
|
117
|
+
return async () => {
|
|
118
|
+
await api.sendMessage(chatId, "Sorry, I could not download the attachment. Please try again.").catch(() => { });
|
|
119
|
+
};
|
|
141
120
|
}
|
|
142
121
|
// Surface the attachment failure so the user knows the file was lost.
|
|
143
122
|
const messageText = resolveMessageText(text, failed);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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);
|
|
123
|
+
const { result: ack, streamedMessageId } = await streamWhile(chatId, () => sendToMulmo(String(chatId), messageText, attachments.length > 0 ? attachments : undefined));
|
|
124
|
+
return () => deliverFinal(chatId, streamedMessageId, ack);
|
|
125
|
+
}
|
|
126
|
+
// A streamed reply is completed in place; an error after streaming is sent
|
|
127
|
+
// as its own message so the partial text is not the last word.
|
|
128
|
+
async function deliverFinal(chatId, streamedMessageId, ack) {
|
|
129
|
+
if (streamedMessageId !== null && ack.ok) {
|
|
130
|
+
await api.editMessageText(chatId, streamedMessageId, ack.reply ?? "").catch(() => { });
|
|
131
|
+
return;
|
|
160
132
|
}
|
|
133
|
+
await sendReply(chatId, ack);
|
|
161
134
|
}
|
|
162
135
|
async function handleDenied(msg) {
|
|
163
136
|
const chatId = msg.chat.id;
|
|
@@ -199,10 +172,7 @@ export function createMessageRouter(deps) {
|
|
|
199
172
|
}
|
|
200
173
|
},
|
|
201
174
|
handleTextChunk(chunk) {
|
|
202
|
-
|
|
203
|
-
return;
|
|
204
|
-
streamAccumulated += chunk;
|
|
205
|
-
streamDirty = true;
|
|
175
|
+
inFlight.stream?.append(chunk);
|
|
206
176
|
},
|
|
207
177
|
deniedAlreadyNotified() {
|
|
208
178
|
return deniedAlreadyNotified;
|
package/dist/stream.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { TelegramApi } from "./api.js";
|
|
2
|
+
interface StreamLog {
|
|
3
|
+
error: (msg: string) => void;
|
|
4
|
+
}
|
|
5
|
+
export interface ReplyStream {
|
|
6
|
+
append(chunk: string): void;
|
|
7
|
+
/** Stops the timer, sends what is pending, and resolves to the id of the
|
|
8
|
+
* streamed message — or null when nothing was streamed. */
|
|
9
|
+
finish(): Promise<number | null>;
|
|
10
|
+
/** Stops the timer without sending anything more. Safe to call twice. */
|
|
11
|
+
stop(): void;
|
|
12
|
+
}
|
|
13
|
+
export declare function createReplyStream(api: TelegramApi, chatId: number, log: StreamLog, interval_ms: number): ReplyStream;
|
|
14
|
+
export {};
|
package/dist/stream.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// One streamed reply: chunks accumulate here and a timer mirrors them into a
|
|
2
|
+
// single Telegram message (sent once, then edited). Created per request so a
|
|
3
|
+
// failed request cannot leave its timer or text behind for the next one.
|
|
4
|
+
export function createReplyStream(api, chatId, log, interval_ms) {
|
|
5
|
+
const state = { accumulated: "", dirty: false, messageId: null, flushChain: Promise.resolve(), timer: null };
|
|
6
|
+
async function sendOrEdit(text) {
|
|
7
|
+
if (state.messageId === null) {
|
|
8
|
+
state.messageId = await api.sendMessage(chatId, text);
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
await api.editMessageText(chatId, state.messageId, text);
|
|
12
|
+
}
|
|
13
|
+
async function flushPending() {
|
|
14
|
+
if (!state.dirty)
|
|
15
|
+
return;
|
|
16
|
+
state.dirty = false;
|
|
17
|
+
const action = state.messageId === null ? "sendMessage" : "editMessage";
|
|
18
|
+
try {
|
|
19
|
+
await sendOrEdit(state.accumulated || "…");
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
log.error(`[telegram] stream ${action} failed: ${String(err)}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function flush() {
|
|
26
|
+
state.flushChain = state.flushChain.then(flushPending);
|
|
27
|
+
return state.flushChain;
|
|
28
|
+
}
|
|
29
|
+
function stop() {
|
|
30
|
+
if (state.timer === null)
|
|
31
|
+
return;
|
|
32
|
+
clearInterval(state.timer);
|
|
33
|
+
state.timer = null;
|
|
34
|
+
}
|
|
35
|
+
state.timer = setInterval(() => void flush(), interval_ms);
|
|
36
|
+
return {
|
|
37
|
+
append(chunk) {
|
|
38
|
+
if (state.timer === null)
|
|
39
|
+
return;
|
|
40
|
+
state.accumulated += chunk;
|
|
41
|
+
state.dirty = true;
|
|
42
|
+
},
|
|
43
|
+
async finish() {
|
|
44
|
+
stop();
|
|
45
|
+
await flush();
|
|
46
|
+
return state.messageId;
|
|
47
|
+
},
|
|
48
|
+
stop,
|
|
49
|
+
};
|
|
50
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mulmobridge/telegram",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
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
|
|
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.
|
|
40
|
-
"@mulmobridge/protocol": "^1.0
|
|
41
|
-
"dotenv": "^
|
|
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
|
|
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",
|