@mulmobridge/telegram 0.1.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 +59 -0
- package/dist/allowlist.d.ts +13 -0
- package/dist/allowlist.js +44 -0
- package/dist/api.d.ts +50 -0
- package/dist/api.js +69 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +105 -0
- package/dist/router.d.ts +25 -0
- package/dist/router.js +135 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# @mulmobridge/telegram
|
|
2
|
+
|
|
3
|
+
Telegram bot bridge for MulmoBridge. Connect a Telegram bot to MulmoClaude.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
1. Create a bot via [@BotFather](https://t.me/BotFather)
|
|
8
|
+
2. Start the MulmoClaude server (`yarn dev` in the main repo)
|
|
9
|
+
3. Run the Telegram bridge:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# npx (no install needed)
|
|
13
|
+
TELEGRAM_BOT_TOKEN=your-token TELEGRAM_ALLOWED_CHAT_IDS=123,456 \
|
|
14
|
+
npx @mulmobridge/telegram@latest
|
|
15
|
+
|
|
16
|
+
# or install globally
|
|
17
|
+
npm install -g @mulmobridge/telegram
|
|
18
|
+
TELEGRAM_BOT_TOKEN=your-token TELEGRAM_ALLOWED_CHAT_IDS=123,456 \
|
|
19
|
+
mulmobridge-telegram
|
|
20
|
+
|
|
21
|
+
# or from the monorepo
|
|
22
|
+
yarn telegram
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Environment Variables
|
|
26
|
+
|
|
27
|
+
| Variable | Required | Description | Default |
|
|
28
|
+
|---|---|---|---|
|
|
29
|
+
| `TELEGRAM_BOT_TOKEN` | Yes | BotFather token | - |
|
|
30
|
+
| `TELEGRAM_ALLOWED_CHAT_IDS` | Yes | Comma-separated integer chat IDs | empty (deny all) |
|
|
31
|
+
| `MULMOCLAUDE_API_URL` | No | Server URL | `http://localhost:3001` |
|
|
32
|
+
| `MULMOCLAUDE_AUTH_TOKEN` | No | Bearer token override | reads from file |
|
|
33
|
+
| `TELEGRAM_POLL_TIMEOUT_SEC` | No | Long-poll timeout | `25` |
|
|
34
|
+
|
|
35
|
+
## Security
|
|
36
|
+
|
|
37
|
+
The bridge enforces a **chat-ID allowlist**. Only messages from listed chat IDs are forwarded to MulmoClaude. All other messages receive a one-time "Access denied" reply.
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- Long-polling (no webhook setup needed)
|
|
42
|
+
- Photo attachment support (downloads and forwards to Claude vision)
|
|
43
|
+
- Long-message chunking (splits replies > 4096 chars)
|
|
44
|
+
- Server push delivery (Phase B of #268)
|
|
45
|
+
- Graceful shutdown on SIGINT
|
|
46
|
+
|
|
47
|
+
## Ecosystem
|
|
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
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface Allowlist {
|
|
2
|
+
allows(chatId: number): boolean;
|
|
3
|
+
size(): number;
|
|
4
|
+
/** For logging — never returns a mutable reference. */
|
|
5
|
+
snapshot(): readonly number[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Parse a comma-separated list of integer chat IDs. Throws if any
|
|
9
|
+
* entry isn't a valid integer so the operator notices a typo at
|
|
10
|
+
* startup instead of silently denying messages.
|
|
11
|
+
*/
|
|
12
|
+
export declare function parseAllowlist(raw: string | undefined): Allowlist;
|
|
13
|
+
export declare function createAllowlist(ids: Iterable<number>): Allowlist;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Telegram chat-ID allowlist. Telegram bots accept messages from any
|
|
2
|
+
// user who knows the bot's username — this is the one gate that keeps
|
|
3
|
+
// the operator's MulmoClaude from being driven by the entire
|
|
4
|
+
// internet. Default = empty set = deny everyone.
|
|
5
|
+
//
|
|
6
|
+
// Chat IDs are Telegram integers (positive for user chats, negative
|
|
7
|
+
// for group chats). We parse strictly: a non-integer entry in the
|
|
8
|
+
// CSV is a misconfiguration and should halt startup, not get silently
|
|
9
|
+
// dropped.
|
|
10
|
+
/**
|
|
11
|
+
* Parse a comma-separated list of integer chat IDs. Throws if any
|
|
12
|
+
* entry isn't a valid integer so the operator notices a typo at
|
|
13
|
+
* startup instead of silently denying messages.
|
|
14
|
+
*/
|
|
15
|
+
export function parseAllowlist(raw) {
|
|
16
|
+
const ids = new Set();
|
|
17
|
+
if (raw && raw.trim().length > 0) {
|
|
18
|
+
for (const part of raw.split(",")) {
|
|
19
|
+
const trimmed = part.trim();
|
|
20
|
+
if (trimmed.length === 0)
|
|
21
|
+
continue;
|
|
22
|
+
const n = Number(trimmed);
|
|
23
|
+
if (!Number.isInteger(n)) {
|
|
24
|
+
throw new Error(`TELEGRAM_ALLOWED_CHAT_IDS: "${trimmed}" is not an integer chat id`);
|
|
25
|
+
}
|
|
26
|
+
ids.add(n);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return createAllowlist(ids);
|
|
30
|
+
}
|
|
31
|
+
export function createAllowlist(ids) {
|
|
32
|
+
const set = new Set(ids);
|
|
33
|
+
return {
|
|
34
|
+
allows(chatId) {
|
|
35
|
+
return set.has(chatId);
|
|
36
|
+
},
|
|
37
|
+
size() {
|
|
38
|
+
return set.size;
|
|
39
|
+
},
|
|
40
|
+
snapshot() {
|
|
41
|
+
return Array.from(set).sort((a, b) => a - b);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export interface TelegramUpdate {
|
|
2
|
+
update_id: number;
|
|
3
|
+
message?: TelegramMessage;
|
|
4
|
+
}
|
|
5
|
+
export interface TelegramPhotoSize {
|
|
6
|
+
file_id: string;
|
|
7
|
+
file_unique_id: string;
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
file_size?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface TelegramMessage {
|
|
13
|
+
message_id: number;
|
|
14
|
+
chat: TelegramChat;
|
|
15
|
+
from?: TelegramUser;
|
|
16
|
+
text?: string;
|
|
17
|
+
photo?: TelegramPhotoSize[];
|
|
18
|
+
caption?: string;
|
|
19
|
+
date: number;
|
|
20
|
+
}
|
|
21
|
+
export interface TelegramChat {
|
|
22
|
+
id: number;
|
|
23
|
+
type: "private" | "group" | "supergroup" | "channel";
|
|
24
|
+
username?: string;
|
|
25
|
+
title?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface TelegramUser {
|
|
28
|
+
id: number;
|
|
29
|
+
is_bot: boolean;
|
|
30
|
+
username?: string;
|
|
31
|
+
first_name: string;
|
|
32
|
+
}
|
|
33
|
+
export interface TelegramApiOptions {
|
|
34
|
+
botToken: string;
|
|
35
|
+
/** Base URL override for tests / self-hosted Telegram Bot API. */
|
|
36
|
+
baseUrl?: string;
|
|
37
|
+
/** Injectable fetch for tests — defaults to global. */
|
|
38
|
+
fetchImpl?: typeof fetch;
|
|
39
|
+
}
|
|
40
|
+
export interface GetUpdatesOptions {
|
|
41
|
+
offset?: number;
|
|
42
|
+
timeoutSec?: number;
|
|
43
|
+
signal?: AbortSignal;
|
|
44
|
+
}
|
|
45
|
+
export interface TelegramApi {
|
|
46
|
+
getUpdates(opts?: GetUpdatesOptions): Promise<TelegramUpdate[]>;
|
|
47
|
+
sendMessage(chatId: number, text: string): Promise<void>;
|
|
48
|
+
downloadPhoto(fileId: string): Promise<string>;
|
|
49
|
+
}
|
|
50
|
+
export declare function createTelegramApi(opts: TelegramApiOptions): TelegramApi;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { mimeFromExtension, buildDataUrl } from "@mulmobridge/client";
|
|
2
|
+
const DEFAULT_BASE = "https://api.telegram.org";
|
|
3
|
+
export function createTelegramApi(opts) {
|
|
4
|
+
const baseUrl = opts.baseUrl ?? DEFAULT_BASE;
|
|
5
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
6
|
+
const base = `${baseUrl}/bot${opts.botToken}`;
|
|
7
|
+
return {
|
|
8
|
+
async getUpdates(gu = {}) {
|
|
9
|
+
const params = new URLSearchParams();
|
|
10
|
+
if (gu.offset !== undefined)
|
|
11
|
+
params.set("offset", String(gu.offset));
|
|
12
|
+
if (gu.timeoutSec !== undefined) {
|
|
13
|
+
params.set("timeout", String(gu.timeoutSec));
|
|
14
|
+
}
|
|
15
|
+
const url = `${base}/getUpdates?${params.toString()}`;
|
|
16
|
+
const res = await fetchImpl(url, { signal: gu.signal });
|
|
17
|
+
if (!res.ok) {
|
|
18
|
+
throw new Error(`getUpdates failed: ${res.status} ${await safeText(res)}`);
|
|
19
|
+
}
|
|
20
|
+
const body = (await res.json());
|
|
21
|
+
if (!body.ok || !Array.isArray(body.result)) {
|
|
22
|
+
throw new Error(`getUpdates API error: ${body.description ?? "unknown"}`);
|
|
23
|
+
}
|
|
24
|
+
return body.result;
|
|
25
|
+
},
|
|
26
|
+
async sendMessage(chatId, text) {
|
|
27
|
+
const res = await fetchImpl(`${base}/sendMessage`, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { "Content-Type": "application/json" },
|
|
30
|
+
body: JSON.stringify({ chat_id: chatId, text }),
|
|
31
|
+
});
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
throw new Error(`sendMessage failed: ${res.status} ${await safeText(res)}`);
|
|
34
|
+
}
|
|
35
|
+
const body = (await res.json());
|
|
36
|
+
if (!body.ok) {
|
|
37
|
+
throw new Error(`sendMessage API error: ${body.description ?? "unknown"}`);
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
async downloadPhoto(fileId) {
|
|
41
|
+
const getFileRes = await fetchImpl(`${base}/getFile?file_id=${encodeURIComponent(fileId)}`);
|
|
42
|
+
if (!getFileRes.ok) {
|
|
43
|
+
throw new Error(`getFile failed: ${getFileRes.status} ${await safeText(getFileRes)}`);
|
|
44
|
+
}
|
|
45
|
+
const getFileBody = (await getFileRes.json());
|
|
46
|
+
if (!getFileBody.ok || !getFileBody.result?.file_path) {
|
|
47
|
+
throw new Error(`getFile API error: ${getFileBody.description ?? "no file_path"}`);
|
|
48
|
+
}
|
|
49
|
+
const fileUrl = `${baseUrl}/file/bot${opts.botToken}/${getFileBody.result.file_path}`;
|
|
50
|
+
const fileRes = await fetchImpl(fileUrl);
|
|
51
|
+
if (!fileRes.ok) {
|
|
52
|
+
throw new Error(`file download failed: ${fileRes.status}`);
|
|
53
|
+
}
|
|
54
|
+
const buffer = await fileRes.arrayBuffer();
|
|
55
|
+
const b64 = Buffer.from(buffer).toString("base64");
|
|
56
|
+
const ext = getFileBody.result.file_path.split(".").pop() ?? "";
|
|
57
|
+
const mediaType = mimeFromExtension(ext, "image/jpeg");
|
|
58
|
+
return buildDataUrl(mediaType, b64);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async function safeText(res) {
|
|
63
|
+
try {
|
|
64
|
+
return await res.text();
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return "<unreadable body>";
|
|
68
|
+
}
|
|
69
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Telegram bridge (issue #321). Polls Telegram for incoming
|
|
3
|
+
// messages and dispatches them through the shared message router,
|
|
4
|
+
// which enforces the chat-ID allowlist, forwards to MulmoClaude,
|
|
5
|
+
// and delivers Phase-B pushes back through sendMessage.
|
|
6
|
+
//
|
|
7
|
+
// Env surface (see docs/message_apps/telegram/README.md):
|
|
8
|
+
// TELEGRAM_BOT_TOKEN — BotFather token (required)
|
|
9
|
+
// TELEGRAM_ALLOWED_CHAT_IDS — CSV of integer chat IDs (required
|
|
10
|
+
// in practice; empty = deny everyone)
|
|
11
|
+
// MULMOCLAUDE_API_URL — optional override
|
|
12
|
+
// MULMOCLAUDE_AUTH_TOKEN — optional override
|
|
13
|
+
// TELEGRAM_POLL_TIMEOUT_SEC — optional, default 25
|
|
14
|
+
import "dotenv/config";
|
|
15
|
+
import { createBridgeClient } from "@mulmobridge/client";
|
|
16
|
+
import { createTelegramApi } from "./api.js";
|
|
17
|
+
import { parseAllowlist } from "./allowlist.js";
|
|
18
|
+
import { createMessageRouter } from "./router.js";
|
|
19
|
+
const TRANSPORT_ID = "telegram";
|
|
20
|
+
function readEnv() {
|
|
21
|
+
const botToken = process.env.TELEGRAM_BOT_TOKEN;
|
|
22
|
+
if (!botToken || botToken.trim().length === 0) {
|
|
23
|
+
console.error("TELEGRAM_BOT_TOKEN is required. See docs/message_apps/telegram/.");
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
let allowlist;
|
|
27
|
+
try {
|
|
28
|
+
allowlist = parseAllowlist(process.env.TELEGRAM_ALLOWED_CHAT_IDS);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
const pollTimeoutSec = Number(process.env.TELEGRAM_POLL_TIMEOUT_SEC ?? "25");
|
|
35
|
+
if (!Number.isInteger(pollTimeoutSec) || pollTimeoutSec < 0) {
|
|
36
|
+
console.error("TELEGRAM_POLL_TIMEOUT_SEC must be a non-negative integer");
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
return { botToken, allowlist, pollTimeoutSec };
|
|
40
|
+
}
|
|
41
|
+
async function pollLoop(api, router, opts) {
|
|
42
|
+
let offset;
|
|
43
|
+
while (!opts.abortSignal.aborted) {
|
|
44
|
+
let updates;
|
|
45
|
+
try {
|
|
46
|
+
updates = await api.getUpdates({
|
|
47
|
+
offset,
|
|
48
|
+
timeoutSec: opts.pollTimeoutSec,
|
|
49
|
+
signal: opts.abortSignal,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (opts.abortSignal.aborted)
|
|
54
|
+
return;
|
|
55
|
+
console.error(`[telegram] getUpdates error: ${String(err)}`);
|
|
56
|
+
// Back off briefly so a broken network doesn't busy-loop.
|
|
57
|
+
await delay(1000);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
for (const update of updates) {
|
|
61
|
+
offset = update.update_id + 1;
|
|
62
|
+
if (update.message) {
|
|
63
|
+
await router.handleMessage(update.message);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function delay(ms) {
|
|
69
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
70
|
+
}
|
|
71
|
+
async function main() {
|
|
72
|
+
const { botToken, allowlist, pollTimeoutSec } = readEnv();
|
|
73
|
+
console.log("MulmoClaude Telegram bridge");
|
|
74
|
+
console.log(`Allowlist: ${allowlist.size() > 0
|
|
75
|
+
? allowlist.snapshot().join(", ")
|
|
76
|
+
: "(empty — all chats will be denied)"}`);
|
|
77
|
+
const api = createTelegramApi({ botToken });
|
|
78
|
+
const client = createBridgeClient({ transportId: TRANSPORT_ID });
|
|
79
|
+
const router = createMessageRouter({
|
|
80
|
+
api,
|
|
81
|
+
allowlist,
|
|
82
|
+
sendToMulmo: (chatId, text, attachments) => client.send(chatId, text, attachments),
|
|
83
|
+
});
|
|
84
|
+
// Server → Telegram async push (Phase B of #268).
|
|
85
|
+
client.onPush((ev) => {
|
|
86
|
+
router
|
|
87
|
+
.handlePush(ev)
|
|
88
|
+
.catch((err) => console.error(`[telegram] handlePush failed: ${String(err)}`));
|
|
89
|
+
});
|
|
90
|
+
const abortController = new AbortController();
|
|
91
|
+
process.on("SIGINT", () => {
|
|
92
|
+
console.log("\nShutting down...");
|
|
93
|
+
abortController.abort();
|
|
94
|
+
client.close();
|
|
95
|
+
process.exit(0);
|
|
96
|
+
});
|
|
97
|
+
await pollLoop(api, router, {
|
|
98
|
+
pollTimeoutSec,
|
|
99
|
+
abortSignal: abortController.signal,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
main().catch((err) => {
|
|
103
|
+
console.error("Fatal:", err);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
});
|
package/dist/router.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { TelegramApi, TelegramMessage } from "./api.js";
|
|
2
|
+
import type { Allowlist } from "./allowlist.js";
|
|
3
|
+
import type { Attachment } from "@mulmobridge/protocol";
|
|
4
|
+
import { type MessageAck, type PushEvent } from "@mulmobridge/client";
|
|
5
|
+
export type SendToMulmoFn = (externalChatId: string, text: string, attachments?: Attachment[]) => Promise<MessageAck>;
|
|
6
|
+
export interface RouterDeps {
|
|
7
|
+
api: TelegramApi;
|
|
8
|
+
allowlist: Allowlist;
|
|
9
|
+
sendToMulmo: SendToMulmoFn;
|
|
10
|
+
/** Structured logger. Console-compatible shape — `[telegram]`
|
|
11
|
+
* prefix is the router's responsibility. */
|
|
12
|
+
log?: {
|
|
13
|
+
info: (msg: string) => void;
|
|
14
|
+
warn: (msg: string) => void;
|
|
15
|
+
error: (msg: string) => void;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export interface MessageRouter {
|
|
19
|
+
handleMessage(msg: TelegramMessage): Promise<void>;
|
|
20
|
+
handlePush(ev: PushEvent): Promise<void>;
|
|
21
|
+
/** For tests / debugging: chat IDs we've already sent the
|
|
22
|
+
* access-denied notice to. */
|
|
23
|
+
deniedAlreadyNotified(): ReadonlySet<number>;
|
|
24
|
+
}
|
|
25
|
+
export declare function createMessageRouter(deps: RouterDeps): MessageRouter;
|
package/dist/router.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Message-routing logic for the Telegram bridge. Kept separate from
|
|
2
|
+
// `index.ts` so it can be exercised with stubbed deps (no real
|
|
3
|
+
// Telegram API, no real MulmoClaude socket, no env reads). The
|
|
4
|
+
// entrypoint in index.ts is then just: read env → wire real deps →
|
|
5
|
+
// drive the polling loop.
|
|
6
|
+
import { parseDataUrl, } from "@mulmobridge/client";
|
|
7
|
+
// Telegram caps a single message at 4096 chars. We split long
|
|
8
|
+
// replies naively; pretty formatting (preserve markdown, break on
|
|
9
|
+
// sentence boundaries) is a follow-up.
|
|
10
|
+
const TELEGRAM_MAX_MESSAGE_CHARS = 4096;
|
|
11
|
+
const defaultLog = {
|
|
12
|
+
info: (m) => console.log(m),
|
|
13
|
+
warn: (m) => console.warn(m),
|
|
14
|
+
error: (m) => console.error(m),
|
|
15
|
+
};
|
|
16
|
+
export function createMessageRouter(deps) {
|
|
17
|
+
const { api, allowlist, sendToMulmo } = deps;
|
|
18
|
+
const log = deps.log ?? defaultLog;
|
|
19
|
+
// One denial reply per chat per bridge lifetime — restart clears.
|
|
20
|
+
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
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
log.error(`[telegram] photo download failed: ${String(err)}`);
|
|
38
|
+
}
|
|
39
|
+
return { attachments: [], failed: true };
|
|
40
|
+
}
|
|
41
|
+
async function sendReply(chatId, ack) {
|
|
42
|
+
if (ack.ok) {
|
|
43
|
+
await sendChunked(api, chatId, ack.reply ?? "");
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
const status = ack.status ? ` (${ack.status})` : "";
|
|
47
|
+
await sendChunked(api, chatId, `Error${status}: ${ack.error ?? "unknown"}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function handleAllowed(msg) {
|
|
51
|
+
const chatId = msg.chat.id;
|
|
52
|
+
const text = msg.text ?? msg.caption ?? "";
|
|
53
|
+
const hasPhoto = Array.isArray(msg.photo) && msg.photo.length > 0;
|
|
54
|
+
if (text.trim().length === 0 && !hasPhoto)
|
|
55
|
+
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.
|
|
60
|
+
if (failed && text.trim().length === 0) {
|
|
61
|
+
await api
|
|
62
|
+
.sendMessage(chatId, "Sorry, I could not download the photo. Please try again.")
|
|
63
|
+
.catch(() => { });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// Surface the photo-drop so the user knows the image was lost.
|
|
67
|
+
const messageText = resolveMessageText(text, failed);
|
|
68
|
+
const ack = await sendToMulmo(String(chatId), messageText, attachments.length > 0 ? attachments : undefined);
|
|
69
|
+
await sendReply(chatId, ack);
|
|
70
|
+
}
|
|
71
|
+
async function handleDenied(msg) {
|
|
72
|
+
const chatId = msg.chat.id;
|
|
73
|
+
log.warn(`[telegram] denied chat=${chatId} user=@${userLabel(msg)} — not on allowlist`);
|
|
74
|
+
if (deniedAlreadyNotified.has(chatId))
|
|
75
|
+
return;
|
|
76
|
+
deniedAlreadyNotified.add(chatId);
|
|
77
|
+
try {
|
|
78
|
+
await api.sendMessage(chatId, "Access denied. Contact the operator to be added to the allowlist.");
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
log.error(`[telegram] access-denied reply failed: ${String(err)}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
async handleMessage(msg) {
|
|
86
|
+
if (allowlist.allows(msg.chat.id)) {
|
|
87
|
+
await handleAllowed(msg);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
await handleDenied(msg);
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
async handlePush(ev) {
|
|
94
|
+
const chatId = Number(ev.chatId);
|
|
95
|
+
if (!Number.isInteger(chatId)) {
|
|
96
|
+
log.warn(`[telegram] push chatId is not integer: ${ev.chatId}`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!allowlist.allows(chatId)) {
|
|
100
|
+
log.warn(`[telegram] push denied: chat ${chatId} not on allowlist`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
await sendChunked(api, chatId, ev.message);
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
log.error(`[telegram] push sendMessage failed: ${String(err)}`);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
deniedAlreadyNotified() {
|
|
111
|
+
return deniedAlreadyNotified;
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
async function sendChunked(api, chatId, text) {
|
|
116
|
+
if (text.length === 0) {
|
|
117
|
+
await api.sendMessage(chatId, "(empty reply)");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
for (let i = 0; i < text.length; i += TELEGRAM_MAX_MESSAGE_CHARS) {
|
|
121
|
+
await api.sendMessage(chatId, text.slice(i, i + TELEGRAM_MAX_MESSAGE_CHARS));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function resolveMessageText(text, photoFailed) {
|
|
125
|
+
const hasText = text.trim().length > 0;
|
|
126
|
+
if (photoFailed && hasText) {
|
|
127
|
+
return `${text}\n\n(note: attached photo could not be downloaded)`;
|
|
128
|
+
}
|
|
129
|
+
if (hasText)
|
|
130
|
+
return text;
|
|
131
|
+
return "What is this image?";
|
|
132
|
+
}
|
|
133
|
+
function userLabel(msg) {
|
|
134
|
+
return msg.from?.username ?? msg.from?.first_name ?? "unknown";
|
|
135
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mulmobridge/telegram",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Telegram bridge for MulmoBridge — connect a Telegram bot to MulmoClaude",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"mulmobridge-telegram": "dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"prepack": "yarn build",
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"test": "tsx --test test/test_*.ts",
|
|
20
|
+
"lint": "eslint src test"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"author": "Receptron Team",
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@mulmobridge/client": "^0.1.0",
|
|
26
|
+
"@mulmobridge/protocol": "^0.1.0",
|
|
27
|
+
"dotenv": "^17.0.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^22.0.0",
|
|
31
|
+
"typescript": "^5.8.0"
|
|
32
|
+
}
|
|
33
|
+
}
|