feat(telegram): inline keyboard buttons (#491)

Co-authored-by: Azade <azade@hey.com>
This commit is contained in:
Peter Steinberger
2026-01-09 20:46:11 +01:00
parent 46f0a08878
commit 6d378ee608
19 changed files with 894 additions and 98 deletions

View 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"]);
});
});

View 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;
}
}

View File

@@ -106,11 +106,14 @@ export type IdentityConfig = {
export type WhatsAppActionConfig = {
reactions?: boolean;
sendMessage?: boolean;
polls?: boolean;
};
export type WhatsAppConfig = {
/** Optional per-account WhatsApp configuration (multi-account). */
accounts?: Record<string, WhatsAppAccountConfig>;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/**
* Inbound message prefix (WhatsApp only).
* Default: `[{agents.list[].identity.name}]` (or `[clawdbot]`) when allowFrom is empty, else `""`.
@@ -155,6 +158,8 @@ export type WhatsAppConfig = {
export type WhatsAppAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** If false, do not start this WhatsApp account provider. Default: true. */
enabled?: boolean;
/** Inbound message prefix override for this account (WhatsApp only). */
@@ -300,6 +305,8 @@ export type TelegramActionConfig = {
export type TelegramAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/**
* Controls how Telegram direct chats (DMs) are handled:
* - "pairing" (default): unknown senders get a pairing code; owner must approve
@@ -441,6 +448,8 @@ export type DiscordActionConfig = {
export type DiscordAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** If false, do not start this Discord account. Default: true. */
enabled?: boolean;
token?: string;
@@ -538,6 +547,8 @@ export type SlackSlashCommandConfig = {
export type SlackAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** If false, do not start this Slack account. Default: true. */
enabled?: boolean;
botToken?: string;
@@ -576,6 +587,8 @@ export type SlackConfig = {
export type SignalAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** If false, do not start this Signal account. Default: true. */
enabled?: boolean;
/** Optional explicit E.164 account for signal-cli. */
@@ -650,6 +663,8 @@ export type MSTeamsTeamConfig = {
export type MSTeamsConfig = {
/** If false, do not start the MS Teams provider. Default: true. */
enabled?: boolean;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** Azure Bot App ID (from Azure Bot registration). */
appId?: string;
/** Azure Bot App Password / Client Secret. */
@@ -682,6 +697,8 @@ export type MSTeamsConfig = {
export type IMessageAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** If false, do not start this iMessage account. Default: true. */
enabled?: boolean;
/** imsg CLI binary path (default: imsg). */

View File

@@ -187,6 +187,7 @@ const TelegramGroupSchema = z.object({
const TelegramAccountSchemaBase = z.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
botToken: z.string().optional(),
@@ -279,6 +280,7 @@ const DiscordGuildSchema = z.object({
const DiscordAccountSchema = z.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
token: z.string().optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
@@ -348,6 +350,7 @@ const SlackChannelSchema = z.object({
const SlackAccountSchema = z.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
botToken: z.string().optional(),
appToken: z.string().optional(),
@@ -390,6 +393,7 @@ const SlackConfigSchema = SlackAccountSchema.extend({
const SignalAccountSchemaBase = z.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
account: z.string().optional(),
httpUrl: z.string().optional(),
@@ -438,6 +442,7 @@ const SignalConfigSchema = SignalAccountSchemaBase.extend({
const IMessageAccountSchemaBase = z.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
cliPath: z.string().optional(),
dbPath: z.string().optional(),
@@ -506,6 +511,7 @@ const MSTeamsTeamSchema = z.object({
const MSTeamsConfigSchema = z
.object({
enabled: z.boolean().optional(),
capabilities: z.array(z.string()).optional(),
appId: z.string().optional(),
appPassword: z.string().optional(),
tenantId: z.string().optional(),
@@ -1228,6 +1234,7 @@ export const ClawdbotSchema = z.object({
z
.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
messagePrefix: z.string().optional(),
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
@@ -1268,6 +1275,7 @@ export const ClawdbotSchema = z.object({
.optional(),
)
.optional(),
capabilities: z.array(z.string()).optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
messagePrefix: z.string().optional(),
selfChatMode: z.boolean().optional(),
@@ -1281,6 +1289,8 @@ export const ClawdbotSchema = z.object({
actions: z
.object({
reactions: z.boolean().optional(),
sendMessage: z.boolean().optional(),
polls: z.boolean().optional(),
})
.optional(),
groups: z