Add Bun bundle docs and Telegram grammY support
This commit is contained in:
30
docs/mac/bun.md
Normal file
30
docs/mac/bun.md
Normal file
@@ -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 <subcommand>` 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.
|
||||||
62
docs/telegram.md
Normal file
62
docs/telegram.md
Normal file
@@ -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:<chatId>`.
|
||||||
|
- 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 <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:<chatId>` 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.
|
||||||
13
package.json
13
package.json
@@ -33,6 +33,7 @@
|
|||||||
"@mariozechner/pi-coding-agent": "^0.13.2",
|
"@mariozechner/pi-coding-agent": "^0.13.2",
|
||||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
|
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
@@ -42,7 +43,9 @@
|
|||||||
"tslog": "^4.9.3",
|
"tslog": "^4.9.3",
|
||||||
"qrcode-terminal": "^0.12.0",
|
"qrcode-terminal": "^0.12.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"zod": "^4.1.13"
|
"undici": "^6.20.1",
|
||||||
|
"zod": "^4.1.13",
|
||||||
|
"grammy": "^1.27.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.8",
|
"@biomejs/biome": "^2.3.8",
|
||||||
@@ -82,10 +85,16 @@
|
|||||||
"src/**/*.test.ts"
|
"src/**/*.test.ts"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.test.ts"
|
||||||
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"dist/**",
|
"dist/**",
|
||||||
"apps/macos/**",
|
"apps/macos/**",
|
||||||
"apps/macos/.build/**"
|
"apps/macos/.build/**",
|
||||||
|
"**/vendor/**",
|
||||||
|
"apps/macos/.build/**",
|
||||||
|
"dist/Clawdis.app/**"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,17 +117,32 @@ pnpm install \
|
|||||||
--config.enable-pre-post-scripts=true \
|
--config.enable-pre-post-scripts=true \
|
||||||
--config.ignore-workspace-root-check=true \
|
--config.ignore-workspace-root-check=true \
|
||||||
--config.shared-workspace-lockfile=false \
|
--config.shared-workspace-lockfile=false \
|
||||||
--lockfile-dir "$ROOT_DIR" \
|
--config.node-linker=hoisted \
|
||||||
|
--lockfile-dir "$TMP_DEPLOY" \
|
||||||
--dir "$TMP_DEPLOY"
|
--dir "$TMP_DEPLOY"
|
||||||
PNPM_STORE_DIR="$TMP_DEPLOY/.pnpm-store" \
|
PNPM_STORE_DIR="$TMP_DEPLOY/.pnpm-store" \
|
||||||
PNPM_HOME="$HOME/Library/pnpm" \
|
PNPM_HOME="$HOME/Library/pnpm" \
|
||||||
pnpm rebuild sharp --config.ignore-workspace-root-check=true --dir "$TMP_DEPLOY"
|
pnpm rebuild sharp --config.ignore-workspace-root-check=true --dir "$TMP_DEPLOY"
|
||||||
rsync -aL "$TMP_DEPLOY/node_modules/" "$RELAY_DIR/node_modules/"
|
rsync -a "$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
|
# Keep only the arm64 macOS sharp vendor payloads to shrink the bundle
|
||||||
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
|
SHARP_VENDOR_DIR="$RELAY_DIR/node_modules/@img"
|
||||||
rm -rf "$RELAY_DIR/node_modules/.pnpm"/*sharp* "$RELAY_DIR/node_modules/.pnpm/node_modules/@img" 2>/dev/null || true
|
if [ -d "$SHARP_VENDOR_DIR" ]; then
|
||||||
rm -f "$RELAY_DIR/node_modules/.bin"/vite "$RELAY_DIR/node_modules/.bin"/rolldown "$RELAY_DIR/node_modules/.bin"/biome 2>/dev/null || true
|
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"
|
rm -rf "$TMP_DEPLOY"
|
||||||
|
|
||||||
if [ -f "$CLI_BIN" ]; then
|
if [ -f "$CLI_BIN" ]; then
|
||||||
|
|||||||
233
src/telegram/bot.ts
Normal file
233
src/telegram/bot.ts
Normal file
@@ -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<string | number>;
|
||||||
|
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 = "<media:document>";
|
||||||
|
if (msg.photo) placeholder = "<media:image>";
|
||||||
|
else if (msg.video) placeholder = "<media:video>";
|
||||||
|
else if (msg.audio || msg.voice) placeholder = "<media:audio>";
|
||||||
|
return { path: saved.path, contentType: saved.contentType, placeholder };
|
||||||
|
}
|
||||||
37
src/telegram/download.test.ts
Normal file
37
src/telegram/download.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
50
src/telegram/download.ts
Normal file
50
src/telegram/download.ts
Normal file
@@ -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<TelegramFileInfo> {
|
||||||
|
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<SavedMedia> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
4
src/telegram/index.ts
Normal file
4
src/telegram/index.ts
Normal file
@@ -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";
|
||||||
75
src/telegram/monitor.test.ts
Normal file
75
src/telegram/monitor.test.ts
Normal file
@@ -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<string, (ctx: any) => Promise<void> | 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
63
src/telegram/monitor.ts
Normal file
63
src/telegram/monitor.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/telegram/proxy.ts
Normal file
7
src/telegram/proxy.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
87
src/telegram/send.test.ts
Normal file
87
src/telegram/send.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
88
src/telegram/send.ts
Normal file
88
src/telegram/send.ts
Normal file
@@ -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<TelegramSendResult> {
|
||||||
|
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<typeof mediaKindFromMime>) {
|
||||||
|
switch (kind) {
|
||||||
|
case "image":
|
||||||
|
return "image.jpg";
|
||||||
|
case "video":
|
||||||
|
return "video.mp4";
|
||||||
|
case "audio":
|
||||||
|
return "audio.ogg";
|
||||||
|
default:
|
||||||
|
return "file.bin";
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/telegram/webhook-set.ts
Normal file
19
src/telegram/webhook-set.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
69
src/telegram/webhook.ts
Normal file
69
src/telegram/webhook.ts
Normal file
@@ -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<void>((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 };
|
||||||
|
}
|
||||||
26
vitest.config.ts
Normal file
26
vitest.config.ts
Normal file
@@ -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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user