diff --git a/CHANGELOG.md b/CHANGELOG.md index 30a185e68..9ba49a2ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,11 @@ Docs: https://docs.clawd.bot Status: unreleased. ### Changes +- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281) +- Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido. - Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos. - Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz. +- Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro. - Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng. - Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr. - Skills: add missing dependency metadata for GitHub, Notion, Slack, Discord. (#1995) Thanks @jackheuberger. @@ -16,9 +19,10 @@ Status: unreleased. - Docs: add DigitalOcean deployment guide. (#1870) Thanks @0xJonHoldsCrypto. - Docs: add Raspberry Pi install guide. (#1871) Thanks @0xJonHoldsCrypto. - Docs: add GCP Compute Engine deployment guide. (#1848) Thanks @hougangdev. -- Docs: add LINE channel guide. +- Docs: add LINE channel guide. Thanks @thewilloftheshadow. - Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD. - Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub. +- Onboarding: strengthen security warning copy for beta + access control expectations. - Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a. - Gateway: prefer newest session metadata when combining stores. (#1823) Thanks @emanuelst. - Web UI: keep sub-agent announce replies visible in WebChat. (#1977) Thanks @andrescardonas7. @@ -27,6 +31,7 @@ Status: unreleased. - Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev. - Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg. - Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra. +- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon. - Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco. - Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla. - Routing: precompile session key regexes. (#1697) Thanks @Ray0907. @@ -50,6 +55,7 @@ Status: unreleased. ## 2026.1.24-3 ### Fixes +- Slack: fix image downloads failing due to missing Authorization header on cross-origin redirects. (#1936) Thanks @sanderhelgesen. - Gateway: harden reverse proxy handling for local-client detection and unauthenticated proxied connects. (#1795) Thanks @orlyjamie. - Security audit: flag loopback Control UI with auth disabled as critical. (#1795) Thanks @orlyjamie. - CLI: resume claude-cli sessions and stream CLI replies to TUI clients. (#1921) Thanks @rmorse. diff --git a/README.md b/README.md index 217a4b61c..535cd1c75 100644 --- a/README.md +++ b/README.md @@ -479,32 +479,33 @@ Thanks to all clawtributors:

steipete plum-dawg bohdanpodvirnyi iHildy joaohlisboa mneves75 MatthieuBizien MaudeBot Glucksberg rahthakor vrknetha radek-paclt Tobias Bischoff joshp123 czekaj mukhtharcm sebslight maxsumrall xadenryan rodrigouroz - juanpablodlc hsrvc magimetal meaningfool tyler6204 patelhiren NicholasSpisak jonisjongithub zerone0x abhisekbasu1 + juanpablodlc hsrvc magimetal zerone0x meaningfool tyler6204 patelhiren NicholasSpisak jonisjongithub abhisekbasu1 jamesgroat claude JustYannicc Hyaxia dantelex SocialNerd42069 daveonkels google-labs-jules[bot] lc0rp mousberg - vignesh07 mteam88 dbhurley Mariano Belinky Eng. Juan Combetto TSavo julianengel bradleypriest benithors rohannagpal - timolins f-trycua benostein nachx639 pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino - Vasanth Rao Naik Sabavat petter-b cpojer scald gumadeiras andranik-sahakyan davidguttman sleontenko denysvitali orlyjamie - thewilloftheshadow sircrumpet peschee rafaelreis-r ratulsarna lutr0 danielz1z emanuelst KristijanJovanovski rdev - joshrad-dev kiranjd osolmaz adityashaw2 CashWilliams sheeek artuskg Takhoffman onutc pauloportella - neooriginal manuelhettich minghinmatthewlam myfunc travisirby buddyh connorshea kyleok mcinteerj dependabot[bot] - John-Rood timkrase uos-status gerardward2007 obviyus roshanasingh4 tosh-hamburg azade-c JonUleis bjesuiter - cheeeee Josh Phillips robbyczgw-cla dlauer pookNast Whoaa512 YuriNachos chriseidhof ngutman ysqander - aj47 superman32432432 Yurii Chukhlib grp06 antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr HeimdallStrategy - imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures Ryan Lisse dougvk erikpr1994 - Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist sibbl chrisrodz - Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Kit koala73 manmal ogulcancelik pasogott petradonka - rubyrunsstuff siddhantjain suminhthanh svkozak VACInc wes-davis zats 24601 adam91holt ameno- - Chris Taylor Django Navarro evalexpr henrino3 humanwritten larlyssa odysseus0 oswalpalash pcty-nextgen-service-account rmorse - Syhids Aaron Konyer aaronveklabs andreabadesso Andrii cash-echo-bot Clawd ClawdFx dguido EnzeD - erik-agens Evizero fcatuhe itsjaydesu ivancasco ivanrvpereira jayhickey jeffersonwarrior jeffersonwarrior jverdi - longmaba mickahouan mjrussell odnxe p6l-richard philipp-spiess robaxelsen Sash Catanzarite T5-AndyML travisp - VAC william arzt zknicker abhaymundhara alejandro maza andrewting19 anpoirier arthyn Asleep123 bolismauro - conhecendoia dasilva333 Developer Dimitrios Ploutarchos Drake Thomsen fal3 Felix Krause foeken ganghyun kim grrowl - gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jarvis Jefferson Nunn Kevin Lin kitze levifig - Lloyd loukotal louzhixian martinpucik Matt mini Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman - nexty5870 Noctivoro prathamdby ptn1411 reeltimeapps RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha senoldogann - Seredeep sergical shiv19 shiyuanhai siraht snopoke testingabc321 The Admiral thesash Ubuntu - voidserf Vultr-Clawd Admin Wimmie wstock yazinsai ymat19 Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee - atalovesyou Azade carlulsoe ddyo Erik hougangdev latitudeki5223 Manuel Maly Mourad Boustani odrobnik - pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani William Stock + vignesh07 mteam88 joeynyc orlyjamie dbhurley Mariano Belinky Eng. Juan Combetto TSavo julianengel bradleypriest + benithors rohannagpal timolins f-trycua benostein nachx639 pvoo sreekaransrinath gupsammy cristip73 + stefangalescu nachoiacovino Vasanth Rao Naik Sabavat petter-b cpojer scald gumadeiras andranik-sahakyan davidguttman sleontenko + denysvitali thewilloftheshadow shakkernerd sircrumpet peschee rafaelreis-r ratulsarna lutr0 danielz1z emanuelst + KristijanJovanovski rdev rhuanssauro joshrad-dev kiranjd osolmaz adityashaw2 CashWilliams sheeek artuskg + Takhoffman onutc pauloportella neooriginal manuelhettich minghinmatthewlam myfunc travisirby buddyh connorshea + kyleok mcinteerj dependabot[bot] John-Rood timkrase uos-status gerardward2007 obviyus roshanasingh4 tosh-hamburg + azade-c JonUleis bjesuiter cheeeee Josh Phillips YuriNachos robbyczgw-cla dlauer pookNast Whoaa512 + chriseidhof ngutman ysqander aj47 superman32432432 Yurii Chukhlib grp06 antons austinm911 blacksmith-sh[bot] + damoahdominic dan-dr HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures + Ryan Lisse dougvk erikpr1994 Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr + neist sibbl chrisrodz Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Kit koala73 manmal + ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain suminhthanh svkozak VACInc wes-davis zats + 24601 adam91holt ameno- Chris Taylor Django Navarro evalexpr henrino3 humanwritten larlyssa odysseus0 + oswalpalash pcty-nextgen-service-account rmorse Syhids Aaron Konyer aaronveklabs andreabadesso Andrii cash-echo-bot Clawd + ClawdFx dguido EnzeD erik-agens Evizero fcatuhe itsjaydesu ivancasco ivanrvpereira jayhickey + jeffersonwarrior jeffersonwarrior jverdi longmaba mickahouan mjrussell odnxe p6l-richard philipp-spiess robaxelsen + Sash Catanzarite T5-AndyML travisp VAC william arzt zknicker abhaymundhara alejandro maza Alex-Alaniz andrewting19 + anpoirier arthyn Asleep123 bolismauro conhecendoia dasilva333 Developer Dimitrios Ploutarchos Drake Thomsen fal3 + Felix Krause foeken ganghyun kim grrowl gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jarvis + Jefferson Nunn Kevin Lin kitze levifig Lloyd loukotal louzhixian martinpucik Matt mini mertcicekci0 + Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman nexty5870 Noctivoro prathamdby ptn1411 reeltimeapps + RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha senoldogann Seredeep sergical shiv19 shiyuanhai siraht + snopoke testingabc321 The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin Wimmie wstock yazinsai + ymat19 Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik + hougangdev latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani + William Stock

