mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-10 06:40:45 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
37
openclaw/extensions/telegram/src/account-config.ts
Normal file
37
openclaw/extensions/telegram/src/account-config.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import {
|
||||
normalizeAccountId,
|
||||
resolveAccountEntry,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/account-core";
|
||||
import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
|
||||
export function resolveTelegramAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): TelegramAccountConfig | undefined {
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
return resolveAccountEntry(cfg.channels?.telegram?.accounts, normalized);
|
||||
}
|
||||
|
||||
export function mergeTelegramAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): TelegramAccountConfig {
|
||||
const {
|
||||
accounts: _ignored,
|
||||
defaultAccount: _ignoredDefaultAccount,
|
||||
groups: channelGroups,
|
||||
...base
|
||||
} = (cfg.channels?.telegram ?? {}) as TelegramAccountConfig & {
|
||||
accounts?: unknown;
|
||||
defaultAccount?: unknown;
|
||||
};
|
||||
const account = resolveTelegramAccountConfig(cfg, accountId) ?? {};
|
||||
|
||||
// Multi-account bots must not inherit channel-level groups unless explicitly set.
|
||||
const configuredAccountIds = Object.keys(cfg.channels?.telegram?.accounts ?? {});
|
||||
const isMultiAccount = configuredAccountIds.length > 1;
|
||||
const groups = account.groups ?? (isMultiAccount ? undefined : channelGroups);
|
||||
|
||||
return { ...base, ...account, groups };
|
||||
}
|
||||
107
openclaw/extensions/telegram/src/account-inspect.test.ts
Normal file
107
openclaw/extensions/telegram/src/account-inspect.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { withEnv } from "openclaw/plugin-sdk/testing";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { inspectTelegramAccount } from "./account-inspect.js";
|
||||
|
||||
describe("inspectTelegramAccount SecretRef resolution", () => {
|
||||
it("resolves default env SecretRef templates in read-only status paths", () => {
|
||||
withEnv({ TG_STATUS_TOKEN: "123:token" }, () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "${TG_STATUS_TOKEN}",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = inspectTelegramAccount({ cfg, accountId: "default" });
|
||||
expect(account.tokenSource).toBe("env");
|
||||
expect(account.tokenStatus).toBe("available");
|
||||
expect(account.token).toBe("123:token");
|
||||
});
|
||||
});
|
||||
|
||||
it("respects env provider allowlists in read-only status paths", () => {
|
||||
withEnv({ TG_NOT_ALLOWED: "123:token" }, () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "secure-env",
|
||||
},
|
||||
providers: {
|
||||
"secure-env": {
|
||||
source: "env",
|
||||
allowlist: ["TG_ALLOWED"],
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "${TG_NOT_ALLOWED}",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = inspectTelegramAccount({ cfg, accountId: "default" });
|
||||
expect(account.tokenSource).toBe("env");
|
||||
expect(account.tokenStatus).toBe("configured_unavailable");
|
||||
expect(account.token).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not read env values for non-env providers", () => {
|
||||
withEnv({ TG_EXEC_PROVIDER: "123:token" }, () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "exec-provider",
|
||||
},
|
||||
providers: {
|
||||
"exec-provider": {
|
||||
source: "exec",
|
||||
command: "/usr/bin/env",
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "${TG_EXEC_PROVIDER}",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = inspectTelegramAccount({ cfg, accountId: "default" });
|
||||
expect(account.tokenSource).toBe("env");
|
||||
expect(account.tokenStatus).toBe("configured_unavailable");
|
||||
expect(account.token).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"treats symlinked token files as configured_unavailable",
|
||||
() => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-inspect-"));
|
||||
const tokenFile = path.join(dir, "token.txt");
|
||||
const tokenLink = path.join(dir, "token-link.txt");
|
||||
fs.writeFileSync(tokenFile, "123:token\n", "utf8");
|
||||
fs.symlinkSync(tokenFile, tokenLink);
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
tokenFile: tokenLink,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = inspectTelegramAccount({ cfg, accountId: "default" });
|
||||
expect(account.tokenSource).toBe("tokenFile");
|
||||
expect(account.tokenStatus).toBe("configured_unavailable");
|
||||
expect(account.token).toBe("");
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
},
|
||||
);
|
||||
});
|
||||
237
openclaw/extensions/telegram/src/account-inspect.ts
Normal file
237
openclaw/extensions/telegram/src/account-inspect.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { resolveAccountWithDefaultFallback } from "openclaw/plugin-sdk/account-core";
|
||||
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/channel-core";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { coerceSecretRef } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeSecretInputString,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
mergeTelegramAccountConfig,
|
||||
resolveDefaultTelegramAccountId,
|
||||
resolveTelegramAccountConfig,
|
||||
} from "./accounts.js";
|
||||
|
||||
export type TelegramCredentialStatus = "available" | "configured_unavailable" | "missing";
|
||||
|
||||
export type InspectedTelegramAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
token: string;
|
||||
tokenSource: "env" | "tokenFile" | "config" | "none";
|
||||
tokenStatus: TelegramCredentialStatus;
|
||||
configured: boolean;
|
||||
config: TelegramAccountConfig;
|
||||
};
|
||||
|
||||
function inspectTokenFile(pathValue: unknown): {
|
||||
token: string;
|
||||
tokenSource: "tokenFile" | "none";
|
||||
tokenStatus: TelegramCredentialStatus;
|
||||
} | null {
|
||||
const tokenFile = normalizeOptionalString(pathValue) ?? "";
|
||||
if (!tokenFile) {
|
||||
return null;
|
||||
}
|
||||
const token = tryReadSecretFileSync(tokenFile, "Telegram bot token", {
|
||||
rejectSymlink: true,
|
||||
});
|
||||
return {
|
||||
token: token ?? "",
|
||||
tokenSource: "tokenFile",
|
||||
tokenStatus: token ? "available" : "configured_unavailable",
|
||||
};
|
||||
}
|
||||
|
||||
function canResolveEnvSecretRefInReadOnlyPath(params: {
|
||||
cfg: OpenClawConfig;
|
||||
provider: string;
|
||||
id: string;
|
||||
}): boolean {
|
||||
const providerConfig = params.cfg.secrets?.providers?.[params.provider];
|
||||
if (!providerConfig) {
|
||||
return params.provider === resolveDefaultSecretProviderAlias(params.cfg, "env");
|
||||
}
|
||||
if (providerConfig.source !== "env") {
|
||||
return false;
|
||||
}
|
||||
const allowlist = providerConfig.allowlist;
|
||||
return !allowlist || allowlist.includes(params.id);
|
||||
}
|
||||
|
||||
function inspectTokenValue(params: { cfg: OpenClawConfig; value: unknown }): {
|
||||
token: string;
|
||||
tokenSource: "config" | "env" | "none";
|
||||
tokenStatus: TelegramCredentialStatus;
|
||||
} | null {
|
||||
// Try to resolve env-based SecretRefs from process.env for read-only inspection
|
||||
const ref = coerceSecretRef(params.value, params.cfg.secrets?.defaults);
|
||||
if (ref?.source === "env") {
|
||||
if (
|
||||
!canResolveEnvSecretRefInReadOnlyPath({
|
||||
cfg: params.cfg,
|
||||
provider: ref.provider,
|
||||
id: ref.id,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
token: "",
|
||||
tokenSource: "env",
|
||||
tokenStatus: "configured_unavailable",
|
||||
};
|
||||
}
|
||||
const envValue = normalizeOptionalString(process.env[ref.id]);
|
||||
if (envValue) {
|
||||
return {
|
||||
token: envValue,
|
||||
tokenSource: "env",
|
||||
tokenStatus: "available",
|
||||
};
|
||||
}
|
||||
return {
|
||||
token: "",
|
||||
tokenSource: "env",
|
||||
tokenStatus: "configured_unavailable",
|
||||
};
|
||||
}
|
||||
const token = normalizeSecretInputString(params.value);
|
||||
if (token) {
|
||||
return {
|
||||
token,
|
||||
tokenSource: "config",
|
||||
tokenStatus: "available",
|
||||
};
|
||||
}
|
||||
if (hasConfiguredSecretInput(params.value, params.cfg.secrets?.defaults)) {
|
||||
return {
|
||||
token: "",
|
||||
tokenSource: "config",
|
||||
tokenStatus: "configured_unavailable",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inspectTelegramAccountPrimary(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
envToken?: string | null;
|
||||
}): InspectedTelegramAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const merged = mergeTelegramAccountConfig(params.cfg, accountId);
|
||||
const enabled = params.cfg.channels?.telegram?.enabled !== false && merged.enabled !== false;
|
||||
|
||||
const accountConfig = resolveTelegramAccountConfig(params.cfg, accountId);
|
||||
const accountTokenFile = inspectTokenFile(accountConfig?.tokenFile);
|
||||
if (accountTokenFile) {
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: normalizeOptionalString(merged.name),
|
||||
token: accountTokenFile.token,
|
||||
tokenSource: accountTokenFile.tokenSource,
|
||||
tokenStatus: accountTokenFile.tokenStatus,
|
||||
configured: accountTokenFile.tokenStatus !== "missing",
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
const accountToken = inspectTokenValue({ cfg: params.cfg, value: accountConfig?.botToken });
|
||||
if (accountToken) {
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: normalizeOptionalString(merged.name),
|
||||
token: accountToken.token,
|
||||
tokenSource: accountToken.tokenSource,
|
||||
tokenStatus: accountToken.tokenStatus,
|
||||
configured: accountToken.tokenStatus !== "missing",
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
const channelTokenFile = inspectTokenFile(params.cfg.channels?.telegram?.tokenFile);
|
||||
if (channelTokenFile) {
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: normalizeOptionalString(merged.name),
|
||||
token: channelTokenFile.token,
|
||||
tokenSource: channelTokenFile.tokenSource,
|
||||
tokenStatus: channelTokenFile.tokenStatus,
|
||||
configured: channelTokenFile.tokenStatus !== "missing",
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
const channelToken = inspectTokenValue({
|
||||
cfg: params.cfg,
|
||||
value: params.cfg.channels?.telegram?.botToken,
|
||||
});
|
||||
if (channelToken) {
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: normalizeOptionalString(merged.name),
|
||||
token: channelToken.token,
|
||||
tokenSource: channelToken.tokenSource,
|
||||
tokenStatus: channelToken.tokenStatus,
|
||||
configured: channelToken.tokenStatus !== "missing",
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const envToken = allowEnv
|
||||
? (normalizeOptionalString(params.envToken) ??
|
||||
normalizeOptionalString(process.env.TELEGRAM_BOT_TOKEN) ??
|
||||
"")
|
||||
: "";
|
||||
if (envToken) {
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: normalizeOptionalString(merged.name),
|
||||
token: envToken,
|
||||
tokenSource: "env",
|
||||
tokenStatus: "available",
|
||||
configured: true,
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: normalizeOptionalString(merged.name),
|
||||
token: "",
|
||||
tokenSource: "none",
|
||||
tokenStatus: "missing",
|
||||
configured: false,
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
export function inspectTelegramAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
envToken?: string | null;
|
||||
}): InspectedTelegramAccount {
|
||||
return resolveAccountWithDefaultFallback({
|
||||
accountId: params.accountId,
|
||||
normalizeAccountId,
|
||||
resolvePrimary: (accountId) =>
|
||||
inspectTelegramAccountPrimary({
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
envToken: params.envToken,
|
||||
}),
|
||||
hasCredential: (account) => account.tokenSource !== "none",
|
||||
resolveDefaultAccountId: () => resolveDefaultTelegramAccountId(params.cfg),
|
||||
});
|
||||
}
|
||||
151
openclaw/extensions/telegram/src/account-selection.ts
Normal file
151
openclaw/extensions/telegram/src/account-selection.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "openclaw/plugin-sdk/account-id";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
|
||||
const DEFAULT_AGENT_ID = "main";
|
||||
|
||||
function normalizeAgentId(value: string | undefined | null): string {
|
||||
const normalized = (value ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/^-+/g, "")
|
||||
.replace(/-+$/g, "");
|
||||
return normalized || DEFAULT_AGENT_ID;
|
||||
}
|
||||
|
||||
function normalizeChannelId(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function resolveDefaultAgentId(cfg: OpenClawConfig): string {
|
||||
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
||||
const chosen = (agents.find((agent) => agent?.default) ?? agents[0])?.id;
|
||||
return normalizeAgentId(chosen);
|
||||
}
|
||||
|
||||
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) {
|
||||
if (key) {
|
||||
ids.add(normalizeAccountId(key));
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
function resolveBindingAccount(params: {
|
||||
binding: unknown;
|
||||
channelId: string;
|
||||
}): { agentId: string; accountId: string } | null {
|
||||
if (!params.binding || typeof params.binding !== "object") {
|
||||
return null;
|
||||
}
|
||||
const binding = params.binding as {
|
||||
agentId?: unknown;
|
||||
match?: { channel?: unknown; accountId?: unknown };
|
||||
};
|
||||
if (normalizeChannelId(binding.match?.channel) !== params.channelId) {
|
||||
return null;
|
||||
}
|
||||
const accountId = typeof binding.match?.accountId === "string" ? binding.match.accountId : "";
|
||||
if (!accountId.trim() || accountId.trim() === "*") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
agentId: normalizeAgentId(typeof binding.agentId === "string" ? binding.agentId : undefined),
|
||||
accountId: normalizeAccountId(accountId),
|
||||
};
|
||||
}
|
||||
|
||||
function listBoundAccountIds(cfg: OpenClawConfig, channelId: string): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const binding of cfg.bindings ?? []) {
|
||||
const resolved = resolveBindingAccount({ binding, channelId });
|
||||
if (resolved) {
|
||||
ids.add(resolved.accountId);
|
||||
}
|
||||
}
|
||||
return [...ids].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function resolveDefaultAgentBoundAccountId(cfg: OpenClawConfig, channelId: string): string | null {
|
||||
const defaultAgentId = resolveDefaultAgentId(cfg);
|
||||
for (const binding of cfg.bindings ?? []) {
|
||||
const resolved = resolveBindingAccount({ binding, channelId });
|
||||
if (resolved?.agentId === defaultAgentId) {
|
||||
return resolved.accountId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function combineAccountIds(params: {
|
||||
configuredAccountIds: readonly string[];
|
||||
additionalAccountIds: readonly string[];
|
||||
}): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const id of [...params.configuredAccountIds, ...params.additionalAccountIds]) {
|
||||
ids.add(normalizeAccountId(id));
|
||||
}
|
||||
if (ids.size === 0) {
|
||||
return [DEFAULT_ACCOUNT_ID];
|
||||
}
|
||||
return [...ids].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function resolveListedDefaultAccountId(params: {
|
||||
accountIds: readonly string[];
|
||||
configuredDefaultAccountId: string | null | undefined;
|
||||
}): string {
|
||||
const configured = normalizeOptionalAccountId(params.configuredDefaultAccountId);
|
||||
if (configured && params.accountIds.includes(configured)) {
|
||||
return configured;
|
||||
}
|
||||
if (params.accountIds.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return params.accountIds[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
export function listTelegramAccountIds(cfg: OpenClawConfig): string[] {
|
||||
return combineAccountIds({
|
||||
configuredAccountIds: listConfiguredAccountIds(cfg),
|
||||
additionalAccountIds: listBoundAccountIds(cfg, "telegram"),
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDefaultTelegramAccountSelection(cfg: OpenClawConfig): {
|
||||
accountId: string;
|
||||
accountIds: string[];
|
||||
shouldWarnMissingDefault: boolean;
|
||||
} {
|
||||
const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram");
|
||||
if (boundDefault) {
|
||||
return {
|
||||
accountId: boundDefault,
|
||||
accountIds: listTelegramAccountIds(cfg),
|
||||
shouldWarnMissingDefault: false,
|
||||
};
|
||||
}
|
||||
const accountIds = listTelegramAccountIds(cfg);
|
||||
const resolved = resolveListedDefaultAccountId({
|
||||
accountIds,
|
||||
configuredDefaultAccountId: cfg.channels?.telegram?.defaultAccount,
|
||||
});
|
||||
return {
|
||||
accountId: resolved,
|
||||
accountIds,
|
||||
shouldWarnMissingDefault:
|
||||
resolved === accountIds[0] &&
|
||||
!accountIds.includes(DEFAULT_ACCOUNT_ID) &&
|
||||
accountIds.length > 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string {
|
||||
return resolveDefaultTelegramAccountSelection(cfg).accountId;
|
||||
}
|
||||
575
openclaw/extensions/telegram/src/accounts.test.ts
Normal file
575
openclaw/extensions/telegram/src/accounts.test.ts
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import * as runtimeEnvModule from "openclaw/plugin-sdk/runtime-env";
|
||||
import { withEnv } from "openclaw/plugin-sdk/testing";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createTelegramActionGate,
|
||||
listTelegramAccountIds,
|
||||
mergeTelegramAccountConfig,
|
||||
resolveTelegramMediaRuntimeOptions,
|
||||
resetMissingDefaultWarnFlag,
|
||||
resolveTelegramPollActionGateState,
|
||||
resolveDefaultTelegramAccountId,
|
||||
resolveTelegramAccount,
|
||||
} from "./accounts.js";
|
||||
|
||||
const { warnMock } = vi.hoisted(() => ({
|
||||
warnMock: vi.fn(),
|
||||
}));
|
||||
|
||||
function warningLines(): string[] {
|
||||
return warnMock.mock.calls.map(([line]) => String(line));
|
||||
}
|
||||
|
||||
function expectNoMissingDefaultWarning() {
|
||||
expect(warningLines().every((line) => !line.includes("accounts.default is missing"))).toBe(true);
|
||||
}
|
||||
|
||||
function resolveAccountWithEnv(
|
||||
env: Record<string, string>,
|
||||
cfg: OpenClawConfig,
|
||||
accountId?: string,
|
||||
) {
|
||||
return withEnv(env, () => resolveTelegramAccount({ cfg, ...(accountId ? { accountId } : {}) }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(runtimeEnvModule, "createSubsystemLogger").mockImplementation(() => {
|
||||
const logger = {
|
||||
warn: warnMock,
|
||||
child: () => logger,
|
||||
};
|
||||
return logger as unknown as ReturnType<typeof runtimeEnvModule.createSubsystemLogger>;
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTelegramAccount", () => {
|
||||
afterEach(() => {
|
||||
warnMock.mockClear();
|
||||
resetMissingDefaultWarnFlag();
|
||||
});
|
||||
|
||||
it("falls back to the first configured account when accountId is omitted", () => {
|
||||
const account = resolveAccountWithEnv(
|
||||
{ TELEGRAM_BOT_TOKEN: "" },
|
||||
{
|
||||
channels: {
|
||||
telegram: { accounts: { work: { botToken: "tok-work" } } },
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(account.accountId).toBe("work");
|
||||
expect(account.token).toBe("tok-work");
|
||||
expect(account.tokenSource).toBe("config");
|
||||
});
|
||||
|
||||
it("uses TELEGRAM_BOT_TOKEN when default account config is missing", () => {
|
||||
const account = resolveAccountWithEnv(
|
||||
{ TELEGRAM_BOT_TOKEN: "tok-env" },
|
||||
{
|
||||
channels: {
|
||||
telegram: { accounts: { work: { botToken: "tok-work" } } },
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(account.accountId).toBe("default");
|
||||
expect(account.token).toBe("tok-env");
|
||||
expect(account.tokenSource).toBe("env");
|
||||
});
|
||||
|
||||
it("prefers default config token over TELEGRAM_BOT_TOKEN", () => {
|
||||
const account = resolveAccountWithEnv(
|
||||
{ TELEGRAM_BOT_TOKEN: "tok-env" },
|
||||
{
|
||||
channels: {
|
||||
telegram: { botToken: "tok-config" },
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(account.accountId).toBe("default");
|
||||
expect(account.token).toBe("tok-config");
|
||||
expect(account.tokenSource).toBe("config");
|
||||
});
|
||||
|
||||
it("does not fall back when accountId is explicitly provided", () => {
|
||||
const account = resolveAccountWithEnv(
|
||||
{ TELEGRAM_BOT_TOKEN: "" },
|
||||
{
|
||||
channels: {
|
||||
telegram: { accounts: { work: { botToken: "tok-work" } } },
|
||||
},
|
||||
},
|
||||
"default",
|
||||
);
|
||||
expect(account.accountId).toBe("default");
|
||||
expect(account.tokenSource).toBe("none");
|
||||
expect(account.token).toBe("");
|
||||
});
|
||||
|
||||
it("formats debug logs with inspect-style output when debug env is enabled", () => {
|
||||
withEnv({ TELEGRAM_BOT_TOKEN: "", OPENCLAW_DEBUG_TELEGRAM_ACCOUNTS: "1" }, () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
telegram: { accounts: { work: { botToken: "tok-work" } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(listTelegramAccountIds(cfg)).toEqual(["work"]);
|
||||
resolveTelegramAccount({ cfg, accountId: "work" });
|
||||
});
|
||||
|
||||
const lines = warnMock.mock.calls.map(([line]) => String(line));
|
||||
expect(lines).toContain("listTelegramAccountIds [ 'work' ]");
|
||||
expect(lines).toContain("resolve { accountId: 'work', enabled: true, tokenSource: 'config' }");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDefaultTelegramAccountId", () => {
|
||||
beforeEach(() => {
|
||||
resetMissingDefaultWarnFlag();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnMock.mockClear();
|
||||
resetMissingDefaultWarnFlag();
|
||||
});
|
||||
|
||||
it("warns when accounts.default is missing in multi-account setup (#32137)", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: { work: { botToken: "tok-work" }, alerts: { botToken: "tok-alerts" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = resolveDefaultTelegramAccountId(cfg);
|
||||
expect(result).toBe("alerts");
|
||||
expect(warnMock).toHaveBeenCalledWith(expect.stringContaining("accounts.default is missing"));
|
||||
});
|
||||
|
||||
it("does not warn when accounts.default exists", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: { default: { botToken: "tok-default" }, work: { botToken: "tok-work" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
resolveDefaultTelegramAccountId(cfg);
|
||||
expectNoMissingDefaultWarning();
|
||||
});
|
||||
|
||||
it("does not warn when defaultAccount is explicitly set", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultAccount: "work",
|
||||
accounts: { work: { botToken: "tok-work" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
resolveDefaultTelegramAccountId(cfg);
|
||||
expectNoMissingDefaultWarning();
|
||||
});
|
||||
|
||||
it("does not warn when only one non-default account is configured", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: { work: { botToken: "tok-work" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
resolveDefaultTelegramAccountId(cfg);
|
||||
expectNoMissingDefaultWarning();
|
||||
});
|
||||
|
||||
it("warns only once per process lifetime", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: { work: { botToken: "tok-work" }, alerts: { botToken: "tok-alerts" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
resolveDefaultTelegramAccountId(cfg);
|
||||
resolveDefaultTelegramAccountId(cfg);
|
||||
resolveDefaultTelegramAccountId(cfg);
|
||||
|
||||
const missingDefaultWarns = warningLines().filter((line) =>
|
||||
line.includes("accounts.default is missing"),
|
||||
);
|
||||
expect(missingDefaultWarns).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("prefers channels.telegram.defaultAccount when it matches a configured account", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultAccount: "work",
|
||||
accounts: { default: { botToken: "tok-default" }, work: { botToken: "tok-work" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveDefaultTelegramAccountId(cfg)).toBe("work");
|
||||
});
|
||||
|
||||
it("normalizes channels.telegram.defaultAccount before lookup", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultAccount: "Router D",
|
||||
accounts: { "router-d": { botToken: "tok-work" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveDefaultTelegramAccountId(cfg)).toBe("router-d");
|
||||
});
|
||||
|
||||
it("falls back when channels.telegram.defaultAccount is not configured", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultAccount: "missing",
|
||||
accounts: { default: { botToken: "tok-default" }, work: { botToken: "tok-work" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveDefaultTelegramAccountId(cfg)).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTelegramAccount allowFrom precedence", () => {
|
||||
it("prefers accounts.default allowlists over top-level for default account", () => {
|
||||
const resolved = resolveTelegramAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
allowFrom: ["top"],
|
||||
groupAllowFrom: ["top-group"],
|
||||
accounts: {
|
||||
default: {
|
||||
botToken: "123:default",
|
||||
allowFrom: ["default"],
|
||||
groupAllowFrom: ["default-group"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolved.config.allowFrom).toEqual(["default"]);
|
||||
expect(resolved.config.groupAllowFrom).toEqual(["default-group"]);
|
||||
});
|
||||
|
||||
it("falls back to top-level allowlists for named account without overrides", () => {
|
||||
const resolved = resolveTelegramAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
allowFrom: ["top"],
|
||||
groupAllowFrom: ["top-group"],
|
||||
accounts: {
|
||||
work: { botToken: "123:work" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(resolved.config.allowFrom).toEqual(["top"]);
|
||||
expect(resolved.config.groupAllowFrom).toEqual(["top-group"]);
|
||||
});
|
||||
|
||||
it("does not inherit default account allowlists for named account when top-level is absent", () => {
|
||||
const resolved = resolveTelegramAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: {
|
||||
default: {
|
||||
botToken: "123:default",
|
||||
allowFrom: ["default"],
|
||||
groupAllowFrom: ["default-group"],
|
||||
},
|
||||
work: { botToken: "123:work" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(resolved.config.allowFrom).toBeUndefined();
|
||||
expect(resolved.config.groupAllowFrom).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeTelegramAccountConfig", () => {
|
||||
it("inherits top-level policy fallback for named accounts", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
enabled: true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["123"],
|
||||
groupPolicy: "allowlist",
|
||||
accounts: {
|
||||
bot1: {
|
||||
enabled: true,
|
||||
botToken: "bot-1-token",
|
||||
},
|
||||
bot2: {
|
||||
enabled: true,
|
||||
botToken: "bot-2-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(mergeTelegramAccountConfig(cfg, "bot1")).toMatchObject({
|
||||
botToken: "bot-1-token",
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["123"],
|
||||
groupPolicy: "allowlist",
|
||||
});
|
||||
expect(mergeTelegramAccountConfig(cfg, "bot2")).toMatchObject({
|
||||
botToken: "bot-2-token",
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["123"],
|
||||
groupPolicy: "allowlist",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps top-level policy fallback when auth lives in accounts.default", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
enabled: true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["123"],
|
||||
groupPolicy: "allowlist",
|
||||
accounts: {
|
||||
default: {
|
||||
botToken: "legacy-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(mergeTelegramAccountConfig(cfg, "default")).toMatchObject({
|
||||
botToken: "legacy-token",
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["123"],
|
||||
groupPolicy: "allowlist",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTelegramPollActionGateState", () => {
|
||||
it("requires both sendMessage and poll actions", () => {
|
||||
const state = resolveTelegramPollActionGateState((key) => key !== "poll");
|
||||
expect(state).toEqual({
|
||||
sendMessageEnabled: true,
|
||||
pollEnabled: false,
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns enabled only when both actions are enabled", () => {
|
||||
const state = resolveTelegramPollActionGateState(() => true);
|
||||
expect(state).toEqual({
|
||||
sendMessageEnabled: true,
|
||||
pollEnabled: true,
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when telegram action gate accountId is omitted", () => {
|
||||
const gate = createTelegramActionGate({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
actions: { sendMessage: false, poll: false },
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
botToken: "123:work",
|
||||
actions: { sendMessage: true, poll: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(gate("sendMessage")).toBe(true);
|
||||
expect(gate("poll")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTelegramAccount groups inheritance (#30673)", () => {
|
||||
const createMultiAccountGroupsConfig = (): OpenClawConfig => ({
|
||||
channels: {
|
||||
telegram: {
|
||||
groups: { "-100123": { requireMention: false } },
|
||||
accounts: {
|
||||
default: { botToken: "123:default" },
|
||||
dev: { botToken: "456:dev" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createDefaultAccountGroupsConfig = (includeDevAccount: boolean): OpenClawConfig => ({
|
||||
channels: {
|
||||
telegram: {
|
||||
groups: { "-100999": { requireMention: true } },
|
||||
accounts: {
|
||||
default: {
|
||||
botToken: "123:default",
|
||||
groups: { "-100123": { requireMention: false } },
|
||||
},
|
||||
...(includeDevAccount ? { dev: { botToken: "456:dev" } } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it("inherits channel-level groups in single-account setup", () => {
|
||||
const resolved = resolveTelegramAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
groups: { "-100123": { requireMention: false } },
|
||||
accounts: {
|
||||
default: { botToken: "123:default" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolved.config.groups).toEqual({ "-100123": { requireMention: false } });
|
||||
});
|
||||
|
||||
it("does NOT inherit channel-level groups to secondary account in multi-account setup", () => {
|
||||
const resolved = resolveTelegramAccount({
|
||||
cfg: createMultiAccountGroupsConfig(),
|
||||
accountId: "dev",
|
||||
});
|
||||
|
||||
expect(resolved.config.groups).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT inherit channel-level groups to default account in multi-account setup", () => {
|
||||
const resolved = resolveTelegramAccount({
|
||||
cfg: createMultiAccountGroupsConfig(),
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolved.config.groups).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses account-level groups even in multi-account setup", () => {
|
||||
const resolved = resolveTelegramAccount({
|
||||
cfg: createDefaultAccountGroupsConfig(true),
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolved.config.groups).toEqual({ "-100123": { requireMention: false } });
|
||||
});
|
||||
|
||||
it("account-level groups takes priority over channel-level in single-account setup", () => {
|
||||
const resolved = resolveTelegramAccount({
|
||||
cfg: createDefaultAccountGroupsConfig(false),
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolved.config.groups).toEqual({ "-100123": { requireMention: false } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTelegramMediaRuntimeOptions", () => {
|
||||
it("uses per-account network overrides for Telegram media downloads", () => {
|
||||
const resolved = resolveTelegramMediaRuntimeOptions({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
apiRoot: "https://api.telegram.org",
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
},
|
||||
trustedLocalFileRoots: ["/srv/telegram/cache"],
|
||||
accounts: {
|
||||
work: {
|
||||
botToken: "123:work",
|
||||
apiRoot: "http://tg-proxy.internal:8081",
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
trustedLocalFileRoots: ["/var/lib/telegram-bot-api"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "work",
|
||||
token: "123:work",
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({
|
||||
token: "123:work",
|
||||
apiRoot: "http://tg-proxy.internal:8081",
|
||||
trustedLocalFileRoots: ["/var/lib/telegram-bot-api"],
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
transport: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to top-level Telegram media settings when account override is absent", () => {
|
||||
const resolved = resolveTelegramMediaRuntimeOptions({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
apiRoot: "http://tg-proxy.internal:8081",
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
trustedLocalFileRoots: ["/srv/telegram/cache"],
|
||||
accounts: {
|
||||
work: {
|
||||
botToken: "123:work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "work",
|
||||
token: "123:work",
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({
|
||||
token: "123:work",
|
||||
apiRoot: "http://tg-proxy.internal:8081",
|
||||
trustedLocalFileRoots: ["/srv/telegram/cache"],
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
transport: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
186
openclaw/extensions/telegram/src/accounts.ts
Normal file
186
openclaw/extensions/telegram/src/accounts.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import util from "node:util";
|
||||
import {
|
||||
createAccountActionGate,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
resolveAccountWithDefaultFallback,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/account-core";
|
||||
import type {
|
||||
TelegramAccountConfig,
|
||||
TelegramActionConfig,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import { formatSetExplicitDefaultInstruction } from "openclaw/plugin-sdk/routing";
|
||||
import { createSubsystemLogger, isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { mergeTelegramAccountConfig, resolveTelegramAccountConfig } from "./account-config.js";
|
||||
import {
|
||||
listTelegramAccountIds as listSelectedTelegramAccountIds,
|
||||
resolveDefaultTelegramAccountSelection,
|
||||
} from "./account-selection.js";
|
||||
import type { TelegramTransport } from "./fetch.js";
|
||||
import { resolveTelegramToken } from "./token.js";
|
||||
|
||||
export { mergeTelegramAccountConfig, resolveTelegramAccountConfig } from "./account-config.js";
|
||||
|
||||
let log: ReturnType<typeof createSubsystemLogger> | null = null;
|
||||
|
||||
function getLog() {
|
||||
if (!log) {
|
||||
log = createSubsystemLogger("telegram/accounts");
|
||||
}
|
||||
return log;
|
||||
}
|
||||
|
||||
function formatDebugArg(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (value instanceof Error) {
|
||||
return value.stack ?? value.message;
|
||||
}
|
||||
return util.inspect(value, { colors: false, depth: null, compact: true, breakLength: Infinity });
|
||||
}
|
||||
|
||||
const debugAccounts = (...args: unknown[]) => {
|
||||
if (isTruthyEnvValue(process.env.OPENCLAW_DEBUG_TELEGRAM_ACCOUNTS)) {
|
||||
const parts = args.map((arg) => formatDebugArg(arg));
|
||||
getLog().warn(parts.join(" ").trim());
|
||||
}
|
||||
};
|
||||
|
||||
export type ResolvedTelegramAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
token: string;
|
||||
tokenSource: "env" | "tokenFile" | "config" | "none";
|
||||
config: TelegramAccountConfig;
|
||||
};
|
||||
|
||||
export type TelegramMediaRuntimeOptions = {
|
||||
token: string;
|
||||
transport?: TelegramTransport;
|
||||
apiRoot?: string;
|
||||
trustedLocalFileRoots?: readonly string[];
|
||||
dangerouslyAllowPrivateNetwork?: boolean;
|
||||
};
|
||||
|
||||
export function listTelegramAccountIds(cfg: OpenClawConfig): string[] {
|
||||
const ids = listSelectedTelegramAccountIds(cfg);
|
||||
debugAccounts("listTelegramAccountIds", ids);
|
||||
return ids;
|
||||
}
|
||||
|
||||
let emittedMissingDefaultWarn = false;
|
||||
|
||||
/** @internal Reset the once-per-process warning flag. Exported for tests only. */
|
||||
export function resetMissingDefaultWarnFlag(): void {
|
||||
emittedMissingDefaultWarn = false;
|
||||
}
|
||||
|
||||
export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string {
|
||||
const selection = resolveDefaultTelegramAccountSelection(cfg);
|
||||
if (selection.shouldWarnMissingDefault && !emittedMissingDefaultWarn) {
|
||||
emittedMissingDefaultWarn = true;
|
||||
getLog().warn(
|
||||
`channels.telegram: accounts.default is missing; falling back to "${selection.accountId}". ` +
|
||||
`${formatSetExplicitDefaultInstruction("telegram")} to avoid routing surprises in multi-account setups.`,
|
||||
);
|
||||
}
|
||||
return selection.accountId;
|
||||
}
|
||||
|
||||
export function createTelegramActionGate(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): (key: keyof TelegramActionConfig, defaultValue?: boolean) => boolean {
|
||||
const accountId = normalizeAccountId(
|
||||
params.accountId ?? resolveDefaultTelegramAccountId(params.cfg),
|
||||
);
|
||||
return createAccountActionGate({
|
||||
baseActions: params.cfg.channels?.telegram?.actions,
|
||||
accountActions: resolveTelegramAccountConfig(params.cfg, accountId)?.actions,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveTelegramMediaRuntimeOptions(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
token: string;
|
||||
transport?: TelegramTransport;
|
||||
}): TelegramMediaRuntimeOptions {
|
||||
const normalizedAccountId = normalizeOptionalAccountId(params.accountId);
|
||||
const accountCfg = normalizedAccountId
|
||||
? mergeTelegramAccountConfig(params.cfg, normalizedAccountId)
|
||||
: params.cfg.channels?.telegram;
|
||||
return {
|
||||
token: params.token,
|
||||
transport: params.transport,
|
||||
apiRoot: accountCfg?.apiRoot,
|
||||
trustedLocalFileRoots: accountCfg?.trustedLocalFileRoots,
|
||||
dangerouslyAllowPrivateNetwork: accountCfg?.network?.dangerouslyAllowPrivateNetwork,
|
||||
};
|
||||
}
|
||||
|
||||
export type TelegramPollActionGateState = {
|
||||
sendMessageEnabled: boolean;
|
||||
pollEnabled: boolean;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export function resolveTelegramPollActionGateState(
|
||||
isActionEnabled: (key: keyof TelegramActionConfig, defaultValue?: boolean) => boolean,
|
||||
): TelegramPollActionGateState {
|
||||
const sendMessageEnabled = isActionEnabled("sendMessage");
|
||||
const pollEnabled = isActionEnabled("poll");
|
||||
return {
|
||||
sendMessageEnabled,
|
||||
pollEnabled,
|
||||
enabled: sendMessageEnabled && pollEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveTelegramAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedTelegramAccount {
|
||||
const baseEnabled = params.cfg.channels?.telegram?.enabled !== false;
|
||||
|
||||
const resolve = (accountId: string) => {
|
||||
const merged = mergeTelegramAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
const tokenResolution = resolveTelegramToken(params.cfg, { accountId });
|
||||
debugAccounts("resolve", {
|
||||
accountId,
|
||||
enabled,
|
||||
tokenSource: tokenResolution.source,
|
||||
});
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: normalizeOptionalString(merged.name),
|
||||
token: tokenResolution.token,
|
||||
tokenSource: tokenResolution.source,
|
||||
config: merged,
|
||||
} satisfies ResolvedTelegramAccount;
|
||||
};
|
||||
|
||||
// If accountId is omitted, prefer a configured account token over failing on
|
||||
// the implicit "default" account. This keeps env-based setups working while
|
||||
// making config-only tokens work for things like heartbeats.
|
||||
return resolveAccountWithDefaultFallback({
|
||||
accountId: params.accountId,
|
||||
normalizeAccountId,
|
||||
resolvePrimary: resolve,
|
||||
hasCredential: (account) => account.tokenSource !== "none",
|
||||
resolveDefaultAccountId: () => resolveDefaultTelegramAccountId(params.cfg),
|
||||
});
|
||||
}
|
||||
|
||||
export function listEnabledTelegramAccounts(cfg: OpenClawConfig): ResolvedTelegramAccount[] {
|
||||
return listTelegramAccountIds(cfg)
|
||||
.map((accountId) => resolveTelegramAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
1015
openclaw/extensions/telegram/src/action-runtime.test.ts
Normal file
1015
openclaw/extensions/telegram/src/action-runtime.test.ts
Normal file
File diff suppressed because it is too large
Load diff
643
openclaw/extensions/telegram/src/action-runtime.ts
Normal file
643
openclaw/extensions/telegram/src/action-runtime.ts
Normal file
|
|
@ -0,0 +1,643 @@
|
|||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
|
||||
import {
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringArrayParam,
|
||||
readStringOrNumberParam,
|
||||
readStringParam,
|
||||
resolvePollMaxSelections,
|
||||
resolveReactionMessageId,
|
||||
} from "openclaw/plugin-sdk/channel-actions";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import { createTelegramActionGate, resolveTelegramPollActionGateState } from "./accounts.js";
|
||||
import {
|
||||
fitsTelegramCallbackData,
|
||||
TELEGRAM_CALLBACK_DATA_MAX_BYTES,
|
||||
} from "./approval-callback-data.js";
|
||||
import type { TelegramButtonStyle, TelegramInlineButtons } from "./button-types.js";
|
||||
import { resolveTelegramInlineButtons } from "./button-types.js";
|
||||
import {
|
||||
resolveTelegramInlineButtonsScope,
|
||||
resolveTelegramTargetChatType,
|
||||
} from "./inline-buttons.js";
|
||||
import { resolveTelegramPollVisibility } from "./poll-visibility.js";
|
||||
import { resolveTelegramReactionLevel } from "./reaction-level.js";
|
||||
import {
|
||||
createForumTopicTelegram,
|
||||
deleteMessageTelegram,
|
||||
editForumTopicTelegram,
|
||||
editMessageTelegram,
|
||||
reactMessageTelegram,
|
||||
sendMessageTelegram,
|
||||
sendPollTelegram,
|
||||
sendStickerTelegram,
|
||||
} from "./send.js";
|
||||
import { getCacheStats, searchStickers } from "./sticker-cache.js";
|
||||
import { resolveTelegramToken } from "./token.js";
|
||||
|
||||
export const telegramActionRuntime = {
|
||||
createForumTopicTelegram,
|
||||
deleteMessageTelegram,
|
||||
editForumTopicTelegram,
|
||||
editMessageTelegram,
|
||||
getCacheStats,
|
||||
reactMessageTelegram,
|
||||
searchStickers,
|
||||
sendMessageTelegram,
|
||||
sendPollTelegram,
|
||||
sendStickerTelegram,
|
||||
};
|
||||
|
||||
const TELEGRAM_BUTTON_STYLES: readonly TelegramButtonStyle[] = ["danger", "success", "primary"];
|
||||
const TELEGRAM_FORUM_TOPIC_ICON_COLORS = [
|
||||
0x6fb9f0, 0xffd67e, 0xcb86db, 0x8eee98, 0xff93b2, 0xfb6f5f,
|
||||
] as const;
|
||||
const TELEGRAM_ACTION_ALIASES = {
|
||||
createForumTopic: "createForumTopic",
|
||||
delete: "deleteMessage",
|
||||
deleteMessage: "deleteMessage",
|
||||
edit: "editMessage",
|
||||
editForumTopic: "editForumTopic",
|
||||
editMessage: "editMessage",
|
||||
poll: "poll",
|
||||
react: "react",
|
||||
searchSticker: "searchSticker",
|
||||
send: "sendMessage",
|
||||
sendMessage: "sendMessage",
|
||||
sendSticker: "sendSticker",
|
||||
sticker: "sendSticker",
|
||||
stickerCacheStats: "stickerCacheStats",
|
||||
"sticker-search": "searchSticker",
|
||||
"topic-create": "createForumTopic",
|
||||
"topic-edit": "editForumTopic",
|
||||
} as const;
|
||||
|
||||
type TelegramActionName = (typeof TELEGRAM_ACTION_ALIASES)[keyof typeof TELEGRAM_ACTION_ALIASES];
|
||||
type TelegramForumTopicIconColor = (typeof TELEGRAM_FORUM_TOPIC_ICON_COLORS)[number];
|
||||
type RawTelegramButton = {
|
||||
callback_data?: unknown;
|
||||
style?: unknown;
|
||||
text?: unknown;
|
||||
};
|
||||
|
||||
function readTelegramForumTopicIconColor(
|
||||
params: Record<string, unknown>,
|
||||
): TelegramForumTopicIconColor | undefined {
|
||||
const iconColor = readNumberParam(params, "iconColor", { integer: true });
|
||||
if (iconColor == null) {
|
||||
return undefined;
|
||||
}
|
||||
if (!TELEGRAM_FORUM_TOPIC_ICON_COLORS.includes(iconColor as TelegramForumTopicIconColor)) {
|
||||
throw new Error("iconColor must be one of Telegram's supported forum topic colors.");
|
||||
}
|
||||
return iconColor as TelegramForumTopicIconColor;
|
||||
}
|
||||
export function readTelegramButtons(
|
||||
params: Record<string, unknown>,
|
||||
): TelegramInlineButtons | undefined {
|
||||
const raw = params.buttons;
|
||||
if (raw == null) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new Error("buttons must be an array of button rows");
|
||||
}
|
||||
const rows = raw.map((row, rowIndex) => {
|
||||
if (!Array.isArray(row)) {
|
||||
throw new Error(`buttons[${rowIndex}] must be an array`);
|
||||
}
|
||||
return row.map((button, buttonIndex) => {
|
||||
if (!button || typeof button !== "object") {
|
||||
throw new Error(`buttons[${rowIndex}][${buttonIndex}] must be an object`);
|
||||
}
|
||||
const rawButton = button as RawTelegramButton;
|
||||
const text = normalizeOptionalString(rawButton.text) ?? "";
|
||||
const callbackData = normalizeOptionalString(rawButton.callback_data) ?? "";
|
||||
if (!text || !callbackData) {
|
||||
throw new Error(`buttons[${rowIndex}][${buttonIndex}] requires text and callback_data`);
|
||||
}
|
||||
if (!fitsTelegramCallbackData(callbackData)) {
|
||||
throw new Error(
|
||||
`buttons[${rowIndex}][${buttonIndex}] callback_data too long (max ${TELEGRAM_CALLBACK_DATA_MAX_BYTES} bytes)`,
|
||||
);
|
||||
}
|
||||
const styleRaw = rawButton.style;
|
||||
const style = normalizeOptionalLowercaseString(styleRaw);
|
||||
if (styleRaw !== undefined && !style) {
|
||||
throw new Error(`buttons[${rowIndex}][${buttonIndex}] style must be string`);
|
||||
}
|
||||
if (style && !TELEGRAM_BUTTON_STYLES.includes(style as TelegramButtonStyle)) {
|
||||
throw new Error(
|
||||
`buttons[${rowIndex}][${buttonIndex}] style must be one of ${TELEGRAM_BUTTON_STYLES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
text,
|
||||
callback_data: callbackData,
|
||||
...(style ? { style: style as TelegramButtonStyle } : {}),
|
||||
};
|
||||
});
|
||||
});
|
||||
const filtered = rows.filter((row) => row.length > 0);
|
||||
return filtered.length > 0 ? filtered : undefined;
|
||||
}
|
||||
|
||||
function normalizeTelegramActionName(action: string): TelegramActionName {
|
||||
const normalized = TELEGRAM_ACTION_ALIASES[action as keyof typeof TELEGRAM_ACTION_ALIASES];
|
||||
if (!normalized) {
|
||||
throw new Error(`Unsupported Telegram action: ${action}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function readTelegramChatId(params: Record<string, unknown>) {
|
||||
return (
|
||||
readStringOrNumberParam(params, "chatId") ??
|
||||
readStringOrNumberParam(params, "channelId") ??
|
||||
readStringOrNumberParam(params, "to", { required: true })
|
||||
);
|
||||
}
|
||||
|
||||
function readTelegramThreadId(params: Record<string, unknown>) {
|
||||
return (
|
||||
readNumberParam(params, "messageThreadId", { integer: true }) ??
|
||||
readNumberParam(params, "threadId", { integer: true })
|
||||
);
|
||||
}
|
||||
|
||||
function readTelegramReplyToMessageId(params: Record<string, unknown>) {
|
||||
return (
|
||||
readNumberParam(params, "replyToMessageId", { integer: true }) ??
|
||||
readNumberParam(params, "replyTo", { integer: true })
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTelegramButtonsFromParams(params: Record<string, unknown>) {
|
||||
return resolveTelegramInlineButtons({
|
||||
buttons: readTelegramButtons(params),
|
||||
interactive: params.interactive,
|
||||
});
|
||||
}
|
||||
|
||||
function readTelegramSendContent(params: {
|
||||
args: Record<string, unknown>;
|
||||
mediaUrl?: string;
|
||||
hasButtons: boolean;
|
||||
}) {
|
||||
const content =
|
||||
readStringParam(params.args, "content", { allowEmpty: true }) ??
|
||||
readStringParam(params.args, "message", { allowEmpty: true }) ??
|
||||
readStringParam(params.args, "caption", { allowEmpty: true });
|
||||
if (content == null && !params.mediaUrl && !params.hasButtons) {
|
||||
throw new Error("content required.");
|
||||
}
|
||||
return content ?? "";
|
||||
}
|
||||
|
||||
export async function handleTelegramAction(
|
||||
params: Record<string, unknown>,
|
||||
cfg: OpenClawConfig,
|
||||
options?: {
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
||||
},
|
||||
): Promise<AgentToolResult<unknown>> {
|
||||
const { action, accountId } = {
|
||||
action: normalizeTelegramActionName(readStringParam(params, "action", { required: true })),
|
||||
accountId: readStringParam(params, "accountId"),
|
||||
};
|
||||
const isActionEnabled = createTelegramActionGate({
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
|
||||
if (action === "react") {
|
||||
// All react failures return soft results (jsonResult with ok:false) instead
|
||||
// of throwing, because hard tool errors can trigger model re-generation
|
||||
// loops and duplicate content.
|
||||
const reactionLevelInfo = resolveTelegramReactionLevel({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
if (!reactionLevelInfo.agentReactionsEnabled) {
|
||||
return jsonResult({
|
||||
ok: false,
|
||||
reason: "disabled",
|
||||
hint: `Telegram agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). Do not retry.`,
|
||||
});
|
||||
}
|
||||
if (!isActionEnabled("reactions")) {
|
||||
return jsonResult({
|
||||
ok: false,
|
||||
reason: "disabled",
|
||||
hint: "Telegram reactions are disabled via actions.reactions. Do not retry.",
|
||||
});
|
||||
}
|
||||
const chatId = readTelegramChatId(params);
|
||||
const messageId =
|
||||
readNumberParam(params, "messageId", { integer: true }) ??
|
||||
resolveReactionMessageId({ args: params });
|
||||
if (typeof messageId !== "number" || !Number.isFinite(messageId) || messageId <= 0) {
|
||||
return jsonResult({
|
||||
ok: false,
|
||||
reason: "missing_message_id",
|
||||
hint: "Telegram reaction requires a valid messageId (or inbound context fallback). Do not retry.",
|
||||
});
|
||||
}
|
||||
const { emoji, remove, isEmpty } = readReactionParams(params, {
|
||||
removeErrorMessage: "Emoji is required to remove a Telegram reaction.",
|
||||
});
|
||||
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||
if (!token) {
|
||||
return jsonResult({
|
||||
ok: false,
|
||||
reason: "missing_token",
|
||||
hint: "Telegram bot token missing. Do not retry.",
|
||||
});
|
||||
}
|
||||
let reactionResult: Awaited<ReturnType<typeof telegramActionRuntime.reactMessageTelegram>>;
|
||||
try {
|
||||
reactionResult = await telegramActionRuntime.reactMessageTelegram(
|
||||
chatId ?? "",
|
||||
messageId ?? 0,
|
||||
emoji ?? "",
|
||||
{
|
||||
cfg,
|
||||
token,
|
||||
remove,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
const isInvalid = String(err).includes("REACTION_INVALID");
|
||||
return jsonResult({
|
||||
ok: false,
|
||||
reason: isInvalid ? "REACTION_INVALID" : "error",
|
||||
emoji,
|
||||
hint: isInvalid
|
||||
? "This emoji is not supported for Telegram reactions. Add it to your reaction disallow list so you do not try it again."
|
||||
: "Reaction failed. Do not retry.",
|
||||
});
|
||||
}
|
||||
if (!reactionResult.ok) {
|
||||
return jsonResult({
|
||||
ok: false,
|
||||
warning: reactionResult.warning,
|
||||
...(remove || isEmpty ? { removed: true } : { added: emoji }),
|
||||
});
|
||||
}
|
||||
if (!remove && !isEmpty) {
|
||||
return jsonResult({ ok: true, added: emoji });
|
||||
}
|
||||
return jsonResult({ ok: true, removed: true });
|
||||
}
|
||||
|
||||
if (action === "sendMessage") {
|
||||
if (!isActionEnabled("sendMessage")) {
|
||||
throw new Error("Telegram sendMessage is disabled.");
|
||||
}
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const mediaUrl =
|
||||
readStringParam(params, "mediaUrl") ??
|
||||
readStringParam(params, "media", {
|
||||
trim: false,
|
||||
});
|
||||
const buttons = resolveTelegramButtonsFromParams(params);
|
||||
const content = readTelegramSendContent({
|
||||
args: params,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
hasButtons: Array.isArray(buttons) && buttons.length > 0,
|
||||
});
|
||||
if (buttons) {
|
||||
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
if (inlineButtonsScope === "off") {
|
||||
throw new Error(
|
||||
'Telegram inline buttons are disabled. Set channels.telegram.capabilities.inlineButtons to "dm", "group", "all", or "allowlist".',
|
||||
);
|
||||
}
|
||||
if (inlineButtonsScope === "dm" || inlineButtonsScope === "group") {
|
||||
const targetType = resolveTelegramTargetChatType(to);
|
||||
if (targetType === "unknown") {
|
||||
throw new Error(
|
||||
`Telegram inline buttons require a numeric chat id when inlineButtons="${inlineButtonsScope}".`,
|
||||
);
|
||||
}
|
||||
if (inlineButtonsScope === "dm" && targetType !== "direct") {
|
||||
throw new Error('Telegram inline buttons are limited to DMs when inlineButtons="dm".');
|
||||
}
|
||||
if (inlineButtonsScope === "group" && targetType !== "group") {
|
||||
throw new Error(
|
||||
'Telegram inline buttons are limited to groups when inlineButtons="group".',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Optional threading parameters for forum topics and reply chains
|
||||
const replyToMessageId = readTelegramReplyToMessageId(params);
|
||||
const messageThreadId = readTelegramThreadId(params);
|
||||
const quoteText = readStringParam(params, "quoteText");
|
||||
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||
);
|
||||
}
|
||||
const result = await telegramActionRuntime.sendMessageTelegram(to, content, {
|
||||
cfg,
|
||||
token,
|
||||
accountId: accountId ?? undefined,
|
||||
mediaUrl: mediaUrl || undefined,
|
||||
mediaLocalRoots: options?.mediaLocalRoots,
|
||||
mediaReadFile: options?.mediaReadFile,
|
||||
buttons,
|
||||
replyToMessageId: replyToMessageId ?? undefined,
|
||||
messageThreadId: messageThreadId ?? undefined,
|
||||
quoteText: quoteText ?? undefined,
|
||||
asVoice: readBooleanParam(params, "asVoice"),
|
||||
silent: readBooleanParam(params, "silent"),
|
||||
forceDocument:
|
||||
readBooleanParam(params, "forceDocument") ??
|
||||
readBooleanParam(params, "asDocument") ??
|
||||
false,
|
||||
});
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
messageId: result.messageId,
|
||||
chatId: result.chatId,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "poll") {
|
||||
const pollActionState = resolveTelegramPollActionGateState(isActionEnabled);
|
||||
if (!pollActionState.sendMessageEnabled) {
|
||||
throw new Error("Telegram sendMessage is disabled.");
|
||||
}
|
||||
if (!pollActionState.pollEnabled) {
|
||||
throw new Error("Telegram polls are disabled.");
|
||||
}
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const question =
|
||||
readStringParam(params, "question") ??
|
||||
readStringParam(params, "pollQuestion", { required: true });
|
||||
const answers =
|
||||
readStringArrayParam(params, "answers") ??
|
||||
readStringArrayParam(params, "pollOption", { required: true });
|
||||
const allowMultiselect =
|
||||
readBooleanParam(params, "allowMultiselect") ?? readBooleanParam(params, "pollMulti");
|
||||
const durationSeconds =
|
||||
readNumberParam(params, "durationSeconds", { integer: true }) ??
|
||||
readNumberParam(params, "pollDurationSeconds", {
|
||||
integer: true,
|
||||
strict: true,
|
||||
});
|
||||
const durationHours =
|
||||
readNumberParam(params, "durationHours", { integer: true }) ??
|
||||
readNumberParam(params, "pollDurationHours", {
|
||||
integer: true,
|
||||
strict: true,
|
||||
});
|
||||
const replyToMessageId = readTelegramReplyToMessageId(params);
|
||||
const messageThreadId = readTelegramThreadId(params);
|
||||
const isAnonymous =
|
||||
readBooleanParam(params, "isAnonymous") ??
|
||||
resolveTelegramPollVisibility({
|
||||
pollAnonymous: readBooleanParam(params, "pollAnonymous"),
|
||||
pollPublic: readBooleanParam(params, "pollPublic"),
|
||||
});
|
||||
const silent = readBooleanParam(params, "silent");
|
||||
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||
);
|
||||
}
|
||||
const result = await telegramActionRuntime.sendPollTelegram(
|
||||
to,
|
||||
{
|
||||
question,
|
||||
options: answers,
|
||||
maxSelections: resolvePollMaxSelections(answers.length, allowMultiselect ?? false),
|
||||
durationSeconds: durationSeconds ?? undefined,
|
||||
durationHours: durationHours ?? undefined,
|
||||
},
|
||||
{
|
||||
cfg,
|
||||
token,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageId: replyToMessageId ?? undefined,
|
||||
messageThreadId: messageThreadId ?? undefined,
|
||||
isAnonymous: isAnonymous ?? undefined,
|
||||
silent: silent ?? undefined,
|
||||
},
|
||||
);
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
messageId: result.messageId,
|
||||
chatId: result.chatId,
|
||||
pollId: result.pollId,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "deleteMessage") {
|
||||
if (!isActionEnabled("deleteMessage")) {
|
||||
throw new Error("Telegram deleteMessage is disabled.");
|
||||
}
|
||||
const chatId = readTelegramChatId(params);
|
||||
const messageId = readNumberParam(params, "messageId", {
|
||||
required: true,
|
||||
integer: true,
|
||||
});
|
||||
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||
);
|
||||
}
|
||||
await telegramActionRuntime.deleteMessageTelegram(chatId ?? "", messageId ?? 0, {
|
||||
cfg,
|
||||
token,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
return jsonResult({ ok: true, deleted: true });
|
||||
}
|
||||
|
||||
if (action === "editMessage") {
|
||||
if (!isActionEnabled("editMessage")) {
|
||||
throw new Error("Telegram editMessage is disabled.");
|
||||
}
|
||||
const chatId = readTelegramChatId(params);
|
||||
const messageId = readNumberParam(params, "messageId", {
|
||||
required: true,
|
||||
integer: true,
|
||||
});
|
||||
const content =
|
||||
readStringParam(params, "content", { allowEmpty: false }) ??
|
||||
readStringParam(params, "message", { required: true, allowEmpty: false });
|
||||
const buttons = resolveTelegramButtonsFromParams(params);
|
||||
if (buttons) {
|
||||
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
if (inlineButtonsScope === "off") {
|
||||
throw new Error(
|
||||
'Telegram inline buttons are disabled. Set channels.telegram.capabilities.inlineButtons to "dm", "group", "all", or "allowlist".',
|
||||
);
|
||||
}
|
||||
}
|
||||
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||
);
|
||||
}
|
||||
const result = await telegramActionRuntime.editMessageTelegram(
|
||||
chatId ?? "",
|
||||
messageId ?? 0,
|
||||
content,
|
||||
{
|
||||
cfg,
|
||||
token,
|
||||
accountId: accountId ?? undefined,
|
||||
buttons,
|
||||
},
|
||||
);
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
messageId: result.messageId,
|
||||
chatId: result.chatId,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "sendSticker") {
|
||||
if (!isActionEnabled("sticker", false)) {
|
||||
throw new Error(
|
||||
"Telegram sticker actions are disabled. Set channels.telegram.actions.sticker to true.",
|
||||
);
|
||||
}
|
||||
const to =
|
||||
readStringParam(params, "to") ?? readStringParam(params, "target", { required: true });
|
||||
const fileId =
|
||||
readStringParam(params, "fileId") ?? readStringArrayParam(params, "stickerId")?.[0];
|
||||
if (!fileId) {
|
||||
throw new Error("fileId is required.");
|
||||
}
|
||||
const replyToMessageId = readTelegramReplyToMessageId(params);
|
||||
const messageThreadId = readTelegramThreadId(params);
|
||||
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||
);
|
||||
}
|
||||
const result = await telegramActionRuntime.sendStickerTelegram(to, fileId, {
|
||||
cfg,
|
||||
token,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageId: replyToMessageId ?? undefined,
|
||||
messageThreadId: messageThreadId ?? undefined,
|
||||
});
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
messageId: result.messageId,
|
||||
chatId: result.chatId,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "searchSticker") {
|
||||
if (!isActionEnabled("sticker", false)) {
|
||||
throw new Error(
|
||||
"Telegram sticker actions are disabled. Set channels.telegram.actions.sticker to true.",
|
||||
);
|
||||
}
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
const limit = readNumberParam(params, "limit", { integer: true }) ?? 5;
|
||||
const results = telegramActionRuntime.searchStickers(query, limit);
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
count: results.length,
|
||||
stickers: results.map((s) => ({
|
||||
fileId: s.fileId,
|
||||
emoji: s.emoji,
|
||||
description: s.description,
|
||||
setName: s.setName,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "stickerCacheStats") {
|
||||
const stats = telegramActionRuntime.getCacheStats();
|
||||
return jsonResult({ ok: true, ...stats });
|
||||
}
|
||||
|
||||
if (action === "createForumTopic") {
|
||||
if (!isActionEnabled("createForumTopic")) {
|
||||
throw new Error("Telegram createForumTopic is disabled.");
|
||||
}
|
||||
const chatId = readTelegramChatId(params);
|
||||
const name = readStringParam(params, "name", { required: true });
|
||||
const iconColor = readTelegramForumTopicIconColor(params);
|
||||
const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId");
|
||||
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||
);
|
||||
}
|
||||
const result = await telegramActionRuntime.createForumTopicTelegram(chatId ?? "", name, {
|
||||
cfg,
|
||||
token,
|
||||
accountId: accountId ?? undefined,
|
||||
iconColor,
|
||||
iconCustomEmojiId: iconCustomEmojiId ?? undefined,
|
||||
});
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
topicId: result.topicId,
|
||||
name: result.name,
|
||||
chatId: result.chatId,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "editForumTopic") {
|
||||
if (!isActionEnabled("editForumTopic")) {
|
||||
throw new Error("Telegram editForumTopic is disabled.");
|
||||
}
|
||||
const chatId = readTelegramChatId(params);
|
||||
const messageThreadId = readTelegramThreadId(params);
|
||||
if (typeof messageThreadId !== "number") {
|
||||
throw new Error("messageThreadId or threadId is required.");
|
||||
}
|
||||
const name = readStringParam(params, "name");
|
||||
const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId");
|
||||
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||
);
|
||||
}
|
||||
const result = await telegramActionRuntime.editForumTopicTelegram(
|
||||
chatId ?? "",
|
||||
messageThreadId,
|
||||
{
|
||||
cfg,
|
||||
token,
|
||||
accountId: accountId ?? undefined,
|
||||
name: name ?? undefined,
|
||||
iconCustomEmojiId: iconCustomEmojiId ?? undefined,
|
||||
},
|
||||
);
|
||||
return jsonResult(result);
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported Telegram action: ${String(action)}`);
|
||||
}
|
||||
28
openclaw/extensions/telegram/src/action-threading.test.ts
Normal file
28
openclaw/extensions/telegram/src/action-threading.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { resolveTelegramAutoThreadId } from "./action-threading.js";
|
||||
|
||||
describe("resolveTelegramAutoThreadId", () => {
|
||||
it("keeps current DM topic threadId even when replyToId-like flow is active", () => {
|
||||
expect(
|
||||
resolveTelegramAutoThreadId({
|
||||
to: "telegram:1234",
|
||||
toolContext: {
|
||||
currentChannelId: "telegram:1234",
|
||||
currentThreadTs: "533274",
|
||||
},
|
||||
}),
|
||||
).toBe("533274");
|
||||
});
|
||||
|
||||
it("does not override an explicit target topic", () => {
|
||||
expect(
|
||||
resolveTelegramAutoThreadId({
|
||||
to: "telegram:-1001:topic:99",
|
||||
toolContext: {
|
||||
currentChannelId: "telegram:-1001:topic:77",
|
||||
currentThreadTs: "77",
|
||||
},
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
24
openclaw/extensions/telegram/src/action-threading.ts
Normal file
24
openclaw/extensions/telegram/src/action-threading.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { parseTelegramTarget } from "./targets.js";
|
||||
|
||||
export function resolveTelegramAutoThreadId(params: {
|
||||
to: string;
|
||||
toolContext?: { currentThreadTs?: string; currentChannelId?: string };
|
||||
}): string | undefined {
|
||||
const context = params.toolContext;
|
||||
if (!context?.currentThreadTs || !context.currentChannelId) {
|
||||
return undefined;
|
||||
}
|
||||
const parsedTo = parseTelegramTarget(params.to);
|
||||
if (parsedTo.messageThreadId != null) {
|
||||
return undefined;
|
||||
}
|
||||
const parsedChannel = parseTelegramTarget(context.currentChannelId);
|
||||
if (
|
||||
normalizeLowercaseStringOrEmpty(parsedTo.chatId) !==
|
||||
normalizeLowercaseStringOrEmpty(parsedChannel.chatId)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return context.currentThreadTs;
|
||||
}
|
||||
17
openclaw/extensions/telegram/src/allow-from.ts
Normal file
17
openclaw/extensions/telegram/src/allow-from.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export function normalizeTelegramAllowFromEntry(raw: unknown): string {
|
||||
const base = typeof raw === "string" ? raw : typeof raw === "number" ? String(raw) : "";
|
||||
return base
|
||||
.trim()
|
||||
.replace(/^(telegram|tg):/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function isNumericTelegramUserId(raw: string): boolean {
|
||||
return /^-?\d+$/.test(raw);
|
||||
}
|
||||
|
||||
// Telegram sender authorization only accepts concrete user IDs. Negative chat IDs
|
||||
// belong under `channels.telegram.groups`, not sender allowlists.
|
||||
export function isNumericTelegramSenderUserId(raw: string): boolean {
|
||||
return /^\d+$/.test(raw);
|
||||
}
|
||||
26
openclaw/extensions/telegram/src/allowed-updates.test.ts
Normal file
26
openclaw/extensions/telegram/src/allowed-updates.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
let API_CONSTANTS: typeof import("grammy").API_CONSTANTS;
|
||||
let DEFAULT_TELEGRAM_UPDATE_TYPES: typeof import("./allowed-updates.js").DEFAULT_TELEGRAM_UPDATE_TYPES;
|
||||
let resolveTelegramAllowedUpdates: typeof import("./allowed-updates.js").resolveTelegramAllowedUpdates;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ API_CONSTANTS } = await import("grammy"));
|
||||
({ DEFAULT_TELEGRAM_UPDATE_TYPES, resolveTelegramAllowedUpdates } =
|
||||
await import("./allowed-updates.js"));
|
||||
});
|
||||
|
||||
describe("resolveTelegramAllowedUpdates", () => {
|
||||
it("includes the default update types plus reaction and channel post support", () => {
|
||||
const updates = resolveTelegramAllowedUpdates();
|
||||
|
||||
expect(updates).toEqual(
|
||||
expect.arrayContaining([
|
||||
...DEFAULT_TELEGRAM_UPDATE_TYPES,
|
||||
...(API_CONSTANTS?.DEFAULT_UPDATE_TYPES ?? []),
|
||||
]),
|
||||
);
|
||||
expect(updates).toContain("message_reaction");
|
||||
expect(updates).toContain("channel_post");
|
||||
expect(new Set(updates).size).toBe(updates.length);
|
||||
});
|
||||
});
|
||||
65
openclaw/extensions/telegram/src/allowed-updates.ts
Normal file
65
openclaw/extensions/telegram/src/allowed-updates.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import * as grammy from "grammy";
|
||||
|
||||
const FALLBACK_ALL_UPDATE_TYPES = [
|
||||
"message",
|
||||
"edited_message",
|
||||
"channel_post",
|
||||
"edited_channel_post",
|
||||
"business_connection",
|
||||
"business_message",
|
||||
"edited_business_message",
|
||||
"deleted_business_messages",
|
||||
"message_reaction",
|
||||
"message_reaction_count",
|
||||
"inline_query",
|
||||
"chosen_inline_result",
|
||||
"callback_query",
|
||||
"shipping_query",
|
||||
"pre_checkout_query",
|
||||
"poll",
|
||||
"poll_answer",
|
||||
"my_chat_member",
|
||||
"chat_member",
|
||||
"chat_join_request",
|
||||
] as const;
|
||||
|
||||
const FALLBACK_DEFAULT_UPDATE_TYPES = [
|
||||
"message",
|
||||
"edited_message",
|
||||
"channel_post",
|
||||
"edited_channel_post",
|
||||
"business_connection",
|
||||
"business_message",
|
||||
"edited_business_message",
|
||||
"deleted_business_messages",
|
||||
"message_reaction",
|
||||
"message_reaction_count",
|
||||
"inline_query",
|
||||
"chosen_inline_result",
|
||||
"callback_query",
|
||||
"shipping_query",
|
||||
"pre_checkout_query",
|
||||
"poll",
|
||||
"poll_answer",
|
||||
"my_chat_member",
|
||||
"chat_member",
|
||||
"chat_join_request",
|
||||
] as const;
|
||||
|
||||
export type TelegramUpdateType =
|
||||
| (typeof FALLBACK_ALL_UPDATE_TYPES)[number]
|
||||
| (typeof grammy.API_CONSTANTS.ALL_UPDATE_TYPES)[number];
|
||||
|
||||
export const DEFAULT_TELEGRAM_UPDATE_TYPES: ReadonlyArray<TelegramUpdateType> =
|
||||
grammy.API_CONSTANTS?.DEFAULT_UPDATE_TYPES ?? FALLBACK_DEFAULT_UPDATE_TYPES;
|
||||
|
||||
export function resolveTelegramAllowedUpdates(): ReadonlyArray<TelegramUpdateType> {
|
||||
const updates = [...DEFAULT_TELEGRAM_UPDATE_TYPES] as TelegramUpdateType[];
|
||||
if (!updates.includes("message_reaction")) {
|
||||
updates.push("message_reaction");
|
||||
}
|
||||
if (!updates.includes("channel_post")) {
|
||||
updates.push("channel_post");
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
188
openclaw/extensions/telegram/src/api-fetch.test.ts
Normal file
188
openclaw/extensions/telegram/src/api-fetch.test.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { createRequire } from "node:module";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchTelegramChatId } from "./api-fetch.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const EnvHttpProxyAgent = require("undici/lib/dispatcher/env-http-proxy-agent.js") as {
|
||||
new (opts?: Record<string, unknown>): Record<PropertyKey, unknown>;
|
||||
};
|
||||
const { kHttpsProxyAgent, kNoProxyAgent } = require("undici/lib/core/symbols.js") as {
|
||||
kHttpsProxyAgent: symbol;
|
||||
kNoProxyAgent: symbol;
|
||||
};
|
||||
const proxyMocks = vi.hoisted(() => {
|
||||
const undiciFetch = vi.fn();
|
||||
const proxyAgentSpy = vi.fn();
|
||||
const setGlobalDispatcher = vi.fn();
|
||||
class ProxyAgent {
|
||||
static lastCreated: ProxyAgent | undefined;
|
||||
proxyUrl: string;
|
||||
constructor(proxyUrl: string) {
|
||||
this.proxyUrl = proxyUrl;
|
||||
ProxyAgent.lastCreated = this;
|
||||
proxyAgentSpy(proxyUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ProxyAgent,
|
||||
undiciFetch,
|
||||
proxyAgentSpy,
|
||||
setGlobalDispatcher,
|
||||
getLastAgent: () => ProxyAgent.lastCreated,
|
||||
};
|
||||
});
|
||||
|
||||
let getProxyUrlFromFetch: typeof import("./proxy.js").getProxyUrlFromFetch;
|
||||
let makeProxyFetch: typeof import("./proxy.js").makeProxyFetch;
|
||||
|
||||
function getOwnSymbolValue(
|
||||
target: Record<PropertyKey, unknown>,
|
||||
description: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const symbol = Object.getOwnPropertySymbols(target).find(
|
||||
(entry) => entry.description === description,
|
||||
);
|
||||
const value = symbol ? target[symbol] : undefined;
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
vi.mock("undici", () => ({
|
||||
ProxyAgent: proxyMocks.ProxyAgent,
|
||||
fetch: proxyMocks.undiciFetch,
|
||||
setGlobalDispatcher: proxyMocks.setGlobalDispatcher,
|
||||
}));
|
||||
|
||||
describe("fetchTelegramChatId", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "returns stringified id when Telegram getChat succeeds",
|
||||
fetchImpl: vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, result: { id: 12345 } }),
|
||||
})),
|
||||
expected: "12345",
|
||||
},
|
||||
{
|
||||
name: "returns null when response is not ok",
|
||||
fetchImpl: vi.fn(async () => ({
|
||||
ok: false,
|
||||
json: async () => ({}),
|
||||
})),
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
name: "returns null on transport failures",
|
||||
fetchImpl: vi.fn(async () => {
|
||||
throw new Error("network failed");
|
||||
}),
|
||||
expected: null,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
it(testCase.name, async () => {
|
||||
vi.stubGlobal("fetch", testCase.fetchImpl);
|
||||
|
||||
const id = await fetchTelegramChatId({
|
||||
token: "abc",
|
||||
chatId: "@user",
|
||||
});
|
||||
|
||||
expect(id).toBe(testCase.expected);
|
||||
});
|
||||
}
|
||||
|
||||
it("calls Telegram getChat endpoint", async () => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, result: { id: 12345 } }),
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await fetchTelegramChatId({ token: "abc", chatId: "@user" });
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.telegram.org/botabc/getChat?chat_id=%40user",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses caller-provided fetch impl when present", async () => {
|
||||
const customFetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, result: { id: 12345 } }),
|
||||
}));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
throw new Error("global fetch should not be called");
|
||||
}),
|
||||
);
|
||||
|
||||
await fetchTelegramChatId({
|
||||
token: "abc",
|
||||
chatId: "@user",
|
||||
fetchImpl: customFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(customFetch).toHaveBeenCalledWith(
|
||||
"https://api.telegram.org/botabc/getChat?chat_id=%40user",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("undici env proxy semantics", () => {
|
||||
it("uses proxyTls rather than connect for proxied HTTPS transport settings", () => {
|
||||
vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890");
|
||||
const connect = {
|
||||
family: 4,
|
||||
autoSelectFamily: false,
|
||||
};
|
||||
|
||||
const withoutProxyTls = new EnvHttpProxyAgent({ connect });
|
||||
const noProxyAgent = withoutProxyTls[kNoProxyAgent] as Record<PropertyKey, unknown>;
|
||||
const httpsProxyAgent = withoutProxyTls[kHttpsProxyAgent] as Record<PropertyKey, unknown>;
|
||||
|
||||
expect(getOwnSymbolValue(noProxyAgent, "options")?.connect).toEqual(
|
||||
expect.objectContaining(connect),
|
||||
);
|
||||
expect(getOwnSymbolValue(httpsProxyAgent, "proxy tls settings")).toBeUndefined();
|
||||
|
||||
const withProxyTls = new EnvHttpProxyAgent({
|
||||
connect,
|
||||
proxyTls: connect,
|
||||
});
|
||||
const httpsProxyAgentWithProxyTls = withProxyTls[kHttpsProxyAgent] as Record<
|
||||
PropertyKey,
|
||||
unknown
|
||||
>;
|
||||
|
||||
expect(getOwnSymbolValue(httpsProxyAgentWithProxyTls, "proxy tls settings")).toEqual(
|
||||
expect.objectContaining(connect),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeProxyFetch", () => {
|
||||
beforeAll(async () => {
|
||||
({ getProxyUrlFromFetch, makeProxyFetch } = await import("./proxy.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
proxyMocks.undiciFetch.mockReset();
|
||||
proxyMocks.proxyAgentSpy.mockClear();
|
||||
proxyMocks.setGlobalDispatcher.mockClear();
|
||||
});
|
||||
|
||||
it("attaches proxy metadata for resolver transport handling", () => {
|
||||
const proxyUrl = "http://proxy.test:8080";
|
||||
const proxyFetch = makeProxyFetch(proxyUrl);
|
||||
|
||||
expect(getProxyUrlFromFetch(proxyFetch)).toBe(proxyUrl);
|
||||
});
|
||||
});
|
||||
61
openclaw/extensions/telegram/src/api-fetch.ts
Normal file
61
openclaw/extensions/telegram/src/api-fetch.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import type { TelegramNetworkConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js";
|
||||
import { makeProxyFetch } from "./proxy.js";
|
||||
|
||||
export function resolveTelegramChatLookupFetch(params?: {
|
||||
proxyUrl?: string;
|
||||
network?: TelegramNetworkConfig;
|
||||
}): typeof fetch {
|
||||
const proxyUrl = params?.proxyUrl?.trim();
|
||||
const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined;
|
||||
return resolveTelegramFetch(proxyFetch, { network: params?.network });
|
||||
}
|
||||
|
||||
export async function lookupTelegramChatId(params: {
|
||||
token: string;
|
||||
chatId: string;
|
||||
signal?: AbortSignal;
|
||||
apiRoot?: string;
|
||||
proxyUrl?: string;
|
||||
network?: TelegramNetworkConfig;
|
||||
}): Promise<string | null> {
|
||||
return fetchTelegramChatId({
|
||||
token: params.token,
|
||||
chatId: params.chatId,
|
||||
signal: params.signal,
|
||||
apiRoot: params.apiRoot,
|
||||
fetchImpl: resolveTelegramChatLookupFetch({
|
||||
proxyUrl: params.proxyUrl,
|
||||
network: params.network,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchTelegramChatId(params: {
|
||||
token: string;
|
||||
chatId: string;
|
||||
signal?: AbortSignal;
|
||||
apiRoot?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): Promise<string | null> {
|
||||
const apiBase = resolveTelegramApiBase(params.apiRoot);
|
||||
const url = `${apiBase}/bot${params.token}/getChat?chat_id=${encodeURIComponent(params.chatId)}`;
|
||||
const fetchImpl = params.fetchImpl ?? fetch;
|
||||
try {
|
||||
const res = await fetchImpl(url, params.signal ? { signal: params.signal } : undefined);
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
const data = (await res.json().catch(() => null)) as {
|
||||
ok?: boolean;
|
||||
result?: { id?: number | string };
|
||||
} | null;
|
||||
const id = data?.ok ? data?.result?.id : undefined;
|
||||
if (typeof id === "number" || typeof id === "string") {
|
||||
return String(id);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
44
openclaw/extensions/telegram/src/api-logging.ts
Normal file
44
openclaw/extensions/telegram/src/api-logging.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
|
||||
export type TelegramApiLogger = (message: string) => void;
|
||||
|
||||
type TelegramApiLoggingParams<T> = {
|
||||
operation: string;
|
||||
fn: () => Promise<T>;
|
||||
runtime?: RuntimeEnv;
|
||||
logger?: TelegramApiLogger;
|
||||
shouldLog?: (err: unknown) => boolean;
|
||||
};
|
||||
|
||||
const fallbackLogger = createSubsystemLogger("telegram/api");
|
||||
|
||||
function resolveTelegramApiLogger(runtime?: RuntimeEnv, logger?: TelegramApiLogger) {
|
||||
if (logger) {
|
||||
return logger;
|
||||
}
|
||||
if (runtime?.error) {
|
||||
return runtime.error;
|
||||
}
|
||||
return (message: string) => fallbackLogger.error(message);
|
||||
}
|
||||
|
||||
export async function withTelegramApiErrorLogging<T>({
|
||||
operation,
|
||||
fn,
|
||||
runtime,
|
||||
logger,
|
||||
shouldLog,
|
||||
}: TelegramApiLoggingParams<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (!shouldLog || shouldLog(err)) {
|
||||
const errText = formatErrorMessage(err);
|
||||
const log = resolveTelegramApiLogger(runtime, logger);
|
||||
log(`telegram ${operation} failed: ${errText}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
fitsTelegramCallbackData,
|
||||
rewriteTelegramApprovalDecisionAlias,
|
||||
sanitizeTelegramCallbackData,
|
||||
} from "./approval-callback-data.js";
|
||||
|
||||
describe("approval callback data", () => {
|
||||
it("enforces Telegram callback byte boundaries", () => {
|
||||
expect(fitsTelegramCallbackData("x".repeat(63))).toBe(true);
|
||||
expect(fitsTelegramCallbackData("x".repeat(64))).toBe(true);
|
||||
expect(fitsTelegramCallbackData("x".repeat(65))).toBe(false);
|
||||
});
|
||||
|
||||
it("rewrites /approve allow-always callbacks to always", () => {
|
||||
const approvalId = `plugin:${"a".repeat(36)}`;
|
||||
expect(rewriteTelegramApprovalDecisionAlias(`/approve ${approvalId} allow-always`)).toBe(
|
||||
`/approve ${approvalId} always`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps rewritten allow-always callbacks when canonical form would overflow", () => {
|
||||
const approvalId = `plugin:${"a".repeat(36)}`;
|
||||
expect(sanitizeTelegramCallbackData(`/approve ${approvalId} allow-always`)).toBe(
|
||||
`/approve ${approvalId} always`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps 64-byte callbacks and drops 65-byte callbacks through sanitize", () => {
|
||||
expect(sanitizeTelegramCallbackData("x".repeat(64))).toBe("x".repeat(64));
|
||||
expect(sanitizeTelegramCallbackData("x".repeat(65))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
23
openclaw/extensions/telegram/src/approval-callback-data.ts
Normal file
23
openclaw/extensions/telegram/src/approval-callback-data.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export const TELEGRAM_CALLBACK_DATA_MAX_BYTES = 64;
|
||||
|
||||
const TELEGRAM_APPROVE_ALLOW_ALWAYS_PATTERN =
|
||||
/^\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+allow-always$/i;
|
||||
|
||||
export function fitsTelegramCallbackData(value: string): boolean {
|
||||
return Buffer.byteLength(value, "utf8") <= TELEGRAM_CALLBACK_DATA_MAX_BYTES;
|
||||
}
|
||||
|
||||
export function rewriteTelegramApprovalDecisionAlias(value: string): string {
|
||||
if (!value.endsWith(" allow-always")) {
|
||||
return value;
|
||||
}
|
||||
if (!TELEGRAM_APPROVE_ALLOW_ALWAYS_PATTERN.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return value.slice(0, -"allow-always".length) + "always";
|
||||
}
|
||||
|
||||
export function sanitizeTelegramCallbackData(value: string): string | undefined {
|
||||
const rewritten = rewriteTelegramApprovalDecisionAlias(value);
|
||||
return fitsTelegramCallbackData(rewritten) ? rewritten : undefined;
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { telegramApprovalNativeRuntime } from "./approval-handler.runtime.js";
|
||||
|
||||
type TelegramPayload = {
|
||||
text: string;
|
||||
buttons?: Array<Array<{ text: string }>>;
|
||||
};
|
||||
|
||||
describe("telegramApprovalNativeRuntime", () => {
|
||||
it("renders only the allowed pending buttons", async () => {
|
||||
const payload = (await telegramApprovalNativeRuntime.presentation.buildPendingPayload({
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
context: {
|
||||
token: "tg-token",
|
||||
},
|
||||
request: {
|
||||
id: "req-1",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 60_000,
|
||||
},
|
||||
approvalKind: "exec",
|
||||
nowMs: 0,
|
||||
view: {
|
||||
approvalKind: "exec",
|
||||
approvalId: "req-1",
|
||||
commandText: "echo hi",
|
||||
actions: [
|
||||
{
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
command: "/approve req-1 allow-once",
|
||||
style: "success",
|
||||
},
|
||||
{
|
||||
decision: "deny",
|
||||
label: "Deny",
|
||||
command: "/approve req-1 deny",
|
||||
style: "danger",
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
})) as TelegramPayload;
|
||||
|
||||
expect(payload.text).toContain("/approve req-1 allow-once");
|
||||
expect(payload.text).not.toContain("allow-always");
|
||||
expect(payload.buttons?.[0]?.map((button) => button.text)).toEqual(["Allow Once", "Deny"]);
|
||||
});
|
||||
|
||||
it("passes topic thread ids to typing and message delivery", async () => {
|
||||
const sendTyping = vi.fn().mockResolvedValue({ ok: true });
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
chatId: "-1003841603622",
|
||||
messageId: "m1",
|
||||
});
|
||||
|
||||
const entry = await telegramApprovalNativeRuntime.transport.deliverPending({
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
context: {
|
||||
token: "tg-token",
|
||||
deps: {
|
||||
sendTyping,
|
||||
sendMessage,
|
||||
},
|
||||
},
|
||||
plannedTarget: {
|
||||
surface: "origin",
|
||||
reason: "preferred",
|
||||
target: {
|
||||
to: "-1003841603622",
|
||||
threadId: 928,
|
||||
},
|
||||
},
|
||||
preparedTarget: {
|
||||
chatId: "-1003841603622",
|
||||
messageThreadId: 928,
|
||||
},
|
||||
request: {
|
||||
id: "req-1",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 60_000,
|
||||
},
|
||||
approvalKind: "exec",
|
||||
view: {
|
||||
approvalKind: "exec",
|
||||
approvalId: "req-1",
|
||||
commandText: "echo hi",
|
||||
actions: [],
|
||||
} as never,
|
||||
pendingPayload: {
|
||||
text: "pending",
|
||||
buttons: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendTyping).toHaveBeenCalledWith(
|
||||
"-1003841603622",
|
||||
expect.objectContaining({
|
||||
token: "tg-token",
|
||||
accountId: "default",
|
||||
messageThreadId: 928,
|
||||
}),
|
||||
);
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"-1003841603622",
|
||||
"pending",
|
||||
expect.objectContaining({
|
||||
token: "tg-token",
|
||||
accountId: "default",
|
||||
messageThreadId: 928,
|
||||
buttons: [],
|
||||
}),
|
||||
);
|
||||
expect(entry).toEqual({
|
||||
chatId: "-1003841603622",
|
||||
messageId: "m1",
|
||||
});
|
||||
});
|
||||
});
|
||||
189
openclaw/extensions/telegram/src/approval-handler.runtime.ts
Normal file
189
openclaw/extensions/telegram/src/approval-handler.runtime.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import type {
|
||||
ChannelApprovalCapabilityHandlerContext,
|
||||
PendingApprovalView,
|
||||
} from "openclaw/plugin-sdk/approval-handler-runtime";
|
||||
import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
|
||||
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
|
||||
import { buildPluginApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import {
|
||||
buildApprovalInteractiveReplyFromActionDescriptors,
|
||||
buildExecApprovalPendingReplyPayload,
|
||||
type ExecApprovalPendingReplyParams,
|
||||
type ExecApprovalRequest,
|
||||
type PluginApprovalRequest,
|
||||
} from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveTelegramInlineButtons } from "./button-types.js";
|
||||
import {
|
||||
isTelegramExecApprovalHandlerConfigured,
|
||||
shouldHandleTelegramExecApprovalRequest,
|
||||
} from "./exec-approvals.js";
|
||||
import { editMessageReplyMarkupTelegram, sendMessageTelegram, sendTypingTelegram } from "./send.js";
|
||||
|
||||
const log = createSubsystemLogger("telegram/approvals");
|
||||
|
||||
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
|
||||
type PendingMessage = {
|
||||
chatId: string;
|
||||
messageId: string;
|
||||
};
|
||||
type TelegramPendingDelivery = {
|
||||
text: string;
|
||||
buttons: ReturnType<typeof resolveTelegramInlineButtons>;
|
||||
};
|
||||
|
||||
export type TelegramExecApprovalHandlerDeps = {
|
||||
nowMs?: () => number;
|
||||
sendTyping?: typeof sendTypingTelegram;
|
||||
sendMessage?: typeof sendMessageTelegram;
|
||||
editReplyMarkup?: typeof editMessageReplyMarkupTelegram;
|
||||
};
|
||||
|
||||
export type TelegramApprovalHandlerContext = {
|
||||
token: string;
|
||||
deps?: TelegramExecApprovalHandlerDeps;
|
||||
};
|
||||
|
||||
function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext): {
|
||||
accountId: string;
|
||||
context: TelegramApprovalHandlerContext;
|
||||
} | null {
|
||||
const context = params.context as TelegramApprovalHandlerContext | undefined;
|
||||
const accountId = normalizeOptionalString(params.accountId) ?? "";
|
||||
if (!context?.token || !accountId) {
|
||||
return null;
|
||||
}
|
||||
return { accountId, context };
|
||||
}
|
||||
|
||||
function buildPendingPayload(params: {
|
||||
request: ApprovalRequest;
|
||||
approvalKind: "exec" | "plugin";
|
||||
nowMs: number;
|
||||
view: PendingApprovalView;
|
||||
}): TelegramPendingDelivery {
|
||||
const payload =
|
||||
params.approvalKind === "plugin"
|
||||
? buildPluginApprovalPendingReplyPayload({
|
||||
request: params.request as PluginApprovalRequest,
|
||||
nowMs: params.nowMs,
|
||||
})
|
||||
: buildExecApprovalPendingReplyPayload({
|
||||
approvalId: params.request.id,
|
||||
approvalSlug: params.request.id.slice(0, 8),
|
||||
approvalCommandId: params.request.id,
|
||||
command: params.view.approvalKind === "exec" ? params.view.commandText : "",
|
||||
cwd: params.view.approvalKind === "exec" ? (params.view.cwd ?? undefined) : undefined,
|
||||
host:
|
||||
params.view.approvalKind === "exec" && params.view.host === "node" ? "node" : "gateway",
|
||||
nodeId:
|
||||
params.view.approvalKind === "exec" ? (params.view.nodeId ?? undefined) : undefined,
|
||||
allowedDecisions: params.view.actions.map((action) => action.decision),
|
||||
expiresAtMs: params.request.expiresAtMs,
|
||||
nowMs: params.nowMs,
|
||||
} satisfies ExecApprovalPendingReplyParams);
|
||||
return {
|
||||
text: payload.text ?? "",
|
||||
buttons: resolveTelegramInlineButtons({
|
||||
interactive: buildApprovalInteractiveReplyFromActionDescriptors(params.view.actions),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const telegramApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
|
||||
TelegramPendingDelivery,
|
||||
{ chatId: string; messageThreadId?: number },
|
||||
PendingMessage,
|
||||
never
|
||||
>({
|
||||
eventKinds: ["exec", "plugin"],
|
||||
availability: {
|
||||
isConfigured: (params) => {
|
||||
const resolved = resolveHandlerContext(params);
|
||||
return resolved
|
||||
? isTelegramExecApprovalHandlerConfigured({
|
||||
cfg: params.cfg,
|
||||
accountId: resolved.accountId,
|
||||
})
|
||||
: false;
|
||||
},
|
||||
shouldHandle: (params) => {
|
||||
const resolved = resolveHandlerContext(params);
|
||||
return resolved
|
||||
? shouldHandleTelegramExecApprovalRequest({
|
||||
cfg: params.cfg,
|
||||
accountId: resolved.accountId,
|
||||
request: params.request,
|
||||
})
|
||||
: false;
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
buildPendingPayload: ({ request, approvalKind, nowMs, view }) =>
|
||||
buildPendingPayload({ request, approvalKind, nowMs, view }),
|
||||
buildResolvedResult: () => ({ kind: "clear-actions" }),
|
||||
buildExpiredResult: () => ({ kind: "clear-actions" }),
|
||||
},
|
||||
transport: {
|
||||
prepareTarget: ({ plannedTarget }) => ({
|
||||
dedupeKey: buildChannelApprovalNativeTargetKey(plannedTarget.target),
|
||||
target: {
|
||||
chatId: plannedTarget.target.to,
|
||||
messageThreadId:
|
||||
typeof plannedTarget.target.threadId === "number"
|
||||
? plannedTarget.target.threadId
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
deliverPending: async ({ cfg, accountId, context, preparedTarget, pendingPayload }) => {
|
||||
const resolved = resolveHandlerContext({ cfg, accountId, context });
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
const sendTyping = resolved.context.deps?.sendTyping ?? sendTypingTelegram;
|
||||
const sendMessage = resolved.context.deps?.sendMessage ?? sendMessageTelegram;
|
||||
await sendTyping(preparedTarget.chatId, {
|
||||
cfg,
|
||||
token: resolved.context.token,
|
||||
accountId: resolved.accountId,
|
||||
...(preparedTarget.messageThreadId != null
|
||||
? { messageThreadId: preparedTarget.messageThreadId }
|
||||
: {}),
|
||||
}).catch(() => {});
|
||||
const result = await sendMessage(preparedTarget.chatId, pendingPayload.text, {
|
||||
cfg,
|
||||
token: resolved.context.token,
|
||||
accountId: resolved.accountId,
|
||||
buttons: pendingPayload.buttons,
|
||||
...(preparedTarget.messageThreadId != null
|
||||
? { messageThreadId: preparedTarget.messageThreadId }
|
||||
: {}),
|
||||
});
|
||||
return {
|
||||
chatId: result.chatId,
|
||||
messageId: result.messageId,
|
||||
};
|
||||
},
|
||||
},
|
||||
interactions: {
|
||||
clearPendingActions: async ({ cfg, accountId, context, entry }) => {
|
||||
const resolved = resolveHandlerContext({ cfg, accountId, context });
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const editReplyMarkup =
|
||||
resolved.context.deps?.editReplyMarkup ?? editMessageReplyMarkupTelegram;
|
||||
await editReplyMarkup(entry.chatId, entry.messageId, [], {
|
||||
cfg,
|
||||
token: resolved.context.token,
|
||||
accountId: resolved.accountId,
|
||||
});
|
||||
},
|
||||
},
|
||||
observe: {
|
||||
onDeliveryError: ({ error, request }) => {
|
||||
log.error(`telegram approvals: failed to send request ${request.id}: ${String(error)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
215
openclaw/extensions/telegram/src/approval-native.test.ts
Normal file
215
openclaw/extensions/telegram/src/approval-native.test.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clearSessionStoreCacheForTest } from "../../../src/config/sessions/store.js";
|
||||
import { telegramApprovalCapability, telegramNativeApprovalAdapter } from "./approval-native.js";
|
||||
|
||||
function buildConfig(
|
||||
overrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["telegram"]>>,
|
||||
): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "tok",
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["8460800771"],
|
||||
target: "dm",
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
const STORE_PATH = path.join(os.tmpdir(), "openclaw-telegram-approval-native-test.json");
|
||||
|
||||
function writeStore(store: Record<string, unknown>) {
|
||||
fs.writeFileSync(STORE_PATH, `${JSON.stringify(store, null, 2)}\n`, "utf8");
|
||||
clearSessionStoreCacheForTest();
|
||||
}
|
||||
|
||||
describe("telegram native approval adapter", () => {
|
||||
it("describes the correct Telegram exec-approval setup path", () => {
|
||||
const text = telegramApprovalCapability.describeExecApprovalSetup?.({
|
||||
channel: "telegram",
|
||||
channelLabel: "Telegram",
|
||||
});
|
||||
|
||||
expect(text).toContain("`channels.telegram.execApprovals.approvers`");
|
||||
expect(text).toContain("`channels.telegram.allowFrom`");
|
||||
expect(text).toContain("`channels.telegram.defaultTo`");
|
||||
expect(text).not.toContain("`channels.telegram.dm.allowFrom`");
|
||||
});
|
||||
|
||||
it("describes the named-account Telegram exec-approval setup path", () => {
|
||||
const text = telegramApprovalCapability.describeExecApprovalSetup?.({
|
||||
channel: "telegram",
|
||||
channelLabel: "Telegram",
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(text).toContain("`channels.telegram.accounts.work.execApprovals.approvers`");
|
||||
expect(text).toContain("`channels.telegram.accounts.work.allowFrom`");
|
||||
expect(text).toContain("`channels.telegram.accounts.work.defaultTo`");
|
||||
expect(text).not.toContain("`channels.telegram.allowFrom`");
|
||||
});
|
||||
|
||||
it("normalizes direct-chat origin targets so DM dedupe can converge", async () => {
|
||||
const target = await telegramNativeApprovalAdapter.native?.resolveOriginTarget?.({
|
||||
cfg: buildConfig(),
|
||||
accountId: "default",
|
||||
approvalKind: "exec",
|
||||
request: {
|
||||
id: "req-1",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "telegram",
|
||||
turnSourceTo: "telegram:8460800771",
|
||||
turnSourceAccountId: "default",
|
||||
sessionKey: "agent:main:telegram:direct:8460800771",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(target).toEqual({
|
||||
to: "8460800771",
|
||||
threadId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses topic-scoped turn-source targets in the extension", async () => {
|
||||
const target = await telegramNativeApprovalAdapter.native?.resolveOriginTarget?.({
|
||||
cfg: buildConfig(),
|
||||
accountId: "default",
|
||||
approvalKind: "exec",
|
||||
request: {
|
||||
id: "req-topic-1",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "telegram",
|
||||
turnSourceTo: "telegram:-1003841603622:topic:928",
|
||||
turnSourceAccountId: "default",
|
||||
sessionKey: "agent:main:telegram:group:-1003841603622:topic:928",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(target).toEqual({
|
||||
to: "-1003841603622",
|
||||
threadId: 928,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the session-bound origin target for plugin approvals", async () => {
|
||||
writeStore({
|
||||
"agent:main:telegram:group:-1003841603622:topic:928": {
|
||||
sessionId: "sess",
|
||||
updatedAt: Date.now(),
|
||||
deliveryContext: {
|
||||
channel: "telegram",
|
||||
to: "-1003841603622",
|
||||
accountId: "default",
|
||||
threadId: 928,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const target = await telegramNativeApprovalAdapter.native?.resolveOriginTarget?.({
|
||||
cfg: {
|
||||
...buildConfig(),
|
||||
session: { store: STORE_PATH },
|
||||
},
|
||||
accountId: "default",
|
||||
approvalKind: "plugin",
|
||||
request: {
|
||||
id: "plugin:req-1",
|
||||
request: {
|
||||
title: "Plugin approval",
|
||||
description: "Allow access",
|
||||
sessionKey: "agent:main:telegram:group:-1003841603622:topic:928",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(target).toEqual({
|
||||
to: "-1003841603622",
|
||||
threadId: 928,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses numeric string thread ids from the session store for plugin approvals", async () => {
|
||||
writeStore({
|
||||
"agent:main:telegram:group:-1003841603622:topic:928": {
|
||||
sessionId: "sess",
|
||||
updatedAt: Date.now(),
|
||||
deliveryContext: {
|
||||
channel: "telegram",
|
||||
to: "-1003841603622",
|
||||
accountId: "default",
|
||||
threadId: "928",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const target = await telegramNativeApprovalAdapter.native?.resolveOriginTarget?.({
|
||||
cfg: {
|
||||
...buildConfig(),
|
||||
session: { store: STORE_PATH },
|
||||
},
|
||||
accountId: "default",
|
||||
approvalKind: "plugin",
|
||||
request: {
|
||||
id: "plugin:req-2",
|
||||
request: {
|
||||
title: "Plugin approval",
|
||||
description: "Allow access",
|
||||
sessionKey: "agent:main:telegram:group:-1003841603622:topic:928",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(target).toEqual({
|
||||
to: "-1003841603622",
|
||||
threadId: 928,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks DM-only telegram approvals to notify the origin chat after delivery", () => {
|
||||
const capabilities = telegramNativeApprovalAdapter.native?.describeDeliveryCapabilities({
|
||||
cfg: buildConfig(),
|
||||
accountId: "default",
|
||||
approvalKind: "exec",
|
||||
request: {
|
||||
id: "req-dm-1",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "telegram",
|
||||
turnSourceTo: "telegram:-1003841603622:topic:928",
|
||||
turnSourceAccountId: "default",
|
||||
turnSourceThreadId: 928,
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(capabilities).toEqual({
|
||||
enabled: true,
|
||||
preferredSurface: "approver-dm",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: true,
|
||||
notifyOriginWhenDmOnly: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
172
openclaw/extensions/telegram/src/approval-native.ts
Normal file
172
openclaw/extensions/telegram/src/approval-native.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import {
|
||||
createApproverRestrictedNativeApprovalCapability,
|
||||
splitChannelApprovalCapability,
|
||||
} from "openclaw/plugin-sdk/approval-delivery-runtime";
|
||||
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
|
||||
import type { ChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
|
||||
import {
|
||||
createChannelApproverDmTargetResolver,
|
||||
createChannelNativeOriginTargetResolver,
|
||||
} from "openclaw/plugin-sdk/approval-native-runtime";
|
||||
import type { ChannelApprovalCapability } from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import { listTelegramAccountIds } from "./accounts.js";
|
||||
import {
|
||||
getTelegramExecApprovalApprovers,
|
||||
isTelegramExecApprovalApprover,
|
||||
isTelegramExecApprovalAuthorizedSender,
|
||||
isTelegramExecApprovalClientEnabled,
|
||||
isTelegramExecApprovalTargetRecipient,
|
||||
resolveTelegramExecApprovalTarget,
|
||||
shouldHandleTelegramExecApprovalRequest,
|
||||
} from "./exec-approvals.js";
|
||||
import { parseTelegramThreadId } from "./outbound-params.js";
|
||||
import { normalizeTelegramChatId, parseTelegramTarget } from "./targets.js";
|
||||
|
||||
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
|
||||
type TelegramOriginTarget = { to: string; threadId?: number };
|
||||
|
||||
function resolveTurnSourceTelegramOriginTarget(
|
||||
request: ApprovalRequest,
|
||||
): TelegramOriginTarget | null {
|
||||
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
|
||||
const rawTurnSourceTo = normalizeOptionalString(request.request.turnSourceTo) ?? "";
|
||||
const parsedTurnSourceTarget = rawTurnSourceTo ? parseTelegramTarget(rawTurnSourceTo) : null;
|
||||
const turnSourceTo = normalizeTelegramChatId(parsedTurnSourceTarget?.chatId ?? rawTurnSourceTo);
|
||||
if (turnSourceChannel !== "telegram" || !turnSourceTo) {
|
||||
return null;
|
||||
}
|
||||
const rawThreadId =
|
||||
request.request.turnSourceThreadId ?? parsedTurnSourceTarget?.messageThreadId ?? undefined;
|
||||
return {
|
||||
to: turnSourceTo,
|
||||
threadId: parseTelegramThreadId(rawThreadId),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSessionTelegramOriginTarget(sessionTarget: {
|
||||
to: string;
|
||||
threadId?: string | number | null;
|
||||
}): TelegramOriginTarget {
|
||||
return {
|
||||
to: normalizeTelegramChatId(sessionTarget.to) ?? sessionTarget.to,
|
||||
threadId: parseTelegramThreadId(sessionTarget.threadId),
|
||||
};
|
||||
}
|
||||
|
||||
function telegramTargetsMatch(a: TelegramOriginTarget, b: TelegramOriginTarget): boolean {
|
||||
const normalizedA = normalizeTelegramChatId(a.to) ?? a.to;
|
||||
const normalizedB = normalizeTelegramChatId(b.to) ?? b.to;
|
||||
return normalizedA === normalizedB && a.threadId === b.threadId;
|
||||
}
|
||||
|
||||
const resolveTelegramOriginTarget = createChannelNativeOriginTargetResolver({
|
||||
channel: "telegram",
|
||||
shouldHandleRequest: ({ cfg, accountId, request }) =>
|
||||
shouldHandleTelegramExecApprovalRequest({
|
||||
cfg,
|
||||
accountId,
|
||||
request,
|
||||
}),
|
||||
resolveTurnSourceTarget: resolveTurnSourceTelegramOriginTarget,
|
||||
resolveSessionTarget: resolveSessionTelegramOriginTarget,
|
||||
targetsMatch: telegramTargetsMatch,
|
||||
});
|
||||
|
||||
const resolveTelegramApproverDmTargets = createChannelApproverDmTargetResolver({
|
||||
shouldHandleRequest: ({ cfg, accountId, request }) =>
|
||||
shouldHandleTelegramExecApprovalRequest({
|
||||
cfg,
|
||||
accountId,
|
||||
request,
|
||||
}),
|
||||
resolveApprovers: getTelegramExecApprovalApprovers,
|
||||
mapApprover: (approver) => ({ to: approver }),
|
||||
});
|
||||
|
||||
const telegramNativeApprovalCapability = createApproverRestrictedNativeApprovalCapability({
|
||||
channel: "telegram",
|
||||
channelLabel: "Telegram",
|
||||
describeExecApprovalSetup: ({ accountId }: { accountId?: string | null }) => {
|
||||
const prefix =
|
||||
accountId && accountId !== "default"
|
||||
? `channels.telegram.accounts.${accountId}`
|
||||
: "channels.telegram";
|
||||
return `Approve it from the Web UI or terminal UI for now. Telegram supports native exec approvals for this account. Configure \`${prefix}.execApprovals.approvers\`; if you leave it unset, OpenClaw can infer numeric owner IDs from \`${prefix}.allowFrom\` or direct-message \`${prefix}.defaultTo\` when possible. Leave \`${prefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
|
||||
},
|
||||
listAccountIds: listTelegramAccountIds,
|
||||
hasApprovers: ({ cfg, accountId }) =>
|
||||
getTelegramExecApprovalApprovers({ cfg, accountId }).length > 0,
|
||||
isExecAuthorizedSender: ({ cfg, accountId, senderId }) =>
|
||||
isTelegramExecApprovalAuthorizedSender({ cfg, accountId, senderId }),
|
||||
isPluginAuthorizedSender: ({ cfg, accountId, senderId }) =>
|
||||
isTelegramExecApprovalApprover({ cfg, accountId, senderId }),
|
||||
isNativeDeliveryEnabled: ({ cfg, accountId }) =>
|
||||
isTelegramExecApprovalClientEnabled({ cfg, accountId }),
|
||||
resolveNativeDeliveryMode: ({ cfg, accountId }) =>
|
||||
resolveTelegramExecApprovalTarget({ cfg, accountId }),
|
||||
requireMatchingTurnSourceChannel: true,
|
||||
resolveSuppressionAccountId: ({ target, request }) =>
|
||||
normalizeOptionalString(target.accountId) ??
|
||||
normalizeOptionalString(request.request.turnSourceAccountId),
|
||||
resolveOriginTarget: resolveTelegramOriginTarget,
|
||||
resolveApproverDmTargets: resolveTelegramApproverDmTargets,
|
||||
notifyOriginWhenDmOnly: true,
|
||||
nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({
|
||||
eventKinds: ["exec", "plugin"],
|
||||
isConfigured: ({ cfg, accountId }) =>
|
||||
isTelegramExecApprovalClientEnabled({
|
||||
cfg,
|
||||
accountId,
|
||||
}),
|
||||
shouldHandle: ({ cfg, accountId, request }) =>
|
||||
shouldHandleTelegramExecApprovalRequest({
|
||||
cfg,
|
||||
accountId,
|
||||
request,
|
||||
}),
|
||||
load: async () =>
|
||||
(await import("./approval-handler.runtime.js"))
|
||||
.telegramApprovalNativeRuntime as unknown as ChannelApprovalNativeRuntimeAdapter,
|
||||
}),
|
||||
});
|
||||
|
||||
const resolveTelegramApproveCommandBehavior: NonNullable<
|
||||
ChannelApprovalCapability["resolveApproveCommandBehavior"]
|
||||
> = (
|
||||
params: Parameters<NonNullable<ChannelApprovalCapability["resolveApproveCommandBehavior"]>>[0],
|
||||
) => {
|
||||
const { cfg, accountId, senderId, approvalKind } = params;
|
||||
if (approvalKind !== "exec") {
|
||||
return undefined;
|
||||
}
|
||||
if (isTelegramExecApprovalClientEnabled({ cfg, accountId })) {
|
||||
return undefined;
|
||||
}
|
||||
if (isTelegramExecApprovalTargetRecipient({ cfg, accountId, senderId })) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
isTelegramExecApprovalAuthorizedSender({ cfg, accountId, senderId }) &&
|
||||
!isTelegramExecApprovalApprover({ cfg, accountId, senderId })
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
kind: "reply",
|
||||
text: "❌ Telegram exec approvals are not enabled for this bot account.",
|
||||
};
|
||||
};
|
||||
|
||||
export const telegramApprovalCapability: ChannelApprovalCapability = {
|
||||
...telegramNativeApprovalCapability,
|
||||
resolveApproveCommandBehavior: resolveTelegramApproveCommandBehavior,
|
||||
};
|
||||
|
||||
export const telegramNativeApprovalAdapter = splitChannelApprovalCapability(
|
||||
telegramApprovalCapability,
|
||||
);
|
||||
78
openclaw/extensions/telegram/src/audit-membership-runtime.ts
Normal file
78
openclaw/extensions/telegram/src/audit-membership-runtime.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { isRecord } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { fetchWithTimeout } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type {
|
||||
AuditTelegramGroupMembershipParams,
|
||||
TelegramGroupMembershipAudit,
|
||||
TelegramGroupMembershipAuditEntry,
|
||||
} from "./audit.types.js";
|
||||
import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js";
|
||||
import { makeProxyFetch } from "./proxy.js";
|
||||
|
||||
type TelegramApiOk<T> = { ok: true; result: T };
|
||||
type TelegramApiErr = { ok: false; description?: string };
|
||||
type TelegramGroupMembershipAuditData = Omit<TelegramGroupMembershipAudit, "elapsedMs">;
|
||||
type TelegramChatMemberResult = { status?: string };
|
||||
|
||||
export async function auditTelegramGroupMembershipImpl(
|
||||
params: AuditTelegramGroupMembershipParams,
|
||||
): Promise<TelegramGroupMembershipAuditData> {
|
||||
const proxyFetch = params.proxyUrl ? makeProxyFetch(params.proxyUrl) : undefined;
|
||||
const fetcher = resolveTelegramFetch(proxyFetch, {
|
||||
network: params.network,
|
||||
});
|
||||
const apiBase = resolveTelegramApiBase(params.apiRoot);
|
||||
const base = `${apiBase}/bot${params.token}`;
|
||||
const groups: TelegramGroupMembershipAuditEntry[] = [];
|
||||
|
||||
for (const chatId of params.groupIds) {
|
||||
try {
|
||||
const url = `${base}/getChatMember?chat_id=${encodeURIComponent(chatId)}&user_id=${encodeURIComponent(String(params.botId))}`;
|
||||
const res = await fetchWithTimeout(url, {}, params.timeoutMs, fetcher);
|
||||
const json = (await res.json()) as TelegramApiOk<TelegramChatMemberResult> | TelegramApiErr;
|
||||
if (!res.ok || !isRecord(json) || !json.ok) {
|
||||
const desc =
|
||||
isRecord(json) && !json.ok && typeof json.description === "string"
|
||||
? json.description
|
||||
: `getChatMember failed (${res.status})`;
|
||||
groups.push({
|
||||
chatId,
|
||||
ok: false,
|
||||
status: null,
|
||||
error: desc,
|
||||
matchKey: chatId,
|
||||
matchSource: "id",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const status =
|
||||
isRecord(json.result) && typeof json.result.status === "string" ? json.result.status : null;
|
||||
const ok = status === "creator" || status === "administrator" || status === "member";
|
||||
groups.push({
|
||||
chatId,
|
||||
ok,
|
||||
status,
|
||||
error: ok ? null : "bot not in group",
|
||||
matchKey: chatId,
|
||||
matchSource: "id",
|
||||
});
|
||||
} catch (err) {
|
||||
groups.push({
|
||||
chatId,
|
||||
ok: false,
|
||||
status: null,
|
||||
error: formatErrorMessage(err),
|
||||
matchKey: chatId,
|
||||
matchSource: "id",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: groups.every((g) => g.ok),
|
||||
checkedGroups: groups.length,
|
||||
unresolvedGroups: 0,
|
||||
hasWildcardUnmentionedGroups: false,
|
||||
groups,
|
||||
};
|
||||
}
|
||||
85
openclaw/extensions/telegram/src/audit.test.ts
Normal file
85
openclaw/extensions/telegram/src/audit.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let collectTelegramUnmentionedGroupIds: typeof import("./audit.js").collectTelegramUnmentionedGroupIds;
|
||||
let auditTelegramGroupMembership: typeof import("./audit.js").auditTelegramGroupMembership;
|
||||
const fetchWithTimeoutMock = vi.hoisted(() => vi.fn());
|
||||
const resolveTelegramFetchMock = vi.hoisted(() => vi.fn(() => fetchWithTimeoutMock));
|
||||
const resolveTelegramApiBaseMock = vi.hoisted(() => vi.fn(() => "https://api.telegram.org"));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/text-runtime", () => ({
|
||||
fetchWithTimeout: fetchWithTimeoutMock,
|
||||
isRecord: (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null,
|
||||
normalizeOptionalString: (value: unknown) => {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
},
|
||||
}));
|
||||
|
||||
function mockGetChatMemberStatus(status: string) {
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ ok: true, result: { status } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function auditSingleGroup() {
|
||||
return auditTelegramGroupMembership({
|
||||
token: "t",
|
||||
botId: 123,
|
||||
groupIds: ["-1001"],
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
describe("telegram audit", () => {
|
||||
beforeAll(async () => {
|
||||
vi.doMock("./fetch.js", () => ({
|
||||
resolveTelegramApiBase: resolveTelegramApiBaseMock,
|
||||
resolveTelegramFetch: resolveTelegramFetchMock,
|
||||
}));
|
||||
({ collectTelegramUnmentionedGroupIds, auditTelegramGroupMembership } =
|
||||
await import("./audit.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchWithTimeoutMock.mockReset();
|
||||
resolveTelegramFetchMock.mockClear();
|
||||
resolveTelegramApiBaseMock.mockClear();
|
||||
});
|
||||
|
||||
it("collects unmentioned numeric group ids and flags wildcard", async () => {
|
||||
const res = collectTelegramUnmentionedGroupIds({
|
||||
"*": { requireMention: false },
|
||||
"-1001": { requireMention: false },
|
||||
"@group": { requireMention: false },
|
||||
"-1002": { requireMention: true },
|
||||
"-1003": { requireMention: false, enabled: false },
|
||||
});
|
||||
expect(res.hasWildcardUnmentionedGroups).toBe(true);
|
||||
expect(res.groupIds).toEqual(["-1001"]);
|
||||
expect(res.unresolvedGroups).toBe(1);
|
||||
});
|
||||
|
||||
it("audits membership via getChatMember", async () => {
|
||||
mockGetChatMemberStatus("member");
|
||||
const res = await auditSingleGroup();
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.groups[0]?.chatId).toBe("-1001");
|
||||
expect(res.groups[0]?.status).toBe("member");
|
||||
expect(resolveTelegramFetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports bot not in group when status is left", async () => {
|
||||
mockGetChatMemberStatus("left");
|
||||
const res = await auditSingleGroup();
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.groups[0]?.ok).toBe(false);
|
||||
expect(res.groups[0]?.status).toBe("left");
|
||||
});
|
||||
});
|
||||
89
openclaw/extensions/telegram/src/audit.ts
Normal file
89
openclaw/extensions/telegram/src/audit.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import type { TelegramGroupConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
export type {
|
||||
AuditTelegramGroupMembershipParams,
|
||||
TelegramGroupMembershipAudit,
|
||||
TelegramGroupMembershipAuditEntry,
|
||||
} from "./audit.types.js";
|
||||
import type {
|
||||
AuditTelegramGroupMembershipParams,
|
||||
TelegramGroupMembershipAudit,
|
||||
} from "./audit.types.js";
|
||||
|
||||
export function collectTelegramUnmentionedGroupIds(
|
||||
groups: Record<string, TelegramGroupConfig> | undefined,
|
||||
) {
|
||||
if (!groups || typeof groups !== "object") {
|
||||
return {
|
||||
groupIds: [] as string[],
|
||||
unresolvedGroups: 0,
|
||||
hasWildcardUnmentionedGroups: false,
|
||||
};
|
||||
}
|
||||
const hasWildcardUnmentionedGroups =
|
||||
groups["*"]?.requireMention === false && groups["*"]?.enabled !== false;
|
||||
const groupIds: string[] = [];
|
||||
let unresolvedGroups = 0;
|
||||
for (const [key, value] of Object.entries(groups)) {
|
||||
if (key === "*") {
|
||||
continue;
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
continue;
|
||||
}
|
||||
if (value.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
if (value.requireMention !== false) {
|
||||
continue;
|
||||
}
|
||||
const id = normalizeOptionalString(key) ?? "";
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
if (/^-?\d+$/.test(id)) {
|
||||
groupIds.push(id);
|
||||
} else {
|
||||
unresolvedGroups += 1;
|
||||
}
|
||||
}
|
||||
groupIds.sort((a, b) => a.localeCompare(b));
|
||||
return { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups };
|
||||
}
|
||||
|
||||
let auditMembershipRuntimePromise: Promise<typeof import("./audit-membership-runtime.js")> | null =
|
||||
null;
|
||||
|
||||
function loadAuditMembershipRuntime() {
|
||||
auditMembershipRuntimePromise ??= import("./audit-membership-runtime.js");
|
||||
return auditMembershipRuntimePromise;
|
||||
}
|
||||
|
||||
export async function auditTelegramGroupMembership(
|
||||
params: AuditTelegramGroupMembershipParams,
|
||||
): Promise<TelegramGroupMembershipAudit> {
|
||||
const started = Date.now();
|
||||
const token = normalizeOptionalString(params.token) ?? "";
|
||||
if (!token || params.groupIds.length === 0) {
|
||||
return {
|
||||
ok: true,
|
||||
checkedGroups: 0,
|
||||
unresolvedGroups: 0,
|
||||
hasWildcardUnmentionedGroups: false,
|
||||
groups: [],
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
// Lazy import to avoid pulling `undici` (ProxyAgent) into cold-path callers that only need
|
||||
// `collectTelegramUnmentionedGroupIds` (e.g. config audits).
|
||||
const { auditTelegramGroupMembershipImpl } = await loadAuditMembershipRuntime();
|
||||
const result = await auditTelegramGroupMembershipImpl({
|
||||
...params,
|
||||
token,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
29
openclaw/extensions/telegram/src/audit.types.ts
Normal file
29
openclaw/extensions/telegram/src/audit.types.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { TelegramNetworkConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
|
||||
export type TelegramGroupMembershipAuditEntry = {
|
||||
chatId: string;
|
||||
ok: boolean;
|
||||
status?: string | null;
|
||||
error?: string | null;
|
||||
matchKey?: string;
|
||||
matchSource?: "id";
|
||||
};
|
||||
|
||||
export type TelegramGroupMembershipAudit = {
|
||||
ok: boolean;
|
||||
checkedGroups: number;
|
||||
unresolvedGroups: number;
|
||||
hasWildcardUnmentionedGroups: boolean;
|
||||
groups: TelegramGroupMembershipAuditEntry[];
|
||||
elapsedMs: number;
|
||||
};
|
||||
|
||||
export type AuditTelegramGroupMembershipParams = {
|
||||
token: string;
|
||||
botId: number;
|
||||
groupIds: string[];
|
||||
proxyUrl?: string;
|
||||
network?: TelegramNetworkConfig;
|
||||
apiRoot?: string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
60
openclaw/extensions/telegram/src/auto-topic-label.test.ts
Normal file
60
openclaw/extensions/telegram/src/auto-topic-label.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const generateConversationLabel = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({
|
||||
generateConversationLabel,
|
||||
}));
|
||||
|
||||
import {
|
||||
AUTO_TOPIC_LABEL_DEFAULT_PROMPT,
|
||||
generateTelegramTopicLabel,
|
||||
resolveAutoTopicLabelConfig,
|
||||
} from "./auto-topic-label.js";
|
||||
|
||||
describe("resolveAutoTopicLabelConfig", () => {
|
||||
it("returns enabled with default prompt when configs are undefined", () => {
|
||||
const result = resolveAutoTopicLabelConfig(undefined, undefined);
|
||||
expect(result).toEqual({ enabled: true, prompt: AUTO_TOPIC_LABEL_DEFAULT_PROMPT });
|
||||
});
|
||||
|
||||
it("prefers direct config over account config", () => {
|
||||
expect(resolveAutoTopicLabelConfig(false, true)).toBeNull();
|
||||
expect(
|
||||
resolveAutoTopicLabelConfig({ prompt: "DM prompt" }, { prompt: "Account prompt" }),
|
||||
).toEqual({
|
||||
enabled: true,
|
||||
prompt: "DM prompt",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to default prompt for empty object prompt", () => {
|
||||
expect(resolveAutoTopicLabelConfig({ enabled: true, prompt: " " }, undefined)).toEqual({
|
||||
enabled: true,
|
||||
prompt: AUTO_TOPIC_LABEL_DEFAULT_PROMPT,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateTelegramTopicLabel", () => {
|
||||
it("delegates to the generic conversation label helper with telegram max length", async () => {
|
||||
generateConversationLabel.mockResolvedValue("Billing");
|
||||
|
||||
await expect(
|
||||
generateTelegramTopicLabel({
|
||||
userMessage: "Need help with invoices",
|
||||
prompt: "prompt",
|
||||
cfg: {},
|
||||
agentId: "billing",
|
||||
}),
|
||||
).resolves.toBe("Billing");
|
||||
|
||||
expect(generateConversationLabel).toHaveBeenCalledWith({
|
||||
userMessage: "Need help with invoices",
|
||||
prompt: "prompt",
|
||||
cfg: {},
|
||||
agentId: "billing",
|
||||
maxLength: 128,
|
||||
});
|
||||
});
|
||||
});
|
||||
39
openclaw/extensions/telegram/src/auto-topic-label.ts
Normal file
39
openclaw/extensions/telegram/src/auto-topic-label.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type {
|
||||
OpenClawConfig,
|
||||
TelegramAccountConfig,
|
||||
TelegramDirectConfig,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import { generateConversationLabel } from "openclaw/plugin-sdk/reply-runtime";
|
||||
|
||||
export const AUTO_TOPIC_LABEL_DEFAULT_PROMPT =
|
||||
"Generate a very short topic label (2-4 words, max 25 chars) for a chat conversation based on the user's first message below. No emoji. Use the same language as the message. Be concise and descriptive. Return ONLY the topic name, nothing else.";
|
||||
|
||||
export function resolveAutoTopicLabelConfig(
|
||||
directConfig?: TelegramDirectConfig["autoTopicLabel"],
|
||||
accountConfig?: TelegramAccountConfig["autoTopicLabel"],
|
||||
): { enabled: true; prompt: string } | null {
|
||||
const config = directConfig ?? accountConfig;
|
||||
if (config === undefined || config === true) {
|
||||
return { enabled: true, prompt: AUTO_TOPIC_LABEL_DEFAULT_PROMPT };
|
||||
}
|
||||
if (config === false || config.enabled === false) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
enabled: true,
|
||||
prompt: config.prompt?.trim() || AUTO_TOPIC_LABEL_DEFAULT_PROMPT,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateTelegramTopicLabel(params: {
|
||||
userMessage: string;
|
||||
prompt: string;
|
||||
cfg: OpenClawConfig;
|
||||
agentId?: string;
|
||||
agentDir?: string;
|
||||
}): Promise<string | null> {
|
||||
return await generateConversationLabel({
|
||||
...params,
|
||||
maxLength: 128,
|
||||
});
|
||||
}
|
||||
97
openclaw/extensions/telegram/src/bot-access.ts
Normal file
97
openclaw/extensions/telegram/src/bot-access.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import {
|
||||
firstDefined,
|
||||
isSenderIdAllowed,
|
||||
mergeDmAllowFromSources,
|
||||
type AllowlistMatch,
|
||||
} from "openclaw/plugin-sdk/allow-from";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
export type NormalizedAllowFrom = {
|
||||
entries: string[];
|
||||
hasWildcard: boolean;
|
||||
hasEntries: boolean;
|
||||
invalidEntries: string[];
|
||||
};
|
||||
|
||||
export type AllowFromMatch = AllowlistMatch<"wildcard" | "id">;
|
||||
|
||||
const warnedInvalidEntries = new Set<string>();
|
||||
const log = createSubsystemLogger("telegram/bot-access");
|
||||
|
||||
function warnInvalidAllowFromEntries(entries: string[]) {
|
||||
if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (warnedInvalidEntries.has(entry)) {
|
||||
continue;
|
||||
}
|
||||
warnedInvalidEntries.add(entry);
|
||||
log.warn(
|
||||
[
|
||||
"Invalid allowFrom entry:",
|
||||
JSON.stringify(entry),
|
||||
"- allowFrom/groupAllowFrom authorization expects numeric Telegram sender user IDs only.",
|
||||
'To allow a Telegram group or supergroup, add its negative chat ID under "channels.telegram.groups" instead.',
|
||||
'If you had "@username" entries, re-run setup (it resolves @username to IDs) or replace them manually.',
|
||||
].join(" "),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const normalizeAllowFrom = (list?: Array<string | number>): NormalizedAllowFrom => {
|
||||
const entries = (list ?? [])
|
||||
.map((value) => normalizeOptionalString(String(value)) ?? "")
|
||||
.filter(Boolean);
|
||||
const hasWildcard = entries.includes("*");
|
||||
const normalized = entries
|
||||
.filter((value) => value !== "*")
|
||||
.map((value) => value.replace(/^(telegram|tg):/i, ""));
|
||||
const invalidEntries = normalized.filter((value) => !/^\d+$/.test(value));
|
||||
if (invalidEntries.length > 0) {
|
||||
warnInvalidAllowFromEntries([...new Set(invalidEntries)]);
|
||||
}
|
||||
const ids = normalized.filter((value) => /^\d+$/.test(value));
|
||||
return {
|
||||
entries: ids,
|
||||
hasWildcard,
|
||||
hasEntries: entries.length > 0,
|
||||
invalidEntries,
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeDmAllowFromWithStore = (params: {
|
||||
allowFrom?: Array<string | number>;
|
||||
storeAllowFrom?: string[];
|
||||
dmPolicy?: string;
|
||||
}): NormalizedAllowFrom => normalizeAllowFrom(mergeDmAllowFromSources(params));
|
||||
|
||||
export const isSenderAllowed = (params: {
|
||||
allow: NormalizedAllowFrom;
|
||||
senderId?: string;
|
||||
senderUsername?: string;
|
||||
}) => {
|
||||
const { allow, senderId } = params;
|
||||
return isSenderIdAllowed(allow, senderId, true);
|
||||
};
|
||||
|
||||
export { firstDefined };
|
||||
|
||||
export const resolveSenderAllowMatch = (params: {
|
||||
allow: NormalizedAllowFrom;
|
||||
senderId?: string;
|
||||
senderUsername?: string;
|
||||
}): AllowFromMatch => {
|
||||
const { allow, senderId } = params;
|
||||
if (allow.hasWildcard) {
|
||||
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
|
||||
}
|
||||
if (!allow.hasEntries) {
|
||||
return { allowed: false };
|
||||
}
|
||||
if (senderId && allow.entries.includes(senderId)) {
|
||||
return { allowed: true, matchKey: senderId, matchSource: "id" };
|
||||
}
|
||||
return { allowed: false };
|
||||
};
|
||||
94
openclaw/extensions/telegram/src/bot-deps.ts
Normal file
94
openclaw/extensions/telegram/src/bot-deps.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
import { loadConfig, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { loadSessionStore } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { buildModelsProviderData } from "openclaw/plugin-sdk/models-provider-runtime";
|
||||
import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-dispatch-runtime";
|
||||
import { listSkillCommandsForAgents } from "openclaw/plugin-sdk/skill-commands-runtime";
|
||||
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
|
||||
import { syncTelegramMenuCommands } from "./bot-native-command-menu.js";
|
||||
import { deliverReplies, emitInternalMessageSentHook } from "./bot/delivery.js";
|
||||
import { createTelegramDraftStream } from "./draft-stream.js";
|
||||
import { resolveTelegramExecApproval } from "./exec-approval-resolver.js";
|
||||
import { editMessageTelegram } from "./send.js";
|
||||
import { wasSentByBot } from "./sent-message-cache.js";
|
||||
|
||||
export type TelegramBotDeps = {
|
||||
loadConfig: typeof loadConfig;
|
||||
resolveStorePath: typeof resolveStorePath;
|
||||
loadSessionStore?: typeof loadSessionStore;
|
||||
readChannelAllowFromStore: typeof readChannelAllowFromStore;
|
||||
upsertChannelPairingRequest: typeof upsertChannelPairingRequest;
|
||||
enqueueSystemEvent: typeof enqueueSystemEvent;
|
||||
dispatchReplyWithBufferedBlockDispatcher: typeof dispatchReplyWithBufferedBlockDispatcher;
|
||||
loadWebMedia?: typeof loadWebMedia;
|
||||
buildModelsProviderData: typeof buildModelsProviderData;
|
||||
listSkillCommandsForAgents: typeof listSkillCommandsForAgents;
|
||||
syncTelegramMenuCommands?: typeof syncTelegramMenuCommands;
|
||||
wasSentByBot: typeof wasSentByBot;
|
||||
resolveExecApproval?: typeof resolveTelegramExecApproval;
|
||||
createTelegramDraftStream?: typeof createTelegramDraftStream;
|
||||
deliverReplies?: typeof deliverReplies;
|
||||
emitInternalMessageSentHook?: typeof emitInternalMessageSentHook;
|
||||
editMessageTelegram?: typeof editMessageTelegram;
|
||||
createChannelReplyPipeline?: typeof createChannelReplyPipeline;
|
||||
};
|
||||
|
||||
export const defaultTelegramBotDeps: TelegramBotDeps = {
|
||||
get loadConfig() {
|
||||
return loadConfig;
|
||||
},
|
||||
get resolveStorePath() {
|
||||
return resolveStorePath;
|
||||
},
|
||||
get readChannelAllowFromStore() {
|
||||
return readChannelAllowFromStore;
|
||||
},
|
||||
get loadSessionStore() {
|
||||
return loadSessionStore;
|
||||
},
|
||||
get upsertChannelPairingRequest() {
|
||||
return upsertChannelPairingRequest;
|
||||
},
|
||||
get enqueueSystemEvent() {
|
||||
return enqueueSystemEvent;
|
||||
},
|
||||
get dispatchReplyWithBufferedBlockDispatcher() {
|
||||
return dispatchReplyWithBufferedBlockDispatcher;
|
||||
},
|
||||
get loadWebMedia() {
|
||||
return loadWebMedia;
|
||||
},
|
||||
get buildModelsProviderData() {
|
||||
return buildModelsProviderData;
|
||||
},
|
||||
get listSkillCommandsForAgents() {
|
||||
return listSkillCommandsForAgents;
|
||||
},
|
||||
get syncTelegramMenuCommands() {
|
||||
return syncTelegramMenuCommands;
|
||||
},
|
||||
get wasSentByBot() {
|
||||
return wasSentByBot;
|
||||
},
|
||||
get resolveExecApproval() {
|
||||
return resolveTelegramExecApproval;
|
||||
},
|
||||
get createTelegramDraftStream() {
|
||||
return createTelegramDraftStream;
|
||||
},
|
||||
get deliverReplies() {
|
||||
return deliverReplies;
|
||||
},
|
||||
get emitInternalMessageSentHook() {
|
||||
return emitInternalMessageSentHook;
|
||||
},
|
||||
get editMessageTelegram() {
|
||||
return editMessageTelegram;
|
||||
},
|
||||
get createChannelReplyPipeline() {
|
||||
return createChannelReplyPipeline;
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export {
|
||||
resolveAgentDir,
|
||||
resolveDefaultAgentId,
|
||||
resolveDefaultModelForAgent,
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export function buildTelegramInboundDebounceKey(params: {
|
||||
accountId?: string | null;
|
||||
conversationKey: string;
|
||||
senderId: string;
|
||||
debounceLane: "default" | "forward";
|
||||
}): string {
|
||||
const resolvedAccountId = params.accountId?.trim() || "default";
|
||||
return `telegram:${resolvedAccountId}:${params.conversationKey}:${params.senderId}:${params.debounceLane}`;
|
||||
}
|
||||
40
openclaw/extensions/telegram/src/bot-handlers.media.ts
Normal file
40
openclaw/extensions/telegram/src/bot-handlers.media.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { Message } from "@grammyjs/types";
|
||||
import { MediaFetchError } from "openclaw/plugin-sdk/media-runtime";
|
||||
|
||||
export const APPROVE_CALLBACK_DATA_RE =
|
||||
/^\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+(allow-once|allow-always|deny)\b/i;
|
||||
|
||||
export function isMediaSizeLimitError(err: unknown): boolean {
|
||||
const errMsg = String(err);
|
||||
return errMsg.includes("exceeds") && errMsg.includes("MB limit");
|
||||
}
|
||||
|
||||
export function isRecoverableMediaGroupError(err: unknown): boolean {
|
||||
return err instanceof MediaFetchError || isMediaSizeLimitError(err);
|
||||
}
|
||||
|
||||
export function hasInboundMedia(msg: Message): boolean {
|
||||
return (
|
||||
Boolean(msg.media_group_id) ||
|
||||
(Array.isArray(msg.photo) && msg.photo.length > 0) ||
|
||||
Boolean(msg.video ?? msg.video_note ?? msg.document ?? msg.audio ?? msg.voice ?? msg.sticker)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasReplyTargetMedia(msg: Message): boolean {
|
||||
const externalReply = (msg as Message & { external_reply?: Message }).external_reply;
|
||||
const replyTarget = msg.reply_to_message ?? externalReply;
|
||||
return Boolean(replyTarget && hasInboundMedia(replyTarget));
|
||||
}
|
||||
|
||||
export function resolveInboundMediaFileId(msg: Message): string | undefined {
|
||||
return (
|
||||
msg.sticker?.file_id ??
|
||||
msg.photo?.[msg.photo.length - 1]?.file_id ??
|
||||
msg.video?.file_id ??
|
||||
msg.video_note?.file_id ??
|
||||
msg.document?.file_id ??
|
||||
msg.audio?.file_id ??
|
||||
msg.voice?.file_id
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildTelegramInboundDebounceKey } from "./bot-handlers.debounce-key.js";
|
||||
|
||||
describe("buildTelegramInboundDebounceKey", () => {
|
||||
it("uses the resolved account id instead of literal default when provided", () => {
|
||||
expect(
|
||||
buildTelegramInboundDebounceKey({
|
||||
accountId: "work",
|
||||
conversationKey: "12345",
|
||||
senderId: "67890",
|
||||
debounceLane: "default",
|
||||
}),
|
||||
).toBe("telegram:work:12345:67890:default");
|
||||
});
|
||||
|
||||
it("falls back to literal default only when account id is actually absent", () => {
|
||||
expect(
|
||||
buildTelegramInboundDebounceKey({
|
||||
accountId: undefined,
|
||||
conversationKey: "12345",
|
||||
senderId: "67890",
|
||||
debounceLane: "forward",
|
||||
}),
|
||||
).toBe("telegram:default:12345:67890:forward");
|
||||
});
|
||||
});
|
||||
1934
openclaw/extensions/telegram/src/bot-handlers.runtime.ts
Normal file
1934
openclaw/extensions/telegram/src/bot-handlers.runtime.ts
Normal file
File diff suppressed because it is too large
Load diff
1
openclaw/extensions/telegram/src/bot-handlers.ts
Normal file
1
openclaw/extensions/telegram/src/bot-handlers.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./bot-handlers.runtime.js";
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => vi.fn());
|
||||
const recordInboundSessionMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
const resolveTelegramConversationRouteMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./conversation-route.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./conversation-route.js")>("./conversation-route.js");
|
||||
return {
|
||||
...actual,
|
||||
resolveTelegramConversationRoute: (...args: unknown[]) =>
|
||||
resolveTelegramConversationRouteMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
let buildTelegramMessageContextForTest: typeof import("./bot-message-context.test-harness.js").buildTelegramMessageContextForTest;
|
||||
|
||||
const configuredBindingRuntime = {
|
||||
ensureConfiguredBindingRouteReady: (...args: unknown[]) =>
|
||||
ensureConfiguredBindingRouteReadyMock(...args),
|
||||
} as NonNullable<
|
||||
import("./bot-message-context.types.js").BuildTelegramMessageContextParams["runtime"]
|
||||
>;
|
||||
|
||||
const configuredBindingSessionRuntime = {
|
||||
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
|
||||
} as NonNullable<
|
||||
import("./bot-message-context.types.js").BuildTelegramMessageContextParams["sessionRuntime"]
|
||||
>;
|
||||
|
||||
function createConfiguredTelegramBinding() {
|
||||
return {
|
||||
spec: {
|
||||
channel: "telegram",
|
||||
accountId: "work",
|
||||
conversationId: "-1001234567890:topic:42",
|
||||
parentConversationId: "-1001234567890",
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
},
|
||||
record: {
|
||||
bindingId: "config:acp:telegram:work:-1001234567890:topic:42",
|
||||
targetSessionKey: "agent:codex:acp:binding:telegram:work:abc123",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "telegram",
|
||||
accountId: "work",
|
||||
conversationId: "-1001234567890:topic:42",
|
||||
parentConversationId: "-1001234567890",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
metadata: {
|
||||
source: "config",
|
||||
mode: "persistent",
|
||||
agentId: "codex",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
function createConfiguredTelegramRoute() {
|
||||
const configuredBinding = createConfiguredTelegramBinding();
|
||||
return {
|
||||
configuredBinding: {
|
||||
conversation: {
|
||||
channel: "telegram",
|
||||
accountId: "work",
|
||||
conversationId: "-1001234567890:topic:42",
|
||||
parentConversationId: "-1001234567890",
|
||||
},
|
||||
compiledBinding: {
|
||||
channel: "telegram",
|
||||
accountPattern: "work",
|
||||
binding: {
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "telegram",
|
||||
accountId: "work",
|
||||
peer: {
|
||||
kind: "group",
|
||||
id: "-1001234567890:topic:42",
|
||||
},
|
||||
},
|
||||
},
|
||||
bindingConversationId: "-1001234567890:topic:42",
|
||||
target: {
|
||||
conversationId: "-1001234567890:topic:42",
|
||||
parentConversationId: "-1001234567890",
|
||||
},
|
||||
agentId: "codex",
|
||||
provider: {
|
||||
compileConfiguredBinding: () => ({
|
||||
conversationId: "-1001234567890:topic:42",
|
||||
parentConversationId: "-1001234567890",
|
||||
}),
|
||||
matchInboundConversation: () => ({
|
||||
conversationId: "-1001234567890:topic:42",
|
||||
parentConversationId: "-1001234567890",
|
||||
}),
|
||||
},
|
||||
targetFactory: {
|
||||
driverId: "acp",
|
||||
materialize: () => ({
|
||||
record: configuredBinding.record,
|
||||
statefulTarget: {
|
||||
kind: "stateful",
|
||||
driverId: "acp",
|
||||
sessionKey: configuredBinding.record.targetSessionKey,
|
||||
agentId: configuredBinding.spec.agentId,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
match: {
|
||||
conversationId: "-1001234567890:topic:42",
|
||||
parentConversationId: "-1001234567890",
|
||||
},
|
||||
record: configuredBinding.record,
|
||||
statefulTarget: {
|
||||
kind: "stateful",
|
||||
driverId: "acp",
|
||||
sessionKey: configuredBinding.record.targetSessionKey,
|
||||
agentId: configuredBinding.spec.agentId,
|
||||
},
|
||||
},
|
||||
configuredBindingSessionKey: configuredBinding.record.targetSessionKey,
|
||||
route: {
|
||||
agentId: "codex",
|
||||
accountId: "work",
|
||||
channel: "telegram",
|
||||
sessionKey: configuredBinding.record.targetSessionKey,
|
||||
mainSessionKey: "agent:codex:main",
|
||||
matchedBy: "binding.channel",
|
||||
lastRoutePolicy: "bound",
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
describe("buildTelegramMessageContext ACP configured bindings", () => {
|
||||
beforeAll(async () => {
|
||||
({ buildTelegramMessageContextForTest } =
|
||||
await import("./bot-message-context.test-harness.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
ensureConfiguredBindingRouteReadyMock.mockReset();
|
||||
recordInboundSessionMock.mockClear();
|
||||
resolveTelegramConversationRouteMock.mockReset();
|
||||
resolveTelegramConversationRouteMock.mockReturnValue(createConfiguredTelegramRoute());
|
||||
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("treats configured topic bindings as explicit route matches on non-default accounts", async () => {
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
accountId: "work",
|
||||
runtime: configuredBindingRuntime,
|
||||
sessionRuntime: configuredBindingSessionRuntime,
|
||||
message: {
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
|
||||
message_thread_id: 42,
|
||||
text: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.route.accountId).toBe("work");
|
||||
expect(ctx?.route.matchedBy).toBe("binding.channel");
|
||||
expect(ctx?.route.sessionKey).toBe("agent:codex:acp:binding:telegram:work:abc123");
|
||||
expect(recordInboundSessionMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
updateLastRoute: undefined,
|
||||
});
|
||||
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips ACP session initialization when topic access is denied", async () => {
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
accountId: "work",
|
||||
runtime: configuredBindingRuntime,
|
||||
sessionRuntime: configuredBindingSessionRuntime,
|
||||
message: {
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
|
||||
message_thread_id: 42,
|
||||
text: "hello",
|
||||
},
|
||||
resolveTelegramGroupConfig: () => ({
|
||||
groupConfig: { requireMention: false },
|
||||
topicConfig: { enabled: false },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(ctx).toBeNull();
|
||||
expect(resolveTelegramConversationRouteMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defers ACP session initialization for unauthorized control commands", async () => {
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
accountId: "work",
|
||||
runtime: configuredBindingRuntime,
|
||||
sessionRuntime: configuredBindingSessionRuntime,
|
||||
message: {
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
|
||||
message_thread_id: 42,
|
||||
text: "/new",
|
||||
},
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {},
|
||||
},
|
||||
commands: {
|
||||
useAccessGroups: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(ctx).toBeNull();
|
||||
expect(resolveTelegramConversationRouteMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops inbound processing when configured ACP binding initialization fails", async () => {
|
||||
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "gateway unavailable",
|
||||
});
|
||||
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
accountId: "work",
|
||||
runtime: configuredBindingRuntime,
|
||||
sessionRuntime: configuredBindingSessionRuntime,
|
||||
message: {
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
|
||||
message_thread_id: 42,
|
||||
text: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
expect(ctx).toBeNull();
|
||||
expect(resolveTelegramConversationRouteMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const transcribeFirstAudioMock = vi.fn();
|
||||
const DEFAULT_MODEL = "anthropic/claude-opus-4-5";
|
||||
const DEFAULT_WORKSPACE = "/tmp/openclaw";
|
||||
const DEFAULT_MENTION_PATTERN = "\\bbot\\b";
|
||||
|
||||
vi.mock("./media-understanding.runtime.js", () => ({
|
||||
transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args),
|
||||
}));
|
||||
|
||||
const { buildTelegramMessageContextForTest } =
|
||||
await import("./bot-message-context.test-harness.js");
|
||||
|
||||
async function buildGroupVoiceContext(params: {
|
||||
messageId: number;
|
||||
chatId: number;
|
||||
title: string;
|
||||
date: number;
|
||||
fromId: number;
|
||||
firstName: string;
|
||||
fileId: string;
|
||||
mediaPath: string;
|
||||
groupDisableAudioPreflight?: boolean;
|
||||
topicDisableAudioPreflight?: boolean;
|
||||
}) {
|
||||
const groupConfig = {
|
||||
requireMention: true,
|
||||
...(params.groupDisableAudioPreflight === undefined
|
||||
? {}
|
||||
: { disableAudioPreflight: params.groupDisableAudioPreflight }),
|
||||
};
|
||||
const topicConfig =
|
||||
params.topicDisableAudioPreflight === undefined
|
||||
? undefined
|
||||
: { disableAudioPreflight: params.topicDisableAudioPreflight };
|
||||
|
||||
return buildTelegramMessageContextForTest({
|
||||
message: {
|
||||
message_id: params.messageId,
|
||||
chat: { id: params.chatId, type: "supergroup", title: params.title },
|
||||
date: params.date,
|
||||
text: undefined,
|
||||
from: { id: params.fromId, first_name: params.firstName },
|
||||
voice: { file_id: params.fileId },
|
||||
},
|
||||
allMedia: [{ path: params.mediaPath, contentType: "audio/ogg" }],
|
||||
options: { forceWasMentioned: true },
|
||||
cfg: {
|
||||
agents: { defaults: { model: DEFAULT_MODEL, workspace: DEFAULT_WORKSPACE } },
|
||||
channels: { telegram: {} },
|
||||
messages: { groupChat: { mentionPatterns: [DEFAULT_MENTION_PATTERN] } },
|
||||
},
|
||||
resolveGroupActivation: () => true,
|
||||
resolveGroupRequireMention: () => true,
|
||||
resolveTelegramGroupConfig: () => ({
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function expectTranscriptRendered(
|
||||
ctx: Awaited<ReturnType<typeof buildGroupVoiceContext>>,
|
||||
transcript: string,
|
||||
) {
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.BodyForAgent).toBe(transcript);
|
||||
expect(ctx?.ctxPayload?.Body).toContain(transcript);
|
||||
expect(ctx?.ctxPayload?.Body).not.toContain("<media:audio>");
|
||||
}
|
||||
|
||||
function expectAudioPlaceholderRendered(ctx: Awaited<ReturnType<typeof buildGroupVoiceContext>>) {
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.Body).toContain("<media:audio>");
|
||||
}
|
||||
|
||||
describe("buildTelegramMessageContext audio transcript body", () => {
|
||||
beforeEach(() => {
|
||||
transcribeFirstAudioMock.mockReset();
|
||||
});
|
||||
|
||||
it("uses preflight transcript as BodyForAgent for mention-gated group voice messages", async () => {
|
||||
transcribeFirstAudioMock.mockResolvedValueOnce("hey bot please help");
|
||||
|
||||
const ctx = await buildGroupVoiceContext({
|
||||
messageId: 1,
|
||||
chatId: -1001234567890,
|
||||
title: "Test Group",
|
||||
date: 1700000000,
|
||||
fromId: 42,
|
||||
firstName: "Alice",
|
||||
fileId: "voice-1",
|
||||
mediaPath: "/tmp/voice.ogg",
|
||||
});
|
||||
|
||||
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
|
||||
expectTranscriptRendered(ctx, "hey bot please help");
|
||||
});
|
||||
|
||||
it("skips preflight transcription when disableAudioPreflight is true", async () => {
|
||||
transcribeFirstAudioMock.mockClear();
|
||||
|
||||
const ctx = await buildGroupVoiceContext({
|
||||
messageId: 2,
|
||||
chatId: -1001234567891,
|
||||
title: "Test Group 2",
|
||||
date: 1700000100,
|
||||
fromId: 43,
|
||||
firstName: "Bob",
|
||||
fileId: "voice-2",
|
||||
mediaPath: "/tmp/voice2.ogg",
|
||||
groupDisableAudioPreflight: true,
|
||||
});
|
||||
|
||||
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
|
||||
expectAudioPlaceholderRendered(ctx);
|
||||
});
|
||||
|
||||
it("uses topic disableAudioPreflight=false to override group disableAudioPreflight=true", async () => {
|
||||
transcribeFirstAudioMock.mockResolvedValueOnce("topic override transcript");
|
||||
|
||||
const ctx = await buildGroupVoiceContext({
|
||||
messageId: 3,
|
||||
chatId: -1001234567892,
|
||||
title: "Test Group 3",
|
||||
date: 1700000200,
|
||||
fromId: 44,
|
||||
firstName: "Cara",
|
||||
fileId: "voice-3",
|
||||
mediaPath: "/tmp/voice3.ogg",
|
||||
groupDisableAudioPreflight: true,
|
||||
topicDisableAudioPreflight: false,
|
||||
});
|
||||
|
||||
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
|
||||
expectTranscriptRendered(ctx, "topic override transcript");
|
||||
});
|
||||
|
||||
it("uses topic disableAudioPreflight=true to override group disableAudioPreflight=false", async () => {
|
||||
transcribeFirstAudioMock.mockClear();
|
||||
|
||||
const ctx = await buildGroupVoiceContext({
|
||||
messageId: 4,
|
||||
chatId: -1001234567893,
|
||||
title: "Test Group 4",
|
||||
date: 1700000300,
|
||||
fromId: 45,
|
||||
firstName: "Dan",
|
||||
fileId: "voice-4",
|
||||
mediaPath: "/tmp/voice4.ogg",
|
||||
groupDisableAudioPreflight: false,
|
||||
topicDisableAudioPreflight: true,
|
||||
});
|
||||
|
||||
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
|
||||
expectAudioPlaceholderRendered(ctx);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { normalizeAllowFrom } from "./bot-access.js";
|
||||
|
||||
const transcribeFirstAudioMock = vi.fn();
|
||||
|
||||
vi.mock("./media-understanding.runtime.js", () => ({
|
||||
transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args),
|
||||
}));
|
||||
|
||||
const { resolveTelegramInboundBody } = await import("./bot-message-context.body.js");
|
||||
|
||||
describe("resolveTelegramInboundBody", () => {
|
||||
it("keeps the media marker when a captioned video has no downloaded media", async () => {
|
||||
const result = await resolveTelegramInboundBody({
|
||||
cfg: {
|
||||
channels: { telegram: {} },
|
||||
} as never,
|
||||
primaryCtx: {
|
||||
me: { id: 7, username: "bot" },
|
||||
} as never,
|
||||
msg: {
|
||||
message_id: 0,
|
||||
date: 1_700_000_000,
|
||||
chat: { id: 42, type: "private", first_name: "Pat" },
|
||||
from: { id: 42, first_name: "Pat" },
|
||||
caption: "episode caption",
|
||||
video: {
|
||||
file_id: "video-1",
|
||||
file_unique_id: "video-u1",
|
||||
duration: 10,
|
||||
width: 320,
|
||||
height: 240,
|
||||
},
|
||||
} as never,
|
||||
allMedia: [],
|
||||
isGroup: false,
|
||||
chatId: 42,
|
||||
senderId: "42",
|
||||
senderUsername: "",
|
||||
routeAgentId: undefined,
|
||||
effectiveGroupAllow: normalizeAllowFrom([]),
|
||||
effectiveDmAllow: normalizeAllowFrom([]),
|
||||
groupConfig: undefined,
|
||||
topicConfig: undefined,
|
||||
requireMention: false,
|
||||
options: undefined,
|
||||
groupHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
logger: { info: vi.fn() },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
rawBody: "episode caption",
|
||||
bodyText: "<media:video> [file_id:video-1]\nepisode caption",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not transcribe group audio for unauthorized senders", async () => {
|
||||
transcribeFirstAudioMock.mockReset();
|
||||
const logger = { info: vi.fn() };
|
||||
|
||||
const result = await resolveTelegramInboundBody({
|
||||
cfg: {
|
||||
channels: { telegram: {} },
|
||||
messages: { groupChat: { mentionPatterns: ["\\bbot\\b"] } },
|
||||
} as never,
|
||||
primaryCtx: {
|
||||
me: { id: 7, username: "bot" },
|
||||
} as never,
|
||||
msg: {
|
||||
message_id: 1,
|
||||
date: 1_700_000_000,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
|
||||
from: { id: 46, first_name: "Eve" },
|
||||
voice: { file_id: "voice-1" },
|
||||
entities: [],
|
||||
} as never,
|
||||
allMedia: [{ path: "/tmp/voice.ogg", contentType: "audio/ogg" }],
|
||||
isGroup: true,
|
||||
chatId: -1001234567890,
|
||||
senderId: "46",
|
||||
senderUsername: "",
|
||||
routeAgentId: undefined,
|
||||
effectiveGroupAllow: normalizeAllowFrom(["999"]),
|
||||
effectiveDmAllow: normalizeAllowFrom([]),
|
||||
groupConfig: { requireMention: true } as never,
|
||||
topicConfig: undefined,
|
||||
requireMention: true,
|
||||
options: undefined,
|
||||
groupHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{ chatId: -1001234567890, reason: "no-mention" },
|
||||
"skipping group message",
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("still transcribes when commands.useAccessGroups is false", async () => {
|
||||
transcribeFirstAudioMock.mockReset();
|
||||
transcribeFirstAudioMock.mockResolvedValueOnce("hey bot please help");
|
||||
|
||||
const result = await resolveTelegramInboundBody({
|
||||
cfg: {
|
||||
channels: { telegram: {} },
|
||||
commands: { useAccessGroups: false },
|
||||
messages: { groupChat: { mentionPatterns: ["\\bbot\\b"] } },
|
||||
tools: { media: { audio: { enabled: true } } },
|
||||
} as never,
|
||||
primaryCtx: {
|
||||
me: { id: 7, username: "bot" },
|
||||
} as never,
|
||||
msg: {
|
||||
message_id: 2,
|
||||
date: 1_700_000_001,
|
||||
chat: { id: -1001234567891, type: "supergroup", title: "Test Group" },
|
||||
from: { id: 46, first_name: "Eve" },
|
||||
voice: { file_id: "voice-2" },
|
||||
entities: [],
|
||||
} as never,
|
||||
allMedia: [{ path: "/tmp/voice-2.ogg", contentType: "audio/ogg" }],
|
||||
isGroup: true,
|
||||
chatId: -1001234567891,
|
||||
senderId: "46",
|
||||
senderUsername: "",
|
||||
routeAgentId: undefined,
|
||||
effectiveGroupAllow: normalizeAllowFrom(["999"]),
|
||||
effectiveDmAllow: normalizeAllowFrom([]),
|
||||
groupConfig: { requireMention: true } as never,
|
||||
topicConfig: undefined,
|
||||
requireMention: true,
|
||||
options: undefined,
|
||||
groupHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
logger: { info: vi.fn() },
|
||||
});
|
||||
|
||||
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
|
||||
expect(result).toMatchObject({
|
||||
bodyText: "hey bot please help",
|
||||
effectiveWasMentioned: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("transcribes DM voice notes via preflight (not only groups)", async () => {
|
||||
transcribeFirstAudioMock.mockReset();
|
||||
transcribeFirstAudioMock.mockResolvedValueOnce("hello from a voice note");
|
||||
|
||||
const result = await resolveTelegramInboundBody({
|
||||
cfg: {
|
||||
channels: { telegram: {} },
|
||||
tools: { media: { audio: { enabled: true } } },
|
||||
} as never,
|
||||
primaryCtx: {
|
||||
me: { id: 7, username: "bot" },
|
||||
} as never,
|
||||
msg: {
|
||||
message_id: 10,
|
||||
date: 1_700_000_010,
|
||||
chat: { id: 42, type: "private", first_name: "Pat" },
|
||||
from: { id: 42, first_name: "Pat" },
|
||||
voice: { file_id: "voice-dm-1" },
|
||||
entities: [],
|
||||
} as never,
|
||||
allMedia: [{ path: "/tmp/voice-dm.ogg", contentType: "audio/ogg" }],
|
||||
isGroup: false,
|
||||
chatId: 42,
|
||||
senderId: "42",
|
||||
senderUsername: "",
|
||||
routeAgentId: undefined,
|
||||
effectiveGroupAllow: normalizeAllowFrom([]),
|
||||
effectiveDmAllow: normalizeAllowFrom([]),
|
||||
groupConfig: undefined,
|
||||
topicConfig: undefined,
|
||||
requireMention: false,
|
||||
options: undefined,
|
||||
groupHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
logger: { info: vi.fn() },
|
||||
});
|
||||
|
||||
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
|
||||
expect(result).toMatchObject({
|
||||
bodyText: "hello from a voice note",
|
||||
});
|
||||
expect(result?.bodyText).not.toContain("<media:audio>");
|
||||
});
|
||||
});
|
||||
363
openclaw/extensions/telegram/src/bot-message-context.body.ts
Normal file
363
openclaw/extensions/telegram/src/bot-message-context.body.ts
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
import {
|
||||
buildMentionRegexes,
|
||||
formatLocationText,
|
||||
implicitMentionKindWhen,
|
||||
logInboundDrop,
|
||||
matchesMentionWithExplicit,
|
||||
resolveInboundMentionDecision,
|
||||
type NormalizedLocation,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth-native";
|
||||
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type {
|
||||
TelegramDirectConfig,
|
||||
TelegramGroupConfig,
|
||||
TelegramTopicConfig,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveChannelGroupPolicy } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
createInternalHookEvent,
|
||||
fireAndForgetHook,
|
||||
toInternalMessageReceivedContext,
|
||||
triggerInternalHook,
|
||||
} from "openclaw/plugin-sdk/hook-runtime";
|
||||
import {
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
type HistoryEntry,
|
||||
} from "openclaw/plugin-sdk/reply-history";
|
||||
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { NormalizedAllowFrom } from "./bot-access.js";
|
||||
import { isSenderAllowed } from "./bot-access.js";
|
||||
import type {
|
||||
TelegramLogger,
|
||||
TelegramMediaRef,
|
||||
TelegramMessageContextOptions,
|
||||
} from "./bot-message-context.types.js";
|
||||
import {
|
||||
buildSenderLabel,
|
||||
buildSenderName,
|
||||
expandTextLinks,
|
||||
extractTelegramLocation,
|
||||
getTelegramTextParts,
|
||||
hasBotMention,
|
||||
resolveTelegramPrimaryMedia,
|
||||
} from "./bot/body-helpers.js";
|
||||
import { buildTelegramGroupPeerId } from "./bot/helpers.js";
|
||||
import type { TelegramContext } from "./bot/types.js";
|
||||
import { isTelegramForumServiceMessage } from "./forum-service-message.js";
|
||||
|
||||
type StickerVisionRuntime = typeof import("./sticker-vision.runtime.js");
|
||||
type MediaUnderstandingRuntime = typeof import("./media-understanding.runtime.js");
|
||||
|
||||
let stickerVisionRuntimePromise: Promise<StickerVisionRuntime> | undefined;
|
||||
let mediaUnderstandingRuntimePromise: Promise<MediaUnderstandingRuntime> | undefined;
|
||||
|
||||
function loadStickerVisionRuntime(): Promise<StickerVisionRuntime> {
|
||||
stickerVisionRuntimePromise ??= import("./sticker-vision.runtime.js");
|
||||
return stickerVisionRuntimePromise;
|
||||
}
|
||||
|
||||
function loadMediaUnderstandingRuntime(): Promise<MediaUnderstandingRuntime> {
|
||||
mediaUnderstandingRuntimePromise ??= import("./media-understanding.runtime.js");
|
||||
return mediaUnderstandingRuntimePromise;
|
||||
}
|
||||
|
||||
export type TelegramInboundBodyResult = {
|
||||
bodyText: string;
|
||||
rawBody: string;
|
||||
historyKey?: string;
|
||||
commandAuthorized: boolean;
|
||||
effectiveWasMentioned: boolean;
|
||||
canDetectMention: boolean;
|
||||
shouldBypassMention: boolean;
|
||||
stickerCacheHit: boolean;
|
||||
locationData?: NormalizedLocation;
|
||||
};
|
||||
|
||||
async function resolveStickerVisionSupport(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId?: string;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const { resolveStickerVisionSupportRuntime } = await loadStickerVisionRuntime();
|
||||
return await resolveStickerVisionSupportRuntime(params);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveTelegramInboundBody(params: {
|
||||
cfg: OpenClawConfig;
|
||||
primaryCtx: TelegramContext;
|
||||
msg: TelegramContext["message"];
|
||||
allMedia: TelegramMediaRef[];
|
||||
isGroup: boolean;
|
||||
chatId: number | string;
|
||||
accountId?: string;
|
||||
senderId: string;
|
||||
senderUsername: string;
|
||||
sessionKey?: string;
|
||||
resolvedThreadId?: number;
|
||||
routeAgentId?: string;
|
||||
effectiveGroupAllow: NormalizedAllowFrom;
|
||||
effectiveDmAllow: NormalizedAllowFrom;
|
||||
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||
topicConfig?: TelegramTopicConfig;
|
||||
requireMention?: boolean;
|
||||
options?: TelegramMessageContextOptions;
|
||||
groupHistories: Map<string, HistoryEntry[]>;
|
||||
historyLimit: number;
|
||||
logger: TelegramLogger;
|
||||
}): Promise<TelegramInboundBodyResult | null> {
|
||||
const {
|
||||
cfg,
|
||||
primaryCtx,
|
||||
msg,
|
||||
allMedia,
|
||||
isGroup,
|
||||
chatId,
|
||||
accountId,
|
||||
senderId,
|
||||
senderUsername,
|
||||
sessionKey,
|
||||
resolvedThreadId,
|
||||
routeAgentId,
|
||||
effectiveGroupAllow,
|
||||
effectiveDmAllow,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
requireMention,
|
||||
options,
|
||||
groupHistories,
|
||||
historyLimit,
|
||||
logger,
|
||||
} = params;
|
||||
const botUsername = normalizeOptionalLowercaseString(primaryCtx.me?.username);
|
||||
const mentionRegexes = buildMentionRegexes(cfg, routeAgentId);
|
||||
const messageTextParts = getTelegramTextParts(msg);
|
||||
const allowForCommands = isGroup ? effectiveGroupAllow : effectiveDmAllow;
|
||||
const senderAllowedForCommands = isSenderAllowed({
|
||||
allow: allowForCommands,
|
||||
senderId,
|
||||
senderUsername,
|
||||
});
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const hasControlCommandInMessage = hasControlCommand(messageTextParts.text, cfg, {
|
||||
botUsername,
|
||||
});
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }],
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
});
|
||||
const commandAuthorized = commandGate.commandAuthorized;
|
||||
const historyKey = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : undefined;
|
||||
|
||||
const primaryMedia = resolveTelegramPrimaryMedia(msg);
|
||||
let placeholder = primaryMedia?.placeholder ?? "";
|
||||
const cachedStickerDescription = allMedia[0]?.stickerMetadata?.cachedDescription;
|
||||
const stickerSupportsVision = msg.sticker
|
||||
? await resolveStickerVisionSupport({ cfg, agentId: routeAgentId })
|
||||
: false;
|
||||
const stickerCacheHit = Boolean(cachedStickerDescription) && !stickerSupportsVision;
|
||||
if (stickerCacheHit) {
|
||||
const emoji = allMedia[0]?.stickerMetadata?.emoji;
|
||||
const setName = allMedia[0]?.stickerMetadata?.setName;
|
||||
const stickerContext = [emoji, setName ? `from "${setName}"` : null].filter(Boolean).join(" ");
|
||||
placeholder = `[Sticker${stickerContext ? ` ${stickerContext}` : ""}] ${cachedStickerDescription}`;
|
||||
}
|
||||
|
||||
const locationData = extractTelegramLocation(msg);
|
||||
const locationText = locationData ? formatLocationText(locationData) : undefined;
|
||||
const rawText = expandTextLinks(messageTextParts.text, messageTextParts.entities).trim();
|
||||
const hasUserText = Boolean(rawText || locationText);
|
||||
let rawBody = [rawText, locationText].filter(Boolean).join("\n").trim();
|
||||
if (!rawBody) {
|
||||
rawBody = placeholder;
|
||||
}
|
||||
if (!rawBody && allMedia.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let bodyText = rawBody;
|
||||
if (allMedia.length === 0 && placeholder && rawBody !== placeholder) {
|
||||
const mediaTag = primaryMedia?.fileRef.file_id
|
||||
? `${placeholder} [file_id:${primaryMedia.fileRef.file_id}]`
|
||||
: placeholder;
|
||||
bodyText = `${mediaTag}\n${bodyText}`.trim();
|
||||
}
|
||||
const hasAudio = allMedia.some((media) => media.contentType?.startsWith("audio/"));
|
||||
const disableAudioPreflight =
|
||||
(topicConfig?.disableAudioPreflight ??
|
||||
(groupConfig as TelegramGroupConfig | undefined)?.disableAudioPreflight) === true;
|
||||
const senderAllowedForAudioPreflight =
|
||||
!useAccessGroups || !allowForCommands.hasEntries || senderAllowedForCommands;
|
||||
|
||||
let preflightTranscript: string | undefined;
|
||||
const needsPreflightTranscription =
|
||||
hasAudio &&
|
||||
!hasUserText &&
|
||||
(!isGroup ||
|
||||
(requireMention &&
|
||||
mentionRegexes.length > 0 &&
|
||||
!disableAudioPreflight &&
|
||||
senderAllowedForAudioPreflight));
|
||||
|
||||
if (needsPreflightTranscription) {
|
||||
try {
|
||||
const { transcribeFirstAudio } = await loadMediaUnderstandingRuntime();
|
||||
const tempCtx: MsgContext = {
|
||||
MediaPaths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
|
||||
MediaTypes:
|
||||
allMedia.length > 0
|
||||
? (allMedia.map((m) => m.contentType).filter(Boolean) as string[])
|
||||
: undefined,
|
||||
};
|
||||
preflightTranscript = await transcribeFirstAudio({
|
||||
ctx: tempCtx,
|
||||
cfg,
|
||||
agentDir: undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(`telegram: audio preflight transcription failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAudio && bodyText === "<media:audio>" && preflightTranscript) {
|
||||
bodyText = preflightTranscript;
|
||||
}
|
||||
|
||||
if (!bodyText && allMedia.length > 0) {
|
||||
if (hasAudio) {
|
||||
bodyText = preflightTranscript || "<media:audio>";
|
||||
} else {
|
||||
bodyText = `<media:image>${allMedia.length > 1 ? ` (${allMedia.length} images)` : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
const hasAnyMention = messageTextParts.entities.some((ent) => ent.type === "mention");
|
||||
const explicitlyMentioned = botUsername ? hasBotMention(msg, botUsername) : false;
|
||||
const computedWasMentioned = matchesMentionWithExplicit({
|
||||
text: messageTextParts.text,
|
||||
mentionRegexes,
|
||||
explicit: {
|
||||
hasAnyMention,
|
||||
isExplicitlyMentioned: explicitlyMentioned,
|
||||
canResolveExplicit: Boolean(botUsername),
|
||||
},
|
||||
transcript: preflightTranscript,
|
||||
});
|
||||
const wasMentioned = options?.forceWasMentioned === true ? true : computedWasMentioned;
|
||||
|
||||
if (isGroup && commandGate.shouldBlock) {
|
||||
logInboundDrop({
|
||||
log: logVerbose,
|
||||
channel: "telegram",
|
||||
reason: "control command (unauthorized)",
|
||||
target: senderId ?? "unknown",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const botId = primaryCtx.me?.id;
|
||||
const replyFromId = msg.reply_to_message?.from?.id;
|
||||
const replyToBotMessage = botId != null && replyFromId === botId;
|
||||
const isReplyToServiceMessage =
|
||||
replyToBotMessage && isTelegramForumServiceMessage(msg.reply_to_message);
|
||||
const implicitMentionKinds = implicitMentionKindWhen(
|
||||
"reply_to_bot",
|
||||
replyToBotMessage && !isReplyToServiceMessage,
|
||||
);
|
||||
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
|
||||
const mentionDecision = resolveInboundMentionDecision({
|
||||
facts: {
|
||||
canDetectMention,
|
||||
wasMentioned,
|
||||
hasAnyMention,
|
||||
implicitMentionKinds: isGroup && Boolean(requireMention) ? implicitMentionKinds : [],
|
||||
},
|
||||
policy: {
|
||||
isGroup,
|
||||
requireMention: Boolean(requireMention),
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
commandAuthorized,
|
||||
},
|
||||
});
|
||||
const effectiveWasMentioned = mentionDecision.effectiveWasMentioned;
|
||||
if (isGroup && requireMention && canDetectMention && mentionDecision.shouldSkip) {
|
||||
logger.info({ chatId, reason: "no-mention" }, "skipping group message");
|
||||
recordPendingHistoryEntryIfEnabled({
|
||||
historyMap: groupHistories,
|
||||
historyKey: historyKey ?? "",
|
||||
limit: historyLimit,
|
||||
entry: historyKey
|
||||
? {
|
||||
sender: buildSenderLabel(msg, senderId || chatId),
|
||||
body: rawBody,
|
||||
timestamp: msg.date ? msg.date * 1000 : undefined,
|
||||
messageId: typeof msg.message_id === "number" ? String(msg.message_id) : undefined,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
const telegramGroupPolicy = resolveChannelGroupPolicy({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
groupId: String(chatId),
|
||||
accountId,
|
||||
});
|
||||
const ingestEnabled =
|
||||
topicConfig?.ingest ??
|
||||
telegramGroupPolicy.groupConfig?.ingest ??
|
||||
telegramGroupPolicy.defaultConfig?.ingest;
|
||||
if (ingestEnabled === true && sessionKey) {
|
||||
fireAndForgetHook(
|
||||
triggerInternalHook(
|
||||
createInternalHookEvent(
|
||||
"message",
|
||||
"received",
|
||||
sessionKey,
|
||||
toInternalMessageReceivedContext({
|
||||
from: `telegram:group:${historyKey ?? chatId}`,
|
||||
to: `telegram:${chatId}`,
|
||||
content: rawBody,
|
||||
timestamp: msg.date ? msg.date * 1000 : undefined,
|
||||
channelId: "telegram",
|
||||
accountId,
|
||||
conversationId: `telegram:${chatId}`,
|
||||
messageId: typeof msg.message_id === "number" ? String(msg.message_id) : undefined,
|
||||
senderId: senderId || undefined,
|
||||
senderName: buildSenderName(msg),
|
||||
senderUsername: senderUsername || undefined,
|
||||
provider: "telegram",
|
||||
surface: "telegram",
|
||||
threadId: resolvedThreadId,
|
||||
originatingChannel: "telegram",
|
||||
originatingTo: `telegram:${chatId}`,
|
||||
isGroup: true,
|
||||
groupId: `telegram:${chatId}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
"telegram: mention-skip message hook failed",
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
bodyText,
|
||||
rawBody,
|
||||
historyKey,
|
||||
commandAuthorized,
|
||||
effectiveWasMentioned,
|
||||
canDetectMention,
|
||||
shouldBypassMention: mentionDecision.shouldBypassMention,
|
||||
stickerCacheHit,
|
||||
locationData: locationData ?? undefined,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,336 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resetTopicNameCacheForTest } from "./topic-name-cache.js";
|
||||
|
||||
type SessionRuntimeModule = typeof import("./bot-message-context.session.runtime.js");
|
||||
type RecordInboundSessionFn = SessionRuntimeModule["recordInboundSession"];
|
||||
type ResolveStorePathFn = SessionRuntimeModule["resolveStorePath"];
|
||||
|
||||
const { recordInboundSessionMock, resolveStorePathMock } = vi.hoisted(() => ({
|
||||
recordInboundSessionMock: vi.fn<RecordInboundSessionFn>(async () => undefined),
|
||||
resolveStorePathMock: vi.fn<ResolveStorePathFn>(() => "/tmp/openclaw-session-store.json"),
|
||||
}));
|
||||
|
||||
vi.mock("./bot-message-context.session.runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./bot-message-context.session.runtime.js")>(
|
||||
"./bot-message-context.session.runtime.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
recordInboundSession: (...args: Parameters<typeof actual.recordInboundSession>) =>
|
||||
recordInboundSessionMock(...args),
|
||||
resolveStorePath: (...args: Parameters<typeof actual.resolveStorePath>) =>
|
||||
resolveStorePathMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./bot-message-context.body.js", () => ({
|
||||
resolveTelegramInboundBody: async () => ({
|
||||
bodyText: "hello",
|
||||
rawBody: "hello",
|
||||
historyKey: undefined,
|
||||
commandAuthorized: false,
|
||||
effectiveWasMentioned: true,
|
||||
canDetectMention: false,
|
||||
shouldBypassMention: false,
|
||||
stickerCacheHit: false,
|
||||
locationData: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { buildTelegramMessageContextForTest } =
|
||||
await import("./bot-message-context.test-harness.js");
|
||||
const { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } =
|
||||
await import("openclaw/plugin-sdk/config-runtime");
|
||||
|
||||
beforeEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
resetTopicNameCacheForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
resetTopicNameCacheForTest();
|
||||
recordInboundSessionMock.mockClear();
|
||||
resolveStorePathMock.mockReset();
|
||||
resolveStorePathMock.mockReturnValue("/tmp/openclaw-session-store.json");
|
||||
});
|
||||
|
||||
describe("buildTelegramMessageContext dm thread sessions", () => {
|
||||
const buildContext = async (message: Record<string, unknown>) =>
|
||||
await buildTelegramMessageContextForTest({
|
||||
message,
|
||||
});
|
||||
|
||||
it("uses thread session key for dm topics", async () => {
|
||||
const ctx = await buildContext({
|
||||
message_id: 1,
|
||||
chat: { id: 1234, type: "private" },
|
||||
date: 1700000000,
|
||||
text: "hello",
|
||||
message_thread_id: 42,
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.MessageThreadId).toBe(42);
|
||||
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main:thread:1234:42");
|
||||
});
|
||||
|
||||
it("keeps legacy dm session key when no thread id", async () => {
|
||||
const ctx = await buildContext({
|
||||
message_id: 2,
|
||||
chat: { id: 1234, type: "private" },
|
||||
date: 1700000001,
|
||||
text: "hello",
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.MessageThreadId).toBeUndefined();
|
||||
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTelegramMessageContext group sessions without forum", () => {
|
||||
const buildContext = async (message: Record<string, unknown>) =>
|
||||
await buildTelegramMessageContextForTest({
|
||||
message,
|
||||
options: { forceWasMentioned: true },
|
||||
resolveGroupActivation: () => true,
|
||||
});
|
||||
|
||||
it("ignores message_thread_id for regular groups (not forums)", async () => {
|
||||
// When someone replies to a message in a non-forum group, Telegram sends
|
||||
// message_thread_id but this should NOT create a separate session
|
||||
const ctx = await buildContext({
|
||||
message_id: 1,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
|
||||
date: 1700000000,
|
||||
text: "@bot hello",
|
||||
message_thread_id: 42, // This is a reply thread, NOT a forum topic
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
// Session key should NOT include :topic:42
|
||||
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890");
|
||||
// MessageThreadId should be undefined (not a forum)
|
||||
expect(ctx?.ctxPayload?.MessageThreadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps same session for regular group with and without message_thread_id", async () => {
|
||||
const ctxWithThread = await buildContext({
|
||||
message_id: 1,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
|
||||
date: 1700000000,
|
||||
text: "@bot hello",
|
||||
message_thread_id: 42,
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
});
|
||||
|
||||
const ctxWithoutThread = await buildContext({
|
||||
message_id: 2,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
|
||||
date: 1700000001,
|
||||
text: "@bot world",
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
});
|
||||
|
||||
expect(ctxWithThread).not.toBeNull();
|
||||
expect(ctxWithoutThread).not.toBeNull();
|
||||
// Both messages should use the same session key
|
||||
expect(ctxWithThread?.ctxPayload?.SessionKey).toBe(ctxWithoutThread?.ctxPayload?.SessionKey);
|
||||
});
|
||||
|
||||
it("uses topic session for forum groups with message_thread_id", async () => {
|
||||
const ctx = await buildContext({
|
||||
message_id: 1,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
|
||||
date: 1700000000,
|
||||
text: "@bot hello",
|
||||
message_thread_id: 99,
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
// Session key SHOULD include :topic:99 for forums
|
||||
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:99");
|
||||
expect(ctx?.ctxPayload?.MessageThreadId).toBe(99);
|
||||
});
|
||||
|
||||
it("surfaces topic name from reply_to_message forum metadata", async () => {
|
||||
const ctx = await buildContext({
|
||||
message_id: 3,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
|
||||
date: 1700000002,
|
||||
text: "@bot hello",
|
||||
message_thread_id: 99,
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
reply_to_message: {
|
||||
message_id: 2,
|
||||
forum_topic_created: { name: "Deployments", icon_color: 0x6fb9f0 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.TopicName).toBe("Deployments");
|
||||
});
|
||||
|
||||
it("handles forum messages without session runtime overrides", async () => {
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
message: {
|
||||
message_id: 3,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
|
||||
date: 1700000002,
|
||||
text: "@bot hello",
|
||||
message_thread_id: 99,
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
reply_to_message: {
|
||||
message_id: 2,
|
||||
forum_topic_created: { name: "Deployments", icon_color: 0x6fb9f0 },
|
||||
},
|
||||
},
|
||||
options: { forceWasMentioned: true },
|
||||
resolveGroupActivation: () => true,
|
||||
sessionRuntime: null,
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.TopicName).toBe("Deployments");
|
||||
});
|
||||
|
||||
it("reloads topic name from disk after cache reset", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-topic-name-"));
|
||||
const sessionStorePath = path.join(tempDir, "sessions.json");
|
||||
const buildPersistedContext = async (message: Record<string, unknown>) =>
|
||||
await buildTelegramMessageContextForTest({
|
||||
message,
|
||||
options: { forceWasMentioned: true },
|
||||
resolveGroupActivation: () => true,
|
||||
sessionRuntime: {
|
||||
resolveStorePath: () => sessionStorePath,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await buildPersistedContext({
|
||||
message_id: 4,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
|
||||
date: 1700000003,
|
||||
text: "@bot hello",
|
||||
message_thread_id: 99,
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
reply_to_message: {
|
||||
message_id: 3,
|
||||
forum_topic_created: { name: "Deployments", icon_color: 0x6fb9f0 },
|
||||
},
|
||||
});
|
||||
|
||||
resetTopicNameCacheForTest();
|
||||
|
||||
const ctx = await buildPersistedContext({
|
||||
message_id: 5,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
|
||||
date: 1700000004,
|
||||
text: "@bot again",
|
||||
message_thread_id: 99,
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.TopicName).toBe("Deployments");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
resetTopicNameCacheForTest();
|
||||
}
|
||||
});
|
||||
|
||||
it("persists topic names through the default session runtime path", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-topic-name-"));
|
||||
const sessionStorePath = path.join(tempDir, "sessions.json");
|
||||
resolveStorePathMock.mockReturnValue(sessionStorePath);
|
||||
|
||||
try {
|
||||
await buildTelegramMessageContextForTest({
|
||||
message: {
|
||||
message_id: 6,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
|
||||
date: 1700000005,
|
||||
text: "@bot hello",
|
||||
message_thread_id: 99,
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
reply_to_message: {
|
||||
message_id: 5,
|
||||
forum_topic_created: { name: "Deployments", icon_color: 0x6fb9f0 },
|
||||
},
|
||||
},
|
||||
options: { forceWasMentioned: true },
|
||||
resolveGroupActivation: () => true,
|
||||
sessionRuntime: null,
|
||||
});
|
||||
|
||||
resetTopicNameCacheForTest();
|
||||
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
message: {
|
||||
message_id: 7,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
|
||||
date: 1700000006,
|
||||
text: "@bot again",
|
||||
message_thread_id: 99,
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
},
|
||||
options: { forceWasMentioned: true },
|
||||
resolveGroupActivation: () => true,
|
||||
sessionRuntime: null,
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.TopicName).toBe("Deployments");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
resetTopicNameCacheForTest();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTelegramMessageContext direct peer routing", () => {
|
||||
it("isolates dm sessions by sender id when chat id differs", async () => {
|
||||
const runtimeCfg = {
|
||||
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
|
||||
channels: { telegram: {} },
|
||||
messages: { groupChat: { mentionPatterns: [] } },
|
||||
session: { dmScope: "per-channel-peer" as const },
|
||||
};
|
||||
setRuntimeConfigSnapshot(runtimeCfg);
|
||||
|
||||
const baseMessage = {
|
||||
chat: { id: 777777777, type: "private" as const },
|
||||
date: 1700000000,
|
||||
text: "hello",
|
||||
};
|
||||
|
||||
const first = await buildTelegramMessageContextForTest({
|
||||
cfg: runtimeCfg,
|
||||
message: {
|
||||
...baseMessage,
|
||||
message_id: 1,
|
||||
from: { id: 123456789, first_name: "Alice" },
|
||||
},
|
||||
});
|
||||
const second = await buildTelegramMessageContextForTest({
|
||||
cfg: runtimeCfg,
|
||||
message: {
|
||||
...baseMessage,
|
||||
message_id: 2,
|
||||
from: { id: 987654321, first_name: "Bob" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(first?.ctxPayload?.SessionKey).toBe("agent:main:telegram:direct:123456789");
|
||||
expect(second?.ctxPayload?.SessionKey).toBe("agent:main:telegram:direct:987654321");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getRecordedUpdateLastRoute,
|
||||
loadTelegramMessageContextRouteHarness,
|
||||
recordInboundSessionMock,
|
||||
} from "./bot-message-context.route-test-support.js";
|
||||
|
||||
vi.mock("./bot-message-context.body.js", () => ({
|
||||
resolveTelegramInboundBody: async () => ({
|
||||
bodyText: "hello",
|
||||
rawBody: "hello",
|
||||
historyKey: undefined,
|
||||
commandAuthorized: false,
|
||||
effectiveWasMentioned: true,
|
||||
canDetectMention: false,
|
||||
shouldBypassMention: false,
|
||||
stickerCacheHit: false,
|
||||
locationData: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
let buildTelegramMessageContextForTest: typeof import("./bot-message-context.test-harness.js").buildTelegramMessageContextForTest;
|
||||
let clearRuntimeConfigSnapshot: typeof import("openclaw/plugin-sdk/config-runtime").clearRuntimeConfigSnapshot;
|
||||
|
||||
describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#8891)", () => {
|
||||
async function buildCtx(params: {
|
||||
message: Record<string, unknown>;
|
||||
options?: Record<string, unknown>;
|
||||
resolveGroupActivation?: () => boolean | undefined;
|
||||
}) {
|
||||
return await buildTelegramMessageContextForTest({
|
||||
message: params.message,
|
||||
options: params.options,
|
||||
resolveGroupActivation: params.resolveGroupActivation,
|
||||
});
|
||||
}
|
||||
|
||||
function expectRecordedRoute(params: { to: string; threadId?: string }) {
|
||||
const updateLastRoute = getRecordedUpdateLastRoute(0) as
|
||||
| { threadId?: string; to?: string }
|
||||
| undefined;
|
||||
expect(updateLastRoute).toBeDefined();
|
||||
expect(updateLastRoute?.to).toBe(params.to);
|
||||
expect(updateLastRoute?.threadId).toBe(params.threadId);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
({ clearRuntimeConfigSnapshot, buildTelegramMessageContextForTest } =
|
||||
await loadTelegramMessageContextRouteHarness());
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
recordInboundSessionMock.mockClear();
|
||||
});
|
||||
|
||||
it("passes threadId to updateLastRoute for DM topics", async () => {
|
||||
const ctx = await buildCtx({
|
||||
message: {
|
||||
chat: { id: 1234, type: "private" },
|
||||
message_thread_id: 42, // DM Topic ID
|
||||
},
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(recordInboundSessionMock).toHaveBeenCalled();
|
||||
|
||||
expectRecordedRoute({ to: "telegram:1234", threadId: "42" });
|
||||
});
|
||||
|
||||
it("does not pass threadId for regular DM without topic", async () => {
|
||||
const ctx = await buildCtx({
|
||||
message: {
|
||||
chat: { id: 1234, type: "private" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(recordInboundSessionMock).toHaveBeenCalled();
|
||||
|
||||
expectRecordedRoute({ to: "telegram:1234" });
|
||||
});
|
||||
|
||||
it("passes threadId to updateLastRoute for forum topic group messages", async () => {
|
||||
const ctx = await buildCtx({
|
||||
message: {
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Group", is_forum: true },
|
||||
text: "@bot hello",
|
||||
message_thread_id: 99,
|
||||
},
|
||||
options: { forceWasMentioned: true },
|
||||
resolveGroupActivation: () => true,
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(recordInboundSessionMock).toHaveBeenCalled();
|
||||
|
||||
expectRecordedRoute({ to: "telegram:-1001234567890:topic:99", threadId: "99" });
|
||||
});
|
||||
|
||||
it("passes threadId to updateLastRoute for the forum General topic", async () => {
|
||||
const ctx = await buildCtx({
|
||||
message: {
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Group", is_forum: true },
|
||||
text: "@bot hello",
|
||||
},
|
||||
options: { forceWasMentioned: true },
|
||||
resolveGroupActivation: () => true,
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(recordInboundSessionMock).toHaveBeenCalled();
|
||||
|
||||
expectRecordedRoute({ to: "telegram:-1001234567890:topic:1", threadId: "1" });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
|
||||
import { TELEGRAM_FORUM_SERVICE_FIELDS } from "./forum-service-message.js";
|
||||
|
||||
describe("buildTelegramMessageContext implicitMention forum service messages", () => {
|
||||
/**
|
||||
* Build a group message context where the user sends a message inside a
|
||||
* forum topic that has `reply_to_message` pointing to a message from the
|
||||
* bot. Callers control whether the reply target looks like a forum service
|
||||
* message (carries `forum_topic_created` etc.) or a real bot reply.
|
||||
*/
|
||||
async function buildGroupReplyCtx(params: {
|
||||
replyToMessageText?: string;
|
||||
replyToMessageCaption?: string;
|
||||
replyFromIsBot?: boolean;
|
||||
replyFromId?: number;
|
||||
/** Extra fields on reply_to_message (e.g. forum_topic_created). */
|
||||
replyToMessageExtra?: Record<string, unknown>;
|
||||
}) {
|
||||
const BOT_ID = 7; // matches test harness primaryCtx.me.id
|
||||
return await buildTelegramMessageContextForTest({
|
||||
message: {
|
||||
message_id: 100,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Forum Group" },
|
||||
date: 1700000000,
|
||||
text: "hello everyone",
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
reply_to_message: {
|
||||
message_id: 1,
|
||||
text: params.replyToMessageText ?? undefined,
|
||||
...(params.replyToMessageCaption != null
|
||||
? { caption: params.replyToMessageCaption }
|
||||
: {}),
|
||||
from: {
|
||||
id: params.replyFromId ?? BOT_ID,
|
||||
first_name: "OpenClaw",
|
||||
is_bot: params.replyFromIsBot ?? true,
|
||||
},
|
||||
...params.replyToMessageExtra,
|
||||
},
|
||||
},
|
||||
resolveGroupActivation: () => true,
|
||||
resolveGroupRequireMention: () => true,
|
||||
resolveTelegramGroupConfig: () => ({
|
||||
groupConfig: { requireMention: true },
|
||||
topicConfig: undefined,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
it("does NOT trigger implicitMention for forum_topic_created service message", async () => {
|
||||
// Bot auto-generated "Topic created" message carries forum_topic_created.
|
||||
const ctx = await buildGroupReplyCtx({
|
||||
replyToMessageText: undefined,
|
||||
replyFromIsBot: true,
|
||||
replyToMessageExtra: {
|
||||
forum_topic_created: { name: "New Topic", icon_color: 0x6fb9f0 },
|
||||
},
|
||||
});
|
||||
|
||||
// With requireMention and no explicit @mention, the message should be
|
||||
// skipped (null) because implicitMention should NOT fire.
|
||||
expect(ctx).toBeNull();
|
||||
});
|
||||
|
||||
it.each(TELEGRAM_FORUM_SERVICE_FIELDS)(
|
||||
"does NOT trigger implicitMention for %s service message",
|
||||
async (field) => {
|
||||
const ctx = await buildGroupReplyCtx({
|
||||
replyToMessageText: undefined,
|
||||
replyFromIsBot: true,
|
||||
replyToMessageExtra: { [field]: {} },
|
||||
});
|
||||
|
||||
expect(ctx).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("does NOT trigger implicitMention for forum_topic_closed service message", async () => {
|
||||
const ctx = await buildGroupReplyCtx({
|
||||
replyToMessageText: undefined,
|
||||
replyFromIsBot: true,
|
||||
replyToMessageExtra: { forum_topic_closed: {} },
|
||||
});
|
||||
|
||||
expect(ctx).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT trigger implicitMention for general_forum_topic_hidden service message", async () => {
|
||||
const ctx = await buildGroupReplyCtx({
|
||||
replyToMessageText: undefined,
|
||||
replyFromIsBot: true,
|
||||
replyToMessageExtra: { general_forum_topic_hidden: {} },
|
||||
});
|
||||
|
||||
expect(ctx).toBeNull();
|
||||
});
|
||||
|
||||
it("DOES trigger implicitMention for real bot replies (non-empty text)", async () => {
|
||||
const ctx = await buildGroupReplyCtx({
|
||||
replyToMessageText: "Here is my answer",
|
||||
replyFromIsBot: true,
|
||||
});
|
||||
|
||||
// Real bot reply → implicitMention fires → message is NOT skipped.
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.WasMentioned).toBe(true);
|
||||
});
|
||||
|
||||
it("DOES trigger implicitMention for bot media messages with caption", async () => {
|
||||
// Media messages from the bot have caption but no text — they should
|
||||
// still count as real bot replies, not service messages.
|
||||
const ctx = await buildGroupReplyCtx({
|
||||
replyToMessageText: undefined,
|
||||
replyToMessageCaption: "Check out this image",
|
||||
replyFromIsBot: true,
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.WasMentioned).toBe(true);
|
||||
});
|
||||
|
||||
it("DOES trigger implicitMention for bot sticker/voice (no text, no caption, no service field)", async () => {
|
||||
// Stickers, voice notes, and captionless photos have neither text nor
|
||||
// caption, but they are NOT service messages — they are legitimate bot
|
||||
// replies that should trigger implicitMention.
|
||||
const ctx = await buildGroupReplyCtx({
|
||||
replyToMessageText: undefined,
|
||||
replyFromIsBot: true,
|
||||
// No forum_topic_* fields → not a service message
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.WasMentioned).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT trigger implicitMention when reply is from a different user", async () => {
|
||||
const ctx = await buildGroupReplyCtx({
|
||||
replyToMessageText: "some message",
|
||||
replyFromIsBot: false,
|
||||
replyFromId: 999,
|
||||
});
|
||||
|
||||
// Different user's message → not an implicit mention → skipped.
|
||||
expect(ctx).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getRecordedUpdateLastRoute,
|
||||
loadTelegramMessageContextRouteHarness,
|
||||
recordInboundSessionMock,
|
||||
} from "./bot-message-context.route-test-support.js";
|
||||
|
||||
let buildTelegramMessageContextForTest: typeof import("./bot-message-context.test-harness.js").buildTelegramMessageContextForTest;
|
||||
let clearRuntimeConfigSnapshot: typeof import("openclaw/plugin-sdk/config-runtime").clearRuntimeConfigSnapshot;
|
||||
let setRuntimeConfigSnapshot: typeof import("openclaw/plugin-sdk/config-runtime").setRuntimeConfigSnapshot;
|
||||
|
||||
describe("buildTelegramMessageContext named-account DM fallback", () => {
|
||||
const baseCfg = {
|
||||
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
|
||||
channels: { telegram: {} },
|
||||
messages: { groupChat: { mentionPatterns: [] } },
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
({ clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot, buildTelegramMessageContextForTest } =
|
||||
await loadTelegramMessageContextRouteHarness());
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
recordInboundSessionMock.mockClear();
|
||||
});
|
||||
|
||||
function getLastUpdateLastRoute(): { sessionKey?: string } | undefined {
|
||||
return getRecordedUpdateLastRoute() as { sessionKey?: string } | undefined;
|
||||
}
|
||||
|
||||
function buildNamedAccountDmMessage(messageId = 1) {
|
||||
return {
|
||||
message_id: messageId,
|
||||
chat: { id: 814912386, type: "private" as const },
|
||||
date: 1700000000 + messageId - 1,
|
||||
text: "hello",
|
||||
from: { id: 814912386, first_name: "Alice" },
|
||||
};
|
||||
}
|
||||
|
||||
async function buildNamedAccountDmContext(accountId = "atlas", messageId = 1) {
|
||||
setRuntimeConfigSnapshot(baseCfg);
|
||||
return await buildTelegramMessageContextForTest({
|
||||
cfg: baseCfg,
|
||||
accountId,
|
||||
message: buildNamedAccountDmMessage(messageId),
|
||||
});
|
||||
}
|
||||
|
||||
it("allows DM through for a named account with no explicit binding", async () => {
|
||||
setRuntimeConfigSnapshot(baseCfg);
|
||||
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
cfg: baseCfg,
|
||||
accountId: "atlas",
|
||||
message: {
|
||||
message_id: 1,
|
||||
chat: { id: 814912386, type: "private" },
|
||||
date: 1700000000,
|
||||
text: "hello",
|
||||
from: { id: 814912386, first_name: "Alice" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.route.matchedBy).toBe("default");
|
||||
expect(ctx?.route.accountId).toBe("atlas");
|
||||
});
|
||||
|
||||
it("uses a per-account session key for named-account DMs", async () => {
|
||||
const ctx = await buildNamedAccountDmContext();
|
||||
|
||||
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:atlas:direct:814912386");
|
||||
});
|
||||
|
||||
it("keeps named-account fallback lastRoute on the isolated DM session", async () => {
|
||||
const ctx = await buildNamedAccountDmContext();
|
||||
|
||||
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:atlas:direct:814912386");
|
||||
expect(getLastUpdateLastRoute()?.sessionKey).toBe("agent:main:telegram:atlas:direct:814912386");
|
||||
});
|
||||
|
||||
it("isolates sessions between named accounts that share the default agent", async () => {
|
||||
const atlas = await buildNamedAccountDmContext("atlas", 1);
|
||||
const skynet = await buildNamedAccountDmContext("skynet", 2);
|
||||
|
||||
expect(atlas?.ctxPayload?.SessionKey).toBe("agent:main:telegram:atlas:direct:814912386");
|
||||
expect(skynet?.ctxPayload?.SessionKey).toBe("agent:main:telegram:skynet:direct:814912386");
|
||||
expect(atlas?.ctxPayload?.SessionKey).not.toBe(skynet?.ctxPayload?.SessionKey);
|
||||
});
|
||||
|
||||
it("keeps identity-linked peer canonicalization in the named-account fallback path", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
session: {
|
||||
identityLinks: {
|
||||
"alice-shared": ["telegram:814912386"],
|
||||
},
|
||||
},
|
||||
};
|
||||
setRuntimeConfigSnapshot(cfg);
|
||||
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
cfg,
|
||||
accountId: "atlas",
|
||||
message: {
|
||||
message_id: 1,
|
||||
chat: { id: 999999999, type: "private" },
|
||||
date: 1700000000,
|
||||
text: "hello",
|
||||
from: { id: 814912386, first_name: "Alice" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:atlas:direct:alice-shared");
|
||||
});
|
||||
|
||||
it("still drops named-account group messages without an explicit binding", async () => {
|
||||
setRuntimeConfigSnapshot(baseCfg);
|
||||
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
cfg: baseCfg,
|
||||
accountId: "atlas",
|
||||
options: { forceWasMentioned: true },
|
||||
resolveGroupActivation: () => true,
|
||||
message: {
|
||||
message_id: 1,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
|
||||
date: 1700000000,
|
||||
text: "@bot hello",
|
||||
from: { id: 814912386, first_name: "Alice" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(ctx).toBeNull();
|
||||
});
|
||||
|
||||
it("does not change the default-account DM session key", async () => {
|
||||
setRuntimeConfigSnapshot(baseCfg);
|
||||
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
cfg: baseCfg,
|
||||
message: {
|
||||
message_id: 1,
|
||||
chat: { id: 42, type: "private" },
|
||||
date: 1700000000,
|
||||
text: "hello",
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { vi, type Mock } from "vitest";
|
||||
|
||||
type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;
|
||||
type BuildTelegramMessageContextForTest =
|
||||
typeof import("./bot-message-context.test-harness.js").buildTelegramMessageContextForTest;
|
||||
type BuildTelegramMessageContextForTestParams = Parameters<BuildTelegramMessageContextForTest>[0];
|
||||
type TelegramTestSessionRuntime = NonNullable<
|
||||
import("./bot-message-context.types.js").BuildTelegramMessageContextParams["sessionRuntime"]
|
||||
>;
|
||||
|
||||
const hoisted = vi.hoisted((): { recordInboundSessionMock: AsyncUnknownMock } => ({
|
||||
recordInboundSessionMock: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
export const recordInboundSessionMock: AsyncUnknownMock = hoisted.recordInboundSessionMock;
|
||||
const finalizeInboundContextForTest = ((ctx) => {
|
||||
const next = ctx as Record<string, unknown>;
|
||||
const body = typeof next.Body === "string" ? next.Body : "";
|
||||
next.Body = body;
|
||||
next.BodyForAgent =
|
||||
typeof next.BodyForAgent === "string"
|
||||
? next.BodyForAgent
|
||||
: typeof next.RawBody === "string"
|
||||
? next.RawBody
|
||||
: body;
|
||||
next.BodyForCommands =
|
||||
typeof next.BodyForCommands === "string"
|
||||
? next.BodyForCommands
|
||||
: typeof next.CommandBody === "string"
|
||||
? next.CommandBody
|
||||
: typeof next.RawBody === "string"
|
||||
? next.RawBody
|
||||
: body;
|
||||
next.CommandAuthorized = Boolean(next.CommandAuthorized);
|
||||
return next;
|
||||
}) as NonNullable<TelegramTestSessionRuntime["finalizeInboundContext"]>;
|
||||
const recordInboundSessionForTest: NonNullable<
|
||||
TelegramTestSessionRuntime["recordInboundSession"]
|
||||
> = async (params) => {
|
||||
await recordInboundSessionMock(params);
|
||||
};
|
||||
|
||||
export const telegramRouteTestSessionRuntime = {
|
||||
finalizeInboundContext: finalizeInboundContextForTest,
|
||||
readSessionUpdatedAt: () => undefined,
|
||||
recordInboundSession: recordInboundSessionForTest,
|
||||
resolveInboundLastRouteSessionKey: ({ route, sessionKey }) =>
|
||||
route.lastRoutePolicy === "main" ? route.mainSessionKey : sessionKey,
|
||||
resolvePinnedMainDmOwnerFromAllowlist: () => null,
|
||||
resolveStorePath: () => "/tmp/openclaw/session-store.json",
|
||||
} satisfies TelegramTestSessionRuntime;
|
||||
|
||||
export async function loadTelegramMessageContextRouteHarness() {
|
||||
const [
|
||||
{ clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot },
|
||||
{ buildTelegramMessageContextForTest },
|
||||
] = await Promise.all([
|
||||
import("../../../src/config/config.js"),
|
||||
import("./bot-message-context.test-harness.js"),
|
||||
]);
|
||||
const buildTelegramMessageContextForRouteTest = (
|
||||
params: BuildTelegramMessageContextForTestParams,
|
||||
) =>
|
||||
buildTelegramMessageContextForTest({
|
||||
...params,
|
||||
sessionRuntime: {
|
||||
...telegramRouteTestSessionRuntime,
|
||||
...params.sessionRuntime,
|
||||
},
|
||||
});
|
||||
return {
|
||||
clearRuntimeConfigSnapshot,
|
||||
setRuntimeConfigSnapshot,
|
||||
buildTelegramMessageContextForTest: buildTelegramMessageContextForRouteTest,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRecordedUpdateLastRoute(callIndex = -1): unknown {
|
||||
const callArgs =
|
||||
callIndex === -1
|
||||
? (recordInboundSessionMock.mock.calls.at(-1)?.[0] as
|
||||
| { updateLastRoute?: unknown }
|
||||
| undefined)
|
||||
: (recordInboundSessionMock.mock.calls[callIndex]?.[0] as
|
||||
| { updateLastRoute?: unknown }
|
||||
| undefined);
|
||||
return callArgs?.updateLastRoute;
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export { createStatusReactionController } from "openclaw/plugin-sdk/channel-feedback";
|
||||
export { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime";
|
||||
export { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
export { ensureConfiguredBindingRouteReady } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
|
||||
import {
|
||||
isTelegramForumServiceMessage,
|
||||
TELEGRAM_FORUM_SERVICE_FIELDS,
|
||||
} from "./forum-service-message.js";
|
||||
|
||||
describe("isTelegramForumServiceMessage", () => {
|
||||
it("returns true for any Telegram forum service field", () => {
|
||||
for (const field of TELEGRAM_FORUM_SERVICE_FIELDS) {
|
||||
expect(isTelegramForumServiceMessage({ [field]: {} })).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false for normal messages and non-objects", () => {
|
||||
expect(isTelegramForumServiceMessage({ text: "hello" })).toBe(false);
|
||||
expect(isTelegramForumServiceMessage(null)).toBe(false);
|
||||
expect(isTelegramForumServiceMessage("topic created")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTelegramMessageContext sender prefix", () => {
|
||||
async function buildCtx(params: { messageId: number; options?: Record<string, unknown> }) {
|
||||
return await buildTelegramMessageContextForTest({
|
||||
message: {
|
||||
message_id: params.messageId,
|
||||
chat: { id: -99, type: "supergroup", title: "Dev Chat" },
|
||||
date: 1700000000,
|
||||
text: "hello",
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
},
|
||||
options: params.options,
|
||||
});
|
||||
}
|
||||
|
||||
it("prefixes group bodies with sender label", async () => {
|
||||
const ctx = await buildCtx({ messageId: 1 });
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
const body = ctx?.ctxPayload?.Body ?? "";
|
||||
expect(body).toContain("Alice (42): hello");
|
||||
});
|
||||
|
||||
it("sets MessageSid from message_id", async () => {
|
||||
const ctx = await buildCtx({ messageId: 12345 });
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.MessageSid).toBe("12345");
|
||||
});
|
||||
|
||||
it("respects messageIdOverride option", async () => {
|
||||
const ctx = await buildCtx({
|
||||
messageId: 12345,
|
||||
options: { messageIdOverride: "67890" },
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.MessageSid).toBe("67890");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
|
||||
export { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
export { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runtime";
|
||||
export { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
export { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
|
||||
470
openclaw/extensions/telegram/src/bot-message-context.session.ts
Normal file
470
openclaw/extensions/telegram/src/bot-message-context.session.ts
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
import {
|
||||
formatInboundEnvelope,
|
||||
resolveEnvelopeFormatOptions,
|
||||
toLocationContext,
|
||||
type NormalizedLocation,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { normalizeCommandBody } from "openclaw/plugin-sdk/command-surface";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type {
|
||||
TelegramDirectConfig,
|
||||
TelegramGroupConfig,
|
||||
TelegramTopicConfig,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
buildPendingHistoryContextFromMap,
|
||||
type HistoryEntry,
|
||||
} from "openclaw/plugin-sdk/reply-history";
|
||||
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { evaluateSupplementalContextVisibility } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { NormalizedAllowFrom } from "./bot-access.js";
|
||||
import { isSenderAllowed, normalizeAllowFrom } from "./bot-access.js";
|
||||
import type {
|
||||
TelegramMediaRef,
|
||||
TelegramMessageContextOptions,
|
||||
TelegramMessageContextSessionRuntimeOverrides,
|
||||
} from "./bot-message-context.types.js";
|
||||
import {
|
||||
buildGroupLabel,
|
||||
buildSenderLabel,
|
||||
buildSenderName,
|
||||
buildTelegramGroupFrom,
|
||||
describeReplyTarget,
|
||||
normalizeForwardedContext,
|
||||
type TelegramReplyTarget,
|
||||
type TelegramThreadSpec,
|
||||
} from "./bot/helpers.js";
|
||||
import type { TelegramContext } from "./bot/types.js";
|
||||
import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js";
|
||||
|
||||
type FinalizedTelegramInboundContext = ReturnType<
|
||||
typeof import("./bot-message-context.session.runtime.js").finalizeInboundContext
|
||||
>;
|
||||
|
||||
type TelegramMessageContextSessionRuntime =
|
||||
typeof import("./bot-message-context.session.runtime.js");
|
||||
|
||||
const sessionRuntimeMethods = [
|
||||
"finalizeInboundContext",
|
||||
"readSessionUpdatedAt",
|
||||
"recordInboundSession",
|
||||
"resolveInboundLastRouteSessionKey",
|
||||
"resolvePinnedMainDmOwnerFromAllowlist",
|
||||
"resolveStorePath",
|
||||
] as const satisfies readonly (keyof TelegramMessageContextSessionRuntime)[];
|
||||
|
||||
function hasCompleteSessionRuntime(
|
||||
runtime: TelegramMessageContextSessionRuntimeOverrides | undefined,
|
||||
): runtime is TelegramMessageContextSessionRuntime {
|
||||
return Boolean(
|
||||
runtime && sessionRuntimeMethods.every((method) => typeof runtime[method] === "function"),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadTelegramMessageContextSessionRuntime(
|
||||
runtime: TelegramMessageContextSessionRuntimeOverrides | undefined,
|
||||
): Promise<TelegramMessageContextSessionRuntime> {
|
||||
if (hasCompleteSessionRuntime(runtime)) {
|
||||
return runtime;
|
||||
}
|
||||
return {
|
||||
...(await import("./bot-message-context.session.runtime.js")),
|
||||
...runtime,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveTelegramMessageContextStorePath(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
sessionRuntime?: TelegramMessageContextSessionRuntimeOverrides;
|
||||
}): Promise<string> {
|
||||
const sessionRuntime = await loadTelegramMessageContextSessionRuntime(params.sessionRuntime);
|
||||
return sessionRuntime.resolveStorePath(params.cfg.session?.store, {
|
||||
agentId: params.agentId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildTelegramInboundContextPayload(params: {
|
||||
cfg: OpenClawConfig;
|
||||
primaryCtx: TelegramContext;
|
||||
msg: TelegramContext["message"];
|
||||
allMedia: TelegramMediaRef[];
|
||||
replyMedia: TelegramMediaRef[];
|
||||
isGroup: boolean;
|
||||
isForum: boolean;
|
||||
chatId: number | string;
|
||||
senderId: string;
|
||||
senderUsername: string;
|
||||
resolvedThreadId?: number;
|
||||
dmThreadId?: number;
|
||||
threadSpec: TelegramThreadSpec;
|
||||
route: ResolvedAgentRoute;
|
||||
rawBody: string;
|
||||
bodyText: string;
|
||||
historyKey?: string;
|
||||
historyLimit: number;
|
||||
groupHistories: Map<string, HistoryEntry[]>;
|
||||
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||
topicConfig?: TelegramTopicConfig;
|
||||
stickerCacheHit: boolean;
|
||||
effectiveWasMentioned: boolean;
|
||||
commandAuthorized: boolean;
|
||||
locationData?: NormalizedLocation;
|
||||
options?: TelegramMessageContextOptions;
|
||||
dmAllowFrom?: Array<string | number>;
|
||||
effectiveGroupAllow?: NormalizedAllowFrom;
|
||||
topicName?: string;
|
||||
sessionRuntime?: TelegramMessageContextSessionRuntimeOverrides;
|
||||
}): Promise<{
|
||||
ctxPayload: FinalizedTelegramInboundContext;
|
||||
skillFilter: string[] | undefined;
|
||||
}> {
|
||||
const {
|
||||
cfg,
|
||||
primaryCtx,
|
||||
msg,
|
||||
allMedia,
|
||||
replyMedia,
|
||||
isGroup,
|
||||
isForum,
|
||||
chatId,
|
||||
senderId,
|
||||
senderUsername,
|
||||
resolvedThreadId,
|
||||
dmThreadId,
|
||||
threadSpec,
|
||||
route,
|
||||
rawBody,
|
||||
bodyText,
|
||||
historyKey,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
stickerCacheHit,
|
||||
effectiveWasMentioned,
|
||||
commandAuthorized,
|
||||
locationData,
|
||||
options,
|
||||
dmAllowFrom,
|
||||
effectiveGroupAllow,
|
||||
topicName,
|
||||
sessionRuntime: sessionRuntimeOverride,
|
||||
} = params;
|
||||
const replyTarget = describeReplyTarget(msg);
|
||||
const forwardOrigin = normalizeForwardedContext(msg);
|
||||
const contextVisibilityMode = resolveChannelContextVisibilityMode({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
const shouldIncludeGroupSupplementalContext = (params: {
|
||||
kind: "quote" | "forwarded";
|
||||
senderId?: string;
|
||||
senderUsername?: string;
|
||||
}): boolean => {
|
||||
if (!isGroup) {
|
||||
return true;
|
||||
}
|
||||
const senderAllowed = effectiveGroupAllow?.hasEntries
|
||||
? isSenderAllowed({
|
||||
allow: effectiveGroupAllow,
|
||||
senderId: params.senderId,
|
||||
senderUsername: params.senderUsername,
|
||||
})
|
||||
: true;
|
||||
return evaluateSupplementalContextVisibility({
|
||||
mode: contextVisibilityMode,
|
||||
kind: params.kind,
|
||||
senderAllowed,
|
||||
}).include;
|
||||
};
|
||||
const includeReplyTarget = replyTarget
|
||||
? shouldIncludeGroupSupplementalContext({
|
||||
kind: "quote",
|
||||
senderId: replyTarget.senderId,
|
||||
senderUsername: replyTarget.senderUsername,
|
||||
})
|
||||
: false;
|
||||
const includeForwardOrigin = forwardOrigin
|
||||
? shouldIncludeGroupSupplementalContext({
|
||||
kind: "forwarded",
|
||||
senderId: forwardOrigin.fromId,
|
||||
senderUsername: forwardOrigin.fromUsername,
|
||||
})
|
||||
: false;
|
||||
const visibleReplyForwardedFrom =
|
||||
includeReplyTarget && replyTarget?.forwardedFrom
|
||||
? shouldIncludeGroupSupplementalContext({
|
||||
kind: "forwarded",
|
||||
senderId: replyTarget.forwardedFrom.fromId,
|
||||
senderUsername: replyTarget.forwardedFrom.fromUsername,
|
||||
})
|
||||
? replyTarget.forwardedFrom
|
||||
: undefined
|
||||
: undefined;
|
||||
const visibleReplyTarget: TelegramReplyTarget | null =
|
||||
includeReplyTarget && replyTarget
|
||||
? {
|
||||
...replyTarget,
|
||||
forwardedFrom: visibleReplyForwardedFrom,
|
||||
}
|
||||
: null;
|
||||
const visibleForwardOrigin = includeForwardOrigin ? forwardOrigin : null;
|
||||
const replyForwardAnnotation = visibleReplyTarget?.forwardedFrom
|
||||
? `[Forwarded from ${visibleReplyTarget.forwardedFrom.from}${
|
||||
visibleReplyTarget.forwardedFrom.date
|
||||
? ` at ${new Date(visibleReplyTarget.forwardedFrom.date * 1000).toISOString()}`
|
||||
: ""
|
||||
}]\n`
|
||||
: "";
|
||||
const buildReplySupplementalLines = (params: { body?: string }) => {
|
||||
const lines: string[] = [];
|
||||
const forwardAnnotation = replyForwardAnnotation.trimEnd();
|
||||
if (forwardAnnotation) {
|
||||
lines.push(forwardAnnotation);
|
||||
}
|
||||
if (params.body) {
|
||||
lines.push(params.body);
|
||||
}
|
||||
return lines.length > 0 ? `\n${lines.join("\n")}` : "";
|
||||
};
|
||||
const replySuffix = visibleReplyTarget
|
||||
? visibleReplyTarget.kind === "quote"
|
||||
? `\n\n[Quoting ${visibleReplyTarget.sender}${
|
||||
visibleReplyTarget.id ? ` id:${visibleReplyTarget.id}` : ""
|
||||
}]${buildReplySupplementalLines({
|
||||
body: visibleReplyTarget.body ? `"${visibleReplyTarget.body}"` : undefined,
|
||||
})}\n[/Quoting]`
|
||||
: `\n\n[Replying to ${visibleReplyTarget.sender}${
|
||||
visibleReplyTarget.id ? ` id:${visibleReplyTarget.id}` : ""
|
||||
}]${buildReplySupplementalLines({
|
||||
body: visibleReplyTarget.body,
|
||||
})}\n[/Replying]`
|
||||
: "";
|
||||
const forwardPrefix = visibleForwardOrigin
|
||||
? `[Forwarded from ${visibleForwardOrigin.from}${
|
||||
visibleForwardOrigin.date
|
||||
? ` at ${new Date(visibleForwardOrigin.date * 1000).toISOString()}`
|
||||
: ""
|
||||
}]\n`
|
||||
: "";
|
||||
const groupLabel = isGroup ? buildGroupLabel(msg, chatId, resolvedThreadId) : undefined;
|
||||
const senderName = buildSenderName(msg);
|
||||
const conversationLabel = isGroup
|
||||
? (groupLabel ?? `group:${chatId}`)
|
||||
: buildSenderLabel(msg, senderId || chatId);
|
||||
const sessionRuntime = await loadTelegramMessageContextSessionRuntime(sessionRuntimeOverride);
|
||||
const storePath = await resolveTelegramMessageContextStorePath({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
sessionRuntime: sessionRuntimeOverride,
|
||||
});
|
||||
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
|
||||
const previousTimestamp = sessionRuntime.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = formatInboundEnvelope({
|
||||
channel: "Telegram",
|
||||
from: conversationLabel,
|
||||
timestamp: msg.date ? msg.date * 1000 : undefined,
|
||||
body: `${forwardPrefix}${bodyText}${replySuffix}`,
|
||||
chatType: isGroup ? "group" : "direct",
|
||||
sender: {
|
||||
name: senderName,
|
||||
username: senderUsername || undefined,
|
||||
id: senderId || undefined,
|
||||
},
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
});
|
||||
let combinedBody = body;
|
||||
if (isGroup && historyKey && historyLimit > 0) {
|
||||
combinedBody = buildPendingHistoryContextFromMap({
|
||||
historyMap: groupHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (entry) =>
|
||||
formatInboundEnvelope({
|
||||
channel: "Telegram",
|
||||
from: groupLabel ?? `group:${chatId}`,
|
||||
timestamp: entry.timestamp,
|
||||
body: `${entry.body} [id:${entry.messageId ?? "unknown"} chat:${chatId}]`,
|
||||
chatType: "group",
|
||||
senderLabel: entry.sender,
|
||||
envelope: envelopeOptions,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
});
|
||||
const commandBody = normalizeCommandBody(rawBody, {
|
||||
botUsername: normalizeOptionalLowercaseString(primaryCtx.me?.username),
|
||||
});
|
||||
const inboundHistory =
|
||||
isGroup && historyKey && historyLimit > 0
|
||||
? (groupHistories.get(historyKey) ?? []).map((entry) => ({
|
||||
sender: entry.sender,
|
||||
body: entry.body,
|
||||
timestamp: entry.timestamp,
|
||||
}))
|
||||
: undefined;
|
||||
const currentMediaForContext = stickerCacheHit ? [] : allMedia;
|
||||
const contextMedia = [...currentMediaForContext, ...replyMedia];
|
||||
const ctxPayload = sessionRuntime.finalizeInboundContext({
|
||||
Body: combinedBody,
|
||||
BodyForAgent: bodyText,
|
||||
InboundHistory: inboundHistory,
|
||||
RawBody: rawBody,
|
||||
CommandBody: commandBody,
|
||||
From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`,
|
||||
To: `telegram:${chatId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
ConversationLabel: conversationLabel,
|
||||
GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined,
|
||||
GroupSystemPrompt: isGroup || (!isGroup && groupConfig) ? groupSystemPrompt : undefined,
|
||||
SenderName: senderName,
|
||||
SenderId: senderId || undefined,
|
||||
SenderUsername: senderUsername || undefined,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
BotUsername: primaryCtx.me?.username ?? undefined,
|
||||
MessageSid: options?.messageIdOverride ?? String(msg.message_id),
|
||||
ReplyToId: visibleReplyTarget?.id,
|
||||
ReplyToBody: visibleReplyTarget?.body,
|
||||
ReplyToSender: visibleReplyTarget?.sender,
|
||||
ReplyToIsQuote: visibleReplyTarget?.kind === "quote" ? true : undefined,
|
||||
ReplyToForwardedFrom: visibleReplyTarget?.forwardedFrom?.from,
|
||||
ReplyToForwardedFromType: visibleReplyTarget?.forwardedFrom?.fromType,
|
||||
ReplyToForwardedFromId: visibleReplyTarget?.forwardedFrom?.fromId,
|
||||
ReplyToForwardedFromUsername: visibleReplyTarget?.forwardedFrom?.fromUsername,
|
||||
ReplyToForwardedFromTitle: visibleReplyTarget?.forwardedFrom?.fromTitle,
|
||||
ReplyToForwardedDate: visibleReplyTarget?.forwardedFrom?.date
|
||||
? visibleReplyTarget.forwardedFrom.date * 1000
|
||||
: undefined,
|
||||
ForwardedFrom: visibleForwardOrigin?.from,
|
||||
ForwardedFromType: visibleForwardOrigin?.fromType,
|
||||
ForwardedFromId: visibleForwardOrigin?.fromId,
|
||||
ForwardedFromUsername: visibleForwardOrigin?.fromUsername,
|
||||
ForwardedFromTitle: visibleForwardOrigin?.fromTitle,
|
||||
ForwardedFromSignature: visibleForwardOrigin?.fromSignature,
|
||||
ForwardedFromChatType: visibleForwardOrigin?.fromChatType,
|
||||
ForwardedFromMessageId: visibleForwardOrigin?.fromMessageId,
|
||||
ForwardedDate: visibleForwardOrigin?.date ? visibleForwardOrigin.date * 1000 : undefined,
|
||||
Timestamp: msg.date ? msg.date * 1000 : undefined,
|
||||
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
|
||||
MediaPath: contextMedia.length > 0 ? contextMedia[0]?.path : undefined,
|
||||
MediaType: contextMedia.length > 0 ? contextMedia[0]?.contentType : undefined,
|
||||
MediaUrl: contextMedia.length > 0 ? contextMedia[0]?.path : undefined,
|
||||
MediaPaths: contextMedia.length > 0 ? contextMedia.map((m) => m.path) : undefined,
|
||||
MediaUrls: contextMedia.length > 0 ? contextMedia.map((m) => m.path) : undefined,
|
||||
MediaTypes:
|
||||
contextMedia.length > 0
|
||||
? (contextMedia.map((m) => m.contentType).filter(Boolean) as string[])
|
||||
: undefined,
|
||||
Sticker: allMedia[0]?.stickerMetadata,
|
||||
StickerMediaIncluded: allMedia[0]?.stickerMetadata ? !stickerCacheHit : undefined,
|
||||
...(locationData ? toLocationContext(locationData) : undefined),
|
||||
CommandAuthorized: commandAuthorized,
|
||||
CommandSource: options?.commandSource,
|
||||
MessageThreadId: threadSpec.id,
|
||||
IsForum: isForum,
|
||||
TopicName: isForum && topicName ? topicName : undefined,
|
||||
OriginatingChannel: "telegram" as const,
|
||||
OriginatingTo: `telegram:${chatId}`,
|
||||
});
|
||||
|
||||
const pinnedMainDmOwner = !isGroup
|
||||
? sessionRuntime.resolvePinnedMainDmOwnerFromAllowlist({
|
||||
dmScope: cfg.session?.dmScope,
|
||||
allowFrom: dmAllowFrom,
|
||||
normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0],
|
||||
})
|
||||
: null;
|
||||
const updateLastRouteSessionKey = sessionRuntime.resolveInboundLastRouteSessionKey({
|
||||
route,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const shouldPersistGroupLastRouteThread = isGroup && route.matchedBy !== "binding.channel";
|
||||
const updateLastRouteThreadId = isGroup
|
||||
? shouldPersistGroupLastRouteThread && resolvedThreadId != null
|
||||
? String(resolvedThreadId)
|
||||
: undefined
|
||||
: dmThreadId != null
|
||||
? String(dmThreadId)
|
||||
: undefined;
|
||||
|
||||
await sessionRuntime.recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
updateLastRoute:
|
||||
!isGroup || updateLastRouteThreadId != null
|
||||
? {
|
||||
sessionKey: updateLastRouteSessionKey,
|
||||
channel: "telegram",
|
||||
to:
|
||||
isGroup && updateLastRouteThreadId != null
|
||||
? `telegram:${chatId}:topic:${updateLastRouteThreadId}`
|
||||
: `telegram:${chatId}`,
|
||||
accountId: route.accountId,
|
||||
threadId: updateLastRouteThreadId,
|
||||
mainDmOwnerPin:
|
||||
!isGroup &&
|
||||
updateLastRouteSessionKey === route.mainSessionKey &&
|
||||
pinnedMainDmOwner &&
|
||||
senderId
|
||||
? {
|
||||
ownerRecipient: pinnedMainDmOwner,
|
||||
senderRecipient: senderId,
|
||||
onSkip: ({ ownerRecipient, senderRecipient }) => {
|
||||
logVerbose(
|
||||
`telegram: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
|
||||
);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
onRecordError: (err) => {
|
||||
logVerbose(`telegram: failed updating session meta: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
if (visibleReplyTarget && shouldLogVerbose()) {
|
||||
const preview = (visibleReplyTarget.body ?? "").replace(/\s+/g, " ").slice(0, 120);
|
||||
logVerbose(
|
||||
`telegram reply-context: replyToId=${visibleReplyTarget.id} replyToSender=${visibleReplyTarget.sender} replyToBody="${preview}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (visibleForwardOrigin && shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`telegram forward-context: forwardedFrom="${visibleForwardOrigin.from}" type=${visibleForwardOrigin.fromType}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
const preview = body.slice(0, 200).replace(/\n/g, "\\n");
|
||||
const mediaInfo = allMedia.length > 1 ? ` mediaCount=${allMedia.length}` : "";
|
||||
const topicInfo = resolvedThreadId != null ? ` topic=${resolvedThreadId}` : "";
|
||||
logVerbose(
|
||||
`telegram inbound: chatId=${chatId} from=${ctxPayload.From} len=${body.length}${mediaInfo}${topicInfo} preview="${preview}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ctxPayload,
|
||||
skillFilter,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
|
||||
|
||||
const internalHookMocks = vi.hoisted(() => ({
|
||||
createInternalHookEvent: vi.fn(
|
||||
(type: string, action: string, sessionKey: string, context: Record<string, unknown>) => ({
|
||||
type,
|
||||
action,
|
||||
sessionKey,
|
||||
context,
|
||||
timestamp: new Date(),
|
||||
messages: [],
|
||||
}),
|
||||
),
|
||||
triggerInternalHook: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/hook-runtime", () => {
|
||||
return {
|
||||
createInternalHookEvent: internalHookMocks.createInternalHookEvent,
|
||||
fireAndForgetHook: (task: Promise<unknown>) => void task,
|
||||
toInternalMessageReceivedContext: (context: Record<string, unknown>) => ({
|
||||
...context,
|
||||
metadata: { to: context.to },
|
||||
}),
|
||||
triggerInternalHook: internalHookMocks.triggerInternalHook,
|
||||
};
|
||||
});
|
||||
|
||||
function makeGroupMessage(text: string) {
|
||||
return {
|
||||
message_id: 42,
|
||||
chat: { id: -1001234567890, type: "supergroup" as const, title: "Test Group" },
|
||||
date: 1_700_000_000,
|
||||
text,
|
||||
from: { id: 99, first_name: "Alice", username: "alice" },
|
||||
};
|
||||
}
|
||||
|
||||
describe("telegram mention-skip silent ingest", () => {
|
||||
it("emits internal message:received when ingest is enabled", async () => {
|
||||
internalHookMocks.createInternalHookEvent.mockClear();
|
||||
internalHookMocks.triggerInternalHook.mockClear();
|
||||
|
||||
const result = await buildTelegramMessageContextForTest({
|
||||
message: makeGroupMessage("hello without mention"),
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/sonnet-4.6",
|
||||
workspace: "/tmp/openclaw",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
groups: {
|
||||
"*": {
|
||||
requireMention: true,
|
||||
ingest: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
groupChat: {
|
||||
mentionPatterns: ["@bot"],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
resolveGroupRequireMention: () => true,
|
||||
resolveTelegramGroupConfig: () => ({
|
||||
groupConfig: {
|
||||
requireMention: true,
|
||||
ingest: true,
|
||||
},
|
||||
topicConfig: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledWith(
|
||||
"message",
|
||||
"received",
|
||||
expect.stringContaining("telegram"),
|
||||
expect.objectContaining({
|
||||
channelId: "telegram",
|
||||
content: "hello without mention",
|
||||
}),
|
||||
);
|
||||
expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses wildcard ingest when a specific group override omits ingest", async () => {
|
||||
internalHookMocks.createInternalHookEvent.mockClear();
|
||||
internalHookMocks.triggerInternalHook.mockClear();
|
||||
|
||||
const result = await buildTelegramMessageContextForTest({
|
||||
message: makeGroupMessage("hello without mention"),
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/sonnet-4.6",
|
||||
workspace: "/tmp/openclaw",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
groups: {
|
||||
"*": {
|
||||
requireMention: true,
|
||||
ingest: true,
|
||||
},
|
||||
"-1001234567890": {
|
||||
requireMention: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
groupChat: {
|
||||
mentionPatterns: ["@bot"],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
resolveGroupRequireMention: () => true,
|
||||
resolveTelegramGroupConfig: () => ({
|
||||
groupConfig: {
|
||||
requireMention: true,
|
||||
},
|
||||
topicConfig: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledWith(
|
||||
"message",
|
||||
"received",
|
||||
expect.stringContaining("telegram"),
|
||||
expect.objectContaining({
|
||||
channelId: "telegram",
|
||||
content: "hello without mention",
|
||||
}),
|
||||
);
|
||||
expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import type { BuildTelegramMessageContextParams, TelegramMediaRef } from "./bot-message-context.js";
|
||||
|
||||
export const baseTelegramMessageContextConfig = {
|
||||
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
|
||||
channels: { telegram: {} },
|
||||
messages: { groupChat: { mentionPatterns: [] } },
|
||||
} as never;
|
||||
|
||||
type TelegramTestSessionRuntime = NonNullable<BuildTelegramMessageContextParams["sessionRuntime"]>;
|
||||
const finalizeInboundContextForTest = ((ctx) => {
|
||||
const next = ctx as Record<string, unknown>;
|
||||
const body = typeof next.Body === "string" ? next.Body : "";
|
||||
next.Body = body;
|
||||
next.BodyForAgent =
|
||||
typeof next.BodyForAgent === "string"
|
||||
? next.BodyForAgent
|
||||
: typeof next.RawBody === "string"
|
||||
? next.RawBody
|
||||
: body;
|
||||
next.BodyForCommands =
|
||||
typeof next.BodyForCommands === "string"
|
||||
? next.BodyForCommands
|
||||
: typeof next.CommandBody === "string"
|
||||
? next.CommandBody
|
||||
: typeof next.RawBody === "string"
|
||||
? next.RawBody
|
||||
: body;
|
||||
next.CommandAuthorized = Boolean(next.CommandAuthorized);
|
||||
return next;
|
||||
}) as NonNullable<TelegramTestSessionRuntime["finalizeInboundContext"]>;
|
||||
|
||||
type BuildTelegramMessageContextForTestParams = {
|
||||
message: Record<string, unknown>;
|
||||
allMedia?: TelegramMediaRef[];
|
||||
options?: BuildTelegramMessageContextParams["options"];
|
||||
cfg?: Record<string, unknown>;
|
||||
accountId?: string;
|
||||
runtime?: BuildTelegramMessageContextParams["runtime"];
|
||||
sessionRuntime?: BuildTelegramMessageContextParams["sessionRuntime"] | null;
|
||||
resolveGroupActivation?: BuildTelegramMessageContextParams["resolveGroupActivation"];
|
||||
resolveGroupRequireMention?: BuildTelegramMessageContextParams["resolveGroupRequireMention"];
|
||||
resolveTelegramGroupConfig?: BuildTelegramMessageContextParams["resolveTelegramGroupConfig"];
|
||||
};
|
||||
|
||||
const telegramMessageContextSessionRuntimeForTest = {
|
||||
finalizeInboundContext: finalizeInboundContextForTest,
|
||||
readSessionUpdatedAt: () => undefined,
|
||||
recordInboundSession: async () => undefined,
|
||||
resolveInboundLastRouteSessionKey: ({ route, sessionKey }) =>
|
||||
route.lastRoutePolicy === "main" ? route.mainSessionKey : sessionKey,
|
||||
resolvePinnedMainDmOwnerFromAllowlist: () => null,
|
||||
resolveStorePath: () => "/tmp/openclaw/session-store.json",
|
||||
} satisfies NonNullable<BuildTelegramMessageContextParams["sessionRuntime"]>;
|
||||
|
||||
export async function buildTelegramMessageContextForTest(
|
||||
params: BuildTelegramMessageContextForTestParams,
|
||||
): Promise<
|
||||
Awaited<ReturnType<typeof import("./bot-message-context.js").buildTelegramMessageContext>>
|
||||
> {
|
||||
const { vi } = await loadVitestModule();
|
||||
const buildTelegramMessageContext = await loadBuildTelegramMessageContext();
|
||||
const sessionRuntime =
|
||||
params.sessionRuntime === null
|
||||
? undefined
|
||||
: {
|
||||
...telegramMessageContextSessionRuntimeForTest,
|
||||
...params.sessionRuntime,
|
||||
};
|
||||
return await buildTelegramMessageContext({
|
||||
primaryCtx: {
|
||||
message: {
|
||||
message_id: 1,
|
||||
date: 1_700_000_000,
|
||||
text: "hello",
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
...params.message,
|
||||
},
|
||||
me: { id: 7, username: "bot" },
|
||||
} as never,
|
||||
allMedia: params.allMedia ?? [],
|
||||
storeAllowFrom: [],
|
||||
options: params.options ?? {},
|
||||
bot: {
|
||||
api: {
|
||||
sendChatAction: vi.fn(),
|
||||
setMessageReaction: vi.fn(),
|
||||
},
|
||||
} as never,
|
||||
cfg: (params.cfg ?? baseTelegramMessageContextConfig) as never,
|
||||
loadFreshConfig: () => (params.cfg ?? baseTelegramMessageContextConfig) as never,
|
||||
runtime: {
|
||||
recordChannelActivity: () => undefined,
|
||||
...params.runtime,
|
||||
},
|
||||
sessionRuntime,
|
||||
account: { accountId: params.accountId ?? "default" } as never,
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
dmPolicy: "open",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
ackReactionScope: "off",
|
||||
logger: { info: vi.fn() },
|
||||
resolveGroupActivation: params.resolveGroupActivation ?? (() => undefined),
|
||||
resolveGroupRequireMention: params.resolveGroupRequireMention ?? (() => false),
|
||||
resolveTelegramGroupConfig:
|
||||
params.resolveTelegramGroupConfig ??
|
||||
(() => ({
|
||||
groupConfig: { requireMention: false },
|
||||
topicConfig: undefined,
|
||||
})),
|
||||
sendChatActionHandler: { sendChatAction: vi.fn() } as never,
|
||||
});
|
||||
}
|
||||
|
||||
let buildTelegramMessageContextLoader:
|
||||
| typeof import("./bot-message-context.js").buildTelegramMessageContext
|
||||
| undefined;
|
||||
let vitestModuleLoader: Promise<typeof import("vitest")> | undefined;
|
||||
let messageContextMocksInstalled = false;
|
||||
|
||||
async function loadBuildTelegramMessageContext() {
|
||||
await installMessageContextTestMocks();
|
||||
if (!buildTelegramMessageContextLoader) {
|
||||
({ buildTelegramMessageContext: buildTelegramMessageContextLoader } =
|
||||
await import("./bot-message-context.js"));
|
||||
}
|
||||
return buildTelegramMessageContextLoader;
|
||||
}
|
||||
|
||||
async function loadVitestModule() {
|
||||
vitestModuleLoader ??= import("vitest");
|
||||
return await vitestModuleLoader;
|
||||
}
|
||||
|
||||
async function installMessageContextTestMocks() {
|
||||
if (messageContextMocksInstalled) {
|
||||
return;
|
||||
}
|
||||
messageContextMocksInstalled = true;
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { telegramRouteTestSessionRuntime } from "./bot-message-context.route-test-support.js";
|
||||
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
|
||||
|
||||
const recordInboundSessionMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
const resolveTelegramConversationRouteMock = vi.hoisted(() => vi.fn());
|
||||
type TelegramTestSessionRuntime = NonNullable<
|
||||
import("./bot-message-context.types.js").BuildTelegramMessageContextParams["sessionRuntime"]
|
||||
>;
|
||||
const recordInboundSessionForThreadBindingTest: NonNullable<
|
||||
TelegramTestSessionRuntime["recordInboundSession"]
|
||||
> = async (params) => {
|
||||
await recordInboundSessionMock(params);
|
||||
};
|
||||
|
||||
vi.mock("./conversation-route.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./conversation-route.js")>("./conversation-route.js");
|
||||
return {
|
||||
...actual,
|
||||
resolveTelegramConversationRoute: (...args: unknown[]) =>
|
||||
resolveTelegramConversationRouteMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
const threadBindingSessionRuntime = {
|
||||
...telegramRouteTestSessionRuntime,
|
||||
recordInboundSession: recordInboundSessionForThreadBindingTest,
|
||||
} satisfies TelegramTestSessionRuntime;
|
||||
|
||||
function createBoundRoute(params: { accountId: string; sessionKey: string; agentId: string }) {
|
||||
return {
|
||||
configuredBinding: null,
|
||||
configuredBindingSessionKey: "",
|
||||
route: {
|
||||
accountId: params.accountId,
|
||||
agentId: params.agentId,
|
||||
channel: "telegram",
|
||||
sessionKey: params.sessionKey,
|
||||
mainSessionKey: `agent:${params.agentId}:main`,
|
||||
matchedBy: "binding.channel",
|
||||
lastRoutePolicy: "bound",
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
describe("buildTelegramMessageContext thread binding override", () => {
|
||||
beforeEach(() => {
|
||||
recordInboundSessionMock.mockClear();
|
||||
resolveTelegramConversationRouteMock.mockReset();
|
||||
});
|
||||
|
||||
it("passes forum topic messages through the route seam and uses the bound session", async () => {
|
||||
resolveTelegramConversationRouteMock.mockReturnValue(
|
||||
createBoundRoute({
|
||||
accountId: "default",
|
||||
sessionKey: "agent:codex-acp:session-1",
|
||||
agentId: "codex-acp",
|
||||
}),
|
||||
);
|
||||
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
sessionRuntime: threadBindingSessionRuntime,
|
||||
message: {
|
||||
message_id: 1,
|
||||
chat: { id: -100200300, type: "supergroup", is_forum: true },
|
||||
message_thread_id: 77,
|
||||
date: 1_700_000_000,
|
||||
text: "hello",
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
},
|
||||
options: { forceWasMentioned: true },
|
||||
resolveGroupActivation: () => true,
|
||||
});
|
||||
|
||||
expect(resolveTelegramConversationRouteMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
chatId: -100200300,
|
||||
isGroup: true,
|
||||
resolvedThreadId: 77,
|
||||
replyThreadId: 77,
|
||||
senderId: "42",
|
||||
}),
|
||||
);
|
||||
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:codex-acp:session-1");
|
||||
expect(recordInboundSessionMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
updateLastRoute: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats named-account bound conversations as explicit route matches", async () => {
|
||||
resolveTelegramConversationRouteMock.mockReturnValue(
|
||||
createBoundRoute({
|
||||
accountId: "work",
|
||||
sessionKey: "agent:codex-acp:session-2",
|
||||
agentId: "codex-acp",
|
||||
}),
|
||||
);
|
||||
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
accountId: "work",
|
||||
sessionRuntime: threadBindingSessionRuntime,
|
||||
message: {
|
||||
message_id: 1,
|
||||
chat: { id: -100200300, type: "supergroup", is_forum: true },
|
||||
message_thread_id: 77,
|
||||
date: 1_700_000_000,
|
||||
text: "hello",
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
},
|
||||
options: { forceWasMentioned: true },
|
||||
resolveGroupActivation: () => true,
|
||||
});
|
||||
|
||||
expect(resolveTelegramConversationRouteMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: "work",
|
||||
chatId: -100200300,
|
||||
isGroup: true,
|
||||
resolvedThreadId: 77,
|
||||
replyThreadId: 77,
|
||||
senderId: "42",
|
||||
}),
|
||||
);
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.route.accountId).toBe("work");
|
||||
expect(ctx?.route.matchedBy).toBe("binding.channel");
|
||||
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:codex-acp:session-2");
|
||||
});
|
||||
|
||||
it("passes dm messages through the route seam and uses the bound session", async () => {
|
||||
resolveTelegramConversationRouteMock.mockReturnValue(
|
||||
createBoundRoute({
|
||||
accountId: "default",
|
||||
sessionKey: "agent:codex-acp:session-dm",
|
||||
agentId: "codex-acp",
|
||||
}),
|
||||
);
|
||||
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
sessionRuntime: threadBindingSessionRuntime,
|
||||
message: {
|
||||
message_id: 1,
|
||||
chat: { id: 1234, type: "private" },
|
||||
date: 1_700_000_000,
|
||||
text: "hello",
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveTelegramConversationRouteMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
chatId: 1234,
|
||||
isGroup: false,
|
||||
resolvedThreadId: undefined,
|
||||
replyThreadId: undefined,
|
||||
senderId: "42",
|
||||
}),
|
||||
);
|
||||
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:codex-acp:session-dm");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { defaultRouteConfig } = vi.hoisted(() => ({
|
||||
defaultRouteConfig: {
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }, { id: "zu" }, { id: "q" }, { id: "support" }],
|
||||
},
|
||||
channels: { telegram: {} },
|
||||
messages: { groupChat: { mentionPatterns: [] } },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
|
||||
"openclaw/plugin-sdk/config-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: vi.fn(() => defaultRouteConfig),
|
||||
};
|
||||
});
|
||||
|
||||
const { buildTelegramMessageContextForTest } =
|
||||
await import("./bot-message-context.test-harness.js");
|
||||
|
||||
describe("buildTelegramMessageContext per-topic agentId routing", () => {
|
||||
function buildForumMessage(threadId = 3) {
|
||||
return {
|
||||
message_id: 1,
|
||||
chat: {
|
||||
id: -1001234567890,
|
||||
type: "supergroup" as const,
|
||||
title: "Forum",
|
||||
is_forum: true,
|
||||
},
|
||||
date: 1700000000,
|
||||
text: "@bot hello",
|
||||
message_thread_id: threadId,
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
};
|
||||
}
|
||||
|
||||
async function buildForumContext(params: {
|
||||
threadId?: number;
|
||||
topicConfig?: Record<string, unknown>;
|
||||
}) {
|
||||
return await buildTelegramMessageContextForTest({
|
||||
message: buildForumMessage(params.threadId),
|
||||
options: { forceWasMentioned: true },
|
||||
resolveGroupActivation: () => true,
|
||||
resolveTelegramGroupConfig: () => ({
|
||||
groupConfig: { requireMention: false },
|
||||
...(params.topicConfig ? { topicConfig: params.topicConfig } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(loadConfig).mockReturnValue(defaultRouteConfig as never);
|
||||
});
|
||||
|
||||
it("uses group-level agent when no topic agentId is set", async () => {
|
||||
const ctx = await buildForumContext({ topicConfig: { systemPrompt: "Be nice" } });
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:3");
|
||||
});
|
||||
|
||||
it("routes to topic-specific agent when agentId is set", async () => {
|
||||
const ctx = await buildForumContext({
|
||||
topicConfig: { agentId: "zu", systemPrompt: "I am Zu" },
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.SessionKey).toContain("agent:zu:");
|
||||
expect(ctx?.ctxPayload?.SessionKey).toContain("telegram:group:-1001234567890:topic:3");
|
||||
});
|
||||
|
||||
it("different topics route to different agents", async () => {
|
||||
const buildForTopic = async (threadId: number, agentId: string) =>
|
||||
await buildForumContext({ threadId, topicConfig: { agentId } });
|
||||
|
||||
const ctxA = await buildForTopic(1, "main");
|
||||
const ctxB = await buildForTopic(3, "zu");
|
||||
const ctxC = await buildForTopic(5, "q");
|
||||
|
||||
expect(ctxA?.ctxPayload?.SessionKey).toContain("agent:main:");
|
||||
expect(ctxB?.ctxPayload?.SessionKey).toContain("agent:zu:");
|
||||
expect(ctxC?.ctxPayload?.SessionKey).toContain("agent:q:");
|
||||
|
||||
expect(ctxA?.ctxPayload?.SessionKey).not.toBe(ctxB?.ctxPayload?.SessionKey);
|
||||
expect(ctxB?.ctxPayload?.SessionKey).not.toBe(ctxC?.ctxPayload?.SessionKey);
|
||||
});
|
||||
|
||||
it("ignores whitespace-only agentId and uses group-level agent", async () => {
|
||||
const ctx = await buildForumContext({
|
||||
topicConfig: { agentId: " ", systemPrompt: "Be nice" },
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.SessionKey).toContain("agent:main:");
|
||||
});
|
||||
|
||||
it("preserves an unknown topic agentId in the session key", async () => {
|
||||
vi.mocked(loadConfig).mockReturnValue({
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }, { id: "zu" }],
|
||||
},
|
||||
channels: { telegram: {} },
|
||||
messages: { groupChat: { mentionPatterns: [] } },
|
||||
} as never);
|
||||
|
||||
const ctx = await buildForumContext({ topicConfig: { agentId: "ghost" } });
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.SessionKey).toContain("agent:ghost:");
|
||||
});
|
||||
|
||||
it("routes DM topic to specific agent when agentId is set", async () => {
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
message: {
|
||||
message_id: 1,
|
||||
chat: {
|
||||
id: 123456789,
|
||||
type: "private",
|
||||
},
|
||||
date: 1700000000,
|
||||
text: "@bot hello",
|
||||
message_thread_id: 99,
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
},
|
||||
options: { forceWasMentioned: true },
|
||||
resolveGroupActivation: () => true,
|
||||
resolveTelegramGroupConfig: () => ({
|
||||
groupConfig: { requireMention: false },
|
||||
topicConfig: { agentId: "support", systemPrompt: "I am support" },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.SessionKey).toContain("agent:support:");
|
||||
});
|
||||
});
|
||||
610
openclaw/extensions/telegram/src/bot-message-context.ts
Normal file
610
openclaw/extensions/telegram/src/bot-message-context.ts
Normal file
|
|
@ -0,0 +1,610 @@
|
|||
import type { ReactionTypeEmoji } from "@grammyjs/types";
|
||||
import {
|
||||
resolveAckReaction,
|
||||
shouldAckReaction as shouldAckReactionGate,
|
||||
} from "openclaw/plugin-sdk/channel-feedback";
|
||||
import { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import type { TelegramDirectConfig, TelegramGroupConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing";
|
||||
import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
||||
import { firstDefined, normalizeAllowFrom, normalizeDmAllowFromWithStore } from "./bot-access.js";
|
||||
import { resolveTelegramInboundBody } from "./bot-message-context.body.js";
|
||||
import {
|
||||
buildTelegramInboundContextPayload,
|
||||
resolveTelegramMessageContextStorePath,
|
||||
} from "./bot-message-context.session.js";
|
||||
import type { BuildTelegramMessageContextParams } from "./bot-message-context.types.js";
|
||||
import {
|
||||
buildTypingThreadParams,
|
||||
extractTelegramForumFlag,
|
||||
resolveTelegramForumFlag,
|
||||
resolveTelegramThreadSpec,
|
||||
} from "./bot/helpers.js";
|
||||
import type { TelegramGetChat } from "./bot/types.js";
|
||||
import {
|
||||
resolveTelegramConversationBaseSessionKey,
|
||||
resolveTelegramConversationRoute,
|
||||
} from "./conversation-route.js";
|
||||
import { enforceTelegramDmAccess } from "./dm-access.js";
|
||||
import { evaluateTelegramGroupBaseAccess } from "./group-access.js";
|
||||
import {
|
||||
buildTelegramStatusReactionVariants,
|
||||
type TelegramReactionEmoji,
|
||||
isTelegramSupportedReactionEmoji,
|
||||
resolveTelegramAllowedEmojiReactions,
|
||||
resolveTelegramReactionVariant,
|
||||
resolveTelegramStatusReactionEmojis,
|
||||
} from "./status-reaction-variants.js";
|
||||
import { getTopicName, resolveTopicNameCachePath, updateTopicName } from "./topic-name-cache.js";
|
||||
|
||||
export type {
|
||||
BuildTelegramMessageContextParams,
|
||||
TelegramMediaRef,
|
||||
} from "./bot-message-context.types.js";
|
||||
|
||||
type TelegramMessageContextRuntime = typeof import("./bot-message-context.runtime.js");
|
||||
|
||||
let telegramMessageContextRuntimePromise: Promise<TelegramMessageContextRuntime> | undefined;
|
||||
|
||||
async function loadTelegramMessageContextRuntime() {
|
||||
telegramMessageContextRuntimePromise ??= import("./bot-message-context.runtime.js");
|
||||
return await telegramMessageContextRuntimePromise;
|
||||
}
|
||||
|
||||
type TelegramMessageContextPayload = Awaited<ReturnType<typeof buildTelegramInboundContextPayload>>;
|
||||
type TelegramReactionApi = (
|
||||
chatId: BuildTelegramMessageContextParams["primaryCtx"]["message"]["chat"]["id"],
|
||||
messageId: number,
|
||||
reactions: Array<{ type: "emoji"; emoji: ReactionTypeEmoji["emoji"] }>,
|
||||
) => Promise<unknown>;
|
||||
type TelegramStatusReactionController = {
|
||||
setQueued: () => void | Promise<void>;
|
||||
setThinking: () => void | Promise<void>;
|
||||
setTool: (name: string) => void | Promise<void>;
|
||||
setCompacting: () => void | Promise<void>;
|
||||
cancelPending: () => void;
|
||||
setError: () => void | Promise<void>;
|
||||
setDone: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export type TelegramMessageContext = {
|
||||
ctxPayload: TelegramMessageContextPayload["ctxPayload"];
|
||||
primaryCtx: BuildTelegramMessageContextParams["primaryCtx"];
|
||||
msg: BuildTelegramMessageContextParams["primaryCtx"]["message"];
|
||||
chatId: BuildTelegramMessageContextParams["primaryCtx"]["message"]["chat"]["id"];
|
||||
isGroup: boolean;
|
||||
groupConfig?: ReturnType<
|
||||
BuildTelegramMessageContextParams["resolveTelegramGroupConfig"]
|
||||
>["groupConfig"];
|
||||
topicConfig?: ReturnType<
|
||||
BuildTelegramMessageContextParams["resolveTelegramGroupConfig"]
|
||||
>["topicConfig"];
|
||||
resolvedThreadId?: number;
|
||||
threadSpec: ReturnType<typeof resolveTelegramThreadSpec>;
|
||||
replyThreadId?: number;
|
||||
isForum: boolean;
|
||||
historyKey?: string;
|
||||
historyLimit: BuildTelegramMessageContextParams["historyLimit"];
|
||||
groupHistories: BuildTelegramMessageContextParams["groupHistories"];
|
||||
route: ReturnType<typeof resolveTelegramConversationRoute>["route"];
|
||||
skillFilter: TelegramMessageContextPayload["skillFilter"];
|
||||
sendTyping: () => Promise<void>;
|
||||
sendRecordVoice: () => Promise<void>;
|
||||
ackReactionPromise: Promise<boolean> | null;
|
||||
reactionApi: TelegramReactionApi | null;
|
||||
removeAckAfterReply: boolean;
|
||||
statusReactionController: TelegramStatusReactionController | null;
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
export const buildTelegramMessageContext = async ({
|
||||
primaryCtx,
|
||||
allMedia,
|
||||
replyMedia = [],
|
||||
storeAllowFrom,
|
||||
options,
|
||||
bot,
|
||||
cfg,
|
||||
account,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
dmPolicy,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
ackReactionScope,
|
||||
logger,
|
||||
resolveGroupActivation,
|
||||
resolveGroupRequireMention,
|
||||
resolveTelegramGroupConfig,
|
||||
loadFreshConfig,
|
||||
runtime,
|
||||
sessionRuntime,
|
||||
upsertPairingRequest,
|
||||
sendChatActionHandler,
|
||||
}: BuildTelegramMessageContextParams): Promise<TelegramMessageContext | null> => {
|
||||
const msg = primaryCtx.message;
|
||||
const chatId = msg.chat.id;
|
||||
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
||||
const senderId = msg.from?.id ? String(msg.from.id) : "";
|
||||
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
|
||||
const reactionApi =
|
||||
typeof bot.api.setMessageReaction === "function"
|
||||
? bot.api.setMessageReaction.bind(bot.api)
|
||||
: null;
|
||||
const getChatApi =
|
||||
typeof bot.api.getChat === "function"
|
||||
? (bot.api.getChat.bind(bot.api) as TelegramGetChat)
|
||||
: undefined;
|
||||
const isForum = await resolveTelegramForumFlag({
|
||||
chatId,
|
||||
chatType: msg.chat.type,
|
||||
isGroup,
|
||||
isForum: extractTelegramForumFlag(msg.chat),
|
||||
getChat: getChatApi,
|
||||
});
|
||||
const threadSpec = resolveTelegramThreadSpec({
|
||||
isGroup,
|
||||
isForum,
|
||||
messageThreadId,
|
||||
});
|
||||
const resolvedThreadId = threadSpec.scope === "forum" ? threadSpec.id : undefined;
|
||||
const replyThreadId = threadSpec.id;
|
||||
const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined;
|
||||
const topicNameCachePath = resolveTopicNameCachePath(
|
||||
await resolveTelegramMessageContextStorePath({
|
||||
cfg,
|
||||
agentId: account.accountId,
|
||||
sessionRuntime,
|
||||
}),
|
||||
);
|
||||
let topicName: string | undefined;
|
||||
if (isForum && resolvedThreadId != null) {
|
||||
const ftCreated = msg.forum_topic_created;
|
||||
const ftEdited = msg.forum_topic_edited;
|
||||
const ftClosed = msg.forum_topic_closed;
|
||||
const ftReopened = msg.forum_topic_reopened;
|
||||
const topicPatch = ftCreated?.name
|
||||
? {
|
||||
name: ftCreated.name,
|
||||
iconColor: ftCreated.icon_color,
|
||||
iconCustomEmojiId: ftCreated.icon_custom_emoji_id,
|
||||
closed: false,
|
||||
}
|
||||
: ftEdited?.name
|
||||
? {
|
||||
name: ftEdited.name,
|
||||
iconCustomEmojiId: ftEdited.icon_custom_emoji_id,
|
||||
}
|
||||
: ftClosed
|
||||
? { closed: true }
|
||||
: ftReopened
|
||||
? { closed: false }
|
||||
: undefined;
|
||||
|
||||
if (topicPatch) {
|
||||
updateTopicName(chatId, resolvedThreadId, topicPatch, topicNameCachePath);
|
||||
}
|
||||
|
||||
topicName = getTopicName(chatId, resolvedThreadId, topicNameCachePath);
|
||||
if (!topicName) {
|
||||
const replyFtCreated = msg.reply_to_message?.forum_topic_created;
|
||||
if (replyFtCreated?.name) {
|
||||
updateTopicName(
|
||||
chatId,
|
||||
resolvedThreadId,
|
||||
{
|
||||
name: replyFtCreated.name,
|
||||
iconColor: replyFtCreated.icon_color,
|
||||
iconCustomEmojiId: replyFtCreated.icon_custom_emoji_id,
|
||||
},
|
||||
topicNameCachePath,
|
||||
);
|
||||
topicName = replyFtCreated.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const threadIdForConfig = resolvedThreadId ?? dmThreadId;
|
||||
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, threadIdForConfig);
|
||||
const directConfig = !isGroup ? (groupConfig as TelegramDirectConfig | undefined) : undefined;
|
||||
const telegramGroupConfig = isGroup
|
||||
? (groupConfig as TelegramGroupConfig | undefined)
|
||||
: undefined;
|
||||
// Use direct config dmPolicy override if available for DMs
|
||||
const effectiveDmPolicy =
|
||||
!isGroup && groupConfig && "dmPolicy" in groupConfig
|
||||
? (groupConfig.dmPolicy ?? dmPolicy)
|
||||
: dmPolicy;
|
||||
// Fresh config for bindings lookup; other routing inputs are payload-derived.
|
||||
const freshCfg =
|
||||
loadFreshConfig?.() ??
|
||||
(runtime?.loadConfig ?? (await loadTelegramMessageContextRuntime()).loadConfig)();
|
||||
let { route, configuredBinding, configuredBindingSessionKey } = resolveTelegramConversationRoute({
|
||||
cfg: freshCfg,
|
||||
accountId: account.accountId,
|
||||
chatId,
|
||||
isGroup,
|
||||
resolvedThreadId,
|
||||
replyThreadId,
|
||||
senderId,
|
||||
topicAgentId: topicConfig?.agentId,
|
||||
});
|
||||
const requiresExplicitAccountBinding = (
|
||||
candidate: ReturnType<typeof resolveTelegramConversationRoute>["route"],
|
||||
): boolean => candidate.accountId !== DEFAULT_ACCOUNT_ID && candidate.matchedBy === "default";
|
||||
const isNamedAccountFallback = requiresExplicitAccountBinding(route);
|
||||
// Named-account groups still require an explicit binding; DMs get a
|
||||
// per-account fallback session key below to preserve isolation.
|
||||
if (isNamedAccountFallback && isGroup) {
|
||||
logInboundDrop({
|
||||
log: logVerbose,
|
||||
channel: "telegram",
|
||||
reason: "non-default account requires explicit binding",
|
||||
target: route.accountId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
// Calculate groupAllowOverride first - it's needed for both DM and group allowlist checks
|
||||
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
|
||||
// For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom
|
||||
const dmAllowFrom = groupAllowOverride ?? allowFrom;
|
||||
const effectiveDmAllow = normalizeDmAllowFromWithStore({
|
||||
allowFrom: dmAllowFrom,
|
||||
storeAllowFrom,
|
||||
dmPolicy: effectiveDmPolicy,
|
||||
});
|
||||
// Group sender checks are explicit and must not inherit DM pairing-store entries.
|
||||
const effectiveGroupAllow = normalizeAllowFrom(groupAllowOverride ?? groupAllowFrom);
|
||||
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
|
||||
const senderUsername = msg.from?.username ?? "";
|
||||
const baseAccess = evaluateTelegramGroupBaseAccess({
|
||||
isGroup,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
hasGroupAllowOverride,
|
||||
effectiveGroupAllow,
|
||||
senderId,
|
||||
senderUsername,
|
||||
enforceAllowOverride: true,
|
||||
requireSenderForAllowOverride: false,
|
||||
});
|
||||
if (!baseAccess.allowed) {
|
||||
if (baseAccess.reason === "group-disabled") {
|
||||
logVerbose(`Blocked telegram group ${chatId} (group disabled)`);
|
||||
return null;
|
||||
}
|
||||
if (baseAccess.reason === "topic-disabled") {
|
||||
logVerbose(
|
||||
`Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
logVerbose(
|
||||
isGroup
|
||||
? `Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)`
|
||||
: `Blocked telegram DM sender ${senderId || "unknown"} (DM allowFrom override)`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const requireTopic = directConfig?.requireTopic;
|
||||
const topicRequiredButMissing = !isGroup && requireTopic === true && dmThreadId == null;
|
||||
if (topicRequiredButMissing) {
|
||||
logVerbose(`Blocked telegram DM ${chatId}: requireTopic=true but no topic present`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const sendTyping = async () => {
|
||||
await withTelegramApiErrorLogging({
|
||||
operation: "sendChatAction",
|
||||
fn: () =>
|
||||
sendChatActionHandler.sendChatAction(
|
||||
chatId,
|
||||
"typing",
|
||||
buildTypingThreadParams(replyThreadId),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const sendRecordVoice = async () => {
|
||||
try {
|
||||
await withTelegramApiErrorLogging({
|
||||
operation: "sendChatAction",
|
||||
fn: () =>
|
||||
sendChatActionHandler.sendChatAction(
|
||||
chatId,
|
||||
"record_voice",
|
||||
buildTypingThreadParams(replyThreadId),
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(`telegram record_voice cue failed for chat ${chatId}: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
!(await enforceTelegramDmAccess({
|
||||
isGroup,
|
||||
dmPolicy: effectiveDmPolicy,
|
||||
msg,
|
||||
chatId,
|
||||
effectiveDmAllow,
|
||||
accountId: account.accountId,
|
||||
bot,
|
||||
logger,
|
||||
upsertPairingRequest,
|
||||
}))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const ensureConfiguredBindingReady = async (): Promise<boolean> => {
|
||||
if (!configuredBinding) {
|
||||
return true;
|
||||
}
|
||||
const ensureConfiguredBindingRouteReady =
|
||||
runtime?.ensureConfiguredBindingRouteReady ??
|
||||
(await loadTelegramMessageContextRuntime()).ensureConfiguredBindingRouteReady;
|
||||
const ensured = await ensureConfiguredBindingRouteReady({
|
||||
cfg: freshCfg,
|
||||
bindingResolution: configuredBinding,
|
||||
});
|
||||
if (ensured.ok) {
|
||||
logVerbose(
|
||||
`telegram: using configured ACP binding for ${configuredBinding.record.conversation.conversationId} -> ${configuredBindingSessionKey}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
logVerbose(
|
||||
`telegram: configured ACP binding unavailable for ${configuredBinding.record.conversation.conversationId}: ${ensured.error}`,
|
||||
);
|
||||
logInboundDrop({
|
||||
log: logVerbose,
|
||||
channel: "telegram",
|
||||
reason: "configured ACP binding unavailable",
|
||||
target: configuredBinding.record.conversation.conversationId,
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
||||
const baseSessionKey = resolveTelegramConversationBaseSessionKey({
|
||||
cfg: freshCfg,
|
||||
route,
|
||||
chatId,
|
||||
isGroup,
|
||||
senderId,
|
||||
});
|
||||
// DMs: use thread suffix for session isolation (works regardless of dmScope)
|
||||
const threadKeys =
|
||||
dmThreadId != null
|
||||
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${chatId}:${dmThreadId}` })
|
||||
: null;
|
||||
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
|
||||
route = {
|
||||
...route,
|
||||
sessionKey,
|
||||
lastRoutePolicy: deriveLastRoutePolicy({
|
||||
sessionKey,
|
||||
mainSessionKey: route.mainSessionKey,
|
||||
}),
|
||||
};
|
||||
// Compute requireMention after access checks and final route selection.
|
||||
const activationOverride = resolveGroupActivation({
|
||||
chatId,
|
||||
messageThreadId: resolvedThreadId,
|
||||
sessionKey: sessionKey,
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const baseRequireMention = resolveGroupRequireMention(chatId);
|
||||
const requireMention = firstDefined(
|
||||
activationOverride,
|
||||
topicConfig?.requireMention,
|
||||
telegramGroupConfig?.requireMention,
|
||||
baseRequireMention,
|
||||
);
|
||||
|
||||
const recordChannelActivity =
|
||||
runtime?.recordChannelActivity ??
|
||||
(await loadTelegramMessageContextRuntime()).recordChannelActivity;
|
||||
recordChannelActivity({
|
||||
channel: "telegram",
|
||||
accountId: account.accountId,
|
||||
direction: "inbound",
|
||||
});
|
||||
|
||||
const bodyResult = await resolveTelegramInboundBody({
|
||||
cfg,
|
||||
primaryCtx,
|
||||
msg,
|
||||
allMedia,
|
||||
isGroup,
|
||||
chatId,
|
||||
accountId: account.accountId,
|
||||
senderId,
|
||||
senderUsername,
|
||||
resolvedThreadId,
|
||||
routeAgentId: route.agentId,
|
||||
sessionKey,
|
||||
effectiveGroupAllow,
|
||||
effectiveDmAllow,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
requireMention,
|
||||
options,
|
||||
groupHistories,
|
||||
historyLimit,
|
||||
logger,
|
||||
});
|
||||
if (!bodyResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!(await ensureConfiguredBindingReady())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ACK reactions
|
||||
const ackReaction = resolveAckReaction(cfg, route.agentId, {
|
||||
channel: "telegram",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const ackReactionEmoji =
|
||||
ackReaction && isTelegramSupportedReactionEmoji(ackReaction) ? ackReaction : undefined;
|
||||
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
|
||||
const shouldAckReaction = () =>
|
||||
Boolean(
|
||||
ackReaction &&
|
||||
shouldAckReactionGate({
|
||||
scope: ackReactionScope,
|
||||
isDirect: !isGroup,
|
||||
isGroup,
|
||||
isMentionableGroup: isGroup,
|
||||
requireMention: Boolean(requireMention),
|
||||
canDetectMention: bodyResult.canDetectMention,
|
||||
effectiveWasMentioned: bodyResult.effectiveWasMentioned,
|
||||
shouldBypassMention: bodyResult.shouldBypassMention,
|
||||
}),
|
||||
);
|
||||
// Status Reactions controller (lifecycle reactions)
|
||||
const statusReactionsConfig = cfg.messages?.statusReactions;
|
||||
const statusReactionsEnabled =
|
||||
statusReactionsConfig?.enabled === true && Boolean(reactionApi) && shouldAckReaction();
|
||||
const resolvedStatusReactionEmojis = resolveTelegramStatusReactionEmojis({
|
||||
initialEmoji: ackReaction,
|
||||
overrides: statusReactionsConfig?.emojis,
|
||||
});
|
||||
const statusReactionVariantsByEmoji = buildTelegramStatusReactionVariants(
|
||||
resolvedStatusReactionEmojis,
|
||||
);
|
||||
let allowedStatusReactionEmojisPromise: Promise<Set<TelegramReactionEmoji> | null> | null = null;
|
||||
const createStatusReactionController =
|
||||
statusReactionsEnabled && msg.message_id
|
||||
? (runtime?.createStatusReactionController ??
|
||||
(await loadTelegramMessageContextRuntime()).createStatusReactionController)
|
||||
: null;
|
||||
const statusReactionController: TelegramStatusReactionController | null =
|
||||
createStatusReactionController
|
||||
? createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter: {
|
||||
setReaction: async (emoji: string) => {
|
||||
if (reactionApi) {
|
||||
if (!allowedStatusReactionEmojisPromise) {
|
||||
allowedStatusReactionEmojisPromise = resolveTelegramAllowedEmojiReactions({
|
||||
chat: msg.chat,
|
||||
chatId,
|
||||
getChat: getChatApi ?? undefined,
|
||||
}).catch((err) => {
|
||||
logVerbose(
|
||||
`telegram status-reaction available_reactions lookup failed for chat ${chatId}: ${String(err)}`,
|
||||
);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
const allowedStatusReactionEmojis = await allowedStatusReactionEmojisPromise;
|
||||
const resolvedEmoji = resolveTelegramReactionVariant({
|
||||
requestedEmoji: emoji,
|
||||
variantsByRequestedEmoji: statusReactionVariantsByEmoji,
|
||||
allowedEmojiReactions: allowedStatusReactionEmojis,
|
||||
});
|
||||
if (!resolvedEmoji) {
|
||||
return;
|
||||
}
|
||||
await reactionApi(chatId, msg.message_id, [
|
||||
{ type: "emoji", emoji: resolvedEmoji },
|
||||
]);
|
||||
}
|
||||
},
|
||||
// Telegram replaces atomically — no removeReaction needed
|
||||
},
|
||||
initialEmoji: ackReaction,
|
||||
emojis: resolvedStatusReactionEmojis,
|
||||
timing: statusReactionsConfig?.timing,
|
||||
onError: (err) => {
|
||||
logVerbose(`telegram status-reaction error for chat ${chatId}: ${String(err)}`);
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
// When status reactions are enabled, setQueued() replaces the simple ack reaction
|
||||
const ackReactionPromise: Promise<boolean> | null = statusReactionController
|
||||
? shouldAckReaction()
|
||||
? Promise.resolve(statusReactionController.setQueued()).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
: null
|
||||
: shouldAckReaction() && msg.message_id && reactionApi && ackReactionEmoji
|
||||
? withTelegramApiErrorLogging({
|
||||
operation: "setMessageReaction",
|
||||
fn: () =>
|
||||
reactionApi(chatId, msg.message_id, [{ type: "emoji", emoji: ackReactionEmoji }]),
|
||||
}).then(
|
||||
() => true,
|
||||
(err) => {
|
||||
logVerbose(`telegram react failed for chat ${chatId}: ${String(err)}`);
|
||||
return false;
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
const { ctxPayload, skillFilter } = await buildTelegramInboundContextPayload({
|
||||
cfg,
|
||||
primaryCtx,
|
||||
msg,
|
||||
allMedia,
|
||||
replyMedia,
|
||||
isGroup,
|
||||
isForum,
|
||||
chatId,
|
||||
senderId,
|
||||
senderUsername,
|
||||
resolvedThreadId,
|
||||
dmThreadId,
|
||||
threadSpec,
|
||||
route,
|
||||
rawBody: bodyResult.rawBody,
|
||||
bodyText: bodyResult.bodyText,
|
||||
historyKey: bodyResult.historyKey ?? "",
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
stickerCacheHit: bodyResult.stickerCacheHit,
|
||||
effectiveWasMentioned: bodyResult.effectiveWasMentioned,
|
||||
locationData: bodyResult.locationData,
|
||||
options,
|
||||
dmAllowFrom,
|
||||
effectiveGroupAllow,
|
||||
commandAuthorized: bodyResult.commandAuthorized,
|
||||
topicName,
|
||||
sessionRuntime,
|
||||
});
|
||||
|
||||
return {
|
||||
ctxPayload,
|
||||
primaryCtx,
|
||||
msg,
|
||||
chatId,
|
||||
isGroup,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
resolvedThreadId,
|
||||
threadSpec,
|
||||
replyThreadId,
|
||||
isForum,
|
||||
historyKey: bodyResult.historyKey ?? "",
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
route,
|
||||
skillFilter,
|
||||
sendTyping,
|
||||
sendRecordVoice,
|
||||
ackReactionPromise,
|
||||
reactionApi,
|
||||
removeAckAfterReply,
|
||||
statusReactionController,
|
||||
accountId: account.accountId,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import type { Bot } from "grammy";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type {
|
||||
DmPolicy,
|
||||
TelegramDirectConfig,
|
||||
TelegramGroupConfig,
|
||||
TelegramTopicConfig,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
|
||||
import type { StickerMetadata, TelegramContext } from "./bot/types.js";
|
||||
|
||||
export type TelegramMediaRef = {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
stickerMetadata?: StickerMetadata;
|
||||
};
|
||||
|
||||
export type TelegramMessageContextOptions = {
|
||||
commandSource?: "text" | "native";
|
||||
forceWasMentioned?: boolean;
|
||||
messageIdOverride?: string;
|
||||
receivedAtMs?: number;
|
||||
ingressBuffer?: "inbound-debounce" | "text-fragment";
|
||||
};
|
||||
|
||||
export type TelegramLogger = {
|
||||
info: (obj: Record<string, unknown>, msg: string) => void;
|
||||
};
|
||||
|
||||
export type ResolveTelegramGroupConfig = (
|
||||
chatId: string | number,
|
||||
messageThreadId?: number,
|
||||
) => {
|
||||
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||
topicConfig?: TelegramTopicConfig;
|
||||
};
|
||||
|
||||
export type ResolveGroupActivation = (params: {
|
||||
chatId: string | number;
|
||||
agentId?: string;
|
||||
messageThreadId?: number;
|
||||
sessionKey?: string;
|
||||
}) => boolean | undefined;
|
||||
|
||||
export type ResolveGroupRequireMention = (chatId: string | number) => boolean;
|
||||
|
||||
export type TelegramMessageContextRuntimeOverrides = Partial<
|
||||
Pick<
|
||||
typeof import("./bot-message-context.runtime.js"),
|
||||
| "createStatusReactionController"
|
||||
| "ensureConfiguredBindingRouteReady"
|
||||
| "loadConfig"
|
||||
| "recordChannelActivity"
|
||||
>
|
||||
>;
|
||||
|
||||
export type TelegramMessageContextSessionRuntimeOverrides = Partial<
|
||||
Pick<
|
||||
typeof import("./bot-message-context.session.runtime.js"),
|
||||
| "finalizeInboundContext"
|
||||
| "readSessionUpdatedAt"
|
||||
| "recordInboundSession"
|
||||
| "resolveInboundLastRouteSessionKey"
|
||||
| "resolvePinnedMainDmOwnerFromAllowlist"
|
||||
| "resolveStorePath"
|
||||
>
|
||||
>;
|
||||
|
||||
export type BuildTelegramMessageContextParams = {
|
||||
primaryCtx: TelegramContext;
|
||||
allMedia: TelegramMediaRef[];
|
||||
replyMedia?: TelegramMediaRef[];
|
||||
storeAllowFrom: string[];
|
||||
options?: TelegramMessageContextOptions;
|
||||
bot: Bot;
|
||||
cfg: OpenClawConfig;
|
||||
account: { accountId: string };
|
||||
historyLimit: number;
|
||||
groupHistories: Map<string, HistoryEntry[]>;
|
||||
dmPolicy: DmPolicy;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
ackReactionScope: "off" | "none" | "group-mentions" | "group-all" | "direct" | "all";
|
||||
logger: TelegramLogger;
|
||||
resolveGroupActivation: ResolveGroupActivation;
|
||||
resolveGroupRequireMention: ResolveGroupRequireMention;
|
||||
resolveTelegramGroupConfig: ResolveTelegramGroupConfig;
|
||||
loadFreshConfig?: () => OpenClawConfig;
|
||||
runtime?: TelegramMessageContextRuntimeOverrides;
|
||||
sessionRuntime?: TelegramMessageContextSessionRuntimeOverrides;
|
||||
upsertPairingRequest?: typeof import("openclaw/plugin-sdk/conversation-runtime").upsertChannelPairingRequest;
|
||||
/** Global (per-account) handler for sendChatAction 401 backoff (#27092). */
|
||||
sendChatActionHandler: import("./sendchataction-401-backoff.js").TelegramSendChatActionHandler;
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export {
|
||||
findModelInCatalog,
|
||||
loadModelCatalog,
|
||||
modelSupportsVision,
|
||||
resolveAgentDir,
|
||||
resolveDefaultModelForAgent,
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
export {
|
||||
loadSessionStore,
|
||||
resolveMarkdownTableMode,
|
||||
resolveSessionStoreEntry,
|
||||
resolveStorePath,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
export { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
|
||||
export { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
|
||||
export {
|
||||
generateTelegramTopicLabel as generateTopicLabel,
|
||||
resolveAutoTopicLabelConfig,
|
||||
} from "./auto-topic-label.js";
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { pruneStickerMediaFromContext } from "./bot-message-dispatch.js";
|
||||
|
||||
type MediaCtx = {
|
||||
MediaPath?: string;
|
||||
MediaUrl?: string;
|
||||
MediaType?: string;
|
||||
MediaPaths?: string[];
|
||||
MediaUrls?: string[];
|
||||
MediaTypes?: string[];
|
||||
};
|
||||
|
||||
function expectSingleImageMedia(ctx: MediaCtx, mediaPath: string) {
|
||||
expect(ctx.MediaPath).toBe(mediaPath);
|
||||
expect(ctx.MediaUrl).toBe(mediaPath);
|
||||
expect(ctx.MediaType).toBe("image/jpeg");
|
||||
expect(ctx.MediaPaths).toEqual([mediaPath]);
|
||||
expect(ctx.MediaUrls).toEqual([mediaPath]);
|
||||
expect(ctx.MediaTypes).toEqual(["image/jpeg"]);
|
||||
}
|
||||
|
||||
describe("pruneStickerMediaFromContext", () => {
|
||||
it("preserves appended reply media while removing primary sticker media", () => {
|
||||
const ctx: MediaCtx = {
|
||||
MediaPath: "/tmp/sticker.webp",
|
||||
MediaUrl: "/tmp/sticker.webp",
|
||||
MediaType: "image/webp",
|
||||
MediaPaths: ["/tmp/sticker.webp", "/tmp/replied.jpg"],
|
||||
MediaUrls: ["/tmp/sticker.webp", "/tmp/replied.jpg"],
|
||||
MediaTypes: ["image/webp", "image/jpeg"],
|
||||
};
|
||||
|
||||
pruneStickerMediaFromContext(ctx);
|
||||
|
||||
expectSingleImageMedia(ctx, "/tmp/replied.jpg");
|
||||
});
|
||||
|
||||
it("clears media fields when sticker is the only media", () => {
|
||||
const ctx: MediaCtx = {
|
||||
MediaPath: "/tmp/sticker.webp",
|
||||
MediaUrl: "/tmp/sticker.webp",
|
||||
MediaType: "image/webp",
|
||||
MediaPaths: ["/tmp/sticker.webp"],
|
||||
MediaUrls: ["/tmp/sticker.webp"],
|
||||
MediaTypes: ["image/webp"],
|
||||
};
|
||||
|
||||
pruneStickerMediaFromContext(ctx);
|
||||
|
||||
expect(ctx.MediaPath).toBeUndefined();
|
||||
expect(ctx.MediaUrl).toBeUndefined();
|
||||
expect(ctx.MediaType).toBeUndefined();
|
||||
expect(ctx.MediaPaths).toBeUndefined();
|
||||
expect(ctx.MediaUrls).toBeUndefined();
|
||||
expect(ctx.MediaTypes).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not prune when sticker media is already omitted from context", () => {
|
||||
const ctx: MediaCtx = {
|
||||
MediaPath: "/tmp/replied.jpg",
|
||||
MediaUrl: "/tmp/replied.jpg",
|
||||
MediaType: "image/jpeg",
|
||||
MediaPaths: ["/tmp/replied.jpg"],
|
||||
MediaUrls: ["/tmp/replied.jpg"],
|
||||
MediaTypes: ["image/jpeg"],
|
||||
};
|
||||
|
||||
pruneStickerMediaFromContext(ctx, { stickerMediaIncluded: false });
|
||||
|
||||
expectSingleImageMedia(ctx, "/tmp/replied.jpg");
|
||||
});
|
||||
});
|
||||
3563
openclaw/extensions/telegram/src/bot-message-dispatch.test.ts
Normal file
3563
openclaw/extensions/telegram/src/bot-message-dispatch.test.ts
Normal file
File diff suppressed because it is too large
Load diff
1143
openclaw/extensions/telegram/src/bot-message-dispatch.ts
Normal file
1143
openclaw/extensions/telegram/src/bot-message-dispatch.ts
Normal file
File diff suppressed because it is too large
Load diff
162
openclaw/extensions/telegram/src/bot-message.test.ts
Normal file
162
openclaw/extensions/telegram/src/bot-message.test.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TelegramBotDeps } from "./bot-deps.js";
|
||||
|
||||
const buildTelegramMessageContext = vi.hoisted(() => vi.fn());
|
||||
const dispatchTelegramMessage = vi.hoisted(() => vi.fn());
|
||||
const upsertChannelPairingRequest = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ code: "PAIRCODE", created: true })),
|
||||
);
|
||||
|
||||
vi.mock("./bot-message-context.js", () => ({
|
||||
buildTelegramMessageContext,
|
||||
}));
|
||||
|
||||
vi.mock("./bot-message-dispatch.js", () => ({
|
||||
dispatchTelegramMessage,
|
||||
}));
|
||||
|
||||
let createTelegramMessageProcessor: typeof import("./bot-message.js").createTelegramMessageProcessor;
|
||||
|
||||
describe("telegram bot message processor", () => {
|
||||
beforeAll(async () => {
|
||||
({ createTelegramMessageProcessor } = await import("./bot-message.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
buildTelegramMessageContext.mockClear();
|
||||
dispatchTelegramMessage.mockClear();
|
||||
upsertChannelPairingRequest.mockClear();
|
||||
});
|
||||
|
||||
const telegramDepsForTest = {
|
||||
upsertChannelPairingRequest,
|
||||
} as unknown as TelegramBotDeps;
|
||||
|
||||
const baseDeps = {
|
||||
bot: {},
|
||||
cfg: {},
|
||||
account: {},
|
||||
telegramCfg: {},
|
||||
historyLimit: 0,
|
||||
groupHistories: {},
|
||||
dmPolicy: {},
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
ackReactionScope: "none",
|
||||
logger: {},
|
||||
resolveGroupActivation: () => true,
|
||||
resolveGroupRequireMention: () => false,
|
||||
resolveTelegramGroupConfig: () => ({}),
|
||||
runtime: {},
|
||||
replyToMode: "auto",
|
||||
streamMode: "partial",
|
||||
textLimit: 4096,
|
||||
telegramDeps: telegramDepsForTest,
|
||||
opts: {},
|
||||
} as unknown as Parameters<typeof createTelegramMessageProcessor>[0];
|
||||
|
||||
async function processSampleMessage(
|
||||
processMessage: ReturnType<typeof createTelegramMessageProcessor>,
|
||||
) {
|
||||
await processMessage(
|
||||
{
|
||||
message: {
|
||||
chat: { id: 123, type: "private", title: "chat" },
|
||||
message_id: 456,
|
||||
},
|
||||
} as unknown as Parameters<typeof processMessage>[0],
|
||||
[],
|
||||
[],
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
function createDispatchFailureHarness(
|
||||
context: Record<string, unknown>,
|
||||
sendMessage: ReturnType<typeof vi.fn>,
|
||||
) {
|
||||
const runtimeError = vi.fn();
|
||||
buildTelegramMessageContext.mockResolvedValue(context);
|
||||
dispatchTelegramMessage.mockRejectedValue(new Error("dispatch exploded"));
|
||||
const processMessage = createTelegramMessageProcessor({
|
||||
...baseDeps,
|
||||
bot: { api: { sendMessage } },
|
||||
runtime: { error: runtimeError },
|
||||
} as unknown as Parameters<typeof createTelegramMessageProcessor>[0]);
|
||||
return { processMessage, runtimeError };
|
||||
}
|
||||
|
||||
it("dispatches when context is available", async () => {
|
||||
buildTelegramMessageContext.mockResolvedValue({ route: { sessionKey: "agent:main:main" } });
|
||||
|
||||
const processMessage = createTelegramMessageProcessor(baseDeps);
|
||||
await processSampleMessage(processMessage);
|
||||
|
||||
expect(dispatchTelegramMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips dispatch when no context is produced", async () => {
|
||||
buildTelegramMessageContext.mockResolvedValue(null);
|
||||
const processMessage = createTelegramMessageProcessor(baseDeps);
|
||||
await processSampleMessage(processMessage);
|
||||
expect(dispatchTelegramMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends user-visible fallback when dispatch throws", async () => {
|
||||
const sendMessage = vi.fn().mockResolvedValue(undefined);
|
||||
const { processMessage, runtimeError } = createDispatchFailureHarness(
|
||||
{
|
||||
chatId: 123,
|
||||
threadSpec: { id: 456, scope: "forum" },
|
||||
route: { sessionKey: "agent:main:main" },
|
||||
},
|
||||
sendMessage,
|
||||
);
|
||||
await expect(processSampleMessage(processMessage)).resolves.toBeUndefined();
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
123,
|
||||
"Something went wrong while processing your request. Please try again.",
|
||||
{ message_thread_id: 456 },
|
||||
);
|
||||
expect(runtimeError).toHaveBeenCalledWith(expect.stringContaining("dispatch exploded"));
|
||||
});
|
||||
|
||||
it("omits message_thread_id for General-topic fallback replies", async () => {
|
||||
const sendMessage = vi.fn().mockResolvedValue(undefined);
|
||||
const { processMessage } = createDispatchFailureHarness(
|
||||
{
|
||||
chatId: 123,
|
||||
threadSpec: { id: 1, scope: "forum" },
|
||||
route: { sessionKey: "agent:main:main" },
|
||||
},
|
||||
sendMessage,
|
||||
);
|
||||
await expect(processSampleMessage(processMessage)).resolves.toBeUndefined();
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
123,
|
||||
"Something went wrong while processing your request. Please try again.",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("swallows fallback delivery failures after dispatch throws", async () => {
|
||||
const sendMessage = vi.fn().mockRejectedValue(new Error("blocked by user"));
|
||||
const { processMessage, runtimeError } = createDispatchFailureHarness(
|
||||
{
|
||||
chatId: 123,
|
||||
route: { sessionKey: "agent:main:main" },
|
||||
},
|
||||
sendMessage,
|
||||
);
|
||||
await expect(processSampleMessage(processMessage)).resolves.toBeUndefined();
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
123,
|
||||
"Something went wrong while processing your request. Please try again.",
|
||||
undefined,
|
||||
);
|
||||
expect(runtimeError).toHaveBeenCalledWith(expect.stringContaining("dispatch exploded"));
|
||||
});
|
||||
});
|
||||
142
openclaw/extensions/telegram/src/bot-message.ts
Normal file
142
openclaw/extensions/telegram/src/bot-message.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import type { ReplyToMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { TelegramBotDeps } from "./bot-deps.js";
|
||||
import {
|
||||
buildTelegramMessageContext,
|
||||
type BuildTelegramMessageContextParams,
|
||||
type TelegramMediaRef,
|
||||
} from "./bot-message-context.js";
|
||||
import type { TelegramMessageContextOptions } from "./bot-message-context.types.js";
|
||||
import { dispatchTelegramMessage } from "./bot-message-dispatch.js";
|
||||
import type { TelegramBotOptions } from "./bot.types.js";
|
||||
import { buildTelegramThreadParams } from "./bot/helpers.js";
|
||||
import type { TelegramContext, TelegramStreamMode } from "./bot/types.js";
|
||||
|
||||
/** Dependencies injected once when creating the message processor. */
|
||||
type TelegramMessageProcessorDeps = Omit<
|
||||
BuildTelegramMessageContextParams,
|
||||
"primaryCtx" | "allMedia" | "storeAllowFrom" | "options"
|
||||
> & {
|
||||
telegramCfg: TelegramAccountConfig;
|
||||
runtime: RuntimeEnv;
|
||||
replyToMode: ReplyToMode;
|
||||
streamMode: TelegramStreamMode;
|
||||
textLimit: number;
|
||||
telegramDeps: TelegramBotDeps;
|
||||
opts: Pick<TelegramBotOptions, "token">;
|
||||
};
|
||||
|
||||
export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDeps) => {
|
||||
const {
|
||||
bot,
|
||||
cfg,
|
||||
account,
|
||||
telegramCfg,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
dmPolicy,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
ackReactionScope,
|
||||
logger,
|
||||
resolveGroupActivation,
|
||||
resolveGroupRequireMention,
|
||||
resolveTelegramGroupConfig,
|
||||
loadFreshConfig,
|
||||
sendChatActionHandler,
|
||||
runtime,
|
||||
replyToMode,
|
||||
streamMode,
|
||||
textLimit,
|
||||
telegramDeps,
|
||||
opts,
|
||||
} = deps;
|
||||
|
||||
return async (
|
||||
primaryCtx: TelegramContext,
|
||||
allMedia: TelegramMediaRef[],
|
||||
storeAllowFrom: string[],
|
||||
options?: TelegramMessageContextOptions,
|
||||
replyMedia?: TelegramMediaRef[],
|
||||
) => {
|
||||
const ingressReceivedAtMs =
|
||||
typeof options?.receivedAtMs === "number" && Number.isFinite(options.receivedAtMs)
|
||||
? options.receivedAtMs
|
||||
: undefined;
|
||||
const ingressDebugEnabled =
|
||||
shouldLogVerbose() || process.env.OPENCLAW_DEBUG_TELEGRAM_INGRESS === "1";
|
||||
const ingressContextStartMs = ingressReceivedAtMs ? Date.now() : undefined;
|
||||
const context = await buildTelegramMessageContext({
|
||||
primaryCtx,
|
||||
allMedia,
|
||||
replyMedia,
|
||||
storeAllowFrom,
|
||||
options,
|
||||
bot,
|
||||
cfg,
|
||||
account,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
dmPolicy,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
ackReactionScope,
|
||||
logger,
|
||||
resolveGroupActivation,
|
||||
resolveGroupRequireMention,
|
||||
resolveTelegramGroupConfig,
|
||||
sendChatActionHandler,
|
||||
loadFreshConfig,
|
||||
upsertPairingRequest: telegramDeps.upsertChannelPairingRequest,
|
||||
});
|
||||
if (!context) {
|
||||
if (ingressDebugEnabled && ingressReceivedAtMs && ingressContextStartMs) {
|
||||
logVerbose(
|
||||
`telegram ingress: chatId=${primaryCtx.message.chat.id} dropped after ${Date.now() - ingressReceivedAtMs}ms` +
|
||||
(options?.ingressBuffer ? ` buffer=${options.ingressBuffer}` : ""),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ingressDebugEnabled && ingressReceivedAtMs && ingressContextStartMs) {
|
||||
logVerbose(
|
||||
`telegram ingress: chatId=${context.chatId} contextReadyMs=${Date.now() - ingressReceivedAtMs}` +
|
||||
` preDispatchMs=${Date.now() - ingressContextStartMs}` +
|
||||
(options?.ingressBuffer ? ` buffer=${options.ingressBuffer}` : ""),
|
||||
);
|
||||
}
|
||||
try {
|
||||
await dispatchTelegramMessage({
|
||||
context,
|
||||
bot,
|
||||
cfg,
|
||||
runtime,
|
||||
replyToMode,
|
||||
streamMode,
|
||||
textLimit,
|
||||
telegramCfg,
|
||||
telegramDeps,
|
||||
opts,
|
||||
});
|
||||
if (ingressDebugEnabled && ingressReceivedAtMs) {
|
||||
logVerbose(
|
||||
`telegram ingress: chatId=${context.chatId} dispatchCompleteMs=${Date.now() - ingressReceivedAtMs}` +
|
||||
(options?.ingressBuffer ? ` buffer=${options.ingressBuffer}` : ""),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.error?.(danger(`telegram message processing failed: ${String(err)}`));
|
||||
try {
|
||||
await bot.api.sendMessage(
|
||||
context.chatId,
|
||||
"Something went wrong while processing your request. Please try again.",
|
||||
buildTelegramThreadParams(context.threadSpec),
|
||||
);
|
||||
} catch {
|
||||
// Best-effort fallback; delivery may fail if the bot was blocked or the chat is invalid.
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { getPluginCommandSpecs } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-dispatch-runtime";
|
||||
import { listSkillCommandsForAgents } from "openclaw/plugin-sdk/skill-commands-runtime";
|
||||
import type { TelegramBotDeps } from "./bot-deps.js";
|
||||
import { syncTelegramMenuCommands } from "./bot-native-command-menu.js";
|
||||
|
||||
export type TelegramNativeCommandDeps = Pick<
|
||||
TelegramBotDeps,
|
||||
| "dispatchReplyWithBufferedBlockDispatcher"
|
||||
| "editMessageTelegram"
|
||||
| "listSkillCommandsForAgents"
|
||||
| "loadConfig"
|
||||
| "readChannelAllowFromStore"
|
||||
| "syncTelegramMenuCommands"
|
||||
> & {
|
||||
getPluginCommandSpecs?: typeof getPluginCommandSpecs;
|
||||
};
|
||||
|
||||
let telegramSendRuntimePromise: Promise<typeof import("./send.js")> | undefined;
|
||||
|
||||
async function loadTelegramSendRuntime() {
|
||||
telegramSendRuntimePromise ??= import("./send.js");
|
||||
return await telegramSendRuntimePromise;
|
||||
}
|
||||
|
||||
export const defaultTelegramNativeCommandDeps: TelegramNativeCommandDeps = {
|
||||
get loadConfig() {
|
||||
return loadConfig;
|
||||
},
|
||||
get readChannelAllowFromStore() {
|
||||
return readChannelAllowFromStore;
|
||||
},
|
||||
get dispatchReplyWithBufferedBlockDispatcher() {
|
||||
return dispatchReplyWithBufferedBlockDispatcher;
|
||||
},
|
||||
get listSkillCommandsForAgents() {
|
||||
return listSkillCommandsForAgents;
|
||||
},
|
||||
get syncTelegramMenuCommands() {
|
||||
return syncTelegramMenuCommands;
|
||||
},
|
||||
get getPluginCommandSpecs() {
|
||||
return getPluginCommandSpecs;
|
||||
},
|
||||
async editMessageTelegram(...args) {
|
||||
const { editMessageTelegram } = await loadTelegramSendRuntime();
|
||||
return await editMessageTelegram(...args);
|
||||
},
|
||||
};
|
||||
352
openclaw/extensions/telegram/src/bot-native-command-menu.test.ts
Normal file
352
openclaw/extensions/telegram/src/bot-native-command-menu.test.ts
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildCappedTelegramMenuCommands,
|
||||
buildPluginTelegramMenuCommands,
|
||||
hashCommandList,
|
||||
syncTelegramMenuCommands,
|
||||
TELEGRAM_TOTAL_COMMAND_TEXT_BUDGET,
|
||||
} from "./bot-native-command-menu.js";
|
||||
|
||||
type SyncMenuOptions = {
|
||||
deleteMyCommands: ReturnType<typeof vi.fn>;
|
||||
setMyCommands: ReturnType<typeof vi.fn>;
|
||||
commandsToRegister: Parameters<typeof syncTelegramMenuCommands>[0]["commandsToRegister"];
|
||||
accountId: string;
|
||||
botIdentity: string;
|
||||
runtimeLog?: ReturnType<typeof vi.fn>;
|
||||
runtimeError?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function syncMenuCommandsWithMocks(options: SyncMenuOptions): void {
|
||||
syncTelegramMenuCommands({
|
||||
bot: {
|
||||
api: { deleteMyCommands: options.deleteMyCommands, setMyCommands: options.setMyCommands },
|
||||
} as unknown as Parameters<typeof syncTelegramMenuCommands>[0]["bot"],
|
||||
runtime: {
|
||||
log: options.runtimeLog ?? vi.fn(),
|
||||
error: options.runtimeError ?? vi.fn(),
|
||||
exit: vi.fn(),
|
||||
} as Parameters<typeof syncTelegramMenuCommands>[0]["runtime"],
|
||||
commandsToRegister: options.commandsToRegister,
|
||||
accountId: options.accountId,
|
||||
botIdentity: options.botIdentity,
|
||||
});
|
||||
}
|
||||
|
||||
describe("bot-native-command-menu", () => {
|
||||
it("caps menu entries to Telegram limit", () => {
|
||||
const allCommands = Array.from({ length: 105 }, (_, i) => ({
|
||||
command: `cmd_${i}`,
|
||||
description: `Command ${i}`,
|
||||
}));
|
||||
|
||||
const result = buildCappedTelegramMenuCommands({ allCommands });
|
||||
|
||||
expect(result.commandsToRegister).toHaveLength(100);
|
||||
expect(result.totalCommands).toBe(105);
|
||||
expect(result.maxCommands).toBe(100);
|
||||
expect(result.overflowCount).toBe(5);
|
||||
expect(result.commandsToRegister[0]).toEqual({ command: "cmd_0", description: "Command 0" });
|
||||
expect(result.commandsToRegister[99]).toEqual({
|
||||
command: "cmd_99",
|
||||
description: "Command 99",
|
||||
});
|
||||
});
|
||||
|
||||
it("shortens descriptions before dropping commands to fit Telegram payload budget", () => {
|
||||
const allCommands = Array.from({ length: 92 }, (_, i) => ({
|
||||
command: `cmd_${i}`,
|
||||
description: "x".repeat(100),
|
||||
}));
|
||||
|
||||
const result = buildCappedTelegramMenuCommands({ allCommands });
|
||||
|
||||
expect(result.commandsToRegister).toHaveLength(92);
|
||||
expect(result.descriptionTrimmed).toBe(true);
|
||||
expect(result.textBudgetDropCount).toBe(0);
|
||||
const totalText = result.commandsToRegister.reduce(
|
||||
(total, command) => total + command.command.length + command.description.length,
|
||||
0,
|
||||
);
|
||||
expect(totalText).toBeLessThanOrEqual(TELEGRAM_TOTAL_COMMAND_TEXT_BUDGET);
|
||||
expect(result.commandsToRegister.every((command) => command.description.length <= 56)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("drops tail commands only when minimal descriptions still cannot fit the payload budget", () => {
|
||||
const allCommands = [
|
||||
{ command: "alpha_cmd", description: "First command" },
|
||||
{ command: "bravo_cmd", description: "Second command" },
|
||||
{ command: "charlie_cmd", description: "Third command" },
|
||||
];
|
||||
|
||||
const result = buildCappedTelegramMenuCommands({
|
||||
allCommands,
|
||||
maxTotalChars: 20,
|
||||
});
|
||||
|
||||
expect(result.commandsToRegister).toEqual([
|
||||
{ command: "alpha_cmd", description: "F" },
|
||||
{ command: "bravo_cmd", description: "S" },
|
||||
]);
|
||||
expect(result.descriptionTrimmed).toBe(true);
|
||||
expect(result.textBudgetDropCount).toBe(1);
|
||||
});
|
||||
|
||||
it("validates plugin command specs and reports conflicts", () => {
|
||||
const existingCommands = new Set(["native"]);
|
||||
|
||||
const result = buildPluginTelegramMenuCommands({
|
||||
specs: [
|
||||
{ name: "valid", description: " Works " },
|
||||
{ name: "bad-name!", description: "Bad" },
|
||||
{ name: "native", description: "Conflicts with native" },
|
||||
{ name: "valid", description: "Duplicate plugin name" },
|
||||
{ name: "empty", description: " " },
|
||||
],
|
||||
existingCommands,
|
||||
});
|
||||
|
||||
expect(result.commands).toEqual([{ command: "valid", description: "Works" }]);
|
||||
expect(result.issues).toContain(
|
||||
'Plugin command "/bad-name!" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).',
|
||||
);
|
||||
expect(result.issues).toContain(
|
||||
'Plugin command "/native" conflicts with an existing Telegram command.',
|
||||
);
|
||||
expect(result.issues).toContain('Plugin command "/valid" is duplicated.');
|
||||
expect(result.issues).toContain('Plugin command "/empty" is missing a description.');
|
||||
});
|
||||
|
||||
it("normalizes hyphenated plugin command names", () => {
|
||||
const result = buildPluginTelegramMenuCommands({
|
||||
specs: [{ name: "agent-run", description: "Run agent" }],
|
||||
existingCommands: new Set<string>(),
|
||||
});
|
||||
|
||||
expect(result.commands).toEqual([{ command: "agent_run", description: "Run agent" }]);
|
||||
expect(result.issues).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores malformed plugin specs without crashing", () => {
|
||||
const malformedSpecs = [
|
||||
{ name: "valid", description: " Works " },
|
||||
{ name: "missing-description", description: undefined },
|
||||
{ name: undefined, description: "Missing name" },
|
||||
] as unknown as Parameters<typeof buildPluginTelegramMenuCommands>[0]["specs"];
|
||||
|
||||
const result = buildPluginTelegramMenuCommands({
|
||||
specs: malformedSpecs,
|
||||
existingCommands: new Set<string>(),
|
||||
});
|
||||
|
||||
expect(result.commands).toEqual([{ command: "valid", description: "Works" }]);
|
||||
expect(result.issues).toContain(
|
||||
'Plugin command "/missing_description" is missing a description.',
|
||||
);
|
||||
expect(result.issues).toContain(
|
||||
'Plugin command "/<unknown>" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).',
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes stale commands before setting new menu", async () => {
|
||||
const callOrder: string[] = [];
|
||||
const deleteMyCommands = vi.fn(async () => {
|
||||
callOrder.push("delete");
|
||||
});
|
||||
const setMyCommands = vi.fn(async () => {
|
||||
callOrder.push("set");
|
||||
});
|
||||
|
||||
syncMenuCommandsWithMocks({
|
||||
deleteMyCommands,
|
||||
setMyCommands,
|
||||
commandsToRegister: [{ command: "cmd", description: "Command" }],
|
||||
accountId: `test-delete-${Date.now()}`,
|
||||
botIdentity: "bot-a",
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(setMyCommands).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(callOrder).toEqual(["delete", "set"]);
|
||||
});
|
||||
|
||||
it("produces a stable hash regardless of command order (#32017)", () => {
|
||||
const commands = [
|
||||
{ command: "bravo", description: "B" },
|
||||
{ command: "alpha", description: "A" },
|
||||
];
|
||||
const reversed = [...commands].toReversed();
|
||||
expect(hashCommandList(commands)).toBe(hashCommandList(reversed));
|
||||
});
|
||||
|
||||
it("produces different hashes for different command lists (#32017)", () => {
|
||||
const a = [{ command: "alpha", description: "A" }];
|
||||
const b = [{ command: "alpha", description: "Changed" }];
|
||||
expect(hashCommandList(a)).not.toBe(hashCommandList(b));
|
||||
});
|
||||
|
||||
it("skips sync when command hash is unchanged (#32017)", async () => {
|
||||
const deleteMyCommands = vi.fn(async () => undefined);
|
||||
const setMyCommands = vi.fn(async () => undefined);
|
||||
const runtimeLog = vi.fn();
|
||||
|
||||
// Use a unique accountId so cached hashes from other tests don't interfere.
|
||||
const accountId = `test-skip-${Date.now()}`;
|
||||
const commands = [{ command: "skip_test", description: "Skip test command" }];
|
||||
|
||||
// First sync — no cached hash, should call setMyCommands.
|
||||
syncMenuCommandsWithMocks({
|
||||
deleteMyCommands,
|
||||
setMyCommands,
|
||||
runtimeLog,
|
||||
commandsToRegister: commands,
|
||||
accountId,
|
||||
botIdentity: "bot-a",
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(setMyCommands).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Second sync with the same commands — hash is cached, should skip.
|
||||
syncMenuCommandsWithMocks({
|
||||
deleteMyCommands,
|
||||
setMyCommands,
|
||||
runtimeLog,
|
||||
commandsToRegister: commands,
|
||||
accountId,
|
||||
botIdentity: "bot-a",
|
||||
});
|
||||
|
||||
// setMyCommands should NOT have been called a second time.
|
||||
expect(setMyCommands).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not reuse cached hash across different bot identities", async () => {
|
||||
const deleteMyCommands = vi.fn(async () => undefined);
|
||||
const setMyCommands = vi.fn(async () => undefined);
|
||||
const runtimeLog = vi.fn();
|
||||
const accountId = `test-bot-identity-${Date.now()}`;
|
||||
const commands = [{ command: "same", description: "Same" }];
|
||||
|
||||
syncMenuCommandsWithMocks({
|
||||
deleteMyCommands,
|
||||
setMyCommands,
|
||||
runtimeLog,
|
||||
commandsToRegister: commands,
|
||||
accountId,
|
||||
botIdentity: "token-bot-a",
|
||||
});
|
||||
await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(1));
|
||||
|
||||
syncMenuCommandsWithMocks({
|
||||
deleteMyCommands,
|
||||
setMyCommands,
|
||||
runtimeLog,
|
||||
commandsToRegister: commands,
|
||||
accountId,
|
||||
botIdentity: "token-bot-b",
|
||||
});
|
||||
await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("does not cache empty-menu hash when deleteMyCommands fails", async () => {
|
||||
const deleteMyCommands = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("transient failure"))
|
||||
.mockResolvedValue(undefined);
|
||||
const setMyCommands = vi.fn(async () => undefined);
|
||||
const runtimeLog = vi.fn();
|
||||
const accountId = `test-empty-delete-fail-${Date.now()}`;
|
||||
|
||||
syncMenuCommandsWithMocks({
|
||||
deleteMyCommands,
|
||||
setMyCommands,
|
||||
runtimeLog,
|
||||
commandsToRegister: [],
|
||||
accountId,
|
||||
botIdentity: "bot-a",
|
||||
});
|
||||
await vi.waitFor(() => expect(deleteMyCommands).toHaveBeenCalledTimes(1));
|
||||
|
||||
syncMenuCommandsWithMocks({
|
||||
deleteMyCommands,
|
||||
setMyCommands,
|
||||
runtimeLog,
|
||||
commandsToRegister: [],
|
||||
accountId,
|
||||
botIdentity: "bot-a",
|
||||
});
|
||||
await vi.waitFor(() => expect(deleteMyCommands).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("retries with fewer commands on BOT_COMMANDS_TOO_MUCH", async () => {
|
||||
const deleteMyCommands = vi.fn(async () => undefined);
|
||||
const setMyCommands = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("400: Bad Request: BOT_COMMANDS_TOO_MUCH"))
|
||||
.mockResolvedValue(undefined);
|
||||
const runtimeLog = vi.fn();
|
||||
const runtimeError = vi.fn();
|
||||
|
||||
syncMenuCommandsWithMocks({
|
||||
deleteMyCommands,
|
||||
setMyCommands,
|
||||
runtimeLog,
|
||||
runtimeError,
|
||||
commandsToRegister: Array.from({ length: 100 }, (_, i) => ({
|
||||
command: `cmd_${i}`,
|
||||
description: `Command ${i}`,
|
||||
})),
|
||||
accountId: `test-retry-${Date.now()}`,
|
||||
botIdentity: "bot-a",
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(setMyCommands).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
const firstPayload = setMyCommands.mock.calls[0]?.[0] as Array<unknown>;
|
||||
const secondPayload = setMyCommands.mock.calls[1]?.[0] as Array<unknown>;
|
||||
expect(firstPayload).toHaveLength(100);
|
||||
expect(secondPayload).toHaveLength(80);
|
||||
expect(runtimeLog).toHaveBeenCalledWith(
|
||||
"Telegram rejected 100 commands (BOT_COMMANDS_TOO_MUCH); retrying with 80.",
|
||||
);
|
||||
expect(runtimeLog).toHaveBeenCalledWith(
|
||||
"Telegram accepted 80 commands after BOT_COMMANDS_TOO_MUCH (started with 100; omitted 20). Reduce plugin/skill/custom commands to expose more menu entries.",
|
||||
);
|
||||
expect(runtimeError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "description envelope", error: { description: "BOT_COMMANDS_TOO_MUCH" } },
|
||||
{ label: "message envelope", error: { message: "BOT_COMMANDS_TOO_MUCH" } },
|
||||
])("retries when Telegram returns a plain-object $label error", async ({ error }) => {
|
||||
const deleteMyCommands = vi.fn(async () => undefined);
|
||||
const setMyCommands = vi.fn().mockRejectedValueOnce(error).mockResolvedValue(undefined);
|
||||
const runtimeLog = vi.fn();
|
||||
|
||||
syncMenuCommandsWithMocks({
|
||||
deleteMyCommands,
|
||||
setMyCommands,
|
||||
runtimeLog,
|
||||
commandsToRegister: Array.from({ length: 10 }, (_, i) => ({
|
||||
command: `cmd_${i}`,
|
||||
description: `Command ${i}`,
|
||||
})),
|
||||
accountId: `test-envelope-${Date.now()}`,
|
||||
botIdentity: "bot-a",
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(setMyCommands).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(runtimeLog).toHaveBeenCalledWith(
|
||||
"Telegram rejected 10 commands (BOT_COMMANDS_TOO_MUCH); retrying with 8.",
|
||||
);
|
||||
});
|
||||
});
|
||||
320
openclaw/extensions/telegram/src/bot-native-command-menu.ts
Normal file
320
openclaw/extensions/telegram/src/bot-native-command-menu.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import type { Bot } from "grammy";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeOptionalString, readStringValue } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
||||
import { normalizeTelegramCommandName, TELEGRAM_COMMAND_NAME_PATTERN } from "./command-config.js";
|
||||
|
||||
export const TELEGRAM_MAX_COMMANDS = 100;
|
||||
export const TELEGRAM_TOTAL_COMMAND_TEXT_BUDGET = 5700;
|
||||
const TELEGRAM_COMMAND_RETRY_RATIO = 0.8;
|
||||
const TELEGRAM_MIN_COMMAND_DESCRIPTION_LENGTH = 1;
|
||||
|
||||
export type TelegramMenuCommand = {
|
||||
command: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type TelegramPluginCommandSpec = {
|
||||
name: unknown;
|
||||
description: unknown;
|
||||
};
|
||||
|
||||
function countTelegramCommandText(value: string): number {
|
||||
return Array.from(value).length;
|
||||
}
|
||||
|
||||
function truncateTelegramCommandText(value: string, maxLength: number): string {
|
||||
if (maxLength <= 0) {
|
||||
return "";
|
||||
}
|
||||
const chars = Array.from(value);
|
||||
if (chars.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
if (maxLength === 1) {
|
||||
return chars[0] ?? "";
|
||||
}
|
||||
return `${chars.slice(0, maxLength - 1).join("")}…`;
|
||||
}
|
||||
|
||||
function fitTelegramCommandsWithinTextBudget(
|
||||
commands: TelegramMenuCommand[],
|
||||
maxTotalChars: number,
|
||||
): {
|
||||
commands: TelegramMenuCommand[];
|
||||
descriptionTrimmed: boolean;
|
||||
textBudgetDropCount: number;
|
||||
} {
|
||||
let candidateCommands = [...commands];
|
||||
while (candidateCommands.length > 0) {
|
||||
const commandNameChars = candidateCommands.reduce(
|
||||
(total, command) => total + countTelegramCommandText(command.command),
|
||||
0,
|
||||
);
|
||||
const descriptionBudget = maxTotalChars - commandNameChars;
|
||||
const minimumDescriptionBudget =
|
||||
candidateCommands.length * TELEGRAM_MIN_COMMAND_DESCRIPTION_LENGTH;
|
||||
if (descriptionBudget < minimumDescriptionBudget) {
|
||||
candidateCommands = candidateCommands.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const descriptionCap = Math.max(
|
||||
TELEGRAM_MIN_COMMAND_DESCRIPTION_LENGTH,
|
||||
Math.floor(descriptionBudget / candidateCommands.length),
|
||||
);
|
||||
let descriptionTrimmed = false;
|
||||
const fittedCommands = candidateCommands.map((command) => {
|
||||
const description = truncateTelegramCommandText(command.description, descriptionCap);
|
||||
if (description !== command.description) {
|
||||
descriptionTrimmed = true;
|
||||
return Object.assign({}, command, { description });
|
||||
}
|
||||
return command;
|
||||
});
|
||||
return {
|
||||
commands: fittedCommands,
|
||||
descriptionTrimmed,
|
||||
textBudgetDropCount: commands.length - fittedCommands.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
commands: [],
|
||||
descriptionTrimmed: false,
|
||||
textBudgetDropCount: commands.length,
|
||||
};
|
||||
}
|
||||
|
||||
function readErrorTextField(value: unknown, key: "description" | "message"): string | undefined {
|
||||
if (!value || typeof value !== "object" || !(key in value)) {
|
||||
return undefined;
|
||||
}
|
||||
return readStringValue((value as Record<"description" | "message", unknown>)[key]);
|
||||
}
|
||||
|
||||
function isBotCommandsTooMuchError(err: unknown): boolean {
|
||||
if (!err) {
|
||||
return false;
|
||||
}
|
||||
const pattern = /\bBOT_COMMANDS_TOO_MUCH\b/i;
|
||||
if (typeof err === "string") {
|
||||
return pattern.test(err);
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
if (pattern.test(err.message)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const description = readErrorTextField(err, "description");
|
||||
if (description && pattern.test(description)) {
|
||||
return true;
|
||||
}
|
||||
const message = readErrorTextField(err, "message");
|
||||
if (message && pattern.test(message)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function formatTelegramCommandRetrySuccessLog(params: {
|
||||
initialCount: number;
|
||||
acceptedCount: number;
|
||||
}): string {
|
||||
const omittedCount = Math.max(0, params.initialCount - params.acceptedCount);
|
||||
return (
|
||||
`Telegram accepted ${params.acceptedCount} commands after BOT_COMMANDS_TOO_MUCH ` +
|
||||
`(started with ${params.initialCount}; omitted ${omittedCount}). ` +
|
||||
"Reduce plugin/skill/custom commands to expose more menu entries."
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPluginTelegramMenuCommands(params: {
|
||||
specs: TelegramPluginCommandSpec[];
|
||||
existingCommands: Set<string>;
|
||||
}): { commands: TelegramMenuCommand[]; issues: string[] } {
|
||||
const { specs, existingCommands } = params;
|
||||
const commands: TelegramMenuCommand[] = [];
|
||||
const issues: string[] = [];
|
||||
const pluginCommandNames = new Set<string>();
|
||||
|
||||
for (const spec of specs) {
|
||||
const rawName = typeof spec.name === "string" ? spec.name : "";
|
||||
const normalized = normalizeTelegramCommandName(rawName);
|
||||
if (!normalized || !TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) {
|
||||
const invalidName = rawName.trim() ? rawName : "<unknown>";
|
||||
issues.push(
|
||||
`Plugin command "/${invalidName}" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const description = normalizeOptionalString(spec.description) ?? "";
|
||||
if (!description) {
|
||||
issues.push(`Plugin command "/${normalized}" is missing a description.`);
|
||||
continue;
|
||||
}
|
||||
if (existingCommands.has(normalized)) {
|
||||
if (pluginCommandNames.has(normalized)) {
|
||||
issues.push(`Plugin command "/${normalized}" is duplicated.`);
|
||||
} else {
|
||||
issues.push(`Plugin command "/${normalized}" conflicts with an existing Telegram command.`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
pluginCommandNames.add(normalized);
|
||||
existingCommands.add(normalized);
|
||||
commands.push({ command: normalized, description });
|
||||
}
|
||||
|
||||
return { commands, issues };
|
||||
}
|
||||
|
||||
export function buildCappedTelegramMenuCommands(params: {
|
||||
allCommands: TelegramMenuCommand[];
|
||||
maxCommands?: number;
|
||||
maxTotalChars?: number;
|
||||
}): {
|
||||
commandsToRegister: TelegramMenuCommand[];
|
||||
totalCommands: number;
|
||||
maxCommands: number;
|
||||
overflowCount: number;
|
||||
maxTotalChars: number;
|
||||
descriptionTrimmed: boolean;
|
||||
textBudgetDropCount: number;
|
||||
} {
|
||||
const { allCommands } = params;
|
||||
const maxCommands = params.maxCommands ?? TELEGRAM_MAX_COMMANDS;
|
||||
const maxTotalChars = params.maxTotalChars ?? TELEGRAM_TOTAL_COMMAND_TEXT_BUDGET;
|
||||
const totalCommands = allCommands.length;
|
||||
const overflowCount = Math.max(0, totalCommands - maxCommands);
|
||||
const {
|
||||
commands: commandsToRegister,
|
||||
descriptionTrimmed,
|
||||
textBudgetDropCount,
|
||||
} = fitTelegramCommandsWithinTextBudget(allCommands.slice(0, maxCommands), maxTotalChars);
|
||||
return {
|
||||
commandsToRegister,
|
||||
totalCommands,
|
||||
maxCommands,
|
||||
overflowCount,
|
||||
maxTotalChars,
|
||||
descriptionTrimmed,
|
||||
textBudgetDropCount,
|
||||
};
|
||||
}
|
||||
|
||||
/** Compute a stable hash of the command list for change detection. */
|
||||
export function hashCommandList(commands: TelegramMenuCommand[]): string {
|
||||
const sorted = [...commands].toSorted((a, b) => a.command.localeCompare(b.command));
|
||||
return createHash("sha256").update(JSON.stringify(sorted)).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
// Keep the sync cache process-local so restarts always re-register commands.
|
||||
const syncedCommandHashes = new Map<string, string>();
|
||||
|
||||
function getCommandHashKey(accountId?: string, botIdentity?: string): string {
|
||||
return `${accountId ?? "default"}:${botIdentity ?? ""}`;
|
||||
}
|
||||
|
||||
function readCachedCommandHash(accountId?: string, botIdentity?: string): string | null {
|
||||
const key = getCommandHashKey(accountId, botIdentity);
|
||||
return syncedCommandHashes.get(key) ?? null;
|
||||
}
|
||||
|
||||
function writeCachedCommandHash(
|
||||
accountId: string | undefined,
|
||||
botIdentity: string | undefined,
|
||||
hash: string,
|
||||
): void {
|
||||
const key = getCommandHashKey(accountId, botIdentity);
|
||||
syncedCommandHashes.set(key, hash);
|
||||
}
|
||||
|
||||
export function syncTelegramMenuCommands(params: {
|
||||
bot: Bot;
|
||||
runtime: RuntimeEnv;
|
||||
commandsToRegister: TelegramMenuCommand[];
|
||||
accountId?: string;
|
||||
botIdentity?: string;
|
||||
}): void {
|
||||
const { bot, runtime, commandsToRegister, accountId, botIdentity } = params;
|
||||
const sync = async () => {
|
||||
// Skip sync if the command list hasn't changed since the last successful
|
||||
// sync. This prevents hitting Telegram's 429 rate limit when the gateway
|
||||
// is restarted several times in quick succession.
|
||||
// See: openclaw/openclaw#32017
|
||||
const currentHash = hashCommandList(commandsToRegister);
|
||||
const cachedHash = readCachedCommandHash(accountId, botIdentity);
|
||||
if (cachedHash === currentHash) {
|
||||
logVerbose("telegram: command menu unchanged; skipping sync");
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep delete -> set ordering to avoid stale deletions racing after fresh registrations.
|
||||
let deleteSucceeded = true;
|
||||
if (typeof bot.api.deleteMyCommands === "function") {
|
||||
deleteSucceeded = await withTelegramApiErrorLogging({
|
||||
operation: "deleteMyCommands",
|
||||
runtime,
|
||||
fn: () => bot.api.deleteMyCommands(),
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
if (commandsToRegister.length === 0) {
|
||||
if (!deleteSucceeded) {
|
||||
runtime.log?.("telegram: deleteMyCommands failed; skipping empty-menu hash cache write");
|
||||
return;
|
||||
}
|
||||
writeCachedCommandHash(accountId, botIdentity, currentHash);
|
||||
return;
|
||||
}
|
||||
|
||||
let retryCommands = commandsToRegister;
|
||||
const initialCommandCount = commandsToRegister.length;
|
||||
while (retryCommands.length > 0) {
|
||||
try {
|
||||
await withTelegramApiErrorLogging({
|
||||
operation: "setMyCommands",
|
||||
runtime,
|
||||
shouldLog: (err) => !isBotCommandsTooMuchError(err),
|
||||
fn: () => bot.api.setMyCommands(retryCommands),
|
||||
});
|
||||
if (retryCommands.length < initialCommandCount) {
|
||||
runtime.log?.(
|
||||
formatTelegramCommandRetrySuccessLog({
|
||||
initialCount: initialCommandCount,
|
||||
acceptedCount: retryCommands.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
writeCachedCommandHash(accountId, botIdentity, currentHash);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (!isBotCommandsTooMuchError(err)) {
|
||||
throw err;
|
||||
}
|
||||
const nextCount = Math.floor(retryCommands.length * TELEGRAM_COMMAND_RETRY_RATIO);
|
||||
const reducedCount =
|
||||
nextCount < retryCommands.length ? nextCount : retryCommands.length - 1;
|
||||
if (reducedCount <= 0) {
|
||||
runtime.error?.(
|
||||
"Telegram rejected native command registration (BOT_COMMANDS_TOO_MUCH); leaving menu empty. Reduce commands or disable channels.telegram.commands.native.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
runtime.log?.(
|
||||
`Telegram rejected ${retryCommands.length} commands (BOT_COMMANDS_TOO_MUCH); retrying with ${reducedCount}.`,
|
||||
);
|
||||
retryCommands = retryCommands.slice(0, reducedCount);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void sync().catch((err) => {
|
||||
runtime.error?.(`Telegram command sync failed: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
import { deliverReplies, emitTelegramMessageSentHooks } from "./bot/delivery.js";
|
||||
|
||||
export { createChannelReplyPipeline, deliverReplies, emitTelegramMessageSentHooks };
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { vi } from "vitest";
|
||||
import type { OpenClawConfig, TelegramAccountConfig } from "../runtime-api.js";
|
||||
import type { RegisterTelegramNativeCommandsParams } from "./bot-native-commands.js";
|
||||
|
||||
export type NativeCommandTestParams = RegisterTelegramNativeCommandsParams;
|
||||
|
||||
export function createDeferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
export function createNativeCommandTestParams(
|
||||
params: Partial<NativeCommandTestParams> = {},
|
||||
): NativeCommandTestParams {
|
||||
const log = vi.fn();
|
||||
return {
|
||||
bot:
|
||||
params.bot ??
|
||||
({
|
||||
api: {
|
||||
setMyCommands: vi.fn().mockResolvedValue(undefined),
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
command: vi.fn(),
|
||||
} as unknown as NativeCommandTestParams["bot"]),
|
||||
cfg: params.cfg ?? ({} as OpenClawConfig),
|
||||
runtime:
|
||||
params.runtime ??
|
||||
({
|
||||
log,
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
} as unknown as RuntimeEnv),
|
||||
accountId: params.accountId ?? "default",
|
||||
telegramCfg: params.telegramCfg ?? ({} as TelegramAccountConfig),
|
||||
allowFrom: params.allowFrom ?? [],
|
||||
groupAllowFrom: params.groupAllowFrom ?? [],
|
||||
replyToMode: params.replyToMode ?? "off",
|
||||
textLimit: params.textLimit ?? 4000,
|
||||
useAccessGroups: params.useAccessGroups ?? false,
|
||||
nativeEnabled: params.nativeEnabled ?? true,
|
||||
nativeSkillsEnabled: params.nativeSkillsEnabled ?? false,
|
||||
nativeDisabledExplicit: params.nativeDisabledExplicit ?? false,
|
||||
resolveGroupPolicy:
|
||||
params.resolveGroupPolicy ??
|
||||
(() =>
|
||||
({
|
||||
allowlistEnabled: false,
|
||||
allowed: true,
|
||||
}) as ReturnType<NativeCommandTestParams["resolveGroupPolicy"]>),
|
||||
resolveTelegramGroupConfig:
|
||||
params.resolveTelegramGroupConfig ??
|
||||
((_chatId, _messageThreadId) => ({ groupConfig: undefined, topicConfig: undefined })),
|
||||
shouldSkipUpdate: params.shouldSkipUpdate ?? (() => false),
|
||||
telegramDeps: params.telegramDeps,
|
||||
opts: params.opts ?? { token: "token" },
|
||||
};
|
||||
}
|
||||
|
||||
export function createTelegramPrivateCommandContext(params?: {
|
||||
match?: string;
|
||||
messageId?: number;
|
||||
date?: number;
|
||||
chatId?: number;
|
||||
userId?: number;
|
||||
username?: string;
|
||||
}) {
|
||||
return {
|
||||
match: params?.match ?? "",
|
||||
message: {
|
||||
message_id: params?.messageId ?? 1,
|
||||
date: params?.date ?? Math.floor(Date.now() / 1000),
|
||||
chat: { id: params?.chatId ?? 100, type: "private" as const },
|
||||
from: { id: params?.userId ?? 200, username: params?.username ?? "bob" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createTelegramTopicCommandContext(params?: {
|
||||
match?: string;
|
||||
messageId?: number;
|
||||
date?: number;
|
||||
chatId?: number;
|
||||
title?: string;
|
||||
threadId?: number;
|
||||
userId?: number;
|
||||
username?: string;
|
||||
}) {
|
||||
return {
|
||||
match: params?.match ?? "",
|
||||
message: {
|
||||
message_id: params?.messageId ?? 2,
|
||||
date: params?.date ?? Math.floor(Date.now() / 1000),
|
||||
chat: {
|
||||
id: params?.chatId ?? -1001234567890,
|
||||
type: "supergroup" as const,
|
||||
title: params?.title ?? "OpenClaw",
|
||||
is_forum: true,
|
||||
},
|
||||
message_thread_id: params?.threadId ?? 42,
|
||||
from: { id: params?.userId ?? 200, username: params?.username ?? "bob" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { ChannelGroupPolicy } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createNativeCommandsHarness,
|
||||
createTelegramGroupCommandContext,
|
||||
findNotAuthorizedCalls,
|
||||
} from "./bot-native-commands.test-helpers.js";
|
||||
|
||||
describe("native command auth in groups", () => {
|
||||
function setup(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
telegramCfg?: TelegramAccountConfig;
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
useAccessGroups?: boolean;
|
||||
groupConfig?: Record<string, unknown>;
|
||||
resolveGroupPolicy?: () => ChannelGroupPolicy;
|
||||
}) {
|
||||
return createNativeCommandsHarness({
|
||||
cfg: params.cfg ?? ({} as OpenClawConfig),
|
||||
telegramCfg: params.telegramCfg ?? ({} as TelegramAccountConfig),
|
||||
allowFrom: params.allowFrom ?? [],
|
||||
groupAllowFrom: params.groupAllowFrom ?? [],
|
||||
useAccessGroups: params.useAccessGroups ?? false,
|
||||
resolveGroupPolicy:
|
||||
params.resolveGroupPolicy ??
|
||||
(() =>
|
||||
({
|
||||
allowlistEnabled: false,
|
||||
allowed: true,
|
||||
}) as ChannelGroupPolicy),
|
||||
groupConfig: params.groupConfig,
|
||||
});
|
||||
}
|
||||
|
||||
it("authorizes native commands in groups when sender is in groupAllowFrom", async () => {
|
||||
const { handlers, sendMessage } = setup({
|
||||
groupAllowFrom: ["12345"],
|
||||
useAccessGroups: true,
|
||||
// no allowFrom — sender is NOT in DM allowlist
|
||||
});
|
||||
|
||||
const ctx = createTelegramGroupCommandContext();
|
||||
|
||||
await handlers.status?.(ctx);
|
||||
|
||||
const notAuthCalls = findNotAuthorizedCalls(sendMessage);
|
||||
expect(notAuthCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("authorizes native commands in groups from commands.allowFrom.telegram", async () => {
|
||||
const { handlers, sendMessage } = setup({
|
||||
cfg: {
|
||||
commands: {
|
||||
allowFrom: {
|
||||
telegram: ["12345"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
allowFrom: ["99999"],
|
||||
groupAllowFrom: ["99999"],
|
||||
useAccessGroups: true,
|
||||
});
|
||||
|
||||
const ctx = createTelegramGroupCommandContext();
|
||||
|
||||
await handlers.status?.(ctx);
|
||||
|
||||
const notAuthCalls = findNotAuthorizedCalls(sendMessage);
|
||||
expect(notAuthCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses commands.allowFrom.telegram as the sole auth source when configured", async () => {
|
||||
const { handlers, sendMessage } = setup({
|
||||
cfg: {
|
||||
commands: {
|
||||
allowFrom: {
|
||||
telegram: ["99999"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
groupAllowFrom: ["12345"],
|
||||
useAccessGroups: true,
|
||||
});
|
||||
|
||||
const ctx = createTelegramGroupCommandContext();
|
||||
|
||||
await handlers.status?.(ctx);
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
-100999,
|
||||
"You are not authorized to use this command.",
|
||||
expect.objectContaining({ message_thread_id: 42 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps groupPolicy disabled enforced when commands.allowFrom is configured", async () => {
|
||||
const { handlers, sendMessage } = setup({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "disabled",
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
allowFrom: {
|
||||
telegram: ["12345"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
useAccessGroups: true,
|
||||
resolveGroupPolicy: () =>
|
||||
({
|
||||
allowlistEnabled: false,
|
||||
allowed: false,
|
||||
}) as ChannelGroupPolicy,
|
||||
});
|
||||
|
||||
const ctx = createTelegramGroupCommandContext();
|
||||
|
||||
await handlers.status?.(ctx);
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
-100999,
|
||||
"Telegram group commands are disabled.",
|
||||
expect.objectContaining({ message_thread_id: 42 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps group chat allowlists enforced when commands.allowFrom is configured", async () => {
|
||||
const { handlers, sendMessage } = setup({
|
||||
cfg: {
|
||||
commands: {
|
||||
allowFrom: {
|
||||
telegram: ["12345"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
useAccessGroups: true,
|
||||
resolveGroupPolicy: () =>
|
||||
({
|
||||
allowlistEnabled: true,
|
||||
allowed: false,
|
||||
}) as ChannelGroupPolicy,
|
||||
});
|
||||
|
||||
const ctx = createTelegramGroupCommandContext();
|
||||
|
||||
await handlers.status?.(ctx);
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
-100999,
|
||||
"This group is not allowed.",
|
||||
expect.objectContaining({ message_thread_id: 42 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects native commands in groups when sender is in neither allowlist", async () => {
|
||||
const { handlers, sendMessage } = setup({
|
||||
allowFrom: ["99999"],
|
||||
groupAllowFrom: ["99999"],
|
||||
useAccessGroups: true,
|
||||
});
|
||||
|
||||
const ctx = createTelegramGroupCommandContext({
|
||||
username: "intruder",
|
||||
});
|
||||
|
||||
await handlers.status?.(ctx);
|
||||
|
||||
const notAuthCalls = findNotAuthorizedCalls(sendMessage);
|
||||
expect(notAuthCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("replies in the originating forum topic when auth is rejected", async () => {
|
||||
const { handlers, sendMessage } = setup({
|
||||
allowFrom: ["99999"],
|
||||
groupAllowFrom: ["99999"],
|
||||
useAccessGroups: true,
|
||||
});
|
||||
|
||||
const ctx = createTelegramGroupCommandContext({
|
||||
username: "intruder",
|
||||
});
|
||||
|
||||
await handlers.status?.(ctx);
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
-100999,
|
||||
"You are not authorized to use this command.",
|
||||
expect.objectContaining({ message_thread_id: 42 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { expect, vi, type Mock } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import type { TelegramNativeCommandDeps } from "./bot-native-command-deps.runtime.js";
|
||||
import {
|
||||
createNativeCommandTestParams as createBaseNativeCommandTestParams,
|
||||
createTelegramPrivateCommandContext,
|
||||
type NativeCommandTestParams as RegisterTelegramNativeCommandsParams,
|
||||
} from "./bot-native-commands.fixture-test-support.js";
|
||||
|
||||
type RegisteredCommand = {
|
||||
command: string;
|
||||
description: string;
|
||||
};
|
||||
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
|
||||
|
||||
type CreateCommandBotResult = {
|
||||
bot: RegisterTelegramNativeCommandsParams["bot"];
|
||||
commandHandlers: Map<string, (ctx: unknown) => Promise<void>>;
|
||||
sendMessage: ReturnType<typeof vi.fn>;
|
||||
deleteMessage: ReturnType<typeof vi.fn>;
|
||||
setMyCommands: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
type CreateCommandBotParams = {
|
||||
api?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const skillCommandMocks = vi.hoisted(() => ({
|
||||
listSkillCommandsForAgents: vi.fn<TelegramNativeCommandDeps["listSkillCommandsForAgents"]>(
|
||||
() => [],
|
||||
),
|
||||
}));
|
||||
|
||||
const deliveryMocks = vi.hoisted(() => ({
|
||||
deliverReplies: vi.fn(async () => ({ delivered: true })),
|
||||
editMessageTelegram: vi.fn(async () => ({ ok: true as const, messageId: "999", chatId: "100" })),
|
||||
emitTelegramMessageSentHooks: vi.fn(),
|
||||
}));
|
||||
|
||||
export const listSkillCommandsForAgents = skillCommandMocks.listSkillCommandsForAgents;
|
||||
export const deliverReplies = deliveryMocks.deliverReplies;
|
||||
export const editMessageTelegram = deliveryMocks.editMessageTelegram;
|
||||
export const emitTelegramMessageSentHooks: UnknownMock = deliveryMocks.emitTelegramMessageSentHooks;
|
||||
|
||||
vi.mock("./bot/delivery.js", () => ({
|
||||
deliverReplies,
|
||||
emitTelegramMessageSentHooks,
|
||||
}));
|
||||
|
||||
vi.mock("./bot/delivery.replies.js", () => ({
|
||||
deliverReplies,
|
||||
}));
|
||||
|
||||
export async function waitForRegisteredCommands(
|
||||
setMyCommands: ReturnType<typeof vi.fn>,
|
||||
): Promise<RegisteredCommand[]> {
|
||||
await vi.waitFor(() => {
|
||||
expect(setMyCommands).toHaveBeenCalled();
|
||||
});
|
||||
return setMyCommands.mock.calls[0]?.[0] as RegisteredCommand[];
|
||||
}
|
||||
|
||||
export function resetNativeCommandMenuMocks() {
|
||||
listSkillCommandsForAgents.mockClear();
|
||||
listSkillCommandsForAgents.mockReturnValue([]);
|
||||
deliverReplies.mockClear();
|
||||
deliverReplies.mockResolvedValue({ delivered: true });
|
||||
editMessageTelegram.mockClear();
|
||||
editMessageTelegram.mockResolvedValue({ ok: true as const, messageId: "999", chatId: "100" });
|
||||
emitTelegramMessageSentHooks.mockClear();
|
||||
}
|
||||
|
||||
export function createCommandBot(params: CreateCommandBotParams = {}): CreateCommandBotResult {
|
||||
const commandHandlers = new Map<string, (ctx: unknown) => Promise<void>>();
|
||||
const sendMessage = vi.fn().mockResolvedValue({ message_id: 999 });
|
||||
const deleteMessage = vi.fn().mockResolvedValue(true);
|
||||
const setMyCommands = vi.fn().mockResolvedValue(undefined);
|
||||
const bot = {
|
||||
api: {
|
||||
setMyCommands,
|
||||
sendMessage,
|
||||
deleteMessage,
|
||||
...params.api,
|
||||
},
|
||||
command: vi.fn((name: string, cb: (ctx: unknown) => Promise<void>) => {
|
||||
commandHandlers.set(name, cb);
|
||||
}),
|
||||
} as unknown as RegisterTelegramNativeCommandsParams["bot"];
|
||||
return { bot, commandHandlers, sendMessage, deleteMessage, setMyCommands };
|
||||
}
|
||||
|
||||
export function createNativeCommandTestParams(
|
||||
cfg: OpenClawConfig,
|
||||
params: Partial<RegisterTelegramNativeCommandsParams> = {},
|
||||
): RegisterTelegramNativeCommandsParams {
|
||||
const dispatchResult: Awaited<
|
||||
ReturnType<TelegramNativeCommandDeps["dispatchReplyWithBufferedBlockDispatcher"]>
|
||||
> = {
|
||||
queuedFinal: false,
|
||||
counts: { block: 0, final: 0, tool: 0 },
|
||||
};
|
||||
const telegramDeps: TelegramNativeCommandDeps = {
|
||||
loadConfig: vi.fn(() => cfg) as TelegramNativeCommandDeps["loadConfig"],
|
||||
readChannelAllowFromStore: vi.fn(
|
||||
async () => [],
|
||||
) as TelegramNativeCommandDeps["readChannelAllowFromStore"],
|
||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn(
|
||||
async () => dispatchResult,
|
||||
) as TelegramNativeCommandDeps["dispatchReplyWithBufferedBlockDispatcher"],
|
||||
listSkillCommandsForAgents,
|
||||
syncTelegramMenuCommands: vi.fn(({ bot, commandsToRegister }) => {
|
||||
if (commandsToRegister.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return bot.api.setMyCommands(commandsToRegister);
|
||||
}) as TelegramNativeCommandDeps["syncTelegramMenuCommands"],
|
||||
editMessageTelegram,
|
||||
};
|
||||
return createBaseNativeCommandTestParams({
|
||||
cfg,
|
||||
runtime: params.runtime ?? ({} as RuntimeEnv),
|
||||
nativeSkillsEnabled: true,
|
||||
telegramDeps,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
export function createPrivateCommandContext(
|
||||
params?: Parameters<typeof createTelegramPrivateCommandContext>[0],
|
||||
) {
|
||||
return createTelegramPrivateCommandContext(params);
|
||||
}
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
let registerTelegramNativeCommands: typeof import("./bot-native-commands.js").registerTelegramNativeCommands;
|
||||
let clearPluginCommands: typeof import("../../../src/plugins/commands.js").clearPluginCommands;
|
||||
let registerPluginCommand: typeof import("../../../src/plugins/commands.js").registerPluginCommand;
|
||||
let setActivePluginRegistry: typeof import("../../../src/plugins/runtime.js").setActivePluginRegistry;
|
||||
let createCommandBot: typeof import("./bot-native-commands.menu-test-support.js").createCommandBot;
|
||||
let createNativeCommandTestParams: typeof import("./bot-native-commands.menu-test-support.js").createNativeCommandTestParams;
|
||||
let createPrivateCommandContext: typeof import("./bot-native-commands.menu-test-support.js").createPrivateCommandContext;
|
||||
let deliverReplies: typeof import("./bot-native-commands.menu-test-support.js").deliverReplies;
|
||||
let editMessageTelegram: typeof import("./bot-native-commands.menu-test-support.js").editMessageTelegram;
|
||||
let resetNativeCommandMenuMocks: typeof import("./bot-native-commands.menu-test-support.js").resetNativeCommandMenuMocks;
|
||||
let waitForRegisteredCommands: typeof import("./bot-native-commands.menu-test-support.js").waitForRegisteredCommands;
|
||||
|
||||
function createTelegramPluginRegistry() {
|
||||
return {
|
||||
plugins: [],
|
||||
tools: [],
|
||||
hooks: [],
|
||||
typedHooks: [],
|
||||
channels: [
|
||||
{
|
||||
pluginId: "telegram",
|
||||
source: "test",
|
||||
plugin: {
|
||||
id: "telegram",
|
||||
meta: {
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
selectionLabel: "Telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
blurb: "test stub.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
commands: {
|
||||
nativeCommandsAutoEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
channelSetups: [
|
||||
{
|
||||
pluginId: "telegram",
|
||||
source: "test",
|
||||
enabled: true,
|
||||
plugin: {
|
||||
id: "telegram",
|
||||
},
|
||||
},
|
||||
],
|
||||
providers: [],
|
||||
speechProviders: [],
|
||||
mediaUnderstandingProviders: [],
|
||||
imageGenerationProviders: [],
|
||||
videoGenerationProviders: [],
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
gatewayHandlers: {},
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
conversationBindingResolvedHandlers: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
function registerPairPluginCommand(params?: {
|
||||
nativeNames?: { telegram?: string; discord?: string };
|
||||
nativeProgressMessages?: { telegram?: string; default?: string };
|
||||
}) {
|
||||
expect(
|
||||
registerPluginCommand("demo-plugin", {
|
||||
name: "pair",
|
||||
...(params?.nativeNames ? { nativeNames: params.nativeNames } : {}),
|
||||
...(params?.nativeProgressMessages
|
||||
? { nativeProgressMessages: params.nativeProgressMessages }
|
||||
: {}),
|
||||
description: "Pair device",
|
||||
acceptsArgs: true,
|
||||
requireAuth: false,
|
||||
handler: async ({ args }) => ({ text: `paired:${args ?? ""}` }),
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
}
|
||||
|
||||
async function registerPairMenu(params: {
|
||||
bot: ReturnType<typeof createCommandBot>["bot"];
|
||||
setMyCommands: ReturnType<typeof createCommandBot>["setMyCommands"];
|
||||
nativeNames?: { telegram?: string; discord?: string };
|
||||
nativeProgressMessages?: { telegram?: string; default?: string };
|
||||
}) {
|
||||
registerPairPluginCommand({
|
||||
...(params.nativeNames ? { nativeNames: params.nativeNames } : {}),
|
||||
...(params.nativeProgressMessages
|
||||
? { nativeProgressMessages: params.nativeProgressMessages }
|
||||
: {}),
|
||||
});
|
||||
|
||||
registerTelegramNativeCommands({
|
||||
...createNativeCommandTestParams({}),
|
||||
bot: params.bot,
|
||||
});
|
||||
|
||||
return await waitForRegisteredCommands(params.setMyCommands);
|
||||
}
|
||||
|
||||
describe("registerTelegramNativeCommands real plugin registry", () => {
|
||||
beforeAll(async () => {
|
||||
({ clearPluginCommands, registerPluginCommand } =
|
||||
await import("../../../src/plugins/commands.js"));
|
||||
({ setActivePluginRegistry } = await import("../../../src/plugins/runtime.js"));
|
||||
({ registerTelegramNativeCommands } = await import("./bot-native-commands.js"));
|
||||
({
|
||||
createCommandBot,
|
||||
createNativeCommandTestParams,
|
||||
createPrivateCommandContext,
|
||||
deliverReplies,
|
||||
editMessageTelegram,
|
||||
resetNativeCommandMenuMocks,
|
||||
waitForRegisteredCommands,
|
||||
} = await import("./bot-native-commands.menu-test-support.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(createTelegramPluginRegistry() as never);
|
||||
clearPluginCommands();
|
||||
resetNativeCommandMenuMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearPluginCommands();
|
||||
});
|
||||
|
||||
it("registers and executes plugin commands through the real plugin registry", async () => {
|
||||
const { bot, commandHandlers, sendMessage, setMyCommands } = createCommandBot();
|
||||
|
||||
const registeredCommands = await registerPairMenu({ bot, setMyCommands });
|
||||
expect(registeredCommands).toEqual(
|
||||
expect.arrayContaining([{ command: "pair", description: "Pair device" }]),
|
||||
);
|
||||
|
||||
const handler = commandHandlers.get("pair");
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
await handler?.(createPrivateCommandContext({ match: "now" }));
|
||||
|
||||
expect(deliverReplies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replies: [expect.objectContaining({ text: "paired:now" })],
|
||||
}),
|
||||
);
|
||||
expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found.");
|
||||
});
|
||||
|
||||
it("uses plugin command metadata to send and edit a Telegram progress placeholder", async () => {
|
||||
const { bot, commandHandlers, setMyCommands, sendMessage } = createCommandBot();
|
||||
|
||||
await registerPairMenu({
|
||||
bot,
|
||||
setMyCommands,
|
||||
nativeProgressMessages: {
|
||||
telegram:
|
||||
"Running pair now...\n\nI'll edit this message with the final result when it's ready.",
|
||||
},
|
||||
});
|
||||
|
||||
const handler = commandHandlers.get("pair");
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
await handler?.(createPrivateCommandContext({ match: "now" }));
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
100,
|
||||
expect.stringContaining("Running pair now"),
|
||||
undefined,
|
||||
);
|
||||
expect(editMessageTelegram).toHaveBeenCalledWith(
|
||||
100,
|
||||
999,
|
||||
"paired:now",
|
||||
expect.objectContaining({ accountId: "default" }),
|
||||
);
|
||||
expect(deliverReplies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("round-trips Telegram native aliases through the real plugin registry", async () => {
|
||||
const { bot, commandHandlers, sendMessage, setMyCommands } = createCommandBot();
|
||||
|
||||
const registeredCommands = await registerPairMenu({
|
||||
bot,
|
||||
setMyCommands,
|
||||
nativeNames: {
|
||||
telegram: "pair_device",
|
||||
discord: "pairdiscord",
|
||||
},
|
||||
});
|
||||
expect(registeredCommands).toEqual(
|
||||
expect.arrayContaining([{ command: "pair_device", description: "Pair device" }]),
|
||||
);
|
||||
|
||||
const handler = commandHandlers.get("pair_device");
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
await handler?.(createPrivateCommandContext({ match: "now", messageId: 2 }));
|
||||
|
||||
expect(deliverReplies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replies: [expect.objectContaining({ text: "paired:now" })],
|
||||
}),
|
||||
);
|
||||
expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found.");
|
||||
});
|
||||
|
||||
it("keeps real plugin command handlers available when native menu registration is disabled", () => {
|
||||
const { bot, commandHandlers, setMyCommands } = createCommandBot();
|
||||
|
||||
registerPairPluginCommand();
|
||||
|
||||
registerTelegramNativeCommands({
|
||||
...createNativeCommandTestParams({}, { accountId: "default" }),
|
||||
bot,
|
||||
nativeEnabled: false,
|
||||
});
|
||||
|
||||
expect(setMyCommands).not.toHaveBeenCalled();
|
||||
expect(commandHandlers.has("pair")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows requireAuth:false plugin commands for unauthorized senders through the real registry", async () => {
|
||||
const { bot, commandHandlers, sendMessage, setMyCommands } = createCommandBot();
|
||||
|
||||
registerPairPluginCommand();
|
||||
|
||||
registerTelegramNativeCommands({
|
||||
...createNativeCommandTestParams({
|
||||
commands: { allowFrom: { telegram: ["999"] } } as OpenClawConfig["commands"],
|
||||
}),
|
||||
bot,
|
||||
allowFrom: ["999"],
|
||||
nativeEnabled: false,
|
||||
});
|
||||
|
||||
expect(setMyCommands).not.toHaveBeenCalled();
|
||||
|
||||
const handler = commandHandlers.get("pair");
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
await handler?.(
|
||||
createPrivateCommandContext({
|
||||
match: "now",
|
||||
messageId: 10,
|
||||
date: 123456,
|
||||
userId: 111,
|
||||
username: "nope",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(deliverReplies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replies: [expect.objectContaining({ text: "paired:now" })],
|
||||
}),
|
||||
);
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
export {
|
||||
ensureConfiguredBindingRouteReady,
|
||||
recordInboundSessionMetaSafe,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
export { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
|
||||
export {
|
||||
executePluginCommand,
|
||||
getPluginCommandSpecs,
|
||||
matchPluginCommand,
|
||||
} from "openclaw/plugin-sdk/plugin-runtime";
|
||||
export {
|
||||
finalizeInboundContext,
|
||||
resolveChunkMode,
|
||||
} from "openclaw/plugin-sdk/reply-dispatch-runtime";
|
||||
export { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
|
||||
|
|
@ -0,0 +1,731 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TelegramNativeCommandDeps } from "./bot-native-command-deps.runtime.js";
|
||||
import {
|
||||
createDeferred,
|
||||
createNativeCommandTestParams,
|
||||
createTelegramPrivateCommandContext,
|
||||
createTelegramTopicCommandContext,
|
||||
type NativeCommandTestParams,
|
||||
} from "./bot-native-commands.fixture-test-support.js";
|
||||
import { type RegisterTelegramHandlerParams } from "./bot-native-commands.js";
|
||||
|
||||
// All mocks scoped to this file only — does not affect bot-native-commands.test.ts
|
||||
|
||||
type ResolveConfiguredBindingRouteFn =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute;
|
||||
type EnsureConfiguredBindingRouteReadyFn =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
|
||||
type DispatchReplyWithBufferedBlockDispatcherFn =
|
||||
typeof import("../../../src/auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher;
|
||||
type DispatchReplyWithBufferedBlockDispatcherParams =
|
||||
Parameters<DispatchReplyWithBufferedBlockDispatcherFn>[0];
|
||||
type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
|
||||
ReturnType<DispatchReplyWithBufferedBlockDispatcherFn>
|
||||
>;
|
||||
type DeliverRepliesFn = typeof import("./bot/delivery.js").deliverReplies;
|
||||
type DeliverRepliesParams = Parameters<DeliverRepliesFn>[0];
|
||||
|
||||
const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = {
|
||||
queuedFinal: false,
|
||||
counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"],
|
||||
};
|
||||
|
||||
const persistentBindingMocks = vi.hoisted(() => ({
|
||||
resolveConfiguredBindingRoute: vi.fn<ResolveConfiguredBindingRouteFn>(({ route }) => ({
|
||||
bindingResolution: null,
|
||||
route,
|
||||
})),
|
||||
ensureConfiguredBindingRouteReady: vi.fn<EnsureConfiguredBindingRouteReadyFn>(async () => ({
|
||||
ok: true,
|
||||
})),
|
||||
}));
|
||||
const sessionMocks = vi.hoisted(() => ({
|
||||
recordSessionMetaFromInbound: vi.fn(),
|
||||
resolveStorePath: vi.fn(),
|
||||
}));
|
||||
const replyMocks = vi.hoisted(() => ({
|
||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn<DispatchReplyWithBufferedBlockDispatcherFn>(
|
||||
async () => dispatchReplyResult,
|
||||
),
|
||||
}));
|
||||
const deliveryMocks = vi.hoisted(() => ({
|
||||
deliverReplies: vi.fn<DeliverRepliesFn>(async () => ({ delivered: true })),
|
||||
}));
|
||||
const sessionBindingMocks = vi.hoisted(() => ({
|
||||
resolveByConversation: vi.fn<
|
||||
(ref: unknown) => { bindingId: string; targetSessionKey: string } | null
|
||||
>(() => null),
|
||||
touch: vi.fn(),
|
||||
}));
|
||||
const conversationStoreMocks = vi.hoisted(() => ({
|
||||
readChannelAllowFromStore: vi.fn(async () => []),
|
||||
upsertChannelPairingRequest: vi.fn(async () => ({ code: "PAIRCODE", created: true })),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
|
||||
"openclaw/plugin-sdk/conversation-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredBindingRoute,
|
||||
ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady,
|
||||
recordInboundSessionMetaSafe: vi.fn(
|
||||
async (params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
ctx: unknown;
|
||||
onError?: (error: unknown) => void;
|
||||
}) => {
|
||||
const storePath = sessionMocks.resolveStorePath(params.cfg.session?.store, {
|
||||
agentId: params.agentId,
|
||||
});
|
||||
try {
|
||||
await sessionMocks.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: params.sessionKey,
|
||||
ctx: params.ctx,
|
||||
});
|
||||
} catch (error) {
|
||||
params.onError?.(error);
|
||||
}
|
||||
},
|
||||
),
|
||||
readChannelAllowFromStore: conversationStoreMocks.readChannelAllowFromStore,
|
||||
upsertChannelPairingRequest: conversationStoreMocks.upsertChannelPairingRequest,
|
||||
getSessionBindingService: () => ({
|
||||
bind: vi.fn(),
|
||||
getCapabilities: vi.fn(),
|
||||
listBySession: vi.fn(),
|
||||
resolveByConversation: (ref: unknown) => sessionBindingMocks.resolveByConversation(ref),
|
||||
touch: (bindingId: string, at?: number) => sessionBindingMocks.touch(bindingId, at),
|
||||
unbind: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("./bot-native-commands.runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./bot-native-commands.runtime.js")>(
|
||||
"./bot-native-commands.runtime.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
|
||||
dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher,
|
||||
};
|
||||
});
|
||||
vi.mock("../../../src/pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: vi.fn(async () => []),
|
||||
}));
|
||||
vi.mock("../../../src/plugins/commands.js", () => ({
|
||||
getPluginCommandSpecs: vi.fn(() => []),
|
||||
matchPluginCommand: vi.fn(() => null),
|
||||
executePluginCommand: vi.fn(async () => ({ text: "ok" })),
|
||||
}));
|
||||
vi.mock("./bot/delivery.js", () => ({
|
||||
deliverReplies: deliveryMocks.deliverReplies,
|
||||
}));
|
||||
vi.mock("./bot/delivery.replies.js", () => ({
|
||||
deliverReplies: deliveryMocks.deliverReplies,
|
||||
}));
|
||||
|
||||
let registerTelegramNativeCommands: typeof import("./bot-native-commands.js").registerTelegramNativeCommands;
|
||||
|
||||
type TelegramCommandHandler = (ctx: unknown) => Promise<void>;
|
||||
|
||||
function registerAndResolveStatusHandler(params: {
|
||||
cfg: OpenClawConfig;
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
telegramCfg?: NativeCommandTestParams["telegramCfg"];
|
||||
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
|
||||
}): {
|
||||
handler: TelegramCommandHandler;
|
||||
sendMessage: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const { cfg, allowFrom, groupAllowFrom, telegramCfg, resolveTelegramGroupConfig } = params;
|
||||
return registerAndResolveCommandHandlerBase({
|
||||
commandName: "status",
|
||||
cfg,
|
||||
allowFrom: allowFrom ?? ["*"],
|
||||
groupAllowFrom: groupAllowFrom ?? [],
|
||||
useAccessGroups: true,
|
||||
telegramCfg,
|
||||
resolveTelegramGroupConfig,
|
||||
});
|
||||
}
|
||||
|
||||
function registerAndResolveCommandHandlerBase(params: {
|
||||
commandName: string;
|
||||
cfg: OpenClawConfig;
|
||||
allowFrom: string[];
|
||||
groupAllowFrom: string[];
|
||||
useAccessGroups: boolean;
|
||||
telegramCfg?: NativeCommandTestParams["telegramCfg"];
|
||||
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
|
||||
}): {
|
||||
handler: TelegramCommandHandler;
|
||||
sendMessage: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const {
|
||||
commandName,
|
||||
cfg,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
useAccessGroups,
|
||||
telegramCfg,
|
||||
resolveTelegramGroupConfig,
|
||||
} = params;
|
||||
const commandHandlers = new Map<string, TelegramCommandHandler>();
|
||||
const sendMessage = vi.fn().mockResolvedValue(undefined);
|
||||
const telegramDeps: TelegramNativeCommandDeps = {
|
||||
loadConfig: vi.fn(() => cfg),
|
||||
readChannelAllowFromStore: vi.fn(async () => []),
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
replyMocks.dispatchReplyWithBufferedBlockDispatcher as TelegramNativeCommandDeps["dispatchReplyWithBufferedBlockDispatcher"],
|
||||
getPluginCommandSpecs: vi.fn(() => []),
|
||||
listSkillCommandsForAgents: vi.fn(() => []),
|
||||
syncTelegramMenuCommands: vi.fn(),
|
||||
};
|
||||
registerTelegramNativeCommands({
|
||||
...createNativeCommandTestParams({
|
||||
bot: {
|
||||
api: {
|
||||
setMyCommands: vi.fn().mockResolvedValue(undefined),
|
||||
sendMessage,
|
||||
},
|
||||
command: vi.fn((name: string, cb: TelegramCommandHandler) => {
|
||||
commandHandlers.set(name, cb);
|
||||
}),
|
||||
} as unknown as NativeCommandTestParams["bot"],
|
||||
cfg,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
useAccessGroups,
|
||||
telegramCfg,
|
||||
resolveTelegramGroupConfig,
|
||||
telegramDeps,
|
||||
}),
|
||||
});
|
||||
|
||||
const handler = commandHandlers.get(commandName);
|
||||
expect(handler).toBeTruthy();
|
||||
return { handler: handler as TelegramCommandHandler, sendMessage };
|
||||
}
|
||||
|
||||
function registerAndResolveCommandHandler(params: {
|
||||
commandName: string;
|
||||
cfg: OpenClawConfig;
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
useAccessGroups?: boolean;
|
||||
telegramCfg?: NativeCommandTestParams["telegramCfg"];
|
||||
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
|
||||
}): {
|
||||
handler: TelegramCommandHandler;
|
||||
sendMessage: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const {
|
||||
commandName,
|
||||
cfg,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
useAccessGroups,
|
||||
telegramCfg,
|
||||
resolveTelegramGroupConfig,
|
||||
} = params;
|
||||
return registerAndResolveCommandHandlerBase({
|
||||
commandName,
|
||||
cfg,
|
||||
allowFrom: allowFrom ?? [],
|
||||
groupAllowFrom: groupAllowFrom ?? [],
|
||||
useAccessGroups: useAccessGroups ?? true,
|
||||
telegramCfg,
|
||||
resolveTelegramGroupConfig,
|
||||
});
|
||||
}
|
||||
|
||||
function createConfiguredAcpTopicBinding(boundSessionKey: string) {
|
||||
return {
|
||||
spec: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-1001234567890:topic:42",
|
||||
parentConversationId: "-1001234567890",
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
},
|
||||
record: {
|
||||
bindingId: "config:acp:telegram:default:-1001234567890:topic:42",
|
||||
targetSessionKey: boundSessionKey,
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-1001234567890:topic:42",
|
||||
parentConversationId: "-1001234567890",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
function createConfiguredBindingRoute(
|
||||
route: ResolvedAgentRoute,
|
||||
binding: ReturnType<typeof createConfiguredAcpTopicBinding> | null,
|
||||
) {
|
||||
return {
|
||||
bindingResolution: binding
|
||||
? {
|
||||
conversation: binding.record.conversation,
|
||||
compiledBinding: {
|
||||
channel: "telegram" as const,
|
||||
binding: {
|
||||
type: "acp" as const,
|
||||
agentId: binding.spec.agentId,
|
||||
match: {
|
||||
channel: "telegram",
|
||||
accountId: binding.spec.accountId,
|
||||
peer: {
|
||||
kind: "group" as const,
|
||||
id: binding.spec.conversationId,
|
||||
},
|
||||
},
|
||||
acp: {
|
||||
mode: binding.spec.mode,
|
||||
},
|
||||
},
|
||||
bindingConversationId: binding.spec.conversationId,
|
||||
target: {
|
||||
conversationId: binding.spec.conversationId,
|
||||
...(binding.spec.parentConversationId
|
||||
? { parentConversationId: binding.spec.parentConversationId }
|
||||
: {}),
|
||||
},
|
||||
agentId: binding.spec.agentId,
|
||||
provider: {
|
||||
compileConfiguredBinding: () => ({
|
||||
conversationId: binding.spec.conversationId,
|
||||
...(binding.spec.parentConversationId
|
||||
? { parentConversationId: binding.spec.parentConversationId }
|
||||
: {}),
|
||||
}),
|
||||
matchInboundConversation: () => ({
|
||||
conversationId: binding.spec.conversationId,
|
||||
...(binding.spec.parentConversationId
|
||||
? { parentConversationId: binding.spec.parentConversationId }
|
||||
: {}),
|
||||
}),
|
||||
},
|
||||
targetFactory: {
|
||||
driverId: "acp" as const,
|
||||
materialize: () => ({
|
||||
record: binding.record,
|
||||
statefulTarget: {
|
||||
kind: "stateful" as const,
|
||||
driverId: "acp" as const,
|
||||
sessionKey: binding.record.targetSessionKey,
|
||||
agentId: binding.spec.agentId,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
match: {
|
||||
conversationId: binding.spec.conversationId,
|
||||
...(binding.spec.parentConversationId
|
||||
? { parentConversationId: binding.spec.parentConversationId }
|
||||
: {}),
|
||||
},
|
||||
record: binding.record,
|
||||
statefulTarget: {
|
||||
kind: "stateful" as const,
|
||||
driverId: "acp" as const,
|
||||
sessionKey: binding.record.targetSessionKey,
|
||||
agentId: binding.spec.agentId,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
...(binding ? { boundSessionKey: binding.record.targetSessionKey } : {}),
|
||||
route,
|
||||
};
|
||||
}
|
||||
|
||||
function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType<typeof vi.fn>) {
|
||||
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
expect(persistentBindingMocks.resolveConfiguredBindingRoute).not.toHaveBeenCalled();
|
||||
expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).not.toHaveBeenCalled();
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
-1001234567890,
|
||||
"You are not authorized to use this command.",
|
||||
expect.objectContaining({ message_thread_id: 42 }),
|
||||
);
|
||||
}
|
||||
|
||||
describe("registerTelegramNativeCommands — session metadata", () => {
|
||||
beforeAll(async () => {
|
||||
({ registerTelegramNativeCommands } = await import("./bot-native-commands.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
persistentBindingMocks.resolveConfiguredBindingRoute.mockClear();
|
||||
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
|
||||
createConfiguredBindingRoute(route, null),
|
||||
);
|
||||
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockClear();
|
||||
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true });
|
||||
sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined);
|
||||
sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json");
|
||||
replyMocks.dispatchReplyWithBufferedBlockDispatcher
|
||||
.mockClear()
|
||||
.mockResolvedValue(dispatchReplyResult);
|
||||
sessionBindingMocks.resolveByConversation.mockReset().mockReturnValue(null);
|
||||
sessionBindingMocks.touch.mockReset();
|
||||
deliveryMocks.deliverReplies.mockClear().mockResolvedValue({ delivered: true });
|
||||
});
|
||||
|
||||
it("calls recordSessionMetaFromInbound after a native slash command", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { handler } = registerAndResolveStatusHandler({ cfg });
|
||||
await handler(createTelegramPrivateCommandContext());
|
||||
|
||||
expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1);
|
||||
const call = (
|
||||
sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array<
|
||||
[{ sessionKey?: string; ctx?: { OriginatingChannel?: string; Provider?: string } }]
|
||||
>
|
||||
)[0]?.[0];
|
||||
expect(call?.ctx?.OriginatingChannel).toBe("telegram");
|
||||
expect(call?.ctx?.Provider).toBe("telegram");
|
||||
expect(call?.sessionKey).toBe("agent:main:telegram:slash:200");
|
||||
});
|
||||
|
||||
it("awaits session metadata persistence before dispatch", async () => {
|
||||
const deferred = createDeferred<void>();
|
||||
sessionMocks.recordSessionMetaFromInbound.mockReturnValue(deferred.promise);
|
||||
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { handler } = registerAndResolveStatusHandler({ cfg });
|
||||
const runPromise = handler(createTelegramPrivateCommandContext());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
|
||||
deferred.resolve();
|
||||
await runPromise;
|
||||
|
||||
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not inject approval buttons for native command replies once the monitor owns approvals", async () => {
|
||||
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
|
||||
async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => {
|
||||
await dispatcherOptions.deliver(
|
||||
{
|
||||
text: "Mode: foreground\nRun: /approve 7f423fdc allow-once (or allow-always / deny).",
|
||||
},
|
||||
{ kind: "final" },
|
||||
);
|
||||
return dispatchReplyResult;
|
||||
},
|
||||
);
|
||||
|
||||
const { handler } = registerAndResolveStatusHandler({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["12345"],
|
||||
target: "dm",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await handler(createTelegramPrivateCommandContext());
|
||||
|
||||
const deliveredCall = deliveryMocks.deliverReplies.mock.calls[0]?.[0] as
|
||||
| DeliverRepliesParams
|
||||
| undefined;
|
||||
const deliveredPayload = deliveredCall?.replies?.[0];
|
||||
expect(deliveredPayload).toBeTruthy();
|
||||
expect(deliveredPayload?.["text"]).toContain("/approve 7f423fdc allow-once");
|
||||
expect(deliveredPayload?.["channelData"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("suppresses local structured exec approval replies for native commands", async () => {
|
||||
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
|
||||
async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => {
|
||||
await dispatcherOptions.deliver(
|
||||
{
|
||||
text: "Approval required.\n\n```txt\n/approve 7f423fdc allow-once\n```",
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: "7f423fdc-1111-2222-3333-444444444444",
|
||||
approvalSlug: "7f423fdc",
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ kind: "tool" },
|
||||
);
|
||||
return dispatchReplyResult;
|
||||
},
|
||||
);
|
||||
|
||||
const { handler } = registerAndResolveStatusHandler({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["12345"],
|
||||
target: "dm",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await handler(createTelegramPrivateCommandContext());
|
||||
|
||||
expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends native command error replies silently when silentErrorReplies is enabled", async () => {
|
||||
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
|
||||
async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => {
|
||||
await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" });
|
||||
return dispatchReplyResult;
|
||||
},
|
||||
);
|
||||
|
||||
const { handler } = registerAndResolveStatusHandler({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
silentErrorReplies: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
telegramCfg: { silentErrorReplies: true },
|
||||
});
|
||||
await handler(createTelegramPrivateCommandContext());
|
||||
|
||||
const deliveredCall = deliveryMocks.deliverReplies.mock.calls[0]?.[0] as
|
||||
| DeliverRepliesParams
|
||||
| undefined;
|
||||
expect(deliveredCall).toEqual(
|
||||
expect.objectContaining({
|
||||
silent: true,
|
||||
replies: [expect.objectContaining({ isError: true })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes Telegram native commands through configured ACP topic bindings", async () => {
|
||||
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
|
||||
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
|
||||
createConfiguredBindingRoute(
|
||||
{
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: "codex",
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
createConfiguredAcpTopicBinding(boundSessionKey),
|
||||
),
|
||||
);
|
||||
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true });
|
||||
|
||||
const { handler } = registerAndResolveStatusHandler({
|
||||
cfg: {},
|
||||
allowFrom: ["200"],
|
||||
groupAllowFrom: ["200"],
|
||||
});
|
||||
await handler(createTelegramTopicCommandContext());
|
||||
|
||||
expect(persistentBindingMocks.resolveConfiguredBindingRoute).toHaveBeenCalledTimes(1);
|
||||
expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).toHaveBeenCalledTimes(1);
|
||||
const dispatchCall = (
|
||||
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array<
|
||||
[{ ctx?: { CommandTargetSessionKey?: string } }]
|
||||
>
|
||||
)[0]?.[0];
|
||||
expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
|
||||
const sessionMetaCall = (
|
||||
sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array<
|
||||
[{ sessionKey?: string }]
|
||||
>
|
||||
)[0]?.[0];
|
||||
expect(sessionMetaCall?.sessionKey).toBe("agent:codex:telegram:slash:200");
|
||||
});
|
||||
|
||||
it("routes Telegram native commands through topic-specific agent sessions", async () => {
|
||||
const { handler } = registerAndResolveStatusHandler({
|
||||
cfg: {},
|
||||
allowFrom: ["200"],
|
||||
groupAllowFrom: ["200"],
|
||||
resolveTelegramGroupConfig: () => ({
|
||||
groupConfig: { requireMention: false },
|
||||
topicConfig: { agentId: "zu" },
|
||||
}),
|
||||
});
|
||||
await handler(createTelegramTopicCommandContext());
|
||||
|
||||
const dispatchCall = (
|
||||
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array<
|
||||
[{ ctx?: { CommandTargetSessionKey?: string } }]
|
||||
>
|
||||
)[0]?.[0];
|
||||
expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe(
|
||||
"agent:zu:telegram:group:-1001234567890:topic:42",
|
||||
);
|
||||
});
|
||||
|
||||
it("routes Telegram native commands through bound topic sessions", async () => {
|
||||
sessionBindingMocks.resolveByConversation.mockReturnValue({
|
||||
bindingId: "default:-1001234567890:topic:42",
|
||||
targetSessionKey: "agent:codex-acp:session-1",
|
||||
});
|
||||
|
||||
const { handler } = registerAndResolveStatusHandler({
|
||||
cfg: {},
|
||||
allowFrom: ["200"],
|
||||
groupAllowFrom: ["200"],
|
||||
});
|
||||
await handler(createTelegramTopicCommandContext());
|
||||
|
||||
expect(sessionBindingMocks.resolveByConversation).toHaveBeenCalledWith({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-1001234567890:topic:42",
|
||||
});
|
||||
const dispatchCall = (
|
||||
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array<
|
||||
[{ ctx?: { CommandTargetSessionKey?: string } }]
|
||||
>
|
||||
)[0]?.[0];
|
||||
expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe("agent:codex-acp:session-1");
|
||||
expect(sessionBindingMocks.touch).toHaveBeenCalledWith(
|
||||
"default:-1001234567890:topic:42",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["new", "reset"] as const)(
|
||||
"preserves the topic-qualified origin target for native /%s in forum topics",
|
||||
async (commandName) => {
|
||||
const { handler } = registerAndResolveCommandHandler({
|
||||
commandName,
|
||||
cfg: {},
|
||||
allowFrom: ["200"],
|
||||
groupAllowFrom: ["200"],
|
||||
useAccessGroups: true,
|
||||
});
|
||||
await handler(createTelegramTopicCommandContext());
|
||||
|
||||
const dispatchCall = (
|
||||
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array<
|
||||
[
|
||||
{
|
||||
ctx?: {
|
||||
CommandTargetSessionKey?: string;
|
||||
MessageThreadId?: number;
|
||||
OriginatingTo?: string;
|
||||
};
|
||||
},
|
||||
]
|
||||
>
|
||||
)[0]?.[0];
|
||||
expect(dispatchCall?.ctx).toEqual(
|
||||
expect.objectContaining({
|
||||
CommandTargetSessionKey: "agent:main:telegram:group:-1001234567890:topic:42",
|
||||
MessageThreadId: 42,
|
||||
OriginatingTo: "telegram:-1001234567890:topic:42",
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("aborts native command dispatch when configured ACP topic binding cannot initialize", async () => {
|
||||
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
|
||||
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
|
||||
createConfiguredBindingRoute(
|
||||
{
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: "codex",
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
createConfiguredAcpTopicBinding(boundSessionKey),
|
||||
),
|
||||
);
|
||||
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "gateway unavailable",
|
||||
});
|
||||
|
||||
const { handler, sendMessage } = registerAndResolveStatusHandler({
|
||||
cfg: {},
|
||||
allowFrom: ["200"],
|
||||
groupAllowFrom: ["200"],
|
||||
});
|
||||
await handler(createTelegramTopicCommandContext());
|
||||
|
||||
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
-1001234567890,
|
||||
"Configured ACP binding is unavailable right now. Please try again.",
|
||||
expect.objectContaining({ message_thread_id: 42 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps /new blocked in ACP-bound Telegram topics when sender is unauthorized", async () => {
|
||||
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
|
||||
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
|
||||
createConfiguredBindingRoute(
|
||||
{
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: "codex",
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
createConfiguredAcpTopicBinding(boundSessionKey),
|
||||
),
|
||||
);
|
||||
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true });
|
||||
|
||||
const { handler, sendMessage } = registerAndResolveCommandHandler({
|
||||
commandName: "new",
|
||||
cfg: {},
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
useAccessGroups: true,
|
||||
});
|
||||
await handler(createTelegramTopicCommandContext());
|
||||
|
||||
expectUnauthorizedNewCommandBlocked(sendMessage);
|
||||
});
|
||||
|
||||
it("keeps /new blocked for unbound Telegram topics when sender is unauthorized", async () => {
|
||||
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
|
||||
createConfiguredBindingRoute(route, null),
|
||||
);
|
||||
|
||||
const { handler, sendMessage } = registerAndResolveCommandHandler({
|
||||
commandName: "new",
|
||||
cfg: {},
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
useAccessGroups: true,
|
||||
});
|
||||
await handler(createTelegramTopicCommandContext());
|
||||
|
||||
expectUnauthorizedNewCommandBlocked(sendMessage);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
|
||||
import {
|
||||
createNativeCommandTestParams,
|
||||
listSkillCommandsForAgents,
|
||||
resetNativeCommandMenuMocks,
|
||||
waitForRegisteredCommands,
|
||||
} from "./bot-native-commands.menu-test-support.js";
|
||||
import { resetPluginCommandMocks } from "./test-support/plugin-command.js";
|
||||
import { writeSkill } from "./test-support/write-skill.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function makeWorkspace(prefix: string) {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("registerTelegramNativeCommands skill allowlist integration", () => {
|
||||
afterEach(async () => {
|
||||
resetNativeCommandMenuMocks();
|
||||
resetPluginCommandMocks();
|
||||
await Promise.all(
|
||||
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it("registers only allowlisted skills for the bound agent menu", async () => {
|
||||
const workspaceDir = await makeWorkspace("openclaw-telegram-skills-");
|
||||
await writeSkill({
|
||||
dir: path.join(workspaceDir, "skills", "alpha-skill"),
|
||||
name: "alpha-skill",
|
||||
description: "Alpha skill",
|
||||
});
|
||||
await writeSkill({
|
||||
dir: path.join(workspaceDir, "skills", "beta-skill"),
|
||||
name: "beta-skill",
|
||||
description: "Beta skill",
|
||||
});
|
||||
|
||||
const setMyCommands = vi.fn().mockResolvedValue(undefined);
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "alpha", workspace: workspaceDir, skills: ["alpha-skill"] },
|
||||
{ id: "beta", workspace: workspaceDir, skills: ["beta-skill"] },
|
||||
],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
agentId: "alpha",
|
||||
match: { channel: "telegram", accountId: "bot-a" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const actualSkillCommands = await import("../../../src/auto-reply/skill-commands.js");
|
||||
listSkillCommandsForAgents.mockImplementation(
|
||||
({ cfg, agentIds }: { cfg: OpenClawConfig; agentIds?: string[] }) =>
|
||||
actualSkillCommands.listSkillCommandsForAgents({ cfg, agentIds }),
|
||||
);
|
||||
|
||||
registerTelegramNativeCommands({
|
||||
...createNativeCommandTestParams(cfg, {
|
||||
bot: {
|
||||
api: {
|
||||
setMyCommands,
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
command: vi.fn(),
|
||||
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
|
||||
runtime: { log: vi.fn() } as unknown as Parameters<
|
||||
typeof registerTelegramNativeCommands
|
||||
>[0]["runtime"],
|
||||
accountId: "bot-a",
|
||||
}),
|
||||
});
|
||||
|
||||
const registeredCommands = await waitForRegisteredCommands(setMyCommands);
|
||||
|
||||
expect(registeredCommands.some((entry) => entry.command === "alpha_skill")).toBe(true);
|
||||
expect(registeredCommands.some((entry) => entry.command === "beta_skill")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { ChannelGroupPolicy } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { MockFn } from "openclaw/plugin-sdk/testing";
|
||||
import { vi } from "vitest";
|
||||
import { createNativeCommandTestParams } from "./bot-native-commands.fixture-test-support.js";
|
||||
import type { RegisterTelegramNativeCommandsParams } from "./bot-native-commands.js";
|
||||
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
|
||||
|
||||
type GetPluginCommandSpecsFn =
|
||||
typeof import("./bot-native-commands.runtime.js").getPluginCommandSpecs;
|
||||
type MatchPluginCommandFn = typeof import("./bot-native-commands.runtime.js").matchPluginCommand;
|
||||
type ExecutePluginCommandFn =
|
||||
typeof import("./bot-native-commands.runtime.js").executePluginCommand;
|
||||
type DispatchReplyWithBufferedBlockDispatcherFn =
|
||||
typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").dispatchReplyWithBufferedBlockDispatcher;
|
||||
type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
|
||||
ReturnType<DispatchReplyWithBufferedBlockDispatcherFn>
|
||||
>;
|
||||
type RecordInboundSessionMetaSafeFn =
|
||||
typeof import("./bot-native-commands.runtime.js").recordInboundSessionMetaSafe;
|
||||
type ResolveChunkModeFn = typeof import("./bot-native-commands.runtime.js").resolveChunkMode;
|
||||
type EnsureConfiguredBindingRouteReadyFn =
|
||||
typeof import("./bot-native-commands.runtime.js").ensureConfiguredBindingRouteReady;
|
||||
type GetAgentScopedMediaLocalRootsFn =
|
||||
typeof import("./bot-native-commands.runtime.js").getAgentScopedMediaLocalRoots;
|
||||
type CreateChannelReplyPipelineFn =
|
||||
typeof import("./bot-native-commands.delivery.runtime.js").createChannelReplyPipeline;
|
||||
type AnyMock = MockFn<(...args: unknown[]) => unknown>;
|
||||
type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise<unknown>>;
|
||||
type NativeCommandHarness = {
|
||||
handlers: Record<string, (ctx: unknown) => Promise<void>>;
|
||||
sendMessage: AnyAsyncMock;
|
||||
setMyCommands: AnyAsyncMock;
|
||||
log: AnyMock;
|
||||
bot: RegisterTelegramNativeCommandsParams["bot"];
|
||||
};
|
||||
|
||||
const pluginCommandMocks = vi.hoisted(() => ({
|
||||
getPluginCommandSpecs: vi.fn<GetPluginCommandSpecsFn>(() => []),
|
||||
matchPluginCommand: vi.fn<MatchPluginCommandFn>(() => null),
|
||||
executePluginCommand: vi.fn<ExecutePluginCommandFn>(async () => ({ text: "ok" })),
|
||||
}));
|
||||
export const getPluginCommandSpecs = pluginCommandMocks.getPluginCommandSpecs;
|
||||
export const matchPluginCommand = pluginCommandMocks.matchPluginCommand;
|
||||
export const executePluginCommand = pluginCommandMocks.executePluginCommand;
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/plugin-runtime", () => ({
|
||||
getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs,
|
||||
matchPluginCommand: pluginCommandMocks.matchPluginCommand,
|
||||
executePluginCommand: pluginCommandMocks.executePluginCommand,
|
||||
}));
|
||||
|
||||
const replyPipelineMocks = vi.hoisted(() => {
|
||||
const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = {
|
||||
queuedFinal: false,
|
||||
counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"],
|
||||
};
|
||||
return {
|
||||
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
|
||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn(
|
||||
(async () => dispatchReplyResult) as DispatchReplyWithBufferedBlockDispatcherFn,
|
||||
),
|
||||
createChannelReplyPipeline: vi.fn((() => ({
|
||||
onModelSelected: () => {},
|
||||
responsePrefixContextProvider: () => undefined,
|
||||
})) as unknown as CreateChannelReplyPipelineFn),
|
||||
recordInboundSessionMetaSafe: vi.fn<RecordInboundSessionMetaSafeFn>(async () => undefined),
|
||||
resolveChunkMode: vi.fn((() => "length") as unknown as ResolveChunkModeFn),
|
||||
ensureConfiguredBindingRouteReady: vi.fn((async () => ({
|
||||
ok: true,
|
||||
})) as unknown as EnsureConfiguredBindingRouteReadyFn),
|
||||
getAgentScopedMediaLocalRoots: vi.fn<GetAgentScopedMediaLocalRootsFn>(() => []),
|
||||
};
|
||||
});
|
||||
export const dispatchReplyWithBufferedBlockDispatcher =
|
||||
replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher;
|
||||
|
||||
const deliveryMocks = vi.hoisted(() => ({
|
||||
deliverReplies: vi.fn(async () => {}),
|
||||
}));
|
||||
export const deliverReplies = deliveryMocks.deliverReplies;
|
||||
|
||||
vi.mock("./bot-native-commands.runtime.js", () => ({
|
||||
getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs,
|
||||
matchPluginCommand: pluginCommandMocks.matchPluginCommand,
|
||||
executePluginCommand: pluginCommandMocks.executePluginCommand,
|
||||
finalizeInboundContext: replyPipelineMocks.finalizeInboundContext,
|
||||
recordInboundSessionMetaSafe: replyPipelineMocks.recordInboundSessionMetaSafe,
|
||||
resolveChunkMode: replyPipelineMocks.resolveChunkMode,
|
||||
ensureConfiguredBindingRouteReady: replyPipelineMocks.ensureConfiguredBindingRouteReady,
|
||||
getAgentScopedMediaLocalRoots: replyPipelineMocks.getAgentScopedMediaLocalRoots,
|
||||
}));
|
||||
vi.mock("./bot-native-commands.delivery.runtime.js", () => ({
|
||||
createChannelReplyPipeline: replyPipelineMocks.createChannelReplyPipeline,
|
||||
deliverReplies: deliveryMocks.deliverReplies,
|
||||
emitTelegramMessageSentHooks: vi.fn(),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/reply-dispatch-runtime", () => ({
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher,
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
|
||||
readChannelAllowFromStore: vi.fn(async () => []),
|
||||
resolveConfiguredBindingRoute: vi.fn(({ route }: { route: unknown }) => ({
|
||||
route,
|
||||
bindingResolution: null,
|
||||
boundSessionKey: "",
|
||||
})),
|
||||
getSessionBindingService: vi.fn(() => ({
|
||||
resolveByConversation: vi.fn(() => null),
|
||||
touch: vi.fn(),
|
||||
})),
|
||||
isPluginOwnedSessionBindingRecord: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("./bot/delivery.js", () => ({ deliverReplies: deliveryMocks.deliverReplies }));
|
||||
vi.mock("./bot/delivery.replies.js", () => ({ deliverReplies: deliveryMocks.deliverReplies }));
|
||||
export { createNativeCommandTestParams };
|
||||
|
||||
export function createNativeCommandsHarness(params?: {
|
||||
cfg?: OpenClawConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
telegramCfg?: TelegramAccountConfig;
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
useAccessGroups?: boolean;
|
||||
nativeEnabled?: boolean;
|
||||
groupConfig?: Record<string, unknown>;
|
||||
resolveGroupPolicy?: () => ChannelGroupPolicy;
|
||||
}): NativeCommandHarness {
|
||||
const handlers: Record<string, (ctx: unknown) => Promise<void>> = {};
|
||||
const sendMessage: AnyAsyncMock = vi.fn(async () => undefined);
|
||||
const setMyCommands: AnyAsyncMock = vi.fn(async () => undefined);
|
||||
const log: AnyMock = vi.fn();
|
||||
const telegramDeps = {
|
||||
loadConfig: vi.fn(() => params?.cfg ?? ({} as OpenClawConfig)),
|
||||
readChannelAllowFromStore: vi.fn(async () => []),
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher,
|
||||
getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs,
|
||||
listSkillCommandsForAgents: vi.fn(() => []),
|
||||
syncTelegramMenuCommands: vi.fn(),
|
||||
};
|
||||
const bot = {
|
||||
api: {
|
||||
setMyCommands,
|
||||
sendMessage,
|
||||
},
|
||||
command: (name: string, handler: (ctx: unknown) => Promise<void>) => {
|
||||
handlers[name] = handler;
|
||||
},
|
||||
} as unknown as RegisterTelegramNativeCommandsParams["bot"];
|
||||
|
||||
registerTelegramNativeCommands({
|
||||
bot,
|
||||
cfg: params?.cfg ?? ({} as OpenClawConfig),
|
||||
runtime: params?.runtime ?? ({ log } as unknown as RuntimeEnv),
|
||||
accountId: "default",
|
||||
telegramCfg: params?.telegramCfg ?? ({} as TelegramAccountConfig),
|
||||
allowFrom: params?.allowFrom ?? [],
|
||||
groupAllowFrom: params?.groupAllowFrom ?? [],
|
||||
replyToMode: "off",
|
||||
textLimit: 4000,
|
||||
useAccessGroups: params?.useAccessGroups ?? false,
|
||||
nativeEnabled: params?.nativeEnabled ?? true,
|
||||
nativeSkillsEnabled: false,
|
||||
nativeDisabledExplicit: false,
|
||||
telegramDeps,
|
||||
resolveGroupPolicy:
|
||||
params?.resolveGroupPolicy ??
|
||||
(() =>
|
||||
({
|
||||
allowlistEnabled: false,
|
||||
allowed: true,
|
||||
}) as ChannelGroupPolicy),
|
||||
resolveTelegramGroupConfig: () => ({
|
||||
groupConfig: params?.groupConfig as undefined,
|
||||
topicConfig: undefined,
|
||||
}),
|
||||
shouldSkipUpdate: () => false,
|
||||
opts: { token: "token" },
|
||||
});
|
||||
|
||||
return { handlers, sendMessage, setMyCommands, log, bot };
|
||||
}
|
||||
|
||||
export function createTelegramGroupCommandContext(params?: {
|
||||
senderId?: number;
|
||||
username?: string;
|
||||
threadId?: number;
|
||||
}) {
|
||||
return {
|
||||
message: {
|
||||
chat: { id: -100999, type: "supergroup", is_forum: true },
|
||||
from: {
|
||||
id: params?.senderId ?? 12345,
|
||||
username: params?.username ?? "testuser",
|
||||
},
|
||||
message_thread_id: params?.threadId ?? 42,
|
||||
message_id: 1,
|
||||
date: 1700000000,
|
||||
},
|
||||
match: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function findNotAuthorizedCalls(sendMessage: AnyAsyncMock) {
|
||||
return sendMessage.mock.calls.filter(
|
||||
(call) => typeof call[1] === "string" && call[1].includes("not authorized"),
|
||||
);
|
||||
}
|
||||
600
openclaw/extensions/telegram/src/bot-native-commands.test.ts
Normal file
600
openclaw/extensions/telegram/src/bot-native-commands.test.ts
Normal file
|
|
@ -0,0 +1,600 @@
|
|||
import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createCommandBot,
|
||||
createNativeCommandTestParams,
|
||||
createPrivateCommandContext,
|
||||
deliverReplies,
|
||||
editMessageTelegram,
|
||||
emitTelegramMessageSentHooks,
|
||||
listSkillCommandsForAgents,
|
||||
resetNativeCommandMenuMocks,
|
||||
waitForRegisteredCommands,
|
||||
} from "./bot-native-commands.menu-test-support.js";
|
||||
import { TELEGRAM_COMMAND_NAME_PATTERN } from "./command-config.js";
|
||||
import { pluginCommandMocks, resetPluginCommandMocks } from "./test-support/plugin-command.js";
|
||||
|
||||
let registerTelegramNativeCommands: typeof import("./bot-native-commands.js").registerTelegramNativeCommands;
|
||||
let parseTelegramNativeCommandCallbackData: typeof import("./bot-native-commands.js").parseTelegramNativeCommandCallbackData;
|
||||
let resolveTelegramNativeCommandDisableBlockStreaming: typeof import("./bot-native-commands.js").resolveTelegramNativeCommandDisableBlockStreaming;
|
||||
|
||||
type CommandBotHarness = ReturnType<typeof createCommandBot>;
|
||||
type CommandHandler = (ctx: unknown) => Promise<void>;
|
||||
type PlugCommandHarnessParams = {
|
||||
botHarness?: CommandBotHarness;
|
||||
cfg?: OpenClawConfig;
|
||||
command?: Record<string, unknown>;
|
||||
args?: string;
|
||||
result?: Record<string, unknown>;
|
||||
registerOverrides?: Partial<Parameters<typeof registerTelegramNativeCommands>[0]>;
|
||||
};
|
||||
|
||||
function primePlugCommand(params: PlugCommandHarnessParams = {}) {
|
||||
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
|
||||
{
|
||||
name: "plug",
|
||||
description: "Plugin command",
|
||||
},
|
||||
] as never);
|
||||
pluginCommandMocks.matchPluginCommand.mockReturnValue({
|
||||
command: {
|
||||
key: "plug",
|
||||
requireAuth: false,
|
||||
...params.command,
|
||||
},
|
||||
args: params.args,
|
||||
} as never);
|
||||
pluginCommandMocks.executePluginCommand.mockResolvedValue(
|
||||
(params.result ?? { text: "ok" }) as never,
|
||||
);
|
||||
}
|
||||
|
||||
function registerPlugCommand(params: PlugCommandHarnessParams = {}) {
|
||||
const botHarness = params.botHarness ?? createCommandBot();
|
||||
primePlugCommand(params);
|
||||
registerTelegramNativeCommands({
|
||||
...createNativeCommandTestParams(params.cfg ?? {}, {
|
||||
bot: botHarness.bot,
|
||||
...params.registerOverrides,
|
||||
}),
|
||||
});
|
||||
const handler = botHarness.commandHandlers.get("plug");
|
||||
expect(handler).toBeTruthy();
|
||||
return {
|
||||
...botHarness,
|
||||
handler: handler as CommandHandler,
|
||||
};
|
||||
}
|
||||
|
||||
describe("registerTelegramNativeCommands", () => {
|
||||
beforeAll(async () => {
|
||||
({
|
||||
registerTelegramNativeCommands,
|
||||
parseTelegramNativeCommandCallbackData,
|
||||
resolveTelegramNativeCommandDisableBlockStreaming,
|
||||
} = await import("./bot-native-commands.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetNativeCommandMenuMocks();
|
||||
resetPluginCommandMocks();
|
||||
});
|
||||
|
||||
it("scopes skill commands when account binding exists", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }, { id: "butler" }],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
agentId: "butler",
|
||||
match: { channel: "telegram", accountId: "bot-a" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
registerTelegramNativeCommands(createNativeCommandTestParams(cfg, { accountId: "bot-a" }));
|
||||
|
||||
expect(listSkillCommandsForAgents).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
agentIds: ["butler"],
|
||||
});
|
||||
});
|
||||
|
||||
it("scopes skill commands to default agent without a matching binding (#15599)", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }, { id: "butler" }],
|
||||
},
|
||||
};
|
||||
|
||||
registerTelegramNativeCommands(createNativeCommandTestParams(cfg, { accountId: "bot-a" }));
|
||||
|
||||
expect(listSkillCommandsForAgents).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
agentIds: ["main"],
|
||||
});
|
||||
});
|
||||
|
||||
it("truncates Telegram command registration to 100 commands", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
commands: { native: false },
|
||||
};
|
||||
const customCommands = Array.from({ length: 120 }, (_, index) => ({
|
||||
command: `cmd_${index}`,
|
||||
description: `Command ${index}`,
|
||||
}));
|
||||
const setMyCommands = vi.fn().mockResolvedValue(undefined);
|
||||
const runtimeLog = vi.fn();
|
||||
|
||||
registerTelegramNativeCommands({
|
||||
...createNativeCommandTestParams(cfg),
|
||||
bot: {
|
||||
api: {
|
||||
setMyCommands,
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
command: vi.fn(),
|
||||
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
|
||||
runtime: { log: runtimeLog } as unknown as RuntimeEnv,
|
||||
telegramCfg: { customCommands } as TelegramAccountConfig,
|
||||
nativeEnabled: false,
|
||||
nativeSkillsEnabled: false,
|
||||
});
|
||||
|
||||
const registeredCommands = await waitForRegisteredCommands(setMyCommands);
|
||||
expect(registeredCommands).toHaveLength(100);
|
||||
expect(registeredCommands).toEqual(customCommands.slice(0, 100));
|
||||
expect(runtimeLog).toHaveBeenCalledWith(
|
||||
"Telegram limits bots to 100 commands. 120 configured; registering first 100. Use channels.telegram.commands.native: false to disable, or reduce plugin/skill/custom commands.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps sub-100 commands by shortening long descriptions to fit Telegram payload budget", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
commands: { native: false },
|
||||
};
|
||||
const customCommands = Array.from({ length: 92 }, (_, index) => ({
|
||||
command: `cmd_${index}`,
|
||||
description: `Command ${index} ` + "x".repeat(120),
|
||||
}));
|
||||
const setMyCommands = vi.fn().mockResolvedValue(undefined);
|
||||
const runtimeLog = vi.fn();
|
||||
|
||||
registerTelegramNativeCommands({
|
||||
...createNativeCommandTestParams(cfg),
|
||||
bot: {
|
||||
api: {
|
||||
setMyCommands,
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
command: vi.fn(),
|
||||
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
|
||||
runtime: { log: runtimeLog } as unknown as RuntimeEnv,
|
||||
telegramCfg: { customCommands } as TelegramAccountConfig,
|
||||
nativeEnabled: false,
|
||||
nativeSkillsEnabled: false,
|
||||
});
|
||||
|
||||
const registeredCommands = await waitForRegisteredCommands(setMyCommands);
|
||||
expect(registeredCommands).toHaveLength(92);
|
||||
expect(
|
||||
registeredCommands.some(
|
||||
(entry) => entry.description.length < customCommands[0].description.length,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(runtimeLog).toHaveBeenCalledWith(
|
||||
"Telegram menu text exceeded the conservative 5700-character payload budget; shortening descriptions to keep 92 commands visible.",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes hyphenated native command names for Telegram registration", async () => {
|
||||
const setMyCommands = vi.fn().mockResolvedValue(undefined);
|
||||
const command = vi.fn();
|
||||
|
||||
registerTelegramNativeCommands({
|
||||
...createNativeCommandTestParams({}),
|
||||
bot: {
|
||||
api: {
|
||||
setMyCommands,
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
command,
|
||||
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
|
||||
});
|
||||
|
||||
const registeredCommands = await waitForRegisteredCommands(setMyCommands);
|
||||
expect(registeredCommands.some((entry) => entry.command === "export_session")).toBe(true);
|
||||
expect(registeredCommands.some((entry) => entry.command === "export-session")).toBe(false);
|
||||
|
||||
const registeredHandlers = command.mock.calls.map(([name]) => name);
|
||||
expect(registeredHandlers).toContain("export_session");
|
||||
expect(registeredHandlers).not.toContain("export-session");
|
||||
});
|
||||
|
||||
it("registers only Telegram-safe command names across native, custom, and plugin sources", async () => {
|
||||
const setMyCommands = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
|
||||
{ name: "plugin-status", description: "Plugin status" },
|
||||
{ name: "plugin@bad", description: "Bad plugin command" },
|
||||
] as never);
|
||||
|
||||
registerTelegramNativeCommands({
|
||||
...createNativeCommandTestParams({}),
|
||||
bot: {
|
||||
api: {
|
||||
setMyCommands,
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
command: vi.fn(),
|
||||
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
|
||||
telegramCfg: {
|
||||
customCommands: [
|
||||
{ command: "custom-backup", description: "Custom backup" },
|
||||
{ command: "custom!bad", description: "Bad custom command" },
|
||||
],
|
||||
} as TelegramAccountConfig,
|
||||
});
|
||||
|
||||
const registeredCommands = await waitForRegisteredCommands(setMyCommands);
|
||||
|
||||
expect(registeredCommands.length).toBeGreaterThan(0);
|
||||
for (const entry of registeredCommands) {
|
||||
expect(entry.command.includes("-")).toBe(false);
|
||||
expect(TELEGRAM_COMMAND_NAME_PATTERN.test(entry.command)).toBe(true);
|
||||
}
|
||||
|
||||
expect(registeredCommands.some((entry) => entry.command === "export_session")).toBe(true);
|
||||
expect(registeredCommands.some((entry) => entry.command === "custom_backup")).toBe(true);
|
||||
expect(registeredCommands.some((entry) => entry.command === "plugin_status")).toBe(true);
|
||||
expect(registeredCommands.some((entry) => entry.command === "plugin-status")).toBe(false);
|
||||
expect(registeredCommands.some((entry) => entry.command === "custom-bad")).toBe(false);
|
||||
});
|
||||
|
||||
it("prefixes native command menu callback data so callback handlers can preserve native routing", async () => {
|
||||
const { bot, commandHandlers, sendMessage } = createCommandBot();
|
||||
|
||||
registerTelegramNativeCommands({
|
||||
...createNativeCommandTestParams({}, { bot }),
|
||||
});
|
||||
|
||||
const handler = commandHandlers.get("fast");
|
||||
expect(handler).toBeTruthy();
|
||||
await handler?.(createPrivateCommandContext());
|
||||
|
||||
const replyMarkup = sendMessage.mock.calls[0]?.[2]?.reply_markup as
|
||||
| { inline_keyboard?: Array<Array<{ callback_data?: string }>> }
|
||||
| undefined;
|
||||
const callbackData = replyMarkup?.inline_keyboard
|
||||
?.flat()
|
||||
.map((button) => button.callback_data)
|
||||
.filter(Boolean);
|
||||
|
||||
expect(callbackData).toEqual(["tgcmd:/fast status", "tgcmd:/fast on", "tgcmd:/fast off"]);
|
||||
expect(parseTelegramNativeCommandCallbackData("tgcmd:/fast status")).toBe("/fast status");
|
||||
expect(parseTelegramNativeCommandCallbackData("tgcmd:fast status")).toBeNull();
|
||||
});
|
||||
|
||||
it("passes agent-scoped media roots for plugin command replies with media", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }, { id: "work" }],
|
||||
},
|
||||
bindings: [{ agentId: "work", match: { channel: "telegram", accountId: "default" } }],
|
||||
};
|
||||
|
||||
const { handler, sendMessage } = registerPlugCommand({
|
||||
cfg,
|
||||
result: {
|
||||
text: "with media",
|
||||
mediaUrl: "/tmp/workspace-work/render.png",
|
||||
},
|
||||
});
|
||||
|
||||
await handler(createPrivateCommandContext());
|
||||
|
||||
const firstDeliverRepliesCall = deliverReplies.mock.calls.at(0) as [unknown] | undefined;
|
||||
expect(firstDeliverRepliesCall?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
mediaLocalRoots: expect.arrayContaining([
|
||||
expect.stringMatching(/[\\/]\.openclaw[\\/]workspace-work$/),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found.");
|
||||
});
|
||||
|
||||
it("uses nested streaming.block.enabled for native command block-streaming behavior", () => {
|
||||
expect(
|
||||
resolveTelegramNativeCommandDisableBlockStreaming({
|
||||
streaming: {
|
||||
block: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as TelegramAccountConfig),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveTelegramNativeCommandDisableBlockStreaming({
|
||||
streaming: {
|
||||
block: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as TelegramAccountConfig),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses plugin command metadata to send and edit a Telegram progress placeholder", async () => {
|
||||
const { handler, sendMessage, deleteMessage } = registerPlugCommand({
|
||||
args: "now",
|
||||
command: {
|
||||
nativeProgressMessages: {
|
||||
telegram:
|
||||
"Running this command now...\n\nI'll edit this message with the final result when it's ready.",
|
||||
},
|
||||
},
|
||||
result: {
|
||||
text: "Command completed successfully",
|
||||
},
|
||||
});
|
||||
|
||||
await handler(
|
||||
createPrivateCommandContext({
|
||||
match: "now",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
100,
|
||||
expect.stringContaining("Running this command now"),
|
||||
undefined,
|
||||
);
|
||||
expect(editMessageTelegram).toHaveBeenCalledWith(
|
||||
100,
|
||||
999,
|
||||
expect.stringContaining("Command completed successfully"),
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
expect(deleteMessage).not.toHaveBeenCalled();
|
||||
expect(deliverReplies).not.toHaveBeenCalled();
|
||||
expect(emitTelegramMessageSentHooks).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatId: "100",
|
||||
content: "Command completed successfully",
|
||||
messageId: 999,
|
||||
success: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves Telegram buttons when editing a metadata-driven progress placeholder", async () => {
|
||||
const { handler, sendMessage, deleteMessage } = registerPlugCommand({
|
||||
args: "now",
|
||||
command: {
|
||||
nativeProgressMessages: { telegram: "Working on it..." },
|
||||
},
|
||||
result: {
|
||||
text: "Choose an option",
|
||||
channelData: {
|
||||
telegram: {
|
||||
buttons: [[{ text: "Approve", callback_data: "approve" }]],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await handler(createPrivateCommandContext({ match: "now" }));
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined);
|
||||
expect(editMessageTelegram).toHaveBeenCalledWith(
|
||||
100,
|
||||
999,
|
||||
"Choose an option",
|
||||
expect.objectContaining({
|
||||
buttons: [[{ text: "Approve", callback_data: "approve" }]],
|
||||
}),
|
||||
);
|
||||
expect(deleteMessage).not.toHaveBeenCalled();
|
||||
expect(deliverReplies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to a normal reply when a metadata-driven progress result is not editable", async () => {
|
||||
const { handler, sendMessage, deleteMessage } = registerPlugCommand({
|
||||
args: "now",
|
||||
command: {
|
||||
nativeProgressMessages: { telegram: "Working on it..." },
|
||||
},
|
||||
result: {
|
||||
text: "rich output",
|
||||
mediaUrl: "/tmp/render.png",
|
||||
},
|
||||
});
|
||||
|
||||
await handler(
|
||||
createPrivateCommandContext({
|
||||
match: "now",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined);
|
||||
expect(editMessageTelegram).not.toHaveBeenCalled();
|
||||
expect(deleteMessage).toHaveBeenCalledWith(100, 999);
|
||||
expect(deliverReplies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replies: [expect.objectContaining({ mediaUrl: "/tmp/render.png" })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("cleans up the progress placeholder before falling back after an edit failure", async () => {
|
||||
const { handler, sendMessage, deleteMessage } = registerPlugCommand({
|
||||
args: "now",
|
||||
command: {
|
||||
nativeProgressMessages: { telegram: "Working on it..." },
|
||||
},
|
||||
result: {
|
||||
text: "Command completed successfully",
|
||||
},
|
||||
});
|
||||
editMessageTelegram.mockRejectedValueOnce(new Error("message to edit not found"));
|
||||
|
||||
await handler(createPrivateCommandContext({ match: "now" }));
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined);
|
||||
expect(editMessageTelegram).toHaveBeenCalledTimes(1);
|
||||
expect(deleteMessage).toHaveBeenCalledWith(100, 999);
|
||||
expect(deliverReplies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replies: [expect.objectContaining({ text: "Command completed successfully" })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("cleans up the progress placeholder when Telegram suppresses a local exec approval reply", async () => {
|
||||
const { handler, sendMessage, deleteMessage } = registerPlugCommand({
|
||||
args: "now",
|
||||
command: {
|
||||
nativeProgressMessages: { telegram: "Working on it..." },
|
||||
},
|
||||
result: {
|
||||
text: "Approval required.\n\n```txt\n/approve 7f423fdc allow-once\n```",
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: "7f423fdc-1111-2222-3333-444444444444",
|
||||
approvalSlug: "7f423fdc",
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
||||
},
|
||||
},
|
||||
},
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["12345"],
|
||||
target: "dm",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await handler(createPrivateCommandContext({ match: "now" }));
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined);
|
||||
expect(deleteMessage).toHaveBeenCalledWith(100, 999);
|
||||
expect(editMessageTelegram).not.toHaveBeenCalled();
|
||||
expect(deliverReplies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends plugin command error replies silently when silentErrorReplies is enabled", async () => {
|
||||
const { handler } = registerPlugCommand({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
silentErrorReplies: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
result: {
|
||||
text: "plugin failed",
|
||||
isError: true,
|
||||
},
|
||||
registerOverrides: {
|
||||
telegramCfg: { silentErrorReplies: true } as TelegramAccountConfig,
|
||||
},
|
||||
});
|
||||
|
||||
await handler(createPrivateCommandContext());
|
||||
|
||||
const firstDeliverRepliesCall = deliverReplies.mock.calls.at(0) as [unknown] | undefined;
|
||||
expect(firstDeliverRepliesCall?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
silent: true,
|
||||
replies: [expect.objectContaining({ isError: true })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards topic-scoped binding context to Telegram plugin commands", async () => {
|
||||
const { handler } = registerPlugCommand();
|
||||
|
||||
await handler({
|
||||
match: "",
|
||||
message: {
|
||||
message_id: 2,
|
||||
date: Math.floor(Date.now() / 1000),
|
||||
chat: {
|
||||
id: -1001234567890,
|
||||
type: "supergroup",
|
||||
title: "Forum Group",
|
||||
is_forum: true,
|
||||
},
|
||||
message_thread_id: 77,
|
||||
from: { id: 200, username: "bob" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(pluginCommandMocks.executePluginCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
from: "telegram:group:-1001234567890:topic:77",
|
||||
to: "telegram:-1001234567890",
|
||||
messageThreadId: 77,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats Telegram forum #General commands as topic 1 when Telegram omits topic metadata", async () => {
|
||||
const getChat = vi.fn(async () => ({ id: -1001234567890, type: "supergroup", is_forum: true }));
|
||||
const { handler } = registerPlugCommand({
|
||||
botHarness: createCommandBot({ api: { getChat } }),
|
||||
});
|
||||
|
||||
await handler({
|
||||
match: "",
|
||||
message: {
|
||||
message_id: 2,
|
||||
date: Math.floor(Date.now() / 1000),
|
||||
chat: {
|
||||
id: -1001234567890,
|
||||
type: "supergroup",
|
||||
title: "Forum Group",
|
||||
},
|
||||
from: { id: 200, username: "bob" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(getChat).toHaveBeenCalledWith(-1001234567890);
|
||||
expect(pluginCommandMocks.executePluginCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
from: "telegram:group:-1001234567890:topic:1",
|
||||
to: "telegram:-1001234567890",
|
||||
messageThreadId: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards direct-message binding context to Telegram plugin commands", async () => {
|
||||
const { handler } = registerPlugCommand();
|
||||
|
||||
await handler(createPrivateCommandContext({ chatId: 100, userId: 200 }));
|
||||
|
||||
expect(pluginCommandMocks.executePluginCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
from: "telegram:100",
|
||||
to: "telegram:100",
|
||||
messageThreadId: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
1162
openclaw/extensions/telegram/src/bot-native-commands.ts
Normal file
1162
openclaw/extensions/telegram/src/bot-native-commands.ts
Normal file
File diff suppressed because it is too large
Load diff
67
openclaw/extensions/telegram/src/bot-updates.ts
Normal file
67
openclaw/extensions/telegram/src/bot-updates.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import type { Message } from "@grammyjs/types";
|
||||
import { createDedupeCache } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import type { TelegramContext } from "./bot/types.js";
|
||||
|
||||
const MEDIA_GROUP_TIMEOUT_MS = 500;
|
||||
const RECENT_TELEGRAM_UPDATE_TTL_MS = 5 * 60_000;
|
||||
const RECENT_TELEGRAM_UPDATE_MAX = 2000;
|
||||
|
||||
export type MediaGroupEntry = {
|
||||
messages: Array<{
|
||||
msg: Message;
|
||||
ctx: TelegramContext;
|
||||
}>;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
export type TelegramUpdateKeyContext = {
|
||||
update?: {
|
||||
update_id?: number;
|
||||
message?: Message;
|
||||
edited_message?: Message;
|
||||
channel_post?: Message;
|
||||
edited_channel_post?: Message;
|
||||
};
|
||||
update_id?: number;
|
||||
message?: Message;
|
||||
channelPost?: Message;
|
||||
editedChannelPost?: Message;
|
||||
callbackQuery?: { id?: string; message?: Message };
|
||||
};
|
||||
|
||||
export const resolveTelegramUpdateId = (ctx: TelegramUpdateKeyContext) =>
|
||||
ctx.update?.update_id ?? ctx.update_id;
|
||||
|
||||
export const buildTelegramUpdateKey = (ctx: TelegramUpdateKeyContext) => {
|
||||
const updateId = resolveTelegramUpdateId(ctx);
|
||||
if (typeof updateId === "number") {
|
||||
return `update:${updateId}`;
|
||||
}
|
||||
const callbackId = ctx.callbackQuery?.id;
|
||||
if (callbackId) {
|
||||
return `callback:${callbackId}`;
|
||||
}
|
||||
const msg =
|
||||
ctx.message ??
|
||||
ctx.channelPost ??
|
||||
ctx.editedChannelPost ??
|
||||
ctx.update?.message ??
|
||||
ctx.update?.edited_message ??
|
||||
ctx.update?.channel_post ??
|
||||
ctx.update?.edited_channel_post ??
|
||||
ctx.callbackQuery?.message;
|
||||
const chatId = msg?.chat?.id;
|
||||
const messageId = msg?.message_id;
|
||||
if (typeof chatId !== "undefined" && typeof messageId === "number") {
|
||||
return `message:${chatId}:${messageId}`;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const createTelegramUpdateDedupe = () =>
|
||||
createDedupeCache({
|
||||
ttlMs: RECENT_TELEGRAM_UPDATE_TTL_MS,
|
||||
maxSize: RECENT_TELEGRAM_UPDATE_MAX,
|
||||
});
|
||||
|
||||
export { MEDIA_GROUP_TIMEOUT_MS };
|
||||
1
openclaw/extensions/telegram/src/bot.agent.runtime.ts
Normal file
1
openclaw/extensions/telegram/src/bot.agent.runtime.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
|
||||
214
openclaw/extensions/telegram/src/bot.command-menu.test.ts
Normal file
214
openclaw/extensions/telegram/src/bot.command-menu.test.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
|
||||
const {
|
||||
getLoadConfigMock,
|
||||
listSkillCommandsForAgents,
|
||||
setMyCommandsSpy,
|
||||
telegramBotDepsForTest,
|
||||
telegramBotRuntimeForTest,
|
||||
} = await import("./bot.create-telegram-bot.test-harness.js");
|
||||
|
||||
let listNativeCommandSpecs: typeof import("../../../src/auto-reply/commands-registry.js").listNativeCommandSpecs;
|
||||
let listNativeCommandSpecsForConfig: typeof import("../../../src/auto-reply/commands-registry.js").listNativeCommandSpecsForConfig;
|
||||
let normalizeTelegramCommandName: typeof import("./command-config.js").normalizeTelegramCommandName;
|
||||
let createTelegramBotBase: typeof import("./bot.js").createTelegramBot;
|
||||
let setTelegramBotRuntimeForTest: typeof import("./bot.js").setTelegramBotRuntimeForTest;
|
||||
let createTelegramBot: (
|
||||
opts: Parameters<typeof import("./bot.js").createTelegramBot>[0],
|
||||
) => ReturnType<typeof import("./bot.js").createTelegramBot>;
|
||||
|
||||
const loadConfig = getLoadConfigMock();
|
||||
|
||||
function createSignal() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function waitForNextSetMyCommands() {
|
||||
const synced = createSignal();
|
||||
setMyCommandsSpy.mockImplementationOnce(async () => {
|
||||
synced.resolve();
|
||||
return undefined;
|
||||
});
|
||||
return synced.promise;
|
||||
}
|
||||
|
||||
function resolveSkillCommands(config: Parameters<typeof listNativeCommandSpecsForConfig>[0]) {
|
||||
void config;
|
||||
return listSkillCommandsForAgents() as NonNullable<
|
||||
Parameters<typeof listNativeCommandSpecsForConfig>[1]
|
||||
>["skillCommands"];
|
||||
}
|
||||
|
||||
describe("createTelegramBot command menu", () => {
|
||||
beforeAll(async () => {
|
||||
({ listNativeCommandSpecs, listNativeCommandSpecsForConfig } =
|
||||
await import("../../../src/auto-reply/commands-registry.js"));
|
||||
({ normalizeTelegramCommandName } = await import("./command-config.js"));
|
||||
({ createTelegramBot: createTelegramBotBase, setTelegramBotRuntimeForTest } =
|
||||
await import("./bot.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
loadConfig.mockReturnValue({
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "utc",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: { dmPolicy: "open", allowFrom: ["*"] },
|
||||
},
|
||||
});
|
||||
setTelegramBotRuntimeForTest(
|
||||
telegramBotRuntimeForTest as unknown as Parameters<typeof setTelegramBotRuntimeForTest>[0],
|
||||
);
|
||||
createTelegramBot = (opts) =>
|
||||
createTelegramBotBase({
|
||||
...opts,
|
||||
telegramDeps: telegramBotDepsForTest,
|
||||
});
|
||||
});
|
||||
|
||||
it("merges custom commands with native commands", async () => {
|
||||
const config = {
|
||||
commands: {
|
||||
native: true,
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "utc",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["9"],
|
||||
target: "dm",
|
||||
},
|
||||
customCommands: [
|
||||
{ command: "custom_backup", description: "Git backup" },
|
||||
{ command: "/Custom_Generate", description: "Create an image" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
loadConfig.mockReturnValue(config);
|
||||
const commandsSynced = waitForNextSetMyCommands();
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
|
||||
await commandsSynced;
|
||||
|
||||
const registered = setMyCommandsSpy.mock.calls.at(-1)?.[0] as Array<{
|
||||
command: string;
|
||||
description: string;
|
||||
}>;
|
||||
const skillCommands = resolveSkillCommands(config);
|
||||
const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({
|
||||
command: normalizeTelegramCommandName(command.name),
|
||||
description: command.description,
|
||||
}));
|
||||
expect(registered.slice(0, native.length)).toEqual(native);
|
||||
});
|
||||
|
||||
it("ignores custom commands that collide with native commands", async () => {
|
||||
const errorSpy = vi.fn();
|
||||
const config = {
|
||||
commands: {
|
||||
native: true,
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "utc",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
customCommands: [
|
||||
{ command: "status", description: "Custom status" },
|
||||
{ command: "custom_backup", description: "Git backup" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
loadConfig.mockReturnValue(config);
|
||||
const commandsSynced = waitForNextSetMyCommands();
|
||||
|
||||
createTelegramBot({
|
||||
token: "tok",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: errorSpy,
|
||||
exit: ((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
},
|
||||
});
|
||||
|
||||
await commandsSynced;
|
||||
|
||||
const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{
|
||||
command: string;
|
||||
description: string;
|
||||
}>;
|
||||
const skillCommands = resolveSkillCommands(config);
|
||||
const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({
|
||||
command: normalizeTelegramCommandName(command.name),
|
||||
description: command.description,
|
||||
}));
|
||||
const nativeStatus = native.find((command) => command.command === "status");
|
||||
expect(nativeStatus).toBeDefined();
|
||||
expect(registered).toContainEqual({ command: "custom_backup", description: "Git backup" });
|
||||
expect(registered).not.toContainEqual({ command: "status", description: "Custom status" });
|
||||
expect(registered.filter((command) => command.command === "status")).toEqual([nativeStatus]);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers custom commands when native commands are disabled", async () => {
|
||||
const config = {
|
||||
commands: { native: false },
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "utc",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
customCommands: [
|
||||
{ command: "custom_backup", description: "Git backup" },
|
||||
{ command: "custom_generate", description: "Create an image" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
loadConfig.mockReturnValue(config);
|
||||
const commandsSynced = waitForNextSetMyCommands();
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
|
||||
await commandsSynced;
|
||||
|
||||
const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{
|
||||
command: string;
|
||||
description: string;
|
||||
}>;
|
||||
expect(registered).toEqual([
|
||||
{ command: "custom_backup", description: "Git backup" },
|
||||
{ command: "custom_generate", description: "Create an image" },
|
||||
]);
|
||||
const reserved = new Set(listNativeCommandSpecs().map((command) => command.name));
|
||||
expect(registered.some((command) => reserved.has(command.command))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,357 @@
|
|||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useFrozenTime, useRealTime } from "../../../test/helpers/plugins/frozen-time.js";
|
||||
|
||||
const harness = await import("./bot.create-telegram-bot.test-harness.js");
|
||||
const {
|
||||
getLoadConfigMock,
|
||||
getOnHandler,
|
||||
replySpy,
|
||||
sendMessageSpy,
|
||||
telegramBotDepsForTest,
|
||||
telegramBotRuntimeForTest,
|
||||
} = harness;
|
||||
const { createTelegramBot: createTelegramBotBase, setTelegramBotRuntimeForTest } =
|
||||
await import("./bot.js");
|
||||
|
||||
let createTelegramBot: (
|
||||
opts: Parameters<typeof import("./bot.js").createTelegramBot>[0],
|
||||
) => ReturnType<typeof import("./bot.js").createTelegramBot>;
|
||||
|
||||
const loadConfig = getLoadConfigMock();
|
||||
|
||||
const TELEGRAM_TEST_TIMINGS = {
|
||||
mediaGroupFlushMs: 20,
|
||||
textFragmentGapMs: 30,
|
||||
} as const;
|
||||
|
||||
function setOpenChannelPostConfig() {
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: {
|
||||
"-100777111222": {
|
||||
enabled: true,
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getChannelPostHandler() {
|
||||
createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS });
|
||||
return getOnHandler("channel_post") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
function resolveFlushTimer(setTimeoutSpy: ReturnType<typeof vi.spyOn>) {
|
||||
const flushTimerCallIndex = setTimeoutSpy.mock.calls.findLastIndex(
|
||||
(call: Parameters<typeof setTimeout>) => call[1] === TELEGRAM_TEST_TIMINGS.mediaGroupFlushMs,
|
||||
);
|
||||
const flushTimer =
|
||||
flushTimerCallIndex >= 0
|
||||
? (setTimeoutSpy.mock.calls[flushTimerCallIndex]?.[0] as (() => unknown) | undefined)
|
||||
: undefined;
|
||||
if (flushTimerCallIndex >= 0) {
|
||||
clearTimeout(
|
||||
setTimeoutSpy.mock.results[flushTimerCallIndex]?.value as ReturnType<typeof setTimeout>,
|
||||
);
|
||||
}
|
||||
return flushTimer;
|
||||
}
|
||||
|
||||
function createImageFetchSpy(params?: { body?: Uint8Array; contentType?: string }) {
|
||||
return vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
async () =>
|
||||
new Response(Buffer.from(params?.body ?? [0x89, 0x50, 0x4e, 0x47]), {
|
||||
status: 200,
|
||||
headers: { "content-type": params?.contentType ?? "image/png" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function createChannelPostContext(params: {
|
||||
messageId: number;
|
||||
date: number;
|
||||
title?: string;
|
||||
caption?: string;
|
||||
text?: string;
|
||||
mediaGroupId?: string;
|
||||
photoFileId?: string;
|
||||
getFileResult?: Record<string, unknown>;
|
||||
}) {
|
||||
const photoFileId = params.photoFileId;
|
||||
return {
|
||||
channelPost: {
|
||||
chat: { id: -100777111222, type: "channel", title: params.title ?? "Wake Channel" },
|
||||
message_id: params.messageId,
|
||||
date: params.date,
|
||||
...(params.caption ? { caption: params.caption } : {}),
|
||||
...(params.text ? { text: params.text } : {}),
|
||||
...(params.mediaGroupId ? { media_group_id: params.mediaGroupId } : {}),
|
||||
...(photoFileId ? { photo: [{ file_id: photoFileId }] } : {}),
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () =>
|
||||
params.getFileResult ?? (photoFileId ? { file_path: `photos/${photoFileId}.jpg` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function flushChannelPostMediaGroup(setTimeoutSpy: ReturnType<typeof vi.spyOn>) {
|
||||
const flushTimer = resolveFlushTimer(setTimeoutSpy);
|
||||
expect(flushTimer).toBeTypeOf("function");
|
||||
await flushTimer?.();
|
||||
}
|
||||
|
||||
async function queueChannelPostAlbum(
|
||||
handler: ReturnType<typeof getChannelPostHandler>,
|
||||
params: {
|
||||
caption: string;
|
||||
mediaGroupId: string;
|
||||
firstMessageId: number;
|
||||
secondMessageId: number;
|
||||
firstPhotoFileId?: string;
|
||||
secondPhotoFileId?: string;
|
||||
secondGetFileResult?: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
const first = handler(
|
||||
createChannelPostContext({
|
||||
messageId: params.firstMessageId,
|
||||
caption: params.caption,
|
||||
date: 1736380800,
|
||||
mediaGroupId: params.mediaGroupId,
|
||||
photoFileId: params.firstPhotoFileId ?? "p1",
|
||||
}),
|
||||
);
|
||||
const second = handler(
|
||||
createChannelPostContext({
|
||||
messageId: params.secondMessageId,
|
||||
date: 1736380801,
|
||||
mediaGroupId: params.mediaGroupId,
|
||||
photoFileId: params.secondPhotoFileId ?? "p2",
|
||||
getFileResult: params.secondGetFileResult,
|
||||
}),
|
||||
);
|
||||
await Promise.all([first, second]);
|
||||
}
|
||||
|
||||
describe("createTelegramBot channel_post media", () => {
|
||||
beforeAll(() => {
|
||||
createTelegramBot = (opts) =>
|
||||
createTelegramBotBase({
|
||||
...opts,
|
||||
telegramDeps: telegramBotDepsForTest,
|
||||
});
|
||||
setTelegramBotRuntimeForTest(
|
||||
telegramBotRuntimeForTest as unknown as Parameters<typeof setTelegramBotRuntimeForTest>[0],
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setTelegramBotRuntimeForTest(
|
||||
telegramBotRuntimeForTest as unknown as Parameters<typeof setTelegramBotRuntimeForTest>[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("buffers channel_post media groups and processes them together", async () => {
|
||||
setOpenChannelPostConfig();
|
||||
|
||||
const fetchSpy = createImageFetchSpy();
|
||||
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
try {
|
||||
const handler = getChannelPostHandler();
|
||||
await queueChannelPostAlbum(handler, {
|
||||
caption: "album caption",
|
||||
mediaGroupId: "channel-album-1",
|
||||
firstMessageId: 201,
|
||||
secondMessageId: 202,
|
||||
});
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
await flushChannelPostMediaGroup(setTimeoutSpy);
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0]?.[0] as { Body?: string };
|
||||
expect(payload.Body).toContain("album caption");
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("coalesces channel_post near-limit text fragments into one message", async () => {
|
||||
setOpenChannelPostConfig();
|
||||
|
||||
useFrozenTime("2026-02-20T00:00:00.000Z");
|
||||
try {
|
||||
const handler = getChannelPostHandler();
|
||||
|
||||
const part1 = "A".repeat(4050);
|
||||
const part2 = "B".repeat(50);
|
||||
|
||||
await handler({
|
||||
channelPost: {
|
||||
chat: { id: -100777111222, type: "channel", title: "Wake Channel" },
|
||||
message_id: 301,
|
||||
date: 1736380800,
|
||||
text: part1,
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({}),
|
||||
});
|
||||
|
||||
await handler({
|
||||
channelPost: {
|
||||
chat: { id: -100777111222, type: "channel", title: "Wake Channel" },
|
||||
message_id: 302,
|
||||
date: 1736380801,
|
||||
text: part2,
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({}),
|
||||
});
|
||||
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(TELEGRAM_TEST_TIMINGS.textFragmentGapMs + 100);
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0]?.[0] as { RawBody?: string };
|
||||
expect(payload.RawBody).toContain(part1.slice(0, 32));
|
||||
expect(payload.RawBody).toContain(part2.slice(0, 32));
|
||||
} finally {
|
||||
useRealTime();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops oversized channel_post media instead of dispatching a placeholder message", async () => {
|
||||
setOpenChannelPostConfig();
|
||||
|
||||
const fetchSpy = createImageFetchSpy({
|
||||
body: new Uint8Array([0xff, 0xd8, 0xff, 0x00]),
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok", mediaMaxMb: 0 });
|
||||
const handler = getOnHandler("channel_post") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await handler(
|
||||
createChannelPostContext({
|
||||
messageId: 401,
|
||||
date: 1736380800,
|
||||
photoFileId: "oversized",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("notifies users when media download fails for direct messages", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: { dmPolicy: "open", allowFrom: ["*"] },
|
||||
},
|
||||
});
|
||||
sendMessageSpy.mockClear();
|
||||
replySpy.mockClear();
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
|
||||
throw new Error("MediaFetchError: Failed to fetch media");
|
||||
});
|
||||
|
||||
try {
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 1234, type: "private" },
|
||||
message_id: 411,
|
||||
date: 1736380800,
|
||||
photo: [{ file_id: "p1" }],
|
||||
from: { id: 55, is_bot: false, first_name: "u" },
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ file_path: "photos/p1.jpg" }),
|
||||
});
|
||||
|
||||
expect(sendMessageSpy).toHaveBeenCalledWith(
|
||||
1234,
|
||||
"⚠️ Failed to download media. Please try again.",
|
||||
{
|
||||
reply_parameters: {
|
||||
message_id: 411,
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("processes remaining media group photos when one photo download fails", async () => {
|
||||
replySpy.mockReset();
|
||||
setOpenChannelPostConfig();
|
||||
|
||||
let fetchCallIndex = 0;
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
|
||||
fetchCallIndex++;
|
||||
if (fetchCallIndex === 2) {
|
||||
throw new Error("MediaFetchError: Failed to fetch media");
|
||||
}
|
||||
return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/png" },
|
||||
});
|
||||
});
|
||||
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
try {
|
||||
const handler = getChannelPostHandler();
|
||||
await queueChannelPostAlbum(handler, {
|
||||
caption: "partial album",
|
||||
mediaGroupId: "partial-album-1",
|
||||
firstMessageId: 401,
|
||||
secondMessageId: 402,
|
||||
});
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
await flushChannelPostMediaGroup(setTimeoutSpy);
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0]?.[0] as { Body?: string };
|
||||
expect(payload.Body).toContain("partial album");
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops the media group when a non-recoverable media error occurs", async () => {
|
||||
replySpy.mockReset();
|
||||
setOpenChannelPostConfig();
|
||||
|
||||
const fetchSpy = createImageFetchSpy();
|
||||
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
try {
|
||||
const handler = getChannelPostHandler();
|
||||
await queueChannelPostAlbum(handler, {
|
||||
caption: "fatal album",
|
||||
mediaGroupId: "fatal-album-1",
|
||||
firstMessageId: 501,
|
||||
secondMessageId: 502,
|
||||
secondGetFileResult: {},
|
||||
});
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
await flushChannelPostMediaGroup(setTimeoutSpy);
|
||||
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,561 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
createReplyDispatcher,
|
||||
resetInboundDedupe,
|
||||
type GetReplyOptions,
|
||||
type MsgContext,
|
||||
} from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { MockFn } from "openclaw/plugin-sdk/testing";
|
||||
import { beforeEach, vi } from "vitest";
|
||||
import type { TelegramBotDeps } from "./bot-deps.js";
|
||||
|
||||
type AnyMock = ReturnType<typeof vi.fn>;
|
||||
type AnyAsyncMock = ReturnType<typeof vi.fn>;
|
||||
type LoadConfigFn = typeof import("openclaw/plugin-sdk/config-runtime").loadConfig;
|
||||
type LoadSessionStoreFn = typeof import("openclaw/plugin-sdk/config-runtime").loadSessionStore;
|
||||
type ResolveStorePathFn = typeof import("openclaw/plugin-sdk/config-runtime").resolveStorePath;
|
||||
type SessionStore = ReturnType<LoadSessionStoreFn>;
|
||||
type TelegramBotRuntimeForTest = NonNullable<
|
||||
Parameters<typeof import("./bot.js").setTelegramBotRuntimeForTest>[0]
|
||||
>;
|
||||
type DispatchReplyWithBufferedBlockDispatcherFn =
|
||||
typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").dispatchReplyWithBufferedBlockDispatcher;
|
||||
type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
|
||||
ReturnType<DispatchReplyWithBufferedBlockDispatcherFn>
|
||||
>;
|
||||
type DispatchReplyHarnessParams = Parameters<DispatchReplyWithBufferedBlockDispatcherFn>[0];
|
||||
type ReplyPayloadLike = {
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
replyToId?: string;
|
||||
};
|
||||
|
||||
const _EMPTY_REPLY_COUNTS: DispatchReplyWithBufferedBlockDispatcherResult["counts"] = {
|
||||
block: 0,
|
||||
final: 0,
|
||||
tool: 0,
|
||||
};
|
||||
|
||||
const { sessionStorePath } = vi.hoisted(() => ({
|
||||
sessionStorePath: `/tmp/openclaw-telegram-${process.pid}-${process.env.VITEST_POOL_ID ?? "0"}.json`,
|
||||
}));
|
||||
|
||||
const { loadWebMedia } = vi.hoisted((): { loadWebMedia: AnyMock } => ({
|
||||
loadWebMedia: vi.fn(),
|
||||
}));
|
||||
|
||||
export function getLoadWebMediaMock(): AnyMock {
|
||||
return loadWebMedia;
|
||||
}
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/web-media", () => ({
|
||||
loadWebMedia,
|
||||
}));
|
||||
|
||||
const { loadConfig, loadSessionStoreMock, resolveStorePathMock, sessionStoreEntries } = vi.hoisted(
|
||||
(): {
|
||||
loadConfig: MockFn<LoadConfigFn>;
|
||||
loadSessionStoreMock: MockFn<LoadSessionStoreFn>;
|
||||
resolveStorePathMock: MockFn<ResolveStorePathFn>;
|
||||
sessionStoreEntries: { value: SessionStore };
|
||||
} => ({
|
||||
loadConfig: vi.fn<LoadConfigFn>(() => ({})),
|
||||
loadSessionStoreMock: vi.fn<LoadSessionStoreFn>(
|
||||
(_storePath, _opts) => sessionStoreEntries.value,
|
||||
),
|
||||
resolveStorePathMock: vi.fn<ResolveStorePathFn>(
|
||||
(storePath?: string) => storePath ?? sessionStorePath,
|
||||
),
|
||||
sessionStoreEntries: { value: {} as SessionStore },
|
||||
}),
|
||||
);
|
||||
|
||||
export function getLoadConfigMock(): AnyMock {
|
||||
return loadConfig;
|
||||
}
|
||||
|
||||
export function getLoadSessionStoreMock(): AnyMock {
|
||||
return loadSessionStoreMock;
|
||||
}
|
||||
|
||||
export function setSessionStoreEntriesForTest(entries: SessionStore) {
|
||||
sessionStoreEntries.value = JSON.parse(JSON.stringify(entries)) as SessionStore;
|
||||
}
|
||||
|
||||
const { readChannelAllowFromStore, upsertChannelPairingRequest } = vi.hoisted(
|
||||
(): {
|
||||
readChannelAllowFromStore: MockFn<TelegramBotDeps["readChannelAllowFromStore"]>;
|
||||
upsertChannelPairingRequest: AnyAsyncMock;
|
||||
} => ({
|
||||
readChannelAllowFromStore: vi.fn(async () => [] as string[]),
|
||||
upsertChannelPairingRequest: vi.fn(async () => ({
|
||||
code: "PAIRCODE",
|
||||
created: true,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
export function getReadChannelAllowFromStoreMock(): AnyAsyncMock {
|
||||
return readChannelAllowFromStore;
|
||||
}
|
||||
|
||||
export function getUpsertChannelPairingRequestMock(): AnyAsyncMock {
|
||||
return upsertChannelPairingRequest;
|
||||
}
|
||||
|
||||
const skillCommandListHoisted = vi.hoisted(() => ({
|
||||
listSkillCommandsForAgents: vi.fn(() => []),
|
||||
}));
|
||||
const modelProviderDataHoisted = vi.hoisted(() => ({
|
||||
buildModelsProviderData: vi.fn(),
|
||||
}));
|
||||
const replySpyHoisted = vi.hoisted(() => ({
|
||||
replySpy: vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
await opts?.onReplyStart?.();
|
||||
return undefined;
|
||||
}) as MockFn<
|
||||
(
|
||||
ctx: MsgContext,
|
||||
opts?: GetReplyOptions,
|
||||
configOverride?: OpenClawConfig,
|
||||
) => Promise<ReplyPayloadLike | ReplyPayloadLike[] | undefined>
|
||||
>,
|
||||
}));
|
||||
|
||||
async function dispatchHarnessReplies(
|
||||
params: DispatchReplyHarnessParams,
|
||||
runReply: (
|
||||
params: DispatchReplyHarnessParams,
|
||||
) => Promise<ReplyPayloadLike | ReplyPayloadLike[] | undefined>,
|
||||
): Promise<DispatchReplyWithBufferedBlockDispatcherResult> {
|
||||
await params.dispatcherOptions.typingCallbacks?.onReplyStart?.();
|
||||
const reply = await runReply(params);
|
||||
const payloads: ReplyPayloadLike[] =
|
||||
reply === undefined ? [] : Array.isArray(reply) ? reply : [reply];
|
||||
const dispatcher = createReplyDispatcher({
|
||||
deliver: async (payload, info) => {
|
||||
await params.dispatcherOptions.deliver?.(payload, info);
|
||||
},
|
||||
responsePrefix: params.dispatcherOptions.responsePrefix,
|
||||
responsePrefixContextProvider: params.dispatcherOptions.responsePrefixContextProvider,
|
||||
responsePrefixContext: params.dispatcherOptions.responsePrefixContext,
|
||||
onHeartbeatStrip: params.dispatcherOptions.onHeartbeatStrip,
|
||||
onSkip: (payload, info) => {
|
||||
params.dispatcherOptions.onSkip?.(payload, info);
|
||||
},
|
||||
onError: (err, info) => {
|
||||
params.dispatcherOptions.onError?.(err, info);
|
||||
},
|
||||
});
|
||||
let finalCount = 0;
|
||||
for (const payload of payloads) {
|
||||
if (dispatcher.sendFinalReply(payload)) {
|
||||
finalCount += 1;
|
||||
}
|
||||
}
|
||||
dispatcher.markComplete();
|
||||
await dispatcher.waitForIdle();
|
||||
return {
|
||||
queuedFinal: finalCount > 0,
|
||||
counts: {
|
||||
block: 0,
|
||||
final: finalCount,
|
||||
tool: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const dispatchReplyHoisted = vi.hoisted(() => ({
|
||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn<DispatchReplyWithBufferedBlockDispatcherFn>(
|
||||
async (params: DispatchReplyHarnessParams) =>
|
||||
await dispatchHarnessReplies(params, async (dispatchParams) => {
|
||||
return await replySpyHoisted.replySpy(dispatchParams.ctx, dispatchParams.replyOptions);
|
||||
}),
|
||||
),
|
||||
}));
|
||||
export const listSkillCommandsForAgents = skillCommandListHoisted.listSkillCommandsForAgents;
|
||||
const buildModelsProviderData = modelProviderDataHoisted.buildModelsProviderData;
|
||||
export const replySpy = replySpyHoisted.replySpy;
|
||||
export const dispatchReplyWithBufferedBlockDispatcher =
|
||||
dispatchReplyHoisted.dispatchReplyWithBufferedBlockDispatcher;
|
||||
const menuSyncHoisted = vi.hoisted(() => ({
|
||||
syncTelegramMenuCommands: vi.fn(async ({ bot, commandsToRegister }) => {
|
||||
await bot.api.setMyCommands(commandsToRegister);
|
||||
}),
|
||||
}));
|
||||
export const syncTelegramMenuCommands = menuSyncHoisted.syncTelegramMenuCommands;
|
||||
|
||||
function parseModelRef(raw: string): { provider?: string; model: string } {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return { model: "" };
|
||||
}
|
||||
const slashIndex = trimmed.indexOf("/");
|
||||
if (slashIndex > 0 && slashIndex < trimmed.length - 1) {
|
||||
return {
|
||||
provider: trimmed.slice(0, slashIndex),
|
||||
model: trimmed.slice(slashIndex + 1),
|
||||
};
|
||||
}
|
||||
return { model: trimmed };
|
||||
}
|
||||
|
||||
function normalizeLowercaseStringOrEmptyForTest(value: string | undefined): string {
|
||||
return value?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function resolveDefaultModelForAgentForTest(params: { cfg: OpenClawConfig }): {
|
||||
provider: string;
|
||||
model: string;
|
||||
} {
|
||||
const modelConfig = params.cfg.agents?.defaults?.model;
|
||||
const rawModel =
|
||||
typeof modelConfig === "string" ? modelConfig : (modelConfig?.primary ?? "openai/gpt-5.4");
|
||||
const parsed = parseModelRef(rawModel);
|
||||
const provider = normalizeLowercaseStringOrEmptyForTest(parsed.provider) || "openai";
|
||||
return {
|
||||
provider: provider === "bedrock" ? "amazon-bedrock" : provider,
|
||||
model: parsed.model || "gpt-5.4",
|
||||
};
|
||||
}
|
||||
|
||||
function createModelsProviderDataFromConfig(cfg: OpenClawConfig): {
|
||||
byProvider: Map<string, Set<string>>;
|
||||
providers: string[];
|
||||
resolvedDefault: { provider: string; model: string };
|
||||
modelNames: Map<string, string>;
|
||||
} {
|
||||
const byProvider = new Map<string, Set<string>>();
|
||||
const add = (providerRaw: string | undefined, modelRaw: string | undefined) => {
|
||||
const provider = normalizeLowercaseStringOrEmptyForTest(providerRaw);
|
||||
const model = modelRaw?.trim();
|
||||
if (!provider || !model) {
|
||||
return;
|
||||
}
|
||||
const existing = byProvider.get(provider) ?? new Set<string>();
|
||||
existing.add(model);
|
||||
byProvider.set(provider, existing);
|
||||
};
|
||||
|
||||
const resolvedDefault = resolveDefaultModelForAgentForTest({ cfg });
|
||||
add(resolvedDefault.provider, resolvedDefault.model);
|
||||
|
||||
for (const raw of Object.keys(cfg.agents?.defaults?.models ?? {})) {
|
||||
const parsed = parseModelRef(raw);
|
||||
add(parsed.provider ?? resolvedDefault.provider, parsed.model);
|
||||
}
|
||||
|
||||
const providers = [...byProvider.keys()].toSorted();
|
||||
return { byProvider, providers, resolvedDefault, modelNames: new Map<string, string>() };
|
||||
}
|
||||
|
||||
const systemEventsHoisted = vi.hoisted(() => ({
|
||||
enqueueSystemEventSpy: vi.fn<TelegramBotDeps["enqueueSystemEvent"]>(() => false),
|
||||
}));
|
||||
export const enqueueSystemEventSpy: MockFn<TelegramBotDeps["enqueueSystemEvent"]> =
|
||||
systemEventsHoisted.enqueueSystemEventSpy;
|
||||
const execApprovalHoisted = vi.hoisted(() => ({
|
||||
resolveExecApprovalSpy: vi.fn(async () => undefined),
|
||||
}));
|
||||
export const resolveExecApprovalSpy = execApprovalHoisted.resolveExecApprovalSpy;
|
||||
|
||||
const sentMessageCacheHoisted = vi.hoisted(() => ({
|
||||
wasSentByBot: vi.fn(() => false),
|
||||
}));
|
||||
export const wasSentByBot = sentMessageCacheHoisted.wasSentByBot;
|
||||
|
||||
vi.doMock("./sent-message-cache.js", () => ({
|
||||
wasSentByBot: sentMessageCacheHoisted.wasSentByBot,
|
||||
recordSentMessage: vi.fn(),
|
||||
clearSentMessageCache: vi.fn(),
|
||||
}));
|
||||
|
||||
// All spy variables used inside vi.mock("grammy", ...) must be created via
|
||||
// vi.hoisted() so they are available when the hoisted factory runs, regardless
|
||||
// of module evaluation order across different test files.
|
||||
const grammySpies = vi.hoisted(() => ({
|
||||
useSpy: vi.fn() as MockFn<(arg: unknown) => void>,
|
||||
middlewareUseSpy: vi.fn(),
|
||||
onSpy: vi.fn(),
|
||||
stopSpy: vi.fn(),
|
||||
commandSpy: vi.fn(),
|
||||
botCtorSpy: vi.fn((_: string, __?: { client?: { fetch?: typeof fetch } }) => undefined),
|
||||
answerCallbackQuerySpy: vi.fn(async () => undefined) as AnyAsyncMock,
|
||||
sendChatActionSpy: vi.fn(),
|
||||
editMessageTextSpy: vi.fn(async () => ({ message_id: 88 })) as AnyAsyncMock,
|
||||
editMessageReplyMarkupSpy: vi.fn(async () => ({ message_id: 88 })) as AnyAsyncMock,
|
||||
sendMessageDraftSpy: vi.fn(async () => true) as AnyAsyncMock,
|
||||
setMessageReactionSpy: vi.fn(async () => undefined) as AnyAsyncMock,
|
||||
setMyCommandsSpy: vi.fn(async () => undefined) as AnyAsyncMock,
|
||||
getMeSpy: vi.fn(async () => ({
|
||||
username: "openclaw_bot",
|
||||
has_topics_enabled: true,
|
||||
})) as AnyAsyncMock,
|
||||
getChatSpy: vi.fn(async () => undefined) as AnyAsyncMock,
|
||||
sendMessageSpy: vi.fn(async () => ({ message_id: 77 })) as AnyAsyncMock,
|
||||
sendAnimationSpy: vi.fn(async () => ({ message_id: 78 })) as AnyAsyncMock,
|
||||
sendPhotoSpy: vi.fn(async () => ({ message_id: 79 })) as AnyAsyncMock,
|
||||
getFileSpy: vi.fn(async () => ({ file_path: "media/file.jpg" })) as AnyAsyncMock,
|
||||
}));
|
||||
|
||||
export const useSpy: MockFn<(arg: unknown) => void> = grammySpies.useSpy;
|
||||
export const middlewareUseSpy: AnyMock = grammySpies.middlewareUseSpy;
|
||||
export const onSpy: AnyMock = grammySpies.onSpy;
|
||||
export const stopSpy: AnyMock = grammySpies.stopSpy;
|
||||
export const commandSpy: AnyMock = grammySpies.commandSpy;
|
||||
export const botCtorSpy: MockFn<
|
||||
(token: string, options?: { client?: { fetch?: typeof fetch } }) => void
|
||||
> = grammySpies.botCtorSpy;
|
||||
export const answerCallbackQuerySpy: AnyAsyncMock = grammySpies.answerCallbackQuerySpy;
|
||||
export const sendChatActionSpy: AnyMock = grammySpies.sendChatActionSpy;
|
||||
export const editMessageTextSpy: AnyAsyncMock = grammySpies.editMessageTextSpy;
|
||||
export const editMessageReplyMarkupSpy: AnyAsyncMock = grammySpies.editMessageReplyMarkupSpy;
|
||||
export const sendMessageDraftSpy: AnyAsyncMock = grammySpies.sendMessageDraftSpy;
|
||||
export const setMessageReactionSpy: AnyAsyncMock = grammySpies.setMessageReactionSpy;
|
||||
export const setMyCommandsSpy: AnyAsyncMock = grammySpies.setMyCommandsSpy;
|
||||
export const getMeSpy: AnyAsyncMock = grammySpies.getMeSpy;
|
||||
export const getChatSpy: AnyAsyncMock = grammySpies.getChatSpy;
|
||||
export const sendMessageSpy: AnyAsyncMock = grammySpies.sendMessageSpy;
|
||||
export const sendAnimationSpy: AnyAsyncMock = grammySpies.sendAnimationSpy;
|
||||
export const sendPhotoSpy: AnyAsyncMock = grammySpies.sendPhotoSpy;
|
||||
export const getFileSpy: AnyAsyncMock = grammySpies.getFileSpy;
|
||||
|
||||
const runnerHoisted = vi.hoisted(() => ({
|
||||
sequentializeMiddleware: vi.fn(async (_ctx: unknown, next?: () => Promise<void>) => {
|
||||
if (typeof next === "function") {
|
||||
await next();
|
||||
}
|
||||
}),
|
||||
sequentializeSpy: vi.fn(() => runnerHoisted.sequentializeMiddleware),
|
||||
throttlerSpy: vi.fn(() => "throttler"),
|
||||
}));
|
||||
export const sequentializeSpy: AnyMock = runnerHoisted.sequentializeSpy;
|
||||
export let sequentializeKey: ((ctx: unknown) => string) | undefined;
|
||||
export const throttlerSpy: AnyMock = runnerHoisted.throttlerSpy;
|
||||
export const telegramBotRuntimeForTest: TelegramBotRuntimeForTest = {
|
||||
Bot: class {
|
||||
api = {
|
||||
config: { use: grammySpies.useSpy },
|
||||
answerCallbackQuery: grammySpies.answerCallbackQuerySpy,
|
||||
sendChatAction: grammySpies.sendChatActionSpy,
|
||||
editMessageText: grammySpies.editMessageTextSpy,
|
||||
editMessageReplyMarkup: grammySpies.editMessageReplyMarkupSpy,
|
||||
sendMessageDraft: grammySpies.sendMessageDraftSpy,
|
||||
setMessageReaction: grammySpies.setMessageReactionSpy,
|
||||
setMyCommands: grammySpies.setMyCommandsSpy,
|
||||
getMe: grammySpies.getMeSpy,
|
||||
getChat: grammySpies.getChatSpy,
|
||||
sendMessage: grammySpies.sendMessageSpy,
|
||||
sendAnimation: grammySpies.sendAnimationSpy,
|
||||
sendPhoto: grammySpies.sendPhotoSpy,
|
||||
getFile: grammySpies.getFileSpy,
|
||||
};
|
||||
use = grammySpies.middlewareUseSpy;
|
||||
on = grammySpies.onSpy;
|
||||
stop = grammySpies.stopSpy;
|
||||
command = grammySpies.commandSpy;
|
||||
catch = vi.fn();
|
||||
constructor(
|
||||
public token: string,
|
||||
public options?: { client?: { fetch?: typeof fetch } },
|
||||
) {
|
||||
(grammySpies.botCtorSpy as unknown as (token: string, options?: unknown) => void)(
|
||||
token,
|
||||
options,
|
||||
);
|
||||
}
|
||||
} as unknown as TelegramBotRuntimeForTest["Bot"],
|
||||
sequentialize: ((keyFn: (ctx: unknown) => string) => {
|
||||
sequentializeKey = keyFn;
|
||||
return (
|
||||
runnerHoisted.sequentializeSpy as unknown as () => ReturnType<
|
||||
TelegramBotRuntimeForTest["sequentialize"]
|
||||
>
|
||||
)();
|
||||
}) as unknown as TelegramBotRuntimeForTest["sequentialize"],
|
||||
apiThrottler: (() =>
|
||||
(
|
||||
runnerHoisted.throttlerSpy as unknown as () => unknown
|
||||
)()) as unknown as TelegramBotRuntimeForTest["apiThrottler"],
|
||||
};
|
||||
export const telegramBotDepsForTest: TelegramBotDeps = {
|
||||
loadConfig,
|
||||
loadSessionStore: loadSessionStoreMock as TelegramBotDeps["loadSessionStore"],
|
||||
resolveStorePath: resolveStorePathMock,
|
||||
readChannelAllowFromStore:
|
||||
readChannelAllowFromStore as TelegramBotDeps["readChannelAllowFromStore"],
|
||||
upsertChannelPairingRequest:
|
||||
upsertChannelPairingRequest as TelegramBotDeps["upsertChannelPairingRequest"],
|
||||
enqueueSystemEvent: enqueueSystemEventSpy as TelegramBotDeps["enqueueSystemEvent"],
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
loadWebMedia: loadWebMedia as TelegramBotDeps["loadWebMedia"],
|
||||
buildModelsProviderData: buildModelsProviderData as TelegramBotDeps["buildModelsProviderData"],
|
||||
listSkillCommandsForAgents:
|
||||
listSkillCommandsForAgents as TelegramBotDeps["listSkillCommandsForAgents"],
|
||||
syncTelegramMenuCommands: syncTelegramMenuCommands as TelegramBotDeps["syncTelegramMenuCommands"],
|
||||
wasSentByBot: wasSentByBot as TelegramBotDeps["wasSentByBot"],
|
||||
resolveExecApproval: resolveExecApprovalSpy as NonNullable<
|
||||
TelegramBotDeps["resolveExecApproval"]
|
||||
>,
|
||||
};
|
||||
|
||||
vi.doMock("./bot.runtime.js", () => telegramBotRuntimeForTest);
|
||||
|
||||
export const getOnHandler = (event: string) => {
|
||||
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
|
||||
if (!handler) {
|
||||
throw new Error(`Missing handler for event: ${event}`);
|
||||
}
|
||||
return handler as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
};
|
||||
|
||||
const DEFAULT_TELEGRAM_TEST_CONFIG: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "utc",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: { dmPolicy: "open", allowFrom: ["*"] },
|
||||
},
|
||||
};
|
||||
|
||||
export function makeTelegramMessageCtx(params: {
|
||||
chat: {
|
||||
id: number;
|
||||
type: string;
|
||||
title?: string;
|
||||
is_forum?: boolean;
|
||||
};
|
||||
from: { id: number; username?: string };
|
||||
text: string;
|
||||
date?: number;
|
||||
messageId?: number;
|
||||
messageThreadId?: number;
|
||||
}) {
|
||||
return {
|
||||
message: {
|
||||
chat: params.chat,
|
||||
from: params.from,
|
||||
text: params.text,
|
||||
date: params.date ?? 1736380800,
|
||||
message_id: params.messageId ?? 42,
|
||||
...(params.messageThreadId === undefined
|
||||
? {}
|
||||
: { message_thread_id: params.messageThreadId }),
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
};
|
||||
}
|
||||
|
||||
export function makeForumGroupMessageCtx(params?: {
|
||||
chatId?: number;
|
||||
threadId?: number;
|
||||
text?: string;
|
||||
fromId?: number;
|
||||
username?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return makeTelegramMessageCtx({
|
||||
chat: {
|
||||
id: params?.chatId ?? -1001234567890,
|
||||
type: "supergroup",
|
||||
title: params?.title ?? "Forum Group",
|
||||
is_forum: true,
|
||||
},
|
||||
from: { id: params?.fromId ?? 12345, username: params?.username ?? "testuser" },
|
||||
text: params?.text ?? "hello",
|
||||
messageThreadId: params?.threadId,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetInboundDedupe();
|
||||
loadConfig.mockReset();
|
||||
loadConfig.mockReturnValue(DEFAULT_TELEGRAM_TEST_CONFIG);
|
||||
sessionStoreEntries.value = {};
|
||||
loadSessionStoreMock.mockReset();
|
||||
loadSessionStoreMock.mockImplementation(() => sessionStoreEntries.value);
|
||||
resolveStorePathMock.mockReset();
|
||||
resolveStorePathMock.mockImplementation((storePath?: string) => storePath ?? sessionStorePath);
|
||||
loadWebMedia.mockReset();
|
||||
readChannelAllowFromStore.mockReset();
|
||||
readChannelAllowFromStore.mockResolvedValue([]);
|
||||
upsertChannelPairingRequest.mockReset();
|
||||
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRCODE", created: true } as const);
|
||||
onSpy.mockReset();
|
||||
commandSpy.mockReset();
|
||||
stopSpy.mockReset();
|
||||
useSpy.mockReset();
|
||||
replySpy.mockReset();
|
||||
replySpy.mockImplementation(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
await opts?.onReplyStart?.();
|
||||
return undefined;
|
||||
});
|
||||
resolveExecApprovalSpy.mockReset();
|
||||
resolveExecApprovalSpy.mockResolvedValue(undefined);
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockReset();
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
async (params: DispatchReplyHarnessParams) =>
|
||||
await dispatchHarnessReplies(params, async (dispatchParams) => {
|
||||
return await replySpy(dispatchParams.ctx, dispatchParams.replyOptions);
|
||||
}),
|
||||
);
|
||||
syncTelegramMenuCommands.mockReset();
|
||||
syncTelegramMenuCommands.mockImplementation(async ({ bot, commandsToRegister }) => {
|
||||
await bot.api.setMyCommands(commandsToRegister);
|
||||
});
|
||||
|
||||
sendAnimationSpy.mockReset();
|
||||
sendAnimationSpy.mockResolvedValue({ message_id: 78 });
|
||||
sendPhotoSpy.mockReset();
|
||||
sendPhotoSpy.mockResolvedValue({ message_id: 79 });
|
||||
sendMessageSpy.mockReset();
|
||||
sendMessageSpy.mockResolvedValue({ message_id: 77 });
|
||||
getFileSpy.mockReset();
|
||||
getFileSpy.mockResolvedValue({ file_path: "media/file.jpg" });
|
||||
|
||||
setMessageReactionSpy.mockReset();
|
||||
setMessageReactionSpy.mockResolvedValue(undefined);
|
||||
answerCallbackQuerySpy.mockReset();
|
||||
answerCallbackQuerySpy.mockResolvedValue(undefined);
|
||||
sendChatActionSpy.mockReset();
|
||||
sendChatActionSpy.mockResolvedValue(undefined);
|
||||
setMyCommandsSpy.mockReset();
|
||||
setMyCommandsSpy.mockResolvedValue(undefined);
|
||||
getChatSpy.mockReset();
|
||||
getChatSpy.mockResolvedValue(undefined);
|
||||
getMeSpy.mockReset();
|
||||
getMeSpy.mockResolvedValue({
|
||||
username: "openclaw_bot",
|
||||
has_topics_enabled: true,
|
||||
});
|
||||
editMessageTextSpy.mockReset();
|
||||
editMessageTextSpy.mockResolvedValue({ message_id: 88 });
|
||||
editMessageReplyMarkupSpy.mockReset();
|
||||
editMessageReplyMarkupSpy.mockResolvedValue({ message_id: 88 });
|
||||
sendMessageDraftSpy.mockReset();
|
||||
sendMessageDraftSpy.mockResolvedValue(true);
|
||||
enqueueSystemEventSpy.mockReset();
|
||||
wasSentByBot.mockReset();
|
||||
wasSentByBot.mockReturnValue(false);
|
||||
listSkillCommandsForAgents.mockReset();
|
||||
listSkillCommandsForAgents.mockReturnValue([]);
|
||||
buildModelsProviderData.mockReset();
|
||||
buildModelsProviderData.mockImplementation(async (cfg: OpenClawConfig) => {
|
||||
return createModelsProviderDataFromConfig(cfg);
|
||||
});
|
||||
middlewareUseSpy.mockReset();
|
||||
runnerHoisted.sequentializeMiddleware.mockReset();
|
||||
runnerHoisted.sequentializeMiddleware.mockImplementation(async (_ctx, next) => {
|
||||
if (typeof next === "function") {
|
||||
await next();
|
||||
}
|
||||
});
|
||||
sequentializeSpy.mockReset();
|
||||
sequentializeSpy.mockImplementation(() => runnerHoisted.sequentializeMiddleware);
|
||||
botCtorSpy.mockReset();
|
||||
sequentializeKey = undefined;
|
||||
});
|
||||
3721
openclaw/extensions/telegram/src/bot.create-telegram-bot.test.ts
Normal file
3721
openclaw/extensions/telegram/src/bot.create-telegram-bot.test.ts
Normal file
File diff suppressed because it is too large
Load diff
110
openclaw/extensions/telegram/src/bot.fetch-abort.test.ts
Normal file
110
openclaw/extensions/telegram/src/bot.fetch-abort.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getTelegramNetworkErrorOrigin } from "./network-errors.js";
|
||||
|
||||
const { botCtorSpy, telegramBotDepsForTest, telegramBotRuntimeForTest } =
|
||||
await import("./bot.create-telegram-bot.test-harness.js");
|
||||
const { createTelegramBot: createTelegramBotBase, setTelegramBotRuntimeForTest } =
|
||||
await import("./bot.js");
|
||||
setTelegramBotRuntimeForTest(
|
||||
telegramBotRuntimeForTest as unknown as Parameters<typeof setTelegramBotRuntimeForTest>[0],
|
||||
);
|
||||
const createTelegramBot = (opts: Parameters<typeof createTelegramBotBase>[0]) =>
|
||||
createTelegramBotBase({
|
||||
...opts,
|
||||
telegramDeps: telegramBotDepsForTest,
|
||||
});
|
||||
|
||||
function createWrappedTelegramClientFetch(proxyFetch: typeof fetch) {
|
||||
const shutdown = new AbortController();
|
||||
botCtorSpy.mockClear();
|
||||
createTelegramBot({
|
||||
token: "tok",
|
||||
fetchAbortSignal: shutdown.signal,
|
||||
proxyFetch,
|
||||
});
|
||||
const clientFetch = (botCtorSpy.mock.calls.at(-1)?.[1] as { client?: { fetch?: unknown } })
|
||||
?.client?.fetch as (input: RequestInfo | URL, init?: RequestInit) => Promise<unknown>;
|
||||
expect(clientFetch).toBeTypeOf("function");
|
||||
return { clientFetch, shutdown };
|
||||
}
|
||||
|
||||
describe("createTelegramBot fetch abort", () => {
|
||||
it("aborts wrapped client fetch when fetchAbortSignal aborts", async () => {
|
||||
const fetchSpy = vi.fn(
|
||||
(_input: RequestInfo | URL, init?: RequestInit) =>
|
||||
new Promise<AbortSignal>((resolve) => {
|
||||
const signal = init?.signal as AbortSignal;
|
||||
signal.addEventListener("abort", () => resolve(signal), { once: true });
|
||||
}),
|
||||
);
|
||||
const { clientFetch, shutdown } = createWrappedTelegramClientFetch(
|
||||
fetchSpy as unknown as typeof fetch,
|
||||
);
|
||||
|
||||
const observedSignalPromise = clientFetch("https://example.test");
|
||||
shutdown.abort(new Error("shutdown"));
|
||||
const observedSignal = (await observedSignalPromise) as AbortSignal;
|
||||
|
||||
expect(observedSignal).toBeInstanceOf(AbortSignal);
|
||||
expect(observedSignal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("tags wrapped Telegram fetch failures with the Bot API method", async () => {
|
||||
const fetchError = Object.assign(new TypeError("fetch failed"), {
|
||||
cause: Object.assign(new Error("connect timeout"), {
|
||||
code: "UND_ERR_CONNECT_TIMEOUT",
|
||||
}),
|
||||
});
|
||||
const fetchSpy = vi.fn(async () => {
|
||||
throw fetchError;
|
||||
});
|
||||
const { clientFetch } = createWrappedTelegramClientFetch(fetchSpy as unknown as typeof fetch);
|
||||
|
||||
await expect(clientFetch("https://api.telegram.org/bot123456:ABC/getUpdates")).rejects.toBe(
|
||||
fetchError,
|
||||
);
|
||||
expect(getTelegramNetworkErrorOrigin(fetchError)).toEqual({
|
||||
method: "getupdates",
|
||||
url: "https://api.telegram.org/bot123456:ABC/getUpdates",
|
||||
});
|
||||
});
|
||||
|
||||
it("aborts wrapped getUpdates fetch after the hard polling timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchSpy = vi.fn(
|
||||
(_input: RequestInfo | URL, init?: RequestInit) =>
|
||||
new Promise<AbortSignal>((resolve) => {
|
||||
const signal = init?.signal as AbortSignal;
|
||||
signal.addEventListener("abort", () => resolve(signal), { once: true });
|
||||
}),
|
||||
);
|
||||
const { clientFetch } = createWrappedTelegramClientFetch(fetchSpy as unknown as typeof fetch);
|
||||
|
||||
const observedSignalPromise = clientFetch("https://api.telegram.org/bot123456:ABC/getUpdates");
|
||||
await vi.advanceTimersByTimeAsync(45_000);
|
||||
const observedSignal = (await observedSignalPromise) as AbortSignal;
|
||||
|
||||
expect(observedSignal).toBeInstanceOf(AbortSignal);
|
||||
expect(observedSignal.aborted).toBe(true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("preserves the original fetch error when tagging cannot attach metadata", async () => {
|
||||
const frozenError = Object.freeze(
|
||||
Object.assign(new TypeError("fetch failed"), {
|
||||
cause: Object.assign(new Error("connect timeout"), {
|
||||
code: "UND_ERR_CONNECT_TIMEOUT",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const fetchSpy = vi.fn(async () => {
|
||||
throw frozenError;
|
||||
});
|
||||
const { clientFetch } = createWrappedTelegramClientFetch(fetchSpy as unknown as typeof fetch);
|
||||
|
||||
await expect(clientFetch("https://api.telegram.org/bot123456:ABC/getUpdates")).rejects.toBe(
|
||||
frozenError,
|
||||
);
|
||||
expect(getTelegramNetworkErrorOrigin(frozenError)).toBeNull();
|
||||
});
|
||||
});
|
||||
79
openclaw/extensions/telegram/src/bot.helpers.test.ts
Normal file
79
openclaw/extensions/telegram/src/bot.helpers.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveTelegramStreamMode } from "./bot/helpers.js";
|
||||
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
|
||||
|
||||
describe("resolveTelegramStreamMode", () => {
|
||||
it("defaults to partial when telegram streaming is unset", () => {
|
||||
expect(resolveTelegramStreamMode(undefined)).toBe("partial");
|
||||
expect(resolveTelegramStreamMode({})).toBe("partial");
|
||||
});
|
||||
|
||||
it("prefers explicit streaming boolean", () => {
|
||||
expect(resolveTelegramStreamMode({ streaming: true })).toBe("partial");
|
||||
expect(resolveTelegramStreamMode({ streaming: false })).toBe("off");
|
||||
});
|
||||
|
||||
it("maps legacy streamMode values", () => {
|
||||
expect(resolveTelegramStreamMode({ streamMode: "off" })).toBe("off");
|
||||
expect(resolveTelegramStreamMode({ streamMode: "partial" })).toBe("partial");
|
||||
expect(resolveTelegramStreamMode({ streamMode: "block" })).toBe("block");
|
||||
});
|
||||
|
||||
it("maps unified progress mode to partial on Telegram", () => {
|
||||
expect(resolveTelegramStreamMode({ streaming: "progress" })).toBe("partial");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTelegramDraftStreamingChunking", () => {
|
||||
it("uses smaller defaults than block streaming", () => {
|
||||
const chunking = resolveTelegramDraftStreamingChunking(undefined, "default");
|
||||
expect(chunking).toEqual({
|
||||
minChars: 200,
|
||||
maxChars: 800,
|
||||
breakPreference: "paragraph",
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps to telegram.textChunkLimit", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { telegram: { allowFrom: ["*"], textChunkLimit: 150 } },
|
||||
};
|
||||
const chunking = resolveTelegramDraftStreamingChunking(cfg, "default");
|
||||
expect(chunking).toEqual({
|
||||
minChars: 150,
|
||||
maxChars: 150,
|
||||
breakPreference: "paragraph",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports per-account overrides", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
allowFrom: ["*"],
|
||||
accounts: {
|
||||
default: {
|
||||
allowFrom: ["*"],
|
||||
streaming: {
|
||||
preview: {
|
||||
chunk: {
|
||||
minChars: 10,
|
||||
maxChars: 20,
|
||||
breakPreference: "sentence",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const chunking = resolveTelegramDraftStreamingChunking(cfg, "default");
|
||||
expect(chunking).toEqual({
|
||||
minChars: 10,
|
||||
maxChars: 20,
|
||||
breakPreference: "sentence",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,469 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { telegramBotDepsForTest } from "./bot.media.e2e-harness.js";
|
||||
import { setNextSavedMediaPath } from "./bot.media.e2e-harness.js";
|
||||
import {
|
||||
TELEGRAM_TEST_TIMINGS,
|
||||
createBotHandler,
|
||||
createBotHandlerWithOptions,
|
||||
mockTelegramFileDownload,
|
||||
mockTelegramPngDownload,
|
||||
watchTelegramFetch,
|
||||
} from "./bot.media.test-utils.js";
|
||||
|
||||
describe("telegram inbound media", () => {
|
||||
// Parallel vitest shards can make this suite slower than the standalone run.
|
||||
const INBOUND_MEDIA_TEST_TIMEOUT_MS = process.platform === "win32" ? 120_000 : 90_000;
|
||||
|
||||
it(
|
||||
"handles file_path media downloads and missing file_path safely",
|
||||
async () => {
|
||||
const runtimeLog = vi.fn();
|
||||
const runtimeError = vi.fn();
|
||||
const { handler, replySpy } = await createBotHandlerWithOptions({
|
||||
runtimeLog,
|
||||
runtimeError,
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{
|
||||
name: "downloads via file_path",
|
||||
messageId: 1,
|
||||
getFile: async () => ({ file_path: "photos/1.jpg" }),
|
||||
setupFetch: () =>
|
||||
mockTelegramFileDownload({
|
||||
contentType: "image/jpeg",
|
||||
bytes: new Uint8Array([0xff, 0xd8, 0xff, 0x00]),
|
||||
}),
|
||||
assert: (params: {
|
||||
fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
replySpy: ReturnType<typeof vi.fn>;
|
||||
runtimeError: ReturnType<typeof vi.fn>;
|
||||
}) => {
|
||||
expect(params.runtimeError).not.toHaveBeenCalled();
|
||||
expect(params.fetchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://api.telegram.org/file/bottok/photos/1.jpg",
|
||||
filePathHint: "photos/1.jpg",
|
||||
}),
|
||||
);
|
||||
expect(params.replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = params.replySpy.mock.calls[0][0];
|
||||
expect(payload.Body).toContain("<media:image>");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skips when file_path is missing",
|
||||
messageId: 2,
|
||||
getFile: async () => ({}),
|
||||
setupFetch: () => watchTelegramFetch(),
|
||||
assert: (params: {
|
||||
fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
replySpy: ReturnType<typeof vi.fn>;
|
||||
runtimeError: ReturnType<typeof vi.fn>;
|
||||
}) => {
|
||||
expect(params.fetchSpy).not.toHaveBeenCalled();
|
||||
expect(params.replySpy).not.toHaveBeenCalled();
|
||||
expect(params.runtimeError).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
]) {
|
||||
replySpy.mockClear();
|
||||
runtimeError.mockClear();
|
||||
const fetchSpy = scenario.setupFetch();
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
message_id: scenario.messageId,
|
||||
chat: { id: 1234, type: "private" },
|
||||
from: { id: 777, is_bot: false, first_name: "Ada" },
|
||||
photo: [{ file_id: "fid" }],
|
||||
date: 1736380800, // 2025-01-09T00:00:00Z
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: scenario.getFile,
|
||||
});
|
||||
|
||||
scenario.assert({ fetchSpy, replySpy, runtimeError });
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
},
|
||||
INBOUND_MEDIA_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"keeps Telegram inbound media paths with triple-dash ids",
|
||||
async () => {
|
||||
const runtimeError = vi.fn();
|
||||
const { handler, replySpy } = await createBotHandlerWithOptions({ runtimeError });
|
||||
const fetchSpy = mockTelegramFileDownload({
|
||||
contentType: "image/jpeg",
|
||||
bytes: new Uint8Array([0xff, 0xd8, 0xff, 0x00]),
|
||||
});
|
||||
const inboundPath = "/tmp/media/inbound/file_1095---f00a04a2-99a0-4d98-99b0-dfe61c5a4198.jpg";
|
||||
setNextSavedMediaPath({
|
||||
path: inboundPath,
|
||||
size: 4,
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
|
||||
try {
|
||||
await handler({
|
||||
message: {
|
||||
message_id: 1001,
|
||||
chat: { id: 1234, type: "private" },
|
||||
from: { id: 777, is_bot: false, first_name: "Ada" },
|
||||
photo: [{ file_id: "fid" }],
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ file_path: "photos/1.jpg" }),
|
||||
});
|
||||
|
||||
expect(runtimeError).not.toHaveBeenCalled();
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0]?.[0] as { Body?: string; MediaPaths?: string[] };
|
||||
expect(payload.Body).toContain("<media:image>");
|
||||
expect(payload.MediaPaths).toContain(inboundPath);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
},
|
||||
INBOUND_MEDIA_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it("prefers proxyFetch over global fetch", async () => {
|
||||
const runtimeLog = vi.fn();
|
||||
const runtimeError = vi.fn();
|
||||
const globalFetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
|
||||
throw new Error("global fetch should not be called");
|
||||
});
|
||||
const proxyFetch = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { get: () => "image/jpeg" },
|
||||
arrayBuffer: async () => new Uint8Array([0xff, 0xd8, 0xff]).buffer,
|
||||
} as unknown as Response);
|
||||
|
||||
const { handler } = await createBotHandlerWithOptions({
|
||||
proxyFetch: proxyFetch as unknown as typeof fetch,
|
||||
runtimeLog,
|
||||
runtimeError,
|
||||
});
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
message_id: 2,
|
||||
chat: { id: 1234, type: "private" },
|
||||
photo: [{ file_id: "fid" }],
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ file_path: "photos/2.jpg" }),
|
||||
});
|
||||
|
||||
expect(runtimeError).not.toHaveBeenCalled();
|
||||
expect(proxyFetch).toHaveBeenCalledWith(
|
||||
"https://api.telegram.org/file/bottok/photos/2.jpg",
|
||||
expect.objectContaining({ redirect: "manual" }),
|
||||
);
|
||||
|
||||
globalFetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("captures pin and venue location payload fields", async () => {
|
||||
const { handler, replySpy } = await createBotHandler();
|
||||
|
||||
const cases = [
|
||||
{
|
||||
message: {
|
||||
chat: { id: 42, type: "private" as const },
|
||||
message_id: 5,
|
||||
caption: "Meet here",
|
||||
date: 1736380800,
|
||||
location: {
|
||||
latitude: 48.858844,
|
||||
longitude: 2.294351,
|
||||
horizontal_accuracy: 12,
|
||||
},
|
||||
},
|
||||
assert: (payload: Record<string, unknown>) => {
|
||||
expect(payload.Body).toContain("Meet here");
|
||||
expect(payload.Body).toContain("48.858844");
|
||||
expect(payload.LocationLat).toBe(48.858844);
|
||||
expect(payload.LocationLon).toBe(2.294351);
|
||||
expect(payload.LocationSource).toBe("pin");
|
||||
expect(payload.LocationIsLive).toBe(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
message: {
|
||||
chat: { id: 42, type: "private" as const },
|
||||
message_id: 6,
|
||||
date: 1736380800,
|
||||
venue: {
|
||||
title: "Eiffel Tower",
|
||||
address: "Champ de Mars, Paris",
|
||||
location: { latitude: 48.858844, longitude: 2.294351 },
|
||||
},
|
||||
},
|
||||
assert: (payload: Record<string, unknown>) => {
|
||||
expect(payload.Body).toContain("Eiffel Tower");
|
||||
expect(payload.LocationName).toBe("Eiffel Tower");
|
||||
expect(payload.LocationAddress).toBe("Champ de Mars, Paris");
|
||||
expect(payload.LocationSource).toBe("place");
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
replySpy.mockClear();
|
||||
await handler({
|
||||
message: testCase.message,
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ file_path: "unused" }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0] as Record<string, unknown>;
|
||||
testCase.assert(payload);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("telegram media groups", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
const MEDIA_GROUP_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 20_000;
|
||||
const MEDIA_GROUP_FLUSH_MS = TELEGRAM_TEST_TIMINGS.mediaGroupFlushMs + 40;
|
||||
|
||||
it(
|
||||
"uses custom apiRoot for buffered media-group downloads",
|
||||
async () => {
|
||||
const originalLoadConfig = telegramBotDepsForTest.loadConfig;
|
||||
telegramBotDepsForTest.loadConfig = (() => ({
|
||||
channels: {
|
||||
telegram: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
apiRoot: "http://127.0.0.1:8081/custom-bot-api",
|
||||
},
|
||||
},
|
||||
})) as typeof telegramBotDepsForTest.loadConfig;
|
||||
|
||||
const runtimeError = vi.fn();
|
||||
const { handler, replySpy } = await createBotHandlerWithOptions({ runtimeError });
|
||||
const fetchSpy = mockTelegramPngDownload();
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
handler({
|
||||
message: {
|
||||
chat: { id: 42, type: "private" as const },
|
||||
from: { id: 777, is_bot: false, first_name: "Ada" },
|
||||
message_id: 1,
|
||||
caption: "Album",
|
||||
date: 1736380800,
|
||||
media_group_id: "album-custom-api-root",
|
||||
photo: [{ file_id: "photo1" }],
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ file_path: "photos/photo1.jpg" }),
|
||||
}),
|
||||
handler({
|
||||
message: {
|
||||
chat: { id: 42, type: "private" as const },
|
||||
from: { id: 777, is_bot: false, first_name: "Ada" },
|
||||
message_id: 2,
|
||||
date: 1736380801,
|
||||
media_group_id: "album-custom-api-root",
|
||||
photo: [{ file_id: "photo2" }],
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ file_path: "photos/photo2.jpg" }),
|
||||
}),
|
||||
]);
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
{ timeout: MEDIA_GROUP_FLUSH_MS * 4, interval: 2 },
|
||||
);
|
||||
|
||||
expect(runtimeError).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
url: "http://127.0.0.1:8081/custom-bot-api/file/bottok/photos/photo1.jpg",
|
||||
}),
|
||||
);
|
||||
expect(fetchSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
url: "http://127.0.0.1:8081/custom-bot-api/file/bottok/photos/photo2.jpg",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
telegramBotDepsForTest.loadConfig = originalLoadConfig;
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
},
|
||||
MEDIA_GROUP_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"handles same-group buffering and separate-group independence",
|
||||
async () => {
|
||||
const runtimeError = vi.fn();
|
||||
const { handler, replySpy } = await createBotHandlerWithOptions({ runtimeError });
|
||||
const fetchSpy = mockTelegramPngDownload();
|
||||
|
||||
try {
|
||||
for (const scenario of [
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
chat: { id: 42, type: "private" as const },
|
||||
from: { id: 777, is_bot: false, first_name: "Ada" },
|
||||
message_id: 1,
|
||||
caption: "Here are my photos",
|
||||
date: 1736380800,
|
||||
media_group_id: "album123",
|
||||
photo: [{ file_id: "photo1" }],
|
||||
filePath: "photos/photo1.jpg",
|
||||
},
|
||||
{
|
||||
chat: { id: 42, type: "private" as const },
|
||||
from: { id: 777, is_bot: false, first_name: "Ada" },
|
||||
message_id: 2,
|
||||
date: 1736380801,
|
||||
media_group_id: "album123",
|
||||
photo: [{ file_id: "photo2" }],
|
||||
filePath: "photos/photo2.jpg",
|
||||
},
|
||||
],
|
||||
expectedReplyCount: 1,
|
||||
assert: (replySpy: ReturnType<typeof vi.fn>) => {
|
||||
const payload = replySpy.mock.calls[0]?.[0];
|
||||
expect(payload?.Body).toContain("Here are my photos");
|
||||
expect(payload?.MediaPaths).toHaveLength(2);
|
||||
},
|
||||
},
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
chat: { id: 42, type: "private" as const },
|
||||
from: { id: 777, is_bot: false, first_name: "Ada" },
|
||||
message_id: 11,
|
||||
caption: "Album A",
|
||||
date: 1736380800,
|
||||
media_group_id: "albumA",
|
||||
photo: [{ file_id: "photoA1" }],
|
||||
filePath: "photos/photoA1.jpg",
|
||||
},
|
||||
{
|
||||
chat: { id: 42, type: "private" as const },
|
||||
from: { id: 777, is_bot: false, first_name: "Ada" },
|
||||
message_id: 12,
|
||||
caption: "Album B",
|
||||
date: 1736380801,
|
||||
media_group_id: "albumB",
|
||||
photo: [{ file_id: "photoB1" }],
|
||||
filePath: "photos/photoB1.jpg",
|
||||
},
|
||||
],
|
||||
expectedReplyCount: 2,
|
||||
assert: () => {},
|
||||
},
|
||||
]) {
|
||||
replySpy.mockClear();
|
||||
runtimeError.mockClear();
|
||||
|
||||
await Promise.all(
|
||||
scenario.messages.map((message) =>
|
||||
handler({
|
||||
message,
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ file_path: message.filePath }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(replySpy).toHaveBeenCalledTimes(scenario.expectedReplyCount);
|
||||
},
|
||||
{ timeout: MEDIA_GROUP_FLUSH_MS * 4, interval: 2 },
|
||||
);
|
||||
|
||||
expect(runtimeError).not.toHaveBeenCalled();
|
||||
scenario.assert(replySpy);
|
||||
}
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
},
|
||||
MEDIA_GROUP_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("telegram forwarded bursts", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const FORWARD_BURST_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 20_000;
|
||||
|
||||
it(
|
||||
"coalesces forwarded text + forwarded attachment into a single processing turn with default debounce config",
|
||||
async () => {
|
||||
const runtimeError = vi.fn();
|
||||
const { handler, replySpy } = await createBotHandlerWithOptions({ runtimeError });
|
||||
const fetchSpy = mockTelegramPngDownload();
|
||||
|
||||
try {
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 42, type: "private" },
|
||||
from: { id: 777, is_bot: false, first_name: "N" },
|
||||
message_id: 21,
|
||||
text: "Look at this",
|
||||
date: 1736380800,
|
||||
forward_origin: { type: "hidden_user", date: 1736380700, sender_user_name: "A" },
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({}),
|
||||
});
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 42, type: "private" },
|
||||
from: { id: 777, is_bot: false, first_name: "N" },
|
||||
message_id: 22,
|
||||
date: 1736380801,
|
||||
photo: [{ file_id: "fwd_photo_1" }],
|
||||
forward_origin: { type: "hidden_user", date: 1736380701, sender_user_name: "A" },
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ file_path: "photos/fwd1.jpg" }),
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(runtimeError).not.toHaveBeenCalled();
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expect(payload.Body).toContain("Look at this");
|
||||
expect(payload.MediaPaths).toHaveLength(1);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
},
|
||||
FORWARD_BURST_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
240
openclaw/extensions/telegram/src/bot.media.e2e-harness.ts
Normal file
240
openclaw/extensions/telegram/src/bot.media.e2e-harness.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { GetReplyOptions, MsgContext } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { beforeEach, vi, type Mock } from "vitest";
|
||||
import type { TelegramBotDeps } from "./bot-deps.js";
|
||||
|
||||
type TelegramBotRuntimeForTest = NonNullable<
|
||||
Parameters<typeof import("./bot.js").setTelegramBotRuntimeForTest>[0]
|
||||
>;
|
||||
type DispatchReplyWithBufferedBlockDispatcherFn =
|
||||
typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithBufferedBlockDispatcher;
|
||||
type DispatchReplyHarnessParams = Parameters<DispatchReplyWithBufferedBlockDispatcherFn>[0];
|
||||
type FetchRemoteMediaFn = typeof import("openclaw/plugin-sdk/media-runtime").fetchRemoteMedia;
|
||||
|
||||
export const useSpy: Mock = vi.fn();
|
||||
export const middlewareUseSpy: Mock = vi.fn();
|
||||
export const onSpy: Mock = vi.fn();
|
||||
export const stopSpy: Mock = vi.fn();
|
||||
export const sendChatActionSpy: Mock = vi.fn();
|
||||
|
||||
function defaultUndiciFetch(input: RequestInfo | URL, init?: RequestInit) {
|
||||
return globalThis.fetch(input, init);
|
||||
}
|
||||
|
||||
export const undiciFetchSpy: Mock = vi.fn(defaultUndiciFetch);
|
||||
|
||||
export function resetUndiciFetchMock() {
|
||||
undiciFetchSpy.mockReset();
|
||||
undiciFetchSpy.mockImplementation(defaultUndiciFetch);
|
||||
}
|
||||
|
||||
async function defaultFetchRemoteMedia(
|
||||
params: Parameters<FetchRemoteMediaFn>[0],
|
||||
): ReturnType<FetchRemoteMediaFn> {
|
||||
if (!params.fetchImpl) {
|
||||
throw new Error(`Missing fetchImpl for ${params.url}`);
|
||||
}
|
||||
const response = await params.fetchImpl(params.url, { redirect: "manual" });
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch media from ${params.url}: HTTP ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return {
|
||||
buffer: Buffer.from(arrayBuffer),
|
||||
contentType: response.headers.get("content-type") ?? undefined,
|
||||
fileName: params.filePathHint ? path.basename(params.filePathHint) : undefined,
|
||||
} as Awaited<ReturnType<FetchRemoteMediaFn>>;
|
||||
}
|
||||
|
||||
export const fetchRemoteMediaSpy: Mock = vi.fn(defaultFetchRemoteMedia);
|
||||
|
||||
export function resetFetchRemoteMediaMock() {
|
||||
fetchRemoteMediaSpy.mockReset();
|
||||
fetchRemoteMediaSpy.mockImplementation(defaultFetchRemoteMedia);
|
||||
}
|
||||
|
||||
async function defaultSaveMediaBuffer(buffer: Buffer, contentType?: string) {
|
||||
return {
|
||||
id: "media",
|
||||
path: "/tmp/telegram-media",
|
||||
size: buffer.byteLength,
|
||||
contentType: contentType ?? "application/octet-stream",
|
||||
};
|
||||
}
|
||||
|
||||
const saveMediaBufferSpy: Mock = vi.fn(defaultSaveMediaBuffer);
|
||||
|
||||
export function setNextSavedMediaPath(params: {
|
||||
path: string;
|
||||
id?: string;
|
||||
contentType?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
saveMediaBufferSpy.mockImplementationOnce(
|
||||
async (buffer: Buffer, detectedContentType?: string) => ({
|
||||
id: params.id ?? "media",
|
||||
path: params.path,
|
||||
size: params.size ?? buffer.byteLength,
|
||||
contentType: params.contentType ?? detectedContentType ?? "application/octet-stream",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function resetSaveMediaBufferMock() {
|
||||
saveMediaBufferSpy.mockReset();
|
||||
saveMediaBufferSpy.mockImplementation(defaultSaveMediaBuffer);
|
||||
}
|
||||
|
||||
type ApiStub = {
|
||||
config: { use: (arg: unknown) => void };
|
||||
getChat: Mock;
|
||||
sendChatAction: Mock;
|
||||
sendMessage: Mock;
|
||||
setMyCommands: (commands: Array<{ command: string; description: string }>) => Promise<void>;
|
||||
};
|
||||
|
||||
const apiStub: ApiStub = {
|
||||
config: { use: useSpy },
|
||||
getChat: vi.fn(async () => undefined),
|
||||
sendChatAction: sendChatActionSpy,
|
||||
sendMessage: vi.fn(async () => ({ message_id: 1 })),
|
||||
setMyCommands: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
const throttlerSpy = vi.fn(() => "throttler");
|
||||
|
||||
export const telegramBotRuntimeForTest: TelegramBotRuntimeForTest = {
|
||||
Bot: class {
|
||||
api = apiStub;
|
||||
use = middlewareUseSpy;
|
||||
on = onSpy;
|
||||
command = vi.fn();
|
||||
stop = stopSpy;
|
||||
catch = vi.fn();
|
||||
constructor(public token: string) {}
|
||||
} as unknown as TelegramBotRuntimeForTest["Bot"],
|
||||
sequentialize: (() => vi.fn()) as TelegramBotRuntimeForTest["sequentialize"],
|
||||
apiThrottler: (() => throttlerSpy()) as unknown as TelegramBotRuntimeForTest["apiThrottler"],
|
||||
};
|
||||
|
||||
const mediaHarnessReplySpy = vi.hoisted(() =>
|
||||
vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
await opts?.onReplyStart?.();
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
export { mediaHarnessReplySpy };
|
||||
|
||||
const mediaHarnessDispatchReplyWithBufferedBlockDispatcher = vi.hoisted(() =>
|
||||
vi.fn<DispatchReplyWithBufferedBlockDispatcherFn>(async (params: DispatchReplyHarnessParams) => {
|
||||
await params.dispatcherOptions.typingCallbacks?.onReplyStart?.();
|
||||
const reply = await mediaHarnessReplySpy(params.ctx, params.replyOptions);
|
||||
const payloads = reply === undefined ? [] : Array.isArray(reply) ? reply : [reply];
|
||||
for (const payload of payloads) {
|
||||
await params.dispatcherOptions?.deliver?.(payload, { kind: "final" });
|
||||
}
|
||||
return {
|
||||
queuedFinal: payloads.length > 0,
|
||||
counts: { block: 0, final: payloads.length, tool: 0 },
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
export const telegramBotDepsForTest: TelegramBotDeps = {
|
||||
loadConfig: (() =>
|
||||
({
|
||||
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
|
||||
}) as OpenClawConfig) as TelegramBotDeps["loadConfig"],
|
||||
resolveStorePath: vi.fn(
|
||||
(storePath?: string) => storePath ?? "/tmp/telegram-media-sessions.json",
|
||||
) as TelegramBotDeps["resolveStorePath"],
|
||||
readChannelAllowFromStore: vi.fn(async () => []) as TelegramBotDeps["readChannelAllowFromStore"],
|
||||
upsertChannelPairingRequest: vi.fn(async () => ({
|
||||
code: "PAIRCODE",
|
||||
created: true,
|
||||
})) as TelegramBotDeps["upsertChannelPairingRequest"],
|
||||
enqueueSystemEvent: vi.fn() as TelegramBotDeps["enqueueSystemEvent"],
|
||||
dispatchReplyWithBufferedBlockDispatcher: mediaHarnessDispatchReplyWithBufferedBlockDispatcher,
|
||||
buildModelsProviderData: vi.fn(async () => ({
|
||||
byProvider: new Map<string, Set<string>>(),
|
||||
providers: [],
|
||||
resolvedDefault: { provider: "openai", model: "gpt-4.1" },
|
||||
modelNames: new Map<string, string>(),
|
||||
})) as TelegramBotDeps["buildModelsProviderData"],
|
||||
listSkillCommandsForAgents: vi.fn(() => []) as TelegramBotDeps["listSkillCommandsForAgents"],
|
||||
wasSentByBot: vi.fn(() => false) as TelegramBotDeps["wasSentByBot"],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetInboundDedupe();
|
||||
resetSaveMediaBufferMock();
|
||||
resetUndiciFetchMock();
|
||||
resetFetchRemoteMediaMock();
|
||||
});
|
||||
|
||||
vi.doMock("./bot.runtime.js", () => ({
|
||||
...telegramBotRuntimeForTest,
|
||||
}));
|
||||
|
||||
vi.mock("undici", () => ({
|
||||
Agent: vi.fn(function MockAgent(this: { options?: unknown }, options?: unknown) {
|
||||
this.options = options;
|
||||
}),
|
||||
EnvHttpProxyAgent: vi.fn(function MockEnvHttpProxyAgent(
|
||||
this: { options?: unknown },
|
||||
options?: unknown,
|
||||
) {
|
||||
this.options = options;
|
||||
}),
|
||||
ProxyAgent: vi.fn(function MockProxyAgent(this: { options?: unknown }, options?: unknown) {
|
||||
this.options = options;
|
||||
}),
|
||||
fetch: (...args: Parameters<typeof undiciFetchSpy>) => undiciFetchSpy(...args),
|
||||
setGlobalDispatcher: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./telegram-media.runtime.js", () => ({
|
||||
fetchRemoteMedia: (...args: Parameters<typeof fetchRemoteMediaSpy>) =>
|
||||
fetchRemoteMediaSpy(...args),
|
||||
getAgentScopedMediaLocalRoots: vi.fn(() => []),
|
||||
saveMediaBuffer: (...args: Parameters<typeof saveMediaBufferSpy>) => saveMediaBufferSpy(...args),
|
||||
}));
|
||||
|
||||
vi.doMock("./bot-message-context.session.runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./bot-message-context.session.runtime.js")>(
|
||||
"./bot-message-context.session.runtime.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
readSessionUpdatedAt: () => undefined,
|
||||
resolveStorePath: (storePath?: string) => storePath ?? "/tmp/sessions.json",
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./bot.agent.runtime.js", () => ({
|
||||
resolveDefaultAgentId: vi.fn(() => "default"),
|
||||
}));
|
||||
|
||||
vi.mock("./bot-handlers.agent.runtime.js", () => ({
|
||||
resolveAgentDir: vi.fn(() => "/tmp/agent"),
|
||||
resolveDefaultAgentId: vi.fn(() => "default"),
|
||||
resolveDefaultModelForAgent: vi.fn(() => ({
|
||||
provider: "openai",
|
||||
model: "gpt-test",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./bot-message-dispatch.agent.runtime.js", () => ({
|
||||
findModelInCatalog: vi.fn(() => undefined),
|
||||
loadModelCatalog: vi.fn(async () => []),
|
||||
modelSupportsVision: vi.fn(() => false),
|
||||
resolveAgentDir: vi.fn(() => "/tmp/agent"),
|
||||
resolveDefaultModelForAgent: vi.fn(() => ({
|
||||
provider: "openai",
|
||||
model: "gpt-test",
|
||||
})),
|
||||
}));
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
TELEGRAM_TEST_TIMINGS,
|
||||
cacheStickerSpy,
|
||||
createBotHandlerWithOptions,
|
||||
describeStickerImageSpy,
|
||||
getCachedStickerSpy,
|
||||
} from "./bot.media.test-utils.js";
|
||||
|
||||
describe("telegram stickers", () => {
|
||||
const STICKER_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 20_000;
|
||||
|
||||
async function createStaticStickerHarness() {
|
||||
const proxyFetch = vi.fn().mockResolvedValue(
|
||||
new Response(Buffer.from(new Uint8Array([0x52, 0x49, 0x46, 0x46])), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/webp" },
|
||||
}),
|
||||
);
|
||||
const handlerContext = await createBotHandlerWithOptions({
|
||||
proxyFetch: proxyFetch as unknown as typeof fetch,
|
||||
});
|
||||
return { proxyFetch, ...handlerContext };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
cacheStickerSpy.mockClear();
|
||||
getCachedStickerSpy.mockClear();
|
||||
describeStickerImageSpy.mockClear();
|
||||
// Re-seed defaults so per-test overrides do not leak when using mockClear.
|
||||
getCachedStickerSpy.mockReturnValue(undefined);
|
||||
describeStickerImageSpy.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
// Skipped pending #50185: deterministic static sticker fetch injection.
|
||||
it.skip(
|
||||
"downloads static sticker (WEBP) and includes sticker metadata",
|
||||
async () => {
|
||||
const { handler, proxyFetch, replySpy, runtimeError } = await createStaticStickerHarness();
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
message_id: 100,
|
||||
chat: { id: 1234, type: "private" },
|
||||
from: { id: 777, is_bot: false, first_name: "Ada" },
|
||||
sticker: {
|
||||
file_id: "sticker_file_id_123",
|
||||
file_unique_id: "sticker_unique_123",
|
||||
type: "regular",
|
||||
width: 512,
|
||||
height: 512,
|
||||
is_animated: false,
|
||||
is_video: false,
|
||||
emoji: "🎉",
|
||||
set_name: "TestStickerPack",
|
||||
},
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ file_path: "stickers/sticker.webp" }),
|
||||
});
|
||||
|
||||
expect(runtimeError).not.toHaveBeenCalled();
|
||||
expect(proxyFetch).toHaveBeenCalledWith(
|
||||
"https://api.telegram.org/file/bottok/stickers/sticker.webp",
|
||||
expect.objectContaining({ redirect: "manual" }),
|
||||
);
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expect(payload.Body).toContain("<media:sticker>");
|
||||
expect(payload.Sticker?.emoji).toBe("🎉");
|
||||
expect(payload.Sticker?.setName).toBe("TestStickerPack");
|
||||
expect(payload.Sticker?.fileId).toBe("sticker_file_id_123");
|
||||
},
|
||||
STICKER_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
// Skipped pending #50185: deterministic cache-refresh assertions in CI.
|
||||
it.skip(
|
||||
"refreshes cached sticker metadata on cache hit",
|
||||
async () => {
|
||||
const { handler, proxyFetch, replySpy, runtimeError } = await createStaticStickerHarness();
|
||||
|
||||
getCachedStickerSpy.mockReturnValue({
|
||||
fileId: "old_file_id",
|
||||
fileUniqueId: "sticker_unique_456",
|
||||
emoji: "😴",
|
||||
setName: "OldSet",
|
||||
description: "Cached description",
|
||||
cachedAt: "2026-01-20T10:00:00.000Z",
|
||||
});
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
message_id: 103,
|
||||
chat: { id: 1234, type: "private" },
|
||||
from: { id: 777, is_bot: false, first_name: "Ada" },
|
||||
sticker: {
|
||||
file_id: "new_file_id",
|
||||
file_unique_id: "sticker_unique_456",
|
||||
type: "regular",
|
||||
width: 512,
|
||||
height: 512,
|
||||
is_animated: false,
|
||||
is_video: false,
|
||||
emoji: "🔥",
|
||||
set_name: "NewSet",
|
||||
},
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ file_path: "stickers/sticker.webp" }),
|
||||
});
|
||||
|
||||
expect(runtimeError).not.toHaveBeenCalled();
|
||||
expect(cacheStickerSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fileId: "new_file_id",
|
||||
emoji: "🔥",
|
||||
setName: "NewSet",
|
||||
}),
|
||||
);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expect(payload.Sticker?.fileId).toBe("new_file_id");
|
||||
expect(payload.Sticker?.cachedDescription).toBe("Cached description");
|
||||
expect(proxyFetch).toHaveBeenCalledWith(
|
||||
"https://api.telegram.org/file/bottok/stickers/sticker.webp",
|
||||
expect.objectContaining({ redirect: "manual" }),
|
||||
);
|
||||
},
|
||||
STICKER_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"skips animated and video sticker formats that cannot be downloaded",
|
||||
async () => {
|
||||
const proxyFetch = vi.fn();
|
||||
const { handler, replySpy, runtimeError } = await createBotHandlerWithOptions({
|
||||
proxyFetch: proxyFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{
|
||||
messageId: 101,
|
||||
filePath: "stickers/animated.tgs",
|
||||
sticker: {
|
||||
file_id: "animated_sticker_id",
|
||||
file_unique_id: "animated_unique",
|
||||
type: "regular",
|
||||
width: 512,
|
||||
height: 512,
|
||||
is_animated: true,
|
||||
is_video: false,
|
||||
emoji: "😎",
|
||||
set_name: "AnimatedPack",
|
||||
},
|
||||
},
|
||||
{
|
||||
messageId: 102,
|
||||
filePath: "stickers/video.webm",
|
||||
sticker: {
|
||||
file_id: "video_sticker_id",
|
||||
file_unique_id: "video_unique",
|
||||
type: "regular",
|
||||
width: 512,
|
||||
height: 512,
|
||||
is_animated: false,
|
||||
is_video: true,
|
||||
emoji: "🎬",
|
||||
set_name: "VideoPack",
|
||||
},
|
||||
},
|
||||
]) {
|
||||
replySpy.mockClear();
|
||||
runtimeError.mockClear();
|
||||
proxyFetch.mockClear();
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
message_id: scenario.messageId,
|
||||
chat: { id: 1234, type: "private" },
|
||||
from: { id: 777, is_bot: false, first_name: "Ada" },
|
||||
sticker: scenario.sticker,
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ file_path: scenario.filePath }),
|
||||
});
|
||||
|
||||
expect(proxyFetch).not.toHaveBeenCalled();
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
expect(runtimeError).not.toHaveBeenCalled();
|
||||
}
|
||||
},
|
||||
STICKER_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("telegram text fragments", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
const TEXT_FRAGMENT_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 20_000;
|
||||
const TEXT_FRAGMENT_FLUSH_MS = TELEGRAM_TEST_TIMINGS.textFragmentGapMs + 80;
|
||||
|
||||
it(
|
||||
"buffers near-limit text and processes sequential parts as one message",
|
||||
async () => {
|
||||
const { handler, replySpy } = await createBotHandlerWithOptions({});
|
||||
const part1 = "A".repeat(4050);
|
||||
const part2 = "B".repeat(50);
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 42, type: "private" },
|
||||
from: { id: 777, is_bot: false, first_name: "Ada" },
|
||||
message_id: 10,
|
||||
date: 1736380800,
|
||||
text: part1,
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({}),
|
||||
});
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 42, type: "private" },
|
||||
from: { id: 777, is_bot: false, first_name: "Ada" },
|
||||
message_id: 11,
|
||||
date: 1736380801,
|
||||
text: part2,
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({}),
|
||||
});
|
||||
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
{ timeout: TEXT_FRAGMENT_FLUSH_MS * 6, interval: 5 },
|
||||
);
|
||||
|
||||
const payload = replySpy.mock.calls[0][0] as { RawBody?: string };
|
||||
expect(payload.RawBody).toContain(part1.slice(0, 32));
|
||||
expect(payload.RawBody).toContain(part2.slice(0, 32));
|
||||
},
|
||||
TEXT_FRAGMENT_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
166
openclaw/extensions/telegram/src/bot.media.test-utils.ts
Normal file
166
openclaw/extensions/telegram/src/bot.media.test-utils.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import * as ssrf from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { afterEach, beforeAll, beforeEach, expect, vi, type Mock } from "vitest";
|
||||
import * as harness from "./bot.media.e2e-harness.js";
|
||||
|
||||
type StickerSpy = Mock<(...args: unknown[]) => unknown>;
|
||||
|
||||
export const cacheStickerSpy: StickerSpy = vi.fn();
|
||||
export const getCachedStickerSpy: StickerSpy = vi.fn();
|
||||
export const describeStickerImageSpy: StickerSpy = vi.fn();
|
||||
|
||||
const resolvePinnedHostname = ssrf.resolvePinnedHostname;
|
||||
const lookupMock = vi.fn();
|
||||
let resolvePinnedHostnameSpy: ReturnType<typeof vi.spyOn> = null;
|
||||
|
||||
export const TELEGRAM_TEST_TIMINGS = {
|
||||
mediaGroupFlushMs: 20,
|
||||
textFragmentGapMs: 30,
|
||||
} as const;
|
||||
|
||||
let createTelegramBotRef: typeof import("./bot.js").createTelegramBot;
|
||||
let replySpyRef: ReturnType<typeof vi.fn>;
|
||||
let onSpyRef: Mock;
|
||||
let sendChatActionSpyRef: Mock;
|
||||
let fetchRemoteMediaSpyRef: Mock;
|
||||
let undiciFetchSpyRef: Mock;
|
||||
let resetFetchRemoteMediaMockRef: () => void;
|
||||
|
||||
type FetchMockHandle = Mock & { mockRestore: () => void };
|
||||
|
||||
function createFetchMockHandle(): FetchMockHandle {
|
||||
return Object.assign(fetchRemoteMediaSpyRef, {
|
||||
mockRestore: () => {
|
||||
resetFetchRemoteMediaMockRef();
|
||||
},
|
||||
}) as FetchMockHandle;
|
||||
}
|
||||
|
||||
export async function createBotHandler(): Promise<{
|
||||
handler: (ctx: Record<string, unknown>) => Promise<void>;
|
||||
replySpy: ReturnType<typeof vi.fn>;
|
||||
runtimeError: ReturnType<typeof vi.fn>;
|
||||
}> {
|
||||
return createBotHandlerWithOptions({});
|
||||
}
|
||||
|
||||
export async function createBotHandlerWithOptions(options: {
|
||||
proxyFetch?: typeof fetch;
|
||||
runtimeLog?: ReturnType<typeof vi.fn>;
|
||||
runtimeError?: ReturnType<typeof vi.fn>;
|
||||
}): Promise<{
|
||||
handler: (ctx: Record<string, unknown>) => Promise<void>;
|
||||
replySpy: ReturnType<typeof vi.fn>;
|
||||
runtimeError: ReturnType<typeof vi.fn>;
|
||||
}> {
|
||||
onSpyRef.mockClear();
|
||||
replySpyRef.mockClear();
|
||||
sendChatActionSpyRef.mockClear();
|
||||
|
||||
const runtimeError = options.runtimeError ?? vi.fn();
|
||||
const runtimeLog = options.runtimeLog ?? vi.fn();
|
||||
const effectiveProxyFetch = options.proxyFetch ?? (undiciFetchSpyRef as unknown as typeof fetch);
|
||||
createTelegramBotRef({
|
||||
token: "tok",
|
||||
testTimings: TELEGRAM_TEST_TIMINGS,
|
||||
...(effectiveProxyFetch ? { proxyFetch: effectiveProxyFetch } : {}),
|
||||
runtime: {
|
||||
log: runtimeLog as (...data: unknown[]) => void,
|
||||
error: runtimeError as (...data: unknown[]) => void,
|
||||
exit: () => {
|
||||
throw new Error("exit");
|
||||
},
|
||||
},
|
||||
});
|
||||
const handler = onSpyRef.mock.calls.find((call) => call[0] === "message")?.[1] as (
|
||||
ctx: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
expect(handler).toBeDefined();
|
||||
return { handler, replySpy: replySpyRef, runtimeError };
|
||||
}
|
||||
|
||||
export function mockTelegramFileDownload(params: {
|
||||
contentType: string;
|
||||
bytes: Uint8Array;
|
||||
}): FetchMockHandle {
|
||||
undiciFetchSpyRef.mockResolvedValueOnce(
|
||||
new Response(Buffer.from(params.bytes), {
|
||||
status: 200,
|
||||
headers: { "content-type": params.contentType },
|
||||
}),
|
||||
);
|
||||
fetchRemoteMediaSpyRef.mockResolvedValueOnce({
|
||||
buffer: Buffer.from(params.bytes),
|
||||
contentType: params.contentType,
|
||||
fileName: "mock-file",
|
||||
});
|
||||
return createFetchMockHandle();
|
||||
}
|
||||
|
||||
export function mockTelegramPngDownload(): FetchMockHandle {
|
||||
undiciFetchSpyRef.mockResolvedValue(
|
||||
new Response(Buffer.from(new Uint8Array([0x89, 0x50, 0x4e, 0x47])), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/png" },
|
||||
}),
|
||||
);
|
||||
fetchRemoteMediaSpyRef.mockResolvedValue({
|
||||
buffer: Buffer.from(new Uint8Array([0x89, 0x50, 0x4e, 0x47])),
|
||||
contentType: "image/png",
|
||||
fileName: "mock-file.png",
|
||||
});
|
||||
return createFetchMockHandle();
|
||||
}
|
||||
|
||||
export function watchTelegramFetch(): FetchMockHandle {
|
||||
return createFetchMockHandle();
|
||||
}
|
||||
|
||||
async function loadTelegramBotHarness() {
|
||||
onSpyRef = harness.onSpy;
|
||||
sendChatActionSpyRef = harness.sendChatActionSpy;
|
||||
fetchRemoteMediaSpyRef = harness.fetchRemoteMediaSpy;
|
||||
undiciFetchSpyRef = harness.undiciFetchSpy;
|
||||
resetFetchRemoteMediaMockRef = harness.resetFetchRemoteMediaMock;
|
||||
const botModule = await import("./bot.js");
|
||||
botModule.setTelegramBotRuntimeForTest(
|
||||
harness.telegramBotRuntimeForTest as unknown as Parameters<
|
||||
typeof botModule.setTelegramBotRuntimeForTest
|
||||
>[0],
|
||||
);
|
||||
createTelegramBotRef = (opts) =>
|
||||
botModule.createTelegramBot({
|
||||
...opts,
|
||||
telegramDeps: harness.telegramBotDepsForTest,
|
||||
});
|
||||
replySpyRef = harness.mediaHarnessReplySpy;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadTelegramBotHarness();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
onSpyRef.mockClear();
|
||||
replySpyRef.mockClear();
|
||||
sendChatActionSpyRef.mockClear();
|
||||
vi.useRealTimers();
|
||||
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
||||
resolvePinnedHostnameSpy = vi
|
||||
.spyOn(ssrf, "resolvePinnedHostname")
|
||||
.mockImplementation((hostname) => resolvePinnedHostname(hostname, lookupMock));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
lookupMock.mockClear();
|
||||
resolvePinnedHostnameSpy?.mockRestore();
|
||||
resolvePinnedHostnameSpy = null;
|
||||
});
|
||||
|
||||
vi.mock("./sticker-cache.js", () => ({
|
||||
cacheSticker: (...args: unknown[]) => cacheStickerSpy(...args),
|
||||
getCachedSticker: (...args: unknown[]) => getCachedStickerSpy(...args),
|
||||
describeStickerImage: (...args: unknown[]) => describeStickerImageSpy(...args),
|
||||
getAllCachedStickers: vi.fn(() => []),
|
||||
getCacheStats: vi.fn(() => ({ count: 0 })),
|
||||
searchStickers: vi.fn(() => []),
|
||||
}));
|
||||
4
openclaw/extensions/telegram/src/bot.runtime.ts
Normal file
4
openclaw/extensions/telegram/src/bot.runtime.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { sequentialize } from "@grammyjs/runner";
|
||||
export { apiThrottler } from "@grammyjs/transformer-throttler";
|
||||
export { Bot } from "grammy";
|
||||
export type { ApiClientOptions } from "grammy";
|
||||
2809
openclaw/extensions/telegram/src/bot.test.ts
Normal file
2809
openclaw/extensions/telegram/src/bot.test.ts
Normal file
File diff suppressed because it is too large
Load diff
630
openclaw/extensions/telegram/src/bot.ts
Normal file
630
openclaw/extensions/telegram/src/bot.ts
Normal file
|
|
@ -0,0 +1,630 @@
|
|||
import {
|
||||
isNativeCommandsExplicitlyDisabled,
|
||||
resolveNativeCommandsEnabled,
|
||||
resolveNativeSkillsEnabled,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
resolveChannelGroupPolicy,
|
||||
resolveChannelGroupRequireMention,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
resolveThreadBindingIdleTimeoutMsForChannel,
|
||||
resolveThreadBindingMaxAgeMsForChannel,
|
||||
resolveThreadBindingSpawnPolicy,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { formatErrorMessage, formatUncaughtError } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking";
|
||||
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-history";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveTelegramAccount } from "./accounts.js";
|
||||
import { defaultTelegramBotDeps } from "./bot-deps.js";
|
||||
import { registerTelegramHandlers } from "./bot-handlers.runtime.js";
|
||||
import { createTelegramMessageProcessor } from "./bot-message.js";
|
||||
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
|
||||
import {
|
||||
buildTelegramUpdateKey,
|
||||
createTelegramUpdateDedupe,
|
||||
resolveTelegramUpdateId,
|
||||
type TelegramUpdateKeyContext,
|
||||
} from "./bot-updates.js";
|
||||
import { resolveDefaultAgentId } from "./bot.agent.runtime.js";
|
||||
import { apiThrottler, Bot, sequentialize, type ApiClientOptions } from "./bot.runtime.js";
|
||||
import type { TelegramBotOptions } from "./bot.types.js";
|
||||
import { buildTelegramGroupPeerId, resolveTelegramStreamMode } from "./bot/helpers.js";
|
||||
import { resolveTelegramTransport } from "./fetch.js";
|
||||
import { tagTelegramNetworkError } from "./network-errors.js";
|
||||
import { resolveTelegramRequestTimeoutMs } from "./request-timeouts.js";
|
||||
import { createTelegramSendChatActionHandler } from "./sendchataction-401-backoff.js";
|
||||
import { getTelegramSequentialKey } from "./sequential-key.js";
|
||||
import { createTelegramThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
export type { TelegramBotOptions } from "./bot.types.js";
|
||||
|
||||
export { getTelegramSequentialKey };
|
||||
|
||||
type TelegramBotRuntime = {
|
||||
Bot: typeof Bot;
|
||||
sequentialize: typeof sequentialize;
|
||||
apiThrottler: typeof apiThrottler;
|
||||
};
|
||||
type TelegramBotInstance = InstanceType<TelegramBotRuntime["Bot"]>;
|
||||
|
||||
const DEFAULT_TELEGRAM_BOT_RUNTIME: TelegramBotRuntime = {
|
||||
Bot,
|
||||
sequentialize,
|
||||
apiThrottler,
|
||||
};
|
||||
|
||||
let telegramBotRuntimeForTest: TelegramBotRuntime | undefined;
|
||||
|
||||
export function setTelegramBotRuntimeForTest(runtime?: TelegramBotRuntime): void {
|
||||
telegramBotRuntimeForTest = runtime;
|
||||
}
|
||||
|
||||
type TelegramFetchInput = Parameters<NonNullable<ApiClientOptions["fetch"]>>[0];
|
||||
type TelegramFetchInit = Parameters<NonNullable<ApiClientOptions["fetch"]>>[1];
|
||||
type TelegramClientFetch = NonNullable<ApiClientOptions["fetch"]>;
|
||||
type TelegramCompatFetch = (
|
||||
input: TelegramFetchInput,
|
||||
init?: TelegramFetchInit,
|
||||
) => ReturnType<TelegramClientFetch>;
|
||||
|
||||
function asTelegramClientFetch(
|
||||
fetchImpl: TelegramCompatFetch | typeof globalThis.fetch,
|
||||
): TelegramClientFetch {
|
||||
return fetchImpl as unknown as TelegramClientFetch;
|
||||
}
|
||||
|
||||
function asTelegramCompatFetch(fetchImpl: TelegramClientFetch): TelegramCompatFetch {
|
||||
return fetchImpl as unknown as TelegramCompatFetch;
|
||||
}
|
||||
|
||||
function readRequestUrl(input: TelegramFetchInput): string | null {
|
||||
if (typeof input === "string") {
|
||||
return input;
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
return input.toString();
|
||||
}
|
||||
if (input instanceof Request) {
|
||||
return input.url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTelegramApiMethod(input: TelegramFetchInput): string | null {
|
||||
const url = readRequestUrl(input);
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const pathname = new URL(url).pathname;
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const method = segments.length > 0 ? (segments.at(-1) ?? null) : null;
|
||||
return normalizeOptionalLowercaseString(method) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createTelegramBot(opts: TelegramBotOptions): TelegramBotInstance {
|
||||
const botRuntime = telegramBotRuntimeForTest ?? DEFAULT_TELEGRAM_BOT_RUNTIME;
|
||||
const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime();
|
||||
const telegramDeps = opts.telegramDeps ?? defaultTelegramBotDeps;
|
||||
const cfg = opts.config ?? telegramDeps.loadConfig();
|
||||
const account = resolveTelegramAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const threadBindingPolicy = resolveThreadBindingSpawnPolicy({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: account.accountId,
|
||||
kind: "subagent",
|
||||
});
|
||||
const threadBindingManager = threadBindingPolicy.enabled
|
||||
? createTelegramThreadBindingManager({
|
||||
accountId: account.accountId,
|
||||
idleTimeoutMs: resolveThreadBindingIdleTimeoutMsForChannel({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: account.accountId,
|
||||
}),
|
||||
maxAgeMs: resolveThreadBindingMaxAgeMsForChannel({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: account.accountId,
|
||||
}),
|
||||
})
|
||||
: null;
|
||||
const telegramCfg = account.config;
|
||||
|
||||
const telegramTransport =
|
||||
opts.telegramTransport ??
|
||||
resolveTelegramTransport(opts.proxyFetch, {
|
||||
network: telegramCfg.network,
|
||||
});
|
||||
const shouldProvideFetch = Boolean(telegramTransport.fetch);
|
||||
// grammY's ApiClientOptions types still track `node-fetch` types; Node 22+ global fetch
|
||||
// (undici) is structurally compatible at runtime but not assignable in TS.
|
||||
const fetchForClient = telegramTransport.fetch
|
||||
? asTelegramCompatFetch(asTelegramClientFetch(telegramTransport.fetch))
|
||||
: undefined;
|
||||
|
||||
// Wrap fetch so polling requests cannot hang indefinitely on a wedged network path,
|
||||
// and so shutdown still aborts in-flight Telegram API requests immediately.
|
||||
let finalFetch: TelegramCompatFetch | undefined = shouldProvideFetch ? fetchForClient : undefined;
|
||||
if (finalFetch || opts.fetchAbortSignal) {
|
||||
const baseFetch = finalFetch ?? asTelegramCompatFetch(asTelegramClientFetch(globalThis.fetch));
|
||||
// Cast baseFetch to global fetch to avoid node-fetch ↔ global-fetch type divergence;
|
||||
// they are runtime-compatible (the codebase already casts at every fetch boundary).
|
||||
const callFetch = baseFetch;
|
||||
// Use manual event forwarding instead of AbortSignal.any() to avoid the cross-realm
|
||||
// AbortSignal issue in Node.js (grammY's signal may come from a different module context,
|
||||
// causing "signals[0] must be an instance of AbortSignal" errors).
|
||||
finalFetch = (input: TelegramFetchInput, init?: TelegramFetchInit) => {
|
||||
const controller = new AbortController();
|
||||
const abortWith = (signal: AbortSignal) => controller.abort(signal.reason);
|
||||
const shutdownSignal = opts.fetchAbortSignal;
|
||||
const onShutdown = () => {
|
||||
if (shutdownSignal) {
|
||||
abortWith(shutdownSignal);
|
||||
}
|
||||
};
|
||||
const method = extractTelegramApiMethod(input);
|
||||
const requestTimeoutMs = resolveTelegramRequestTimeoutMs(method);
|
||||
let requestTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let onRequestAbort: (() => void) | undefined;
|
||||
const requestSignal = init?.signal;
|
||||
if (shutdownSignal?.aborted) {
|
||||
abortWith(shutdownSignal);
|
||||
} else if (shutdownSignal) {
|
||||
shutdownSignal.addEventListener("abort", onShutdown, { once: true });
|
||||
}
|
||||
if (requestSignal) {
|
||||
if (requestSignal.aborted) {
|
||||
abortWith(requestSignal);
|
||||
} else {
|
||||
onRequestAbort = () => abortWith(requestSignal);
|
||||
requestSignal.addEventListener("abort", onRequestAbort);
|
||||
}
|
||||
}
|
||||
if (requestTimeoutMs) {
|
||||
requestTimeout = setTimeout(() => {
|
||||
controller.abort(new Error(`Telegram ${method} timed out after ${requestTimeoutMs}ms`));
|
||||
}, requestTimeoutMs);
|
||||
requestTimeout.unref?.();
|
||||
}
|
||||
return callFetch(input, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
}).finally(() => {
|
||||
if (requestTimeout) {
|
||||
clearTimeout(requestTimeout);
|
||||
}
|
||||
shutdownSignal?.removeEventListener("abort", onShutdown);
|
||||
if (requestSignal && onRequestAbort) {
|
||||
requestSignal.removeEventListener("abort", onRequestAbort);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
if (finalFetch) {
|
||||
const baseFetch = finalFetch;
|
||||
finalFetch = (input: TelegramFetchInput, init?: TelegramFetchInit) => {
|
||||
return Promise.resolve(baseFetch(input, init)).catch((err: unknown) => {
|
||||
try {
|
||||
tagTelegramNetworkError(err, {
|
||||
method: extractTelegramApiMethod(input),
|
||||
url: readRequestUrl(input),
|
||||
});
|
||||
} catch {
|
||||
// Tagging is best-effort; preserve the original fetch failure if the
|
||||
// error object cannot accept extra metadata.
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutSeconds =
|
||||
typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds)
|
||||
? Math.max(1, Math.floor(telegramCfg.timeoutSeconds))
|
||||
: undefined;
|
||||
const apiRoot = normalizeOptionalString(telegramCfg.apiRoot);
|
||||
const client: ApiClientOptions | undefined =
|
||||
finalFetch || timeoutSeconds || apiRoot
|
||||
? {
|
||||
...(finalFetch ? { fetch: asTelegramClientFetch(finalFetch) } : {}),
|
||||
...(timeoutSeconds ? { timeoutSeconds } : {}),
|
||||
...(apiRoot ? { apiRoot } : {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const bot = new botRuntime.Bot(opts.token, client ? { client } : undefined);
|
||||
bot.api.config.use(botRuntime.apiThrottler());
|
||||
// Catch all errors from bot middleware to prevent unhandled rejections
|
||||
bot.catch((err) => {
|
||||
runtime.error?.(danger(`telegram bot error: ${formatUncaughtError(err)}`));
|
||||
});
|
||||
|
||||
const recentUpdates = createTelegramUpdateDedupe();
|
||||
const pendingUpdateKeys = new Set<string>();
|
||||
const activeHandledUpdateKeys = new Map<string, boolean>();
|
||||
const initialUpdateId =
|
||||
typeof opts.updateOffset?.lastUpdateId === "number" ? opts.updateOffset.lastUpdateId : null;
|
||||
|
||||
// Track update_ids that have entered the middleware pipeline but have not completed yet.
|
||||
// This includes updates that are "queued" behind sequentialize(...) for a chat/topic key.
|
||||
// We only persist a watermark that is strictly less than the smallest pending update_id,
|
||||
// so we never write an offset that would skip an update still waiting to run.
|
||||
const pendingUpdateIds = new Set<number>();
|
||||
const failedUpdateIds = new Set<number>();
|
||||
let highestCompletedUpdateId: number | null = initialUpdateId;
|
||||
let highestPersistedUpdateId: number | null = initialUpdateId;
|
||||
const maybePersistSafeWatermark = () => {
|
||||
if (typeof opts.updateOffset?.onUpdateId !== "function") {
|
||||
return;
|
||||
}
|
||||
if (highestCompletedUpdateId === null) {
|
||||
return;
|
||||
}
|
||||
let safe = highestCompletedUpdateId;
|
||||
if (pendingUpdateIds.size > 0) {
|
||||
let minPending: number | null = null;
|
||||
for (const id of pendingUpdateIds) {
|
||||
if (minPending === null || id < minPending) {
|
||||
minPending = id;
|
||||
}
|
||||
}
|
||||
if (minPending !== null) {
|
||||
safe = Math.min(safe, minPending - 1);
|
||||
}
|
||||
}
|
||||
if (failedUpdateIds.size > 0) {
|
||||
let minFailed: number | null = null;
|
||||
for (const id of failedUpdateIds) {
|
||||
if (minFailed === null || id < minFailed) {
|
||||
minFailed = id;
|
||||
}
|
||||
}
|
||||
if (minFailed !== null) {
|
||||
safe = Math.min(safe, minFailed - 1);
|
||||
}
|
||||
}
|
||||
if (highestPersistedUpdateId !== null && safe <= highestPersistedUpdateId) {
|
||||
return;
|
||||
}
|
||||
highestPersistedUpdateId = safe;
|
||||
void Promise.resolve()
|
||||
.then(() => opts.updateOffset?.onUpdateId?.(safe))
|
||||
.catch((err) => {
|
||||
runtime.error?.(`telegram: failed to persist update watermark: ${formatErrorMessage(err)}`);
|
||||
});
|
||||
};
|
||||
|
||||
const logSkippedUpdate = (key: string) => {
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(`telegram dedupe: skipped ${key}`);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldSkipUpdate = (ctx: TelegramUpdateKeyContext) => {
|
||||
const updateId = resolveTelegramUpdateId(ctx);
|
||||
const skipCutoff = highestPersistedUpdateId ?? initialUpdateId;
|
||||
if (typeof updateId === "number" && skipCutoff !== null && updateId <= skipCutoff) {
|
||||
return true;
|
||||
}
|
||||
const key = buildTelegramUpdateKey(ctx);
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
const handled = activeHandledUpdateKeys.get(key);
|
||||
if (handled != null) {
|
||||
if (handled) {
|
||||
logSkippedUpdate(key);
|
||||
return true;
|
||||
}
|
||||
activeHandledUpdateKeys.set(key, true);
|
||||
return false;
|
||||
}
|
||||
const skipped = recentUpdates.check(key);
|
||||
if (skipped) {
|
||||
logSkippedUpdate(key);
|
||||
}
|
||||
return skipped;
|
||||
};
|
||||
|
||||
bot.use(async (ctx, next) => {
|
||||
const updateId = resolveTelegramUpdateId(ctx);
|
||||
const updateKey = buildTelegramUpdateKey(ctx);
|
||||
let completed = false;
|
||||
if (typeof updateId === "number") {
|
||||
failedUpdateIds.delete(updateId);
|
||||
pendingUpdateIds.add(updateId);
|
||||
}
|
||||
if (updateKey) {
|
||||
if (pendingUpdateKeys.has(updateKey) || recentUpdates.peek(updateKey)) {
|
||||
logSkippedUpdate(updateKey);
|
||||
if (typeof updateId === "number") {
|
||||
pendingUpdateIds.delete(updateId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
pendingUpdateKeys.add(updateKey);
|
||||
activeHandledUpdateKeys.set(updateKey, false);
|
||||
}
|
||||
try {
|
||||
await next();
|
||||
completed = true;
|
||||
} finally {
|
||||
if (updateKey) {
|
||||
activeHandledUpdateKeys.delete(updateKey);
|
||||
if (completed) {
|
||||
recentUpdates.check(updateKey);
|
||||
}
|
||||
pendingUpdateKeys.delete(updateKey);
|
||||
}
|
||||
if (typeof updateId === "number") {
|
||||
pendingUpdateIds.delete(updateId);
|
||||
if (completed) {
|
||||
if (highestCompletedUpdateId === null || updateId > highestCompletedUpdateId) {
|
||||
highestCompletedUpdateId = updateId;
|
||||
}
|
||||
maybePersistSafeWatermark();
|
||||
} else {
|
||||
failedUpdateIds.add(updateId);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
bot.use(botRuntime.sequentialize(getTelegramSequentialKey));
|
||||
|
||||
const rawUpdateLogger = createSubsystemLogger("gateway/channels/telegram/raw-update");
|
||||
const MAX_RAW_UPDATE_CHARS = 8000;
|
||||
const MAX_RAW_UPDATE_STRING = 500;
|
||||
const MAX_RAW_UPDATE_ARRAY = 20;
|
||||
const stringifyUpdate = (update: unknown) => {
|
||||
const seen = new WeakSet();
|
||||
return JSON.stringify(update ?? null, (key, value) => {
|
||||
if (typeof value === "string" && value.length > MAX_RAW_UPDATE_STRING) {
|
||||
return `${value.slice(0, MAX_RAW_UPDATE_STRING)}...`;
|
||||
}
|
||||
if (Array.isArray(value) && value.length > MAX_RAW_UPDATE_ARRAY) {
|
||||
return [
|
||||
...value.slice(0, MAX_RAW_UPDATE_ARRAY),
|
||||
`...(${value.length - MAX_RAW_UPDATE_ARRAY} more)`,
|
||||
];
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
if (seen.has(value)) {
|
||||
return "[Circular]";
|
||||
}
|
||||
seen.add(value);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
};
|
||||
|
||||
bot.use(async (ctx, next) => {
|
||||
if (shouldLogVerbose()) {
|
||||
try {
|
||||
const raw = stringifyUpdate(ctx.update);
|
||||
const preview =
|
||||
raw.length > MAX_RAW_UPDATE_CHARS ? `${raw.slice(0, MAX_RAW_UPDATE_CHARS)}...` : raw;
|
||||
rawUpdateLogger.debug(`telegram update: ${preview}`);
|
||||
} catch (err) {
|
||||
rawUpdateLogger.debug(`telegram update log failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
const historyLimit = Math.max(
|
||||
0,
|
||||
telegramCfg.historyLimit ??
|
||||
cfg.messages?.groupChat?.historyLimit ??
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
);
|
||||
const groupHistories = new Map<string, HistoryEntry[]>();
|
||||
const textLimit = resolveTextChunkLimit(cfg, "telegram", account.accountId);
|
||||
const dmPolicy = telegramCfg.dmPolicy ?? "pairing";
|
||||
const allowFrom = opts.allowFrom ?? telegramCfg.allowFrom;
|
||||
const groupAllowFrom =
|
||||
opts.groupAllowFrom ?? telegramCfg.groupAllowFrom ?? telegramCfg.allowFrom ?? allowFrom;
|
||||
const replyToMode = opts.replyToMode ?? telegramCfg.replyToMode ?? "off";
|
||||
const nativeEnabled = resolveNativeCommandsEnabled({
|
||||
providerId: "telegram",
|
||||
providerSetting: telegramCfg.commands?.native,
|
||||
globalSetting: cfg.commands?.native,
|
||||
});
|
||||
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
|
||||
providerId: "telegram",
|
||||
providerSetting: telegramCfg.commands?.nativeSkills,
|
||||
globalSetting: cfg.commands?.nativeSkills,
|
||||
});
|
||||
const nativeDisabledExplicit = isNativeCommandsExplicitlyDisabled({
|
||||
providerSetting: telegramCfg.commands?.native,
|
||||
globalSetting: cfg.commands?.native,
|
||||
});
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||
const mediaMaxBytes = (opts.mediaMaxMb ?? telegramCfg.mediaMaxMb ?? 100) * 1024 * 1024;
|
||||
const logger = getChildLogger({ module: "telegram-auto-reply" });
|
||||
const streamMode = resolveTelegramStreamMode(telegramCfg);
|
||||
const resolveGroupPolicy = (chatId: string | number) =>
|
||||
resolveChannelGroupPolicy({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: account.accountId,
|
||||
groupId: String(chatId),
|
||||
});
|
||||
const resolveGroupActivation = (params: {
|
||||
chatId: string | number;
|
||||
agentId?: string;
|
||||
messageThreadId?: number;
|
||||
sessionKey?: string;
|
||||
}) => {
|
||||
const agentId = params.agentId ?? resolveDefaultAgentId(cfg);
|
||||
const sessionKey =
|
||||
params.sessionKey ??
|
||||
`agent:${agentId}:telegram:group:${buildTelegramGroupPeerId(params.chatId, params.messageThreadId)}`;
|
||||
const storePath = telegramDeps.resolveStorePath(cfg.session?.store, { agentId });
|
||||
try {
|
||||
const loadSessionStore = telegramDeps.loadSessionStore;
|
||||
if (!loadSessionStore) {
|
||||
return undefined;
|
||||
}
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store[sessionKey];
|
||||
if (entry?.groupActivation === "always") {
|
||||
return false;
|
||||
}
|
||||
if (entry?.groupActivation === "mention") {
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(`Failed to load session for activation check: ${String(err)}`);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const resolveGroupRequireMention = (chatId: string | number) =>
|
||||
resolveChannelGroupRequireMention({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: account.accountId,
|
||||
groupId: String(chatId),
|
||||
requireMentionOverride: opts.requireMention,
|
||||
overrideOrder: "after-config",
|
||||
});
|
||||
const loadFreshTelegramAccountConfig = () => {
|
||||
try {
|
||||
return resolveTelegramAccount({
|
||||
cfg: telegramDeps.loadConfig(),
|
||||
accountId: account.accountId,
|
||||
}).config;
|
||||
} catch (error) {
|
||||
logVerbose(
|
||||
`telegram: failed to load fresh config for account ${account.accountId}; using startup snapshot: ${String(error)}`,
|
||||
);
|
||||
return telegramCfg;
|
||||
}
|
||||
};
|
||||
const resolveTelegramGroupConfig = (chatId: string | number, messageThreadId?: number) => {
|
||||
const freshTelegramCfg = loadFreshTelegramAccountConfig();
|
||||
const groups = freshTelegramCfg.groups;
|
||||
const direct = freshTelegramCfg.direct;
|
||||
const chatIdStr = String(chatId);
|
||||
const isDm = !chatIdStr.startsWith("-");
|
||||
|
||||
if (isDm) {
|
||||
const directConfig = direct?.[chatIdStr] ?? direct?.["*"];
|
||||
if (directConfig) {
|
||||
const topicConfig =
|
||||
messageThreadId != null ? directConfig.topics?.[String(messageThreadId)] : undefined;
|
||||
return { groupConfig: directConfig, topicConfig };
|
||||
}
|
||||
// DMs without direct config: don't fall through to groups lookup
|
||||
return { groupConfig: undefined, topicConfig: undefined };
|
||||
}
|
||||
|
||||
if (!groups) {
|
||||
return { groupConfig: undefined, topicConfig: undefined };
|
||||
}
|
||||
const groupConfig = groups[chatIdStr] ?? groups["*"];
|
||||
const topicConfig =
|
||||
messageThreadId != null ? groupConfig?.topics?.[String(messageThreadId)] : undefined;
|
||||
return { groupConfig, topicConfig };
|
||||
};
|
||||
|
||||
// Global sendChatAction handler with 401 backoff / circuit breaker (issue #27092).
|
||||
// Created BEFORE the message processor so it can be injected into every message context.
|
||||
// Shared across all message contexts for this account so that consecutive 401s
|
||||
// from ANY chat are tracked together — prevents infinite retry storms.
|
||||
const sendChatActionHandler = createTelegramSendChatActionHandler({
|
||||
sendChatActionFn: (chatId, action, threadParams) =>
|
||||
bot.api.sendChatAction(chatId, action, threadParams),
|
||||
logger: (message) => logVerbose(`telegram: ${message}`),
|
||||
});
|
||||
|
||||
const processMessage = createTelegramMessageProcessor({
|
||||
bot,
|
||||
cfg,
|
||||
account,
|
||||
telegramCfg,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
dmPolicy,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
ackReactionScope,
|
||||
logger,
|
||||
resolveGroupActivation,
|
||||
resolveGroupRequireMention,
|
||||
resolveTelegramGroupConfig,
|
||||
loadFreshConfig: () => telegramDeps.loadConfig(),
|
||||
sendChatActionHandler,
|
||||
runtime,
|
||||
replyToMode,
|
||||
streamMode,
|
||||
textLimit,
|
||||
opts,
|
||||
telegramDeps,
|
||||
});
|
||||
|
||||
registerTelegramNativeCommands({
|
||||
bot,
|
||||
cfg,
|
||||
runtime,
|
||||
accountId: account.accountId,
|
||||
telegramCfg,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
useAccessGroups,
|
||||
nativeEnabled,
|
||||
nativeSkillsEnabled,
|
||||
nativeDisabledExplicit,
|
||||
resolveGroupPolicy,
|
||||
resolveTelegramGroupConfig,
|
||||
shouldSkipUpdate,
|
||||
opts,
|
||||
telegramDeps,
|
||||
});
|
||||
|
||||
registerTelegramHandlers({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
bot,
|
||||
opts,
|
||||
telegramTransport,
|
||||
runtime,
|
||||
mediaMaxBytes,
|
||||
telegramCfg,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
resolveGroupPolicy,
|
||||
resolveTelegramGroupConfig,
|
||||
shouldSkipUpdate,
|
||||
processMessage,
|
||||
logger,
|
||||
telegramDeps,
|
||||
});
|
||||
|
||||
const originalStop = bot.stop.bind(bot);
|
||||
bot.stop = ((...args: Parameters<typeof originalStop>) => {
|
||||
threadBindingManager?.stop();
|
||||
return originalStop(...args);
|
||||
}) as typeof bot.stop;
|
||||
|
||||
return bot;
|
||||
}
|
||||
30
openclaw/extensions/telegram/src/bot.types.ts
Normal file
30
openclaw/extensions/telegram/src/bot.types.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { TelegramBotDeps } from "./bot-deps.js";
|
||||
import type { TelegramTransport } from "./fetch.js";
|
||||
|
||||
export type TelegramBotOptions = {
|
||||
token: string;
|
||||
accountId?: string;
|
||||
runtime?: RuntimeEnv;
|
||||
requireMention?: boolean;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
mediaMaxMb?: number;
|
||||
replyToMode?: ReplyToMode;
|
||||
proxyFetch?: typeof fetch;
|
||||
config?: OpenClawConfig;
|
||||
/** Signal to abort in-flight Telegram API fetch requests (e.g. getUpdates) on shutdown. */
|
||||
fetchAbortSignal?: AbortSignal;
|
||||
updateOffset?: {
|
||||
lastUpdateId?: number | null;
|
||||
onUpdateId?: (updateId: number) => void | Promise<void>;
|
||||
};
|
||||
testTimings?: {
|
||||
mediaGroupFlushMs?: number;
|
||||
textFragmentGapMs?: number;
|
||||
};
|
||||
/** Pre-resolved Telegram transport to reuse across bot instances. If not provided, creates a new one. */
|
||||
telegramTransport?: TelegramTransport;
|
||||
telegramDeps?: TelegramBotDeps;
|
||||
};
|
||||
357
openclaw/extensions/telegram/src/bot/body-helpers.ts
Normal file
357
openclaw/extensions/telegram/src/bot/body-helpers.ts
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
import type { Chat, Message, MessageOrigin, User } from "@grammyjs/types";
|
||||
import type { NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
type TelegramMediaMessage = Pick<
|
||||
Message,
|
||||
"photo" | "video" | "video_note" | "audio" | "voice" | "document" | "sticker"
|
||||
>;
|
||||
|
||||
type TelegramMediaFileRef =
|
||||
| NonNullable<Message["photo"]>[number]
|
||||
| NonNullable<Message["video"]>
|
||||
| NonNullable<Message["video_note"]>
|
||||
| NonNullable<Message["audio"]>
|
||||
| NonNullable<Message["voice"]>
|
||||
| NonNullable<Message["document"]>
|
||||
| NonNullable<Message["sticker"]>;
|
||||
|
||||
export type TelegramPrimaryMedia = {
|
||||
placeholder: string;
|
||||
fileRef: TelegramMediaFileRef;
|
||||
};
|
||||
|
||||
export function buildSenderName(msg: Message) {
|
||||
const name =
|
||||
[msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ").trim() ||
|
||||
msg.from?.username;
|
||||
return name || undefined;
|
||||
}
|
||||
|
||||
export function resolveTelegramPrimaryMedia(
|
||||
msg: TelegramMediaMessage | undefined | null,
|
||||
): TelegramPrimaryMedia | undefined {
|
||||
if (!msg) {
|
||||
return undefined;
|
||||
}
|
||||
const photo = msg.photo?.[msg.photo.length - 1];
|
||||
if (photo) {
|
||||
return { placeholder: "<media:image>", fileRef: photo };
|
||||
}
|
||||
if (msg.video) {
|
||||
return { placeholder: "<media:video>", fileRef: msg.video };
|
||||
}
|
||||
if (msg.video_note) {
|
||||
return { placeholder: "<media:video>", fileRef: msg.video_note };
|
||||
}
|
||||
if (msg.audio) {
|
||||
return { placeholder: "<media:audio>", fileRef: msg.audio };
|
||||
}
|
||||
if (msg.voice) {
|
||||
return { placeholder: "<media:audio>", fileRef: msg.voice };
|
||||
}
|
||||
if (msg.document) {
|
||||
return { placeholder: "<media:document>", fileRef: msg.document };
|
||||
}
|
||||
if (msg.sticker) {
|
||||
return { placeholder: "<media:sticker>", fileRef: msg.sticker };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveTelegramMediaPlaceholder(
|
||||
msg: TelegramMediaMessage | undefined | null,
|
||||
): string | undefined {
|
||||
return resolveTelegramPrimaryMedia(msg)?.placeholder;
|
||||
}
|
||||
|
||||
export function buildSenderLabel(msg: Message, senderId?: number | string) {
|
||||
const name = buildSenderName(msg);
|
||||
const username = msg.from?.username ? `@${msg.from.username}` : undefined;
|
||||
let label = name;
|
||||
if (name && username) {
|
||||
label = `${name} (${username})`;
|
||||
} else if (!name && username) {
|
||||
label = username;
|
||||
}
|
||||
const normalizedSenderId =
|
||||
senderId != null ? normalizeOptionalString(String(senderId)) : undefined;
|
||||
const fallbackId = normalizedSenderId ?? (msg.from?.id != null ? String(msg.from.id) : undefined);
|
||||
const idPart = fallbackId ? `id:${fallbackId}` : undefined;
|
||||
if (label && idPart) {
|
||||
return `${label} ${idPart}`;
|
||||
}
|
||||
if (label) {
|
||||
return label;
|
||||
}
|
||||
return idPart ?? "id:unknown";
|
||||
}
|
||||
|
||||
export type TelegramTextEntity = NonNullable<Message["entities"]>[number];
|
||||
|
||||
export function isBinaryContent(text: string): boolean {
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const code = text.charCodeAt(i);
|
||||
if (code <= 0x1f && code !== 0x09 && code !== 0x0a && code !== 0x0d) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function resolveTelegramTextContent(text: unknown, caption?: unknown): string {
|
||||
const raw = typeof text === "string" ? text : typeof caption === "string" ? caption : "";
|
||||
return isBinaryContent(raw) ? "" : raw;
|
||||
}
|
||||
|
||||
export function getTelegramTextParts(
|
||||
msg: Pick<Message, "text" | "caption" | "entities" | "caption_entities">,
|
||||
): {
|
||||
text: string;
|
||||
entities: TelegramTextEntity[];
|
||||
} {
|
||||
const text = resolveTelegramTextContent(msg.text, msg.caption);
|
||||
const entities = text ? (msg.entities ?? msg.caption_entities ?? []) : [];
|
||||
return { text, entities };
|
||||
}
|
||||
|
||||
function isTelegramMentionWordChar(char: string | undefined): boolean {
|
||||
return char != null && /[a-z0-9_]/i.test(char);
|
||||
}
|
||||
|
||||
function hasStandaloneTelegramMention(text: string, mention: string): boolean {
|
||||
let startIndex = 0;
|
||||
while (startIndex < text.length) {
|
||||
const idx = text.indexOf(mention, startIndex);
|
||||
if (idx === -1) {
|
||||
return false;
|
||||
}
|
||||
const prev = idx > 0 ? text[idx - 1] : undefined;
|
||||
const next = text[idx + mention.length];
|
||||
if (!isTelegramMentionWordChar(prev) && !isTelegramMentionWordChar(next)) {
|
||||
return true;
|
||||
}
|
||||
startIndex = idx + 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasBotMention(msg: Message, botUsername: string) {
|
||||
const { text, entities } = getTelegramTextParts(msg);
|
||||
const mention = normalizeLowercaseStringOrEmpty(`@${botUsername}`);
|
||||
if (hasStandaloneTelegramMention(normalizeLowercaseStringOrEmpty(text), mention)) {
|
||||
return true;
|
||||
}
|
||||
for (const ent of entities) {
|
||||
if (ent.type !== "mention") {
|
||||
continue;
|
||||
}
|
||||
const slice = text.slice(ent.offset, ent.offset + ent.length);
|
||||
if (normalizeLowercaseStringOrEmpty(slice) === mention) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type TelegramTextLinkEntity = {
|
||||
type: string;
|
||||
offset: number;
|
||||
length: number;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export function expandTextLinks(text: string, entities?: TelegramTextLinkEntity[] | null): string {
|
||||
if (!text || !entities?.length) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const textLinks = entities
|
||||
.filter(
|
||||
(entity): entity is TelegramTextLinkEntity & { url: string } =>
|
||||
entity.type === "text_link" && Boolean(entity.url),
|
||||
)
|
||||
.toSorted((a, b) => b.offset - a.offset);
|
||||
|
||||
if (textLinks.length === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let result = text;
|
||||
for (const entity of textLinks) {
|
||||
const linkText = text.slice(entity.offset, entity.offset + entity.length);
|
||||
const markdown = `[${linkText}](${entity.url})`;
|
||||
result =
|
||||
result.slice(0, entity.offset) + markdown + result.slice(entity.offset + entity.length);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export type TelegramForwardedContext = {
|
||||
from: string;
|
||||
date?: number;
|
||||
fromType: string;
|
||||
fromId?: string;
|
||||
fromUsername?: string;
|
||||
fromTitle?: string;
|
||||
fromSignature?: string;
|
||||
fromChatType?: Chat["type"];
|
||||
fromMessageId?: number;
|
||||
};
|
||||
|
||||
function normalizeForwardedUserLabel(user: User) {
|
||||
const name = [user.first_name, user.last_name].filter(Boolean).join(" ").trim();
|
||||
const username = normalizeOptionalString(user.username);
|
||||
const id = String(user.id);
|
||||
const display =
|
||||
(name && username
|
||||
? `${name} (@${username})`
|
||||
: name || (username ? `@${username}` : undefined)) || `user:${id}`;
|
||||
return { display, name: name || undefined, username, id };
|
||||
}
|
||||
|
||||
function normalizeForwardedChatLabel(chat: Chat, fallbackKind: "chat" | "channel") {
|
||||
const title = normalizeOptionalString(chat.title);
|
||||
const username = normalizeOptionalString(chat.username);
|
||||
const id = String(chat.id);
|
||||
const display = title || (username ? `@${username}` : undefined) || `${fallbackKind}:${id}`;
|
||||
return { display, title, username, id };
|
||||
}
|
||||
|
||||
function buildForwardedContextFromUser(params: {
|
||||
user: User;
|
||||
date?: number;
|
||||
type: string;
|
||||
}): TelegramForwardedContext | null {
|
||||
const { display, name, username, id } = normalizeForwardedUserLabel(params.user);
|
||||
if (!display) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
from: display,
|
||||
date: params.date,
|
||||
fromType: params.type,
|
||||
fromId: id,
|
||||
fromUsername: username,
|
||||
fromTitle: name,
|
||||
};
|
||||
}
|
||||
|
||||
function buildForwardedContextFromHiddenName(params: {
|
||||
name?: string;
|
||||
date?: number;
|
||||
type: string;
|
||||
}): TelegramForwardedContext | null {
|
||||
const trimmed = params.name?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
from: trimmed,
|
||||
date: params.date,
|
||||
fromType: params.type,
|
||||
fromTitle: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
function buildForwardedContextFromChat(params: {
|
||||
chat: Chat;
|
||||
date?: number;
|
||||
type: string;
|
||||
signature?: string;
|
||||
messageId?: number;
|
||||
}): TelegramForwardedContext | null {
|
||||
const fallbackKind = params.type === "channel" ? "channel" : "chat";
|
||||
const { display, title, username, id } = normalizeForwardedChatLabel(params.chat, fallbackKind);
|
||||
if (!display) {
|
||||
return null;
|
||||
}
|
||||
const signature = normalizeOptionalString(params.signature);
|
||||
const from = signature ? `${display} (${signature})` : display;
|
||||
const chatType = normalizeOptionalString(params.chat.type) as Chat["type"] | undefined;
|
||||
return {
|
||||
from,
|
||||
date: params.date,
|
||||
fromType: params.type,
|
||||
fromId: id,
|
||||
fromUsername: username,
|
||||
fromTitle: title,
|
||||
fromSignature: signature,
|
||||
fromChatType: chatType,
|
||||
fromMessageId: params.messageId,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveForwardOrigin(origin: MessageOrigin): TelegramForwardedContext | null {
|
||||
switch (origin.type) {
|
||||
case "user":
|
||||
return buildForwardedContextFromUser({
|
||||
user: origin.sender_user,
|
||||
date: origin.date,
|
||||
type: "user",
|
||||
});
|
||||
case "hidden_user":
|
||||
return buildForwardedContextFromHiddenName({
|
||||
name: origin.sender_user_name,
|
||||
date: origin.date,
|
||||
type: "hidden_user",
|
||||
});
|
||||
case "chat":
|
||||
return buildForwardedContextFromChat({
|
||||
chat: origin.sender_chat,
|
||||
date: origin.date,
|
||||
type: "chat",
|
||||
signature: origin.author_signature,
|
||||
});
|
||||
case "channel":
|
||||
return buildForwardedContextFromChat({
|
||||
chat: origin.chat,
|
||||
date: origin.date,
|
||||
type: "channel",
|
||||
signature: origin.author_signature,
|
||||
messageId: origin.message_id,
|
||||
});
|
||||
default:
|
||||
origin satisfies never;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeForwardedContext(msg: Message): TelegramForwardedContext | null {
|
||||
if (!msg.forward_origin) {
|
||||
return null;
|
||||
}
|
||||
return resolveForwardOrigin(msg.forward_origin);
|
||||
}
|
||||
|
||||
export function extractTelegramLocation(msg: Message): NormalizedLocation | null {
|
||||
const { venue, location } = msg;
|
||||
|
||||
if (venue) {
|
||||
return {
|
||||
latitude: venue.location.latitude,
|
||||
longitude: venue.location.longitude,
|
||||
accuracy: venue.location.horizontal_accuracy,
|
||||
name: venue.title,
|
||||
address: venue.address,
|
||||
source: "place",
|
||||
isLive: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (location) {
|
||||
const isLive = typeof location.live_period === "number" && location.live_period > 0;
|
||||
return {
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
accuracy: location.horizontal_accuracy,
|
||||
source: isLive ? "live" : "pin",
|
||||
isLive,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
752
openclaw/extensions/telegram/src/bot/delivery.replies.ts
Normal file
752
openclaw/extensions/telegram/src/bot/delivery.replies.ts
Normal file
|
|
@ -0,0 +1,752 @@
|
|||
import { type Bot, GrammyError, InputFile } from "grammy";
|
||||
import type { ReplyToMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { fireAndForgetHook } from "openclaw/plugin-sdk/hook-runtime";
|
||||
import { createInternalHookEvent, triggerInternalHook } from "openclaw/plugin-sdk/hook-runtime";
|
||||
import {
|
||||
buildCanonicalSentMessageHookContext,
|
||||
toInternalMessageSentContext,
|
||||
toPluginMessageContext,
|
||||
toPluginMessageSentEvent,
|
||||
} from "openclaw/plugin-sdk/hook-runtime";
|
||||
import { buildOutboundMediaLoadOptions } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { isGifMedia, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
|
||||
import type { TelegramInlineButtons } from "../button-types.js";
|
||||
import { splitTelegramCaption } from "../caption.js";
|
||||
import {
|
||||
markdownToTelegramChunks,
|
||||
markdownToTelegramHtml,
|
||||
renderTelegramHtmlText,
|
||||
wrapFileReferencesInHtml,
|
||||
} from "../format.js";
|
||||
import { buildInlineKeyboard } from "../send.js";
|
||||
import { resolveTelegramVoiceSend } from "../voice.js";
|
||||
import {
|
||||
buildTelegramSendParams,
|
||||
sendTelegramText,
|
||||
sendTelegramWithThreadFallback,
|
||||
} from "./delivery.send.js";
|
||||
import { resolveTelegramReplyId, type TelegramThreadSpec } from "./helpers.js";
|
||||
import {
|
||||
markReplyApplied,
|
||||
resolveReplyToForSend,
|
||||
sendChunkedTelegramReplyText,
|
||||
type DeliveryProgress as ReplyThreadDeliveryProgress,
|
||||
} from "./reply-threading.js";
|
||||
|
||||
const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;
|
||||
const CAPTION_TOO_LONG_RE = /caption is too long/i;
|
||||
const GrammyErrorCtor: typeof GrammyError | undefined =
|
||||
typeof GrammyError === "function" ? GrammyError : undefined;
|
||||
|
||||
type DeliveryProgress = ReplyThreadDeliveryProgress & {
|
||||
deliveredCount: number;
|
||||
};
|
||||
|
||||
type TelegramReplyChannelData = {
|
||||
buttons?: TelegramInlineButtons;
|
||||
pin?: boolean;
|
||||
};
|
||||
|
||||
type ChunkTextFn = (markdown: string) => ReturnType<typeof markdownToTelegramChunks>;
|
||||
|
||||
function buildChunkTextResolver(params: {
|
||||
textLimit: number;
|
||||
chunkMode: ChunkMode;
|
||||
tableMode?: MarkdownTableMode;
|
||||
}): ChunkTextFn {
|
||||
return (markdown: string) => {
|
||||
const markdownChunks =
|
||||
params.chunkMode === "newline"
|
||||
? chunkMarkdownTextWithMode(markdown, params.textLimit, params.chunkMode)
|
||||
: [markdown];
|
||||
const chunks: ReturnType<typeof markdownToTelegramChunks> = [];
|
||||
for (const chunk of markdownChunks) {
|
||||
const nested = markdownToTelegramChunks(chunk, params.textLimit, {
|
||||
tableMode: params.tableMode,
|
||||
});
|
||||
if (!nested.length && chunk) {
|
||||
chunks.push({
|
||||
html: wrapFileReferencesInHtml(
|
||||
markdownToTelegramHtml(chunk, { tableMode: params.tableMode, wrapFileRefs: false }),
|
||||
),
|
||||
text: chunk,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
chunks.push(...nested);
|
||||
}
|
||||
return chunks;
|
||||
};
|
||||
}
|
||||
|
||||
function markDelivered(progress: DeliveryProgress): void {
|
||||
progress.hasDelivered = true;
|
||||
progress.deliveredCount += 1;
|
||||
}
|
||||
|
||||
function filterEmptyTelegramTextChunks<T extends { text: string }>(chunks: readonly T[]): T[] {
|
||||
// Telegram rejects whitespace-only text payloads; drop them before sendMessage so
|
||||
// hook-mutated or model-emitted empty replies become a no-op instead of a 400.
|
||||
return chunks.filter((chunk) => chunk.text.trim().length > 0);
|
||||
}
|
||||
|
||||
async function deliverTextReply(params: {
|
||||
bot: Bot;
|
||||
chatId: string;
|
||||
runtime: RuntimeEnv;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
chunkText: ChunkTextFn;
|
||||
replyText: string;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
replyQuoteText?: string;
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
}): Promise<number | undefined> {
|
||||
let firstDeliveredMessageId: number | undefined;
|
||||
const chunks = filterEmptyTelegramTextChunks(params.chunkText(params.replyText));
|
||||
await sendChunkedTelegramReplyText({
|
||||
chunks,
|
||||
progress: params.progress,
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
replyMarkup: params.replyMarkup,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
markDelivered,
|
||||
sendChunk: async ({ chunk, replyToMessageId, replyMarkup, replyQuoteText }) => {
|
||||
const messageId = await sendTelegramText(
|
||||
params.bot,
|
||||
params.chatId,
|
||||
chunk.html,
|
||||
params.runtime,
|
||||
{
|
||||
replyToMessageId,
|
||||
replyQuoteText,
|
||||
thread: params.thread,
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyMarkup,
|
||||
},
|
||||
);
|
||||
if (firstDeliveredMessageId == null) {
|
||||
firstDeliveredMessageId = messageId;
|
||||
}
|
||||
},
|
||||
});
|
||||
return firstDeliveredMessageId;
|
||||
}
|
||||
|
||||
async function sendPendingFollowUpText(params: {
|
||||
bot: Bot;
|
||||
chatId: string;
|
||||
runtime: RuntimeEnv;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
chunkText: ChunkTextFn;
|
||||
text: string;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
}): Promise<void> {
|
||||
const chunks = filterEmptyTelegramTextChunks(params.chunkText(params.text));
|
||||
await sendChunkedTelegramReplyText({
|
||||
chunks,
|
||||
progress: params.progress,
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
replyMarkup: params.replyMarkup,
|
||||
markDelivered,
|
||||
sendChunk: async ({ chunk, replyToMessageId, replyMarkup }) => {
|
||||
await sendTelegramText(params.bot, params.chatId, chunk.html, params.runtime, {
|
||||
replyToMessageId,
|
||||
thread: params.thread,
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyMarkup,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isVoiceMessagesForbidden(err: unknown): boolean {
|
||||
if (GrammyErrorCtor && err instanceof GrammyErrorCtor) {
|
||||
return VOICE_FORBIDDEN_RE.test(err.description);
|
||||
}
|
||||
return VOICE_FORBIDDEN_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
|
||||
function isCaptionTooLong(err: unknown): boolean {
|
||||
if (GrammyErrorCtor && err instanceof GrammyErrorCtor) {
|
||||
return CAPTION_TOO_LONG_RE.test(err.description);
|
||||
}
|
||||
return CAPTION_TOO_LONG_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
|
||||
async function sendTelegramVoiceFallbackText(opts: {
|
||||
bot: Bot;
|
||||
chatId: string;
|
||||
runtime: RuntimeEnv;
|
||||
text: string;
|
||||
chunkText: (markdown: string) => ReturnType<typeof markdownToTelegramChunks>;
|
||||
replyToId?: number;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
replyQuoteText?: string;
|
||||
}): Promise<number | undefined> {
|
||||
let firstDeliveredMessageId: number | undefined;
|
||||
const chunks = filterEmptyTelegramTextChunks(opts.chunkText(opts.text));
|
||||
let appliedReplyTo = false;
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
const chunk = chunks[i];
|
||||
// Only apply reply reference, quote text, and buttons to the first chunk.
|
||||
const replyToForChunk = !appliedReplyTo ? opts.replyToId : undefined;
|
||||
const messageId = await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, {
|
||||
replyToMessageId: replyToForChunk,
|
||||
replyQuoteText: !appliedReplyTo ? opts.replyQuoteText : undefined,
|
||||
thread: opts.thread,
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: opts.linkPreview,
|
||||
silent: opts.silent,
|
||||
replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined,
|
||||
});
|
||||
if (firstDeliveredMessageId == null) {
|
||||
firstDeliveredMessageId = messageId;
|
||||
}
|
||||
if (replyToForChunk) {
|
||||
appliedReplyTo = true;
|
||||
}
|
||||
}
|
||||
return firstDeliveredMessageId;
|
||||
}
|
||||
|
||||
async function deliverMediaReply(params: {
|
||||
reply: ReplyPayload;
|
||||
mediaList: string[];
|
||||
bot: Bot;
|
||||
chatId: string;
|
||||
runtime: RuntimeEnv;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
tableMode?: MarkdownTableMode;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
chunkText: ChunkTextFn;
|
||||
mediaLoader: typeof loadWebMedia;
|
||||
onVoiceRecording?: () => Promise<void> | void;
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
replyQuoteText?: string;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
}): Promise<number | undefined> {
|
||||
let firstDeliveredMessageId: number | undefined;
|
||||
let first = true;
|
||||
let pendingFollowUpText: string | undefined;
|
||||
for (const mediaUrl of params.mediaList) {
|
||||
const isFirstMedia = first;
|
||||
const media = await params.mediaLoader(
|
||||
mediaUrl,
|
||||
buildOutboundMediaLoadOptions({ mediaLocalRoots: params.mediaLocalRoots }),
|
||||
);
|
||||
const kind = kindFromMime(media.contentType ?? undefined);
|
||||
const isGif = isGifMedia({
|
||||
contentType: media.contentType,
|
||||
fileName: media.fileName,
|
||||
});
|
||||
const fileName = media.fileName ?? (isGif ? "animation.gif" : "file");
|
||||
const file = new InputFile(media.buffer, fileName);
|
||||
const { caption, followUpText } = splitTelegramCaption(
|
||||
isFirstMedia ? (params.reply.text ?? undefined) : undefined,
|
||||
);
|
||||
const htmlCaption = caption
|
||||
? renderTelegramHtmlText(caption, { tableMode: params.tableMode })
|
||||
: undefined;
|
||||
if (followUpText) {
|
||||
pendingFollowUpText = followUpText;
|
||||
}
|
||||
first = false;
|
||||
const replyToMessageId = resolveReplyToForSend({
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
});
|
||||
const shouldAttachButtonsToMedia = isFirstMedia && params.replyMarkup && !followUpText;
|
||||
const mediaParams: Record<string, unknown> = {
|
||||
caption: htmlCaption,
|
||||
...(htmlCaption ? { parse_mode: "HTML" } : {}),
|
||||
...(shouldAttachButtonsToMedia ? { reply_markup: params.replyMarkup } : {}),
|
||||
...buildTelegramSendParams({
|
||||
replyToMessageId,
|
||||
thread: params.thread,
|
||||
silent: params.silent,
|
||||
}),
|
||||
};
|
||||
if (isGif) {
|
||||
const result = await sendTelegramWithThreadFallback({
|
||||
operation: "sendAnimation",
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
requestParams: mediaParams,
|
||||
send: (effectiveParams) =>
|
||||
params.bot.api.sendAnimation(params.chatId, file, { ...effectiveParams }),
|
||||
});
|
||||
if (firstDeliveredMessageId == null) {
|
||||
firstDeliveredMessageId = result.message_id;
|
||||
}
|
||||
markDelivered(params.progress);
|
||||
} else if (kind === "image") {
|
||||
const result = await sendTelegramWithThreadFallback({
|
||||
operation: "sendPhoto",
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
requestParams: mediaParams,
|
||||
send: (effectiveParams) =>
|
||||
params.bot.api.sendPhoto(params.chatId, file, { ...effectiveParams }),
|
||||
});
|
||||
if (firstDeliveredMessageId == null) {
|
||||
firstDeliveredMessageId = result.message_id;
|
||||
}
|
||||
markDelivered(params.progress);
|
||||
} else if (kind === "video") {
|
||||
const result = await sendTelegramWithThreadFallback({
|
||||
operation: "sendVideo",
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
requestParams: mediaParams,
|
||||
send: (effectiveParams) =>
|
||||
params.bot.api.sendVideo(params.chatId, file, { ...effectiveParams }),
|
||||
});
|
||||
if (firstDeliveredMessageId == null) {
|
||||
firstDeliveredMessageId = result.message_id;
|
||||
}
|
||||
markDelivered(params.progress);
|
||||
} else if (kind === "audio") {
|
||||
const { useVoice } = resolveTelegramVoiceSend({
|
||||
wantsVoice: params.reply.audioAsVoice === true,
|
||||
contentType: media.contentType,
|
||||
fileName,
|
||||
logFallback: logVerbose,
|
||||
});
|
||||
if (useVoice) {
|
||||
const sendVoiceMedia = async (
|
||||
requestParams: typeof mediaParams,
|
||||
shouldLog?: (err: unknown) => boolean,
|
||||
) => {
|
||||
const result = await sendTelegramWithThreadFallback({
|
||||
operation: "sendVoice",
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
requestParams,
|
||||
shouldLog,
|
||||
send: (effectiveParams) =>
|
||||
params.bot.api.sendVoice(params.chatId, file, { ...effectiveParams }),
|
||||
});
|
||||
if (firstDeliveredMessageId == null) {
|
||||
firstDeliveredMessageId = result.message_id;
|
||||
}
|
||||
markDelivered(params.progress);
|
||||
};
|
||||
await params.onVoiceRecording?.();
|
||||
try {
|
||||
await sendVoiceMedia(mediaParams, (err) => !isVoiceMessagesForbidden(err));
|
||||
} catch (voiceErr) {
|
||||
if (isVoiceMessagesForbidden(voiceErr)) {
|
||||
const fallbackText = params.reply.text;
|
||||
if (!fallbackText || !fallbackText.trim()) {
|
||||
throw voiceErr;
|
||||
}
|
||||
logVerbose(
|
||||
"telegram sendVoice forbidden (recipient has voice messages blocked in privacy settings); falling back to text",
|
||||
);
|
||||
const voiceFallbackReplyTo = resolveReplyToForSend({
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
});
|
||||
const fallbackMessageId = await sendTelegramVoiceFallbackText({
|
||||
bot: params.bot,
|
||||
chatId: params.chatId,
|
||||
runtime: params.runtime,
|
||||
text: fallbackText,
|
||||
chunkText: params.chunkText,
|
||||
replyToId: voiceFallbackReplyTo,
|
||||
thread: params.thread,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyMarkup: params.replyMarkup,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
});
|
||||
if (firstDeliveredMessageId == null) {
|
||||
firstDeliveredMessageId = fallbackMessageId;
|
||||
}
|
||||
markReplyApplied(params.progress, voiceFallbackReplyTo);
|
||||
markDelivered(params.progress);
|
||||
continue;
|
||||
}
|
||||
if (isCaptionTooLong(voiceErr)) {
|
||||
logVerbose(
|
||||
"telegram sendVoice caption too long; resending voice without caption + text separately",
|
||||
);
|
||||
const noCaptionParams = { ...mediaParams };
|
||||
delete noCaptionParams.caption;
|
||||
delete noCaptionParams.parse_mode;
|
||||
await sendVoiceMedia(noCaptionParams);
|
||||
const fallbackText = params.reply.text;
|
||||
if (fallbackText?.trim()) {
|
||||
await sendTelegramVoiceFallbackText({
|
||||
bot: params.bot,
|
||||
chatId: params.chatId,
|
||||
runtime: params.runtime,
|
||||
text: fallbackText,
|
||||
chunkText: params.chunkText,
|
||||
replyToId: undefined,
|
||||
thread: params.thread,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyMarkup: params.replyMarkup,
|
||||
});
|
||||
}
|
||||
markReplyApplied(params.progress, replyToMessageId);
|
||||
continue;
|
||||
}
|
||||
throw voiceErr;
|
||||
}
|
||||
} else {
|
||||
const result = await sendTelegramWithThreadFallback({
|
||||
operation: "sendAudio",
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
requestParams: mediaParams,
|
||||
send: (effectiveParams) =>
|
||||
params.bot.api.sendAudio(params.chatId, file, { ...effectiveParams }),
|
||||
});
|
||||
if (firstDeliveredMessageId == null) {
|
||||
firstDeliveredMessageId = result.message_id;
|
||||
}
|
||||
markDelivered(params.progress);
|
||||
}
|
||||
} else {
|
||||
const result = await sendTelegramWithThreadFallback({
|
||||
operation: "sendDocument",
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
requestParams: mediaParams,
|
||||
send: (effectiveParams) =>
|
||||
params.bot.api.sendDocument(params.chatId, file, { ...effectiveParams }),
|
||||
});
|
||||
if (firstDeliveredMessageId == null) {
|
||||
firstDeliveredMessageId = result.message_id;
|
||||
}
|
||||
markDelivered(params.progress);
|
||||
}
|
||||
markReplyApplied(params.progress, replyToMessageId);
|
||||
if (pendingFollowUpText && isFirstMedia) {
|
||||
await sendPendingFollowUpText({
|
||||
bot: params.bot,
|
||||
chatId: params.chatId,
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
chunkText: params.chunkText,
|
||||
text: pendingFollowUpText,
|
||||
replyMarkup: params.replyMarkup,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
});
|
||||
pendingFollowUpText = undefined;
|
||||
}
|
||||
}
|
||||
return firstDeliveredMessageId;
|
||||
}
|
||||
|
||||
async function maybePinFirstDeliveredMessage(params: {
|
||||
shouldPin: boolean;
|
||||
bot: Bot;
|
||||
chatId: string;
|
||||
runtime: RuntimeEnv;
|
||||
firstDeliveredMessageId?: number;
|
||||
}): Promise<void> {
|
||||
if (!params.shouldPin || typeof params.firstDeliveredMessageId !== "number") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await params.bot.api.pinChatMessage(params.chatId, params.firstDeliveredMessageId, {
|
||||
disable_notification: true,
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`telegram pinChatMessage failed chat=${params.chatId} message=${params.firstDeliveredMessageId}: ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type EmitMessageSentHookParams = {
|
||||
sessionKeyForInternalHooks?: string;
|
||||
chatId: string;
|
||||
accountId?: string;
|
||||
content: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
messageId?: number;
|
||||
isGroup?: boolean;
|
||||
groupId?: string;
|
||||
};
|
||||
|
||||
function buildTelegramSentHookContext(params: EmitMessageSentHookParams) {
|
||||
return buildCanonicalSentMessageHookContext({
|
||||
to: params.chatId,
|
||||
content: params.content,
|
||||
success: params.success,
|
||||
error: params.error,
|
||||
channelId: "telegram",
|
||||
accountId: params.accountId,
|
||||
conversationId: params.chatId,
|
||||
messageId: typeof params.messageId === "number" ? String(params.messageId) : undefined,
|
||||
isGroup: params.isGroup,
|
||||
groupId: params.groupId,
|
||||
});
|
||||
}
|
||||
|
||||
export function emitInternalMessageSentHook(params: EmitMessageSentHookParams): void {
|
||||
if (!params.sessionKeyForInternalHooks) {
|
||||
return;
|
||||
}
|
||||
const canonical = buildTelegramSentHookContext(params);
|
||||
fireAndForgetHook(
|
||||
triggerInternalHook(
|
||||
createInternalHookEvent(
|
||||
"message",
|
||||
"sent",
|
||||
params.sessionKeyForInternalHooks,
|
||||
toInternalMessageSentContext(canonical),
|
||||
),
|
||||
),
|
||||
"telegram: message:sent internal hook failed",
|
||||
);
|
||||
}
|
||||
|
||||
function emitMessageSentHooks(
|
||||
params: EmitMessageSentHookParams & {
|
||||
hookRunner: ReturnType<typeof getGlobalHookRunner>;
|
||||
enabled: boolean;
|
||||
},
|
||||
): void {
|
||||
if (!params.enabled && !params.sessionKeyForInternalHooks) {
|
||||
return;
|
||||
}
|
||||
const canonical = buildTelegramSentHookContext(params);
|
||||
if (params.enabled) {
|
||||
fireAndForgetHook(
|
||||
Promise.resolve(
|
||||
params.hookRunner!.runMessageSent(
|
||||
toPluginMessageSentEvent(canonical),
|
||||
toPluginMessageContext(canonical),
|
||||
),
|
||||
),
|
||||
"telegram: message_sent plugin hook failed",
|
||||
);
|
||||
}
|
||||
emitInternalMessageSentHook(params);
|
||||
}
|
||||
|
||||
export function emitTelegramMessageSentHooks(params: EmitMessageSentHookParams): void {
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
emitMessageSentHooks({
|
||||
...params,
|
||||
hookRunner,
|
||||
enabled: hookRunner?.hasHooks("message_sent") ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deliverReplies(params: {
|
||||
replies: ReplyPayload[];
|
||||
chatId: string;
|
||||
accountId?: string;
|
||||
sessionKeyForInternalHooks?: string;
|
||||
mirrorIsGroup?: boolean;
|
||||
mirrorGroupId?: string;
|
||||
token: string;
|
||||
runtime: RuntimeEnv;
|
||||
bot: Bot;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
replyToMode: ReplyToMode;
|
||||
textLimit: number;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
tableMode?: MarkdownTableMode;
|
||||
chunkMode?: ChunkMode;
|
||||
/** Callback invoked before sending a voice message to switch typing indicator. */
|
||||
onVoiceRecording?: () => Promise<void> | void;
|
||||
/** Controls whether link previews are shown. Default: true (previews enabled). */
|
||||
linkPreview?: boolean;
|
||||
/** When true, messages are sent with disable_notification. */
|
||||
silent?: boolean;
|
||||
/** Optional quote text for Telegram reply_parameters. */
|
||||
replyQuoteText?: string;
|
||||
/** Override media loader (tests). */
|
||||
mediaLoader?: typeof loadWebMedia;
|
||||
}): Promise<{ delivered: boolean }> {
|
||||
const progress: DeliveryProgress = {
|
||||
hasReplied: false,
|
||||
hasDelivered: false,
|
||||
deliveredCount: 0,
|
||||
};
|
||||
const mediaLoader = params.mediaLoader ?? loadWebMedia;
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
const hasMessageSendingHooks = hookRunner?.hasHooks("message_sending") ?? false;
|
||||
const hasMessageSentHooks = hookRunner?.hasHooks("message_sent") ?? false;
|
||||
const chunkText = buildChunkTextResolver({
|
||||
textLimit: params.textLimit,
|
||||
chunkMode: params.chunkMode ?? "length",
|
||||
tableMode: params.tableMode,
|
||||
});
|
||||
for (const originalReply of params.replies) {
|
||||
let reply = originalReply;
|
||||
const mediaList = reply?.mediaUrls?.length
|
||||
? reply.mediaUrls
|
||||
: reply?.mediaUrl
|
||||
? [reply.mediaUrl]
|
||||
: [];
|
||||
const hasMedia = mediaList.length > 0;
|
||||
if (!reply?.text && !hasMedia) {
|
||||
if (reply?.audioAsVoice) {
|
||||
logVerbose("telegram reply has audioAsVoice without media/text; skipping");
|
||||
continue;
|
||||
}
|
||||
params.runtime.error?.(danger("reply missing text/media"));
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawContent = reply.text || "";
|
||||
if (hasMessageSendingHooks) {
|
||||
const hookResult = await hookRunner?.runMessageSending(
|
||||
{
|
||||
to: params.chatId,
|
||||
content: rawContent,
|
||||
metadata: {
|
||||
channel: "telegram",
|
||||
mediaUrls: mediaList,
|
||||
threadId: params.thread?.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
channelId: "telegram",
|
||||
accountId: params.accountId,
|
||||
conversationId: params.chatId,
|
||||
},
|
||||
);
|
||||
if (hookResult?.cancel) {
|
||||
continue;
|
||||
}
|
||||
if (typeof hookResult?.content === "string" && hookResult.content !== rawContent) {
|
||||
reply = { ...reply, text: hookResult.content };
|
||||
}
|
||||
}
|
||||
|
||||
const contentForSentHook = reply.text || "";
|
||||
|
||||
try {
|
||||
const deliveredCountBeforeReply = progress.deliveredCount;
|
||||
const replyToId =
|
||||
params.replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId);
|
||||
const telegramData = reply.channelData?.telegram as TelegramReplyChannelData | undefined;
|
||||
const shouldPinFirstMessage = telegramData?.pin === true;
|
||||
const replyMarkup = buildInlineKeyboard(telegramData?.buttons);
|
||||
let firstDeliveredMessageId: number | undefined;
|
||||
if (mediaList.length === 0) {
|
||||
firstDeliveredMessageId = await deliverTextReply({
|
||||
bot: params.bot,
|
||||
chatId: params.chatId,
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
chunkText,
|
||||
replyText: reply.text || "",
|
||||
replyMarkup,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress,
|
||||
});
|
||||
} else {
|
||||
firstDeliveredMessageId = await deliverMediaReply({
|
||||
reply,
|
||||
mediaList,
|
||||
bot: params.bot,
|
||||
chatId: params.chatId,
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
tableMode: params.tableMode,
|
||||
mediaLocalRoots: params.mediaLocalRoots,
|
||||
chunkText,
|
||||
mediaLoader,
|
||||
onVoiceRecording: params.onVoiceRecording,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
replyMarkup,
|
||||
replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress,
|
||||
});
|
||||
}
|
||||
await maybePinFirstDeliveredMessage({
|
||||
shouldPin: shouldPinFirstMessage,
|
||||
bot: params.bot,
|
||||
chatId: params.chatId,
|
||||
runtime: params.runtime,
|
||||
firstDeliveredMessageId,
|
||||
});
|
||||
|
||||
emitMessageSentHooks({
|
||||
hookRunner,
|
||||
enabled: hasMessageSentHooks,
|
||||
sessionKeyForInternalHooks: params.sessionKeyForInternalHooks,
|
||||
chatId: params.chatId,
|
||||
accountId: params.accountId,
|
||||
content: contentForSentHook,
|
||||
success: progress.deliveredCount > deliveredCountBeforeReply,
|
||||
messageId: firstDeliveredMessageId,
|
||||
isGroup: params.mirrorIsGroup,
|
||||
groupId: params.mirrorGroupId,
|
||||
});
|
||||
} catch (error) {
|
||||
emitMessageSentHooks({
|
||||
hookRunner,
|
||||
enabled: hasMessageSentHooks,
|
||||
sessionKeyForInternalHooks: params.sessionKeyForInternalHooks,
|
||||
chatId: params.chatId,
|
||||
accountId: params.accountId,
|
||||
content: contentForSentHook,
|
||||
success: false,
|
||||
error: formatErrorMessage(error),
|
||||
isGroup: params.mirrorIsGroup,
|
||||
groupId: params.mirrorGroupId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { delivered: progress.hasDelivered };
|
||||
}
|
||||
|
|
@ -0,0 +1,766 @@
|
|||
import type { Message } from "@grammyjs/types";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { retryAsync } from "../../../../src/infra/retry.js";
|
||||
import { resolveMedia } from "./delivery.resolve-media.js";
|
||||
import type { TelegramContext } from "./types.js";
|
||||
|
||||
const saveMediaBuffer = vi.fn();
|
||||
const fetchRemoteMedia = vi.fn();
|
||||
const readFileWithinRoot = vi.fn();
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/infra-runtime", () => ({
|
||||
readFileWithinRoot: (...args: unknown[]) => readFileWithinRoot(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./delivery.resolve-media.runtime.js", () => {
|
||||
class MediaFetchError extends Error {
|
||||
code: string;
|
||||
|
||||
constructor(code: string, message: string, options?: { cause?: unknown }) {
|
||||
super(message, options);
|
||||
this.name = "MediaFetchError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
return {
|
||||
fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args),
|
||||
formatErrorMessage: (err: unknown) => (err instanceof Error ? err.message : String(err)),
|
||||
logVerbose: () => {},
|
||||
MediaFetchError,
|
||||
resolveTelegramApiBase: (apiRoot?: string) =>
|
||||
apiRoot?.trim() ? apiRoot.replace(/\/+$/u, "") : "https://api.telegram.org",
|
||||
retryAsync,
|
||||
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
|
||||
shouldRetryTelegramTransportFallback: vi.fn(() => false),
|
||||
warn: (s: string) => s,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../sticker-cache.js", () => ({
|
||||
cacheSticker: () => {},
|
||||
getCachedSticker: () => null,
|
||||
getCacheStats: () => ({ count: 0 }),
|
||||
searchStickers: () => [],
|
||||
getAllCachedStickers: () => [],
|
||||
describeStickerImage: async () => null,
|
||||
}));
|
||||
|
||||
const MAX_MEDIA_BYTES = 10_000_000;
|
||||
const BOT_TOKEN = "tok123";
|
||||
|
||||
function makeCtx(
|
||||
mediaField: "voice" | "audio" | "photo" | "video" | "document" | "animation" | "sticker",
|
||||
getFile: TelegramContext["getFile"],
|
||||
opts?: { file_name?: string; mime_type?: string },
|
||||
): TelegramContext {
|
||||
const msg: Record<string, unknown> = {
|
||||
message_id: 1,
|
||||
date: 0,
|
||||
chat: { id: 1, type: "private" },
|
||||
};
|
||||
if (mediaField === "voice") {
|
||||
msg.voice = {
|
||||
file_id: "v1",
|
||||
duration: 5,
|
||||
file_unique_id: "u1",
|
||||
...(opts?.mime_type && { mime_type: opts.mime_type }),
|
||||
};
|
||||
}
|
||||
if (mediaField === "audio") {
|
||||
msg.audio = {
|
||||
file_id: "a1",
|
||||
duration: 5,
|
||||
file_unique_id: "u2",
|
||||
...(opts?.file_name && { file_name: opts.file_name }),
|
||||
...(opts?.mime_type && { mime_type: opts.mime_type }),
|
||||
};
|
||||
}
|
||||
if (mediaField === "photo") {
|
||||
msg.photo = [{ file_id: "p1", width: 100, height: 100 }];
|
||||
}
|
||||
if (mediaField === "video") {
|
||||
msg.video = {
|
||||
file_id: "vid1",
|
||||
duration: 10,
|
||||
file_unique_id: "u3",
|
||||
...(opts?.file_name && { file_name: opts.file_name }),
|
||||
};
|
||||
}
|
||||
if (mediaField === "document") {
|
||||
msg.document = {
|
||||
file_id: "d1",
|
||||
file_unique_id: "u4",
|
||||
...(opts?.file_name && { file_name: opts.file_name }),
|
||||
...(opts?.mime_type && { mime_type: opts.mime_type }),
|
||||
};
|
||||
}
|
||||
if (mediaField === "animation") {
|
||||
msg.animation = {
|
||||
file_id: "an1",
|
||||
duration: 3,
|
||||
file_unique_id: "u5",
|
||||
width: 200,
|
||||
height: 200,
|
||||
...(opts?.file_name && { file_name: opts.file_name }),
|
||||
};
|
||||
}
|
||||
if (mediaField === "sticker") {
|
||||
msg.sticker = {
|
||||
file_id: "stk1",
|
||||
file_unique_id: "ustk1",
|
||||
type: "regular",
|
||||
width: 512,
|
||||
height: 512,
|
||||
is_animated: false,
|
||||
is_video: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
message: msg as unknown as Message,
|
||||
me: {
|
||||
id: 1,
|
||||
is_bot: true,
|
||||
first_name: "bot",
|
||||
username: "bot",
|
||||
} as unknown as TelegramContext["me"],
|
||||
getFile,
|
||||
};
|
||||
}
|
||||
|
||||
function setupTransientGetFileRetry() {
|
||||
const getFile = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("Network request for 'getFile' failed!"))
|
||||
.mockResolvedValueOnce({ file_path: "voice/file_0.oga" });
|
||||
|
||||
fetchRemoteMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("audio"),
|
||||
contentType: "audio/ogg",
|
||||
fileName: "file_0.oga",
|
||||
});
|
||||
saveMediaBuffer.mockResolvedValueOnce({
|
||||
path: "/tmp/file_0.oga",
|
||||
contentType: "audio/ogg",
|
||||
});
|
||||
|
||||
return getFile;
|
||||
}
|
||||
|
||||
function mockPdfFetchAndSave(fileName: string | undefined) {
|
||||
fetchRemoteMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("pdf-data"),
|
||||
contentType: "application/pdf",
|
||||
fileName,
|
||||
});
|
||||
saveMediaBuffer.mockResolvedValueOnce({
|
||||
path: "/tmp/file_42---uuid.pdf",
|
||||
contentType: "application/pdf",
|
||||
});
|
||||
}
|
||||
|
||||
function createFileTooBigError(): Error {
|
||||
return new Error("GrammyError: Call to 'getFile' failed! (400: Bad Request: file is too big)");
|
||||
}
|
||||
|
||||
function resolveMediaWithDefaults(
|
||||
ctx: TelegramContext,
|
||||
overrides: Partial<Parameters<typeof resolveMedia>[0]> = {},
|
||||
) {
|
||||
return resolveMedia({
|
||||
ctx,
|
||||
maxBytes: MAX_MEDIA_BYTES,
|
||||
token: BOT_TOKEN,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectTransientGetFileRetrySuccess() {
|
||||
const getFile = setupTransientGetFileRetry();
|
||||
const promise = resolveMediaWithDefaults(makeCtx("voice", getFile));
|
||||
await flushRetryTimers();
|
||||
const result = await promise;
|
||||
expect(getFile).toHaveBeenCalledTimes(2);
|
||||
expect(fetchRemoteMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: `https://api.telegram.org/file/bot${BOT_TOKEN}/voice/file_0.oga`,
|
||||
ssrfPolicy: {
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
hostnameAllowlist: ["api.telegram.org"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function flushRetryTimers() {
|
||||
await vi.runAllTimersAsync();
|
||||
}
|
||||
|
||||
describe("resolveMedia getFile retry", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
fetchRemoteMedia.mockReset();
|
||||
saveMediaBuffer.mockReset();
|
||||
readFileWithinRoot.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("retries getFile on transient failure and succeeds on second attempt", async () => {
|
||||
const result = await expectTransientGetFileRetrySuccess();
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ path: "/tmp/file_0.oga", placeholder: "<media:audio>" }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["voice", "photo", "video"] as const)(
|
||||
"returns null for %s when getFile exhausts retries so message is not dropped",
|
||||
async (mediaField) => {
|
||||
const getFile = vi.fn().mockRejectedValue(new Error("Network request for 'getFile' failed!"));
|
||||
|
||||
const promise = resolveMediaWithDefaults(makeCtx(mediaField, getFile));
|
||||
await flushRetryTimers();
|
||||
const result = await promise;
|
||||
|
||||
expect(getFile).toHaveBeenCalledTimes(3);
|
||||
expect(result).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("does not catch errors from fetchRemoteMedia (only getFile is retried)", async () => {
|
||||
const getFile = vi.fn().mockResolvedValue({ file_path: "voice/file_0.oga" });
|
||||
fetchRemoteMedia.mockRejectedValueOnce(new Error("download failed"));
|
||||
|
||||
await expect(resolveMediaWithDefaults(makeCtx("voice", getFile))).rejects.toThrow(
|
||||
"download failed",
|
||||
);
|
||||
|
||||
expect(getFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry 'file is too big' error (400 Bad Request) and returns null", async () => {
|
||||
// Simulate Telegram Bot API error when file exceeds 20MB limit.
|
||||
const fileTooBigError = createFileTooBigError();
|
||||
const getFile = vi.fn().mockRejectedValue(fileTooBigError);
|
||||
|
||||
const result = await resolveMediaWithDefaults(makeCtx("video", getFile));
|
||||
|
||||
// Should NOT retry - "file is too big" is a permanent error, not transient.
|
||||
expect(getFile).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("does not retry 'file is too big' GrammyError instances and returns null", async () => {
|
||||
const fileTooBigError = new Error(
|
||||
"GrammyError: Call to 'getFile' failed! (400: Bad Request: file is too big)",
|
||||
);
|
||||
const getFile = vi.fn().mockRejectedValue(fileTooBigError);
|
||||
|
||||
const result = await resolveMediaWithDefaults(makeCtx("video", getFile));
|
||||
|
||||
expect(getFile).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["audio", "voice"] as const)(
|
||||
"returns null for %s when file is too big",
|
||||
async (mediaField) => {
|
||||
const getFile = vi.fn().mockRejectedValue(createFileTooBigError());
|
||||
|
||||
const result = await resolveMediaWithDefaults(makeCtx(mediaField, getFile));
|
||||
|
||||
expect(getFile).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("throws when getFile returns no file_path", async () => {
|
||||
const getFile = vi.fn().mockResolvedValue({});
|
||||
await expect(resolveMediaWithDefaults(makeCtx("voice", getFile))).rejects.toThrow(
|
||||
"Telegram getFile returned no file_path",
|
||||
);
|
||||
expect(getFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still retries transient errors even after encountering file too big in different call", async () => {
|
||||
const result = await expectTransientGetFileRetrySuccess();
|
||||
// Should retry transient errors.
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("retries getFile for stickers on transient failure", async () => {
|
||||
const getFile = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("Network request for 'getFile' failed!"))
|
||||
.mockResolvedValueOnce({ file_path: "stickers/file_0.webp" });
|
||||
|
||||
fetchRemoteMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("sticker-data"),
|
||||
contentType: "image/webp",
|
||||
fileName: "file_0.webp",
|
||||
});
|
||||
saveMediaBuffer.mockResolvedValueOnce({
|
||||
path: "/tmp/file_0.webp",
|
||||
contentType: "image/webp",
|
||||
});
|
||||
|
||||
const ctx = makeCtx("sticker", getFile);
|
||||
const promise = resolveMediaWithDefaults(ctx);
|
||||
await flushRetryTimers();
|
||||
const result = await promise;
|
||||
|
||||
expect(getFile).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ path: "/tmp/file_0.webp", placeholder: "<media:sticker>" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for sticker when getFile exhausts retries", async () => {
|
||||
const getFile = vi.fn().mockRejectedValue(new Error("Network request for 'getFile' failed!"));
|
||||
|
||||
const ctx = makeCtx("sticker", getFile);
|
||||
const promise = resolveMediaWithDefaults(ctx);
|
||||
await flushRetryTimers();
|
||||
const result = await promise;
|
||||
|
||||
expect(getFile).toHaveBeenCalledTimes(3);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("uses caller-provided fetch impl for file downloads", async () => {
|
||||
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
|
||||
const callerFetch = vi.fn() as unknown as typeof fetch;
|
||||
const dispatcherAttempts = [
|
||||
{
|
||||
dispatcherPolicy: {
|
||||
mode: "explicit-proxy" as const,
|
||||
proxyUrl: "http://localhost:6152",
|
||||
allowPrivateProxy: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
const callerTransport = {
|
||||
fetch: callerFetch,
|
||||
sourceFetch: callerFetch,
|
||||
dispatcherAttempts,
|
||||
};
|
||||
fetchRemoteMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("pdf-data"),
|
||||
contentType: "application/pdf",
|
||||
fileName: "file_42.pdf",
|
||||
});
|
||||
saveMediaBuffer.mockResolvedValueOnce({
|
||||
path: "/tmp/file_42---uuid.pdf",
|
||||
contentType: "application/pdf",
|
||||
});
|
||||
|
||||
const result = await resolveMediaWithDefaults(makeCtx("document", getFile), {
|
||||
transport: callerTransport,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(fetchRemoteMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fetchImpl: callerFetch,
|
||||
dispatcherAttempts,
|
||||
trustExplicitProxyDns: true,
|
||||
shouldRetryFetchError: expect.any(Function),
|
||||
readIdleTimeoutMs: 30_000,
|
||||
ssrfPolicy: {
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
hostnameAllowlist: ["api.telegram.org"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses caller-provided fetch impl for sticker downloads", async () => {
|
||||
const getFile = vi.fn().mockResolvedValue({ file_path: "stickers/file_0.webp" });
|
||||
const callerFetch = vi.fn() as unknown as typeof fetch;
|
||||
const callerTransport = { fetch: callerFetch, sourceFetch: callerFetch };
|
||||
fetchRemoteMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("sticker-data"),
|
||||
contentType: "image/webp",
|
||||
fileName: "file_0.webp",
|
||||
});
|
||||
saveMediaBuffer.mockResolvedValueOnce({
|
||||
path: "/tmp/file_0.webp",
|
||||
contentType: "image/webp",
|
||||
});
|
||||
|
||||
const result = await resolveMediaWithDefaults(makeCtx("sticker", getFile), {
|
||||
transport: callerTransport,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(fetchRemoteMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fetchImpl: callerFetch,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows an explicit Telegram apiRoot host without broadening the default SSRF allowlist", async () => {
|
||||
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
|
||||
fetchRemoteMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("pdf-data"),
|
||||
contentType: "application/pdf",
|
||||
fileName: "file_42.pdf",
|
||||
});
|
||||
saveMediaBuffer.mockResolvedValueOnce({
|
||||
path: "/tmp/file_42---uuid.pdf",
|
||||
contentType: "application/pdf",
|
||||
});
|
||||
|
||||
await resolveMediaWithDefaults(makeCtx("document", getFile), {
|
||||
apiRoot: "https://telegram.internal:8443/custom/",
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
});
|
||||
|
||||
expect(fetchRemoteMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: `https://telegram.internal:8443/custom/file/bot${BOT_TOKEN}/documents/file_42.pdf`,
|
||||
ssrfPolicy: {
|
||||
hostnameAllowlist: ["api.telegram.org", "telegram.internal"],
|
||||
allowedHostnames: ["telegram.internal"],
|
||||
allowPrivateNetwork: true,
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("copies trusted local absolute file paths into inbound media storage for media downloads", async () => {
|
||||
const getFile = vi.fn().mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/file.pdf" });
|
||||
readFileWithinRoot.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("pdf-data"),
|
||||
realPath: "/var/lib/telegram-bot-api/file.pdf",
|
||||
stat: { size: 8 },
|
||||
});
|
||||
saveMediaBuffer.mockResolvedValueOnce({
|
||||
path: "/tmp/inbound/file.pdf",
|
||||
contentType: "application/pdf",
|
||||
});
|
||||
|
||||
const result = await resolveMediaWithDefaults(
|
||||
makeCtx("document", getFile, { mime_type: "application/pdf" }),
|
||||
{ trustedLocalFileRoots: ["/var/lib/telegram-bot-api"] },
|
||||
);
|
||||
|
||||
expect(fetchRemoteMedia).not.toHaveBeenCalled();
|
||||
expect(readFileWithinRoot).toHaveBeenCalledWith({
|
||||
rootDir: "/var/lib/telegram-bot-api",
|
||||
relativePath: "file.pdf",
|
||||
maxBytes: MAX_MEDIA_BYTES,
|
||||
});
|
||||
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
||||
Buffer.from("pdf-data"),
|
||||
"application/pdf",
|
||||
"inbound",
|
||||
MAX_MEDIA_BYTES,
|
||||
"file.pdf",
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
path: "/tmp/inbound/file.pdf",
|
||||
contentType: "application/pdf",
|
||||
placeholder: "<media:document>",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("copies trusted local absolute file paths into inbound media storage for sticker downloads", async () => {
|
||||
const getFile = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/sticker.webp" });
|
||||
readFileWithinRoot.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("sticker-data"),
|
||||
realPath: "/var/lib/telegram-bot-api/sticker.webp",
|
||||
stat: { size: 12 },
|
||||
});
|
||||
saveMediaBuffer.mockResolvedValueOnce({
|
||||
path: "/tmp/inbound/sticker.webp",
|
||||
contentType: "image/webp",
|
||||
});
|
||||
|
||||
const result = await resolveMediaWithDefaults(makeCtx("sticker", getFile), {
|
||||
trustedLocalFileRoots: ["/var/lib/telegram-bot-api"],
|
||||
});
|
||||
|
||||
expect(fetchRemoteMedia).not.toHaveBeenCalled();
|
||||
expect(readFileWithinRoot).toHaveBeenCalledWith({
|
||||
rootDir: "/var/lib/telegram-bot-api",
|
||||
relativePath: "sticker.webp",
|
||||
maxBytes: MAX_MEDIA_BYTES,
|
||||
});
|
||||
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
||||
Buffer.from("sticker-data"),
|
||||
undefined,
|
||||
"inbound",
|
||||
MAX_MEDIA_BYTES,
|
||||
"sticker.webp",
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
path: "/tmp/inbound/sticker.webp",
|
||||
placeholder: "<media:sticker>",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps trusted local absolute path read failures to MediaFetchError", async () => {
|
||||
const getFile = vi.fn().mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/file.pdf" });
|
||||
readFileWithinRoot.mockRejectedValueOnce(new Error("file not found"));
|
||||
|
||||
await expect(
|
||||
resolveMediaWithDefaults(makeCtx("document", getFile, { mime_type: "application/pdf" }), {
|
||||
trustedLocalFileRoots: ["/var/lib/telegram-bot-api"],
|
||||
}),
|
||||
).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
name: "MediaFetchError",
|
||||
code: "fetch_failed",
|
||||
message: expect.stringContaining("/var/lib/telegram-bot-api/file.pdf"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps oversized trusted local absolute path reads to MediaFetchError", async () => {
|
||||
const getFile = vi.fn().mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/file.pdf" });
|
||||
readFileWithinRoot.mockRejectedValueOnce(new Error("file exceeds limit"));
|
||||
|
||||
await expect(
|
||||
resolveMediaWithDefaults(makeCtx("document", getFile, { mime_type: "application/pdf" }), {
|
||||
trustedLocalFileRoots: ["/var/lib/telegram-bot-api"],
|
||||
}),
|
||||
).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
name: "MediaFetchError",
|
||||
code: "fetch_failed",
|
||||
message: expect.stringContaining("file exceeds limit"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects absolute Bot API file paths outside trustedLocalFileRoots", async () => {
|
||||
const getFile = vi.fn().mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/file.pdf" });
|
||||
|
||||
await expect(
|
||||
resolveMediaWithDefaults(makeCtx("document", getFile, { mime_type: "application/pdf" })),
|
||||
).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
name: "MediaFetchError",
|
||||
code: "fetch_failed",
|
||||
message: expect.stringContaining("outside trustedLocalFileRoots"),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(readFileWithinRoot).not.toHaveBeenCalled();
|
||||
expect(fetchRemoteMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMedia original filename preservation", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
fetchRemoteMedia.mockClear();
|
||||
saveMediaBuffer.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("passes document.file_name to saveMediaBuffer instead of server-side path", async () => {
|
||||
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
|
||||
fetchRemoteMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("pdf-data"),
|
||||
contentType: "application/pdf",
|
||||
fileName: "file_42.pdf",
|
||||
});
|
||||
saveMediaBuffer.mockResolvedValueOnce({
|
||||
path: "/tmp/business-plan---uuid.pdf",
|
||||
contentType: "application/pdf",
|
||||
});
|
||||
|
||||
const ctx = makeCtx("document", getFile, { file_name: "business-plan.pdf" });
|
||||
const result = await resolveMediaWithDefaults(ctx);
|
||||
|
||||
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
||||
expect.any(Buffer),
|
||||
"application/pdf",
|
||||
"inbound",
|
||||
MAX_MEDIA_BYTES,
|
||||
"business-plan.pdf",
|
||||
);
|
||||
expect(result).toEqual(expect.objectContaining({ path: "/tmp/business-plan---uuid.pdf" }));
|
||||
});
|
||||
|
||||
it("passes audio.file_name to saveMediaBuffer", async () => {
|
||||
const getFile = vi.fn().mockResolvedValue({ file_path: "music/file_99.mp3" });
|
||||
fetchRemoteMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("audio-data"),
|
||||
contentType: "audio/mpeg",
|
||||
fileName: "file_99.mp3",
|
||||
});
|
||||
saveMediaBuffer.mockResolvedValueOnce({
|
||||
path: "/tmp/my-song---uuid.mp3",
|
||||
contentType: "audio/mpeg",
|
||||
});
|
||||
|
||||
const ctx = makeCtx("audio", getFile, { file_name: "my-song.mp3" });
|
||||
const result = await resolveMediaWithDefaults(ctx);
|
||||
|
||||
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
||||
expect.any(Buffer),
|
||||
"audio/mpeg",
|
||||
"inbound",
|
||||
MAX_MEDIA_BYTES,
|
||||
"my-song.mp3",
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("passes video.file_name to saveMediaBuffer", async () => {
|
||||
const getFile = vi.fn().mockResolvedValue({ file_path: "videos/file_55.mp4" });
|
||||
fetchRemoteMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("video-data"),
|
||||
contentType: "video/mp4",
|
||||
fileName: "file_55.mp4",
|
||||
});
|
||||
saveMediaBuffer.mockResolvedValueOnce({
|
||||
path: "/tmp/presentation---uuid.mp4",
|
||||
contentType: "video/mp4",
|
||||
});
|
||||
|
||||
const ctx = makeCtx("video", getFile, { file_name: "presentation.mp4" });
|
||||
const result = await resolveMediaWithDefaults(ctx);
|
||||
|
||||
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
||||
expect.any(Buffer),
|
||||
"video/mp4",
|
||||
"inbound",
|
||||
MAX_MEDIA_BYTES,
|
||||
"presentation.mp4",
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to fetched.fileName when telegram file_name is absent", async () => {
|
||||
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
|
||||
mockPdfFetchAndSave("file_42.pdf");
|
||||
|
||||
const ctx = makeCtx("document", getFile);
|
||||
const result = await resolveMediaWithDefaults(ctx);
|
||||
|
||||
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
||||
expect.any(Buffer),
|
||||
"application/pdf",
|
||||
"inbound",
|
||||
MAX_MEDIA_BYTES,
|
||||
"file_42.pdf",
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to filePath when neither telegram nor fetched fileName is available", async () => {
|
||||
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
|
||||
mockPdfFetchAndSave(undefined);
|
||||
|
||||
const ctx = makeCtx("document", getFile);
|
||||
const result = await resolveMediaWithDefaults(ctx);
|
||||
|
||||
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
||||
expect.any(Buffer),
|
||||
"application/pdf",
|
||||
"inbound",
|
||||
MAX_MEDIA_BYTES,
|
||||
"documents/file_42.pdf",
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("allows a configured custom apiRoot host while keeping the hostname allowlist", async () => {
|
||||
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
|
||||
mockPdfFetchAndSave("file_42.pdf");
|
||||
|
||||
const ctx = makeCtx("document", getFile);
|
||||
const result = await resolveMediaWithDefaults(ctx, {
|
||||
apiRoot: "http://192.168.1.50:8081/custom-bot-api/",
|
||||
});
|
||||
|
||||
expect(fetchRemoteMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ssrfPolicy: {
|
||||
hostnameAllowlist: ["api.telegram.org", "192.168.1.50"],
|
||||
allowedHostnames: ["192.168.1.50"],
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("opts into private-network Telegram media downloads only when explicitly configured", async () => {
|
||||
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
|
||||
mockPdfFetchAndSave("file_42.pdf");
|
||||
|
||||
const ctx = makeCtx("document", getFile);
|
||||
const result = await resolveMediaWithDefaults(ctx, { dangerouslyAllowPrivateNetwork: true });
|
||||
|
||||
expect(fetchRemoteMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ssrfPolicy: {
|
||||
hostnameAllowlist: ["api.telegram.org"],
|
||||
allowPrivateNetwork: true,
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("constructs correct download URL with custom apiRoot for documents", async () => {
|
||||
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
|
||||
mockPdfFetchAndSave("file_42.pdf");
|
||||
|
||||
const customApiRoot = "http://192.168.1.50:8081/custom-bot-api";
|
||||
const ctx = makeCtx("document", getFile);
|
||||
const result = await resolveMediaWithDefaults(ctx, { apiRoot: customApiRoot });
|
||||
|
||||
// Verify the URL uses the custom apiRoot, not the default Telegram API
|
||||
expect(fetchRemoteMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: `${customApiRoot}/file/bot${BOT_TOKEN}/documents/file_42.pdf`,
|
||||
}),
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("constructs correct download URL with custom apiRoot for stickers", async () => {
|
||||
const getFile = vi.fn().mockResolvedValue({ file_path: "stickers/file_0.webp" });
|
||||
fetchRemoteMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("sticker-data"),
|
||||
contentType: "image/webp",
|
||||
fileName: "file_0.webp",
|
||||
});
|
||||
saveMediaBuffer.mockResolvedValueOnce({
|
||||
path: "/tmp/file_0.webp",
|
||||
contentType: "image/webp",
|
||||
});
|
||||
|
||||
const customApiRoot = "http://localhost:8081/bot";
|
||||
const ctx = makeCtx("sticker", getFile);
|
||||
const result = await resolveMediaWithDefaults(ctx, { apiRoot: customApiRoot });
|
||||
|
||||
// Verify the URL uses the custom apiRoot for sticker downloads
|
||||
expect(fetchRemoteMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: `${customApiRoot}/file/bot${BOT_TOKEN}/stickers/file_0.webp`,
|
||||
}),
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { logVerbose, retryAsync, warn } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { resolveTelegramApiBase, shouldRetryTelegramTransportFallback } from "../fetch.js";
|
||||
import { fetchRemoteMedia, MediaFetchError, saveMediaBuffer } from "../telegram-media.runtime.js";
|
||||
|
||||
export {
|
||||
fetchRemoteMedia,
|
||||
formatErrorMessage,
|
||||
logVerbose,
|
||||
MediaFetchError,
|
||||
resolveTelegramApiBase,
|
||||
retryAsync,
|
||||
saveMediaBuffer,
|
||||
shouldRetryTelegramTransportFallback,
|
||||
warn,
|
||||
};
|
||||
414
openclaw/extensions/telegram/src/bot/delivery.resolve-media.ts
Normal file
414
openclaw/extensions/telegram/src/bot/delivery.resolve-media.ts
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
import path from "node:path";
|
||||
import { GrammyError } from "grammy";
|
||||
import { readFileWithinRoot } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import type { TelegramTransport } from "../fetch.js";
|
||||
import { cacheSticker, getCachedSticker } from "../sticker-cache.js";
|
||||
import {
|
||||
fetchRemoteMedia,
|
||||
formatErrorMessage,
|
||||
logVerbose,
|
||||
MediaFetchError,
|
||||
resolveTelegramApiBase,
|
||||
retryAsync,
|
||||
saveMediaBuffer,
|
||||
shouldRetryTelegramTransportFallback,
|
||||
warn,
|
||||
} from "./delivery.resolve-media.runtime.js";
|
||||
import { resolveTelegramMediaPlaceholder } from "./helpers.js";
|
||||
import type { StickerMetadata, TelegramContext } from "./types.js";
|
||||
|
||||
const FILE_TOO_BIG_RE = /file is too big/i;
|
||||
const GrammyErrorCtor: typeof GrammyError | undefined =
|
||||
typeof GrammyError === "function" ? GrammyError : undefined;
|
||||
|
||||
function buildTelegramMediaSsrfPolicy(apiRoot?: string, dangerouslyAllowPrivateNetwork?: boolean) {
|
||||
const hostnames = ["api.telegram.org"];
|
||||
let allowedHostnames: string[] | undefined;
|
||||
if (apiRoot) {
|
||||
try {
|
||||
const customHost = new URL(apiRoot).hostname;
|
||||
if (customHost && !hostnames.includes(customHost)) {
|
||||
hostnames.push(customHost);
|
||||
// A configured custom Bot API host is an explicit operator override and
|
||||
// may legitimately live on a private network (for example, self-hosted
|
||||
// Bot API or an internal reverse proxy). Keep that host reachable while
|
||||
// still enforcing resolved-IP checks for the default public host.
|
||||
allowedHostnames = [customHost];
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(`telegram: invalid apiRoot URL "${apiRoot}": ${String(err)}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
// Restrict media downloads to the configured Telegram API hosts while still
|
||||
// enforcing SSRF checks on the resolved and redirected targets.
|
||||
hostnameAllowlist: hostnames,
|
||||
...(allowedHostnames ? { allowedHostnames } : {}),
|
||||
...(dangerouslyAllowPrivateNetwork ? { allowPrivateNetwork: true } : {}),
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the error is Telegram's "file is too big" error.
|
||||
* This happens when trying to download files >20MB via the Bot API.
|
||||
* Unlike network errors, this is a permanent error and should not be retried.
|
||||
*/
|
||||
function isFileTooBigError(err: unknown): boolean {
|
||||
if (GrammyErrorCtor && err instanceof GrammyErrorCtor) {
|
||||
return FILE_TOO_BIG_RE.test(err.description);
|
||||
}
|
||||
return FILE_TOO_BIG_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the error is a transient network error that should be retried.
|
||||
* Returns false for permanent errors like "file is too big" (400 Bad Request).
|
||||
*/
|
||||
function isRetryableGetFileError(err: unknown): boolean {
|
||||
// Don't retry "file is too big" - it's a permanent 400 error
|
||||
if (isFileTooBigError(err)) {
|
||||
return false;
|
||||
}
|
||||
// Retry all other errors (network issues, timeouts, etc.)
|
||||
return true;
|
||||
}
|
||||
|
||||
interface MediaMetadata {
|
||||
fileRef?:
|
||||
| NonNullable<TelegramContext["message"]["photo"]>[number]
|
||||
| TelegramContext["message"]["video"]
|
||||
| TelegramContext["message"]["video_note"]
|
||||
| TelegramContext["message"]["document"]
|
||||
| TelegramContext["message"]["audio"]
|
||||
| TelegramContext["message"]["voice"];
|
||||
fileName?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
function resolveMediaMetadata(msg: TelegramContext["message"]): MediaMetadata {
|
||||
return {
|
||||
fileRef:
|
||||
msg.photo?.[msg.photo.length - 1] ??
|
||||
msg.video ??
|
||||
msg.video_note ??
|
||||
msg.document ??
|
||||
msg.audio ??
|
||||
msg.voice,
|
||||
fileName:
|
||||
msg.document?.file_name ??
|
||||
msg.audio?.file_name ??
|
||||
msg.video?.file_name ??
|
||||
msg.animation?.file_name,
|
||||
mimeType:
|
||||
msg.audio?.mime_type ??
|
||||
msg.voice?.mime_type ??
|
||||
msg.video?.mime_type ??
|
||||
msg.document?.mime_type ??
|
||||
msg.animation?.mime_type,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveTelegramFileWithRetry(
|
||||
ctx: TelegramContext,
|
||||
): Promise<{ file_path?: string } | null> {
|
||||
try {
|
||||
return await retryAsync(() => ctx.getFile(), {
|
||||
attempts: 3,
|
||||
minDelayMs: 1000,
|
||||
maxDelayMs: 4000,
|
||||
jitter: 0.2,
|
||||
label: "telegram:getFile",
|
||||
shouldRetry: isRetryableGetFileError,
|
||||
onRetry: ({ attempt, maxAttempts }) =>
|
||||
logVerbose(`telegram: getFile retry ${attempt}/${maxAttempts}`),
|
||||
});
|
||||
} catch (err) {
|
||||
// Handle "file is too big" separately - Telegram Bot API has a 20MB download limit
|
||||
if (isFileTooBigError(err)) {
|
||||
logVerbose(
|
||||
warn(
|
||||
"telegram: getFile failed - file exceeds Telegram Bot API 20MB limit; skipping attachment",
|
||||
),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// All retries exhausted — return null so the message still reaches the agent
|
||||
// with a type-based placeholder (e.g. <media:audio>) instead of being dropped.
|
||||
logVerbose(`telegram: getFile failed after retries: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRequiredTelegramTransport(transport?: TelegramTransport): TelegramTransport {
|
||||
if (transport) {
|
||||
return transport;
|
||||
}
|
||||
const resolvedFetch = globalThis.fetch;
|
||||
if (!resolvedFetch) {
|
||||
throw new Error("fetch is not available; set channels.telegram.proxy in config");
|
||||
}
|
||||
return {
|
||||
fetch: resolvedFetch,
|
||||
sourceFetch: resolvedFetch,
|
||||
};
|
||||
}
|
||||
|
||||
/** Default idle timeout for Telegram media downloads (30 seconds). */
|
||||
const TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS = 30_000;
|
||||
|
||||
function usesTrustedTelegramExplicitProxy(transport: TelegramTransport): boolean {
|
||||
return (
|
||||
transport.dispatcherAttempts?.some(
|
||||
(attempt) => attempt.dispatcherPolicy?.mode === "explicit-proxy",
|
||||
) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTrustedLocalTelegramRoot(
|
||||
filePath: string,
|
||||
trustedLocalFileRoots?: readonly string[],
|
||||
): { rootDir: string; relativePath: string } | null {
|
||||
if (!path.isAbsolute(filePath)) {
|
||||
return null;
|
||||
}
|
||||
for (const rootDir of trustedLocalFileRoots ?? []) {
|
||||
const relativePath = path.relative(rootDir, filePath);
|
||||
if (relativePath === "" || relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
||||
continue;
|
||||
}
|
||||
return { rootDir, relativePath };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function downloadAndSaveTelegramFile(params: {
|
||||
filePath: string;
|
||||
token: string;
|
||||
transport?: TelegramTransport;
|
||||
maxBytes: number;
|
||||
telegramFileName?: string;
|
||||
mimeType?: string;
|
||||
apiRoot?: string;
|
||||
trustedLocalFileRoots?: readonly string[];
|
||||
dangerouslyAllowPrivateNetwork?: boolean;
|
||||
}) {
|
||||
const trustedLocalFile = resolveTrustedLocalTelegramRoot(
|
||||
params.filePath,
|
||||
params.trustedLocalFileRoots,
|
||||
);
|
||||
if (trustedLocalFile) {
|
||||
let localFile;
|
||||
try {
|
||||
localFile = await readFileWithinRoot({
|
||||
rootDir: trustedLocalFile.rootDir,
|
||||
relativePath: trustedLocalFile.relativePath,
|
||||
maxBytes: params.maxBytes,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new MediaFetchError(
|
||||
"fetch_failed",
|
||||
`Failed to read local Telegram Bot API media from ${params.filePath}: ${formatErrorMessage(err)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
return await saveMediaBuffer(
|
||||
localFile.buffer,
|
||||
params.mimeType,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
params.telegramFileName ?? path.basename(localFile.realPath),
|
||||
);
|
||||
}
|
||||
if (path.isAbsolute(params.filePath)) {
|
||||
throw new MediaFetchError(
|
||||
"fetch_failed",
|
||||
`Telegram Bot API returned absolute file path ${params.filePath} outside trustedLocalFileRoots`,
|
||||
);
|
||||
}
|
||||
const transport = resolveRequiredTelegramTransport(params.transport);
|
||||
const apiBase = resolveTelegramApiBase(params.apiRoot);
|
||||
const url = `${apiBase}/file/bot${params.token}/${params.filePath}`;
|
||||
const fetched = await fetchRemoteMedia({
|
||||
url,
|
||||
fetchImpl: transport.sourceFetch,
|
||||
dispatcherAttempts: transport.dispatcherAttempts,
|
||||
trustExplicitProxyDns: usesTrustedTelegramExplicitProxy(transport),
|
||||
shouldRetryFetchError: shouldRetryTelegramTransportFallback,
|
||||
filePathHint: params.filePath,
|
||||
maxBytes: params.maxBytes,
|
||||
readIdleTimeoutMs: TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS,
|
||||
ssrfPolicy: buildTelegramMediaSsrfPolicy(params.apiRoot, params.dangerouslyAllowPrivateNetwork),
|
||||
});
|
||||
const originalName = params.telegramFileName ?? fetched.fileName ?? params.filePath;
|
||||
return saveMediaBuffer(
|
||||
fetched.buffer,
|
||||
fetched.contentType,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
originalName,
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveStickerMedia(params: {
|
||||
msg: TelegramContext["message"];
|
||||
ctx: TelegramContext;
|
||||
maxBytes: number;
|
||||
token: string;
|
||||
transport?: TelegramTransport;
|
||||
apiRoot?: string;
|
||||
trustedLocalFileRoots?: readonly string[];
|
||||
dangerouslyAllowPrivateNetwork?: boolean;
|
||||
}): Promise<
|
||||
| {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
stickerMetadata?: StickerMetadata;
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
> {
|
||||
const { msg, ctx, maxBytes, token, transport } = params;
|
||||
if (!msg.sticker) {
|
||||
return undefined;
|
||||
}
|
||||
const sticker = msg.sticker;
|
||||
// Skip animated (TGS) and video (WEBM) stickers - only static WEBP supported
|
||||
if (sticker.is_animated || sticker.is_video) {
|
||||
logVerbose("telegram: skipping animated/video sticker (only static stickers supported)");
|
||||
return null;
|
||||
}
|
||||
if (!sticker.file_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await resolveTelegramFileWithRetry(ctx);
|
||||
if (!file?.file_path) {
|
||||
logVerbose("telegram: getFile returned no file_path for sticker");
|
||||
return null;
|
||||
}
|
||||
const saved = await downloadAndSaveTelegramFile({
|
||||
filePath: file.file_path,
|
||||
token,
|
||||
transport,
|
||||
maxBytes,
|
||||
apiRoot: params.apiRoot,
|
||||
trustedLocalFileRoots: params.trustedLocalFileRoots,
|
||||
dangerouslyAllowPrivateNetwork: params.dangerouslyAllowPrivateNetwork,
|
||||
});
|
||||
|
||||
// Check sticker cache for existing description
|
||||
const cached = sticker.file_unique_id ? getCachedSticker(sticker.file_unique_id) : null;
|
||||
if (cached) {
|
||||
logVerbose(`telegram: sticker cache hit for ${sticker.file_unique_id}`);
|
||||
const fileId = sticker.file_id ?? cached.fileId;
|
||||
const emoji = sticker.emoji ?? cached.emoji;
|
||||
const setName = sticker.set_name ?? cached.setName;
|
||||
if (fileId !== cached.fileId || emoji !== cached.emoji || setName !== cached.setName) {
|
||||
// Refresh cached sticker metadata on hits so sends/searches use latest file_id.
|
||||
cacheSticker({
|
||||
...cached,
|
||||
fileId,
|
||||
emoji,
|
||||
setName,
|
||||
});
|
||||
}
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: "<media:sticker>",
|
||||
stickerMetadata: {
|
||||
emoji,
|
||||
setName,
|
||||
fileId,
|
||||
fileUniqueId: sticker.file_unique_id,
|
||||
cachedDescription: cached.description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Cache miss - return metadata for vision processing
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: "<media:sticker>",
|
||||
stickerMetadata: {
|
||||
emoji: sticker.emoji ?? undefined,
|
||||
setName: sticker.set_name ?? undefined,
|
||||
fileId: sticker.file_id,
|
||||
fileUniqueId: sticker.file_unique_id,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
logVerbose(`telegram: failed to process sticker: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveMedia(params: {
|
||||
ctx: TelegramContext;
|
||||
maxBytes: number;
|
||||
token: string;
|
||||
transport?: TelegramTransport;
|
||||
apiRoot?: string;
|
||||
trustedLocalFileRoots?: readonly string[];
|
||||
dangerouslyAllowPrivateNetwork?: boolean;
|
||||
}): Promise<{
|
||||
path: string;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
stickerMetadata?: StickerMetadata;
|
||||
} | null> {
|
||||
const {
|
||||
ctx,
|
||||
maxBytes,
|
||||
token,
|
||||
transport,
|
||||
apiRoot,
|
||||
trustedLocalFileRoots,
|
||||
dangerouslyAllowPrivateNetwork,
|
||||
} = params;
|
||||
const msg = ctx.message;
|
||||
const stickerResolved = await resolveStickerMedia({
|
||||
msg,
|
||||
ctx,
|
||||
maxBytes,
|
||||
token,
|
||||
transport,
|
||||
apiRoot,
|
||||
trustedLocalFileRoots,
|
||||
dangerouslyAllowPrivateNetwork,
|
||||
});
|
||||
if (stickerResolved !== undefined) {
|
||||
return stickerResolved;
|
||||
}
|
||||
|
||||
const metadata = resolveMediaMetadata(msg);
|
||||
const m = metadata.fileRef;
|
||||
if (!m?.file_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const file = await resolveTelegramFileWithRetry(ctx);
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
if (!file.file_path) {
|
||||
throw new Error("Telegram getFile returned no file_path");
|
||||
}
|
||||
const saved = await downloadAndSaveTelegramFile({
|
||||
filePath: file.file_path,
|
||||
token,
|
||||
transport,
|
||||
maxBytes,
|
||||
telegramFileName: metadata.fileName,
|
||||
mimeType: metadata.mimeType,
|
||||
apiRoot,
|
||||
trustedLocalFileRoots,
|
||||
dangerouslyAllowPrivateNetwork,
|
||||
});
|
||||
const placeholder = resolveTelegramMediaPlaceholder(msg) ?? "<media:document>";
|
||||
return { path: saved.path, contentType: saved.contentType, placeholder };
|
||||
}
|
||||
183
openclaw/extensions/telegram/src/bot/delivery.send.ts
Normal file
183
openclaw/extensions/telegram/src/bot/delivery.send.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { type Bot, GrammyError } from "grammy";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { withTelegramApiErrorLogging } from "../api-logging.js";
|
||||
import { markdownToTelegramHtml } from "../format.js";
|
||||
import { normalizeTelegramReplyToMessageId } from "../outbound-params.js";
|
||||
import { buildInlineKeyboard } from "../send.js";
|
||||
import { buildTelegramThreadParams, type TelegramThreadSpec } from "./helpers.js";
|
||||
|
||||
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
|
||||
const EMPTY_TEXT_ERR_RE = /message text is empty/i;
|
||||
const THREAD_NOT_FOUND_RE = /message thread not found/i;
|
||||
const GrammyErrorCtor: typeof GrammyError | undefined =
|
||||
typeof GrammyError === "function" ? GrammyError : undefined;
|
||||
|
||||
function isTelegramThreadNotFoundError(err: unknown): boolean {
|
||||
if (GrammyErrorCtor && err instanceof GrammyErrorCtor) {
|
||||
return THREAD_NOT_FOUND_RE.test(err.description);
|
||||
}
|
||||
return THREAD_NOT_FOUND_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
|
||||
function hasMessageThreadIdParam(params: Record<string, unknown> | undefined): boolean {
|
||||
if (!params) {
|
||||
return false;
|
||||
}
|
||||
return typeof params.message_thread_id === "number";
|
||||
}
|
||||
|
||||
function removeMessageThreadIdParam(
|
||||
params: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> {
|
||||
if (!params) {
|
||||
return {};
|
||||
}
|
||||
const { message_thread_id: _ignored, ...rest } = params;
|
||||
return rest;
|
||||
}
|
||||
|
||||
export async function sendTelegramWithThreadFallback<T>(params: {
|
||||
operation: string;
|
||||
runtime: RuntimeEnv;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
requestParams: Record<string, unknown>;
|
||||
send: (effectiveParams: Record<string, unknown>) => Promise<T>;
|
||||
shouldLog?: (err: unknown) => boolean;
|
||||
}): Promise<T> {
|
||||
const allowThreadlessRetry = params.thread?.scope === "dm";
|
||||
const hasThreadId = hasMessageThreadIdParam(params.requestParams);
|
||||
const shouldSuppressFirstErrorLog = (err: unknown) =>
|
||||
allowThreadlessRetry && hasThreadId && isTelegramThreadNotFoundError(err);
|
||||
const mergedShouldLog = params.shouldLog
|
||||
? (err: unknown) => params.shouldLog!(err) && !shouldSuppressFirstErrorLog(err)
|
||||
: (err: unknown) => !shouldSuppressFirstErrorLog(err);
|
||||
|
||||
try {
|
||||
return await withTelegramApiErrorLogging({
|
||||
operation: params.operation,
|
||||
runtime: params.runtime,
|
||||
shouldLog: mergedShouldLog,
|
||||
fn: () => params.send(params.requestParams),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!allowThreadlessRetry || !hasThreadId || !isTelegramThreadNotFoundError(err)) {
|
||||
throw err;
|
||||
}
|
||||
const retryParams = removeMessageThreadIdParam(params.requestParams);
|
||||
params.runtime.log?.(
|
||||
`telegram ${params.operation}: message thread not found; retrying without message_thread_id`,
|
||||
);
|
||||
return await withTelegramApiErrorLogging({
|
||||
operation: `${params.operation} (threadless retry)`,
|
||||
runtime: params.runtime,
|
||||
fn: () => params.send(retryParams),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTelegramSendParams(opts?: {
|
||||
replyToMessageId?: number;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
silent?: boolean;
|
||||
}): Record<string, unknown> {
|
||||
const threadParams = buildTelegramThreadParams(opts?.thread);
|
||||
const params: Record<string, unknown> = {};
|
||||
const replyToMessageId = normalizeTelegramReplyToMessageId(opts?.replyToMessageId);
|
||||
if (replyToMessageId != null) {
|
||||
params.reply_to_message_id = replyToMessageId;
|
||||
params.allow_sending_without_reply = true;
|
||||
}
|
||||
if (threadParams) {
|
||||
params.message_thread_id = threadParams.message_thread_id;
|
||||
}
|
||||
if (opts?.silent === true) {
|
||||
params.disable_notification = true;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function sendTelegramText(
|
||||
bot: Bot,
|
||||
chatId: string,
|
||||
text: string,
|
||||
runtime: RuntimeEnv,
|
||||
opts?: {
|
||||
replyToMessageId?: number;
|
||||
replyQuoteText?: string;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
textMode?: "markdown" | "html";
|
||||
plainText?: string;
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
},
|
||||
): Promise<number> {
|
||||
const baseParams = buildTelegramSendParams({
|
||||
replyToMessageId: opts?.replyToMessageId,
|
||||
thread: opts?.thread,
|
||||
silent: opts?.silent,
|
||||
});
|
||||
// Add link_preview_options when link preview is disabled.
|
||||
const linkPreviewEnabled = opts?.linkPreview ?? true;
|
||||
const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true };
|
||||
const textMode = opts?.textMode ?? "markdown";
|
||||
const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text);
|
||||
const fallbackText = opts?.plainText ?? text;
|
||||
const hasFallbackText = fallbackText.trim().length > 0;
|
||||
const sendPlainFallback = async () => {
|
||||
const res = await sendTelegramWithThreadFallback({
|
||||
operation: "sendMessage",
|
||||
runtime,
|
||||
thread: opts?.thread,
|
||||
requestParams: baseParams,
|
||||
send: (effectiveParams) =>
|
||||
bot.api.sendMessage(chatId, fallbackText, {
|
||||
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
|
||||
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
|
||||
...effectiveParams,
|
||||
}),
|
||||
});
|
||||
runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id} (plain)`);
|
||||
return res.message_id;
|
||||
};
|
||||
|
||||
// Markdown can render to empty HTML for syntax-only chunks; recover with plain text.
|
||||
if (!htmlText.trim()) {
|
||||
if (!hasFallbackText) {
|
||||
throw new Error("telegram sendMessage failed: empty formatted text and empty plain fallback");
|
||||
}
|
||||
return await sendPlainFallback();
|
||||
}
|
||||
try {
|
||||
const res = await sendTelegramWithThreadFallback({
|
||||
operation: "sendMessage",
|
||||
runtime,
|
||||
thread: opts?.thread,
|
||||
requestParams: baseParams,
|
||||
shouldLog: (err) => {
|
||||
const errText = formatErrorMessage(err);
|
||||
return !PARSE_ERR_RE.test(errText) && !EMPTY_TEXT_ERR_RE.test(errText);
|
||||
},
|
||||
send: (effectiveParams) =>
|
||||
bot.api.sendMessage(chatId, htmlText, {
|
||||
parse_mode: "HTML",
|
||||
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
|
||||
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
|
||||
...effectiveParams,
|
||||
}),
|
||||
});
|
||||
runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id}`);
|
||||
return res.message_id;
|
||||
} catch (err) {
|
||||
const errText = formatErrorMessage(err);
|
||||
if (PARSE_ERR_RE.test(errText) || EMPTY_TEXT_ERR_RE.test(errText)) {
|
||||
if (!hasFallbackText) {
|
||||
throw err;
|
||||
}
|
||||
runtime.log?.(`telegram formatted send failed; retrying without formatting: ${errText}`);
|
||||
return await sendPlainFallback();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
937
openclaw/extensions/telegram/src/bot/delivery.test.ts
Normal file
937
openclaw/extensions/telegram/src/bot/delivery.test.ts
Normal file
|
|
@ -0,0 +1,937 @@
|
|||
import type { Bot } from "grammy";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { loadWebMedia } = vi.hoisted(() => ({
|
||||
loadWebMedia: vi.fn(),
|
||||
}));
|
||||
const triggerInternalHook = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const messageHookRunner = vi.hoisted(() => ({
|
||||
hasHooks: vi.fn<(name: string) => boolean>(() => false),
|
||||
runMessageSending: vi.fn(),
|
||||
runMessageSent: vi.fn(),
|
||||
}));
|
||||
const baseDeliveryParams = {
|
||||
chatId: "123",
|
||||
token: "tok",
|
||||
replyToMode: "off",
|
||||
textLimit: 4000,
|
||||
} as const;
|
||||
type DeliverRepliesParams = Parameters<typeof deliverReplies>[0];
|
||||
type DeliverWithParams = Omit<
|
||||
DeliverRepliesParams,
|
||||
"chatId" | "token" | "replyToMode" | "textLimit"
|
||||
> &
|
||||
Partial<Pick<DeliverRepliesParams, "replyToMode" | "textLimit" | "mediaLoader">>;
|
||||
type RuntimeStub = Pick<RuntimeEnv, "error" | "log" | "exit">;
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/web-media", () => ({
|
||||
loadWebMedia: (...args: unknown[]) => loadWebMedia(...args),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/web-media", () => ({
|
||||
loadWebMedia: (...args: unknown[]) => loadWebMedia(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/plugins/hook-runner-global.js", () => ({
|
||||
getGlobalHookRunner: () => messageHookRunner,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/hooks/internal-hooks.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../../../src/hooks/internal-hooks.js")>(
|
||||
"../../../../src/hooks/internal-hooks.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
triggerInternalHook,
|
||||
};
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const { deliverReplies } = await import("./delivery.js");
|
||||
|
||||
vi.mock("grammy", () => ({
|
||||
API_CONSTANTS: {
|
||||
DEFAULT_UPDATE_TYPES: ["message"],
|
||||
ALL_UPDATE_TYPES: ["message"],
|
||||
},
|
||||
InputFile: class {
|
||||
constructor(
|
||||
public buffer: Buffer,
|
||||
public fileName?: string,
|
||||
) {}
|
||||
},
|
||||
GrammyError: class GrammyError extends Error {
|
||||
description = "";
|
||||
},
|
||||
}));
|
||||
|
||||
function createRuntime(withLog = true): RuntimeStub {
|
||||
return {
|
||||
error: vi.fn(),
|
||||
log: withLog ? vi.fn() : vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createBot(api: Record<string, unknown> = {}): Bot {
|
||||
return { api } as unknown as Bot;
|
||||
}
|
||||
|
||||
async function deliverWith(params: DeliverWithParams) {
|
||||
await deliverReplies({
|
||||
...baseDeliveryParams,
|
||||
...params,
|
||||
mediaLoader: params.mediaLoader ?? loadWebMedia,
|
||||
});
|
||||
}
|
||||
|
||||
function mockMediaLoad(fileName: string, contentType: string, data: string) {
|
||||
loadWebMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from(data),
|
||||
contentType,
|
||||
fileName,
|
||||
});
|
||||
}
|
||||
|
||||
function createSendMessageHarness(messageId = 4) {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: messageId,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendMessage });
|
||||
return { runtime, sendMessage, bot };
|
||||
}
|
||||
|
||||
function createVoiceMessagesForbiddenError() {
|
||||
return new Error(
|
||||
"GrammyError: Call to 'sendVoice' failed! (400: Bad Request: VOICE_MESSAGES_FORBIDDEN)",
|
||||
);
|
||||
}
|
||||
|
||||
function createThreadNotFoundError(operation = "sendMessage") {
|
||||
return new Error(
|
||||
`GrammyError: Call to '${operation}' failed! (400: Bad Request: message thread not found)`,
|
||||
);
|
||||
}
|
||||
|
||||
function createVoiceFailureHarness(params: {
|
||||
voiceError: Error;
|
||||
sendMessageResult?: { message_id: number; chat: { id: string } };
|
||||
}) {
|
||||
const runtime = createRuntime();
|
||||
const sendVoice = vi.fn().mockRejectedValue(params.voiceError);
|
||||
const sendMessage = params.sendMessageResult
|
||||
? vi.fn().mockResolvedValue(params.sendMessageResult)
|
||||
: vi.fn();
|
||||
const bot = createBot({ sendVoice, sendMessage });
|
||||
return { runtime, sendVoice, sendMessage, bot };
|
||||
}
|
||||
|
||||
describe("deliverReplies", () => {
|
||||
beforeEach(() => {
|
||||
loadWebMedia.mockClear();
|
||||
triggerInternalHook.mockReset();
|
||||
messageHookRunner.hasHooks.mockReset();
|
||||
messageHookRunner.hasHooks.mockReturnValue(false);
|
||||
messageHookRunner.runMessageSending.mockReset();
|
||||
messageHookRunner.runMessageSent.mockReset();
|
||||
});
|
||||
|
||||
it("skips audioAsVoice-only payloads without logging an error", async () => {
|
||||
const runtime = createRuntime(false);
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ audioAsVoice: true }],
|
||||
runtime,
|
||||
bot: createBot(),
|
||||
});
|
||||
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips malformed replies and continues with valid entries", async () => {
|
||||
const runtime = createRuntime(false);
|
||||
const sendMessage = vi.fn().mockResolvedValue({ message_id: 1, chat: { id: "123" } });
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await deliverWith({
|
||||
replies: [undefined, { text: "hello" }] as unknown as DeliverRepliesParams["replies"],
|
||||
runtime,
|
||||
bot,
|
||||
});
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage.mock.calls[0]?.[1]).toBe("hello");
|
||||
});
|
||||
|
||||
it("reports message_sent success=false when hooks blank out a text-only reply", async () => {
|
||||
messageHookRunner.hasHooks.mockImplementation(
|
||||
(name: string) => name === "message_sending" || name === "message_sent",
|
||||
);
|
||||
messageHookRunner.runMessageSending.mockResolvedValue({ content: " " });
|
||||
|
||||
const runtime = createRuntime(false);
|
||||
const sendMessage = vi.fn();
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "hello" }],
|
||||
runtime,
|
||||
bot,
|
||||
});
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
expect(messageHookRunner.runMessageSent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ success: false, content: " " }),
|
||||
expect.objectContaining({ channelId: "telegram", conversationId: "123" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes accountId into message hooks", async () => {
|
||||
messageHookRunner.hasHooks.mockImplementation(
|
||||
(name: string) => name === "message_sending" || name === "message_sent",
|
||||
);
|
||||
|
||||
const runtime = createRuntime(false);
|
||||
const sendMessage = vi.fn().mockResolvedValue({ message_id: 9, chat: { id: "123" } });
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await deliverWith({
|
||||
accountId: "work",
|
||||
replies: [{ text: "hello" }],
|
||||
runtime,
|
||||
bot,
|
||||
});
|
||||
|
||||
expect(messageHookRunner.runMessageSending).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
channelId: "telegram",
|
||||
accountId: "work",
|
||||
conversationId: "123",
|
||||
}),
|
||||
);
|
||||
expect(messageHookRunner.runMessageSent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ success: true }),
|
||||
expect.objectContaining({
|
||||
channelId: "telegram",
|
||||
accountId: "work",
|
||||
conversationId: "123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("sets disable_notification when silent is true", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 5,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "hello" }],
|
||||
runtime,
|
||||
bot,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"123",
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
disable_notification: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits internal message:sent when session hook context is available", async () => {
|
||||
const runtime = createRuntime(false);
|
||||
const sendMessage = vi.fn().mockResolvedValue({ message_id: 9, chat: { id: "123" } });
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await deliverWith({
|
||||
sessionKeyForInternalHooks: "agent:test:telegram:123",
|
||||
mirrorIsGroup: true,
|
||||
mirrorGroupId: "123",
|
||||
replies: [{ text: "hello" }],
|
||||
runtime,
|
||||
bot,
|
||||
});
|
||||
|
||||
expect(triggerInternalHook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "message",
|
||||
action: "sent",
|
||||
sessionKey: "agent:test:telegram:123",
|
||||
context: expect.objectContaining({
|
||||
to: "123",
|
||||
content: "hello",
|
||||
success: true,
|
||||
channelId: "telegram",
|
||||
conversationId: "123",
|
||||
messageId: "9",
|
||||
isGroup: true,
|
||||
groupId: "123",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not emit internal message:sent without a session key", async () => {
|
||||
const runtime = createRuntime(false);
|
||||
const sendMessage = vi.fn().mockResolvedValue({ message_id: 11, chat: { id: "123" } });
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "hello" }],
|
||||
runtime,
|
||||
bot,
|
||||
});
|
||||
|
||||
expect(triggerInternalHook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits internal message:sent with success=false on delivery failure", async () => {
|
||||
const runtime = createRuntime(false);
|
||||
const sendMessage = vi.fn().mockRejectedValue(new Error("network error"));
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await expect(
|
||||
deliverWith({
|
||||
sessionKeyForInternalHooks: "agent:test:telegram:123",
|
||||
replies: [{ text: "hello" }],
|
||||
runtime,
|
||||
bot,
|
||||
}),
|
||||
).rejects.toThrow("network error");
|
||||
|
||||
expect(triggerInternalHook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "message",
|
||||
action: "sent",
|
||||
sessionKey: "agent:test:telegram:123",
|
||||
context: expect.objectContaining({
|
||||
to: "123",
|
||||
content: "hello",
|
||||
success: false,
|
||||
error: "network error",
|
||||
channelId: "telegram",
|
||||
conversationId: "123",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes media metadata to message_sending hooks", async () => {
|
||||
messageHookRunner.hasHooks.mockImplementation((name: string) => name === "message_sending");
|
||||
|
||||
const runtime = createRuntime(false);
|
||||
const sendPhoto = vi.fn().mockResolvedValue({ message_id: 2, chat: { id: "123" } });
|
||||
const bot = createBot({ sendPhoto });
|
||||
|
||||
mockMediaLoad("photo.jpg", "image/jpeg", "image");
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "caption", mediaUrl: "https://example.com/photo.jpg" }],
|
||||
runtime,
|
||||
bot,
|
||||
});
|
||||
|
||||
expect(messageHookRunner.runMessageSending).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "123",
|
||||
content: "caption",
|
||||
metadata: expect.objectContaining({
|
||||
channel: "telegram",
|
||||
mediaUrls: ["https://example.com/photo.jpg"],
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({ channelId: "telegram", conversationId: "123" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("invokes onVoiceRecording before sending a voice note", async () => {
|
||||
const events: string[] = [];
|
||||
const runtime = createRuntime(false);
|
||||
const sendVoice = vi.fn(async () => {
|
||||
events.push("sendVoice");
|
||||
return { message_id: 1, chat: { id: "123" } };
|
||||
});
|
||||
const bot = createBot({ sendVoice });
|
||||
const onVoiceRecording = vi.fn(async () => {
|
||||
events.push("recordVoice");
|
||||
});
|
||||
|
||||
mockMediaLoad("note.ogg", "audio/ogg", "voice");
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ mediaUrl: "https://example.com/note.ogg", audioAsVoice: true }],
|
||||
runtime,
|
||||
bot,
|
||||
onVoiceRecording,
|
||||
});
|
||||
|
||||
expect(onVoiceRecording).toHaveBeenCalledTimes(1);
|
||||
expect(sendVoice).toHaveBeenCalledTimes(1);
|
||||
expect(events).toEqual(["recordVoice", "sendVoice"]);
|
||||
});
|
||||
|
||||
it("renders markdown in media captions", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendPhoto = vi.fn().mockResolvedValue({
|
||||
message_id: 2,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendPhoto });
|
||||
|
||||
mockMediaLoad("photo.jpg", "image/jpeg", "image");
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ mediaUrl: "https://example.com/photo.jpg", text: "hi **boss**" }],
|
||||
runtime,
|
||||
bot,
|
||||
});
|
||||
|
||||
expect(sendPhoto).toHaveBeenCalledWith(
|
||||
"123",
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
caption: "hi <b>boss</b>",
|
||||
parse_mode: "HTML",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes mediaLocalRoots to media loading", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendPhoto = vi.fn().mockResolvedValue({
|
||||
message_id: 12,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendPhoto });
|
||||
const mediaLocalRoots = ["/tmp/workspace-work"];
|
||||
|
||||
mockMediaLoad("photo.jpg", "image/jpeg", "image");
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ mediaUrl: "/tmp/workspace-work/photo.jpg" }],
|
||||
runtime,
|
||||
bot,
|
||||
mediaLocalRoots,
|
||||
});
|
||||
|
||||
expect(loadWebMedia).toHaveBeenCalledWith("/tmp/workspace-work/photo.jpg", {
|
||||
localRoots: mediaLocalRoots,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes link_preview_options when linkPreview is false", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 3,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "Check https://example.com" }],
|
||||
runtime,
|
||||
bot,
|
||||
linkPreview: false,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"123",
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
link_preview_options: { is_disabled: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes message_thread_id for DM topics", async () => {
|
||||
const { runtime, sendMessage, bot } = createSendMessageHarness();
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "Hello" }],
|
||||
runtime,
|
||||
bot,
|
||||
thread: { id: 42, scope: "dm" },
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"123",
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
message_thread_id: 42,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("retries DM topic sends without message_thread_id when thread is missing", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(createThreadNotFoundError("sendMessage"))
|
||||
.mockResolvedValueOnce({
|
||||
message_id: 7,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "hello" }],
|
||||
runtime,
|
||||
bot,
|
||||
thread: { id: 42, scope: "dm" },
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(2);
|
||||
expect(sendMessage.mock.calls[0]?.[2]).toEqual(
|
||||
expect.objectContaining({
|
||||
message_thread_id: 42,
|
||||
}),
|
||||
);
|
||||
expect(sendMessage.mock.calls[1]?.[2]).not.toHaveProperty("message_thread_id");
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not retry forum sends without message_thread_id", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockRejectedValue(createThreadNotFoundError("sendMessage"));
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await expect(
|
||||
deliverWith({
|
||||
replies: [{ text: "hello" }],
|
||||
runtime,
|
||||
bot,
|
||||
thread: { id: 42, scope: "forum" },
|
||||
}),
|
||||
).rejects.toThrow("message thread not found");
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries media sends without message_thread_id for DM topics", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendPhoto = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(createThreadNotFoundError("sendPhoto"))
|
||||
.mockResolvedValueOnce({
|
||||
message_id: 8,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendPhoto });
|
||||
|
||||
mockMediaLoad("photo.jpg", "image/jpeg", "image");
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ mediaUrl: "https://example.com/photo.jpg", text: "caption" }],
|
||||
runtime,
|
||||
bot,
|
||||
thread: { id: 42, scope: "dm" },
|
||||
});
|
||||
|
||||
expect(sendPhoto).toHaveBeenCalledTimes(2);
|
||||
expect(sendPhoto.mock.calls[0]?.[2]).toEqual(
|
||||
expect.objectContaining({
|
||||
message_thread_id: 42,
|
||||
}),
|
||||
);
|
||||
expect(sendPhoto.mock.calls[1]?.[2]).not.toHaveProperty("message_thread_id");
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not include link_preview_options when linkPreview is true", async () => {
|
||||
const { runtime, sendMessage, bot } = createSendMessageHarness();
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "Check https://example.com" }],
|
||||
runtime,
|
||||
bot,
|
||||
linkPreview: true,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"123",
|
||||
expect.any(String),
|
||||
expect.not.objectContaining({
|
||||
link_preview_options: expect.anything(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to plain text when markdown renders to empty HTML in threaded mode", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn(async (_chatId: string, text: string) => {
|
||||
if (text === "") {
|
||||
throw new Error("400: Bad Request: message text is empty");
|
||||
}
|
||||
return {
|
||||
message_id: 6,
|
||||
chat: { id: "123" },
|
||||
};
|
||||
});
|
||||
const bot = { api: { sendMessage } } as unknown as Bot;
|
||||
|
||||
await deliverReplies({
|
||||
replies: [{ text: ">" }],
|
||||
chatId: "123",
|
||||
token: "tok",
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "off",
|
||||
textLimit: 4000,
|
||||
thread: { id: 42, scope: "forum" },
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"123",
|
||||
">",
|
||||
expect.objectContaining({
|
||||
message_thread_id: 42,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips whitespace-only text replies without calling Telegram", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn();
|
||||
const bot = { api: { sendMessage } } as unknown as Bot;
|
||||
|
||||
await expect(
|
||||
deliverReplies({
|
||||
replies: [{ text: " " }],
|
||||
chatId: "123",
|
||||
token: "tok",
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "off",
|
||||
textLimit: 4000,
|
||||
}),
|
||||
).resolves.toEqual({ delivered: false });
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses reply_to_message_id when quote text is provided", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 10,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "Hello there", replyToId: "500" }],
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "all",
|
||||
replyQuoteText: "quoted text",
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"123",
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
reply_to_message_id: 500,
|
||||
allow_sending_without_reply: true,
|
||||
}),
|
||||
);
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"123",
|
||||
expect.any(String),
|
||||
expect.not.objectContaining({
|
||||
reply_parameters: expect.anything(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to text when sendVoice fails with VOICE_MESSAGES_FORBIDDEN", async () => {
|
||||
const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({
|
||||
voiceError: createVoiceMessagesForbiddenError(),
|
||||
sendMessageResult: {
|
||||
message_id: 5,
|
||||
chat: { id: "123" },
|
||||
},
|
||||
});
|
||||
|
||||
mockMediaLoad("note.ogg", "audio/ogg", "voice");
|
||||
|
||||
await deliverWith({
|
||||
replies: [
|
||||
{ mediaUrl: "https://example.com/note.ogg", text: "Hello there", audioAsVoice: true },
|
||||
],
|
||||
runtime,
|
||||
bot,
|
||||
});
|
||||
|
||||
// Voice was attempted but failed
|
||||
expect(sendVoice).toHaveBeenCalledTimes(1);
|
||||
// Fallback to text succeeded
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"123",
|
||||
expect.stringContaining("Hello there"),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps disable_notification on voice fallback text when silent is true", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendVoice = vi.fn().mockRejectedValue(createVoiceMessagesForbiddenError());
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 5,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendVoice, sendMessage });
|
||||
|
||||
mockMediaLoad("note.ogg", "audio/ogg", "voice");
|
||||
|
||||
await deliverWith({
|
||||
replies: [
|
||||
{ mediaUrl: "https://example.com/note.ogg", text: "Hello there", audioAsVoice: true },
|
||||
],
|
||||
runtime,
|
||||
bot,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
expect(sendVoice).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"123",
|
||||
expect.stringContaining("Hello there"),
|
||||
expect.objectContaining({
|
||||
disable_notification: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("voice fallback applies reply-to only on first chunk when replyToMode is first", async () => {
|
||||
const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({
|
||||
voiceError: createVoiceMessagesForbiddenError(),
|
||||
sendMessageResult: {
|
||||
message_id: 6,
|
||||
chat: { id: "123" },
|
||||
},
|
||||
});
|
||||
|
||||
mockMediaLoad("note.ogg", "audio/ogg", "voice");
|
||||
|
||||
await deliverWith({
|
||||
replies: [
|
||||
{
|
||||
mediaUrl: "https://example.com/note.ogg",
|
||||
text: "chunk-one\n\nchunk-two",
|
||||
replyToId: "77",
|
||||
audioAsVoice: true,
|
||||
channelData: {
|
||||
telegram: {
|
||||
buttons: [[{ text: "Ack", callback_data: "ack" }]],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "first",
|
||||
replyQuoteText: "quoted context",
|
||||
textLimit: 12,
|
||||
});
|
||||
|
||||
expect(sendVoice).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
expect(sendMessage.mock.calls[0][2]).toEqual(
|
||||
expect.objectContaining({
|
||||
reply_to_message_id: 77,
|
||||
allow_sending_without_reply: true,
|
||||
reply_markup: {
|
||||
inline_keyboard: [[{ text: "Ack", callback_data: "ack" }]],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(sendMessage.mock.calls[1][2]).not.toEqual(
|
||||
expect.objectContaining({ reply_to_message_id: 77 }),
|
||||
);
|
||||
expect(sendMessage.mock.calls[1][2]).not.toHaveProperty("reply_parameters");
|
||||
expect(sendMessage.mock.calls[1][2]).not.toHaveProperty("reply_markup");
|
||||
});
|
||||
|
||||
it("rethrows non-VOICE_MESSAGES_FORBIDDEN errors from sendVoice", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendVoice = vi.fn().mockRejectedValue(new Error("Network error"));
|
||||
const sendMessage = vi.fn();
|
||||
const bot = createBot({ sendVoice, sendMessage });
|
||||
|
||||
mockMediaLoad("note.ogg", "audio/ogg", "voice");
|
||||
|
||||
await expect(
|
||||
deliverWith({
|
||||
replies: [{ mediaUrl: "https://example.com/note.ogg", text: "Hello", audioAsVoice: true }],
|
||||
runtime,
|
||||
bot,
|
||||
}),
|
||||
).rejects.toThrow("Network error");
|
||||
|
||||
expect(sendVoice).toHaveBeenCalledTimes(1);
|
||||
// Text fallback should NOT be attempted for other errors
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replyToMode 'first' only applies reply-to to the first text chunk", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 20,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
// Use a small textLimit to force multiple chunks
|
||||
await deliverReplies({
|
||||
replies: [{ text: "chunk-one\n\nchunk-two", replyToId: "700" }],
|
||||
chatId: "123",
|
||||
token: "tok",
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "first",
|
||||
textLimit: 12,
|
||||
});
|
||||
|
||||
expect(sendMessage.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
// First chunk should have reply_to_message_id
|
||||
expect(sendMessage.mock.calls[0][2]).toEqual(
|
||||
expect.objectContaining({
|
||||
reply_to_message_id: 700,
|
||||
allow_sending_without_reply: true,
|
||||
}),
|
||||
);
|
||||
// Second chunk should NOT have reply_to_message_id
|
||||
expect(sendMessage.mock.calls[1][2]).not.toHaveProperty("reply_to_message_id");
|
||||
});
|
||||
|
||||
it("replyToMode 'all' applies reply-to to every text chunk", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 21,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await deliverReplies({
|
||||
replies: [{ text: "chunk-one\n\nchunk-two", replyToId: "800" }],
|
||||
chatId: "123",
|
||||
token: "tok",
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "all",
|
||||
textLimit: 12,
|
||||
});
|
||||
|
||||
expect(sendMessage.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
// Both chunks should have reply_to_message_id
|
||||
for (const call of sendMessage.mock.calls) {
|
||||
expect(call[2]).toEqual(
|
||||
expect.objectContaining({
|
||||
reply_to_message_id: 800,
|
||||
allow_sending_without_reply: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("replyToMode 'first' only applies reply-to to first media item", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendPhoto = vi.fn().mockResolvedValue({
|
||||
message_id: 30,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendPhoto });
|
||||
|
||||
mockMediaLoad("a.jpg", "image/jpeg", "img1");
|
||||
mockMediaLoad("b.jpg", "image/jpeg", "img2");
|
||||
|
||||
await deliverReplies({
|
||||
replies: [{ mediaUrls: ["https://a.jpg", "https://b.jpg"], replyToId: "900" }],
|
||||
chatId: "123",
|
||||
token: "tok",
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "first",
|
||||
textLimit: 4000,
|
||||
});
|
||||
|
||||
expect(sendPhoto).toHaveBeenCalledTimes(2);
|
||||
// First media should have reply_to_message_id
|
||||
expect(sendPhoto.mock.calls[0][2]).toEqual(
|
||||
expect.objectContaining({
|
||||
reply_to_message_id: 900,
|
||||
allow_sending_without_reply: true,
|
||||
}),
|
||||
);
|
||||
// Second media should NOT have reply_to_message_id
|
||||
expect(sendPhoto.mock.calls[1][2]).not.toHaveProperty("reply_to_message_id");
|
||||
});
|
||||
|
||||
it("pins the first delivered text message when telegram pin is requested", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ message_id: 101, chat: { id: "123" } })
|
||||
.mockResolvedValueOnce({ message_id: 102, chat: { id: "123" } });
|
||||
const pinChatMessage = vi.fn().mockResolvedValue(true);
|
||||
const bot = createBot({ sendMessage, pinChatMessage });
|
||||
|
||||
await deliverReplies({
|
||||
replies: [{ text: "chunk-one\n\nchunk-two", channelData: { telegram: { pin: true } } }],
|
||||
chatId: "123",
|
||||
token: "tok",
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "off",
|
||||
textLimit: 12,
|
||||
});
|
||||
|
||||
expect(pinChatMessage).toHaveBeenCalledTimes(1);
|
||||
expect(pinChatMessage).toHaveBeenCalledWith("123", 101, { disable_notification: true });
|
||||
});
|
||||
|
||||
it("continues when pinning fails", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({ message_id: 201, chat: { id: "123" } });
|
||||
const pinChatMessage = vi.fn().mockRejectedValue(new Error("pin failed"));
|
||||
const bot = createBot({ sendMessage, pinChatMessage });
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "hello", channelData: { telegram: { pin: true } } }],
|
||||
runtime,
|
||||
bot,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(pinChatMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rethrows VOICE_MESSAGES_FORBIDDEN when no text fallback is available", async () => {
|
||||
const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({
|
||||
voiceError: createVoiceMessagesForbiddenError(),
|
||||
});
|
||||
|
||||
mockMediaLoad("note.ogg", "audio/ogg", "voice");
|
||||
|
||||
await expect(
|
||||
deliverWith({
|
||||
replies: [{ mediaUrl: "https://example.com/note.ogg", audioAsVoice: true }],
|
||||
runtime,
|
||||
bot,
|
||||
}),
|
||||
).rejects.toThrow("VOICE_MESSAGES_FORBIDDEN");
|
||||
|
||||
expect(sendVoice).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
6
openclaw/extensions/telegram/src/bot/delivery.ts
Normal file
6
openclaw/extensions/telegram/src/bot/delivery.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export {
|
||||
deliverReplies,
|
||||
emitInternalMessageSentHook,
|
||||
emitTelegramMessageSentHooks,
|
||||
} from "./delivery.replies.js";
|
||||
export { resolveMedia } from "./delivery.resolve-media.js";
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue