mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-01 07:40:21 +00:00
refactor: move Telegram channel implementation to extensions/ (#45635)
* refactor: move Telegram channel implementation to extensions/telegram/src/ Move all Telegram channel code (123 files + 10 bot/ files + 8 channel plugin files) from src/telegram/ and src/channels/plugins/*/telegram.ts to extensions/telegram/src/. Leave thin re-export shims at original locations so cross-cutting src/ imports continue to resolve. - Fix all relative import paths in moved files (../X/ -> ../../../src/X/) - Fix vi.mock paths in 60 test files - Fix inline typeof import() expressions - Update tsconfig.plugin-sdk.dts.json rootDir to "." for cross-directory DTS - Update write-plugin-sdk-entry-dts.ts for new rootDir structure - Move channel plugin files with correct path remapping * fix: support keyed telegram send deps * fix: sync telegram extension copies with latest main * fix: correct import paths and remove misplaced files in telegram extension * fix: sync outbound-adapter with main (add sendTelegramPayloadMessages) and fix delivery.test import path
This commit is contained in:
@@ -1,287 +1 @@
|
||||
import {
|
||||
readNumberParam,
|
||||
readStringArrayParam,
|
||||
readStringOrNumberParam,
|
||||
readStringParam,
|
||||
} from "../../../agents/tools/common.js";
|
||||
import { handleTelegramAction } from "../../../agents/tools/telegram-actions.js";
|
||||
import type { TelegramActionConfig } from "../../../config/types.telegram.js";
|
||||
import { readBooleanParam } from "../../../plugin-sdk/boolean-param.js";
|
||||
import { extractToolSend } from "../../../plugin-sdk/tool-send.js";
|
||||
import { resolveTelegramPollVisibility } from "../../../poll-params.js";
|
||||
import {
|
||||
createTelegramActionGate,
|
||||
listEnabledTelegramAccounts,
|
||||
resolveTelegramPollActionGateState,
|
||||
} from "../../../telegram/accounts.js";
|
||||
import { isTelegramInlineButtonsEnabled } from "../../../telegram/inline-buttons.js";
|
||||
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js";
|
||||
import { resolveReactionMessageId } from "./reaction-message-id.js";
|
||||
import { createUnionActionGate, listTokenSourcedAccounts } from "./shared.js";
|
||||
|
||||
const providerId = "telegram";
|
||||
|
||||
function readTelegramSendParams(params: Record<string, unknown>) {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||
const message = readStringParam(params, "message", { required: !mediaUrl, allowEmpty: true });
|
||||
const caption = readStringParam(params, "caption", { allowEmpty: true });
|
||||
const content = message || caption || "";
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
const threadId = readStringParam(params, "threadId");
|
||||
const buttons = params.buttons;
|
||||
const asVoice = readBooleanParam(params, "asVoice");
|
||||
const silent = readBooleanParam(params, "silent");
|
||||
const quoteText = readStringParam(params, "quoteText");
|
||||
return {
|
||||
to,
|
||||
content,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
replyToMessageId: replyTo ?? undefined,
|
||||
messageThreadId: threadId ?? undefined,
|
||||
buttons,
|
||||
asVoice,
|
||||
silent,
|
||||
quoteText: quoteText ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function readTelegramChatIdParam(params: Record<string, unknown>): string | number {
|
||||
return (
|
||||
readStringOrNumberParam(params, "chatId") ??
|
||||
readStringOrNumberParam(params, "channelId") ??
|
||||
readStringParam(params, "to", { required: true })
|
||||
);
|
||||
}
|
||||
|
||||
function readTelegramMessageIdParam(params: Record<string, unknown>): number {
|
||||
const messageId = readNumberParam(params, "messageId", {
|
||||
required: true,
|
||||
integer: true,
|
||||
});
|
||||
if (typeof messageId !== "number") {
|
||||
throw new Error("messageId is required.");
|
||||
}
|
||||
return messageId;
|
||||
}
|
||||
|
||||
export const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: ({ cfg }) => {
|
||||
const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg));
|
||||
if (accounts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
// Union of all accounts' action gates (any account enabling an action makes it available)
|
||||
const gate = createUnionActionGate(accounts, (account) =>
|
||||
createTelegramActionGate({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
}),
|
||||
);
|
||||
const isEnabled = (key: keyof TelegramActionConfig, defaultValue = true) =>
|
||||
gate(key, defaultValue);
|
||||
const actions = new Set<ChannelMessageActionName>(["send"]);
|
||||
const pollEnabledForAnyAccount = accounts.some((account) => {
|
||||
const accountGate = createTelegramActionGate({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
return resolveTelegramPollActionGateState(accountGate).enabled;
|
||||
});
|
||||
if (pollEnabledForAnyAccount) {
|
||||
actions.add("poll");
|
||||
}
|
||||
if (isEnabled("reactions")) {
|
||||
actions.add("react");
|
||||
}
|
||||
if (isEnabled("deleteMessage")) {
|
||||
actions.add("delete");
|
||||
}
|
||||
if (isEnabled("editMessage")) {
|
||||
actions.add("edit");
|
||||
}
|
||||
if (isEnabled("sticker", false)) {
|
||||
actions.add("sticker");
|
||||
actions.add("sticker-search");
|
||||
}
|
||||
if (isEnabled("createForumTopic")) {
|
||||
actions.add("topic-create");
|
||||
}
|
||||
return Array.from(actions);
|
||||
},
|
||||
supportsButtons: ({ cfg }) => {
|
||||
const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg));
|
||||
if (accounts.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return accounts.some((account) =>
|
||||
isTelegramInlineButtonsEnabled({ cfg, accountId: account.accountId }),
|
||||
);
|
||||
},
|
||||
extractToolSend: ({ args }) => {
|
||||
return extractToolSend(args, "sendMessage");
|
||||
},
|
||||
handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots, toolContext }) => {
|
||||
if (action === "send") {
|
||||
const sendParams = readTelegramSendParams(params);
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
...sendParams,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
{ mediaLocalRoots },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "react") {
|
||||
const messageId = resolveReactionMessageId({ args: params, toolContext });
|
||||
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
|
||||
const remove = readBooleanParam(params, "remove");
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "react",
|
||||
chatId: readTelegramChatIdParam(params),
|
||||
messageId,
|
||||
emoji,
|
||||
remove,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
{ mediaLocalRoots },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "poll") {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const question = readStringParam(params, "pollQuestion", { required: true });
|
||||
const answers = readStringArrayParam(params, "pollOption", { required: true });
|
||||
const durationHours = readNumberParam(params, "pollDurationHours", {
|
||||
integer: true,
|
||||
strict: true,
|
||||
});
|
||||
const durationSeconds = readNumberParam(params, "pollDurationSeconds", {
|
||||
integer: true,
|
||||
strict: true,
|
||||
});
|
||||
const replyToMessageId = readNumberParam(params, "replyTo", { integer: true });
|
||||
const messageThreadId = readNumberParam(params, "threadId", { integer: true });
|
||||
const allowMultiselect = readBooleanParam(params, "pollMulti");
|
||||
const pollAnonymous = readBooleanParam(params, "pollAnonymous");
|
||||
const pollPublic = readBooleanParam(params, "pollPublic");
|
||||
const isAnonymous = resolveTelegramPollVisibility({ pollAnonymous, pollPublic });
|
||||
const silent = readBooleanParam(params, "silent");
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "poll",
|
||||
to,
|
||||
question,
|
||||
answers,
|
||||
allowMultiselect,
|
||||
durationHours: durationHours ?? undefined,
|
||||
durationSeconds: durationSeconds ?? undefined,
|
||||
replyToMessageId: replyToMessageId ?? undefined,
|
||||
messageThreadId: messageThreadId ?? undefined,
|
||||
isAnonymous,
|
||||
silent,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
{ mediaLocalRoots },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "delete") {
|
||||
const chatId = readTelegramChatIdParam(params);
|
||||
const messageId = readTelegramMessageIdParam(params);
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "deleteMessage",
|
||||
chatId,
|
||||
messageId,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
{ mediaLocalRoots },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "edit") {
|
||||
const chatId = readTelegramChatIdParam(params);
|
||||
const messageId = readTelegramMessageIdParam(params);
|
||||
const message = readStringParam(params, "message", { required: true, allowEmpty: false });
|
||||
const buttons = params.buttons;
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "editMessage",
|
||||
chatId,
|
||||
messageId,
|
||||
content: message,
|
||||
buttons,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
{ mediaLocalRoots },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "sticker") {
|
||||
const to =
|
||||
readStringParam(params, "to") ?? readStringParam(params, "target", { required: true });
|
||||
// Accept stickerId (array from shared schema) and use first element as fileId
|
||||
const stickerIds = readStringArrayParam(params, "stickerId");
|
||||
const fileId = stickerIds?.[0] ?? readStringParam(params, "fileId", { required: true });
|
||||
const replyToMessageId = readNumberParam(params, "replyTo", { integer: true });
|
||||
const messageThreadId = readNumberParam(params, "threadId", { integer: true });
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "sendSticker",
|
||||
to,
|
||||
fileId,
|
||||
replyToMessageId: replyToMessageId ?? undefined,
|
||||
messageThreadId: messageThreadId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
{ mediaLocalRoots },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "sticker-search") {
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "searchSticker",
|
||||
query,
|
||||
limit: limit ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
{ mediaLocalRoots },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "topic-create") {
|
||||
const chatId = readTelegramChatIdParam(params);
|
||||
const name = readStringParam(params, "name", { required: true });
|
||||
const iconColor = readNumberParam(params, "iconColor", { integer: true });
|
||||
const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId");
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "createForumTopic",
|
||||
chatId,
|
||||
name,
|
||||
iconColor: iconColor ?? undefined,
|
||||
iconCustomEmojiId: iconCustomEmojiId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
{ mediaLocalRoots },
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
|
||||
},
|
||||
};
|
||||
export * from "../../../../extensions/telegram/src/channel-actions.js";
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget } from "./telegram.js";
|
||||
|
||||
describe("normalizeTelegramMessagingTarget", () => {
|
||||
it("normalizes t.me links to prefixed usernames", () => {
|
||||
expect(normalizeTelegramMessagingTarget("https://t.me/MyChannel")).toBe("telegram:@mychannel");
|
||||
});
|
||||
|
||||
it("keeps unprefixed topic targets valid", () => {
|
||||
expect(normalizeTelegramMessagingTarget("@MyChannel:topic:9")).toBe(
|
||||
"telegram:@mychannel:topic:9",
|
||||
);
|
||||
expect(normalizeTelegramMessagingTarget("-1001234567890:topic:456")).toBe(
|
||||
"telegram:-1001234567890:topic:456",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps legacy prefixed topic targets valid", () => {
|
||||
expect(normalizeTelegramMessagingTarget("telegram:group:-1001234567890:topic:456")).toBe(
|
||||
"telegram:group:-1001234567890:topic:456",
|
||||
);
|
||||
expect(normalizeTelegramMessagingTarget("tg:group:-1001234567890:topic:456")).toBe(
|
||||
"telegram:group:-1001234567890:topic:456",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeTelegramTargetId", () => {
|
||||
it("recognizes unprefixed topic targets", () => {
|
||||
expect(looksLikeTelegramTargetId("@mychannel:topic:9")).toBe(true);
|
||||
expect(looksLikeTelegramTargetId("-1001234567890:topic:456")).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes legacy prefixed topic targets", () => {
|
||||
expect(looksLikeTelegramTargetId("telegram:group:-1001234567890:topic:456")).toBe(true);
|
||||
expect(looksLikeTelegramTargetId("tg:group:-1001234567890:topic:456")).toBe(true);
|
||||
});
|
||||
|
||||
it("still recognizes normalized lookup targets", () => {
|
||||
expect(looksLikeTelegramTargetId("https://t.me/MyChannel")).toBe(true);
|
||||
expect(looksLikeTelegramTargetId("@mychannel")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,44 +1 @@
|
||||
import { normalizeTelegramLookupTarget, parseTelegramTarget } from "../../../telegram/targets.js";
|
||||
|
||||
const TELEGRAM_PREFIX_RE = /^(telegram|tg):/i;
|
||||
|
||||
function normalizeTelegramTargetBody(raw: string): string | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const prefixStripped = trimmed.replace(TELEGRAM_PREFIX_RE, "").trim();
|
||||
if (!prefixStripped) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = parseTelegramTarget(trimmed);
|
||||
const normalizedChatId = normalizeTelegramLookupTarget(parsed.chatId);
|
||||
if (!normalizedChatId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keepLegacyGroupPrefix = /^group:/i.test(prefixStripped);
|
||||
const hasTopicSuffix = /:topic:\d+$/i.test(prefixStripped);
|
||||
const chatSegment = keepLegacyGroupPrefix ? `group:${normalizedChatId}` : normalizedChatId;
|
||||
if (parsed.messageThreadId == null) {
|
||||
return chatSegment;
|
||||
}
|
||||
const threadSuffix = hasTopicSuffix
|
||||
? `:topic:${parsed.messageThreadId}`
|
||||
: `:${parsed.messageThreadId}`;
|
||||
return `${chatSegment}${threadSuffix}`;
|
||||
}
|
||||
|
||||
export function normalizeTelegramMessagingTarget(raw: string): string | undefined {
|
||||
const normalizedBody = normalizeTelegramTargetBody(raw);
|
||||
if (!normalizedBody) {
|
||||
return undefined;
|
||||
}
|
||||
return `telegram:${normalizedBody}`.toLowerCase();
|
||||
}
|
||||
|
||||
export function looksLikeTelegramTargetId(raw: string): boolean {
|
||||
return normalizeTelegramTargetBody(raw) !== undefined;
|
||||
}
|
||||
export * from "../../../../extensions/telegram/src/normalize.js";
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeTelegramAllowFromInput, parseTelegramAllowFromId } from "./telegram.js";
|
||||
|
||||
describe("normalizeTelegramAllowFromInput", () => {
|
||||
it("strips telegram/tg prefixes and trims whitespace", () => {
|
||||
expect(normalizeTelegramAllowFromInput(" telegram:123 ")).toBe("123");
|
||||
expect(normalizeTelegramAllowFromInput("tg:@alice")).toBe("@alice");
|
||||
expect(normalizeTelegramAllowFromInput(" @bob ")).toBe("@bob");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTelegramAllowFromId", () => {
|
||||
it("accepts numeric ids with optional prefixes", () => {
|
||||
expect(parseTelegramAllowFromId("12345")).toBe("12345");
|
||||
expect(parseTelegramAllowFromId("telegram:98765")).toBe("98765");
|
||||
expect(parseTelegramAllowFromId("tg:2468")).toBe("2468");
|
||||
});
|
||||
|
||||
it("rejects non-numeric values", () => {
|
||||
expect(parseTelegramAllowFromId("@alice")).toBeNull();
|
||||
expect(parseTelegramAllowFromId("tg:alice")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,243 +1 @@
|
||||
import { formatCliCommand } from "../../../cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import { hasConfiguredSecretInput } from "../../../config/types.secrets.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
|
||||
import { inspectTelegramAccount } from "../../../telegram/account-inspect.js";
|
||||
import {
|
||||
listTelegramAccountIds,
|
||||
resolveDefaultTelegramAccountId,
|
||||
resolveTelegramAccount,
|
||||
} from "../../../telegram/accounts.js";
|
||||
import { formatDocsLink } from "../../../terminal/links.js";
|
||||
import type { WizardPrompter } from "../../../wizard/prompts.js";
|
||||
import { fetchTelegramChatId } from "../../telegram/api.js";
|
||||
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js";
|
||||
import {
|
||||
applySingleTokenPromptResult,
|
||||
patchChannelConfigForAccount,
|
||||
promptResolvedAllowFrom,
|
||||
resolveAccountIdForConfigure,
|
||||
resolveOnboardingAccountId,
|
||||
runSingleChannelSecretStep,
|
||||
setChannelDmPolicyWithAllowFrom,
|
||||
setOnboardingChannelEnabled,
|
||||
splitOnboardingEntries,
|
||||
} from "./helpers.js";
|
||||
|
||||
const channel = "telegram" as const;
|
||||
|
||||
async function noteTelegramTokenHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"1) Open Telegram and chat with @BotFather",
|
||||
"2) Run /newbot (or /mybots)",
|
||||
"3) Copy the token (looks like 123456:ABC...)",
|
||||
"Tip: you can also set TELEGRAM_BOT_TOKEN in your env.",
|
||||
`Docs: ${formatDocsLink("/telegram")}`,
|
||||
"Website: https://openclaw.ai",
|
||||
].join("\n"),
|
||||
"Telegram bot token",
|
||||
);
|
||||
}
|
||||
|
||||
async function noteTelegramUserIdHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
`1) DM your bot, then read from.id in \`${formatCliCommand("openclaw logs --follow")}\` (safest)`,
|
||||
"2) Or call https://api.telegram.org/bot<bot_token>/getUpdates and read message.from.id",
|
||||
"3) Third-party: DM @userinfobot or @getidsbot",
|
||||
`Docs: ${formatDocsLink("/telegram")}`,
|
||||
"Website: https://openclaw.ai",
|
||||
].join("\n"),
|
||||
"Telegram user id",
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeTelegramAllowFromInput(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.replace(/^(telegram|tg):/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function parseTelegramAllowFromId(raw: string): string | null {
|
||||
const stripped = normalizeTelegramAllowFromInput(raw);
|
||||
return /^\d+$/.test(stripped) ? stripped : null;
|
||||
}
|
||||
|
||||
async function promptTelegramAllowFrom(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId: string;
|
||||
tokenOverride?: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const { cfg, prompter, accountId } = params;
|
||||
const resolved = resolveTelegramAccount({ cfg, accountId });
|
||||
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
||||
await noteTelegramUserIdHelp(prompter);
|
||||
|
||||
const token = params.tokenOverride?.trim() || resolved.token;
|
||||
if (!token) {
|
||||
await prompter.note("Telegram token missing; username lookup is unavailable.", "Telegram");
|
||||
}
|
||||
const unique = await promptResolvedAllowFrom({
|
||||
prompter,
|
||||
existing: existingAllowFrom,
|
||||
token,
|
||||
message: "Telegram allowFrom (numeric sender id; @username resolves to id)",
|
||||
placeholder: "@username",
|
||||
label: "Telegram allowlist",
|
||||
parseInputs: splitOnboardingEntries,
|
||||
parseId: parseTelegramAllowFromId,
|
||||
invalidWithoutTokenNote:
|
||||
"Telegram token missing; use numeric sender ids (usernames require a bot token).",
|
||||
resolveEntries: async ({ token: tokenValue, entries }) => {
|
||||
const results = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const numericId = parseTelegramAllowFromId(entry);
|
||||
if (numericId) {
|
||||
return { input: entry, resolved: true, id: numericId };
|
||||
}
|
||||
const stripped = normalizeTelegramAllowFromInput(entry);
|
||||
if (!stripped) {
|
||||
return { input: entry, resolved: false, id: null };
|
||||
}
|
||||
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
|
||||
const id = await fetchTelegramChatId({ token: tokenValue, chatId: username });
|
||||
return { input: entry, resolved: Boolean(id), id };
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
},
|
||||
});
|
||||
|
||||
return patchChannelConfigForAccount({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId,
|
||||
patch: { dmPolicy: "allowlist", allowFrom: unique },
|
||||
});
|
||||
}
|
||||
|
||||
async function promptTelegramAllowFromForAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId?: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const accountId = resolveOnboardingAccountId({
|
||||
accountId: params.accountId,
|
||||
defaultAccountId: resolveDefaultTelegramAccountId(params.cfg),
|
||||
});
|
||||
return promptTelegramAllowFrom({
|
||||
cfg: params.cfg,
|
||||
prompter: params.prompter,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Telegram",
|
||||
channel,
|
||||
policyKey: "channels.telegram.dmPolicy",
|
||||
allowFromKey: "channels.telegram.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy) =>
|
||||
setChannelDmPolicyWithAllowFrom({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
dmPolicy: policy,
|
||||
}),
|
||||
promptAllowFrom: promptTelegramAllowFromForAccount,
|
||||
};
|
||||
|
||||
export const telegramOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listTelegramAccountIds(cfg).some((accountId) => {
|
||||
const account = inspectTelegramAccount({ cfg, accountId });
|
||||
return account.configured;
|
||||
});
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [`Telegram: ${configured ? "configured" : "needs token"}`],
|
||||
selectionHint: configured ? "recommended · configured" : "recommended · newcomer-friendly",
|
||||
quickstartScore: configured ? 1 : 10,
|
||||
};
|
||||
},
|
||||
configure: async ({
|
||||
cfg,
|
||||
prompter,
|
||||
options,
|
||||
accountOverrides,
|
||||
shouldPromptAccountIds,
|
||||
forceAllowFrom,
|
||||
}) => {
|
||||
const defaultTelegramAccountId = resolveDefaultTelegramAccountId(cfg);
|
||||
const telegramAccountId = await resolveAccountIdForConfigure({
|
||||
cfg,
|
||||
prompter,
|
||||
label: "Telegram",
|
||||
accountOverride: accountOverrides.telegram,
|
||||
shouldPromptAccountIds,
|
||||
listAccountIds: listTelegramAccountIds,
|
||||
defaultAccountId: defaultTelegramAccountId,
|
||||
});
|
||||
|
||||
let next = cfg;
|
||||
const resolvedAccount = resolveTelegramAccount({
|
||||
cfg: next,
|
||||
accountId: telegramAccountId,
|
||||
});
|
||||
const hasConfiguredBotToken = hasConfiguredSecretInput(resolvedAccount.config.botToken);
|
||||
const hasConfigToken =
|
||||
hasConfiguredBotToken || Boolean(resolvedAccount.config.tokenFile?.trim());
|
||||
const allowEnv = telegramAccountId === DEFAULT_ACCOUNT_ID;
|
||||
const tokenStep = await runSingleChannelSecretStep({
|
||||
cfg: next,
|
||||
prompter,
|
||||
providerHint: "telegram",
|
||||
credentialLabel: "Telegram bot token",
|
||||
secretInputMode: options?.secretInputMode,
|
||||
accountConfigured: Boolean(resolvedAccount.token) || hasConfigToken,
|
||||
hasConfigToken,
|
||||
allowEnv,
|
||||
envValue: process.env.TELEGRAM_BOT_TOKEN,
|
||||
envPrompt: "TELEGRAM_BOT_TOKEN detected. Use env var?",
|
||||
keepPrompt: "Telegram token already configured. Keep it?",
|
||||
inputPrompt: "Enter Telegram bot token",
|
||||
preferredEnvVar: allowEnv ? "TELEGRAM_BOT_TOKEN" : undefined,
|
||||
onMissingConfigured: async () => await noteTelegramTokenHelp(prompter),
|
||||
applyUseEnv: async (cfg) =>
|
||||
applySingleTokenPromptResult({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: telegramAccountId,
|
||||
tokenPatchKey: "botToken",
|
||||
tokenResult: { useEnv: true, token: null },
|
||||
}),
|
||||
applySet: async (cfg, value) =>
|
||||
applySingleTokenPromptResult({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: telegramAccountId,
|
||||
tokenPatchKey: "botToken",
|
||||
tokenResult: { useEnv: false, token: value },
|
||||
}),
|
||||
});
|
||||
next = tokenStep.cfg;
|
||||
|
||||
if (forceAllowFrom) {
|
||||
next = await promptTelegramAllowFrom({
|
||||
cfg: next,
|
||||
prompter,
|
||||
accountId: telegramAccountId,
|
||||
tokenOverride: tokenStep.resolvedValue,
|
||||
});
|
||||
}
|
||||
|
||||
return { cfg: next, accountId: telegramAccountId };
|
||||
},
|
||||
dmPolicy,
|
||||
disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false),
|
||||
};
|
||||
export * from "../../../../extensions/telegram/src/onboarding.js";
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ReplyPayload } from "../../../auto-reply/types.js";
|
||||
import { telegramOutbound } from "./telegram.js";
|
||||
|
||||
describe("telegramOutbound", () => {
|
||||
it("passes parsed reply/thread ids for sendText", async () => {
|
||||
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "tg-text-1", chatId: "123" });
|
||||
const sendText = telegramOutbound.sendText;
|
||||
expect(sendText).toBeDefined();
|
||||
|
||||
const result = await sendText!({
|
||||
cfg: {},
|
||||
to: "123",
|
||||
text: "<b>hello</b>",
|
||||
accountId: "work",
|
||||
replyToId: "44",
|
||||
threadId: "55",
|
||||
deps: { telegram: sendTelegram },
|
||||
});
|
||||
|
||||
expect(sendTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"<b>hello</b>",
|
||||
expect.objectContaining({
|
||||
textMode: "html",
|
||||
verbose: false,
|
||||
accountId: "work",
|
||||
replyToMessageId: 44,
|
||||
messageThreadId: 55,
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ channel: "telegram", messageId: "tg-text-1", chatId: "123" });
|
||||
});
|
||||
|
||||
it("parses scoped DM thread ids for sendText", async () => {
|
||||
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "tg-text-2", chatId: "12345" });
|
||||
const sendText = telegramOutbound.sendText;
|
||||
expect(sendText).toBeDefined();
|
||||
|
||||
await sendText!({
|
||||
cfg: {},
|
||||
to: "12345",
|
||||
text: "<b>hello</b>",
|
||||
accountId: "work",
|
||||
threadId: "12345:99",
|
||||
deps: { telegram: sendTelegram },
|
||||
});
|
||||
|
||||
expect(sendTelegram).toHaveBeenCalledWith(
|
||||
"12345",
|
||||
"<b>hello</b>",
|
||||
expect.objectContaining({
|
||||
textMode: "html",
|
||||
verbose: false,
|
||||
accountId: "work",
|
||||
messageThreadId: 99,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes media options for sendMedia", async () => {
|
||||
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "tg-media-1", chatId: "123" });
|
||||
const sendMedia = telegramOutbound.sendMedia;
|
||||
expect(sendMedia).toBeDefined();
|
||||
|
||||
const result = await sendMedia!({
|
||||
cfg: {},
|
||||
to: "123",
|
||||
text: "caption",
|
||||
mediaUrl: "https://example.com/a.jpg",
|
||||
mediaLocalRoots: ["/tmp/media"],
|
||||
accountId: "default",
|
||||
deps: { telegram: sendTelegram },
|
||||
});
|
||||
|
||||
expect(sendTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"caption",
|
||||
expect.objectContaining({
|
||||
textMode: "html",
|
||||
verbose: false,
|
||||
mediaUrl: "https://example.com/a.jpg",
|
||||
mediaLocalRoots: ["/tmp/media"],
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ channel: "telegram", messageId: "tg-media-1", chatId: "123" });
|
||||
});
|
||||
|
||||
it("sends payload media list and applies buttons only to first message", async () => {
|
||||
const sendTelegram = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ messageId: "tg-1", chatId: "123" })
|
||||
.mockResolvedValueOnce({ messageId: "tg-2", chatId: "123" });
|
||||
const sendPayload = telegramOutbound.sendPayload;
|
||||
expect(sendPayload).toBeDefined();
|
||||
|
||||
const payload: ReplyPayload = {
|
||||
text: "caption",
|
||||
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
|
||||
channelData: {
|
||||
telegram: {
|
||||
quoteText: "quoted",
|
||||
buttons: [[{ text: "Approve", callback_data: "ok" }]],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await sendPayload!({
|
||||
cfg: {},
|
||||
to: "123",
|
||||
text: "",
|
||||
payload,
|
||||
mediaLocalRoots: ["/tmp/media"],
|
||||
accountId: "default",
|
||||
deps: { telegram: sendTelegram },
|
||||
});
|
||||
|
||||
expect(sendTelegram).toHaveBeenCalledTimes(2);
|
||||
expect(sendTelegram).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"123",
|
||||
"caption",
|
||||
expect.objectContaining({
|
||||
mediaUrl: "https://example.com/1.jpg",
|
||||
quoteText: "quoted",
|
||||
buttons: [[{ text: "Approve", callback_data: "ok" }]],
|
||||
}),
|
||||
);
|
||||
expect(sendTelegram).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"123",
|
||||
"",
|
||||
expect.objectContaining({
|
||||
mediaUrl: "https://example.com/2.jpg",
|
||||
quoteText: "quoted",
|
||||
}),
|
||||
);
|
||||
const secondCallOpts = sendTelegram.mock.calls[1]?.[2] as Record<string, unknown>;
|
||||
expect(secondCallOpts?.buttons).toBeUndefined();
|
||||
expect(result).toEqual({ channel: "telegram", messageId: "tg-2", chatId: "123" });
|
||||
});
|
||||
});
|
||||
@@ -1,159 +1 @@
|
||||
import type { ReplyPayload } from "../../../auto-reply/types.js";
|
||||
import { resolveOutboundSendDep, type OutboundSendDeps } from "../../../infra/outbound/deliver.js";
|
||||
import type { TelegramInlineButtons } from "../../../telegram/button-types.js";
|
||||
import { markdownToTelegramHtmlChunks } from "../../../telegram/format.js";
|
||||
import {
|
||||
parseTelegramReplyToMessageId,
|
||||
parseTelegramThreadId,
|
||||
} from "../../../telegram/outbound-params.js";
|
||||
import { sendMessageTelegram } from "../../../telegram/send.js";
|
||||
import type { ChannelOutboundAdapter } from "../types.js";
|
||||
import { resolvePayloadMediaUrls, sendPayloadMediaSequence } from "./direct-text-media.js";
|
||||
|
||||
type TelegramSendFn = typeof sendMessageTelegram;
|
||||
type TelegramSendOpts = Parameters<TelegramSendFn>[2];
|
||||
|
||||
function resolveTelegramSendContext(params: {
|
||||
cfg: NonNullable<TelegramSendOpts>["cfg"];
|
||||
deps?: OutboundSendDeps;
|
||||
accountId?: string | null;
|
||||
replyToId?: string | null;
|
||||
threadId?: string | number | null;
|
||||
}): {
|
||||
send: TelegramSendFn;
|
||||
baseOpts: {
|
||||
cfg: NonNullable<TelegramSendOpts>["cfg"];
|
||||
verbose: false;
|
||||
textMode: "html";
|
||||
messageThreadId?: number;
|
||||
replyToMessageId?: number;
|
||||
accountId?: string;
|
||||
};
|
||||
} {
|
||||
const send =
|
||||
resolveOutboundSendDep<typeof sendMessageTelegram>(params.deps, "telegram") ??
|
||||
sendMessageTelegram;
|
||||
return {
|
||||
send,
|
||||
baseOpts: {
|
||||
verbose: false,
|
||||
textMode: "html",
|
||||
cfg: params.cfg,
|
||||
messageThreadId: parseTelegramThreadId(params.threadId),
|
||||
replyToMessageId: parseTelegramReplyToMessageId(params.replyToId),
|
||||
accountId: params.accountId ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendTelegramPayloadMessages(params: {
|
||||
send: TelegramSendFn;
|
||||
to: string;
|
||||
payload: ReplyPayload;
|
||||
baseOpts: Omit<NonNullable<TelegramSendOpts>, "buttons" | "mediaUrl" | "quoteText">;
|
||||
}): Promise<Awaited<ReturnType<TelegramSendFn>>> {
|
||||
const telegramData = params.payload.channelData?.telegram as
|
||||
| { buttons?: TelegramInlineButtons; quoteText?: string }
|
||||
| undefined;
|
||||
const quoteText =
|
||||
typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined;
|
||||
const text = params.payload.text ?? "";
|
||||
const mediaUrls = resolvePayloadMediaUrls(params.payload);
|
||||
const payloadOpts = {
|
||||
...params.baseOpts,
|
||||
quoteText,
|
||||
};
|
||||
|
||||
if (mediaUrls.length === 0) {
|
||||
return await params.send(params.to, text, {
|
||||
...payloadOpts,
|
||||
buttons: telegramData?.buttons,
|
||||
});
|
||||
}
|
||||
|
||||
// Telegram allows reply_markup on media; attach buttons only to the first send.
|
||||
const finalResult = await sendPayloadMediaSequence({
|
||||
text,
|
||||
mediaUrls,
|
||||
send: async ({ text, mediaUrl, isFirst }) =>
|
||||
await params.send(params.to, text, {
|
||||
...payloadOpts,
|
||||
mediaUrl,
|
||||
...(isFirst ? { buttons: telegramData?.buttons } : {}),
|
||||
}),
|
||||
});
|
||||
return finalResult ?? { messageId: "unknown", chatId: params.to };
|
||||
}
|
||||
|
||||
export const telegramOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
chunker: markdownToTelegramHtmlChunks,
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId }) => {
|
||||
const { send, baseOpts } = resolveTelegramSendContext({
|
||||
cfg,
|
||||
deps,
|
||||
accountId,
|
||||
replyToId,
|
||||
threadId,
|
||||
});
|
||||
const result = await send(to, text, {
|
||||
...baseOpts,
|
||||
});
|
||||
return { channel: "telegram", ...result };
|
||||
},
|
||||
sendMedia: async ({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
accountId,
|
||||
deps,
|
||||
replyToId,
|
||||
threadId,
|
||||
}) => {
|
||||
const { send, baseOpts } = resolveTelegramSendContext({
|
||||
cfg,
|
||||
deps,
|
||||
accountId,
|
||||
replyToId,
|
||||
threadId,
|
||||
});
|
||||
const result = await send(to, text, {
|
||||
...baseOpts,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
});
|
||||
return { channel: "telegram", ...result };
|
||||
},
|
||||
sendPayload: async ({
|
||||
cfg,
|
||||
to,
|
||||
payload,
|
||||
mediaLocalRoots,
|
||||
accountId,
|
||||
deps,
|
||||
replyToId,
|
||||
threadId,
|
||||
}) => {
|
||||
const { send, baseOpts } = resolveTelegramSendContext({
|
||||
cfg,
|
||||
deps,
|
||||
accountId,
|
||||
replyToId,
|
||||
threadId,
|
||||
});
|
||||
const result = await sendTelegramPayloadMessages({
|
||||
send,
|
||||
to,
|
||||
payload,
|
||||
baseOpts: {
|
||||
...baseOpts,
|
||||
mediaLocalRoots,
|
||||
},
|
||||
});
|
||||
return { channel: "telegram", ...result };
|
||||
},
|
||||
};
|
||||
export * from "../../../../extensions/telegram/src/outbound-adapter.js";
|
||||
|
||||
@@ -1,145 +1 @@
|
||||
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js";
|
||||
import {
|
||||
appendMatchMetadata,
|
||||
asString,
|
||||
isRecord,
|
||||
resolveEnabledConfiguredAccountId,
|
||||
} from "./shared.js";
|
||||
|
||||
type TelegramAccountStatus = {
|
||||
accountId?: unknown;
|
||||
enabled?: unknown;
|
||||
configured?: unknown;
|
||||
allowUnmentionedGroups?: unknown;
|
||||
audit?: unknown;
|
||||
};
|
||||
|
||||
type TelegramGroupMembershipAuditSummary = {
|
||||
unresolvedGroups?: number;
|
||||
hasWildcardUnmentionedGroups?: boolean;
|
||||
groups?: Array<{
|
||||
chatId: string;
|
||||
ok?: boolean;
|
||||
status?: string | null;
|
||||
error?: string | null;
|
||||
matchKey?: string;
|
||||
matchSource?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function readTelegramAccountStatus(value: ChannelAccountSnapshot): TelegramAccountStatus | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
accountId: value.accountId,
|
||||
enabled: value.enabled,
|
||||
configured: value.configured,
|
||||
allowUnmentionedGroups: value.allowUnmentionedGroups,
|
||||
audit: value.audit,
|
||||
};
|
||||
}
|
||||
|
||||
function readTelegramGroupMembershipAuditSummary(
|
||||
value: unknown,
|
||||
): TelegramGroupMembershipAuditSummary {
|
||||
if (!isRecord(value)) {
|
||||
return {};
|
||||
}
|
||||
const unresolvedGroups =
|
||||
typeof value.unresolvedGroups === "number" && Number.isFinite(value.unresolvedGroups)
|
||||
? value.unresolvedGroups
|
||||
: undefined;
|
||||
const hasWildcardUnmentionedGroups =
|
||||
typeof value.hasWildcardUnmentionedGroups === "boolean"
|
||||
? value.hasWildcardUnmentionedGroups
|
||||
: undefined;
|
||||
const groupsRaw = value.groups;
|
||||
const groups = Array.isArray(groupsRaw)
|
||||
? (groupsRaw
|
||||
.map((entry) => {
|
||||
if (!isRecord(entry)) {
|
||||
return null;
|
||||
}
|
||||
const chatId = asString(entry.chatId);
|
||||
if (!chatId) {
|
||||
return null;
|
||||
}
|
||||
const ok = typeof entry.ok === "boolean" ? entry.ok : undefined;
|
||||
const status = asString(entry.status) ?? null;
|
||||
const error = asString(entry.error) ?? null;
|
||||
const matchKey = asString(entry.matchKey) ?? undefined;
|
||||
const matchSource = asString(entry.matchSource) ?? undefined;
|
||||
return { chatId, ok, status, error, matchKey, matchSource };
|
||||
})
|
||||
.filter(Boolean) as TelegramGroupMembershipAuditSummary["groups"])
|
||||
: undefined;
|
||||
return { unresolvedGroups, hasWildcardUnmentionedGroups, groups };
|
||||
}
|
||||
|
||||
export function collectTelegramStatusIssues(
|
||||
accounts: ChannelAccountSnapshot[],
|
||||
): ChannelStatusIssue[] {
|
||||
const issues: ChannelStatusIssue[] = [];
|
||||
for (const entry of accounts) {
|
||||
const account = readTelegramAccountStatus(entry);
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
const accountId = resolveEnabledConfiguredAccountId(account);
|
||||
if (!accountId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (account.allowUnmentionedGroups === true) {
|
||||
issues.push({
|
||||
channel: "telegram",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message:
|
||||
"Config allows unmentioned group messages (requireMention=false). Telegram Bot API privacy mode will block most group messages unless disabled.",
|
||||
fix: "In BotFather run /setprivacy → Disable for this bot (then restart the gateway).",
|
||||
});
|
||||
}
|
||||
|
||||
const audit = readTelegramGroupMembershipAuditSummary(account.audit);
|
||||
if (audit.hasWildcardUnmentionedGroups === true) {
|
||||
issues.push({
|
||||
channel: "telegram",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message:
|
||||
'Telegram groups config uses "*" with requireMention=false; membership probing is not possible without explicit group IDs.',
|
||||
fix: "Add explicit numeric group ids under channels.telegram.groups (or per-account groups) to enable probing.",
|
||||
});
|
||||
}
|
||||
if (audit.unresolvedGroups && audit.unresolvedGroups > 0) {
|
||||
issues.push({
|
||||
channel: "telegram",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: `Some configured Telegram groups are not numeric IDs (unresolvedGroups=${audit.unresolvedGroups}). Membership probe can only check numeric group IDs.`,
|
||||
fix: "Use numeric chat IDs (e.g. -100...) as keys in channels.telegram.groups for requireMention=false groups.",
|
||||
});
|
||||
}
|
||||
for (const group of audit.groups ?? []) {
|
||||
if (group.ok === true) {
|
||||
continue;
|
||||
}
|
||||
const status = group.status ? ` status=${group.status}` : "";
|
||||
const err = group.error ? `: ${group.error}` : "";
|
||||
const baseMessage = `Group ${group.chatId} not reachable by bot.${status}${err}`;
|
||||
issues.push({
|
||||
channel: "telegram",
|
||||
accountId,
|
||||
kind: "runtime",
|
||||
message: appendMatchMetadata(baseMessage, {
|
||||
matchKey: group.matchKey,
|
||||
matchSource: group.matchSource,
|
||||
}),
|
||||
fix: "Invite the bot to the group, then DM the bot once (/start) and restart the gateway.",
|
||||
});
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
export * from "../../../../extensions/telegram/src/status-issues.js";
|
||||
|
||||
Reference in New Issue
Block a user