feat(telegram): inline keyboard buttons (#491)
Co-authored-by: Azade <azade@hey.com>
This commit is contained in:
@@ -77,6 +77,7 @@
|
|||||||
- Control UI: drop explicit `ui:install` step; `ui:build` now auto-installs UI deps (docs + update flow).
|
- Control UI: drop explicit `ui:install` step; `ui:build` now auto-installs UI deps (docs + update flow).
|
||||||
- Telegram: retry long-polling conflicts with backoff to avoid fatal exits.
|
- Telegram: retry long-polling conflicts with backoff to avoid fatal exits.
|
||||||
- Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos
|
- Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos
|
||||||
|
- Telegram: add inline keyboard buttons (capability-gated) and route callback query payloads as messages. (#491) — thanks @azade-c
|
||||||
- WhatsApp: resolve @lid JIDs via Baileys mapping to unblock inbound messages. (#415)
|
- WhatsApp: resolve @lid JIDs via Baileys mapping to unblock inbound messages. (#415)
|
||||||
- Pairing: replies now include sender ids for Discord/Slack/Signal/iMessage/WhatsApp; pairing list labels them explicitly.
|
- Pairing: replies now include sender ids for Discord/Slack/Signal/iMessage/WhatsApp; pairing list labels them explicitly.
|
||||||
- Messages: default inbound/outbound prefixes from the routed agent’s `identity.name` when set. (#578) — thanks @p6l-richard
|
- Messages: default inbound/outbound prefixes from the routed agent’s `identity.name` when set. (#578) — thanks @p6l-richard
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ Target formats (`--to`):
|
|||||||
- `send`
|
- `send`
|
||||||
- Required: `--to`, `--message`
|
- Required: `--to`, `--message`
|
||||||
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
|
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
|
||||||
|
- Telegram only: `--buttons-json` (requires `"inlineButtons"` in `telegram.capabilities` or `telegram.accounts.<id>.capabilities`)
|
||||||
|
|
||||||
- `poll`
|
- `poll`
|
||||||
- Required: `--to`, `--poll-question`, `--poll-option` (repeat)
|
- Required: `--to`, `--poll-question`, `--poll-option` (repeat)
|
||||||
@@ -174,3 +175,9 @@ React in Slack:
|
|||||||
clawdbot message react --provider slack \
|
clawdbot message react --provider slack \
|
||||||
--to C123 --message-id 456 --emoji "✅"
|
--to C123 --message-id 456 --emoji "✅"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Send Telegram inline buttons:
|
||||||
|
```
|
||||||
|
clawdbot message send --provider telegram --to @mychat --message "Choose:" \
|
||||||
|
--buttons-json '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]'
|
||||||
|
```
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import type {
|
|||||||
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
|
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
|
||||||
import { isCacheEnabled, resolveCacheTtlMs } from "../config/cache-utils.js";
|
import { isCacheEnabled, resolveCacheTtlMs } from "../config/cache-utils.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { resolveProviderCapabilities } from "../config/provider-capabilities.js";
|
||||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||||
import { createSubsystemLogger } from "../logging.js";
|
import { createSubsystemLogger } from "../logging.js";
|
||||||
import { splitMediaFromOutput } from "../media/parse.js";
|
import { splitMediaFromOutput } from "../media/parse.js";
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
type enqueueCommand,
|
type enqueueCommand,
|
||||||
enqueueCommandInLane,
|
enqueueCommandInLane,
|
||||||
} from "../process/command-queue.js";
|
} from "../process/command-queue.js";
|
||||||
|
import { normalizeMessageProvider } from "../utils/message-provider.js";
|
||||||
import { resolveUserPath } from "../utils.js";
|
import { resolveUserPath } from "../utils.js";
|
||||||
import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
||||||
import {
|
import {
|
||||||
@@ -580,6 +582,8 @@ function buildEmbeddedSystemPrompt(params: {
|
|||||||
arch: string;
|
arch: string;
|
||||||
node: string;
|
node: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
provider?: string;
|
||||||
|
capabilities?: string[];
|
||||||
};
|
};
|
||||||
sandboxInfo?: EmbeddedSandboxInfo;
|
sandboxInfo?: EmbeddedSandboxInfo;
|
||||||
tools: AgentTool[];
|
tools: AgentTool[];
|
||||||
@@ -855,12 +859,24 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
config: params.config,
|
config: params.config,
|
||||||
});
|
});
|
||||||
const machineName = await getMachineDisplayName();
|
const machineName = await getMachineDisplayName();
|
||||||
|
const runtimeProvider = normalizeMessageProvider(
|
||||||
|
params.messageProvider,
|
||||||
|
);
|
||||||
|
const runtimeCapabilities = runtimeProvider
|
||||||
|
? (resolveProviderCapabilities({
|
||||||
|
cfg: params.config,
|
||||||
|
provider: runtimeProvider,
|
||||||
|
accountId: params.agentAccountId,
|
||||||
|
}) ?? [])
|
||||||
|
: undefined;
|
||||||
const runtimeInfo = {
|
const runtimeInfo = {
|
||||||
host: machineName,
|
host: machineName,
|
||||||
os: `${os.type()} ${os.release()}`,
|
os: `${os.type()} ${os.release()}`,
|
||||||
arch: os.arch(),
|
arch: os.arch(),
|
||||||
node: process.version,
|
node: process.version,
|
||||||
model: `${provider}/${modelId}`,
|
model: `${provider}/${modelId}`,
|
||||||
|
provider: runtimeProvider,
|
||||||
|
capabilities: runtimeCapabilities,
|
||||||
};
|
};
|
||||||
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox);
|
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox);
|
||||||
const reasoningTagHint = provider === "ollama";
|
const reasoningTagHint = provider === "ollama";
|
||||||
|
|||||||
@@ -109,4 +109,27 @@ describe("buildAgentSystemPrompt", () => {
|
|||||||
expect(prompt).toContain("## IDENTITY.md");
|
expect(prompt).toContain("## IDENTITY.md");
|
||||||
expect(prompt).toContain("Bravo");
|
expect(prompt).toContain("Bravo");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("summarizes the message tool when available", () => {
|
||||||
|
const prompt = buildAgentSystemPrompt({
|
||||||
|
workspaceDir: "/tmp/clawd",
|
||||||
|
toolNames: ["message"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prompt).toContain("message: Send messages and provider actions");
|
||||||
|
expect(prompt).toContain("### message tool");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes runtime provider capabilities when present", () => {
|
||||||
|
const prompt = buildAgentSystemPrompt({
|
||||||
|
workspaceDir: "/tmp/clawd",
|
||||||
|
runtimeInfo: {
|
||||||
|
provider: "telegram",
|
||||||
|
capabilities: ["inlineButtons"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prompt).toContain("provider=telegram");
|
||||||
|
expect(prompt).toContain("capabilities=inlineButtons");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
arch?: string;
|
arch?: string;
|
||||||
node?: string;
|
node?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
provider?: string;
|
||||||
|
capabilities?: string[];
|
||||||
};
|
};
|
||||||
sandboxInfo?: {
|
sandboxInfo?: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -43,6 +45,7 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
canvas: "Present/eval/snapshot the Canvas",
|
canvas: "Present/eval/snapshot the Canvas",
|
||||||
nodes: "List/describe/notify/camera/screen on paired nodes",
|
nodes: "List/describe/notify/camera/screen on paired nodes",
|
||||||
cron: "Manage cron jobs and wake events",
|
cron: "Manage cron jobs and wake events",
|
||||||
|
message: "Send messages and provider actions",
|
||||||
gateway:
|
gateway:
|
||||||
"Restart, apply config, or run updates on the running Clawdbot process",
|
"Restart, apply config, or run updates on the running Clawdbot process",
|
||||||
agents_list: "List agent ids allowed for sessions_spawn",
|
agents_list: "List agent ids allowed for sessions_spawn",
|
||||||
@@ -51,10 +54,6 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
sessions_send: "Send a message to another session/sub-agent",
|
sessions_send: "Send a message to another session/sub-agent",
|
||||||
sessions_spawn: "Spawn a sub-agent session",
|
sessions_spawn: "Spawn a sub-agent session",
|
||||||
image: "Analyze an image with the configured image model",
|
image: "Analyze an image with the configured image model",
|
||||||
discord: "Send Discord reactions/messages and manage threads",
|
|
||||||
slack: "Send Slack messages and manage channels",
|
|
||||||
telegram: "Send Telegram reactions",
|
|
||||||
whatsapp: "Send WhatsApp reactions",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toolOrder = [
|
const toolOrder = [
|
||||||
@@ -71,16 +70,13 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
"canvas",
|
"canvas",
|
||||||
"nodes",
|
"nodes",
|
||||||
"cron",
|
"cron",
|
||||||
|
"message",
|
||||||
"gateway",
|
"gateway",
|
||||||
"agents_list",
|
"agents_list",
|
||||||
"sessions_list",
|
"sessions_list",
|
||||||
"sessions_history",
|
"sessions_history",
|
||||||
"sessions_send",
|
"sessions_send",
|
||||||
"image",
|
"image",
|
||||||
"discord",
|
|
||||||
"slack",
|
|
||||||
"telegram",
|
|
||||||
"whatsapp",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const normalizedTools = (params.toolNames ?? [])
|
const normalizedTools = (params.toolNames ?? [])
|
||||||
@@ -127,6 +123,16 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
? `Heartbeat prompt: ${heartbeatPrompt}`
|
? `Heartbeat prompt: ${heartbeatPrompt}`
|
||||||
: "Heartbeat prompt: (configured)";
|
: "Heartbeat prompt: (configured)";
|
||||||
const runtimeInfo = params.runtimeInfo;
|
const runtimeInfo = params.runtimeInfo;
|
||||||
|
const runtimeProvider = runtimeInfo?.provider?.trim().toLowerCase();
|
||||||
|
const runtimeCapabilities = (runtimeInfo?.capabilities ?? [])
|
||||||
|
.map((cap) => String(cap).trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const runtimeCapabilitiesLower = new Set(
|
||||||
|
runtimeCapabilities.map((cap) => cap.toLowerCase()),
|
||||||
|
);
|
||||||
|
const telegramInlineButtonsEnabled =
|
||||||
|
runtimeProvider === "telegram" &&
|
||||||
|
runtimeCapabilitiesLower.has("inlinebuttons");
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
"You are a personal assistant running inside Clawdbot.",
|
"You are a personal assistant running inside Clawdbot.",
|
||||||
@@ -229,6 +235,21 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
"- Reply in current session → automatically routes to the source provider (Signal, Telegram, etc.)",
|
"- Reply in current session → automatically routes to the source provider (Signal, Telegram, etc.)",
|
||||||
"- Cross-session messaging → use sessions_send(sessionKey, message)",
|
"- Cross-session messaging → use sessions_send(sessionKey, message)",
|
||||||
"- Never use bash/curl for provider messaging; Clawdbot handles all routing internally.",
|
"- Never use bash/curl for provider messaging; Clawdbot handles all routing internally.",
|
||||||
|
availableTools.has("message")
|
||||||
|
? [
|
||||||
|
"",
|
||||||
|
"### message tool",
|
||||||
|
"- Use `message` for proactive sends + provider actions (polls, reactions, etc.).",
|
||||||
|
"- If multiple providers are configured, pass `provider` (whatsapp|telegram|discord|slack|signal|imessage|msteams).",
|
||||||
|
telegramInlineButtonsEnabled
|
||||||
|
? "- Telegram: inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
|
||||||
|
: runtimeProvider === "telegram"
|
||||||
|
? '- Telegram: inline buttons NOT enabled. If you need them, ask to add "inlineButtons" to telegram.capabilities or telegram.accounts.<id>.capabilities.'
|
||||||
|
: "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
: "",
|
||||||
"",
|
"",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -270,6 +291,14 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
: "",
|
: "",
|
||||||
runtimeInfo?.node ? `node=${runtimeInfo.node}` : "",
|
runtimeInfo?.node ? `node=${runtimeInfo.node}` : "",
|
||||||
runtimeInfo?.model ? `model=${runtimeInfo.model}` : "",
|
runtimeInfo?.model ? `model=${runtimeInfo.model}` : "",
|
||||||
|
runtimeProvider ? `provider=${runtimeProvider}` : "",
|
||||||
|
runtimeProvider
|
||||||
|
? `capabilities=${
|
||||||
|
runtimeCapabilities.length > 0
|
||||||
|
? runtimeCapabilities.join(",")
|
||||||
|
: "none"
|
||||||
|
}`
|
||||||
|
: "",
|
||||||
`thinking=${params.defaultThinkLevel ?? "off"}`,
|
`thinking=${params.defaultThinkLevel ?? "off"}`,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox";
|
|||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
|
import { listEnabledDiscordAccounts } from "../../discord/accounts.js";
|
||||||
import {
|
import {
|
||||||
type MessagePollResult,
|
type MessagePollResult,
|
||||||
type MessageSendResult,
|
type MessageSendResult,
|
||||||
@@ -9,9 +10,13 @@ import {
|
|||||||
sendPoll,
|
sendPoll,
|
||||||
} from "../../infra/outbound/message.js";
|
} from "../../infra/outbound/message.js";
|
||||||
import { resolveMessageProviderSelection } from "../../infra/outbound/provider-selection.js";
|
import { resolveMessageProviderSelection } from "../../infra/outbound/provider-selection.js";
|
||||||
|
import { resolveMSTeamsCredentials } from "../../msteams/token.js";
|
||||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||||
|
import { listEnabledSlackAccounts } from "../../slack/accounts.js";
|
||||||
|
import { listEnabledTelegramAccounts } from "../../telegram/accounts.js";
|
||||||
import type { AnyAgentTool } from "./common.js";
|
import type { AnyAgentTool } from "./common.js";
|
||||||
import {
|
import {
|
||||||
|
createActionGate,
|
||||||
jsonResult,
|
jsonResult,
|
||||||
readNumberParam,
|
readNumberParam,
|
||||||
readStringArrayParam,
|
readStringArrayParam,
|
||||||
@@ -62,6 +67,19 @@ const MessageToolSchema = Type.Object({
|
|||||||
to: Type.Optional(Type.String()),
|
to: Type.Optional(Type.String()),
|
||||||
message: Type.Optional(Type.String()),
|
message: Type.Optional(Type.String()),
|
||||||
media: Type.Optional(Type.String()),
|
media: Type.Optional(Type.String()),
|
||||||
|
buttons: Type.Optional(
|
||||||
|
Type.Array(
|
||||||
|
Type.Array(
|
||||||
|
Type.Object({
|
||||||
|
text: Type.String(),
|
||||||
|
callback_data: Type.String(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
description: "Telegram inline keyboard buttons (array of button rows)",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
messageId: Type.Optional(Type.String()),
|
messageId: Type.Optional(Type.String()),
|
||||||
replyTo: Type.Optional(Type.String()),
|
replyTo: Type.Optional(Type.String()),
|
||||||
threadId: Type.Optional(Type.String()),
|
threadId: Type.Optional(Type.String()),
|
||||||
@@ -118,6 +136,164 @@ type MessageToolOptions = {
|
|||||||
config?: ClawdbotConfig;
|
config?: ClawdbotConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function hasTelegramInlineButtons(cfg: ClawdbotConfig): boolean {
|
||||||
|
const caps = new Set<string>();
|
||||||
|
for (const entry of cfg.telegram?.capabilities ?? []) {
|
||||||
|
const trimmed = String(entry).trim();
|
||||||
|
if (trimmed) caps.add(trimmed.toLowerCase());
|
||||||
|
}
|
||||||
|
const accounts = cfg.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");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMessageActionSchema(cfg: ClawdbotConfig) {
|
||||||
|
const actions = new Set<string>(["send"]);
|
||||||
|
|
||||||
|
const discordAccounts = listEnabledDiscordAccounts(cfg).filter(
|
||||||
|
(account) => account.tokenSource !== "none",
|
||||||
|
);
|
||||||
|
const discordEnabled = discordAccounts.length > 0;
|
||||||
|
const discordGate = createActionGate(cfg.discord?.actions);
|
||||||
|
|
||||||
|
const slackAccounts = listEnabledSlackAccounts(cfg).filter(
|
||||||
|
(account) => account.botTokenSource !== "none",
|
||||||
|
);
|
||||||
|
const slackEnabled = slackAccounts.length > 0;
|
||||||
|
const isSlackActionEnabled = (key: string, defaultValue = true) => {
|
||||||
|
if (!slackEnabled) return false;
|
||||||
|
for (const account of slackAccounts) {
|
||||||
|
const gate = createActionGate(
|
||||||
|
(account.actions ?? cfg.slack?.actions) as Record<
|
||||||
|
string,
|
||||||
|
boolean | undefined
|
||||||
|
>,
|
||||||
|
);
|
||||||
|
if (gate(key, defaultValue)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const telegramAccounts = listEnabledTelegramAccounts(cfg).filter(
|
||||||
|
(account) => account.tokenSource !== "none",
|
||||||
|
);
|
||||||
|
const telegramEnabled = telegramAccounts.length > 0;
|
||||||
|
const telegramGate = createActionGate(cfg.telegram?.actions);
|
||||||
|
|
||||||
|
const whatsappGate = createActionGate(cfg.whatsapp?.actions);
|
||||||
|
|
||||||
|
const canDiscordReactions = discordEnabled && discordGate("reactions");
|
||||||
|
const canSlackReactions = isSlackActionEnabled("reactions");
|
||||||
|
const canTelegramReactions = telegramEnabled && telegramGate("reactions");
|
||||||
|
const canWhatsAppReactions = cfg.whatsapp ? whatsappGate("reactions") : false;
|
||||||
|
const canAnyReactions =
|
||||||
|
canDiscordReactions ||
|
||||||
|
canSlackReactions ||
|
||||||
|
canTelegramReactions ||
|
||||||
|
canWhatsAppReactions;
|
||||||
|
if (canAnyReactions) actions.add("react");
|
||||||
|
if (canDiscordReactions || canSlackReactions) actions.add("reactions");
|
||||||
|
|
||||||
|
const canDiscordMessages = discordEnabled && discordGate("messages");
|
||||||
|
const canSlackMessages = isSlackActionEnabled("messages");
|
||||||
|
if (canDiscordMessages || canSlackMessages) {
|
||||||
|
actions.add("read");
|
||||||
|
actions.add("edit");
|
||||||
|
actions.add("delete");
|
||||||
|
}
|
||||||
|
|
||||||
|
const canDiscordPins = discordEnabled && discordGate("pins");
|
||||||
|
const canSlackPins = isSlackActionEnabled("pins");
|
||||||
|
if (canDiscordPins || canSlackPins) {
|
||||||
|
actions.add("pin");
|
||||||
|
actions.add("unpin");
|
||||||
|
actions.add("list-pins");
|
||||||
|
}
|
||||||
|
|
||||||
|
const msteamsEnabled =
|
||||||
|
cfg.msteams?.enabled !== false &&
|
||||||
|
Boolean(cfg.msteams && resolveMSTeamsCredentials(cfg.msteams));
|
||||||
|
const canDiscordPolls = discordEnabled && discordGate("polls");
|
||||||
|
const canWhatsAppPolls = cfg.whatsapp ? whatsappGate("polls") : false;
|
||||||
|
if (canDiscordPolls || canWhatsAppPolls || msteamsEnabled)
|
||||||
|
actions.add("poll");
|
||||||
|
if (discordEnabled && discordGate("permissions")) actions.add("permissions");
|
||||||
|
if (discordEnabled && discordGate("threads")) {
|
||||||
|
actions.add("thread-create");
|
||||||
|
actions.add("thread-list");
|
||||||
|
actions.add("thread-reply");
|
||||||
|
}
|
||||||
|
if (discordEnabled && discordGate("search")) actions.add("search");
|
||||||
|
if (discordEnabled && discordGate("stickers")) actions.add("sticker");
|
||||||
|
if (
|
||||||
|
(discordEnabled && discordGate("memberInfo")) ||
|
||||||
|
isSlackActionEnabled("memberInfo")
|
||||||
|
) {
|
||||||
|
actions.add("member-info");
|
||||||
|
}
|
||||||
|
if (discordEnabled && discordGate("roleInfo")) actions.add("role-info");
|
||||||
|
if (
|
||||||
|
(discordEnabled && discordGate("reactions")) ||
|
||||||
|
isSlackActionEnabled("emojiList")
|
||||||
|
) {
|
||||||
|
actions.add("emoji-list");
|
||||||
|
}
|
||||||
|
if (discordEnabled && discordGate("emojiUploads"))
|
||||||
|
actions.add("emoji-upload");
|
||||||
|
if (discordEnabled && discordGate("stickerUploads"))
|
||||||
|
actions.add("sticker-upload");
|
||||||
|
|
||||||
|
const canDiscordRoles = discordEnabled && discordGate("roles", false);
|
||||||
|
if (canDiscordRoles) {
|
||||||
|
actions.add("role-add");
|
||||||
|
actions.add("role-remove");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (discordEnabled && discordGate("channelInfo")) {
|
||||||
|
actions.add("channel-info");
|
||||||
|
actions.add("channel-list");
|
||||||
|
}
|
||||||
|
if (discordEnabled && discordGate("voiceStatus")) actions.add("voice-status");
|
||||||
|
if (discordEnabled && discordGate("events")) {
|
||||||
|
actions.add("event-list");
|
||||||
|
actions.add("event-create");
|
||||||
|
}
|
||||||
|
if (discordEnabled && discordGate("moderation", false)) {
|
||||||
|
actions.add("timeout");
|
||||||
|
actions.add("kick");
|
||||||
|
actions.add("ban");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Type.Union(Array.from(actions).map((action) => Type.Literal(action)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMessageToolSchema(cfg: ClawdbotConfig) {
|
||||||
|
const base = MessageToolSchema as unknown as Record<string, unknown>;
|
||||||
|
const baseProps = (base.properties ?? {}) as Record<string, unknown>;
|
||||||
|
const props: Record<string, unknown> = {
|
||||||
|
...baseProps,
|
||||||
|
action: buildMessageActionSchema(cfg),
|
||||||
|
};
|
||||||
|
|
||||||
|
const telegramEnabled = listEnabledTelegramAccounts(cfg).some(
|
||||||
|
(account) => account.tokenSource !== "none",
|
||||||
|
);
|
||||||
|
if (!telegramEnabled || !hasTelegramInlineButtons(cfg)) {
|
||||||
|
delete props.buttons;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...base, properties: props };
|
||||||
|
}
|
||||||
|
|
||||||
function resolveAgentAccountId(value?: string): string | undefined {
|
function resolveAgentAccountId(value?: string): string | undefined {
|
||||||
const trimmed = value?.trim();
|
const trimmed = value?.trim();
|
||||||
if (!trimmed) return undefined;
|
if (!trimmed) return undefined;
|
||||||
@@ -126,12 +302,15 @@ function resolveAgentAccountId(value?: string): string | undefined {
|
|||||||
|
|
||||||
export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
||||||
const agentAccountId = resolveAgentAccountId(options?.agentAccountId);
|
const agentAccountId = resolveAgentAccountId(options?.agentAccountId);
|
||||||
|
const schema = options?.config
|
||||||
|
? buildMessageToolSchema(options.config)
|
||||||
|
: MessageToolSchema;
|
||||||
return {
|
return {
|
||||||
label: "Message",
|
label: "Message",
|
||||||
name: "message",
|
name: "message",
|
||||||
description:
|
description:
|
||||||
"Send messages and provider-specific actions (Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams).",
|
"Send messages and provider-specific actions (Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams).",
|
||||||
parameters: MessageToolSchema,
|
parameters: schema,
|
||||||
execute: async (_toolCallId, args) => {
|
execute: async (_toolCallId, args) => {
|
||||||
const params = args as Record<string, unknown>;
|
const params = args as Record<string, unknown>;
|
||||||
const cfg = options?.config ?? loadConfig();
|
const cfg = options?.config ?? loadConfig();
|
||||||
@@ -160,6 +339,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
|||||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||||
const replyTo = readStringParam(params, "replyTo");
|
const replyTo = readStringParam(params, "replyTo");
|
||||||
const threadId = readStringParam(params, "threadId");
|
const threadId = readStringParam(params, "threadId");
|
||||||
|
const buttons = params.buttons;
|
||||||
const gifPlayback =
|
const gifPlayback =
|
||||||
typeof params.gifPlayback === "boolean" ? params.gifPlayback : false;
|
typeof params.gifPlayback === "boolean" ? params.gifPlayback : false;
|
||||||
const bestEffort =
|
const bestEffort =
|
||||||
@@ -216,6 +396,8 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
|||||||
mediaUrl: mediaUrl ?? undefined,
|
mediaUrl: mediaUrl ?? undefined,
|
||||||
replyToMessageId: replyTo ?? undefined,
|
replyToMessageId: replyTo ?? undefined,
|
||||||
messageThreadId: threadId ?? undefined,
|
messageThreadId: threadId ?? undefined,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
buttons,
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
@@ -344,6 +526,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
|||||||
messageId,
|
messageId,
|
||||||
emoji,
|
emoji,
|
||||||
remove,
|
remove,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { handleTelegramAction } from "./telegram-actions.js";
|
import {
|
||||||
|
handleTelegramAction,
|
||||||
|
readTelegramButtons,
|
||||||
|
} from "./telegram-actions.js";
|
||||||
|
|
||||||
const reactMessageTelegram = vi.fn(async () => ({ ok: true }));
|
const reactMessageTelegram = vi.fn(async () => ({ ok: true }));
|
||||||
const sendMessageTelegram = vi.fn(async () => ({
|
const sendMessageTelegram = vi.fn(async () => ({
|
||||||
@@ -41,10 +44,12 @@ describe("handleTelegramAction", () => {
|
|||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
expect(reactMessageTelegram).toHaveBeenCalledWith("123", 456, "✅", {
|
expect(reactMessageTelegram).toHaveBeenCalledWith(
|
||||||
token: "tok",
|
"123",
|
||||||
remove: false,
|
456,
|
||||||
});
|
"✅",
|
||||||
|
expect.objectContaining({ token: "tok", remove: false }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes reactions on empty emoji", async () => {
|
it("removes reactions on empty emoji", async () => {
|
||||||
@@ -58,10 +63,12 @@ describe("handleTelegramAction", () => {
|
|||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
expect(reactMessageTelegram).toHaveBeenCalledWith("123", 456, "", {
|
expect(reactMessageTelegram).toHaveBeenCalledWith(
|
||||||
token: "tok",
|
"123",
|
||||||
remove: false,
|
456,
|
||||||
});
|
"",
|
||||||
|
expect.objectContaining({ token: "tok", remove: false }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes reactions when remove flag set", async () => {
|
it("removes reactions when remove flag set", async () => {
|
||||||
@@ -76,10 +83,12 @@ describe("handleTelegramAction", () => {
|
|||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
expect(reactMessageTelegram).toHaveBeenCalledWith("123", 456, "✅", {
|
expect(reactMessageTelegram).toHaveBeenCalledWith(
|
||||||
token: "tok",
|
"123",
|
||||||
remove: true,
|
456,
|
||||||
});
|
"✅",
|
||||||
|
expect.objectContaining({ token: "tok", remove: true }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("respects reaction gating", async () => {
|
it("respects reaction gating", async () => {
|
||||||
@@ -112,7 +121,7 @@ describe("handleTelegramAction", () => {
|
|||||||
expect(sendMessageTelegram).toHaveBeenCalledWith(
|
expect(sendMessageTelegram).toHaveBeenCalledWith(
|
||||||
"@testchannel",
|
"@testchannel",
|
||||||
"Hello, Telegram!",
|
"Hello, Telegram!",
|
||||||
{ token: "tok", mediaUrl: undefined },
|
expect.objectContaining({ token: "tok", mediaUrl: undefined }),
|
||||||
);
|
);
|
||||||
expect(result.content).toContainEqual({
|
expect(result.content).toContainEqual({
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -134,7 +143,10 @@ describe("handleTelegramAction", () => {
|
|||||||
expect(sendMessageTelegram).toHaveBeenCalledWith(
|
expect(sendMessageTelegram).toHaveBeenCalledWith(
|
||||||
"123456",
|
"123456",
|
||||||
"Check this image!",
|
"Check this image!",
|
||||||
{ token: "tok", mediaUrl: "https://example.com/image.jpg" },
|
expect.objectContaining({
|
||||||
|
token: "tok",
|
||||||
|
mediaUrl: "https://example.com/image.jpg",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,4 +180,50 @@ describe("handleTelegramAction", () => {
|
|||||||
),
|
),
|
||||||
).rejects.toThrow(/Telegram bot token missing/);
|
).rejects.toThrow(/Telegram bot token missing/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires inlineButtons capability when buttons are provided", async () => {
|
||||||
|
const cfg = { telegram: { botToken: "tok" } } as ClawdbotConfig;
|
||||||
|
await expect(
|
||||||
|
handleTelegramAction(
|
||||||
|
{
|
||||||
|
action: "sendMessage",
|
||||||
|
to: "@testchannel",
|
||||||
|
content: "Choose",
|
||||||
|
buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]],
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/inlineButtons/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends messages with inline keyboard buttons when enabled", async () => {
|
||||||
|
const cfg = {
|
||||||
|
telegram: { botToken: "tok", capabilities: ["inlineButtons"] },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
await handleTelegramAction(
|
||||||
|
{
|
||||||
|
action: "sendMessage",
|
||||||
|
to: "@testchannel",
|
||||||
|
content: "Choose",
|
||||||
|
buttons: [[{ text: " Option A ", callback_data: " cmd:a " }]],
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
expect(sendMessageTelegram).toHaveBeenCalledWith(
|
||||||
|
"@testchannel",
|
||||||
|
"Choose",
|
||||||
|
expect.objectContaining({
|
||||||
|
buttons: [[{ text: "Option A", callback_data: "cmd:a" }]],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readTelegramButtons", () => {
|
||||||
|
it("returns trimmed button rows for valid input", () => {
|
||||||
|
const result = readTelegramButtons({
|
||||||
|
buttons: [[{ text: " Option A ", callback_data: " cmd:a " }]],
|
||||||
|
});
|
||||||
|
expect(result).toEqual([[{ text: "Option A", callback_data: "cmd:a" }]]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import { resolveProviderCapabilities } from "../../config/provider-capabilities.js";
|
||||||
import {
|
import {
|
||||||
reactMessageTelegram,
|
reactMessageTelegram,
|
||||||
sendMessageTelegram,
|
sendMessageTelegram,
|
||||||
@@ -15,11 +16,74 @@ import {
|
|||||||
readStringParam,
|
readStringParam,
|
||||||
} from "./common.js";
|
} from "./common.js";
|
||||||
|
|
||||||
|
type TelegramButton = {
|
||||||
|
text: string;
|
||||||
|
callback_data: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function hasInlineButtonsCapability(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
accountId?: string | undefined;
|
||||||
|
}): boolean {
|
||||||
|
const caps =
|
||||||
|
resolveProviderCapabilities({
|
||||||
|
cfg: params.cfg,
|
||||||
|
provider: "telegram",
|
||||||
|
accountId: params.accountId,
|
||||||
|
}) ?? [];
|
||||||
|
return caps.some((cap) => cap.toLowerCase() === "inlinebuttons");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readTelegramButtons(
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): TelegramButton[][] | undefined {
|
||||||
|
const raw = params.buttons;
|
||||||
|
if (raw == null) return undefined;
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
throw new Error("buttons must be an array of button rows");
|
||||||
|
}
|
||||||
|
const rows = raw.map((row, rowIndex) => {
|
||||||
|
if (!Array.isArray(row)) {
|
||||||
|
throw new Error(`buttons[${rowIndex}] must be an array`);
|
||||||
|
}
|
||||||
|
return row.map((button, buttonIndex) => {
|
||||||
|
if (!button || typeof button !== "object") {
|
||||||
|
throw new Error(
|
||||||
|
`buttons[${rowIndex}][${buttonIndex}] must be an object`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const text =
|
||||||
|
typeof (button as { text?: unknown }).text === "string"
|
||||||
|
? (button as { text: string }).text.trim()
|
||||||
|
: "";
|
||||||
|
const callbackData =
|
||||||
|
typeof (button as { callback_data?: unknown }).callback_data ===
|
||||||
|
"string"
|
||||||
|
? (button as { callback_data: string }).callback_data.trim()
|
||||||
|
: "";
|
||||||
|
if (!text || !callbackData) {
|
||||||
|
throw new Error(
|
||||||
|
`buttons[${rowIndex}][${buttonIndex}] requires text and callback_data`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (callbackData.length > 64) {
|
||||||
|
throw new Error(
|
||||||
|
`buttons[${rowIndex}][${buttonIndex}] callback_data too long (max 64 chars)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { text, callback_data: callbackData };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const filtered = rows.filter((row) => row.length > 0);
|
||||||
|
return filtered.length > 0 ? filtered : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleTelegramAction(
|
export async function handleTelegramAction(
|
||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
): Promise<AgentToolResult<unknown>> {
|
): Promise<AgentToolResult<unknown>> {
|
||||||
const action = readStringParam(params, "action", { required: true });
|
const action = readStringParam(params, "action", { required: true });
|
||||||
|
const accountId = readStringParam(params, "accountId");
|
||||||
const isActionEnabled = createActionGate(cfg.telegram?.actions);
|
const isActionEnabled = createActionGate(cfg.telegram?.actions);
|
||||||
|
|
||||||
if (action === "react") {
|
if (action === "react") {
|
||||||
@@ -36,7 +100,7 @@ export async function handleTelegramAction(
|
|||||||
const { emoji, remove, isEmpty } = readReactionParams(params, {
|
const { emoji, remove, isEmpty } = readReactionParams(params, {
|
||||||
removeErrorMessage: "Emoji is required to remove a Telegram reaction.",
|
removeErrorMessage: "Emoji is required to remove a Telegram reaction.",
|
||||||
});
|
});
|
||||||
const token = resolveTelegramToken(cfg).token;
|
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or telegram.botToken.",
|
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or telegram.botToken.",
|
||||||
@@ -45,6 +109,7 @@ export async function handleTelegramAction(
|
|||||||
await reactMessageTelegram(chatId ?? "", messageId ?? 0, emoji ?? "", {
|
await reactMessageTelegram(chatId ?? "", messageId ?? 0, emoji ?? "", {
|
||||||
token,
|
token,
|
||||||
remove,
|
remove,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
});
|
});
|
||||||
if (!remove && !isEmpty) {
|
if (!remove && !isEmpty) {
|
||||||
return jsonResult({ ok: true, added: emoji });
|
return jsonResult({ ok: true, added: emoji });
|
||||||
@@ -59,6 +124,15 @@ export async function handleTelegramAction(
|
|||||||
const to = readStringParam(params, "to", { required: true });
|
const to = readStringParam(params, "to", { required: true });
|
||||||
const content = readStringParam(params, "content", { required: true });
|
const content = readStringParam(params, "content", { required: true });
|
||||||
const mediaUrl = readStringParam(params, "mediaUrl");
|
const mediaUrl = readStringParam(params, "mediaUrl");
|
||||||
|
const buttons = readTelegramButtons(params);
|
||||||
|
if (
|
||||||
|
buttons &&
|
||||||
|
!hasInlineButtonsCapability({ cfg, accountId: accountId ?? undefined })
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Telegram inline buttons requested but not enabled. Add "inlineButtons" to telegram.capabilities (or telegram.accounts.<id>.capabilities).',
|
||||||
|
);
|
||||||
|
}
|
||||||
// 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", {
|
||||||
integer: true,
|
integer: true,
|
||||||
@@ -66,7 +140,7 @@ export async function handleTelegramAction(
|
|||||||
const messageThreadId = readNumberParam(params, "messageThreadId", {
|
const messageThreadId = readNumberParam(params, "messageThreadId", {
|
||||||
integer: true,
|
integer: true,
|
||||||
});
|
});
|
||||||
const token = resolveTelegramToken(cfg).token;
|
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or telegram.botToken.",
|
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or telegram.botToken.",
|
||||||
@@ -74,7 +148,9 @@ export async function handleTelegramAction(
|
|||||||
}
|
}
|
||||||
const result = await sendMessageTelegram(to, content, {
|
const result = await sendMessageTelegram(to, content, {
|
||||||
token,
|
token,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
mediaUrl: mediaUrl || undefined,
|
mediaUrl: mediaUrl || undefined,
|
||||||
|
buttons,
|
||||||
replyToMessageId: replyToMessageId ?? undefined,
|
replyToMessageId: replyToMessageId ?? undefined,
|
||||||
messageThreadId: messageThreadId ?? undefined,
|
messageThreadId: messageThreadId ?? undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -509,6 +509,10 @@ Examples:
|
|||||||
"--media <path-or-url>",
|
"--media <path-or-url>",
|
||||||
"Attach media (image/audio/video/document). Accepts local paths or URLs.",
|
"Attach media (image/audio/video/document). Accepts local paths or URLs.",
|
||||||
)
|
)
|
||||||
|
.option(
|
||||||
|
"--buttons-json <json>",
|
||||||
|
"Telegram inline keyboard buttons as JSON (array of button rows)",
|
||||||
|
)
|
||||||
.option("--reply-to <id>", "Reply-to message id")
|
.option("--reply-to <id>", "Reply-to message id")
|
||||||
.option("--thread-id <id>", "Thread id (Telegram forum thread)")
|
.option("--thread-id <id>", "Thread id (Telegram forum thread)")
|
||||||
.option(
|
.option(
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ type MessageCommandOpts = {
|
|||||||
to?: string;
|
to?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
media?: string;
|
media?: string;
|
||||||
|
buttonsJson?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
replyTo?: string;
|
replyTo?: string;
|
||||||
threadId?: string;
|
threadId?: string;
|
||||||
@@ -354,6 +355,15 @@ export async function messageCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (provider === "telegram") {
|
if (provider === "telegram") {
|
||||||
|
const buttonsJson = optionalString(opts.buttonsJson);
|
||||||
|
let buttons: unknown;
|
||||||
|
if (buttonsJson) {
|
||||||
|
try {
|
||||||
|
buttons = JSON.parse(buttonsJson);
|
||||||
|
} catch {
|
||||||
|
throw new Error("buttons-json must be valid JSON");
|
||||||
|
}
|
||||||
|
}
|
||||||
const result = await handleTelegramAction(
|
const result = await handleTelegramAction(
|
||||||
{
|
{
|
||||||
action: "sendMessage",
|
action: "sendMessage",
|
||||||
@@ -362,6 +372,8 @@ export async function messageCommand(
|
|||||||
mediaUrl: optionalString(opts.media),
|
mediaUrl: optionalString(opts.media),
|
||||||
replyToMessageId: optionalString(opts.replyTo),
|
replyToMessageId: optionalString(opts.replyTo),
|
||||||
messageThreadId: optionalString(opts.threadId),
|
messageThreadId: optionalString(opts.threadId),
|
||||||
|
accountId: optionalString(opts.account),
|
||||||
|
buttons,
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
@@ -550,6 +562,7 @@ export async function messageCommand(
|
|||||||
messageId,
|
messageId,
|
||||||
emoji,
|
emoji,
|
||||||
remove: opts.remove,
|
remove: opts.remove,
|
||||||
|
accountId: optionalString(opts.account),
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
|
|||||||
87
src/config/provider-capabilities.test.ts
Normal file
87
src/config/provider-capabilities.test.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "./config.js";
|
||||||
|
import { resolveProviderCapabilities } from "./provider-capabilities.js";
|
||||||
|
|
||||||
|
describe("resolveProviderCapabilities", () => {
|
||||||
|
it("returns undefined for missing inputs", () => {
|
||||||
|
expect(resolveProviderCapabilities({})).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
resolveProviderCapabilities({ cfg: {} as ClawdbotConfig }),
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
resolveProviderCapabilities({ cfg: {} as ClawdbotConfig, provider: "" }),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes and prefers per-account capabilities", () => {
|
||||||
|
const cfg = {
|
||||||
|
telegram: {
|
||||||
|
capabilities: [" inlineButtons ", ""],
|
||||||
|
accounts: {
|
||||||
|
default: {
|
||||||
|
capabilities: [" perAccount ", " "],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Partial<ClawdbotConfig>;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveProviderCapabilities({
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
provider: "telegram",
|
||||||
|
accountId: "default",
|
||||||
|
}),
|
||||||
|
).toEqual(["perAccount"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to provider capabilities when account capabilities are missing", () => {
|
||||||
|
const cfg = {
|
||||||
|
telegram: {
|
||||||
|
capabilities: ["inlineButtons"],
|
||||||
|
accounts: {
|
||||||
|
default: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Partial<ClawdbotConfig>;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveProviderCapabilities({
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
provider: "telegram",
|
||||||
|
accountId: "default",
|
||||||
|
}),
|
||||||
|
).toEqual(["inlineButtons"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches account keys case-insensitively", () => {
|
||||||
|
const cfg = {
|
||||||
|
slack: {
|
||||||
|
accounts: {
|
||||||
|
Family: { capabilities: ["threads"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Partial<ClawdbotConfig>;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveProviderCapabilities({
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
provider: "slack",
|
||||||
|
accountId: "family",
|
||||||
|
}),
|
||||||
|
).toEqual(["threads"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports msteams capabilities", () => {
|
||||||
|
const cfg = {
|
||||||
|
msteams: { capabilities: [" polls ", ""] },
|
||||||
|
} satisfies Partial<ClawdbotConfig>;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveProviderCapabilities({
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
provider: "msteams",
|
||||||
|
}),
|
||||||
|
).toEqual(["polls"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
91
src/config/provider-capabilities.ts
Normal file
91
src/config/provider-capabilities.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { normalizeAccountId } from "../routing/session-key.js";
|
||||||
|
import type { ClawdbotConfig } from "./config.js";
|
||||||
|
|
||||||
|
function normalizeCapabilities(
|
||||||
|
capabilities: string[] | undefined,
|
||||||
|
): string[] | undefined {
|
||||||
|
if (!capabilities) return undefined;
|
||||||
|
const normalized = capabilities.map((entry) => entry.trim()).filter(Boolean);
|
||||||
|
return normalized.length > 0 ? normalized : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAccountCapabilities(params: {
|
||||||
|
cfg?: { accounts?: Record<string, { capabilities?: string[] }> } & {
|
||||||
|
capabilities?: string[];
|
||||||
|
};
|
||||||
|
accountId?: string | null;
|
||||||
|
}): string[] | undefined {
|
||||||
|
const cfg = params.cfg;
|
||||||
|
if (!cfg) return undefined;
|
||||||
|
const normalizedAccountId = normalizeAccountId(params.accountId);
|
||||||
|
|
||||||
|
const accounts = cfg.accounts;
|
||||||
|
if (accounts && typeof accounts === "object") {
|
||||||
|
const direct = accounts[normalizedAccountId];
|
||||||
|
if (direct) {
|
||||||
|
return (
|
||||||
|
normalizeCapabilities(direct.capabilities) ??
|
||||||
|
normalizeCapabilities(cfg.capabilities)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const matchKey = Object.keys(accounts).find(
|
||||||
|
(key) => key.toLowerCase() === normalizedAccountId.toLowerCase(),
|
||||||
|
);
|
||||||
|
const match = matchKey ? accounts[matchKey] : undefined;
|
||||||
|
if (match) {
|
||||||
|
return (
|
||||||
|
normalizeCapabilities(match.capabilities) ??
|
||||||
|
normalizeCapabilities(cfg.capabilities)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeCapabilities(cfg.capabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProviderCapabilities(params: {
|
||||||
|
cfg?: ClawdbotConfig;
|
||||||
|
provider?: string | null;
|
||||||
|
accountId?: string | null;
|
||||||
|
}): string[] | undefined {
|
||||||
|
const cfg = params.cfg;
|
||||||
|
const provider = params.provider?.trim().toLowerCase();
|
||||||
|
if (!cfg || !provider) return undefined;
|
||||||
|
|
||||||
|
switch (provider) {
|
||||||
|
case "whatsapp":
|
||||||
|
return resolveAccountCapabilities({
|
||||||
|
cfg: cfg.whatsapp,
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
|
case "telegram":
|
||||||
|
return resolveAccountCapabilities({
|
||||||
|
cfg: cfg.telegram,
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
|
case "discord":
|
||||||
|
return resolveAccountCapabilities({
|
||||||
|
cfg: cfg.discord,
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
|
case "slack":
|
||||||
|
return resolveAccountCapabilities({
|
||||||
|
cfg: cfg.slack,
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
|
case "signal":
|
||||||
|
return resolveAccountCapabilities({
|
||||||
|
cfg: cfg.signal,
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
|
case "imessage":
|
||||||
|
return resolveAccountCapabilities({
|
||||||
|
cfg: cfg.imessage,
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
|
case "msteams":
|
||||||
|
return normalizeCapabilities(cfg.msteams?.capabilities);
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,11 +106,14 @@ export type IdentityConfig = {
|
|||||||
export type WhatsAppActionConfig = {
|
export type WhatsAppActionConfig = {
|
||||||
reactions?: boolean;
|
reactions?: boolean;
|
||||||
sendMessage?: boolean;
|
sendMessage?: boolean;
|
||||||
|
polls?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WhatsAppConfig = {
|
export type WhatsAppConfig = {
|
||||||
/** Optional per-account WhatsApp configuration (multi-account). */
|
/** Optional per-account WhatsApp configuration (multi-account). */
|
||||||
accounts?: Record<string, WhatsAppAccountConfig>;
|
accounts?: Record<string, WhatsAppAccountConfig>;
|
||||||
|
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||||
|
capabilities?: string[];
|
||||||
/**
|
/**
|
||||||
* Inbound message prefix (WhatsApp only).
|
* Inbound message prefix (WhatsApp only).
|
||||||
* Default: `[{agents.list[].identity.name}]` (or `[clawdbot]`) when allowFrom is empty, else `""`.
|
* Default: `[{agents.list[].identity.name}]` (or `[clawdbot]`) when allowFrom is empty, else `""`.
|
||||||
@@ -155,6 +158,8 @@ export type WhatsAppConfig = {
|
|||||||
export type WhatsAppAccountConfig = {
|
export type WhatsAppAccountConfig = {
|
||||||
/** 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. */
|
||||||
|
capabilities?: string[];
|
||||||
/** If false, do not start this WhatsApp account provider. Default: true. */
|
/** If false, do not start this WhatsApp account provider. Default: true. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Inbound message prefix override for this account (WhatsApp only). */
|
/** Inbound message prefix override for this account (WhatsApp only). */
|
||||||
@@ -300,6 +305,8 @@ export type TelegramActionConfig = {
|
|||||||
export type TelegramAccountConfig = {
|
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. */
|
||||||
|
capabilities?: string[];
|
||||||
/**
|
/**
|
||||||
* Controls how Telegram direct chats (DMs) are handled:
|
* Controls how Telegram direct chats (DMs) are handled:
|
||||||
* - "pairing" (default): unknown senders get a pairing code; owner must approve
|
* - "pairing" (default): unknown senders get a pairing code; owner must approve
|
||||||
@@ -441,6 +448,8 @@ export type DiscordActionConfig = {
|
|||||||
export type DiscordAccountConfig = {
|
export type DiscordAccountConfig = {
|
||||||
/** 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. */
|
||||||
|
capabilities?: string[];
|
||||||
/** If false, do not start this Discord account. Default: true. */
|
/** If false, do not start this Discord account. Default: true. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -538,6 +547,8 @@ export type SlackSlashCommandConfig = {
|
|||||||
export type SlackAccountConfig = {
|
export type SlackAccountConfig = {
|
||||||
/** 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. */
|
||||||
|
capabilities?: string[];
|
||||||
/** If false, do not start this Slack account. Default: true. */
|
/** If false, do not start this Slack account. Default: true. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
botToken?: string;
|
botToken?: string;
|
||||||
@@ -576,6 +587,8 @@ export type SlackConfig = {
|
|||||||
export type SignalAccountConfig = {
|
export type SignalAccountConfig = {
|
||||||
/** 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. */
|
||||||
|
capabilities?: string[];
|
||||||
/** If false, do not start this Signal account. Default: true. */
|
/** If false, do not start this Signal account. Default: true. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Optional explicit E.164 account for signal-cli. */
|
/** Optional explicit E.164 account for signal-cli. */
|
||||||
@@ -650,6 +663,8 @@ export type MSTeamsTeamConfig = {
|
|||||||
export type MSTeamsConfig = {
|
export type MSTeamsConfig = {
|
||||||
/** If false, do not start the MS Teams provider. Default: true. */
|
/** If false, do not start the MS Teams provider. Default: true. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||||
|
capabilities?: string[];
|
||||||
/** Azure Bot App ID (from Azure Bot registration). */
|
/** Azure Bot App ID (from Azure Bot registration). */
|
||||||
appId?: string;
|
appId?: string;
|
||||||
/** Azure Bot App Password / Client Secret. */
|
/** Azure Bot App Password / Client Secret. */
|
||||||
@@ -682,6 +697,8 @@ export type MSTeamsConfig = {
|
|||||||
export type IMessageAccountConfig = {
|
export type IMessageAccountConfig = {
|
||||||
/** 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. */
|
||||||
|
capabilities?: string[];
|
||||||
/** If false, do not start this iMessage account. Default: true. */
|
/** If false, do not start this iMessage account. Default: true. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** imsg CLI binary path (default: imsg). */
|
/** imsg CLI binary path (default: imsg). */
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ const TelegramGroupSchema = z.object({
|
|||||||
|
|
||||||
const TelegramAccountSchemaBase = z.object({
|
const TelegramAccountSchemaBase = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
|
capabilities: z.array(z.string()).optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||||
botToken: z.string().optional(),
|
botToken: z.string().optional(),
|
||||||
@@ -279,6 +280,7 @@ const DiscordGuildSchema = z.object({
|
|||||||
|
|
||||||
const DiscordAccountSchema = z.object({
|
const DiscordAccountSchema = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
|
capabilities: z.array(z.string()).optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
token: z.string().optional(),
|
token: z.string().optional(),
|
||||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||||
@@ -348,6 +350,7 @@ const SlackChannelSchema = z.object({
|
|||||||
|
|
||||||
const SlackAccountSchema = z.object({
|
const SlackAccountSchema = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
|
capabilities: z.array(z.string()).optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
botToken: z.string().optional(),
|
botToken: z.string().optional(),
|
||||||
appToken: z.string().optional(),
|
appToken: z.string().optional(),
|
||||||
@@ -390,6 +393,7 @@ const SlackConfigSchema = SlackAccountSchema.extend({
|
|||||||
|
|
||||||
const SignalAccountSchemaBase = z.object({
|
const SignalAccountSchemaBase = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
|
capabilities: z.array(z.string()).optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
account: z.string().optional(),
|
account: z.string().optional(),
|
||||||
httpUrl: z.string().optional(),
|
httpUrl: z.string().optional(),
|
||||||
@@ -438,6 +442,7 @@ const SignalConfigSchema = SignalAccountSchemaBase.extend({
|
|||||||
|
|
||||||
const IMessageAccountSchemaBase = z.object({
|
const IMessageAccountSchemaBase = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
|
capabilities: z.array(z.string()).optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
cliPath: z.string().optional(),
|
cliPath: z.string().optional(),
|
||||||
dbPath: z.string().optional(),
|
dbPath: z.string().optional(),
|
||||||
@@ -506,6 +511,7 @@ const MSTeamsTeamSchema = z.object({
|
|||||||
const MSTeamsConfigSchema = z
|
const MSTeamsConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
|
capabilities: z.array(z.string()).optional(),
|
||||||
appId: z.string().optional(),
|
appId: z.string().optional(),
|
||||||
appPassword: z.string().optional(),
|
appPassword: z.string().optional(),
|
||||||
tenantId: z.string().optional(),
|
tenantId: z.string().optional(),
|
||||||
@@ -1228,6 +1234,7 @@ export const ClawdbotSchema = z.object({
|
|||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
|
capabilities: z.array(z.string()).optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
messagePrefix: z.string().optional(),
|
messagePrefix: z.string().optional(),
|
||||||
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
|
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
|
||||||
@@ -1268,6 +1275,7 @@ export const ClawdbotSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
capabilities: z.array(z.string()).optional(),
|
||||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||||
messagePrefix: z.string().optional(),
|
messagePrefix: z.string().optional(),
|
||||||
selfChatMode: z.boolean().optional(),
|
selfChatMode: z.boolean().optional(),
|
||||||
@@ -1281,6 +1289,8 @@ export const ClawdbotSchema = z.object({
|
|||||||
actions: z
|
actions: z
|
||||||
.object({
|
.object({
|
||||||
reactions: z.boolean().optional(),
|
reactions: z.boolean().optional(),
|
||||||
|
sendMessage: z.boolean().optional(),
|
||||||
|
polls: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
groups: z
|
groups: z
|
||||||
|
|||||||
@@ -85,9 +85,10 @@ describe("telegram inbound media", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const handler = onSpy.mock.calls[0]?.[1] as (
|
const handler = onSpy.mock.calls.find(
|
||||||
ctx: Record<string, unknown>,
|
(call) => call[0] === "message",
|
||||||
) => Promise<void>;
|
)?.[1] as (ctx: Record<string, unknown>) => Promise<void>;
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
const fetchSpy = vi
|
const fetchSpy = vi
|
||||||
.spyOn(globalThis, "fetch" as never)
|
.spyOn(globalThis, "fetch" as never)
|
||||||
@@ -153,9 +154,10 @@ describe("telegram inbound media", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const handler = onSpy.mock.calls[0]?.[1] as (
|
const handler = onSpy.mock.calls.find(
|
||||||
ctx: Record<string, unknown>,
|
(call) => call[0] === "message",
|
||||||
) => Promise<void>;
|
)?.[1] as (ctx: Record<string, unknown>) => Promise<void>;
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
message: {
|
message: {
|
||||||
@@ -199,9 +201,10 @@ describe("telegram inbound media", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const handler = onSpy.mock.calls[0]?.[1] as (
|
const handler = onSpy.mock.calls.find(
|
||||||
ctx: Record<string, unknown>,
|
(call) => call[0] === "message",
|
||||||
) => Promise<void>;
|
)?.[1] as (ctx: Record<string, unknown>) => Promise<void>;
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
message: {
|
message: {
|
||||||
@@ -263,9 +266,10 @@ describe("telegram media groups", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = onSpy.mock.calls.find(
|
||||||
ctx: Record<string, unknown>,
|
(call) => call[0] === "message",
|
||||||
) => Promise<void>;
|
)?.[1] as (ctx: Record<string, unknown>) => Promise<void>;
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
message: {
|
message: {
|
||||||
@@ -323,9 +327,10 @@ describe("telegram media groups", () => {
|
|||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = onSpy.mock.calls.find(
|
||||||
ctx: Record<string, unknown>,
|
(call) => call[0] === "message",
|
||||||
) => Promise<void>;
|
)?.[1] as (ctx: Record<string, unknown>) => Promise<void>;
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
message: {
|
message: {
|
||||||
@@ -374,9 +379,10 @@ describe("telegram location parsing", () => {
|
|||||||
replySpy.mockReset();
|
replySpy.mockReset();
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0]?.[1] as (
|
const handler = onSpy.mock.calls.find(
|
||||||
ctx: Record<string, unknown>,
|
(call) => call[0] === "message",
|
||||||
) => Promise<void>;
|
)?.[1] as (ctx: Record<string, unknown>) => Promise<void>;
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
message: {
|
message: {
|
||||||
@@ -415,9 +421,10 @@ describe("telegram location parsing", () => {
|
|||||||
replySpy.mockReset();
|
replySpy.mockReset();
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0]?.[1] as (
|
const handler = onSpy.mock.calls.find(
|
||||||
ctx: Record<string, unknown>,
|
(call) => call[0] === "message",
|
||||||
) => Promise<void>;
|
)?.[1] as (ctx: Record<string, unknown>) => Promise<void>;
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
message: {
|
message: {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const onSpy = vi.fn();
|
|||||||
const stopSpy = vi.fn();
|
const stopSpy = vi.fn();
|
||||||
const commandSpy = vi.fn();
|
const commandSpy = vi.fn();
|
||||||
const botCtorSpy = vi.fn();
|
const botCtorSpy = vi.fn();
|
||||||
|
const answerCallbackQuerySpy = vi.fn(async () => undefined);
|
||||||
const sendChatActionSpy = vi.fn();
|
const sendChatActionSpy = vi.fn();
|
||||||
const setMessageReactionSpy = vi.fn(async () => undefined);
|
const setMessageReactionSpy = vi.fn(async () => undefined);
|
||||||
const setMyCommandsSpy = vi.fn(async () => undefined);
|
const setMyCommandsSpy = vi.fn(async () => undefined);
|
||||||
@@ -54,6 +55,7 @@ const sendAnimationSpy = vi.fn(async () => ({ message_id: 78 }));
|
|||||||
const sendPhotoSpy = vi.fn(async () => ({ message_id: 79 }));
|
const sendPhotoSpy = vi.fn(async () => ({ message_id: 79 }));
|
||||||
type ApiStub = {
|
type ApiStub = {
|
||||||
config: { use: (arg: unknown) => void };
|
config: { use: (arg: unknown) => void };
|
||||||
|
answerCallbackQuery: typeof answerCallbackQuerySpy;
|
||||||
sendChatAction: typeof sendChatActionSpy;
|
sendChatAction: typeof sendChatActionSpy;
|
||||||
setMessageReaction: typeof setMessageReactionSpy;
|
setMessageReaction: typeof setMessageReactionSpy;
|
||||||
setMyCommands: typeof setMyCommandsSpy;
|
setMyCommands: typeof setMyCommandsSpy;
|
||||||
@@ -63,6 +65,7 @@ type ApiStub = {
|
|||||||
};
|
};
|
||||||
const apiStub: ApiStub = {
|
const apiStub: ApiStub = {
|
||||||
config: { use: useSpy },
|
config: { use: useSpy },
|
||||||
|
answerCallbackQuery: answerCallbackQuerySpy,
|
||||||
sendChatAction: sendChatActionSpy,
|
sendChatAction: sendChatActionSpy,
|
||||||
setMessageReaction: setMessageReactionSpy,
|
setMessageReaction: setMessageReactionSpy,
|
||||||
setMyCommands: setMyCommandsSpy,
|
setMyCommands: setMyCommandsSpy,
|
||||||
@@ -113,6 +116,12 @@ vi.mock("../auto-reply/reply.js", () => {
|
|||||||
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
|
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getOnHandler = (event: string) => {
|
||||||
|
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
|
||||||
|
if (!handler) throw new Error(`Missing handler for event: ${event}`);
|
||||||
|
return handler as (ctx: Record<string, unknown>) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
describe("createTelegramBot", () => {
|
describe("createTelegramBot", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
@@ -122,6 +131,7 @@ describe("createTelegramBot", () => {
|
|||||||
sendAnimationSpy.mockReset();
|
sendAnimationSpy.mockReset();
|
||||||
sendPhotoSpy.mockReset();
|
sendPhotoSpy.mockReset();
|
||||||
setMessageReactionSpy.mockReset();
|
setMessageReactionSpy.mockReset();
|
||||||
|
answerCallbackQuerySpy.mockReset();
|
||||||
setMyCommandsSpy.mockReset();
|
setMyCommandsSpy.mockReset();
|
||||||
middlewareUseSpy.mockReset();
|
middlewareUseSpy.mockReset();
|
||||||
sequentializeSpy.mockReset();
|
sequentializeSpy.mockReset();
|
||||||
@@ -206,6 +216,40 @@ describe("createTelegramBot", () => {
|
|||||||
).toBe("telegram:555");
|
).toBe("telegram:555");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("routes callback_query payloads as messages and answers callbacks", async () => {
|
||||||
|
onSpy.mockReset();
|
||||||
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||||
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
replySpy.mockReset();
|
||||||
|
|
||||||
|
createTelegramBot({ token: "tok" });
|
||||||
|
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-1",
|
||||||
|
data: "cmd:option_a",
|
||||||
|
from: { id: 9, first_name: "Ada", username: "ada_bot" },
|
||||||
|
message: {
|
||||||
|
chat: { id: 1234, type: "private" },
|
||||||
|
date: 1736380800,
|
||||||
|
message_id: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
me: { username: "clawdbot_bot" },
|
||||||
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = replySpy.mock.calls[0][0];
|
||||||
|
expect(payload.Body).toContain("cmd:option_a");
|
||||||
|
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-1");
|
||||||
|
});
|
||||||
|
|
||||||
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";
|
||||||
@@ -219,7 +263,7 @@ describe("createTelegramBot", () => {
|
|||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
expect(onSpy).toHaveBeenCalledWith("message", expect.any(Function));
|
expect(onSpy).toHaveBeenCalledWith("message", expect.any(Function));
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -266,7 +310,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -308,7 +352,7 @@ describe("createTelegramBot", () => {
|
|||||||
.mockResolvedValueOnce({ code: "PAIRME12", created: false });
|
.mockResolvedValueOnce({ code: "PAIRME12", created: false });
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -339,7 +383,7 @@ describe("createTelegramBot", () => {
|
|||||||
sendChatActionSpy.mockReset();
|
sendChatActionSpy.mockReset();
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
await handler({
|
await handler({
|
||||||
@@ -365,7 +409,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -401,7 +445,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -447,7 +491,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -491,7 +535,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -523,7 +567,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -553,7 +597,7 @@ describe("createTelegramBot", () => {
|
|||||||
replySpy.mockReset();
|
replySpy.mockReset();
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -591,7 +635,7 @@ describe("createTelegramBot", () => {
|
|||||||
replySpy.mockResolvedValue({ text: "a".repeat(4500) });
|
replySpy.mockResolvedValue({ text: "a".repeat(4500) });
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
await handler({
|
await handler({
|
||||||
@@ -624,7 +668,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok", replyToMode: "first" });
|
createTelegramBot({ token: "tok", replyToMode: "first" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
await handler({
|
await handler({
|
||||||
@@ -663,7 +707,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
await handler({
|
await handler({
|
||||||
@@ -694,7 +738,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok", replyToMode: "all" });
|
createTelegramBot({ token: "tok", replyToMode: "all" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
await handler({
|
await handler({
|
||||||
@@ -729,7 +773,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -757,7 +801,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -806,7 +850,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -839,7 +883,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -877,7 +921,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -913,7 +957,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -941,7 +985,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -976,7 +1020,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -1015,7 +1059,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -1048,7 +1092,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -1081,7 +1125,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -1114,7 +1158,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -1147,7 +1191,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -1180,7 +1224,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -1212,7 +1256,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -1245,7 +1289,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -1277,7 +1321,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -1308,7 +1352,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -1339,7 +1383,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -1372,7 +1416,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -1404,7 +1448,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -1437,7 +1481,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -1471,7 +1515,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -1504,7 +1548,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -1537,7 +1581,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -1568,7 +1612,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -1629,7 +1673,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
@@ -1673,7 +1717,7 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = onSpy.mock.calls[0][1] as (
|
const handler = getOnHandler("message") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
primaryCtx: TelegramContext,
|
primaryCtx: TelegramContext,
|
||||||
allMedia: Array<{ path: string; contentType?: string }>,
|
allMedia: Array<{ path: string; contentType?: string }>,
|
||||||
storeAllowFrom: string[],
|
storeAllowFrom: string[],
|
||||||
|
options?: { forceWasMentioned?: boolean },
|
||||||
) => {
|
) => {
|
||||||
const msg = primaryCtx.message;
|
const msg = primaryCtx.message;
|
||||||
recordProviderActivity({
|
recordProviderActivity({
|
||||||
@@ -468,9 +469,11 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
senderId,
|
senderId,
|
||||||
senderUsername,
|
senderUsername,
|
||||||
});
|
});
|
||||||
const wasMentioned =
|
const computedWasMentioned =
|
||||||
(Boolean(botUsername) && hasBotMention(msg, botUsername)) ||
|
(Boolean(botUsername) && hasBotMention(msg, botUsername)) ||
|
||||||
matchesMentionPatterns(msg.text ?? msg.caption ?? "", mentionRegexes);
|
matchesMentionPatterns(msg.text ?? msg.caption ?? "", mentionRegexes);
|
||||||
|
const wasMentioned =
|
||||||
|
options?.forceWasMentioned === true ? true : computedWasMentioned;
|
||||||
const hasAnyMention = (msg.entities ?? msg.caption_entities ?? []).some(
|
const hasAnyMention = (msg.entities ?? msg.caption_entities ?? []).some(
|
||||||
(ent) => ent.type === "mention",
|
(ent) => ent.type === "mention",
|
||||||
);
|
);
|
||||||
@@ -991,6 +994,40 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bot.on("callback_query", async (ctx) => {
|
||||||
|
const callback = ctx.callbackQuery;
|
||||||
|
if (!callback) return;
|
||||||
|
try {
|
||||||
|
const data = (callback.data ?? "").trim();
|
||||||
|
const callbackMessage = callback.message;
|
||||||
|
if (!data || !callbackMessage) return;
|
||||||
|
|
||||||
|
const syntheticMessage: TelegramMessage = {
|
||||||
|
...callbackMessage,
|
||||||
|
from: callback.from,
|
||||||
|
text: data,
|
||||||
|
caption: undefined,
|
||||||
|
caption_entities: undefined,
|
||||||
|
entities: undefined,
|
||||||
|
};
|
||||||
|
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
|
||||||
|
const getFile =
|
||||||
|
typeof ctx.getFile === "function"
|
||||||
|
? ctx.getFile.bind(ctx)
|
||||||
|
: async () => ({});
|
||||||
|
await processMessage(
|
||||||
|
{ message: syntheticMessage, me: ctx.me, getFile },
|
||||||
|
[],
|
||||||
|
storeAllowFrom,
|
||||||
|
{ forceWasMentioned: true },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
runtime.error?.(danger(`callback handler failed: ${String(err)}`));
|
||||||
|
} finally {
|
||||||
|
await bot.api.answerCallbackQuery(callback.id).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
bot.on("message", async (ctx) => {
|
bot.on("message", async (ctx) => {
|
||||||
try {
|
try {
|
||||||
const msg = ctx.message;
|
const msg = ctx.message;
|
||||||
|
|||||||
@@ -29,7 +29,51 @@ vi.mock("grammy", () => ({
|
|||||||
InputFile: class {},
|
InputFile: class {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { reactMessageTelegram, sendMessageTelegram } from "./send.js";
|
import {
|
||||||
|
buildInlineKeyboard,
|
||||||
|
reactMessageTelegram,
|
||||||
|
sendMessageTelegram,
|
||||||
|
} from "./send.js";
|
||||||
|
|
||||||
|
describe("buildInlineKeyboard", () => {
|
||||||
|
it("returns undefined for empty input", () => {
|
||||||
|
expect(buildInlineKeyboard()).toBeUndefined();
|
||||||
|
expect(buildInlineKeyboard([])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds inline keyboards for valid input", () => {
|
||||||
|
const result = buildInlineKeyboard([
|
||||||
|
[{ text: "Option A", callback_data: "cmd:a" }],
|
||||||
|
[
|
||||||
|
{ text: "Option B", callback_data: "cmd:b" },
|
||||||
|
{ text: "Option C", callback_data: "cmd:c" },
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
expect(result).toEqual({
|
||||||
|
inline_keyboard: [
|
||||||
|
[{ text: "Option A", callback_data: "cmd:a" }],
|
||||||
|
[
|
||||||
|
{ text: "Option B", callback_data: "cmd:b" },
|
||||||
|
{ text: "Option C", callback_data: "cmd:c" },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters invalid buttons and empty rows", () => {
|
||||||
|
const result = buildInlineKeyboard([
|
||||||
|
[
|
||||||
|
{ text: "", callback_data: "cmd:skip" },
|
||||||
|
{ text: "Ok", callback_data: "cmd:ok" },
|
||||||
|
],
|
||||||
|
[{ text: "Missing data", callback_data: "" }],
|
||||||
|
[],
|
||||||
|
]);
|
||||||
|
expect(result).toEqual({
|
||||||
|
inline_keyboard: [[{ text: "Ok", callback_data: "cmd:ok" }]],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("sendMessageTelegram", () => {
|
describe("sendMessageTelegram", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { ReactionType, ReactionTypeEmoji } from "@grammyjs/types";
|
import type {
|
||||||
|
InlineKeyboardButton,
|
||||||
|
InlineKeyboardMarkup,
|
||||||
|
ReactionType,
|
||||||
|
ReactionTypeEmoji,
|
||||||
|
} from "@grammyjs/types";
|
||||||
import { type ApiClientOptions, Bot, InputFile } from "grammy";
|
import { type ApiClientOptions, Bot, InputFile } from "grammy";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { formatErrorMessage } from "../infra/errors.js";
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
@@ -30,6 +35,8 @@ type TelegramSendOpts = {
|
|||||||
replyToMessageId?: number;
|
replyToMessageId?: number;
|
||||||
/** Forum topic thread ID (for forum supergroups) */
|
/** Forum topic thread ID (for forum supergroups) */
|
||||||
messageThreadId?: number;
|
messageThreadId?: number;
|
||||||
|
/** Inline keyboard buttons (reply markup). */
|
||||||
|
buttons?: Array<Array<{ text: string; callback_data: string }>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TelegramSendResult = {
|
type TelegramSendResult = {
|
||||||
@@ -103,6 +110,26 @@ function normalizeMessageId(raw: string | number): number {
|
|||||||
throw new Error("Message id is required for Telegram reactions");
|
throw new Error("Message id is required for Telegram reactions");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildInlineKeyboard(
|
||||||
|
buttons?: TelegramSendOpts["buttons"],
|
||||||
|
): InlineKeyboardMarkup | undefined {
|
||||||
|
if (!buttons?.length) return undefined;
|
||||||
|
const rows = buttons
|
||||||
|
.map((row) =>
|
||||||
|
row
|
||||||
|
.filter((button) => button?.text && button?.callback_data)
|
||||||
|
.map(
|
||||||
|
(button): InlineKeyboardButton => ({
|
||||||
|
text: button.text,
|
||||||
|
callback_data: button.callback_data,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter((row) => row.length > 0);
|
||||||
|
if (rows.length === 0) return undefined;
|
||||||
|
return { inline_keyboard: rows };
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendMessageTelegram(
|
export async function sendMessageTelegram(
|
||||||
to: string,
|
to: string,
|
||||||
text: string,
|
text: string,
|
||||||
@@ -124,6 +151,7 @@ export async function sendMessageTelegram(
|
|||||||
: undefined;
|
: undefined;
|
||||||
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
|
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
|
||||||
const mediaUrl = opts.mediaUrl?.trim();
|
const mediaUrl = opts.mediaUrl?.trim();
|
||||||
|
const replyMarkup = buildInlineKeyboard(opts.buttons);
|
||||||
|
|
||||||
// Build optional params for forum topics and reply threading.
|
// Build optional params for forum topics and reply threading.
|
||||||
// Only include these if actually provided to keep API calls clean.
|
// Only include these if actually provided to keep API calls clean.
|
||||||
@@ -171,8 +199,15 @@ export async function sendMessageTelegram(
|
|||||||
const file = new InputFile(media.buffer, fileName);
|
const file = new InputFile(media.buffer, fileName);
|
||||||
const caption = text?.trim() || undefined;
|
const caption = text?.trim() || undefined;
|
||||||
const mediaParams = hasThreadParams
|
const mediaParams = hasThreadParams
|
||||||
? { caption, ...threadParams }
|
? {
|
||||||
: { caption };
|
caption,
|
||||||
|
...threadParams,
|
||||||
|
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
caption,
|
||||||
|
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||||
|
};
|
||||||
let result:
|
let result:
|
||||||
| Awaited<ReturnType<typeof api.sendPhoto>>
|
| Awaited<ReturnType<typeof api.sendPhoto>>
|
||||||
| Awaited<ReturnType<typeof api.sendVideo>>
|
| Awaited<ReturnType<typeof api.sendVideo>>
|
||||||
@@ -240,8 +275,15 @@ export async function sendMessageTelegram(
|
|||||||
}
|
}
|
||||||
const htmlText = markdownToTelegramHtml(text);
|
const htmlText = markdownToTelegramHtml(text);
|
||||||
const textParams = hasThreadParams
|
const textParams = hasThreadParams
|
||||||
? { parse_mode: "HTML" as const, ...threadParams }
|
? {
|
||||||
: { parse_mode: "HTML" as const };
|
parse_mode: "HTML" as const,
|
||||||
|
...threadParams,
|
||||||
|
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
parse_mode: "HTML" as const,
|
||||||
|
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||||
|
};
|
||||||
const res = await request(
|
const res = await request(
|
||||||
() => api.sendMessage(chatId, htmlText, textParams),
|
() => api.sendMessage(chatId, htmlText, textParams),
|
||||||
"message",
|
"message",
|
||||||
@@ -255,10 +297,17 @@ export async function sendMessageTelegram(
|
|||||||
`telegram HTML parse failed, retrying as plain text: ${errText}`,
|
`telegram HTML parse failed, retrying as plain text: ${errText}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const plainParams =
|
||||||
|
hasThreadParams || replyMarkup
|
||||||
|
? {
|
||||||
|
...threadParams,
|
||||||
|
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
return await request(
|
return await request(
|
||||||
() =>
|
() =>
|
||||||
hasThreadParams
|
plainParams
|
||||||
? api.sendMessage(chatId, text, threadParams)
|
? api.sendMessage(chatId, text, plainParams)
|
||||||
: api.sendMessage(chatId, text),
|
: api.sendMessage(chatId, text),
|
||||||
"message-plain",
|
"message-plain",
|
||||||
).catch((err2) => {
|
).catch((err2) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user