feat: scope telegram inline buttons
This commit is contained in:
@@ -15,6 +15,7 @@
|
|||||||
### Changes
|
### Changes
|
||||||
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
||||||
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
|
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
|
||||||
|
- Telegram: scope inline buttons with allowlist default + callback gating in DMs/groups.
|
||||||
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
||||||
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
|
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
|
||||||
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
|
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
|
||||||
|
|||||||
@@ -219,13 +219,15 @@ Private chats can include `message_thread_id` in some edge cases. Clawdbot keeps
|
|||||||
|
|
||||||
## Inline Buttons
|
## Inline Buttons
|
||||||
|
|
||||||
Telegram supports inline keyboards with callback buttons. Enable this feature via capabilities:
|
Telegram supports inline keyboards with callback buttons.
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
"channels": {
|
"channels": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"capabilities": ["inlineButtons"]
|
"capabilities": {
|
||||||
|
"inlineButtons": "allowlist"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,7 +240,9 @@ For per-account configuration:
|
|||||||
"telegram": {
|
"telegram": {
|
||||||
"accounts": {
|
"accounts": {
|
||||||
"main": {
|
"main": {
|
||||||
"capabilities": ["inlineButtons"]
|
"capabilities": {
|
||||||
|
"inlineButtons": "allowlist"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,6 +250,16 @@ For per-account configuration:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Scopes:
|
||||||
|
- `off` — inline buttons disabled
|
||||||
|
- `dm` — only DMs (group targets blocked)
|
||||||
|
- `group` — only groups (DM targets blocked)
|
||||||
|
- `all` — DMs + groups
|
||||||
|
- `allowlist` — DMs + groups, but only senders allowed by `allowFrom`/`groupAllowFrom` (same rules as control commands)
|
||||||
|
|
||||||
|
Default: `allowlist`.
|
||||||
|
Legacy: `capabilities: ["inlineButtons"]` = `inlineButtons: "all"`.
|
||||||
|
|
||||||
### Sending buttons
|
### Sending buttons
|
||||||
|
|
||||||
Use the message tool with the `buttons` parameter:
|
Use the message tool with the `buttons` parameter:
|
||||||
@@ -273,12 +287,12 @@ When a user clicks a button, the callback data is sent back to the agent as a me
|
|||||||
|
|
||||||
### Configuration options
|
### Configuration options
|
||||||
|
|
||||||
Telegram capabilities can be configured at two levels:
|
Telegram capabilities can be configured at two levels (object form shown above; legacy string arrays still supported):
|
||||||
|
|
||||||
- `channels.telegram.capabilities`: Global default capability list applied to all Telegram accounts unless overridden.
|
- `channels.telegram.capabilities`: Global default capability config applied to all Telegram accounts unless overridden.
|
||||||
- `channels.telegram.accounts.<account>.capabilities`: Per-account capabilities that override or extend the global defaults for that specific account.
|
- `channels.telegram.accounts.<account>.capabilities`: Per-account capabilities that override the global defaults for that specific account.
|
||||||
|
|
||||||
Use the global setting when all Telegram bots/accounts should behave the same. Use per-account configuration when different bots need different behaviors (for example, one account only handles DMs while another is allowed in groups or has extra capabilities).
|
Use the global setting when all Telegram bots/accounts should behave the same. Use per-account configuration when different bots need different behaviors (for example, one account only handles DMs while another is allowed in groups).
|
||||||
## Access control (DMs + groups)
|
## Access control (DMs + groups)
|
||||||
|
|
||||||
### DM access
|
### DM access
|
||||||
@@ -477,8 +491,8 @@ Provider options:
|
|||||||
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
|
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
|
||||||
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
|
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
|
||||||
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
||||||
- `channels.telegram.capabilities`: Enable channel features (e.g., "inlineButtons").
|
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
|
||||||
- `channels.telegram.accounts.<account>.capabilities`: Per-account capabilities.
|
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
||||||
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
||||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||||
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ Target formats (`--to`):
|
|||||||
- Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
|
- Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
|
||||||
- Required: `--to`, plus `--message` or `--media`
|
- Required: `--to`, plus `--message` or `--media`
|
||||||
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
|
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
|
||||||
- Telegram only: `--buttons` (requires `"inlineButtons"` in `channels.telegram.capabilities` or `channels.telegram.accounts.<id>.capabilities`)
|
- Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it)
|
||||||
- Telegram only: `--thread-id` (forum topic id)
|
- Telegram only: `--thread-id` (forum topic id)
|
||||||
- Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field)
|
- Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field)
|
||||||
- WhatsApp only: `--gif-playback`
|
- WhatsApp only: `--gif-playback`
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
|
|||||||
import { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
|
import { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { getMachineDisplayName } from "../../infra/machine-name.js";
|
import { getMachineDisplayName } from "../../infra/machine-name.js";
|
||||||
|
import { resolveTelegramInlineButtonsScope } from "../../telegram/inline-buttons.js";
|
||||||
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
|
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
|
||||||
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||||
import { isSubagentSessionKey } from "../../routing/session-key.js";
|
import { isSubagentSessionKey } from "../../routing/session-key.js";
|
||||||
@@ -210,13 +211,29 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
const runtimeChannel = normalizeMessageChannel(
|
const runtimeChannel = normalizeMessageChannel(
|
||||||
params.messageChannel ?? params.messageProvider,
|
params.messageChannel ?? params.messageProvider,
|
||||||
);
|
);
|
||||||
const runtimeCapabilities = runtimeChannel
|
let runtimeCapabilities = runtimeChannel
|
||||||
? (resolveChannelCapabilities({
|
? (resolveChannelCapabilities({
|
||||||
cfg: params.config,
|
cfg: params.config,
|
||||||
channel: runtimeChannel,
|
channel: runtimeChannel,
|
||||||
accountId: params.agentAccountId,
|
accountId: params.agentAccountId,
|
||||||
}) ?? [])
|
}) ?? [])
|
||||||
: undefined;
|
: undefined;
|
||||||
|
if (runtimeChannel === "telegram" && params.config) {
|
||||||
|
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
|
||||||
|
cfg: params.config,
|
||||||
|
accountId: params.agentAccountId ?? undefined,
|
||||||
|
});
|
||||||
|
if (inlineButtonsScope !== "off") {
|
||||||
|
if (!runtimeCapabilities) runtimeCapabilities = [];
|
||||||
|
if (
|
||||||
|
!runtimeCapabilities.some(
|
||||||
|
(cap) => String(cap).trim().toLowerCase() === "inlinebuttons",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
runtimeCapabilities.push("inlineButtons");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const runtimeInfo = {
|
const runtimeInfo = {
|
||||||
host: machineName,
|
host: machineName,
|
||||||
os: `${os.type()} ${os.release()}`,
|
os: `${os.type()} ${os.release()}`,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { createAgentSession, SessionManager, SettingsManager } from "@mariozechn
|
|||||||
import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js";
|
import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js";
|
||||||
import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js";
|
import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js";
|
||||||
import { getMachineDisplayName } from "../../../infra/machine-name.js";
|
import { getMachineDisplayName } from "../../../infra/machine-name.js";
|
||||||
|
import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js";
|
||||||
import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js";
|
import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js";
|
||||||
import { normalizeMessageChannel } from "../../../utils/message-channel.js";
|
import { normalizeMessageChannel } from "../../../utils/message-channel.js";
|
||||||
import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
|
import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
|
||||||
@@ -157,13 +158,27 @@ export async function runEmbeddedAttempt(
|
|||||||
|
|
||||||
const machineName = await getMachineDisplayName();
|
const machineName = await getMachineDisplayName();
|
||||||
const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider);
|
const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider);
|
||||||
const runtimeCapabilities = runtimeChannel
|
let runtimeCapabilities = runtimeChannel
|
||||||
? (resolveChannelCapabilities({
|
? (resolveChannelCapabilities({
|
||||||
cfg: params.config,
|
cfg: params.config,
|
||||||
channel: runtimeChannel,
|
channel: runtimeChannel,
|
||||||
accountId: params.agentAccountId,
|
accountId: params.agentAccountId,
|
||||||
}) ?? [])
|
}) ?? [])
|
||||||
: undefined;
|
: undefined;
|
||||||
|
if (runtimeChannel === "telegram" && params.config) {
|
||||||
|
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
|
||||||
|
cfg: params.config,
|
||||||
|
accountId: params.agentAccountId ?? undefined,
|
||||||
|
});
|
||||||
|
if (inlineButtonsScope !== "off") {
|
||||||
|
if (!runtimeCapabilities) runtimeCapabilities = [];
|
||||||
|
if (
|
||||||
|
!runtimeCapabilities.some((cap) => String(cap).trim().toLowerCase() === "inlinebuttons")
|
||||||
|
) {
|
||||||
|
runtimeCapabilities.push("inlineButtons");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const reactionGuidance =
|
const reactionGuidance =
|
||||||
runtimeChannel === "telegram" && params.config
|
runtimeChannel === "telegram" && params.config
|
||||||
? (() => {
|
? (() => {
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ function buildMessagingSection(params: {
|
|||||||
params.inlineButtonsEnabled
|
params.inlineButtonsEnabled
|
||||||
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
|
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
|
||||||
: params.runtimeChannel
|
: params.runtimeChannel
|
||||||
? `- Inline buttons not enabled for ${params.runtimeChannel}. If you need them, ask to add "inlineButtons" to ${params.runtimeChannel}.capabilities or ${params.runtimeChannel}.accounts.<id>.capabilities.`
|
? `- Inline buttons not enabled for ${params.runtimeChannel}. If you need them, ask to set ${params.runtimeChannel}.capabilities.inlineButtons ("dm"|"group"|"all"|"allowlist").`
|
||||||
: "",
|
: "",
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|||||||
@@ -328,10 +328,28 @@ describe("handleTelegramAction", () => {
|
|||||||
).rejects.toThrow(/Telegram bot token missing/);
|
).rejects.toThrow(/Telegram bot token missing/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires inlineButtons capability when buttons are provided", async () => {
|
it("allows inline buttons by default (allowlist)", async () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
channels: { telegram: { botToken: "tok" } },
|
channels: { telegram: { botToken: "tok" } },
|
||||||
} as ClawdbotConfig;
|
} as ClawdbotConfig;
|
||||||
|
await handleTelegramAction(
|
||||||
|
{
|
||||||
|
action: "sendMessage",
|
||||||
|
to: "@testchannel",
|
||||||
|
content: "Choose",
|
||||||
|
buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]],
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
expect(sendMessageTelegram).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks inline buttons when scope is off", async () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
telegram: { botToken: "tok", capabilities: { inlineButtons: "off" } },
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
await expect(
|
await expect(
|
||||||
handleTelegramAction(
|
handleTelegramAction(
|
||||||
{
|
{
|
||||||
@@ -342,13 +360,32 @@ describe("handleTelegramAction", () => {
|
|||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
),
|
),
|
||||||
).rejects.toThrow(/inlineButtons/i);
|
).rejects.toThrow(/inline buttons are disabled/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks inline buttons in groups when scope is dm", async () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
telegram: { botToken: "tok", capabilities: { inlineButtons: "dm" } },
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
await expect(
|
||||||
|
handleTelegramAction(
|
||||||
|
{
|
||||||
|
action: "sendMessage",
|
||||||
|
to: "-100123456",
|
||||||
|
content: "Choose",
|
||||||
|
buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]],
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/inline buttons are limited to DMs/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends messages with inline keyboard buttons when enabled", async () => {
|
it("sends messages with inline keyboard buttons when enabled", async () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
channels: {
|
channels: {
|
||||||
telegram: { botToken: "tok", capabilities: ["inlineButtons"] },
|
telegram: { botToken: "tok", capabilities: { inlineButtons: "all" } },
|
||||||
},
|
},
|
||||||
} as ClawdbotConfig;
|
} as ClawdbotConfig;
|
||||||
await handleTelegramAction(
|
await handleTelegramAction(
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
import { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
|
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +7,10 @@ import {
|
|||||||
sendMessageTelegram,
|
sendMessageTelegram,
|
||||||
} from "../../telegram/send.js";
|
} from "../../telegram/send.js";
|
||||||
import { resolveTelegramToken } from "../../telegram/token.js";
|
import { resolveTelegramToken } from "../../telegram/token.js";
|
||||||
|
import {
|
||||||
|
resolveTelegramInlineButtonsScope,
|
||||||
|
resolveTelegramTargetChatType,
|
||||||
|
} from "../../telegram/inline-buttons.js";
|
||||||
import {
|
import {
|
||||||
createActionGate,
|
createActionGate,
|
||||||
jsonResult,
|
jsonResult,
|
||||||
@@ -22,19 +25,6 @@ type TelegramButton = {
|
|||||||
callback_data: string;
|
callback_data: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function hasInlineButtonsCapability(params: {
|
|
||||||
cfg: ClawdbotConfig;
|
|
||||||
accountId?: string | undefined;
|
|
||||||
}): boolean {
|
|
||||||
const caps =
|
|
||||||
resolveChannelCapabilities({
|
|
||||||
cfg: params.cfg,
|
|
||||||
channel: "telegram",
|
|
||||||
accountId: params.accountId,
|
|
||||||
}) ?? [];
|
|
||||||
return caps.some((cap) => cap.toLowerCase() === "inlinebuttons");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readTelegramButtons(
|
export function readTelegramButtons(
|
||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
): TelegramButton[][] | undefined {
|
): TelegramButton[][] | undefined {
|
||||||
@@ -138,10 +128,32 @@ export async function handleTelegramAction(
|
|||||||
allowEmpty: true,
|
allowEmpty: true,
|
||||||
}) ?? "";
|
}) ?? "";
|
||||||
const buttons = readTelegramButtons(params);
|
const buttons = readTelegramButtons(params);
|
||||||
if (buttons && !hasInlineButtonsCapability({ cfg, accountId: accountId ?? undefined })) {
|
if (buttons) {
|
||||||
throw new Error(
|
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
|
||||||
'Telegram inline buttons requested but not enabled. Add "inlineButtons" to channels.telegram.capabilities (or channels.telegram.accounts.<id>.capabilities).',
|
cfg,
|
||||||
);
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
if (inlineButtonsScope === "off") {
|
||||||
|
throw new Error(
|
||||||
|
'Telegram inline buttons are disabled. Set channels.telegram.capabilities.inlineButtons to "dm", "group", "all", or "allowlist".',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (inlineButtonsScope === "dm" || inlineButtonsScope === "group") {
|
||||||
|
const targetType = resolveTelegramTargetChatType(to);
|
||||||
|
if (targetType === "unknown") {
|
||||||
|
throw new Error(
|
||||||
|
`Telegram inline buttons require a numeric chat id when inlineButtons="${inlineButtonsScope}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (inlineButtonsScope === "dm" && targetType !== "direct") {
|
||||||
|
throw new Error('Telegram inline buttons are limited to DMs when inlineButtons="dm".');
|
||||||
|
}
|
||||||
|
if (inlineButtonsScope === "group" && targetType !== "group") {
|
||||||
|
throw new Error(
|
||||||
|
'Telegram inline buttons are limited to groups when inlineButtons="group".',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Optional threading parameters for forum topics and reply chains
|
// Optional threading parameters for forum topics and reply chains
|
||||||
const replyToMessageId = readNumberParam(params, "replyToMessageId", {
|
const replyToMessageId = readNumberParam(params, "replyToMessageId", {
|
||||||
|
|||||||
@@ -4,32 +4,12 @@ import {
|
|||||||
readStringParam,
|
readStringParam,
|
||||||
} from "../../../agents/tools/common.js";
|
} from "../../../agents/tools/common.js";
|
||||||
import { handleTelegramAction } from "../../../agents/tools/telegram-actions.js";
|
import { handleTelegramAction } from "../../../agents/tools/telegram-actions.js";
|
||||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
|
||||||
import { listEnabledTelegramAccounts } from "../../../telegram/accounts.js";
|
import { listEnabledTelegramAccounts } from "../../../telegram/accounts.js";
|
||||||
|
import { isTelegramInlineButtonsEnabled } from "../../../telegram/inline-buttons.js";
|
||||||
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js";
|
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js";
|
||||||
|
|
||||||
const providerId = "telegram";
|
const providerId = "telegram";
|
||||||
|
|
||||||
function hasTelegramInlineButtons(cfg: ClawdbotConfig): boolean {
|
|
||||||
const caps = new Set<string>();
|
|
||||||
for (const entry of cfg.channels?.telegram?.capabilities ?? []) {
|
|
||||||
const trimmed = String(entry).trim();
|
|
||||||
if (trimmed) caps.add(trimmed.toLowerCase());
|
|
||||||
}
|
|
||||||
const accounts = cfg.channels?.telegram?.accounts;
|
|
||||||
if (accounts && typeof accounts === "object") {
|
|
||||||
for (const account of Object.values(accounts)) {
|
|
||||||
const accountCaps = (account as { capabilities?: unknown })?.capabilities;
|
|
||||||
if (!Array.isArray(accountCaps)) continue;
|
|
||||||
for (const entry of accountCaps) {
|
|
||||||
const trimmed = String(entry).trim();
|
|
||||||
if (trimmed) caps.add(trimmed.toLowerCase());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return caps.has("inlinebuttons");
|
|
||||||
}
|
|
||||||
|
|
||||||
export const telegramMessageActions: ChannelMessageActionAdapter = {
|
export const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||||
listActions: ({ cfg }) => {
|
listActions: ({ cfg }) => {
|
||||||
const accounts = listEnabledTelegramAccounts(cfg).filter(
|
const accounts = listEnabledTelegramAccounts(cfg).filter(
|
||||||
@@ -42,7 +22,15 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
|||||||
if (gate("deleteMessage")) actions.add("delete");
|
if (gate("deleteMessage")) actions.add("delete");
|
||||||
return Array.from(actions);
|
return Array.from(actions);
|
||||||
},
|
},
|
||||||
supportsButtons: ({ cfg }) => hasTelegramInlineButtons(cfg),
|
supportsButtons: ({ cfg }) => {
|
||||||
|
const accounts = listEnabledTelegramAccounts(cfg).filter(
|
||||||
|
(account) => account.tokenSource !== "none",
|
||||||
|
);
|
||||||
|
if (accounts.length === 0) return false;
|
||||||
|
return accounts.some((account) =>
|
||||||
|
isTelegramInlineButtonsEnabled({ cfg, accountId: account.accountId }),
|
||||||
|
);
|
||||||
|
},
|
||||||
extractToolSend: ({ args }) => {
|
extractToolSend: ({ args }) => {
|
||||||
const action = typeof args.action === "string" ? args.action.trim() : "";
|
const action = typeof args.action === "string" ? args.action.trim() : "";
|
||||||
if (action !== "sendMessage") return null;
|
if (action !== "sendMessage") return null;
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",
|
"channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",
|
||||||
"channels.telegram.retry.jitter": "Telegram Retry Jitter",
|
"channels.telegram.retry.jitter": "Telegram Retry Jitter",
|
||||||
"channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)",
|
"channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)",
|
||||||
|
"channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons",
|
||||||
"channels.whatsapp.dmPolicy": "WhatsApp DM Policy",
|
"channels.whatsapp.dmPolicy": "WhatsApp DM Policy",
|
||||||
"channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode",
|
"channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode",
|
||||||
"channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)",
|
"channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)",
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ export type TelegramActionConfig = {
|
|||||||
deleteMessage?: boolean;
|
deleteMessage?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist";
|
||||||
|
|
||||||
|
export type TelegramCapabilitiesConfig =
|
||||||
|
| string[]
|
||||||
|
| {
|
||||||
|
inlineButtons?: TelegramInlineButtonsScope;
|
||||||
|
};
|
||||||
|
|
||||||
/** Custom command definition for Telegram bot menu. */
|
/** Custom command definition for Telegram bot menu. */
|
||||||
export type TelegramCustomCommand = {
|
export type TelegramCustomCommand = {
|
||||||
/** Command name (without leading /). */
|
/** Command name (without leading /). */
|
||||||
@@ -26,7 +34,7 @@ export type TelegramAccountConfig = {
|
|||||||
/** Optional display name for this account (used in CLI/UI lists). */
|
/** Optional display name for this account (used in CLI/UI lists). */
|
||||||
name?: string;
|
name?: string;
|
||||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||||
capabilities?: string[];
|
capabilities?: TelegramCapabilitiesConfig;
|
||||||
/** Override native command registration for Telegram (bool or "auto"). */
|
/** Override native command registration for Telegram (bool or "auto"). */
|
||||||
commands?: ProviderCommandsConfig;
|
commands?: ProviderCommandsConfig;
|
||||||
/** Custom commands to register in Telegram's command menu (merged with native). */
|
/** Custom commands to register in Telegram's command menu (merged with native). */
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ import {
|
|||||||
resolveTelegramCustomCommands,
|
resolveTelegramCustomCommands,
|
||||||
} from "./telegram-custom-commands.js";
|
} from "./telegram-custom-commands.js";
|
||||||
|
|
||||||
|
const TelegramInlineButtonsScopeSchema = z.enum(["off", "dm", "group", "all", "allowlist"]);
|
||||||
|
|
||||||
|
const TelegramCapabilitiesSchema = z.union([
|
||||||
|
z.array(z.string()),
|
||||||
|
z.object({
|
||||||
|
inlineButtons: TelegramInlineButtonsScopeSchema.optional(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
export const TelegramTopicSchema = z.object({
|
export const TelegramTopicSchema = z.object({
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
skills: z.array(z.string()).optional(),
|
skills: z.array(z.string()).optional(),
|
||||||
@@ -62,7 +71,7 @@ const validateTelegramCustomCommands = (
|
|||||||
|
|
||||||
export const TelegramAccountSchemaBase = z.object({
|
export const TelegramAccountSchemaBase = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
capabilities: z.array(z.string()).optional(),
|
capabilities: TelegramCapabilitiesSchema.optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
commands: ProviderCommandsSchema,
|
commands: ProviderCommandsSchema,
|
||||||
customCommands: z.array(TelegramCustomCommandSchema).optional(),
|
customCommands: z.array(TelegramCustomCommandSchema).optional(),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { TelegramMessage } from "./bot/types.js";
|
|||||||
import { firstDefined, isSenderAllowed, normalizeAllowFrom } from "./bot-access.js";
|
import { firstDefined, isSenderAllowed, normalizeAllowFrom } from "./bot-access.js";
|
||||||
import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js";
|
import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js";
|
||||||
import { migrateTelegramGroupConfig } from "./group-migration.js";
|
import { migrateTelegramGroupConfig } from "./group-migration.js";
|
||||||
|
import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js";
|
||||||
import { readTelegramAllowFromStore } from "./pairing-store.js";
|
import { readTelegramAllowFromStore } from "./pairing-store.js";
|
||||||
import { resolveChannelConfigWrites } from "../channels/plugins/config-writes.js";
|
import { resolveChannelConfigWrites } from "../channels/plugins/config-writes.js";
|
||||||
|
|
||||||
@@ -183,6 +184,131 @@ export const registerTelegramHandlers = ({
|
|||||||
const callbackMessage = callback.message;
|
const callbackMessage = callback.message;
|
||||||
if (!data || !callbackMessage) return;
|
if (!data || !callbackMessage) return;
|
||||||
|
|
||||||
|
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
|
||||||
|
cfg,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
|
if (inlineButtonsScope === "off") return;
|
||||||
|
|
||||||
|
const chatId = callbackMessage.chat.id;
|
||||||
|
const isGroup =
|
||||||
|
callbackMessage.chat.type === "group" || callbackMessage.chat.type === "supergroup";
|
||||||
|
if (inlineButtonsScope === "dm" && isGroup) return;
|
||||||
|
if (inlineButtonsScope === "group" && !isGroup) return;
|
||||||
|
|
||||||
|
const messageThreadId = (callbackMessage as { message_thread_id?: number }).message_thread_id;
|
||||||
|
const isForum = (callbackMessage.chat as { is_forum?: boolean }).is_forum === true;
|
||||||
|
const resolvedThreadId = resolveTelegramForumThreadId({
|
||||||
|
isForum,
|
||||||
|
messageThreadId,
|
||||||
|
});
|
||||||
|
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId);
|
||||||
|
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
|
||||||
|
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
|
||||||
|
const effectiveGroupAllow = normalizeAllowFrom([
|
||||||
|
...(groupAllowOverride ?? groupAllowFrom ?? []),
|
||||||
|
...storeAllowFrom,
|
||||||
|
]);
|
||||||
|
const effectiveDmAllow = normalizeAllowFrom([
|
||||||
|
...(telegramCfg.allowFrom ?? []),
|
||||||
|
...storeAllowFrom,
|
||||||
|
]);
|
||||||
|
const dmPolicy = telegramCfg.dmPolicy ?? "pairing";
|
||||||
|
const senderId = callback.from?.id ? String(callback.from.id) : "";
|
||||||
|
const senderUsername = callback.from?.username ?? "";
|
||||||
|
|
||||||
|
if (isGroup) {
|
||||||
|
if (groupConfig?.enabled === false) {
|
||||||
|
logVerbose(`Blocked telegram group ${chatId} (group disabled)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (topicConfig?.enabled === false) {
|
||||||
|
logVerbose(
|
||||||
|
`Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof groupAllowOverride !== "undefined") {
|
||||||
|
const allowed =
|
||||||
|
senderId &&
|
||||||
|
isSenderAllowed({
|
||||||
|
allow: effectiveGroupAllow,
|
||||||
|
senderId,
|
||||||
|
senderUsername,
|
||||||
|
});
|
||||||
|
if (!allowed) {
|
||||||
|
logVerbose(
|
||||||
|
`Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const groupPolicy = telegramCfg.groupPolicy ?? "open";
|
||||||
|
if (groupPolicy === "disabled") {
|
||||||
|
logVerbose(`Blocked telegram group message (groupPolicy: disabled)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (groupPolicy === "allowlist") {
|
||||||
|
if (!senderId) {
|
||||||
|
logVerbose(`Blocked telegram group message (no sender ID, groupPolicy: allowlist)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!effectiveGroupAllow.hasEntries) {
|
||||||
|
logVerbose(
|
||||||
|
"Blocked telegram group message (groupPolicy: allowlist, no group allowlist entries)",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!isSenderAllowed({
|
||||||
|
allow: effectiveGroupAllow,
|
||||||
|
senderId,
|
||||||
|
senderUsername,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
logVerbose(
|
||||||
|
`Blocked telegram group message from ${senderId} (groupPolicy: allowlist)`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const groupAllowlist = resolveGroupPolicy(chatId);
|
||||||
|
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
|
||||||
|
logger.info(
|
||||||
|
{ chatId, title: callbackMessage.chat.title, reason: "not-allowed" },
|
||||||
|
"skipping group message",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inlineButtonsScope === "allowlist") {
|
||||||
|
if (!isGroup) {
|
||||||
|
if (dmPolicy === "disabled") return;
|
||||||
|
if (dmPolicy !== "open") {
|
||||||
|
const allowed =
|
||||||
|
effectiveDmAllow.hasWildcard ||
|
||||||
|
(effectiveDmAllow.hasEntries &&
|
||||||
|
isSenderAllowed({
|
||||||
|
allow: effectiveDmAllow,
|
||||||
|
senderId,
|
||||||
|
senderUsername,
|
||||||
|
}));
|
||||||
|
if (!allowed) return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const allowed =
|
||||||
|
effectiveGroupAllow.hasWildcard ||
|
||||||
|
(effectiveGroupAllow.hasEntries &&
|
||||||
|
isSenderAllowed({
|
||||||
|
allow: effectiveGroupAllow,
|
||||||
|
senderId,
|
||||||
|
senderUsername,
|
||||||
|
}));
|
||||||
|
if (!allowed) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const syntheticMessage: TelegramMessage = {
|
const syntheticMessage: TelegramMessage = {
|
||||||
...callbackMessage,
|
...callbackMessage,
|
||||||
from: callback.from,
|
from: callback.from,
|
||||||
@@ -191,7 +317,6 @@ export const registerTelegramHandlers = ({
|
|||||||
caption_entities: undefined,
|
caption_entities: undefined,
|
||||||
entities: undefined,
|
entities: undefined,
|
||||||
};
|
};
|
||||||
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
|
|
||||||
const getFile = typeof ctx.getFile === "function" ? ctx.getFile.bind(ctx) : async () => ({});
|
const getFile = typeof ctx.getFile === "function" ? ctx.getFile.bind(ctx) : async () => ({});
|
||||||
await processMessage({ message: syntheticMessage, me: ctx.me, getFile }, [], storeAllowFrom, {
|
await processMessage({ message: syntheticMessage, me: ctx.me, getFile }, [], storeAllowFrom, {
|
||||||
forceWasMentioned: true,
|
forceWasMentioned: true,
|
||||||
|
|||||||
@@ -382,6 +382,47 @@ describe("createTelegramBot", () => {
|
|||||||
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-1");
|
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks callback_query when inline buttons are allowlist-only and sender not authorized", async () => {
|
||||||
|
onSpy.mockReset();
|
||||||
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
replySpy.mockReset();
|
||||||
|
|
||||||
|
createTelegramBot({
|
||||||
|
token: "tok",
|
||||||
|
config: {
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
dmPolicy: "pairing",
|
||||||
|
capabilities: { inlineButtons: "allowlist" },
|
||||||
|
allowFrom: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
|
||||||
|
ctx: Record<string, unknown>,
|
||||||
|
) => Promise<void>;
|
||||||
|
expect(callbackHandler).toBeDefined();
|
||||||
|
|
||||||
|
await callbackHandler({
|
||||||
|
callbackQuery: {
|
||||||
|
id: "cbq-2",
|
||||||
|
data: "cmd:option_b",
|
||||||
|
from: { id: 9, first_name: "Ada", username: "ada_bot" },
|
||||||
|
message: {
|
||||||
|
chat: { id: 1234, type: "private" },
|
||||||
|
date: 1736380800,
|
||||||
|
message_id: 11,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
me: { username: "clawdbot_bot" },
|
||||||
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replySpy).not.toHaveBeenCalled();
|
||||||
|
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-2");
|
||||||
|
});
|
||||||
|
|
||||||
it("wraps inbound message with Telegram envelope", async () => {
|
it("wraps inbound message with Telegram envelope", async () => {
|
||||||
const originalTz = process.env.TZ;
|
const originalTz = process.env.TZ;
|
||||||
process.env.TZ = "Europe/Vienna";
|
process.env.TZ = "Europe/Vienna";
|
||||||
|
|||||||
73
src/telegram/inline-buttons.ts
Normal file
73
src/telegram/inline-buttons.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import type { TelegramInlineButtonsScope } from "../config/types.telegram.js";
|
||||||
|
import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js";
|
||||||
|
|
||||||
|
const DEFAULT_INLINE_BUTTONS_SCOPE: TelegramInlineButtonsScope = "allowlist";
|
||||||
|
|
||||||
|
function normalizeInlineButtonsScope(value: unknown): TelegramInlineButtonsScope | undefined {
|
||||||
|
if (typeof value !== "string") return undefined;
|
||||||
|
const trimmed = value.trim().toLowerCase();
|
||||||
|
if (
|
||||||
|
trimmed === "off" ||
|
||||||
|
trimmed === "dm" ||
|
||||||
|
trimmed === "group" ||
|
||||||
|
trimmed === "all" ||
|
||||||
|
trimmed === "allowlist"
|
||||||
|
) {
|
||||||
|
return trimmed as TelegramInlineButtonsScope;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInlineButtonsScopeFromCapabilities(
|
||||||
|
capabilities: unknown,
|
||||||
|
): TelegramInlineButtonsScope {
|
||||||
|
if (!capabilities) return DEFAULT_INLINE_BUTTONS_SCOPE;
|
||||||
|
if (Array.isArray(capabilities)) {
|
||||||
|
const enabled = capabilities.some(
|
||||||
|
(entry) => String(entry).trim().toLowerCase() === "inlinebuttons",
|
||||||
|
);
|
||||||
|
return enabled ? "all" : "off";
|
||||||
|
}
|
||||||
|
if (typeof capabilities === "object") {
|
||||||
|
const inlineButtons = (capabilities as { inlineButtons?: unknown }).inlineButtons;
|
||||||
|
return normalizeInlineButtonsScope(inlineButtons) ?? DEFAULT_INLINE_BUTTONS_SCOPE;
|
||||||
|
}
|
||||||
|
return DEFAULT_INLINE_BUTTONS_SCOPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTelegramInlineButtonsScope(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
accountId?: string | null;
|
||||||
|
}): TelegramInlineButtonsScope {
|
||||||
|
const account = resolveTelegramAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||||
|
return resolveInlineButtonsScopeFromCapabilities(account.config.capabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTelegramInlineButtonsEnabled(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
accountId?: string | null;
|
||||||
|
}): boolean {
|
||||||
|
if (params.accountId) {
|
||||||
|
return resolveTelegramInlineButtonsScope(params) !== "off";
|
||||||
|
}
|
||||||
|
const accountIds = listTelegramAccountIds(params.cfg);
|
||||||
|
if (accountIds.length === 0) {
|
||||||
|
return resolveTelegramInlineButtonsScope(params) !== "off";
|
||||||
|
}
|
||||||
|
return accountIds.some(
|
||||||
|
(accountId) =>
|
||||||
|
resolveTelegramInlineButtonsScope({ cfg: params.cfg, accountId }) !== "off",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTelegramTargetChatType(
|
||||||
|
target: string,
|
||||||
|
): "direct" | "group" | "unknown" {
|
||||||
|
const trimmed = target.trim();
|
||||||
|
if (!trimmed) return "unknown";
|
||||||
|
if (/^-?\d+$/.test(trimmed)) {
|
||||||
|
return trimmed.startsWith("-") ? "group" : "direct";
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user