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:
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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",
);