fix: telegram sendPayload and plugin auth (#1917) (thanks @JoshuaLelon)

This commit is contained in:
Ayaan Zaidi
2026-01-26 22:25:55 +05:30
committed by Ayaan Zaidi
parent db2395744b
commit 94ead83ba4
6 changed files with 457 additions and 214 deletions

View File

@@ -29,6 +29,7 @@ Status: unreleased.
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev. - Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
- Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg. - Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg.
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra. - Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco. - Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla. - Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907. - Routing: precompile session key regexes. (#1697) Thanks @Ray0907.

View File

@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../../config/config.js";
import { telegramOutbound } from "./telegram.js";
describe("telegramOutbound.sendPayload", () => {
it("sends text payload with buttons", async () => {
const sendTelegram = vi.fn(async () => ({ messageId: "m1", chatId: "c1" }));
const result = await telegramOutbound.sendPayload?.({
cfg: {} as ClawdbotConfig,
to: "telegram:123",
text: "ignored",
payload: {
text: "Hello",
channelData: {
telegram: {
buttons: [[{ text: "Option", callback_data: "/option" }]],
},
},
},
deps: { sendTelegram },
});
expect(sendTelegram).toHaveBeenCalledTimes(1);
expect(sendTelegram).toHaveBeenCalledWith(
"telegram:123",
"Hello",
expect.objectContaining({
buttons: [[{ text: "Option", callback_data: "/option" }]],
textMode: "html",
}),
);
expect(result).toEqual({ channel: "telegram", messageId: "m1", chatId: "c1" });
});
it("sends media payloads and attaches buttons only to first", async () => {
const sendTelegram = vi
.fn()
.mockResolvedValueOnce({ messageId: "m1", chatId: "c1" })
.mockResolvedValueOnce({ messageId: "m2", chatId: "c1" });
const result = await telegramOutbound.sendPayload?.({
cfg: {} as ClawdbotConfig,
to: "telegram:123",
text: "ignored",
payload: {
text: "Caption",
mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
channelData: {
telegram: {
buttons: [[{ text: "Go", callback_data: "/go" }]],
},
},
},
deps: { sendTelegram },
});
expect(sendTelegram).toHaveBeenCalledTimes(2);
expect(sendTelegram).toHaveBeenNthCalledWith(
1,
"telegram:123",
"Caption",
expect.objectContaining({
mediaUrl: "https://example.com/a.png",
buttons: [[{ text: "Go", callback_data: "/go" }]],
}),
);
const secondOpts = sendTelegram.mock.calls[1]?.[2] as { buttons?: unknown } | undefined;
expect(sendTelegram).toHaveBeenNthCalledWith(
2,
"telegram:123",
"",
expect.objectContaining({
mediaUrl: "https://example.com/b.png",
}),
);
expect(secondOpts?.buttons).toBeUndefined();
expect(result).toEqual({ channel: "telegram", messageId: "m2", chatId: "c1" });
});
});

View File

@@ -18,6 +18,7 @@ function parseThreadId(threadId?: string | number | null) {
const parsed = Number.parseInt(trimmed, 10); const parsed = Number.parseInt(trimmed, 10);
return Number.isFinite(parsed) ? parsed : undefined; return Number.isFinite(parsed) ? parsed : undefined;
} }
export const telegramOutbound: ChannelOutboundAdapter = { export const telegramOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: markdownToTelegramHtmlChunks, chunker: markdownToTelegramHtmlChunks,
@@ -54,20 +55,42 @@ export const telegramOutbound: ChannelOutboundAdapter = {
const send = deps?.sendTelegram ?? sendMessageTelegram; const send = deps?.sendTelegram ?? sendMessageTelegram;
const replyToMessageId = parseReplyToMessageId(replyToId); const replyToMessageId = parseReplyToMessageId(replyToId);
const messageThreadId = parseThreadId(threadId); const messageThreadId = parseThreadId(threadId);
// Extract Telegram-specific data from channelData
const telegramData = payload.channelData?.telegram as const telegramData = payload.channelData?.telegram as
| { buttons?: Array<Array<{ text: string; callback_data: string }>> } | { buttons?: Array<Array<{ text: string; callback_data: string }>> }
| undefined; | undefined;
const text = payload.text ?? "";
const result = await send(to, payload.text ?? "", { const mediaUrls = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
const baseOpts = {
verbose: false, verbose: false,
textMode: "html", textMode: "html" as const,
messageThreadId, messageThreadId,
replyToMessageId, replyToMessageId,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
buttons: telegramData?.buttons, };
});
return { channel: "telegram", ...result }; if (mediaUrls.length === 0) {
const result = await send(to, text, {
...baseOpts,
buttons: telegramData?.buttons,
});
return { channel: "telegram", ...result };
}
// Telegram allows reply_markup on media; attach buttons only to first send.
let finalResult: Awaited<ReturnType<typeof send>> | undefined;
for (let i = 0; i < mediaUrls.length; i += 1) {
const mediaUrl = mediaUrls[i];
const isFirst = i === 0;
finalResult = await send(to, isFirst ? text : "", {
...baseOpts,
mediaUrl,
...(isFirst ? { buttons: telegramData?.buttons } : {}),
});
}
return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) };
}, },
}; };