diff --git a/docs/platforms/fly.md b/docs/platforms/fly.md index 0fdf176ae..dee731ea7 100644 --- a/docs/platforms/fly.md +++ b/docs/platforms/fly.md @@ -39,7 +39,9 @@ fly volumes create clawdbot_data --size 1 --region iad ## 2) Configure fly.toml -Edit `fly.toml` to match your app name and requirements: +Edit `fly.toml` to match your app name and requirements. + +**Security note:** The default config exposes a public URL. For a hardened deployment with no public IP, see [Private Deployment](#private-deployment-hardened) or use `fly.private.toml`. ```toml app = "my-clawdbot" # Your app name @@ -104,6 +106,7 @@ fly secrets set DISCORD_BOT_TOKEN=MTQ... **Notes:** - Non-loopback binds (`--bind lan`) require `CLAWDBOT_GATEWAY_TOKEN` for security. - Treat these tokens like passwords. +- **Prefer env vars over config file** for all API keys and tokens. This keeps secrets out of `clawdbot.json` where they could be accidentally exposed or logged. ## 4) Deploy @@ -337,6 +340,114 @@ fly machine update --vm-memory 2048 --command "node dist/index.js g **Note:** After `fly deploy`, the machine command may reset to what's in `fly.toml`. If you made manual changes, re-apply them after deploy. +## Private Deployment (Hardened) + +By default, Fly allocates public IPs, making your gateway accessible at `https://your-app.fly.dev`. This is convenient but means your deployment is discoverable by internet scanners (Shodan, Censys, etc.). + +For a hardened deployment with **no public exposure**, use the private template. + +### When to use private deployment + +- You only make **outbound** calls/messages (no inbound webhooks) +- You use **ngrok or Tailscale** tunnels for any webhook callbacks +- You access the gateway via **SSH, proxy, or WireGuard** instead of browser +- You want the deployment **hidden from internet scanners** + +### Setup + +Use `fly.private.toml` instead of the standard config: + +```bash +# Deploy with private config +fly deploy -c fly.private.toml +``` + +Or convert an existing deployment: + +```bash +# List current IPs +fly ips list -a my-clawdbot + +# Release public IPs +fly ips release -a my-clawdbot +fly ips release -a my-clawdbot + +# Switch to private config so future deploys don't re-allocate public IPs +# (remove [http_service] or deploy with the private template) +fly deploy -c fly.private.toml + +# Allocate private-only IPv6 +fly ips allocate-v6 --private -a my-clawdbot +``` + +After this, `fly ips list` should show only a `private` type IP: +``` +VERSION IP TYPE REGION +v6 fdaa:x:x:x:x::x private global +``` + +### Accessing a private deployment + +Since there's no public URL, use one of these methods: + +**Option 1: Local proxy (simplest)** +```bash +# Forward local port 3000 to the app +fly proxy 3000:3000 -a my-clawdbot + +# Then open http://localhost:3000 in browser +``` + +**Option 2: WireGuard VPN** +```bash +# Create WireGuard config (one-time) +fly wireguard create + +# Import to WireGuard client, then access via internal IPv6 +# Example: http://[fdaa:x:x:x:x::x]:3000 +``` + +**Option 3: SSH only** +```bash +fly ssh console -a my-clawdbot +``` + +### Webhooks with private deployment + +If you need webhook callbacks (Twilio, Telnyx, etc.) without public exposure: + +1. **ngrok tunnel** - Run ngrok inside the container or as a sidecar +2. **Tailscale Funnel** - Expose specific paths via Tailscale +3. **Outbound-only** - Some providers (Twilio) work fine for outbound calls without webhooks + +Example voice-call config with ngrok: +```json +{ + "plugins": { + "entries": { + "voice-call": { + "enabled": true, + "config": { + "provider": "twilio", + "tunnel": { "provider": "ngrok" } + } + } + } + } +} +``` + +The ngrok tunnel runs inside the container and provides a public webhook URL without exposing the Fly app itself. + +### Security benefits + +| Aspect | Public | Private | +|--------|--------|---------| +| Internet scanners | Discoverable | Hidden | +| Direct attacks | Possible | Blocked | +| Control UI access | Browser | Proxy/VPN | +| Webhook delivery | Direct | Via tunnel | + ## Notes - Fly.io uses **x86 architecture** (not ARM) diff --git a/fly.private.toml b/fly.private.toml new file mode 100644 index 000000000..6edbc8005 --- /dev/null +++ b/fly.private.toml @@ -0,0 +1,39 @@ +# Clawdbot Fly.io PRIVATE deployment configuration +# Use this template for hardened deployments with no public IP exposure. +# +# This config is suitable when: +# - You only make outbound calls (no inbound webhooks needed) +# - You use ngrok/Tailscale tunnels for any webhook callbacks +# - You access the gateway via `fly proxy` or WireGuard, not public URL +# - You want the deployment hidden from internet scanners (Shodan, etc.) +# +# See https://fly.io/docs/reference/configuration/ + +app = "my-clawdbot" # change to your app name +primary_region = "iad" # change to your closest region + +[build] + dockerfile = "Dockerfile" + +[env] + NODE_ENV = "production" + CLAWDBOT_PREFER_PNPM = "1" + CLAWDBOT_STATE_DIR = "/data" + NODE_OPTIONS = "--max-old-space-size=1536" + +[processes] + app = "node dist/index.js gateway --allow-unconfigured --port 3000 --bind lan" + +# NOTE: No [http_service] block = no public ingress allocated. +# The gateway will only be accessible via: +# - fly proxy 3000:3000 -a +# - fly wireguard (then access via internal IPv6) +# - fly ssh console + +[[vm]] + size = "shared-cpu-2x" + memory = "2048mb" + +[mounts] + source = "clawdbot_data" + destination = "/data" diff --git a/src/agents/pi-tools.safe-bins.test.ts b/src/agents/pi-tools.safe-bins.test.ts new file mode 100644 index 000000000..43202bbb5 --- /dev/null +++ b/src/agents/pi-tools.safe-bins.test.ts @@ -0,0 +1,78 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import type { ClawdbotConfig } from "../config/config.js"; +import type { ExecApprovalsResolved } from "../infra/exec-approvals.js"; +import { createClawdbotCodingTools } from "./pi-tools.js"; + +vi.mock("../infra/exec-approvals.js", async (importOriginal) => { + const mod = await importOriginal(); + const approvals: ExecApprovalsResolved = { + path: "/tmp/exec-approvals.json", + socketPath: "/tmp/exec-approvals.sock", + token: "token", + defaults: { + security: "allowlist", + ask: "off", + askFallback: "deny", + autoAllowSkills: false, + }, + agent: { + security: "allowlist", + ask: "off", + askFallback: "deny", + autoAllowSkills: false, + }, + allowlist: [], + file: { + version: 1, + socket: { path: "/tmp/exec-approvals.sock", token: "token" }, + defaults: { + security: "allowlist", + ask: "off", + askFallback: "deny", + autoAllowSkills: false, + }, + agents: {}, + }, + }; + return { ...mod, resolveExecApprovals: () => approvals }; +}); + +describe("createClawdbotCodingTools safeBins", () => { + it("threads tools.exec.safeBins into exec allowlist checks", async () => { + if (process.platform === "win32") return; + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-safe-bins-")); + const cfg: ClawdbotConfig = { + tools: { + exec: { + host: "gateway", + security: "allowlist", + ask: "off", + safeBins: ["echo"], + }, + }, + }; + + const tools = createClawdbotCodingTools({ + config: cfg, + sessionKey: "agent:main:main", + workspaceDir: tmpDir, + agentDir: path.join(tmpDir, "agent"), + }); + const execTool = tools.find((tool) => tool.name === "exec"); + expect(execTool).toBeDefined(); + + const marker = `safe-bins-${Date.now()}`; + const result = await execTool!.execute("call1", { + command: `echo ${marker}`, + workdir: tmpDir, + }); + const text = result.content.find((content) => content.type === "text")?.text ?? ""; + + expect(result.details.status).toBe("completed"); + expect(text).toContain(marker); + }); +}); diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index bd745da03..9013f1e52 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -86,6 +86,7 @@ function resolveExecConfig(cfg: ClawdbotConfig | undefined) { ask: globalExec?.ask, node: globalExec?.node, pathPrepend: globalExec?.pathPrepend, + safeBins: globalExec?.safeBins, backgroundMs: globalExec?.backgroundMs, timeoutSec: globalExec?.timeoutSec, approvalRunningNoticeMs: globalExec?.approvalRunningNoticeMs, @@ -235,6 +236,7 @@ export function createClawdbotCodingTools(options?: { ask: options?.exec?.ask ?? execConfig.ask, node: options?.exec?.node ?? execConfig.node, pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend, + safeBins: options?.exec?.safeBins ?? execConfig.safeBins, agentId, cwd: options?.workspaceDir, allowBackground, diff --git a/src/agents/tools/discord-actions-guild.ts b/src/agents/tools/discord-actions-guild.ts index 0994829bd..26e21c82e 100644 --- a/src/agents/tools/discord-actions-guild.ts +++ b/src/agents/tools/discord-actions-guild.ts @@ -1,5 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { DiscordActionConfig } from "../../config/config.js"; +import { getPresence } from "../../discord/monitor/presence-cache.js"; import { addRoleDiscord, createChannelDiscord, @@ -54,7 +55,10 @@ export async function handleDiscordGuildAction( const member = accountId ? await fetchMemberInfoDiscord(guildId, userId, { accountId }) : await fetchMemberInfoDiscord(guildId, userId); - return jsonResult({ ok: true, member }); + const presence = getPresence(accountId, userId); + const activities = presence?.activities ?? undefined; + const status = presence?.status ?? undefined; + return jsonResult({ ok: true, member, ...(presence ? { status, activities } : {}) }); } case "roleInfo": { if (!isActionEnabled("roleInfo")) { diff --git a/src/channels/plugins/outbound/telegram.test.ts b/src/channels/plugins/outbound/telegram.test.ts new file mode 100644 index 000000000..3bbab0cee --- /dev/null +++ b/src/channels/plugins/outbound/telegram.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ClawdbotConfig } from "../../../config/config.js"; +import { telegramOutbound } from "./telegram.js"; + +describe("telegramOutbound.sendPayload", () => { + it("sends text payload with buttons", async () => { + const sendTelegram = vi.fn(async () => ({ messageId: "m1", chatId: "c1" })); + + const result = await telegramOutbound.sendPayload?.({ + cfg: {} as ClawdbotConfig, + to: "telegram:123", + text: "ignored", + payload: { + text: "Hello", + channelData: { + telegram: { + buttons: [[{ text: "Option", callback_data: "/option" }]], + }, + }, + }, + deps: { sendTelegram }, + }); + + expect(sendTelegram).toHaveBeenCalledTimes(1); + expect(sendTelegram).toHaveBeenCalledWith( + "telegram:123", + "Hello", + expect.objectContaining({ + buttons: [[{ text: "Option", callback_data: "/option" }]], + textMode: "html", + }), + ); + expect(result).toEqual({ channel: "telegram", messageId: "m1", chatId: "c1" }); + }); + + it("sends media payloads and attaches buttons only to first", async () => { + const sendTelegram = vi + .fn() + .mockResolvedValueOnce({ messageId: "m1", chatId: "c1" }) + .mockResolvedValueOnce({ messageId: "m2", chatId: "c1" }); + + const result = await telegramOutbound.sendPayload?.({ + cfg: {} as ClawdbotConfig, + to: "telegram:123", + text: "ignored", + payload: { + text: "Caption", + mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], + channelData: { + telegram: { + buttons: [[{ text: "Go", callback_data: "/go" }]], + }, + }, + }, + deps: { sendTelegram }, + }); + + expect(sendTelegram).toHaveBeenCalledTimes(2); + expect(sendTelegram).toHaveBeenNthCalledWith( + 1, + "telegram:123", + "Caption", + expect.objectContaining({ + mediaUrl: "https://example.com/a.png", + buttons: [[{ text: "Go", callback_data: "/go" }]], + }), + ); + const secondOpts = sendTelegram.mock.calls[1]?.[2] as { buttons?: unknown } | undefined; + expect(sendTelegram).toHaveBeenNthCalledWith( + 2, + "telegram:123", + "", + expect.objectContaining({ + mediaUrl: "https://example.com/b.png", + }), + ); + expect(secondOpts?.buttons).toBeUndefined(); + expect(result).toEqual({ channel: "telegram", messageId: "m2", chatId: "c1" }); + }); +}); diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index 9b138705a..6db7afd28 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -18,6 +18,7 @@ function parseThreadId(threadId?: string | number | null) { const parsed = Number.parseInt(trimmed, 10); return Number.isFinite(parsed) ? parsed : undefined; } + export const telegramOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: markdownToTelegramHtmlChunks, @@ -50,4 +51,46 @@ export const telegramOutbound: ChannelOutboundAdapter = { }); return { channel: "telegram", ...result }; }, + sendPayload: async ({ to, payload, accountId, deps, replyToId, threadId }) => { + const send = deps?.sendTelegram ?? sendMessageTelegram; + const replyToMessageId = parseReplyToMessageId(replyToId); + const messageThreadId = parseThreadId(threadId); + const telegramData = payload.channelData?.telegram as + | { buttons?: Array> } + | undefined; + const text = payload.text ?? ""; + const mediaUrls = payload.mediaUrls?.length + ? payload.mediaUrls + : payload.mediaUrl + ? [payload.mediaUrl] + : []; + const baseOpts = { + verbose: false, + textMode: "html" as const, + messageThreadId, + replyToMessageId, + accountId: accountId ?? undefined, + }; + + if (mediaUrls.length === 0) { + const result = await send(to, text, { + ...baseOpts, + buttons: telegramData?.buttons, + }); + return { channel: "telegram", ...result }; + } + + // Telegram allows reply_markup on media; attach buttons only to first send. + let finalResult: Awaited> | undefined; + for (let i = 0; i < mediaUrls.length; i += 1) { + const mediaUrl = mediaUrls[i]; + const isFirst = i === 0; + finalResult = await send(to, isFirst ? text : "", { + ...baseOpts, + mediaUrl, + ...(isFirst ? { buttons: telegramData?.buttons } : {}), + }); + } + return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) }; + }, }; diff --git a/src/config/schema.ts b/src/config/schema.ts index f33c8a47e..9e93ec334 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -322,6 +322,8 @@ const FIELD_LABELS: Record = { "channels.discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)", "channels.discord.retry.jitter": "Discord Retry Jitter", "channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message", + "channels.discord.intents.presence": "Discord Presence Intent", + "channels.discord.intents.guildMembers": "Discord Guild Members Intent", "channels.slack.dm.policy": "Slack DM Policy", "channels.slack.allowBots": "Slack Allow Bot Messages", "channels.discord.token": "Discord Bot Token", @@ -659,6 +661,10 @@ const FIELD_HELP: Record = { "channels.discord.retry.maxDelayMs": "Maximum retry delay cap in ms for Discord outbound calls.", "channels.discord.retry.jitter": "Jitter factor (0-1) applied to Discord retry delays.", "channels.discord.maxLinesPerMessage": "Soft max line count per Discord message (default: 17).", + "channels.discord.intents.presence": + "Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.", + "channels.discord.intents.guildMembers": + "Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.", "channels.slack.dm.policy": 'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].', }; diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 071d6e6a7..70ea5f1fb 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -72,6 +72,13 @@ export type DiscordActionConfig = { channels?: boolean; }; +export type DiscordIntentsConfig = { + /** Enable Guild Presences privileged intent (requires Portal opt-in). Default: false. */ + presence?: boolean; + /** Enable Guild Members privileged intent (requires Portal opt-in). Default: false. */ + guildMembers?: boolean; +}; + export type DiscordExecApprovalConfig = { /** Enable exec approval forwarding to Discord DMs. Default: false. */ enabled?: boolean; @@ -139,6 +146,8 @@ export type DiscordAccountConfig = { heartbeat?: ChannelHeartbeatVisibilityConfig; /** Exec approval forwarding configuration. */ execApprovals?: DiscordExecApprovalConfig; + /** Privileged Gateway Intents (must also be enabled in Discord Developer Portal). */ + intents?: DiscordIntentsConfig; }; export type DiscordConfig = { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 4b1b9338a..374e6e8aa 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -256,6 +256,13 @@ export const DiscordAccountSchema = z }) .strict() .optional(), + intents: z + .object({ + presence: z.boolean().optional(), + guildMembers: z.boolean().optional(), + }) + .strict() + .optional(), }) .strict(); diff --git a/src/discord/monitor.slash.test.ts b/src/discord/monitor.slash.test.ts index a6c43087d..d5488cb98 100644 --- a/src/discord/monitor.slash.test.ts +++ b/src/discord/monitor.slash.test.ts @@ -16,6 +16,7 @@ vi.mock("@buape/carbon", () => ({ MessageCreateListener: class {}, MessageReactionAddListener: class {}, MessageReactionRemoveListener: class {}, + PresenceUpdateListener: class {}, Row: class { constructor(_components: unknown[]) {} }, diff --git a/src/discord/monitor/listeners.ts b/src/discord/monitor/listeners.ts index 0eb5e2e8e..770ae6d6c 100644 --- a/src/discord/monitor/listeners.ts +++ b/src/discord/monitor/listeners.ts @@ -4,11 +4,13 @@ import { MessageCreateListener, MessageReactionAddListener, MessageReactionRemoveListener, + PresenceUpdateListener, } from "@buape/carbon"; import { danger } from "../../globals.js"; import { formatDurationSeconds } from "../../infra/format-duration.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; +import { setPresence } from "./presence-cache.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { @@ -269,3 +271,34 @@ async function handleDiscordReactionEvent(params: { params.logger.error(danger(`discord reaction handler failed: ${String(err)}`)); } } + +type PresenceUpdateEvent = Parameters[0]; + +export class DiscordPresenceListener extends PresenceUpdateListener { + private logger?: Logger; + private accountId?: string; + + constructor(params: { logger?: Logger; accountId?: string }) { + super(); + this.logger = params.logger; + this.accountId = params.accountId; + } + + async handle(data: PresenceUpdateEvent) { + try { + const userId = + "user" in data && data.user && typeof data.user === "object" && "id" in data.user + ? String(data.user.id) + : undefined; + if (!userId) return; + setPresence( + this.accountId, + userId, + data as import("discord-api-types/v10").GatewayPresenceUpdate, + ); + } catch (err) { + const logger = this.logger ?? discordEventQueueLog; + logger.error(danger(`discord presence handler failed: ${String(err)}`)); + } + } +} diff --git a/src/discord/monitor/presence-cache.test.ts b/src/discord/monitor/presence-cache.test.ts new file mode 100644 index 000000000..8cdf8cefa --- /dev/null +++ b/src/discord/monitor/presence-cache.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { GatewayPresenceUpdate } from "discord-api-types/v10"; +import { + clearPresences, + getPresence, + presenceCacheSize, + setPresence, +} from "./presence-cache.js"; + +describe("presence-cache", () => { + beforeEach(() => { + clearPresences(); + }); + + it("scopes presence entries by account", () => { + const presenceA = { status: "online" } as GatewayPresenceUpdate; + const presenceB = { status: "idle" } as GatewayPresenceUpdate; + + setPresence("account-a", "user-1", presenceA); + setPresence("account-b", "user-1", presenceB); + + expect(getPresence("account-a", "user-1")).toBe(presenceA); + expect(getPresence("account-b", "user-1")).toBe(presenceB); + expect(getPresence("account-a", "user-2")).toBeUndefined(); + }); + + it("clears presence per account", () => { + const presence = { status: "dnd" } as GatewayPresenceUpdate; + + setPresence("account-a", "user-1", presence); + setPresence("account-b", "user-2", presence); + + clearPresences("account-a"); + + expect(getPresence("account-a", "user-1")).toBeUndefined(); + expect(getPresence("account-b", "user-2")).toBe(presence); + expect(presenceCacheSize()).toBe(1); + }); +}); diff --git a/src/discord/monitor/presence-cache.ts b/src/discord/monitor/presence-cache.ts new file mode 100644 index 000000000..e112297e8 --- /dev/null +++ b/src/discord/monitor/presence-cache.ts @@ -0,0 +1,52 @@ +import type { GatewayPresenceUpdate } from "discord-api-types/v10"; + +/** + * In-memory cache of Discord user presence data. + * Populated by PRESENCE_UPDATE gateway events when the GuildPresences intent is enabled. + */ +const presenceCache = new Map>(); + +function resolveAccountKey(accountId?: string): string { + return accountId ?? "default"; +} + +/** Update cached presence for a user. */ +export function setPresence( + accountId: string | undefined, + userId: string, + data: GatewayPresenceUpdate, +): void { + const accountKey = resolveAccountKey(accountId); + let accountCache = presenceCache.get(accountKey); + if (!accountCache) { + accountCache = new Map(); + presenceCache.set(accountKey, accountCache); + } + accountCache.set(userId, data); +} + +/** Get cached presence for a user. Returns undefined if not cached. */ +export function getPresence( + accountId: string | undefined, + userId: string, +): GatewayPresenceUpdate | undefined { + return presenceCache.get(resolveAccountKey(accountId))?.get(userId); +} + +/** Clear cached presence data. */ +export function clearPresences(accountId?: string): void { + if (accountId) { + presenceCache.delete(resolveAccountKey(accountId)); + return; + } + presenceCache.clear(); +} + +/** Get the number of cached presence entries. */ +export function presenceCacheSize(): number { + let total = 0; + for (const accountCache of presenceCache.values()) { + total += accountCache.size; + } + return total; +} diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index 0599d104e..ed5299cf7 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -28,6 +28,7 @@ import { resolveDiscordUserAllowlist } from "../resolve-users.js"; import { normalizeDiscordToken } from "../token.js"; import { DiscordMessageListener, + DiscordPresenceListener, DiscordReactionListener, DiscordReactionRemoveListener, registerDiscordListener, @@ -109,6 +110,25 @@ function formatDiscordDeployErrorDetails(err: unknown): string { return details.length > 0 ? ` (${details.join(", ")})` : ""; } +function resolveDiscordGatewayIntents( + intentsConfig?: import("../../config/types.discord.js").DiscordIntentsConfig, +): number { + let intents = + GatewayIntents.Guilds | + GatewayIntents.GuildMessages | + GatewayIntents.MessageContent | + GatewayIntents.DirectMessages | + GatewayIntents.GuildMessageReactions | + GatewayIntents.DirectMessageReactions; + if (intentsConfig?.presence) { + intents |= GatewayIntents.GuildPresences; + } + if (intentsConfig?.guildMembers) { + intents |= GatewayIntents.GuildMembers; + } + return intents; +} + export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const cfg = opts.config ?? loadConfig(); const account = resolveDiscordAccount({ @@ -451,13 +471,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { reconnect: { maxAttempts: Number.POSITIVE_INFINITY, }, - intents: - GatewayIntents.Guilds | - GatewayIntents.GuildMessages | - GatewayIntents.MessageContent | - GatewayIntents.DirectMessages | - GatewayIntents.GuildMessageReactions | - GatewayIntents.DirectMessageReactions, + intents: resolveDiscordGatewayIntents(discordCfg.intents), autoInteractions: true, }), ], @@ -527,6 +541,14 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }), ); + if (discordCfg.intents?.presence) { + registerDiscordListener( + client.listeners, + new DiscordPresenceListener({ logger, accountId: account.accountId }), + ); + runtime.log?.("discord: GuildPresences intent enabled — presence listener registered"); + } + runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`); // Start exec approvals handler after client is ready diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 60782ff6d..c0c201ff0 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -63,6 +63,11 @@ export type { ClawdbotPluginService, ClawdbotPluginServiceContext, } from "../plugins/types.js"; +export type { + GatewayRequestHandler, + GatewayRequestHandlerOptions, + RespondFn, +} from "../gateway/server-methods/types.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export { normalizePluginHttpPath } from "../plugins/http-path.js"; export { registerPluginHttpRoute } from "../plugins/http-registry.js"; diff --git a/src/slack/monitor/media.test.ts b/src/slack/monitor/media.test.ts new file mode 100644 index 000000000..bfe70f005 --- /dev/null +++ b/src/slack/monitor/media.test.ts @@ -0,0 +1,278 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Store original fetch +const originalFetch = globalThis.fetch; +let mockFetch: ReturnType; + +describe("fetchWithSlackAuth", () => { + beforeEach(() => { + // Create a new mock for each test + mockFetch = vi.fn(); + globalThis.fetch = mockFetch as typeof fetch; + }); + + afterEach(() => { + // Restore original fetch + globalThis.fetch = originalFetch; + vi.resetModules(); + }); + + it("sends Authorization header on initial request with manual redirect", async () => { + // Import after mocking fetch + const { fetchWithSlackAuth } = await import("./media.js"); + + // Simulate direct 200 response (no redirect) + const mockResponse = new Response(Buffer.from("image data"), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }); + mockFetch.mockResolvedValueOnce(mockResponse); + + const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token"); + + expect(result).toBe(mockResponse); + + // Verify fetch was called with correct params + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith("https://files.slack.com/test.jpg", { + headers: { Authorization: "Bearer xoxb-test-token" }, + redirect: "manual", + }); + }); + + it("follows redirects without Authorization header", async () => { + const { fetchWithSlackAuth } = await import("./media.js"); + + // First call: redirect response from Slack + const redirectResponse = new Response(null, { + status: 302, + headers: { location: "https://cdn.slack-edge.com/presigned-url?sig=abc123" }, + }); + + // Second call: actual file content from CDN + const fileResponse = new Response(Buffer.from("actual image data"), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }); + + mockFetch.mockResolvedValueOnce(redirectResponse).mockResolvedValueOnce(fileResponse); + + const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token"); + + expect(result).toBe(fileResponse); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // First call should have Authorization header and manual redirect + expect(mockFetch).toHaveBeenNthCalledWith(1, "https://files.slack.com/test.jpg", { + headers: { Authorization: "Bearer xoxb-test-token" }, + redirect: "manual", + }); + + // Second call should follow the redirect without Authorization + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + "https://cdn.slack-edge.com/presigned-url?sig=abc123", + { redirect: "follow" }, + ); + }); + + it("handles relative redirect URLs", async () => { + const { fetchWithSlackAuth } = await import("./media.js"); + + // Redirect with relative URL + const redirectResponse = new Response(null, { + status: 302, + headers: { location: "/files/redirect-target" }, + }); + + const fileResponse = new Response(Buffer.from("image data"), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }); + + mockFetch.mockResolvedValueOnce(redirectResponse).mockResolvedValueOnce(fileResponse); + + await fetchWithSlackAuth("https://files.slack.com/original.jpg", "xoxb-test-token"); + + // Second call should resolve the relative URL against the original + expect(mockFetch).toHaveBeenNthCalledWith(2, "https://files.slack.com/files/redirect-target", { + redirect: "follow", + }); + }); + + it("returns redirect response when no location header is provided", async () => { + const { fetchWithSlackAuth } = await import("./media.js"); + + // Redirect without location header + const redirectResponse = new Response(null, { + status: 302, + // No location header + }); + + mockFetch.mockResolvedValueOnce(redirectResponse); + + const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token"); + + // Should return the redirect response directly + expect(result).toBe(redirectResponse); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("returns 4xx/5xx responses directly without following", async () => { + const { fetchWithSlackAuth } = await import("./media.js"); + + const errorResponse = new Response("Not Found", { + status: 404, + }); + + mockFetch.mockResolvedValueOnce(errorResponse); + + const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token"); + + expect(result).toBe(errorResponse); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("handles 301 permanent redirects", async () => { + const { fetchWithSlackAuth } = await import("./media.js"); + + const redirectResponse = new Response(null, { + status: 301, + headers: { location: "https://cdn.slack.com/new-url" }, + }); + + const fileResponse = new Response(Buffer.from("image data"), { + status: 200, + }); + + mockFetch.mockResolvedValueOnce(redirectResponse).mockResolvedValueOnce(fileResponse); + + await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token"); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenNthCalledWith(2, "https://cdn.slack.com/new-url", { + redirect: "follow", + }); + }); +}); + +describe("resolveSlackMedia", () => { + beforeEach(() => { + mockFetch = vi.fn(); + globalThis.fetch = mockFetch as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.resetModules(); + }); + + it("prefers url_private_download over url_private", async () => { + // Mock the store module + vi.doMock("../../media/store.js", () => ({ + saveMediaBuffer: vi.fn().mockResolvedValue({ + path: "/tmp/test.jpg", + contentType: "image/jpeg", + }), + })); + + const { resolveSlackMedia } = await import("./media.js"); + + const mockResponse = new Response(Buffer.from("image data"), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }); + mockFetch.mockResolvedValueOnce(mockResponse); + + await resolveSlackMedia({ + files: [ + { + url_private: "https://files.slack.com/private.jpg", + url_private_download: "https://files.slack.com/download.jpg", + name: "test.jpg", + }, + ], + token: "xoxb-test-token", + maxBytes: 1024 * 1024, + }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://files.slack.com/download.jpg", + expect.anything(), + ); + }); + + it("returns null when download fails", async () => { + const { resolveSlackMedia } = await import("./media.js"); + + // Simulate a network error + mockFetch.mockRejectedValueOnce(new Error("Network error")); + + const result = await resolveSlackMedia({ + files: [{ url_private: "https://files.slack.com/test.jpg", name: "test.jpg" }], + token: "xoxb-test-token", + maxBytes: 1024 * 1024, + }); + + expect(result).toBeNull(); + }); + + it("returns null when no files are provided", async () => { + const { resolveSlackMedia } = await import("./media.js"); + + const result = await resolveSlackMedia({ + files: [], + token: "xoxb-test-token", + maxBytes: 1024 * 1024, + }); + + expect(result).toBeNull(); + }); + + it("skips files without url_private", async () => { + const { resolveSlackMedia } = await import("./media.js"); + + const result = await resolveSlackMedia({ + files: [{ name: "test.jpg" }], // No url_private + token: "xoxb-test-token", + maxBytes: 1024 * 1024, + }); + + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("falls through to next file when first file returns error", async () => { + // Mock the store module + vi.doMock("../../media/store.js", () => ({ + saveMediaBuffer: vi.fn().mockResolvedValue({ + path: "/tmp/test.jpg", + contentType: "image/jpeg", + }), + })); + + const { resolveSlackMedia } = await import("./media.js"); + + // First file: 404 + const errorResponse = new Response("Not Found", { status: 404 }); + // Second file: success + const successResponse = new Response(Buffer.from("image data"), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }); + + mockFetch.mockResolvedValueOnce(errorResponse).mockResolvedValueOnce(successResponse); + + const result = await resolveSlackMedia({ + files: [ + { url_private: "https://files.slack.com/first.jpg", name: "first.jpg" }, + { url_private: "https://files.slack.com/second.jpg", name: "second.jpg" }, + ], + token: "xoxb-test-token", + maxBytes: 1024 * 1024, + }); + + expect(result).not.toBeNull(); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/slack/monitor/media.ts b/src/slack/monitor/media.ts index 143d6b36f..2674e2d50 100644 --- a/src/slack/monitor/media.ts +++ b/src/slack/monitor/media.ts @@ -5,6 +5,38 @@ import { fetchRemoteMedia } from "../../media/fetch.js"; import { saveMediaBuffer } from "../../media/store.js"; import type { SlackFile } from "../types.js"; +/** + * Fetches a URL with Authorization header, handling cross-origin redirects. + * Node.js fetch strips Authorization headers on cross-origin redirects for security. + * Slack's files.slack.com URLs redirect to CDN domains with pre-signed URLs that + * don't need the Authorization header, so we handle the initial auth request manually. + */ +export async function fetchWithSlackAuth(url: string, token: string): Promise { + // Initial request with auth and manual redirect handling + const initialRes = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + redirect: "manual", + }); + + // If not a redirect, return the response directly + if (initialRes.status < 300 || initialRes.status >= 400) { + return initialRes; + } + + // Handle redirect - the redirected URL should be pre-signed and not need auth + const redirectUrl = initialRes.headers.get("location"); + if (!redirectUrl) { + return initialRes; + } + + // Resolve relative URLs against the original + const resolvedUrl = new URL(redirectUrl, url).toString(); + + // Follow the redirect without the Authorization header + // (Slack's CDN URLs are pre-signed and don't need it) + return fetch(resolvedUrl, { redirect: "follow" }); +} + export async function resolveSlackMedia(params: { files?: SlackFile[]; token: string; @@ -19,10 +51,12 @@ export async function resolveSlackMedia(params: { const url = file.url_private_download ?? file.url_private; if (!url) continue; try { - const fetchImpl: FetchLike = (input, init) => { - const headers = new Headers(init?.headers); - headers.set("Authorization", `Bearer ${params.token}`); - return fetch(input, { ...init, headers }); + // Note: We ignore init options because fetchWithSlackAuth handles + // redirect behavior specially. fetchRemoteMedia only passes the URL. + const fetchImpl: FetchLike = (input) => { + const inputUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + return fetchWithSlackAuth(inputUrl, params.token); }; const fetched = await fetchRemoteMedia({ url, diff --git a/src/telegram/bot-native-commands.plugin-auth.test.ts b/src/telegram/bot-native-commands.plugin-auth.test.ts new file mode 100644 index 000000000..5da5f0453 --- /dev/null +++ b/src/telegram/bot-native-commands.plugin-auth.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ChannelGroupPolicy } from "../config/group-policy.js"; +import type { ClawdbotConfig } from "../config/config.js"; +import type { TelegramAccountConfig } from "../config/types.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { registerTelegramNativeCommands } from "./bot-native-commands.js"; + +const getPluginCommandSpecs = vi.hoisted(() => vi.fn()); +const matchPluginCommand = vi.hoisted(() => vi.fn()); +const executePluginCommand = vi.hoisted(() => vi.fn()); + +vi.mock("../plugins/commands.js", () => ({ + getPluginCommandSpecs, + matchPluginCommand, + executePluginCommand, +})); + +const deliverReplies = vi.hoisted(() => vi.fn(async () => {})); +vi.mock("./bot/delivery.js", () => ({ deliverReplies })); + +vi.mock("./pairing-store.js", () => ({ + readTelegramAllowFromStore: vi.fn(async () => []), +})); + +describe("registerTelegramNativeCommands (plugin auth)", () => { + it("allows requireAuth:false plugin command even when sender is unauthorized", async () => { + const command = { + name: "plugin", + description: "Plugin command", + requireAuth: false, + handler: vi.fn(), + } as const; + + getPluginCommandSpecs.mockReturnValue([{ name: "plugin", description: "Plugin command" }]); + matchPluginCommand.mockReturnValue({ command, args: undefined }); + executePluginCommand.mockResolvedValue({ text: "ok" }); + + const handlers: Record Promise> = {}; + const bot = { + api: { + setMyCommands: vi.fn().mockResolvedValue(undefined), + sendMessage: vi.fn(), + }, + command: (name: string, handler: (ctx: unknown) => Promise) => { + handlers[name] = handler; + }, + } as const; + + const cfg = {} as ClawdbotConfig; + const telegramCfg = {} as TelegramAccountConfig; + const resolveGroupPolicy = () => + ({ + allowlistEnabled: false, + allowed: true, + }) as ChannelGroupPolicy; + + registerTelegramNativeCommands({ + bot: bot as unknown as Parameters[0]["bot"], + cfg, + runtime: {} as RuntimeEnv, + accountId: "default", + telegramCfg, + allowFrom: ["999"], + groupAllowFrom: [], + replyToMode: "off", + textLimit: 4000, + useAccessGroups: false, + nativeEnabled: false, + nativeSkillsEnabled: false, + nativeDisabledExplicit: false, + resolveGroupPolicy, + resolveTelegramGroupConfig: () => ({ + groupConfig: undefined, + topicConfig: undefined, + }), + shouldSkipUpdate: () => false, + opts: { token: "token" }, + }); + + const ctx = { + message: { + chat: { id: 123, type: "private" }, + from: { id: 111, username: "nope" }, + message_id: 10, + date: 123456, + }, + match: "", + }; + + await handlers.plugin?.(ctx); + + expect(matchPluginCommand).toHaveBeenCalled(); + expect(executePluginCommand).toHaveBeenCalledWith( + expect.objectContaining({ + isAuthorizedSender: false, + }), + ); + expect(deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [{ text: "ok" }], + }), + ); + expect(bot.api.sendMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 0f1cc1cb7..c33f1e18e 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -17,9 +17,18 @@ import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/pr import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { danger, logVerbose } from "../globals.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; +import { + normalizeTelegramCommandName, + TELEGRAM_COMMAND_NAME_PATTERN, +} from "../config/telegram-custom-commands.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../routing/session-key.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js"; +import { + executePluginCommand, + getPluginCommandSpecs, + matchPluginCommand, +} from "../plugins/commands.js"; import type { ChannelGroupPolicy } from "../config/group-policy.js"; import type { ReplyToMode, @@ -42,6 +51,18 @@ import { readTelegramAllowFromStore } from "./pairing-store.js"; type TelegramNativeCommandContext = Context & { match?: string }; +type TelegramCommandAuthResult = { + chatId: number; + isGroup: boolean; + isForum: boolean; + resolvedThreadId?: number; + senderId: string; + senderUsername: string; + groupConfig?: TelegramGroupConfig; + topicConfig?: TelegramTopicConfig; + commandAuthorized: boolean; +}; + type RegisterTelegramNativeCommandsParams = { bot: Bot; cfg: ClawdbotConfig; @@ -65,6 +86,134 @@ type RegisterTelegramNativeCommandsParams = { opts: { token: string }; }; +async function resolveTelegramCommandAuth(params: { + msg: NonNullable; + bot: Bot; + cfg: ClawdbotConfig; + telegramCfg: TelegramAccountConfig; + allowFrom?: Array; + groupAllowFrom?: Array; + useAccessGroups: boolean; + resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy; + resolveTelegramGroupConfig: ( + chatId: string | number, + messageThreadId?: number, + ) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig }; + requireAuth: boolean; +}): Promise { + const { + msg, + bot, + cfg, + telegramCfg, + allowFrom, + groupAllowFrom, + useAccessGroups, + resolveGroupPolicy, + resolveTelegramGroupConfig, + requireAuth, + } = params; + const chatId = msg.chat.id; + const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; + const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id; + const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true; + const resolvedThreadId = resolveTelegramForumThreadId({ + isForum, + messageThreadId, + }); + const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []); + const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId); + const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom); + const effectiveGroupAllow = normalizeAllowFromWithStore({ + allowFrom: groupAllowOverride ?? groupAllowFrom, + storeAllowFrom, + }); + const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined"; + const senderIdRaw = msg.from?.id; + const senderId = senderIdRaw ? String(senderIdRaw) : ""; + const senderUsername = msg.from?.username ?? ""; + + if (isGroup && groupConfig?.enabled === false) { + await bot.api.sendMessage(chatId, "This group is disabled."); + return null; + } + if (isGroup && topicConfig?.enabled === false) { + await bot.api.sendMessage(chatId, "This topic is disabled."); + return null; + } + if (requireAuth && isGroup && hasGroupAllowOverride) { + if ( + senderIdRaw == null || + !isSenderAllowed({ + allow: effectiveGroupAllow, + senderId: String(senderIdRaw), + senderUsername, + }) + ) { + await bot.api.sendMessage(chatId, "You are not authorized to use this command."); + return null; + } + } + + if (isGroup && useAccessGroups) { + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open"; + if (groupPolicy === "disabled") { + await bot.api.sendMessage(chatId, "Telegram group commands are disabled."); + return null; + } + if (groupPolicy === "allowlist" && requireAuth) { + if ( + senderIdRaw == null || + !isSenderAllowed({ + allow: effectiveGroupAllow, + senderId: String(senderIdRaw), + senderUsername, + }) + ) { + await bot.api.sendMessage(chatId, "You are not authorized to use this command."); + return null; + } + } + const groupAllowlist = resolveGroupPolicy(chatId); + if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) { + await bot.api.sendMessage(chatId, "This group is not allowed."); + return null; + } + } + + const dmAllow = normalizeAllowFromWithStore({ + allowFrom: allowFrom, + storeAllowFrom, + }); + const senderAllowed = isSenderAllowed({ + allow: dmAllow, + senderId, + senderUsername, + }); + const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups, + authorizers: [{ configured: dmAllow.hasEntries, allowed: senderAllowed }], + modeWhenAccessGroupsOff: "configured", + }); + if (requireAuth && !commandAuthorized) { + await bot.api.sendMessage(chatId, "You are not authorized to use this command."); + return null; + } + + return { + chatId, + isGroup, + isForum, + resolvedThreadId, + senderId, + senderUsername, + groupConfig, + topicConfig, + commandAuthorized, + }; +} + export const registerTelegramNativeCommands = ({ bot, cfg, @@ -103,11 +252,50 @@ export const registerTelegramNativeCommands = ({ runtime.error?.(danger(issue.message)); } const customCommands = customResolution.commands; + const pluginCommandSpecs = getPluginCommandSpecs(); + const pluginCommands: Array<{ command: string; description: string }> = []; + const existingCommands = new Set( + [ + ...nativeCommands.map((command) => command.name), + ...customCommands.map((command) => command.command), + ].map((command) => command.toLowerCase()), + ); + const pluginCommandNames = new Set(); + for (const spec of pluginCommandSpecs) { + const normalized = normalizeTelegramCommandName(spec.name); + if (!normalized || !TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) { + runtime.error?.( + danger( + `Plugin command "/${spec.name}" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).`, + ), + ); + continue; + } + const description = spec.description.trim(); + if (!description) { + runtime.error?.(danger(`Plugin command "/${normalized}" is missing a description.`)); + continue; + } + if (existingCommands.has(normalized)) { + runtime.error?.( + danger(`Plugin command "/${normalized}" conflicts with an existing Telegram command.`), + ); + continue; + } + if (pluginCommandNames.has(normalized)) { + runtime.error?.(danger(`Plugin command "/${normalized}" is duplicated.`)); + continue; + } + pluginCommandNames.add(normalized); + existingCommands.add(normalized); + pluginCommands.push({ command: normalized, description }); + } const allCommands: Array<{ command: string; description: string }> = [ ...nativeCommands.map((command) => ({ command: command.name, description: command.description, })), + ...pluginCommands, ...customCommands, ]; @@ -124,99 +312,30 @@ export const registerTelegramNativeCommands = ({ const msg = ctx.message; if (!msg) return; if (shouldSkipUpdate(ctx)) return; - const chatId = msg.chat.id; - const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; - const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id; - const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true; - const resolvedThreadId = resolveTelegramForumThreadId({ + const auth = await resolveTelegramCommandAuth({ + msg, + bot, + cfg, + telegramCfg, + allowFrom, + groupAllowFrom, + useAccessGroups, + resolveGroupPolicy, + resolveTelegramGroupConfig, + requireAuth: true, + }); + if (!auth) return; + const { + chatId, + isGroup, isForum, - messageThreadId, - }); - const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []); - const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId); - const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom); - const effectiveGroupAllow = normalizeAllowFromWithStore({ - allowFrom: groupAllowOverride ?? groupAllowFrom, - storeAllowFrom, - }); - const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined"; - - if (isGroup && groupConfig?.enabled === false) { - await bot.api.sendMessage(chatId, "This group is disabled."); - return; - } - if (isGroup && topicConfig?.enabled === false) { - await bot.api.sendMessage(chatId, "This topic is disabled."); - return; - } - if (isGroup && hasGroupAllowOverride) { - const senderId = msg.from?.id; - const senderUsername = msg.from?.username ?? ""; - if ( - senderId == null || - !isSenderAllowed({ - allow: effectiveGroupAllow, - senderId: String(senderId), - senderUsername, - }) - ) { - await bot.api.sendMessage(chatId, "You are not authorized to use this command."); - return; - } - } - - if (isGroup && useAccessGroups) { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open"; - if (groupPolicy === "disabled") { - await bot.api.sendMessage(chatId, "Telegram group commands are disabled."); - return; - } - if (groupPolicy === "allowlist") { - const senderId = msg.from?.id; - if (senderId == null) { - await bot.api.sendMessage(chatId, "You are not authorized to use this command."); - return; - } - const senderUsername = msg.from?.username ?? ""; - if ( - !isSenderAllowed({ - allow: effectiveGroupAllow, - senderId: String(senderId), - senderUsername, - }) - ) { - await bot.api.sendMessage(chatId, "You are not authorized to use this command."); - return; - } - } - const groupAllowlist = resolveGroupPolicy(chatId); - if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) { - await bot.api.sendMessage(chatId, "This group is not allowed."); - return; - } - } - - const senderId = msg.from?.id ? String(msg.from.id) : ""; - const senderUsername = msg.from?.username ?? ""; - const dmAllow = normalizeAllowFromWithStore({ - allowFrom: allowFrom, - storeAllowFrom, - }); - const senderAllowed = isSenderAllowed({ - allow: dmAllow, + resolvedThreadId, senderId, senderUsername, - }); - const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups, - authorizers: [{ configured: dmAllow.hasEntries, allowed: senderAllowed }], - modeWhenAccessGroupsOff: "configured", - }); - if (!commandAuthorized) { - await bot.api.sendMessage(chatId, "You are not authorized to use this command."); - return; - } + groupConfig, + topicConfig, + commandAuthorized, + } = auth; const commandDefinition = findCommandByNativeName(command.name, "telegram"); const rawText = ctx.match?.trim() ?? ""; @@ -362,6 +481,66 @@ export const registerTelegramNativeCommands = ({ }); }); } + + for (const pluginCommand of pluginCommands) { + bot.command(pluginCommand.command, async (ctx: TelegramNativeCommandContext) => { + const msg = ctx.message; + if (!msg) return; + if (shouldSkipUpdate(ctx)) return; + const chatId = msg.chat.id; + const rawText = ctx.match?.trim() ?? ""; + const commandBody = `/${pluginCommand.command}${rawText ? ` ${rawText}` : ""}`; + const match = matchPluginCommand(commandBody); + if (!match) { + await bot.api.sendMessage(chatId, "Command not found."); + return; + } + const auth = await resolveTelegramCommandAuth({ + msg, + bot, + cfg, + telegramCfg, + allowFrom, + groupAllowFrom, + useAccessGroups, + resolveGroupPolicy, + resolveTelegramGroupConfig, + requireAuth: match.command.requireAuth !== false, + }); + if (!auth) return; + const { resolvedThreadId, senderId, commandAuthorized } = auth; + + const result = await executePluginCommand({ + command: match.command, + args: match.args, + senderId, + channel: "telegram", + isAuthorizedSender: commandAuthorized, + commandBody, + config: cfg, + }); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "telegram", + accountId, + }); + const chunkMode = resolveChunkMode(cfg, "telegram", accountId); + + await deliverReplies({ + replies: [result], + chatId: String(chatId), + token: opts.token, + runtime, + bot, + replyToMode, + textLimit, + messageThreadId: resolvedThreadId, + tableMode, + chunkMode, + linkPreview: telegramCfg.linkPreview, + }); + }); + } } } else if (nativeDisabledExplicit) { bot.api.setMyCommands([]).catch((err) => { diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 4edc91c8a..36a680227 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -17,6 +17,7 @@ import { isGifMedia } from "../../media/mime.js"; import { saveMediaBuffer } from "../../media/store.js"; import type { RuntimeEnv } from "../../runtime.js"; import { loadWebMedia } from "../../web/media.js"; +import { buildInlineKeyboard } from "../send.js"; import { resolveTelegramVoiceSend } from "../voice.js"; import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js"; import type { TelegramContext } from "./types.js"; @@ -80,9 +81,17 @@ export async function deliverReplies(params: { : reply.mediaUrl ? [reply.mediaUrl] : []; + const telegramData = reply.channelData?.telegram as + | { buttons?: Array> } + | undefined; + const replyMarkup = buildInlineKeyboard(telegramData?.buttons); if (mediaList.length === 0) { const chunks = chunkText(reply.text || ""); - for (const chunk of chunks) { + for (let i = 0; i < chunks.length; i += 1) { + const chunk = chunks[i]; + if (!chunk) continue; + // Only attach buttons to the first chunk. + const shouldAttachButtons = i === 0 && replyMarkup; await sendTelegramText(bot, chatId, chunk.html, runtime, { replyToMessageId: replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined, @@ -90,6 +99,7 @@ export async function deliverReplies(params: { textMode: "html", plainText: chunk.text, linkPreview, + replyMarkup: shouldAttachButtons ? replyMarkup : undefined, }); if (replyToId && !hasReplied) { hasReplied = true; @@ -125,10 +135,12 @@ export async function deliverReplies(params: { first = false; const replyToMessageId = replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined; + const shouldAttachButtonsToMedia = isFirstMedia && replyMarkup && !followUpText; const mediaParams: Record = { caption: htmlCaption, reply_to_message_id: replyToMessageId, ...(htmlCaption ? { parse_mode: "HTML" } : {}), + ...(shouldAttachButtonsToMedia ? { reply_markup: replyMarkup } : {}), }; if (threadParams) { mediaParams.message_thread_id = threadParams.message_thread_id; @@ -183,6 +195,7 @@ export async function deliverReplies(params: { hasReplied, messageThreadId, linkPreview, + replyMarkup, }); // Skip this media item; continue with next. continue; @@ -207,7 +220,8 @@ export async function deliverReplies(params: { // Chunk it in case it's extremely long (same logic as text-only replies). if (pendingFollowUpText && isFirstMedia) { const chunks = chunkText(pendingFollowUpText); - for (const chunk of chunks) { + for (let i = 0; i < chunks.length; i += 1) { + const chunk = chunks[i]; const replyToMessageIdFollowup = replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined; await sendTelegramText(bot, chatId, chunk.html, runtime, { @@ -216,6 +230,7 @@ export async function deliverReplies(params: { textMode: "html", plainText: chunk.text, linkPreview, + replyMarkup: i === 0 ? replyMarkup : undefined, }); if (replyToId && !hasReplied) { hasReplied = true; @@ -277,10 +292,12 @@ async function sendTelegramVoiceFallbackText(opts: { hasReplied: boolean; messageThreadId?: number; linkPreview?: boolean; + replyMarkup?: ReturnType; }): Promise { const chunks = opts.chunkText(opts.text); let hasReplied = opts.hasReplied; - for (const chunk of chunks) { + for (let i = 0; i < chunks.length; i += 1) { + const chunk = chunks[i]; await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, { replyToMessageId: opts.replyToId && (opts.replyToMode === "all" || !hasReplied) ? opts.replyToId : undefined, @@ -288,6 +305,7 @@ async function sendTelegramVoiceFallbackText(opts: { textMode: "html", plainText: chunk.text, linkPreview: opts.linkPreview, + replyMarkup: i === 0 ? opts.replyMarkup : undefined, }); if (opts.replyToId && !hasReplied) { hasReplied = true; @@ -322,6 +340,7 @@ async function sendTelegramText( textMode?: "markdown" | "html"; plainText?: string; linkPreview?: boolean; + replyMarkup?: ReturnType; }, ): Promise { const baseParams = buildTelegramSendParams({ @@ -337,6 +356,7 @@ async function sendTelegramText( const res = await bot.api.sendMessage(chatId, htmlText, { parse_mode: "HTML", ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}), + ...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}), ...baseParams, }); return res.message_id; @@ -347,6 +367,7 @@ async function sendTelegramText( const fallbackText = opts?.plainText ?? text; const res = await bot.api.sendMessage(chatId, fallbackText, { ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}), + ...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}), ...baseParams, }); return res.message_id; diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 5c5590bf2..1016e5680 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -51,12 +51,26 @@ async function requireRiskAcknowledgement(params: { await params.prompter.note( [ - "Please read: https://docs.clawd.bot/security", + "Security warning — please read.", "", - "Clawdbot agents can run commands, read/write files, and act through any tools you enable. They can only send messages on channels you configure (for example, an account you log in on this machine, or a bot account like Slack/Discord).", + "Clawdbot is a hobby project and still in beta. Expect sharp edges.", + "This bot can read files and run actions if tools are enabled.", + "A bad prompt can trick it into doing unsafe things.", "", - "If you’re new to this, start with the sandbox and least privilege. It helps limit what an agent can do if it’s tricked or makes a mistake.", - "Learn more: https://docs.clawd.bot/sandboxing", + "If you’re not comfortable with basic security and access control, don’t run Clawdbot.", + "Ask someone experienced to help before enabling tools or exposing it to the internet.", + "", + "Recommended baseline:", + "- Pairing/allowlists + mention gating.", + "- Sandbox + least-privilege tools.", + "- Keep secrets out of the agent’s reachable filesystem.", + "- Use the strongest available model for any bot with tools or untrusted inboxes.", + "", + "Run regularly:", + "clawdbot security audit --deep", + "clawdbot security audit --fix", + "", + "Must read: https://docs.clawd.bot/gateway/security", ].join("\n"), "Security", );