mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 07:22:44 +00:00
fix: persist resolved telegram delivery targets at runtime
This commit is contained in:
@@ -1,23 +1,7 @@
|
|||||||
|
import { normalizeTelegramLookupTarget } from "../../../telegram/targets.js";
|
||||||
|
|
||||||
export function normalizeTelegramMessagingTarget(raw: string): string | undefined {
|
export function normalizeTelegramMessagingTarget(raw: string): string | undefined {
|
||||||
const trimmed = raw.trim();
|
const normalized = normalizeTelegramLookupTarget(raw);
|
||||||
if (!trimmed) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
let normalized = trimmed;
|
|
||||||
if (normalized.startsWith("telegram:")) {
|
|
||||||
normalized = normalized.slice("telegram:".length).trim();
|
|
||||||
} else if (normalized.startsWith("tg:")) {
|
|
||||||
normalized = normalized.slice("tg:".length).trim();
|
|
||||||
}
|
|
||||||
if (!normalized) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const tmeMatch =
|
|
||||||
/^https?:\/\/t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized) ??
|
|
||||||
/^t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized);
|
|
||||||
if (tmeMatch?.[1]) {
|
|
||||||
normalized = `@${tmeMatch[1]}`;
|
|
||||||
}
|
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -25,15 +9,5 @@ export function normalizeTelegramMessagingTarget(raw: string): string | undefine
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function looksLikeTelegramTargetId(raw: string): boolean {
|
export function looksLikeTelegramTargetId(raw: string): boolean {
|
||||||
const trimmed = raw.trim();
|
return Boolean(normalizeTelegramLookupTarget(raw));
|
||||||
if (!trimmed) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (/^(telegram|tg):/i.test(trimmed)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (trimmed.startsWith("@")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return /^-?\d{6,}$/.test(trimmed);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,11 +27,16 @@ const { loadConfig } = vi.hoisted(() => ({
|
|||||||
loadConfig: vi.fn(() => ({})),
|
loadConfig: vi.fn(() => ({})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const { maybePersistResolvedTelegramTarget } = vi.hoisted(() => ({
|
||||||
|
maybePersistResolvedTelegramTarget: vi.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
type TelegramSendTestMocks = {
|
type TelegramSendTestMocks = {
|
||||||
botApi: Record<string, MockFn>;
|
botApi: Record<string, MockFn>;
|
||||||
botCtorSpy: MockFn;
|
botCtorSpy: MockFn;
|
||||||
loadConfig: MockFn;
|
loadConfig: MockFn;
|
||||||
loadWebMedia: MockFn;
|
loadWebMedia: MockFn;
|
||||||
|
maybePersistResolvedTelegramTarget: MockFn;
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("../web/media.js", () => ({
|
vi.mock("../web/media.js", () => ({
|
||||||
@@ -62,14 +67,20 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("./target-writeback.js", () => ({
|
||||||
|
maybePersistResolvedTelegramTarget,
|
||||||
|
}));
|
||||||
|
|
||||||
export function getTelegramSendTestMocks(): TelegramSendTestMocks {
|
export function getTelegramSendTestMocks(): TelegramSendTestMocks {
|
||||||
return { botApi, botCtorSpy, loadConfig, loadWebMedia };
|
return { botApi, botCtorSpy, loadConfig, loadWebMedia, maybePersistResolvedTelegramTarget };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function installTelegramSendTestHooks() {
|
export function installTelegramSendTestHooks() {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
loadConfig.mockReturnValue({});
|
loadConfig.mockReturnValue({});
|
||||||
loadWebMedia.mockReset();
|
loadWebMedia.mockReset();
|
||||||
|
maybePersistResolvedTelegramTarget.mockReset();
|
||||||
|
maybePersistResolvedTelegramTarget.mockResolvedValue(undefined);
|
||||||
botCtorSpy.mockReset();
|
botCtorSpy.mockReset();
|
||||||
for (const fn of Object.values(botApi)) {
|
for (const fn of Object.values(botApi)) {
|
||||||
fn.mockReset();
|
fn.mockReset();
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { clearSentMessageCache, recordSentMessage, wasSentByBot } from "./sent-m
|
|||||||
|
|
||||||
installTelegramSendTestHooks();
|
installTelegramSendTestHooks();
|
||||||
|
|
||||||
const { botApi, botCtorSpy, loadConfig, loadWebMedia } = getTelegramSendTestMocks();
|
const { botApi, botCtorSpy, loadConfig, loadWebMedia, maybePersistResolvedTelegramTarget } =
|
||||||
|
getTelegramSendTestMocks();
|
||||||
const {
|
const {
|
||||||
buildInlineKeyboard,
|
buildInlineKeyboard,
|
||||||
createForumTopicTelegram,
|
createForumTopicTelegram,
|
||||||
@@ -369,6 +370,48 @@ describe("sendMessageTelegram", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves t.me targets to numeric chat ids via getChat", async () => {
|
||||||
|
const sendMessage = vi.fn().mockResolvedValue({
|
||||||
|
message_id: 1,
|
||||||
|
chat: { id: "-100123" },
|
||||||
|
});
|
||||||
|
const getChat = vi.fn().mockResolvedValue({ id: -100123 });
|
||||||
|
const api = { sendMessage, getChat } as unknown as {
|
||||||
|
sendMessage: typeof sendMessage;
|
||||||
|
getChat: typeof getChat;
|
||||||
|
};
|
||||||
|
|
||||||
|
await sendMessageTelegram("https://t.me/mychannel", "hi", {
|
||||||
|
token: "tok",
|
||||||
|
api,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getChat).toHaveBeenCalledWith("@mychannel");
|
||||||
|
expect(sendMessage).toHaveBeenCalledWith("-100123", "hi", {
|
||||||
|
parse_mode: "HTML",
|
||||||
|
});
|
||||||
|
expect(maybePersistResolvedTelegramTarget).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
rawTarget: "https://t.me/mychannel",
|
||||||
|
resolvedChatId: "-100123",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails clearly when a legacy target cannot be resolved", async () => {
|
||||||
|
const getChat = vi.fn().mockRejectedValue(new Error("400: Bad Request: chat not found"));
|
||||||
|
const api = { getChat } as unknown as {
|
||||||
|
getChat: typeof getChat;
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sendMessageTelegram("@missingchannel", "hi", {
|
||||||
|
token: "tok",
|
||||||
|
api,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/could not be resolved to a numeric chat ID/i);
|
||||||
|
});
|
||||||
|
|
||||||
it("includes thread params in media messages", async () => {
|
it("includes thread params in media messages", async () => {
|
||||||
const chatId = "-1001234567890";
|
const chatId = "-1001234567890";
|
||||||
const sendPhoto = vi.fn().mockResolvedValue({
|
const sendPhoto = vi.fn().mockResolvedValue({
|
||||||
@@ -1100,6 +1143,31 @@ describe("reactMessageTelegram", () => {
|
|||||||
|
|
||||||
expect(setMessageReaction).toHaveBeenCalledWith("123", 456, testCase.expected);
|
expect(setMessageReaction).toHaveBeenCalledWith("123", 456, testCase.expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves legacy telegram targets before reacting", async () => {
|
||||||
|
const setMessageReaction = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const getChat = vi.fn().mockResolvedValue({ id: -100123 });
|
||||||
|
const api = { setMessageReaction, getChat } as unknown as {
|
||||||
|
setMessageReaction: typeof setMessageReaction;
|
||||||
|
getChat: typeof getChat;
|
||||||
|
};
|
||||||
|
|
||||||
|
await reactMessageTelegram("@mychannel", 456, "✅", {
|
||||||
|
token: "tok",
|
||||||
|
api,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getChat).toHaveBeenCalledWith("@mychannel");
|
||||||
|
expect(setMessageReaction).toHaveBeenCalledWith("-100123", 456, [
|
||||||
|
{ type: "emoji", emoji: "✅" },
|
||||||
|
]);
|
||||||
|
expect(maybePersistResolvedTelegramTarget).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
rawTarget: "@mychannel",
|
||||||
|
resolvedChatId: "-100123",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sendStickerTelegram", () => {
|
describe("sendStickerTelegram", () => {
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ import { renderTelegramHtmlText } from "./format.js";
|
|||||||
import { isRecoverableTelegramNetworkError } from "./network-errors.js";
|
import { isRecoverableTelegramNetworkError } from "./network-errors.js";
|
||||||
import { makeProxyFetch } from "./proxy.js";
|
import { makeProxyFetch } from "./proxy.js";
|
||||||
import { recordSentMessage } from "./sent-message-cache.js";
|
import { recordSentMessage } from "./sent-message-cache.js";
|
||||||
import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js";
|
import { maybePersistResolvedTelegramTarget } from "./target-writeback.js";
|
||||||
|
import {
|
||||||
|
normalizeTelegramChatId,
|
||||||
|
normalizeTelegramLookupTarget,
|
||||||
|
parseTelegramTarget,
|
||||||
|
} from "./targets.js";
|
||||||
import { resolveTelegramVoiceSend } from "./voice.js";
|
import { resolveTelegramVoiceSend } from "./voice.js";
|
||||||
|
|
||||||
type TelegramApi = Bot["api"];
|
type TelegramApi = Bot["api"];
|
||||||
@@ -136,42 +141,56 @@ function resolveToken(explicit: string | undefined, params: { accountId: string;
|
|||||||
return params.token.trim();
|
return params.token.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeChatId(to: string): string {
|
async function resolveChatId(
|
||||||
const trimmed = to.trim();
|
to: string,
|
||||||
if (!trimmed) {
|
params: { api: TelegramApiOverride; verbose?: boolean },
|
||||||
throw new Error("Recipient is required for Telegram sends");
|
): Promise<string> {
|
||||||
|
const numericChatId = normalizeTelegramChatId(to);
|
||||||
|
if (numericChatId) {
|
||||||
|
return numericChatId;
|
||||||
}
|
}
|
||||||
|
const lookupTarget = normalizeTelegramLookupTarget(to);
|
||||||
|
const getChat = params.api.getChat;
|
||||||
|
if (!lookupTarget || typeof getChat !== "function") {
|
||||||
|
throw new Error("Telegram recipient must be a numeric chat ID");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const chat = await getChat.call(params.api, lookupTarget);
|
||||||
|
const resolved = normalizeTelegramChatId(String(chat?.id ?? ""));
|
||||||
|
if (!resolved) {
|
||||||
|
throw new Error(`resolved chat id is not numeric (${String(chat?.id ?? "")})`);
|
||||||
|
}
|
||||||
|
if (params.verbose) {
|
||||||
|
sendLogger.warn(`telegram recipient ${lookupTarget} resolved to numeric chat id ${resolved}`);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
} catch (err) {
|
||||||
|
const detail = formatErrorMessage(err);
|
||||||
|
throw new Error(
|
||||||
|
`Telegram recipient ${lookupTarget} could not be resolved to a numeric chat ID (${detail})`,
|
||||||
|
{ cause: err },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Common internal prefixes that sometimes leak into outbound sends.
|
async function resolveAndPersistChatId(params: {
|
||||||
// - ctx.To uses `telegram:<id>`
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
// - group sessions often use `telegram:group:<id>`
|
api: TelegramApiOverride;
|
||||||
let normalized = stripTelegramInternalPrefixes(trimmed);
|
lookupTarget: string;
|
||||||
|
persistTarget: string;
|
||||||
// Accept t.me links for public chats/channels.
|
verbose?: boolean;
|
||||||
// (Invite links like `t.me/+...` are not resolvable via Bot API.)
|
}): Promise<string> {
|
||||||
const m =
|
const chatId = await resolveChatId(params.lookupTarget, {
|
||||||
/^https?:\/\/t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized) ??
|
api: params.api,
|
||||||
/^t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized);
|
verbose: params.verbose,
|
||||||
if (m?.[1]) {
|
});
|
||||||
normalized = `@${m[1]}`;
|
await maybePersistResolvedTelegramTarget({
|
||||||
}
|
cfg: params.cfg,
|
||||||
|
rawTarget: params.persistTarget,
|
||||||
if (!normalized) {
|
resolvedChatId: chatId,
|
||||||
throw new Error("Recipient is required for Telegram sends");
|
verbose: params.verbose,
|
||||||
}
|
});
|
||||||
if (normalized.startsWith("@")) {
|
return chatId;
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
if (/^-?\d+$/.test(normalized)) {
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the user passed a username without `@`, assume they meant a public chat/channel.
|
|
||||||
if (/^[A-Za-z0-9_]{5,}$/i.test(normalized)) {
|
|
||||||
return `@${normalized}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMessageId(raw: string | number): number {
|
function normalizeMessageId(raw: string | number): number {
|
||||||
@@ -434,7 +453,13 @@ export async function sendMessageTelegram(
|
|||||||
): Promise<TelegramSendResult> {
|
): Promise<TelegramSendResult> {
|
||||||
const { cfg, account, api } = resolveTelegramApiContext(opts);
|
const { cfg, account, api } = resolveTelegramApiContext(opts);
|
||||||
const target = parseTelegramTarget(to);
|
const target = parseTelegramTarget(to);
|
||||||
const chatId = normalizeChatId(target.chatId);
|
const chatId = await resolveAndPersistChatId({
|
||||||
|
cfg,
|
||||||
|
api,
|
||||||
|
lookupTarget: target.chatId,
|
||||||
|
persistTarget: to,
|
||||||
|
verbose: opts.verbose,
|
||||||
|
});
|
||||||
const mediaUrl = opts.mediaUrl?.trim();
|
const mediaUrl = opts.mediaUrl?.trim();
|
||||||
const replyMarkup = buildInlineKeyboard(opts.buttons);
|
const replyMarkup = buildInlineKeyboard(opts.buttons);
|
||||||
|
|
||||||
@@ -722,7 +747,14 @@ export async function reactMessageTelegram(
|
|||||||
opts: TelegramReactionOpts = {},
|
opts: TelegramReactionOpts = {},
|
||||||
): Promise<{ ok: true } | { ok: false; warning: string }> {
|
): Promise<{ ok: true } | { ok: false; warning: string }> {
|
||||||
const { cfg, account, api } = resolveTelegramApiContext(opts);
|
const { cfg, account, api } = resolveTelegramApiContext(opts);
|
||||||
const chatId = normalizeChatId(String(chatIdInput));
|
const rawTarget = String(chatIdInput);
|
||||||
|
const chatId = await resolveAndPersistChatId({
|
||||||
|
cfg,
|
||||||
|
api,
|
||||||
|
lookupTarget: rawTarget,
|
||||||
|
persistTarget: rawTarget,
|
||||||
|
verbose: opts.verbose,
|
||||||
|
});
|
||||||
const messageId = normalizeMessageId(messageIdInput);
|
const messageId = normalizeMessageId(messageIdInput);
|
||||||
const requestWithDiag = createTelegramRequestWithDiag({
|
const requestWithDiag = createTelegramRequestWithDiag({
|
||||||
cfg,
|
cfg,
|
||||||
@@ -768,7 +800,14 @@ export async function deleteMessageTelegram(
|
|||||||
opts: TelegramDeleteOpts = {},
|
opts: TelegramDeleteOpts = {},
|
||||||
): Promise<{ ok: true }> {
|
): Promise<{ ok: true }> {
|
||||||
const { cfg, account, api } = resolveTelegramApiContext(opts);
|
const { cfg, account, api } = resolveTelegramApiContext(opts);
|
||||||
const chatId = normalizeChatId(String(chatIdInput));
|
const rawTarget = String(chatIdInput);
|
||||||
|
const chatId = await resolveAndPersistChatId({
|
||||||
|
cfg,
|
||||||
|
api,
|
||||||
|
lookupTarget: rawTarget,
|
||||||
|
persistTarget: rawTarget,
|
||||||
|
verbose: opts.verbose,
|
||||||
|
});
|
||||||
const messageId = normalizeMessageId(messageIdInput);
|
const messageId = normalizeMessageId(messageIdInput);
|
||||||
const requestWithDiag = createTelegramRequestWithDiag({
|
const requestWithDiag = createTelegramRequestWithDiag({
|
||||||
cfg,
|
cfg,
|
||||||
@@ -807,7 +846,14 @@ export async function editMessageTelegram(
|
|||||||
...opts,
|
...opts,
|
||||||
cfg: opts.cfg,
|
cfg: opts.cfg,
|
||||||
});
|
});
|
||||||
const chatId = normalizeChatId(String(chatIdInput));
|
const rawTarget = String(chatIdInput);
|
||||||
|
const chatId = await resolveAndPersistChatId({
|
||||||
|
cfg,
|
||||||
|
api,
|
||||||
|
lookupTarget: rawTarget,
|
||||||
|
persistTarget: rawTarget,
|
||||||
|
verbose: opts.verbose,
|
||||||
|
});
|
||||||
const messageId = normalizeMessageId(messageIdInput);
|
const messageId = normalizeMessageId(messageIdInput);
|
||||||
const requestWithDiag = createTelegramRequestWithDiag({
|
const requestWithDiag = createTelegramRequestWithDiag({
|
||||||
cfg,
|
cfg,
|
||||||
@@ -928,7 +974,13 @@ export async function sendStickerTelegram(
|
|||||||
|
|
||||||
const { cfg, account, api } = resolveTelegramApiContext(opts);
|
const { cfg, account, api } = resolveTelegramApiContext(opts);
|
||||||
const target = parseTelegramTarget(to);
|
const target = parseTelegramTarget(to);
|
||||||
const chatId = normalizeChatId(target.chatId);
|
const chatId = await resolveAndPersistChatId({
|
||||||
|
cfg,
|
||||||
|
api,
|
||||||
|
lookupTarget: target.chatId,
|
||||||
|
persistTarget: to,
|
||||||
|
verbose: opts.verbose,
|
||||||
|
});
|
||||||
|
|
||||||
const threadParams = buildTelegramThreadReplyParams({
|
const threadParams = buildTelegramThreadReplyParams({
|
||||||
targetMessageThreadId: target.messageThreadId,
|
targetMessageThreadId: target.messageThreadId,
|
||||||
@@ -1004,7 +1056,13 @@ export async function sendPollTelegram(
|
|||||||
): Promise<{ messageId: string; chatId: string; pollId?: string }> {
|
): Promise<{ messageId: string; chatId: string; pollId?: string }> {
|
||||||
const { cfg, account, api } = resolveTelegramApiContext(opts);
|
const { cfg, account, api } = resolveTelegramApiContext(opts);
|
||||||
const target = parseTelegramTarget(to);
|
const target = parseTelegramTarget(to);
|
||||||
const chatId = normalizeChatId(target.chatId);
|
const chatId = await resolveAndPersistChatId({
|
||||||
|
cfg,
|
||||||
|
api,
|
||||||
|
lookupTarget: target.chatId,
|
||||||
|
persistTarget: to,
|
||||||
|
verbose: opts.verbose,
|
||||||
|
});
|
||||||
|
|
||||||
// Normalize the poll input (validates question, options, maxSelections)
|
// Normalize the poll input (validates question, options, maxSelections)
|
||||||
const normalizedPoll = normalizePollInput(poll, { maxOptions: 10 });
|
const normalizedPoll = normalizePollInput(poll, { maxOptions: 10 });
|
||||||
@@ -1130,10 +1188,16 @@ export async function createForumTopicTelegram(
|
|||||||
const token = resolveToken(opts.token, account);
|
const token = resolveToken(opts.token, account);
|
||||||
// Accept topic-qualified targets (e.g. telegram:group:<id>:topic:<thread>)
|
// Accept topic-qualified targets (e.g. telegram:group:<id>:topic:<thread>)
|
||||||
// but createForumTopic must always target the base supergroup chat id.
|
// but createForumTopic must always target the base supergroup chat id.
|
||||||
const target = parseTelegramTarget(chatId);
|
|
||||||
const normalizedChatId = normalizeChatId(target.chatId);
|
|
||||||
const client = resolveTelegramClientOptions(account);
|
const client = resolveTelegramClientOptions(account);
|
||||||
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
|
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
|
||||||
|
const target = parseTelegramTarget(chatId);
|
||||||
|
const normalizedChatId = await resolveAndPersistChatId({
|
||||||
|
cfg,
|
||||||
|
api,
|
||||||
|
lookupTarget: target.chatId,
|
||||||
|
persistTarget: chatId,
|
||||||
|
verbose: opts.verbose,
|
||||||
|
});
|
||||||
|
|
||||||
const request = createTelegramRetryRunner({
|
const request = createTelegramRetryRunner({
|
||||||
retry: opts.retry,
|
retry: opts.retry,
|
||||||
|
|||||||
146
src/telegram/target-writeback.test.ts
Normal file
146
src/telegram/target-writeback.test.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
|
|
||||||
|
const readConfigFileSnapshotForWrite = vi.fn();
|
||||||
|
const writeConfigFile = vi.fn();
|
||||||
|
const loadCronStore = vi.fn();
|
||||||
|
const resolveCronStorePath = vi.fn();
|
||||||
|
const saveCronStore = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../config/config.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
readConfigFileSnapshotForWrite,
|
||||||
|
writeConfigFile,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../cron/store.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../cron/store.js")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
loadCronStore,
|
||||||
|
resolveCronStorePath,
|
||||||
|
saveCronStore,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { maybePersistResolvedTelegramTarget } = await import("./target-writeback.js");
|
||||||
|
|
||||||
|
describe("maybePersistResolvedTelegramTarget", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
readConfigFileSnapshotForWrite.mockReset();
|
||||||
|
writeConfigFile.mockReset();
|
||||||
|
loadCronStore.mockReset();
|
||||||
|
resolveCronStorePath.mockReset();
|
||||||
|
saveCronStore.mockReset();
|
||||||
|
resolveCronStorePath.mockReturnValue("/tmp/cron/jobs.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips writeback when target is already numeric", async () => {
|
||||||
|
await maybePersistResolvedTelegramTarget({
|
||||||
|
cfg: {} as OpenClawConfig,
|
||||||
|
rawTarget: "-100123",
|
||||||
|
resolvedChatId: "-100123",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readConfigFileSnapshotForWrite).not.toHaveBeenCalled();
|
||||||
|
expect(loadCronStore).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes back matching config and cron targets", async () => {
|
||||||
|
readConfigFileSnapshotForWrite.mockResolvedValue({
|
||||||
|
snapshot: {
|
||||||
|
config: {
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
defaultTo: "t.me/mychannel",
|
||||||
|
accounts: {
|
||||||
|
alerts: {
|
||||||
|
defaultTo: "@mychannel",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
writeOptions: { expectedConfigPath: "/tmp/openclaw.json" },
|
||||||
|
});
|
||||||
|
loadCronStore.mockResolvedValue({
|
||||||
|
version: 1,
|
||||||
|
jobs: [
|
||||||
|
{ id: "a", delivery: { channel: "telegram", to: "https://t.me/mychannel" } },
|
||||||
|
{ id: "b", delivery: { channel: "slack", to: "C123" } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await maybePersistResolvedTelegramTarget({
|
||||||
|
cfg: {
|
||||||
|
cron: { store: "/tmp/cron/jobs.json" },
|
||||||
|
} as OpenClawConfig,
|
||||||
|
rawTarget: "t.me/mychannel",
|
||||||
|
resolvedChatId: "-100123",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(writeConfigFile).toHaveBeenCalledTimes(1);
|
||||||
|
expect(writeConfigFile).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
defaultTo: "-100123",
|
||||||
|
accounts: {
|
||||||
|
alerts: {
|
||||||
|
defaultTo: "-100123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
expect.objectContaining({ expectedConfigPath: "/tmp/openclaw.json" }),
|
||||||
|
);
|
||||||
|
expect(saveCronStore).toHaveBeenCalledTimes(1);
|
||||||
|
expect(saveCronStore).toHaveBeenCalledWith(
|
||||||
|
"/tmp/cron/jobs.json",
|
||||||
|
expect.objectContaining({
|
||||||
|
jobs: [
|
||||||
|
{ id: "a", delivery: { channel: "telegram", to: "-100123" } },
|
||||||
|
{ id: "b", delivery: { channel: "slack", to: "C123" } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves topic suffix style in writeback target", async () => {
|
||||||
|
readConfigFileSnapshotForWrite.mockResolvedValue({
|
||||||
|
snapshot: {
|
||||||
|
config: {
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
defaultTo: "t.me/mychannel:topic:9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
writeOptions: {},
|
||||||
|
});
|
||||||
|
loadCronStore.mockResolvedValue({ version: 1, jobs: [] });
|
||||||
|
|
||||||
|
await maybePersistResolvedTelegramTarget({
|
||||||
|
cfg: {} as OpenClawConfig,
|
||||||
|
rawTarget: "t.me/mychannel:topic:9",
|
||||||
|
resolvedChatId: "-100123",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(writeConfigFile).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
defaultTo: "-100123:topic:9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
193
src/telegram/target-writeback.ts
Normal file
193
src/telegram/target-writeback.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
|
import { readConfigFileSnapshotForWrite, writeConfigFile } from "../config/config.js";
|
||||||
|
import { loadCronStore, resolveCronStorePath, saveCronStore } from "../cron/store.js";
|
||||||
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
|
import {
|
||||||
|
normalizeTelegramChatId,
|
||||||
|
normalizeTelegramLookupTarget,
|
||||||
|
parseTelegramTarget,
|
||||||
|
} from "./targets.js";
|
||||||
|
|
||||||
|
const writebackLogger = createSubsystemLogger("telegram/target-writeback");
|
||||||
|
|
||||||
|
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTelegramTargetForMatch(raw: string): string | undefined {
|
||||||
|
const parsed = parseTelegramTarget(raw);
|
||||||
|
const normalized = normalizeTelegramLookupTarget(parsed.chatId);
|
||||||
|
if (!normalized) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const threadKey = parsed.messageThreadId == null ? "" : String(parsed.messageThreadId);
|
||||||
|
return `${normalized}|${threadKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResolvedTelegramTarget(params: {
|
||||||
|
raw: string;
|
||||||
|
parsed: ReturnType<typeof parseTelegramTarget>;
|
||||||
|
resolvedChatId: string;
|
||||||
|
}): string {
|
||||||
|
const { raw, parsed, resolvedChatId } = params;
|
||||||
|
if (parsed.messageThreadId == null) {
|
||||||
|
return resolvedChatId;
|
||||||
|
}
|
||||||
|
return raw.includes(":topic:")
|
||||||
|
? `${resolvedChatId}:topic:${parsed.messageThreadId}`
|
||||||
|
: `${resolvedChatId}:${parsed.messageThreadId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLegacyRewrite(params: {
|
||||||
|
raw: string;
|
||||||
|
resolvedChatId: string;
|
||||||
|
}): { matchKey: string; resolvedTarget: string } | null {
|
||||||
|
const parsed = parseTelegramTarget(params.raw);
|
||||||
|
if (normalizeTelegramChatId(parsed.chatId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = normalizeTelegramLookupTarget(parsed.chatId);
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const threadKey = parsed.messageThreadId == null ? "" : String(parsed.messageThreadId);
|
||||||
|
return {
|
||||||
|
matchKey: `${normalized}|${threadKey}`,
|
||||||
|
resolvedTarget: buildResolvedTelegramTarget({
|
||||||
|
raw: params.raw,
|
||||||
|
parsed,
|
||||||
|
resolvedChatId: params.resolvedChatId,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteTargetIfMatch(params: {
|
||||||
|
rawValue: unknown;
|
||||||
|
matchKey: string;
|
||||||
|
resolvedTarget: string;
|
||||||
|
}): string | null {
|
||||||
|
if (typeof params.rawValue !== "string" && typeof params.rawValue !== "number") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = String(params.rawValue).trim();
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (normalizeTelegramTargetForMatch(value) !== params.matchKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return params.resolvedTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceTelegramDefaultToTargets(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
matchKey: string;
|
||||||
|
resolvedTarget: string;
|
||||||
|
}): boolean {
|
||||||
|
let changed = false;
|
||||||
|
const telegram = asObjectRecord(params.cfg.channels?.telegram);
|
||||||
|
if (!telegram) {
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeReplace = (holder: Record<string, unknown>, key: string) => {
|
||||||
|
const nextTarget = rewriteTargetIfMatch({
|
||||||
|
rawValue: holder[key],
|
||||||
|
matchKey: params.matchKey,
|
||||||
|
resolvedTarget: params.resolvedTarget,
|
||||||
|
});
|
||||||
|
if (!nextTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
holder[key] = nextTarget;
|
||||||
|
changed = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
maybeReplace(telegram, "defaultTo");
|
||||||
|
const accounts = asObjectRecord(telegram.accounts);
|
||||||
|
if (!accounts) {
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
for (const accountId of Object.keys(accounts)) {
|
||||||
|
const account = asObjectRecord(accounts[accountId]);
|
||||||
|
if (!account) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
maybeReplace(account, "defaultTo");
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function maybePersistResolvedTelegramTarget(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
rawTarget: string;
|
||||||
|
resolvedChatId: string;
|
||||||
|
verbose?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const raw = params.rawTarget.trim();
|
||||||
|
if (!raw) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rewrite = resolveLegacyRewrite({
|
||||||
|
raw,
|
||||||
|
resolvedChatId: params.resolvedChatId,
|
||||||
|
});
|
||||||
|
if (!rewrite) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { matchKey, resolvedTarget } = rewrite;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { snapshot, writeOptions } = await readConfigFileSnapshotForWrite();
|
||||||
|
const nextConfig = structuredClone(snapshot.config ?? {});
|
||||||
|
const configChanged = replaceTelegramDefaultToTargets({
|
||||||
|
cfg: nextConfig,
|
||||||
|
matchKey,
|
||||||
|
resolvedTarget,
|
||||||
|
});
|
||||||
|
if (configChanged) {
|
||||||
|
await writeConfigFile(nextConfig, writeOptions);
|
||||||
|
if (params.verbose) {
|
||||||
|
writebackLogger.warn(`resolved Telegram defaultTo target ${raw} -> ${resolvedTarget}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (params.verbose) {
|
||||||
|
writebackLogger.warn(`failed to persist Telegram defaultTo target ${raw}: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storePath = resolveCronStorePath(params.cfg.cron?.store);
|
||||||
|
const store = await loadCronStore(storePath);
|
||||||
|
let cronChanged = false;
|
||||||
|
for (const job of store.jobs) {
|
||||||
|
if (job.delivery?.channel !== "telegram") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nextTarget = rewriteTargetIfMatch({
|
||||||
|
rawValue: job.delivery.to,
|
||||||
|
matchKey,
|
||||||
|
resolvedTarget,
|
||||||
|
});
|
||||||
|
if (!nextTarget) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
job.delivery.to = nextTarget;
|
||||||
|
cronChanged = true;
|
||||||
|
}
|
||||||
|
if (cronChanged) {
|
||||||
|
await saveCronStore(storePath, store);
|
||||||
|
if (params.verbose) {
|
||||||
|
writebackLogger.warn(`resolved Telegram cron delivery target ${raw} -> ${resolvedTarget}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (params.verbose) {
|
||||||
|
writebackLogger.warn(`failed to persist Telegram cron target ${raw}: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js";
|
import {
|
||||||
|
isNumericTelegramChatId,
|
||||||
|
normalizeTelegramChatId,
|
||||||
|
normalizeTelegramLookupTarget,
|
||||||
|
parseTelegramTarget,
|
||||||
|
stripTelegramInternalPrefixes,
|
||||||
|
} from "./targets.js";
|
||||||
|
|
||||||
describe("stripTelegramInternalPrefixes", () => {
|
describe("stripTelegramInternalPrefixes", () => {
|
||||||
it("strips telegram prefix", () => {
|
it("strips telegram prefix", () => {
|
||||||
@@ -73,3 +79,53 @@ describe("parseTelegramTarget", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("normalizeTelegramChatId", () => {
|
||||||
|
it("rejects username and t.me forms", () => {
|
||||||
|
expect(normalizeTelegramChatId("telegram:https://t.me/MyChannel")).toBeUndefined();
|
||||||
|
expect(normalizeTelegramChatId("tg:t.me/mychannel")).toBeUndefined();
|
||||||
|
expect(normalizeTelegramChatId("@MyChannel")).toBeUndefined();
|
||||||
|
expect(normalizeTelegramChatId("MyChannel")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps numeric chat ids unchanged", () => {
|
||||||
|
expect(normalizeTelegramChatId("-1001234567890")).toBe("-1001234567890");
|
||||||
|
expect(normalizeTelegramChatId("123456789")).toBe("123456789");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for empty input", () => {
|
||||||
|
expect(normalizeTelegramChatId(" ")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeTelegramLookupTarget", () => {
|
||||||
|
it("normalizes legacy t.me and username targets", () => {
|
||||||
|
expect(normalizeTelegramLookupTarget("telegram:https://t.me/MyChannel")).toBe("@MyChannel");
|
||||||
|
expect(normalizeTelegramLookupTarget("tg:t.me/mychannel")).toBe("@mychannel");
|
||||||
|
expect(normalizeTelegramLookupTarget("@MyChannel")).toBe("@MyChannel");
|
||||||
|
expect(normalizeTelegramLookupTarget("MyChannel")).toBe("@MyChannel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps numeric chat ids unchanged", () => {
|
||||||
|
expect(normalizeTelegramLookupTarget("-1001234567890")).toBe("-1001234567890");
|
||||||
|
expect(normalizeTelegramLookupTarget("123456789")).toBe("123456789");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid username forms", () => {
|
||||||
|
expect(normalizeTelegramLookupTarget("@bad-handle")).toBeUndefined();
|
||||||
|
expect(normalizeTelegramLookupTarget("bad-handle")).toBeUndefined();
|
||||||
|
expect(normalizeTelegramLookupTarget("ab")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isNumericTelegramChatId", () => {
|
||||||
|
it("matches numeric telegram chat ids", () => {
|
||||||
|
expect(isNumericTelegramChatId("-1001234567890")).toBe(true);
|
||||||
|
expect(isNumericTelegramChatId("123456789")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-numeric chat ids", () => {
|
||||||
|
expect(isNumericTelegramChatId("@mychannel")).toBe(false);
|
||||||
|
expect(isNumericTelegramChatId("t.me/mychannel")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ export type TelegramTarget = {
|
|||||||
chatType: "direct" | "group" | "unknown";
|
chatType: "direct" | "group" | "unknown";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TELEGRAM_NUMERIC_CHAT_ID_REGEX = /^-?\d+$/;
|
||||||
|
const TELEGRAM_USERNAME_REGEX = /^[A-Za-z0-9_]{5,}$/i;
|
||||||
|
|
||||||
export function stripTelegramInternalPrefixes(to: string): string {
|
export function stripTelegramInternalPrefixes(to: string): string {
|
||||||
let trimmed = to.trim();
|
let trimmed = to.trim();
|
||||||
let strippedTelegramPrefix = false;
|
let strippedTelegramPrefix = false;
|
||||||
@@ -26,6 +29,46 @@ export function stripTelegramInternalPrefixes(to: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeTelegramChatId(raw: string): string | undefined {
|
||||||
|
const stripped = stripTelegramInternalPrefixes(raw);
|
||||||
|
if (!stripped) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (TELEGRAM_NUMERIC_CHAT_ID_REGEX.test(stripped)) {
|
||||||
|
return stripped;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNumericTelegramChatId(raw: string): boolean {
|
||||||
|
return TELEGRAM_NUMERIC_CHAT_ID_REGEX.test(raw.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTelegramLookupTarget(raw: string): string | undefined {
|
||||||
|
const stripped = stripTelegramInternalPrefixes(raw);
|
||||||
|
if (!stripped) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (isNumericTelegramChatId(stripped)) {
|
||||||
|
return stripped;
|
||||||
|
}
|
||||||
|
const tmeMatch = /^(?:https?:\/\/)?t\.me\/([A-Za-z0-9_]+)$/i.exec(stripped);
|
||||||
|
if (tmeMatch?.[1]) {
|
||||||
|
return `@${tmeMatch[1]}`;
|
||||||
|
}
|
||||||
|
if (stripped.startsWith("@")) {
|
||||||
|
const handle = stripped.slice(1);
|
||||||
|
if (!handle || !TELEGRAM_USERNAME_REGEX.test(handle)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return `@${handle}`;
|
||||||
|
}
|
||||||
|
if (TELEGRAM_USERNAME_REGEX.test(stripped)) {
|
||||||
|
return `@${stripped}`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a Telegram delivery target into chatId and optional topic/thread ID.
|
* Parse a Telegram delivery target into chatId and optional topic/thread ID.
|
||||||
*
|
*
|
||||||
@@ -39,7 +82,7 @@ function resolveTelegramChatType(chatId: string): "direct" | "group" | "unknown"
|
|||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
if (/^-?\d+$/.test(trimmed)) {
|
if (isNumericTelegramChatId(trimmed)) {
|
||||||
return trimmed.startsWith("-") ? "group" : "direct";
|
return trimmed.startsWith("-") ? "group" : "direct";
|
||||||
}
|
}
|
||||||
return "unknown";
|
return "unknown";
|
||||||
|
|||||||
Reference in New Issue
Block a user