View File

@@ -0,0 +1,106 @@
import { describe, expect, it, vi } from "vitest";
import type { ChannelGroupPolicy } from "../config/group-policy.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { TelegramAccountConfig } from "../config/types.js";
import type { RuntimeEnv } from "../runtime.js";
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
const getPluginCommandSpecs = vi.hoisted(() => vi.fn());
const matchPluginCommand = vi.hoisted(() => vi.fn());
const executePluginCommand = vi.hoisted(() => vi.fn());
vi.mock("../plugins/commands.js", () => ({
getPluginCommandSpecs,
matchPluginCommand,
executePluginCommand,
}));
const deliverReplies = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("./bot/delivery.js", () => ({ deliverReplies }));
vi.mock("./pairing-store.js", () => ({
readTelegramAllowFromStore: vi.fn(async () => []),
}));
describe("registerTelegramNativeCommands (plugin auth)", () => {
it("allows requireAuth:false plugin command even when sender is unauthorized", async () => {
const command = {
name: "plugin",
description: "Plugin command",
requireAuth: false,
handler: vi.fn(),
} as const;
getPluginCommandSpecs.mockReturnValue([{ name: "plugin", description: "Plugin command" }]);
matchPluginCommand.mockReturnValue({ command, args: undefined });
executePluginCommand.mockResolvedValue({ text: "ok" });
const handlers: Record<string, (ctx: unknown) => Promise<void>> = {};
const bot = {
api: {
setMyCommands: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn(),
},
command: (name: string, handler: (ctx: unknown) => Promise<void>) => {
handlers[name] = handler;
},
} as const;
const cfg = {} as ClawdbotConfig;
const telegramCfg = {} as TelegramAccountConfig;
const resolveGroupPolicy = () =>
({
allowlistEnabled: false,
allowed: true,
}) as ChannelGroupPolicy;
registerTelegramNativeCommands({
bot: bot as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
cfg,
runtime: {} as RuntimeEnv,
accountId: "default",
telegramCfg,
allowFrom: ["999"],
groupAllowFrom: [],
replyToMode: "off",
textLimit: 4000,
useAccessGroups: false,
nativeEnabled: false,
nativeSkillsEnabled: false,
nativeDisabledExplicit: false,
resolveGroupPolicy,
resolveTelegramGroupConfig: () => ({
groupConfig: undefined,
topicConfig: undefined,
}),
shouldSkipUpdate: () => false,
opts: { token: "token" },
});
const ctx = {
message: {
chat: { id: 123, type: "private" },
from: { id: 111, username: "nope" },
message_id: 10,
date: 123456,
},
match: "",
};
await handlers.plugin?.(ctx);
expect(matchPluginCommand).toHaveBeenCalled();
expect(executePluginCommand).toHaveBeenCalledWith(
expect.objectContaining({
isAuthorizedSender: false,
}),
);
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [{ text: "ok" }],
}),
);
expect(bot.api.sendMessage).not.toHaveBeenCalled();
});
});

