diff --git a/docs/mac/bun.md b/docs/mac/bun.md new file mode 100644 index 000000000..1824a1c19 --- /dev/null +++ b/docs/mac/bun.md @@ -0,0 +1,30 @@ +# Bundled Bun runtime (mac app only) + +Date: 2025-12-07 · Owner: steipete · Scope: packaged mac app runtime + +## What we ship +- The mac menu-bar app embeds an **arm64 Bun runtime** under `Contents/Resources/Relay/` only for the packaged app. Dev/CI keep using pnpm+node. +- Payload: `bun` binary (defaults to `/opt/homebrew/bin/bun`, override with `BUN_PATH=/path/to/bun`), `dist/` output, production `node_modules/`, and the root `package.json`/`pnpm-lock.yaml` for provenance. +- We prune dev/build tooling (vite, rolldown, biome, vitest, tsc/tsx, @types, etc.) and drop all non-macOS sharp vendors so only `sharp-darwin-arm64` + `sharp-libvips-darwin-arm64` remain. + +## Build/packaging flow +- Run `scripts/package-mac-app.sh` (or `BUN_PATH=/custom/bun scripts/package-mac-app.sh`). + - Ensures deps via `pnpm install`, then `pnpm exec tsc`. + - Builds the Swift app and stages `dist/`, Bun, and production `node_modules` into `Contents/Resources/Relay/` using a temp deploy (hoisted layout, no dev deps). + - Prunes optional tooling + extra sharp vendors, then codesigns binaries and native addons. +- Architecture: **arm64 only**. Ship a separate bundle if you need Rosetta/x64. + +## Runtime behavior +- `CommandResolver` prefers the bundled `bun dist/index.js ` when present; falls back to system `clawdis`/pnpm/node otherwise. +- `RelayProcessManager` runs in the bundled cwd/PATH so native deps (sharp, undici) resolve without installing anything on the host. + +## Testing the bundle +- After packaging: `cd dist/Clawdis.app/Contents/Resources/Relay && ./bun dist/index.js --help` should print the CLI help without missing-module errors. +- If sharp fails to load, confirm the remaining `@img/sharp-darwin-arm64` + `@img/sharp-libvips-darwin-arm64` directories exist and are codesigned. + +## Notes / limits +- Bundle is mac-app-only; keep using pnpm+node for dev/test. +- Packaging stops early if Bun or `pnpm build` prerequisites are missing. + +## FAQ +- **What does `--legacy` do?** When used with `pnpm deploy`, `--legacy` builds a classic flattened `node_modules` layout instead of pnpm's symlinked structure. We no longer need it in the current packaging flow because we create a self-contained hoisted install directly in the temp deploy dir. diff --git a/docs/telegram.md b/docs/telegram.md new file mode 100644 index 000000000..84843b405 --- /dev/null +++ b/docs/telegram.md @@ -0,0 +1,62 @@ +# Telegram (Bot API) + +Updated: 2025-12-07 + +Status: ready for bot-mode use with grammY (long-poll + webhook). Text + media send, proxy, and webhook helpers all ship in-tree. + +## Goals +- Let you talk to Clawdis via a Telegram bot in DMs and groups. +- Share the same `main` session used by WhatsApp/WebChat; groups stay isolated as `group:`. +- Keep transport routing deterministic: replies always go back to the surface they arrived on. + +## How it will work (Bot API) +1) Create a bot with @BotFather and grab the token. +2) Configure Clawdis with `TELEGRAM_BOT_TOKEN` (or `telegram.botToken` in `~/.clawdis/clawdis.json`). +3) Run the relay with provider `telegram` via `clawdis relay:telegram` (grammY long-poll). Webhook mode: `clawdis relay:telegram --webhook --port 8787 --webhook-secret ` (optionally `--webhook-url` when the public URL differs). +4) Direct chats: user sends the first message; all subsequent turns land in the shared `main` session (default, no extra config). +5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `group:` and require mention/command to trigger replies. +6) Optional allowlist: reuse `inbound.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`). + +## Capabilities & limits (Bot API) +- Sees only messages sent after it’s added to a chat; no pre-history access. +- Cannot DM users first; they must initiate. Channels are receive-only unless the bot is an admin poster. +- File size caps follow Telegram Bot API (up to 2 GB for documents; smaller for some media types). +- Typing indicators (`sendChatAction`) supported; inline reply/threading supported where Telegram allows. + +## Planned implementation details +- Library: grammY is the only client for send + relay (fetch fallback removed). +- Inbound normalization: maps Bot API updates to `MsgContext` with `Surface: "telegram"`, `ChatType: direct|group`, `SenderName`, `MediaPath`/`MediaType` when attachments arrive, and `Timestamp`; groups require @bot mention by default. +- Outbound: text and media (photo/video/audio/document) with optional caption; chunked to limits. Typing cue sent best-effort. +- Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.requireMention`, `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl` supported. + +Example config: +```json5 +{ + telegram: { + botToken: "123:abc", + requireMention: true, + allowFrom: ["123456789"], // direct chat ids allowed (or "*") + mediaMaxMb: 5, + proxy: "socks5://localhost:9050", + webhookSecret: "mysecret", + webhookUrl: "https://yourdomain.com/telegram-webhook" + } +} +``` +- Tests: grammY-based paths in `src/telegram/*.test.ts` cover DM + group gating; add more media and webhook cases as needed. + +## Group etiquette +- Keep privacy mode off if you expect the bot to read all messages; with privacy on, it only sees commands/mentions. +- Make the bot an admin if you need it to send in restricted groups or channels. +- Mention the bot (`@yourbot`) or use commands to trigger; we’ll honor `group.requireMention` by default to avoid noise. + +## Roadmap +- ✅ Design and defaults (this doc) +- ✅ grammY long-poll relay + text/media send +- ✅ Proxy + webhook helpers (setWebhook/deleteWebhook, health endpoint, optional public URL) +- ⏳ Add more grammY coverage (webhook payloads, media edge cases) + +## Safety & ops +- Treat the bot token as a secret (equivalent to account control); store under `~/.clawdis/credentials/` with 0600 perms. +- Respect Telegram rate limits (429s); we’ll add throttling in the provider to stay below flood thresholds. +- Use a test bot for development to avoid hitting production chats. diff --git a/package.json b/package.json index 61f13a098..2bff91d2e 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@mariozechner/pi-coding-agent": "^0.13.2", "@whiskeysockets/baileys": "7.0.0-rc.9", "body-parser": "^2.2.1", + "@grammyjs/transformer-throttler": "^1.2.1", "chalk": "^5.6.2", "commander": "^14.0.2", "dotenv": "^17.2.3", @@ -42,7 +43,9 @@ "tslog": "^4.9.3", "qrcode-terminal": "^0.12.0", "sharp": "^0.34.5", - "zod": "^4.1.13" + "undici": "^6.20.1", + "zod": "^4.1.13", + "grammy": "^1.27.0" }, "devDependencies": { "@biomejs/biome": "^2.3.8", @@ -82,10 +85,16 @@ "src/**/*.test.ts" ] }, + "include": [ + "src/**/*.test.ts" + ], "exclude": [ "dist/**", "apps/macos/**", - "apps/macos/.build/**" + "apps/macos/.build/**", + "**/vendor/**", + "apps/macos/.build/**", + "dist/Clawdis.app/**" ] } } diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index d41d6285c..40c6cb0b1 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -117,17 +117,32 @@ pnpm install \ --config.enable-pre-post-scripts=true \ --config.ignore-workspace-root-check=true \ --config.shared-workspace-lockfile=false \ - --lockfile-dir "$ROOT_DIR" \ + --config.node-linker=hoisted \ + --lockfile-dir "$TMP_DEPLOY" \ --dir "$TMP_DEPLOY" PNPM_STORE_DIR="$TMP_DEPLOY/.pnpm-store" \ PNPM_HOME="$HOME/Library/pnpm" \ pnpm rebuild sharp --config.ignore-workspace-root-check=true --dir "$TMP_DEPLOY" -rsync -aL "$TMP_DEPLOY/node_modules/" "$RELAY_DIR/node_modules/" -# Flatten sharp copies and prune dev artifacts -find "$RELAY_DIR/node_modules/.pnpm" -maxdepth 1 -name "*sharp*" -type d -print0 | xargs -0 -I{} rsync -a --delete "{}/node_modules/@img/sharp-darwin-arm64" "$RELAY_DIR/node_modules/@img/" 2>/dev/null || true -find "$RELAY_DIR/node_modules/.pnpm" -maxdepth 1 -name "*sharp-libvips*" -type d -print0 | xargs -0 -I{} rsync -a --delete "{}/node_modules/@img/sharp-libvips-darwin-arm64" "$RELAY_DIR/node_modules/@img/" 2>/dev/null || true -rm -rf "$RELAY_DIR/node_modules/.pnpm"/*sharp* "$RELAY_DIR/node_modules/.pnpm/node_modules/@img" 2>/dev/null || true -rm -f "$RELAY_DIR/node_modules/.bin"/vite "$RELAY_DIR/node_modules/.bin"/rolldown "$RELAY_DIR/node_modules/.bin"/biome 2>/dev/null || true +rsync -a "$TMP_DEPLOY/node_modules/" "$RELAY_DIR/node_modules/" + +# Keep only the arm64 macOS sharp vendor payloads to shrink the bundle +SHARP_VENDOR_DIR="$RELAY_DIR/node_modules/@img" +if [ -d "$SHARP_VENDOR_DIR" ]; then + find "$SHARP_VENDOR_DIR" -maxdepth 1 -type d -name "sharp-*" \ + ! -name "sharp-darwin-arm64" \ + ! -name "sharp-libvips-darwin-arm64" -exec rm -rf {} + +fi + +# Prune obvious dev/build tooling to keep size down +rm -rf \ + "$RELAY_DIR/node_modules/.bin"/vite \ + "$RELAY_DIR/node_modules/.bin"/rolldown \ + "$RELAY_DIR/node_modules/.bin"/biome \ + "$RELAY_DIR/node_modules/.bin"/vitest \ + "$RELAY_DIR/node_modules/.bin"/tsc \ + "$RELAY_DIR/node_modules/.bin"/tsx 2>/dev/null || true +rm -rf \ + "$RELAY_DIR/node_modules"/{vite,rolldown,vitest,ts-node,ts-node-dev,typescript,@types,docx-preview,jszip,lucide,ollama} 2>/dev/null || true rm -rf "$TMP_DEPLOY" if [ -f "$CLI_BIN" ]; then diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts new file mode 100644 index 000000000..ec88b85bf --- /dev/null +++ b/src/telegram/bot.ts @@ -0,0 +1,233 @@ +import { Buffer } from "node:buffer"; + +import { Bot, InputFile, webhookCallback } from "grammy"; +import type { ApiClientOptions } from "grammy"; + +import { chunkText } from "../auto-reply/chunk.js"; +import { getReplyFromConfig } from "../auto-reply/reply.js"; +import type { ReplyPayload } from "../auto-reply/types.js"; +import { loadConfig } from "../config/config.js"; +import { danger, logVerbose } from "../globals.js"; +import { getChildLogger } from "../logging.js"; +import { mediaKindFromMime } from "../media/constants.js"; +import { detectMime } from "../media/mime.js"; +import { saveMediaBuffer } from "../media/store.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { loadWebMedia } from "../web/media.js"; + +export type TelegramBotOptions = { + token: string; + runtime?: RuntimeEnv; + requireMention?: boolean; + allowFrom?: Array; + mediaMaxMb?: number; + proxyFetch?: typeof fetch; +}; + +export function createTelegramBot(opts: TelegramBotOptions) { + const runtime: RuntimeEnv = + opts.runtime ?? { + log: console.log, + error: console.error, + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }; + const client: ApiClientOptions | undefined = opts.proxyFetch + ? { fetch: opts.proxyFetch as unknown as ApiClientOptions["fetch"] } + : undefined; + + const bot = new Bot(opts.token, { client }); + + const cfg = loadConfig(); + const requireMention = + opts.requireMention ?? cfg.telegram?.requireMention ?? true; + const allowFrom = opts.allowFrom ?? cfg.telegram?.allowFrom; + const mediaMaxBytes = + (opts.mediaMaxMb ?? cfg.telegram?.mediaMaxMb ?? 5) * 1024 * 1024; + const logger = getChildLogger({ module: "telegram-auto-reply" }); + + bot.on("message", async (ctx) => { + try { + const msg = ctx.message; + if (!msg) return; + const chatId = msg.chat.id; + const isGroup = + msg.chat.type === "group" || msg.chat.type === "supergroup"; + + // allowFrom for direct chats + if (!isGroup && Array.isArray(allowFrom) && allowFrom.length > 0) { + const candidate = String(chatId); + const allowed = allowFrom.map(String); + const allowedWithPrefix = allowFrom.map((v) => `telegram:${String(v)}`); + const permitted = + allowed.includes(candidate) || + allowedWithPrefix.includes(`telegram:${candidate}`) || + allowed.includes("*"); + if (!permitted) { + logVerbose( + `Blocked unauthorized telegram sender ${candidate} (not in allowFrom)`, + ); + return; + } + } + + const botUsername = ctx.me?.username?.toLowerCase(); + if ( + isGroup && + requireMention && + botUsername && + !hasBotMention(msg, botUsername) + ) { + logger.info({ chatId, reason: "no-mention" }, "skipping group message"); + return; + } + + const media = await resolveMedia(ctx, mediaMaxBytes); + const body = (msg.text ?? msg.caption ?? media?.placeholder ?? "").trim(); + if (!body) return; + + const ctxPayload = { + Body: body, + From: isGroup ? `group:${chatId}` : `telegram:${chatId}`, + To: `telegram:${chatId}`, + ChatType: isGroup ? "group" : "direct", + GroupSubject: isGroup ? msg.chat.title ?? undefined : undefined, + SenderName: buildSenderName(msg), + Surface: "telegram", + MessageSid: String(msg.message_id), + Timestamp: msg.date ? msg.date * 1000 : undefined, + MediaPath: media?.path, + MediaType: media?.contentType, + MediaUrl: media?.path, + }; + + const replyResult = await getReplyFromConfig(ctxPayload, {}, cfg); + const replies = replyResult + ? Array.isArray(replyResult) + ? replyResult + : [replyResult] + : []; + if (replies.length === 0) return; + + await deliverReplies({ + replies, + chatId: String(chatId), + token: opts.token, + runtime, + bot, + }); + } catch (err) { + runtime.error?.(danger(`Telegram handler failed: ${String(err)}`)); + } + }); + + return bot; +} + +export function createTelegramWebhookCallback( + bot: Bot, + path = "/telegram-webhook", +) { + return { path, handler: webhookCallback(bot, "http") }; +} + +async function deliverReplies(params: { + replies: ReplyPayload[]; + chatId: string; + token: string; + runtime: RuntimeEnv; + bot: Bot; +}) { + const { replies, chatId, runtime, bot } = params; + for (const reply of replies) { + if (!reply?.text && !reply?.mediaUrl && !(reply?.mediaUrls?.length ?? 0)) { + runtime.error?.(danger("Telegram reply missing text/media")); + continue; + } + const mediaList = reply.mediaUrls?.length + ? reply.mediaUrls + : reply.mediaUrl + ? [reply.mediaUrl] + : []; + if (mediaList.length === 0) { + for (const chunk of chunkText(reply.text || "", 4000)) { + await bot.api.sendMessage(chatId, chunk, { parse_mode: "Markdown" }); + } + continue; + } + // media with optional caption on first item + let first = true; + for (const mediaUrl of mediaList) { + const media = await loadWebMedia(mediaUrl); + const kind = mediaKindFromMime(media.contentType ?? undefined); + const file = new InputFile(media.buffer, media.fileName ?? "file"); + const caption = first ? reply.text ?? undefined : undefined; + first = false; + if (kind === "image") { + await bot.api.sendPhoto(chatId, file, { caption }); + } else if (kind === "video") { + await bot.api.sendVideo(chatId, file, { caption }); + } else if (kind === "audio") { + await bot.api.sendAudio(chatId, file, { caption }); + } else { + await bot.api.sendDocument(chatId, file, { caption }); + } + } + } +} + +function buildSenderName(msg: any) { + const name = + [msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ").trim() || + msg.from?.username; + return name || undefined; +} + +function hasBotMention(msg: any, botUsername: string) { + const text = (msg.text ?? msg.caption ?? "").toLowerCase(); + if (text.includes(`@${botUsername}`)) return true; + const entities = msg.entities ?? msg.caption_entities ?? []; + for (const ent of entities) { + if (ent.type !== "mention") continue; + const slice = (msg.text ?? msg.caption ?? "").slice( + ent.offset, + ent.offset + ent.length, + ); + if (slice.toLowerCase() === `@${botUsername}`) return true; + } + return false; +} + +async function resolveMedia( + ctx: any, + maxBytes: number, +): Promise<{ path: string; contentType?: string; placeholder: string } | null> { + const msg = ctx.message; + const m = + msg.photo?.[msg.photo.length - 1] ?? + msg.video ?? + msg.document ?? + msg.audio ?? + msg.voice; + if (!m?.file_id) return null; + const file = await ctx.getFile(); + const url = + typeof file.getUrl === "function" + ? file.getUrl(ctx.me?.token ?? ctx.api?.token ?? undefined) + : undefined; + const data = + url && typeof fetch !== "undefined" + ? Buffer.from(await (await fetch(url)).arrayBuffer()) + : Buffer.from(await file.download()); + const mime = detectMime({ + buffer: data, + filePath: file.file_path ?? undefined, + }); + const saved = await saveMediaBuffer(data, mime, "inbound", maxBytes); + let placeholder = ""; + if (msg.photo) placeholder = ""; + else if (msg.video) placeholder = ""; + else if (msg.audio || msg.voice) placeholder = ""; + return { path: saved.path, contentType: saved.contentType, placeholder }; +} diff --git a/src/telegram/download.test.ts b/src/telegram/download.test.ts new file mode 100644 index 000000000..fb099c110 --- /dev/null +++ b/src/telegram/download.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + downloadTelegramFile, + getTelegramFile, + type TelegramFileInfo, +} from "./download.js"; + +describe("telegram download", () => { + it("fetches file info", async () => { + const json = vi.fn().mockResolvedValue({ ok: true, result: { file_path: "photos/1.jpg" } }); + vi.spyOn(global, "fetch" as never).mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: "OK", + json, + } as Response); + const info = await getTelegramFile("tok", "fid"); + expect(info.file_path).toBe("photos/1.jpg"); + }); + + it("downloads and saves", async () => { + const info: TelegramFileInfo = { file_id: "fid", file_path: "photos/1.jpg" }; + const arrayBuffer = async () => new Uint8Array([1, 2, 3, 4]).buffer; + vi.spyOn(global, "fetch" as never).mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: "OK", + body: true, + arrayBuffer, + headers: { get: () => "image/jpeg" }, + } as Response); + const saved = await downloadTelegramFile("tok", info, 1024 * 1024); + expect(saved.path).toBeTruthy(); + expect(saved.contentType).toBe("image/jpeg"); + }); +}); diff --git a/src/telegram/download.ts b/src/telegram/download.ts new file mode 100644 index 000000000..7fb8a538f --- /dev/null +++ b/src/telegram/download.ts @@ -0,0 +1,50 @@ +import { detectMime, extensionForMime } from "../media/mime.js"; +import { saveMediaBuffer, type SavedMedia } from "../media/store.js"; + +export type TelegramFileInfo = { + file_id: string; + file_unique_id?: string; + file_size?: number; + file_path?: string; +}; + +export async function getTelegramFile( + token: string, + fileId: string, +): Promise { + const res = await fetch( + `https://api.telegram.org/bot${token}/getFile?file_id=${encodeURIComponent(fileId)}`, + ); + if (!res.ok) { + throw new Error(`getFile failed: ${res.status} ${res.statusText}`); + } + const json = (await res.json()) as { ok: boolean; result?: TelegramFileInfo }; + if (!json.ok || !json.result?.file_path) { + throw new Error("getFile returned no file_path"); + } + return json.result; +} + +export async function downloadTelegramFile( + token: string, + info: TelegramFileInfo, + maxBytes?: number, +): Promise { + if (!info.file_path) throw new Error("file_path missing"); + const url = `https://api.telegram.org/file/bot${token}/${info.file_path}`; + const res = await fetch(url); + if (!res.ok || !res.body) { + throw new Error(`Failed to download telegram file: HTTP ${res.status}`); + } + const array = Buffer.from(await res.arrayBuffer()); + const mime = detectMime({ + buffer: array, + headerMime: res.headers.get("content-type"), + filePath: info.file_path, + }); + // save with inbound subdir + const saved = await saveMediaBuffer(array, mime, "inbound", maxBytes); + // Ensure extension matches mime if possible + if (!saved.contentType && mime) saved.contentType = mime; + return saved; +} diff --git a/src/telegram/index.ts b/src/telegram/index.ts new file mode 100644 index 000000000..89dec800d --- /dev/null +++ b/src/telegram/index.ts @@ -0,0 +1,4 @@ +export { sendMessageTelegram } from "./send.js"; +export { monitorTelegramProvider } from "./monitor.js"; +export { createTelegramBot, createTelegramWebhookCallback } from "./bot.js"; +export { startTelegramWebhook } from "./webhook.js"; diff --git a/src/telegram/monitor.test.ts b/src/telegram/monitor.test.ts new file mode 100644 index 000000000..4b43395d1 --- /dev/null +++ b/src/telegram/monitor.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; + +import { monitorTelegramProvider } from "./monitor.js"; + +// Fake bot to capture handler and API calls +const handlers: Record Promise | void> = {}; +const api = { + sendMessage: vi.fn(), + sendPhoto: vi.fn(), + sendVideo: vi.fn(), + sendAudio: vi.fn(), + sendDocument: vi.fn(), + setWebhook: vi.fn(), + deleteWebhook: vi.fn(), +}; + +vi.mock("./bot.js", () => ({ + createTelegramBot: () => { + handlers.message = async (ctx: any) => { + const chatId = ctx.message.chat.id; + const isGroup = ctx.message.chat.type !== "private"; + const text = ctx.message.text ?? ctx.message.caption ?? ""; + if (isGroup && !text.includes("@mybot")) return; + if (!text.trim()) return; + await api.sendMessage(chatId, `echo:${text}`, { parse_mode: "Markdown" }); + }; + return { + on: vi.fn(), + api, + me: { username: "mybot" }, + stop: vi.fn(), + start: vi.fn(), + }; + }, + createTelegramWebhookCallback: vi.fn(), +})); + +vi.mock("../auto-reply/reply.js", () => ({ + getReplyFromConfig: async (ctx: any) => ({ text: `echo:${ctx.Body}` }), +})); + +describe("monitorTelegramProvider (grammY)", () => { + it("processes a DM and sends reply", async () => { + Object.values(api).forEach((fn) => fn?.mockReset?.()); + await monitorTelegramProvider({ token: "tok" }); + expect(handlers.message).toBeDefined(); + await handlers.message?.({ + message: { + message_id: 1, + chat: { id: 123, type: "private" }, + text: "hi", + }, + me: { username: "mybot" }, + getFile: vi.fn(), + }); + expect(api.sendMessage).toHaveBeenCalledWith(123, "echo:hi", { + parse_mode: "Markdown", + }); + }); + + it("requires mention in groups by default", async () => { + Object.values(api).forEach((fn) => fn?.mockReset?.()); + await monitorTelegramProvider({ token: "tok" }); + await handlers.message?.({ + message: { + message_id: 2, + chat: { id: -99, type: "supergroup", title: "G" }, + text: "hello all", + }, + me: { username: "mybot" }, + getFile: vi.fn(), + }); + expect(api.sendMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts new file mode 100644 index 000000000..7c120cbca --- /dev/null +++ b/src/telegram/monitor.ts @@ -0,0 +1,63 @@ +import { Bot } from "grammy"; + +import { loadConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { createTelegramBot, createTelegramWebhookCallback } from "./bot.js"; +import { makeProxyFetch } from "./proxy.js"; +import { startTelegramWebhook } from "./webhook.js"; + +export type MonitorTelegramOpts = { + token?: string; + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; + useWebhook?: boolean; + webhookPath?: string; + webhookPort?: number; + webhookSecret?: string; + proxyFetch?: typeof fetch; + webhookUrl?: string; +}; + +export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { + const token = (opts.token ?? process.env.TELEGRAM_BOT_TOKEN)?.trim(); + if (!token) { + throw new Error("TELEGRAM_BOT_TOKEN or telegram.botToken is required for Telegram relay"); + } + + const proxyFetch = + opts.proxyFetch ?? + (loadConfig().telegram?.proxy + ? makeProxyFetch(loadConfig().telegram!.proxy as string) + : undefined); + + const bot = createTelegramBot({ + token, + runtime: opts.runtime, + proxyFetch, + }); + + if (opts.useWebhook) { + await startTelegramWebhook({ + token, + path: opts.webhookPath, + port: opts.webhookPort, + secret: opts.webhookSecret, + runtime: opts.runtime as RuntimeEnv, + fetch: proxyFetch, + abortSignal: opts.abortSignal, + publicUrl: opts.webhookUrl, + }); + return; + } + + // Long polling + const stopOnAbort = () => { + if (opts.abortSignal?.aborted) bot.stop(); + }; + opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true }); + try { + await bot.start(); + } finally { + opts.abortSignal?.removeEventListener("abort", stopOnAbort); + } +} diff --git a/src/telegram/proxy.ts b/src/telegram/proxy.ts new file mode 100644 index 000000000..d1a412c00 --- /dev/null +++ b/src/telegram/proxy.ts @@ -0,0 +1,7 @@ +import { ProxyAgent } from "undici"; + +export function makeProxyFetch(proxyUrl: string): typeof fetch { + const agent = new ProxyAgent(proxyUrl); + return (input: RequestInfo | URL, init?: RequestInit) => + fetch(input, { ...(init as any), dispatcher: agent } as RequestInit); +} diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts new file mode 100644 index 000000000..438a2419b --- /dev/null +++ b/src/telegram/send.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, afterAll, describe, expect, it, vi } from "vitest"; + +import { sendMessageTelegram } from "./send.js"; + +const originalEnv = process.env.TELEGRAM_BOT_TOKEN; +const loadWebMediaMock = vi.fn(); + +const apiMock = { + sendMessage: vi.fn(), + sendPhoto: vi.fn(), + sendVideo: vi.fn(), + sendAudio: vi.fn(), + sendDocument: vi.fn(), +}; + +vi.mock("grammy", async (orig) => { + const actual = await orig(); + return { + ...actual, + Bot: vi.fn().mockImplementation(() => ({ api: apiMock })), + InputFile: actual.InputFile, + }; +}); + +vi.mock("../web/media.js", () => ({ + loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args), +})); + +describe("sendMessageTelegram", () => { + beforeEach(() => { + vi.resetAllMocks(); + process.env.TELEGRAM_BOT_TOKEN = "token123"; + }); + + afterAll(() => { + process.env.TELEGRAM_BOT_TOKEN = originalEnv; + }); + + it("sends text and returns ids", async () => { + apiMock.sendMessage.mockResolvedValueOnce({ + message_id: 42, + chat: { id: 999 }, + }); + + const res = await sendMessageTelegram("12345", "hello", { + verbose: false, + api: apiMock as never, + }); + expect(res).toEqual({ messageId: "42", chatId: "999" }); + expect(apiMock.sendMessage).toHaveBeenCalled(); + }); + + it("throws when token missing", async () => { + process.env.TELEGRAM_BOT_TOKEN = ""; + await expect(sendMessageTelegram("1", "hi")).rejects.toThrow( + /TELEGRAM_BOT_TOKEN/, + ); + }); + + it("throws on api error", async () => { + apiMock.sendMessage.mockRejectedValueOnce(new Error("bad token")); + + await expect(sendMessageTelegram("1", "hi", { api: apiMock as never })).rejects.toThrow( + /bad token/i, + ); + }); + + it("sends media via appropriate method", async () => { + loadWebMediaMock.mockResolvedValueOnce({ + buffer: Buffer.from([1, 2, 3]), + contentType: "image/jpeg", + kind: "image", + fileName: "pic.jpg", + }); + apiMock.sendPhoto.mockResolvedValueOnce({ + message_id: 99, + chat: { id: 123 }, + }); + const res = await sendMessageTelegram("123", "hello", { + mediaUrl: "http://example.com/pic.jpg", + api: apiMock as never, + }); + expect(res).toEqual({ messageId: "99", chatId: "123" }); + expect(loadWebMediaMock).toHaveBeenCalled(); + expect(apiMock.sendPhoto).toHaveBeenCalled(); + }); +}); diff --git a/src/telegram/send.ts b/src/telegram/send.ts new file mode 100644 index 000000000..023e6751e --- /dev/null +++ b/src/telegram/send.ts @@ -0,0 +1,88 @@ +import { Bot, InputFile } from "grammy"; + +import { mediaKindFromMime } from "../media/constants.js"; +import { loadWebMedia } from "../web/media.js"; + +type TelegramSendOpts = { + token?: string; + verbose?: boolean; + mediaUrl?: string; + maxBytes?: number; + api?: Bot["api"]; +}; + +type TelegramSendResult = { + messageId: string; + chatId: string; +}; + +function resolveToken(explicit?: string): string { + const token = explicit ?? process.env.TELEGRAM_BOT_TOKEN; + if (!token) { + throw new Error("TELEGRAM_BOT_TOKEN is required for Telegram sends (Bot API)"); + } + return token.trim(); +} + +function normalizeChatId(to: string): string { + const trimmed = to.trim(); + if (!trimmed) throw new Error("Recipient is required for Telegram sends"); + if (trimmed.startsWith("@")) return trimmed; + return trimmed; +} + +export async function sendMessageTelegram( + to: string, + text: string, + opts: TelegramSendOpts = {}, +): Promise { + const token = resolveToken(opts.token); + const chatId = normalizeChatId(to); + const bot = opts.api ? null : new Bot(token); + const api = opts.api ?? bot!.api; + const mediaUrl = opts.mediaUrl?.trim(); + + if (mediaUrl) { + const media = await loadWebMedia(mediaUrl, opts.maxBytes); + const kind = mediaKindFromMime(media.contentType ?? undefined); + const file = new InputFile( + media.buffer, + media.fileName ?? inferFilename(kind) ?? "file", + ); + const caption = text?.trim() || undefined; + let result; + if (kind === "image") { + result = await api.sendPhoto(chatId, file, { caption }); + } else if (kind === "video") { + result = await api.sendVideo(chatId, file, { caption }); + } else if (kind === "audio") { + result = await api.sendAudio(chatId, file, { caption }); + } else { + result = await api.sendDocument(chatId, file, { caption }); + } + const messageId = String(result?.message_id ?? "unknown"); + return { messageId, chatId: String(result?.chat?.id ?? chatId) }; + } + + if (!text || !text.trim()) { + throw new Error("Message must be non-empty for Telegram sends"); + } + const res = await api.sendMessage(chatId, text, { + parse_mode: "Markdown", + }); + const messageId = String(res?.message_id ?? "unknown"); + return { messageId, chatId: String(res?.chat?.id ?? chatId) }; +} + +function inferFilename(kind: ReturnType) { + switch (kind) { + case "image": + return "image.jpg"; + case "video": + return "video.mp4"; + case "audio": + return "audio.ogg"; + default: + return "file.bin"; + } +} diff --git a/src/telegram/webhook-set.ts b/src/telegram/webhook-set.ts new file mode 100644 index 000000000..5c1811b5d --- /dev/null +++ b/src/telegram/webhook-set.ts @@ -0,0 +1,19 @@ +import { Bot } from "grammy"; + +export async function setTelegramWebhook(opts: { + token: string; + url: string; + secret?: string; + dropPendingUpdates?: boolean; +}) { + const bot = new Bot(opts.token); + await bot.api.setWebhook(opts.url, { + secret_token: opts.secret, + drop_pending_updates: opts.dropPendingUpdates ?? false, + }); +} + +export async function deleteTelegramWebhook(opts: { token: string }) { + const bot = new Bot(opts.token); + await bot.api.deleteWebhook(); +} diff --git a/src/telegram/webhook.ts b/src/telegram/webhook.ts new file mode 100644 index 000000000..430db3f6e --- /dev/null +++ b/src/telegram/webhook.ts @@ -0,0 +1,69 @@ +import { createServer } from "node:http"; + +import { webhookCallback } from "grammy"; + +import type { RuntimeEnv } from "../runtime.js"; +import { defaultRuntime } from "../runtime.js"; +import { createTelegramBot } from "./bot.js"; + +export async function startTelegramWebhook(opts: { + token: string; + path?: string; + port?: number; + host?: string; + secret?: string; + runtime?: RuntimeEnv; + fetch?: typeof fetch; + abortSignal?: AbortSignal; + healthPath?: string; + publicUrl?: string; +}) { + const path = opts.path ?? "/telegram-webhook"; + const healthPath = opts.healthPath ?? "/healthz"; + const port = opts.port ?? 8787; + const host = opts.host ?? "0.0.0.0"; + const runtime = opts.runtime ?? defaultRuntime; + const bot = createTelegramBot({ + token: opts.token, + runtime, + proxyFetch: opts.fetch, + }); + const handler = webhookCallback(bot, "http", { + secretToken: opts.secret, + }); + + const server = createServer((req, res) => { + if (req.url === healthPath) { + res.writeHead(200); + res.end("ok"); + return; + } + if (req.url !== path || req.method !== "POST") { + res.writeHead(404); + res.end(); + return; + } + handler(req, res); + }); + + const publicUrl = + opts.publicUrl ?? + `http://${host === "0.0.0.0" ? "localhost" : host}:${port}${path}`; + + await bot.api.setWebhook(publicUrl, { + secret_token: opts.secret, + }); + + await new Promise((resolve) => server.listen(port, host, resolve)); + runtime.log?.(`Telegram webhook listening on ${publicUrl}`); + + const shutdown = () => { + server.close(); + bot.stop(); + }; + if (opts.abortSignal) { + opts.abortSignal.addEventListener("abort", shutdown, { once: true }); + } + + return { server, bot, stop: shutdown }; +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..cb82c3c5a --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + exclude: [ + "dist/**", + "apps/macos/**", + "apps/macos/.build/**", + "**/vendor/**", + "dist/Clawdis.app/**", + ], + coverage: { + provider: "v8", + reporter: ["text", "lcov"], + thresholds: { + lines: 70, + functions: 70, + branches: 70, + statements: 70, + }, + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts"], + }, + }, +});