View File

@@ -17,13 +17,17 @@ import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/pr
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { danger, logVerbose } from "../globals.js"; import { danger, logVerbose } from "../globals.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import {
normalizeTelegramCommandName,
TELEGRAM_COMMAND_NAME_PATTERN,
} from "../config/telegram-custom-commands.js";
import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../routing/session-key.js"; import { resolveThreadSessionKeys } from "../routing/session-key.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js";
import { import {
executePluginCommand,
getPluginCommandSpecs, getPluginCommandSpecs,
matchPluginCommand, matchPluginCommand,
executePluginCommand,
} from "../plugins/commands.js"; } from "../plugins/commands.js";
import type { ChannelGroupPolicy } from "../config/group-policy.js"; import type { ChannelGroupPolicy } from "../config/group-policy.js";
import type { import type {
@@ -47,6 +51,18 @@ import { readTelegramAllowFromStore } from "./pairing-store.js";
type TelegramNativeCommandContext = Context & { match?: string }; type TelegramNativeCommandContext = Context & { match?: string };
type TelegramCommandAuthResult = {
chatId: number;
isGroup: boolean;
isForum: boolean;
resolvedThreadId?: number;
senderId: string;
senderUsername: string;
groupConfig?: TelegramGroupConfig;
topicConfig?: TelegramTopicConfig;
commandAuthorized: boolean;
};
type RegisterTelegramNativeCommandsParams = { type RegisterTelegramNativeCommandsParams = {
bot: Bot; bot: Bot;
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
@@ -70,6 +86,134 @@ type RegisterTelegramNativeCommandsParams = {
opts: { token: string }; opts: { token: string };
}; };
async function resolveTelegramCommandAuth(params: {
msg: NonNullable<TelegramNativeCommandContext["message"]>;
bot: Bot;
cfg: ClawdbotConfig;
telegramCfg: TelegramAccountConfig;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
useAccessGroups: boolean;
resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy;
resolveTelegramGroupConfig: (
chatId: string | number,
messageThreadId?: number,
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
requireAuth: boolean;
}): Promise<TelegramCommandAuthResult | null> {
const {
msg,
bot,
cfg,
telegramCfg,
allowFrom,
groupAllowFrom,
useAccessGroups,
resolveGroupPolicy,
resolveTelegramGroupConfig,
requireAuth,
} = params;
const chatId = msg.chat.id;
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
const resolvedThreadId = resolveTelegramForumThreadId({
isForum,
messageThreadId,
});
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId);
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
const effectiveGroupAllow = normalizeAllowFromWithStore({
allowFrom: groupAllowOverride ?? groupAllowFrom,
storeAllowFrom,
});
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
const senderIdRaw = msg.from?.id;
const senderId = senderIdRaw ? String(senderIdRaw) : "";
const senderUsername = msg.from?.username ?? "";
if (isGroup && groupConfig?.enabled === false) {
await bot.api.sendMessage(chatId, "This group is disabled.");
return null;
}
if (isGroup && topicConfig?.enabled === false) {
await bot.api.sendMessage(chatId, "This topic is disabled.");
return null;
}
if (requireAuth && isGroup && hasGroupAllowOverride) {
if (
senderIdRaw == null ||
!isSenderAllowed({
allow: effectiveGroupAllow,
senderId: String(senderIdRaw),
senderUsername,
})
) {
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
return null;
}
}
if (isGroup && useAccessGroups) {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open";
if (groupPolicy === "disabled") {
await bot.api.sendMessage(chatId, "Telegram group commands are disabled.");
return null;
}
if (groupPolicy === "allowlist" && requireAuth) {
if (
senderIdRaw == null ||
!isSenderAllowed({
allow: effectiveGroupAllow,
senderId: String(senderIdRaw),
senderUsername,
})
) {
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
return null;
}
}
const groupAllowlist = resolveGroupPolicy(chatId);
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
await bot.api.sendMessage(chatId, "This group is not allowed.");
return null;
}
}
const dmAllow = normalizeAllowFromWithStore({
allowFrom: allowFrom,
storeAllowFrom,
});
const senderAllowed = isSenderAllowed({
allow: dmAllow,
senderId,
senderUsername,
});
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [{ configured: dmAllow.hasEntries, allowed: senderAllowed }],
modeWhenAccessGroupsOff: "configured",
});
if (requireAuth && !commandAuthorized) {
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
return null;
}
return {
chatId,
isGroup,
isForum,
resolvedThreadId,
senderId,
senderUsername,
groupConfig,
topicConfig,
commandAuthorized,
};
}
export const registerTelegramNativeCommands = ({ export const registerTelegramNativeCommands = ({
bot, bot,
cfg, cfg,
@@ -109,15 +253,49 @@ export const registerTelegramNativeCommands = ({
} }
const customCommands = customResolution.commands; const customCommands = customResolution.commands;
const pluginCommandSpecs = getPluginCommandSpecs(); const pluginCommandSpecs = getPluginCommandSpecs();
const pluginCommands: Array<{ command: string; description: string }> = [];
const existingCommands = new Set(
[
...nativeCommands.map((command) => command.name),
...customCommands.map((command) => command.command),
].map((command) => command.toLowerCase()),
);
const pluginCommandNames = new Set<string>();
for (const spec of pluginCommandSpecs) {
const normalized = normalizeTelegramCommandName(spec.name);
if (!normalized || !TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) {
runtime.error?.(
danger(
`Plugin command "/${spec.name}" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).`,
),
);
continue;
}
const description = spec.description.trim();
if (!description) {
runtime.error?.(danger(`Plugin command "/${normalized}" is missing a description.`));
continue;
}
if (existingCommands.has(normalized)) {
runtime.error?.(
danger(`Plugin command "/${normalized}" conflicts with an existing Telegram command.`),
);
continue;
}
if (pluginCommandNames.has(normalized)) {
runtime.error?.(danger(`Plugin command "/${normalized}" is duplicated.`));
continue;
}
pluginCommandNames.add(normalized);
existingCommands.add(normalized);
pluginCommands.push({ command: normalized, description });
}
const allCommands: Array<{ command: string; description: string }> = [ const allCommands: Array<{ command: string; description: string }> = [
...nativeCommands.map((command) => ({ ...nativeCommands.map((command) => ({
command: command.name, command: command.name,
description: command.description, description: command.description,
})), })),
...pluginCommandSpecs.map((spec) => ({ ...pluginCommands,
command: spec.name,
description: spec.description,
})),
...customCommands, ...customCommands,
]; ];
@@ -134,99 +312,30 @@ export const registerTelegramNativeCommands = ({
const msg = ctx.message; const msg = ctx.message;
if (!msg) return; if (!msg) return;
if (shouldSkipUpdate(ctx)) return; if (shouldSkipUpdate(ctx)) return;
const chatId = msg.chat.id; const auth = await resolveTelegramCommandAuth({
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; msg,
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id; bot,
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true; cfg,
const resolvedThreadId = resolveTelegramForumThreadId({ telegramCfg,
allowFrom,
groupAllowFrom,
useAccessGroups,
resolveGroupPolicy,
resolveTelegramGroupConfig,
requireAuth: true,
});
if (!auth) return;
const {
chatId,
isGroup,
isForum, isForum,
messageThreadId, resolvedThreadId,
});
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId);
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
const effectiveGroupAllow = normalizeAllowFromWithStore({
allowFrom: groupAllowOverride ?? groupAllowFrom,
storeAllowFrom,
});
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
if (isGroup && groupConfig?.enabled === false) {
await bot.api.sendMessage(chatId, "This group is disabled.");
return;
}
if (isGroup && topicConfig?.enabled === false) {
await bot.api.sendMessage(chatId, "This topic is disabled.");
return;
}
if (isGroup && hasGroupAllowOverride) {
const senderId = msg.from?.id;
const senderUsername = msg.from?.username ?? "";
if (
senderId == null ||
!isSenderAllowed({
allow: effectiveGroupAllow,
senderId: String(senderId),
senderUsername,
})
) {
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
return;
}
}
if (isGroup && useAccessGroups) {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open";
if (groupPolicy === "disabled") {
await bot.api.sendMessage(chatId, "Telegram group commands are disabled.");
return;
}
if (groupPolicy === "allowlist") {
const senderId = msg.from?.id;
if (senderId == null) {
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
return;
}
const senderUsername = msg.from?.username ?? "";
if (
!isSenderAllowed({
allow: effectiveGroupAllow,
senderId: String(senderId),
senderUsername,
})
) {
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
return;
}
}
const groupAllowlist = resolveGroupPolicy(chatId);
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
await bot.api.sendMessage(chatId, "This group is not allowed.");
return;
}
}
const senderId = msg.from?.id ? String(msg.from.id) : "";
const senderUsername = msg.from?.username ?? "";
const dmAllow = normalizeAllowFromWithStore({
allowFrom: allowFrom,
storeAllowFrom,
});
const senderAllowed = isSenderAllowed({
allow: dmAllow,
senderId, senderId,
senderUsername, senderUsername,
}); groupConfig,
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ topicConfig,
useAccessGroups, commandAuthorized,
authorizers: [{ configured: dmAllow.hasEntries, allowed: senderAllowed }], } = auth;
modeWhenAccessGroupsOff: "configured",
});
if (!commandAuthorized) {
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
return;
}
const commandDefinition = findCommandByNativeName(command.name, "telegram"); const commandDefinition = findCommandByNativeName(command.name, "telegram");
const rawText = ctx.match?.trim() ?? ""; const rawText = ctx.match?.trim() ?? "";
@@ -373,114 +482,33 @@ export const registerTelegramNativeCommands = ({
}); });
} }
// Register handlers for plugin commands for (const pluginCommand of pluginCommands) {
for (const pluginSpec of pluginCommandSpecs) { bot.command(pluginCommand.command, async (ctx: TelegramNativeCommandContext) => {
bot.command(pluginSpec.name, async (ctx: TelegramNativeCommandContext) => {
const msg = ctx.message; const msg = ctx.message;
if (!msg) return; if (!msg) return;
if (shouldSkipUpdate(ctx)) return; if (shouldSkipUpdate(ctx)) return;
const chatId = msg.chat.id; const chatId = msg.chat.id;
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
const resolvedThreadId = resolveTelegramForumThreadId({
isForum,
messageThreadId,
});
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId);
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
const effectiveGroupAllow = normalizeAllowFromWithStore({
allowFrom: groupAllowOverride ?? groupAllowFrom,
storeAllowFrom,
});
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
if (isGroup && groupConfig?.enabled === false) {
await bot.api.sendMessage(chatId, "This group is disabled.");
return;
}
if (isGroup && topicConfig?.enabled === false) {
await bot.api.sendMessage(chatId, "This topic is disabled.");
return;
}
if (isGroup && hasGroupAllowOverride) {
const senderId = msg.from?.id;
const senderUsername = msg.from?.username ?? "";
if (
senderId == null ||
!isSenderAllowed({
allow: effectiveGroupAllow,
senderId: String(senderId),
senderUsername,
})
) {
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
return;
}
}
if (isGroup && useAccessGroups) {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open";
if (groupPolicy === "disabled") {
await bot.api.sendMessage(chatId, "Telegram group commands are disabled.");
return;
}
if (groupPolicy === "allowlist") {
const senderId = msg.from?.id;
if (senderId == null) {
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
return;
}
const senderUsername = msg.from?.username ?? "";
if (
!isSenderAllowed({
allow: effectiveGroupAllow,
senderId: String(senderId),
senderUsername,
})
) {
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
return;
}
}
const groupAllowlist = resolveGroupPolicy(chatId);
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
await bot.api.sendMessage(chatId, "This group is not allowed.");
return;
}
}
const senderId = msg.from?.id ? String(msg.from.id) : "";
const senderUsername = msg.from?.username ?? "";
const dmAllow = normalizeAllowFromWithStore({
allowFrom: allowFrom,
storeAllowFrom,
});
const senderAllowed = isSenderAllowed({
allow: dmAllow,
senderId,
senderUsername,
});
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [{ configured: dmAllow.hasEntries, allowed: senderAllowed }],
modeWhenAccessGroupsOff: "configured",
});
if (!commandAuthorized) {
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
return;
}
// Match and execute plugin command
const rawText = ctx.match?.trim() ?? ""; const rawText = ctx.match?.trim() ?? "";
const commandBody = `/${pluginSpec.name}${rawText ? ` ${rawText}` : ""}`; const commandBody = `/${pluginCommand.command}${rawText ? ` ${rawText}` : ""}`;
const match = matchPluginCommand(commandBody); const match = matchPluginCommand(commandBody);
if (!match) { if (!match) {
await bot.api.sendMessage(chatId, "Command not found."); await bot.api.sendMessage(chatId, "Command not found.");
return; return;
} }
const auth = await resolveTelegramCommandAuth({
msg,
bot,
cfg,
telegramCfg,
allowFrom,
groupAllowFrom,
useAccessGroups,
resolveGroupPolicy,
resolveTelegramGroupConfig,
requireAuth: match.command.requireAuth !== false,
});
if (!auth) return;
const { resolvedThreadId, senderId, commandAuthorized } = auth;
const result = await executePluginCommand({ const result = await executePluginCommand({
command: match.command, command: match.command,
@@ -491,8 +519,6 @@ export const registerTelegramNativeCommands = ({
commandBody, commandBody,
config: cfg, config: cfg,
}); });
// Deliver the result
const tableMode = resolveMarkdownTableMode({ const tableMode = resolveMarkdownTableMode({
cfg, cfg,
channel: "telegram", channel: "telegram",

View File

@@ -17,8 +17,8 @@ import { isGifMedia } from "../../media/mime.js";
import { saveMediaBuffer } from "../../media/store.js"; import { saveMediaBuffer } from "../../media/store.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { loadWebMedia } from "../../web/media.js"; import { loadWebMedia } from "../../web/media.js";
import { resolveTelegramVoiceSend } from "../voice.js";
import { buildInlineKeyboard } from "../send.js"; import { buildInlineKeyboard } from "../send.js";
import { resolveTelegramVoiceSend } from "../voice.js";
import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js"; import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js";
import type { TelegramContext } from "./types.js"; import type { TelegramContext } from "./types.js";
@@ -81,18 +81,16 @@ export async function deliverReplies(params: {
: reply.mediaUrl : reply.mediaUrl
? [reply.mediaUrl] ? [reply.mediaUrl]
: []; : [];
const telegramData = reply.channelData?.telegram as
| { buttons?: Array<Array<{ text: string; callback_data: string }>> }
| undefined;
const replyMarkup = buildInlineKeyboard(telegramData?.buttons);
if (mediaList.length === 0) { if (mediaList.length === 0) {
// Extract Telegram buttons from channelData
const telegramData = reply.channelData?.telegram as
| { buttons?: Array<Array<{ text: string; callback_data: string }>> }
| undefined;
const replyMarkup = buildInlineKeyboard(telegramData?.buttons);
const chunks = chunkText(reply.text || ""); const chunks = chunkText(reply.text || "");
for (let i = 0; i < chunks.length; i++) { for (let i = 0; i < chunks.length; i += 1) {
const chunk = chunks[i]; const chunk = chunks[i];
if (!chunk) continue; if (!chunk) continue;
// Only attach buttons to the first chunk // Only attach buttons to the first chunk.
const shouldAttachButtons = i === 0 && replyMarkup; const shouldAttachButtons = i === 0 && replyMarkup;
await sendTelegramText(bot, chatId, chunk.html, runtime, { await sendTelegramText(bot, chatId, chunk.html, runtime, {
replyToMessageId: replyToMessageId:
@@ -137,10 +135,12 @@ export async function deliverReplies(params: {
first = false; first = false;
const replyToMessageId = const replyToMessageId =
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined; replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;
const shouldAttachButtonsToMedia = isFirstMedia && replyMarkup && !followUpText;
const mediaParams: Record<string, unknown> = { const mediaParams: Record<string, unknown> = {
caption: htmlCaption, caption: htmlCaption,
reply_to_message_id: replyToMessageId, reply_to_message_id: replyToMessageId,
...(htmlCaption ? { parse_mode: "HTML" } : {}), ...(htmlCaption ? { parse_mode: "HTML" } : {}),
...(shouldAttachButtonsToMedia ? { reply_markup: replyMarkup } : {}),
}; };
if (threadParams) { if (threadParams) {
mediaParams.message_thread_id = threadParams.message_thread_id; mediaParams.message_thread_id = threadParams.message_thread_id;
@@ -195,6 +195,7 @@ export async function deliverReplies(params: {
hasReplied, hasReplied,
messageThreadId, messageThreadId,
linkPreview, linkPreview,
replyMarkup,
}); });
// Skip this media item; continue with next. // Skip this media item; continue with next.
continue; continue;
@@ -219,7 +220,8 @@ export async function deliverReplies(params: {
// Chunk it in case it's extremely long (same logic as text-only replies). // Chunk it in case it's extremely long (same logic as text-only replies).
if (pendingFollowUpText && isFirstMedia) { if (pendingFollowUpText && isFirstMedia) {
const chunks = chunkText(pendingFollowUpText); const chunks = chunkText(pendingFollowUpText);
for (const chunk of chunks) { for (let i = 0; i < chunks.length; i += 1) {
const chunk = chunks[i];
const replyToMessageIdFollowup = const replyToMessageIdFollowup =
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined; replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;
await sendTelegramText(bot, chatId, chunk.html, runtime, { await sendTelegramText(bot, chatId, chunk.html, runtime, {
@@ -228,6 +230,7 @@ export async function deliverReplies(params: {
textMode: "html", textMode: "html",
plainText: chunk.text, plainText: chunk.text,
linkPreview, linkPreview,
replyMarkup: i === 0 ? replyMarkup : undefined,
}); });
if (replyToId && !hasReplied) { if (replyToId && !hasReplied) {
hasReplied = true; hasReplied = true;
@@ -289,10 +292,12 @@ async function sendTelegramVoiceFallbackText(opts: {
hasReplied: boolean; hasReplied: boolean;
messageThreadId?: number; messageThreadId?: number;
linkPreview?: boolean; linkPreview?: boolean;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
}): Promise<boolean> { }): Promise<boolean> {
const chunks = opts.chunkText(opts.text); const chunks = opts.chunkText(opts.text);
let hasReplied = opts.hasReplied; let hasReplied = opts.hasReplied;
for (const chunk of chunks) { for (let i = 0; i < chunks.length; i += 1) {
const chunk = chunks[i];
await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, { await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, {
replyToMessageId: replyToMessageId:
opts.replyToId && (opts.replyToMode === "all" || !hasReplied) ? opts.replyToId : undefined, opts.replyToId && (opts.replyToMode === "all" || !hasReplied) ? opts.replyToId : undefined,
@@ -300,6 +305,7 @@ async function sendTelegramVoiceFallbackText(opts: {
textMode: "html", textMode: "html",
plainText: chunk.text, plainText: chunk.text,
linkPreview: opts.linkPreview, linkPreview: opts.linkPreview,
replyMarkup: i === 0 ? opts.replyMarkup : undefined,
}); });
if (opts.replyToId && !hasReplied) { if (opts.replyToId && !hasReplied) {
hasReplied = true; hasReplied = true;