mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-16 12:30:49 +00:00
refactor: move Telegram channel implementation to extensions/ (#45635)
* refactor: move Telegram channel implementation to extensions/telegram/src/ Move all Telegram channel code (123 files + 10 bot/ files + 8 channel plugin files) from src/telegram/ and src/channels/plugins/*/telegram.ts to extensions/telegram/src/. Leave thin re-export shims at original locations so cross-cutting src/ imports continue to resolve. - Fix all relative import paths in moved files (../X/ -> ../../../src/X/) - Fix vi.mock paths in 60 test files - Fix inline typeof import() expressions - Update tsconfig.plugin-sdk.dts.json rootDir to "." for cross-directory DTS - Update write-plugin-sdk-entry-dts.ts for new rootDir structure - Move channel plugin files with correct path remapping * fix: support keyed telegram send deps * fix: sync telegram extension copies with latest main * fix: correct import paths and remove misplaced files in telegram extension * fix: sync outbound-adapter with main (add sendTelegramPayloadMessages) and fix delivery.test import path
This commit is contained in:
107
extensions/telegram/src/account-inspect.test.ts
Normal file
107
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 { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { withEnv } from "../../../src/test-utils/env.js";
|
||||
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 });
|
||||
},
|
||||
);
|
||||
});
|
||||
232
extensions/telegram/src/account-inspect.ts
Normal file
232
extensions/telegram/src/account-inspect.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import {
|
||||
coerceSecretRef,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeSecretInputString,
|
||||
} from "../../../src/config/types.secrets.js";
|
||||
import type { TelegramAccountConfig } from "../../../src/config/types.telegram.js";
|
||||
import { tryReadSecretFileSync } from "../../../src/infra/secret-file.js";
|
||||
import { resolveAccountWithDefaultFallback } from "../../../src/plugin-sdk/account-resolution.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
|
||||
import { resolveDefaultSecretProviderAlias } from "../../../src/secrets/ref-contract.js";
|
||||
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 = typeof pathValue === "string" ? pathValue.trim() : "";
|
||||
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 = process.env[ref.id];
|
||||
if (envValue && envValue.trim()) {
|
||||
return {
|
||||
token: envValue.trim(),
|
||||
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: merged.name?.trim() || undefined,
|
||||
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: merged.name?.trim() || undefined,
|
||||
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: merged.name?.trim() || undefined,
|
||||
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: merged.name?.trim() || undefined,
|
||||
token: channelToken.token,
|
||||
tokenSource: channelToken.tokenSource,
|
||||
tokenStatus: channelToken.tokenStatus,
|
||||
configured: channelToken.tokenStatus !== "missing",
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const envToken = allowEnv ? (params.envToken ?? process.env.TELEGRAM_BOT_TOKEN)?.trim() : "";
|
||||
if (envToken) {
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: merged.name?.trim() || undefined,
|
||||
token: envToken,
|
||||
tokenSource: "env",
|
||||
tokenStatus: "available",
|
||||
configured: true,
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: merged.name?.trim() || undefined,
|
||||
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),
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { withEnv } from "../test-utils/env.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { withEnv } from "../../../src/test-utils/env.js";
|
||||
import {
|
||||
listTelegramAccountIds,
|
||||
resetMissingDefaultWarnFlag,
|
||||
@@ -29,7 +29,7 @@ function resolveAccountWithEnv(
|
||||
return withEnv(env, () => resolveTelegramAccount({ cfg, ...(accountId ? { accountId } : {}) }));
|
||||
}
|
||||
|
||||
vi.mock("../logging/subsystem.js", () => ({
|
||||
vi.mock("../../../src/logging/subsystem.js", () => ({
|
||||
createSubsystemLogger: () => {
|
||||
const logger = {
|
||||
warn: warnMock,
|
||||
211
extensions/telegram/src/accounts.ts
Normal file
211
extensions/telegram/src/accounts.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import util from "node:util";
|
||||
import { createAccountActionGate } from "../../../src/channels/plugins/account-action-gate.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { TelegramAccountConfig, TelegramActionConfig } from "../../../src/config/types.js";
|
||||
import { isTruthyEnvValue } from "../../../src/infra/env.js";
|
||||
import { createSubsystemLogger } from "../../../src/logging/subsystem.js";
|
||||
import {
|
||||
listConfiguredAccountIds as listConfiguredAccountIdsFromSection,
|
||||
resolveAccountWithDefaultFallback,
|
||||
} from "../../../src/plugin-sdk/account-resolution.js";
|
||||
import { resolveAccountEntry } from "../../../src/routing/account-lookup.js";
|
||||
import {
|
||||
listBoundAccountIds,
|
||||
resolveDefaultAgentBoundAccountId,
|
||||
} from "../../../src/routing/bindings.js";
|
||||
import { formatSetExplicitDefaultInstruction } from "../../../src/routing/default-account-warnings.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "../../../src/routing/session-key.js";
|
||||
import { resolveTelegramToken } from "./token.js";
|
||||
|
||||
const log = createSubsystemLogger("telegram/accounts");
|
||||
|
||||
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));
|
||||
log.warn(parts.join(" ").trim());
|
||||
}
|
||||
};
|
||||
|
||||
export type ResolvedTelegramAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
token: string;
|
||||
tokenSource: "env" | "tokenFile" | "config" | "none";
|
||||
config: TelegramAccountConfig;
|
||||
};
|
||||
|
||||
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
|
||||
return listConfiguredAccountIdsFromSection({
|
||||
accounts: cfg.channels?.telegram?.accounts,
|
||||
normalizeAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
export function listTelegramAccountIds(cfg: OpenClawConfig): string[] {
|
||||
const ids = Array.from(
|
||||
new Set([...listConfiguredAccountIds(cfg), ...listBoundAccountIds(cfg, "telegram")]),
|
||||
);
|
||||
debugAccounts("listTelegramAccountIds", ids);
|
||||
if (ids.length === 0) {
|
||||
return [DEFAULT_ACCOUNT_ID];
|
||||
}
|
||||
return ids.toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
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 boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram");
|
||||
if (boundDefault) {
|
||||
return boundDefault;
|
||||
}
|
||||
const preferred = normalizeOptionalAccountId(cfg.channels?.telegram?.defaultAccount);
|
||||
if (
|
||||
preferred &&
|
||||
listTelegramAccountIds(cfg).some((accountId) => normalizeAccountId(accountId) === preferred)
|
||||
) {
|
||||
return preferred;
|
||||
}
|
||||
const ids = listTelegramAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
if (ids.length > 1 && !emittedMissingDefaultWarn) {
|
||||
emittedMissingDefaultWarn = true;
|
||||
log.warn(
|
||||
`channels.telegram: accounts.default is missing; falling back to "${ids[0]}". ` +
|
||||
`${formatSetExplicitDefaultInstruction("telegram")} to avoid routing surprises in multi-account setups.`,
|
||||
);
|
||||
}
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
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) ?? {};
|
||||
|
||||
// In multi-account setups, channel-level `groups` must NOT be inherited by
|
||||
// accounts that don't have their own `groups` config. A bot that is not a
|
||||
// member of a configured group will fail when handling group messages, and
|
||||
// this failure disrupts message delivery for *all* accounts.
|
||||
// Single-account setups keep backward compat: channel-level groups still
|
||||
// applies when the account has no override.
|
||||
// See: https://github.com/openclaw/openclaw/issues/30673
|
||||
const configuredAccountIds = Object.keys(cfg.channels?.telegram?.accounts ?? {});
|
||||
const isMultiAccount = configuredAccountIds.length > 1;
|
||||
const groups = account.groups ?? (isMultiAccount ? undefined : channelGroups);
|
||||
|
||||
return { ...base, ...account, groups };
|
||||
}
|
||||
|
||||
export function createTelegramActionGate(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): (key: keyof TelegramActionConfig, defaultValue?: boolean) => boolean {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
return createAccountActionGate({
|
||||
baseActions: params.cfg.channels?.telegram?.actions,
|
||||
accountActions: resolveTelegramAccountConfig(params.cfg, accountId)?.actions,
|
||||
});
|
||||
}
|
||||
|
||||
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: merged.name?.trim() || undefined,
|
||||
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);
|
||||
}
|
||||
14
extensions/telegram/src/allowed-updates.ts
Normal file
14
extensions/telegram/src/allowed-updates.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { API_CONSTANTS } from "grammy";
|
||||
|
||||
type TelegramUpdateType = (typeof API_CONSTANTS.ALL_UPDATE_TYPES)[number];
|
||||
|
||||
export function resolveTelegramAllowedUpdates(): ReadonlyArray<TelegramUpdateType> {
|
||||
const updates = [...API_CONSTANTS.DEFAULT_UPDATE_TYPES] as TelegramUpdateType[];
|
||||
if (!updates.includes("message_reaction")) {
|
||||
updates.push("message_reaction");
|
||||
}
|
||||
if (!updates.includes("channel_post")) {
|
||||
updates.push("channel_post");
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
45
extensions/telegram/src/api-logging.ts
Normal file
45
extensions/telegram/src/api-logging.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { danger } from "../../../src/globals.js";
|
||||
import { formatErrorMessage } from "../../../src/infra/errors.js";
|
||||
import { createSubsystemLogger } from "../../../src/logging/subsystem.js";
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
|
||||
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(danger(`telegram ${operation} failed: ${errText}`));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
18
extensions/telegram/src/approval-buttons.test.ts
Normal file
18
extensions/telegram/src/approval-buttons.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildTelegramExecApprovalButtons } from "./approval-buttons.js";
|
||||
|
||||
describe("telegram approval buttons", () => {
|
||||
it("builds allow-once/allow-always/deny buttons", () => {
|
||||
expect(buildTelegramExecApprovalButtons("fbd8daf7")).toEqual([
|
||||
[
|
||||
{ text: "Allow Once", callback_data: "/approve fbd8daf7 allow-once" },
|
||||
{ text: "Allow Always", callback_data: "/approve fbd8daf7 allow-always" },
|
||||
],
|
||||
[{ text: "Deny", callback_data: "/approve fbd8daf7 deny" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips buttons when callback_data exceeds Telegram limit", () => {
|
||||
expect(buildTelegramExecApprovalButtons(`a${"b".repeat(60)}`)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
42
extensions/telegram/src/approval-buttons.ts
Normal file
42
extensions/telegram/src/approval-buttons.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ExecApprovalReplyDecision } from "../../../src/infra/exec-approval-reply.js";
|
||||
import type { TelegramInlineButtons } from "./button-types.js";
|
||||
|
||||
const MAX_CALLBACK_DATA_BYTES = 64;
|
||||
|
||||
function fitsCallbackData(value: string): boolean {
|
||||
return Buffer.byteLength(value, "utf8") <= MAX_CALLBACK_DATA_BYTES;
|
||||
}
|
||||
|
||||
export function buildTelegramExecApprovalButtons(
|
||||
approvalId: string,
|
||||
): TelegramInlineButtons | undefined {
|
||||
return buildTelegramExecApprovalButtonsForDecisions(approvalId, [
|
||||
"allow-once",
|
||||
"allow-always",
|
||||
"deny",
|
||||
]);
|
||||
}
|
||||
|
||||
function buildTelegramExecApprovalButtonsForDecisions(
|
||||
approvalId: string,
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[],
|
||||
): TelegramInlineButtons | undefined {
|
||||
const allowOnce = `/approve ${approvalId} allow-once`;
|
||||
if (!allowedDecisions.includes("allow-once") || !fitsCallbackData(allowOnce)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const primaryRow: Array<{ text: string; callback_data: string }> = [
|
||||
{ text: "Allow Once", callback_data: allowOnce },
|
||||
];
|
||||
const allowAlways = `/approve ${approvalId} allow-always`;
|
||||
if (allowedDecisions.includes("allow-always") && fitsCallbackData(allowAlways)) {
|
||||
primaryRow.push({ text: "Allow Always", callback_data: allowAlways });
|
||||
}
|
||||
const rows: Array<Array<{ text: string; callback_data: string }>> = [primaryRow];
|
||||
const deny = `/approve ${approvalId} deny`;
|
||||
if (allowedDecisions.includes("deny") && fitsCallbackData(deny)) {
|
||||
rows.push([{ text: "Deny", callback_data: deny }]);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
76
extensions/telegram/src/audit-membership-runtime.ts
Normal file
76
extensions/telegram/src/audit-membership-runtime.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { isRecord } from "../../../src/utils.js";
|
||||
import { fetchWithTimeout } from "../../../src/utils/fetch-timeout.js";
|
||||
import type {
|
||||
AuditTelegramGroupMembershipParams,
|
||||
TelegramGroupMembershipAudit,
|
||||
TelegramGroupMembershipAuditEntry,
|
||||
} from "./audit.js";
|
||||
import { resolveTelegramFetch } from "./fetch.js";
|
||||
import { makeProxyFetch } from "./proxy.js";
|
||||
|
||||
const TELEGRAM_API_BASE = "https://api.telegram.org";
|
||||
|
||||
type TelegramApiOk<T> = { ok: true; result: T };
|
||||
type TelegramApiErr = { ok: false; description?: string };
|
||||
type TelegramGroupMembershipAuditData = Omit<TelegramGroupMembershipAudit, "elapsedMs">;
|
||||
|
||||
export async function auditTelegramGroupMembershipImpl(
|
||||
params: AuditTelegramGroupMembershipParams,
|
||||
): Promise<TelegramGroupMembershipAuditData> {
|
||||
const proxyFetch = params.proxyUrl ? makeProxyFetch(params.proxyUrl) : undefined;
|
||||
const fetcher = resolveTelegramFetch(proxyFetch, { network: params.network });
|
||||
const base = `${TELEGRAM_API_BASE}/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<{ status?: string }> | 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 as TelegramApiOk<unknown>).result)
|
||||
? ((json as TelegramApiOk<{ status?: string }>).result.status ?? null)
|
||||
: 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: err instanceof Error ? err.message : String(err),
|
||||
matchKey: chatId,
|
||||
matchSource: "id",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: groups.every((g) => g.ok),
|
||||
checkedGroups: groups.length,
|
||||
unresolvedGroups: 0,
|
||||
hasWildcardUnmentionedGroups: false,
|
||||
groups,
|
||||
};
|
||||
}
|
||||
107
extensions/telegram/src/audit.ts
Normal file
107
extensions/telegram/src/audit.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { TelegramGroupConfig } from "../../../src/config/types.js";
|
||||
import type { TelegramNetworkConfig } from "../../../src/config/types.telegram.js";
|
||||
|
||||
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 function collectTelegramUnmentionedGroupIds(
|
||||
groups: Record<string, TelegramGroupConfig> | undefined,
|
||||
) {
|
||||
if (!groups || typeof groups !== "object") {
|
||||
return {
|
||||
groupIds: [] as string[],
|
||||
unresolvedGroups: 0,
|
||||
hasWildcardUnmentionedGroups: false,
|
||||
};
|
||||
}
|
||||
const hasWildcardUnmentionedGroups =
|
||||
Boolean(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 = String(key).trim();
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
if (/^-?\d+$/.test(id)) {
|
||||
groupIds.push(id);
|
||||
} else {
|
||||
unresolvedGroups += 1;
|
||||
}
|
||||
}
|
||||
groupIds.sort((a, b) => a.localeCompare(b));
|
||||
return { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups };
|
||||
}
|
||||
|
||||
export type AuditTelegramGroupMembershipParams = {
|
||||
token: string;
|
||||
botId: number;
|
||||
groupIds: string[];
|
||||
proxyUrl?: string;
|
||||
network?: TelegramNetworkConfig;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
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 = params.token?.trim() ?? "";
|
||||
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,
|
||||
};
|
||||
}
|
||||
15
extensions/telegram/src/bot-access.test.ts
Normal file
15
extensions/telegram/src/bot-access.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeAllowFrom } from "./bot-access.js";
|
||||
|
||||
describe("normalizeAllowFrom", () => {
|
||||
it("accepts sender IDs and keeps negative chat IDs invalid", () => {
|
||||
const result = normalizeAllowFrom(["-1001234567890", " tg:-100999 ", "745123456", "@someone"]);
|
||||
|
||||
expect(result).toEqual({
|
||||
entries: ["745123456"],
|
||||
hasWildcard: false,
|
||||
hasEntries: true,
|
||||
invalidEntries: ["-1001234567890", "-100999", "@someone"],
|
||||
});
|
||||
});
|
||||
});
|
||||
94
extensions/telegram/src/bot-access.ts
Normal file
94
extensions/telegram/src/bot-access.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
firstDefined,
|
||||
isSenderIdAllowed,
|
||||
mergeDmAllowFromSources,
|
||||
} from "../../../src/channels/allow-from.js";
|
||||
import type { AllowlistMatch } from "../../../src/channels/allowlist-match.js";
|
||||
import { createSubsystemLogger } from "../../../src/logging/subsystem.js";
|
||||
|
||||
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 onboarding (it resolves @username to IDs) or replace them manually.',
|
||||
].join(" "),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const normalizeAllowFrom = (list?: Array<string | number>): NormalizedAllowFrom => {
|
||||
const entries = (list ?? []).map((value) => String(value).trim()).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 };
|
||||
};
|
||||
1679
extensions/telegram/src/bot-handlers.ts
Normal file
1679
extensions/telegram/src/bot-handlers.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn());
|
||||
const resolveConfiguredAcpBindingRecordMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../acp/persistent-bindings.js", () => ({
|
||||
vi.mock("../../../src/acp/persistent-bindings.js", () => ({
|
||||
ensureConfiguredAcpBindingSession: (...args: unknown[]) =>
|
||||
ensureConfiguredAcpBindingSessionMock(...args),
|
||||
resolveConfiguredAcpBindingRecord: (...args: unknown[]) =>
|
||||
@@ -6,7 +6,7 @@ const DEFAULT_MODEL = "anthropic/claude-opus-4-5";
|
||||
const DEFAULT_WORKSPACE = "/tmp/openclaw";
|
||||
const DEFAULT_MENTION_PATTERN = "\\bbot\\b";
|
||||
|
||||
vi.mock("../media-understanding/audio-preflight.js", () => ({
|
||||
vi.mock("../../../src/media-understanding/audio-preflight.js", () => ({
|
||||
transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args),
|
||||
}));
|
||||
|
||||
288
extensions/telegram/src/bot-message-context.body.ts
Normal file
288
extensions/telegram/src/bot-message-context.body.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import {
|
||||
findModelInCatalog,
|
||||
loadModelCatalog,
|
||||
modelSupportsVision,
|
||||
} from "../../../src/agents/model-catalog.js";
|
||||
import { resolveDefaultModelForAgent } from "../../../src/agents/model-selection.js";
|
||||
import { hasControlCommand } from "../../../src/auto-reply/command-detection.js";
|
||||
import {
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
type HistoryEntry,
|
||||
} from "../../../src/auto-reply/reply/history.js";
|
||||
import {
|
||||
buildMentionRegexes,
|
||||
matchesMentionWithExplicit,
|
||||
} from "../../../src/auto-reply/reply/mentions.js";
|
||||
import type { MsgContext } from "../../../src/auto-reply/templating.js";
|
||||
import { resolveControlCommandGate } from "../../../src/channels/command-gating.js";
|
||||
import { formatLocationText, type NormalizedLocation } from "../../../src/channels/location.js";
|
||||
import { logInboundDrop } from "../../../src/channels/logging.js";
|
||||
import { resolveMentionGatingWithBypass } from "../../../src/channels/mention-gating.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type {
|
||||
TelegramDirectConfig,
|
||||
TelegramGroupConfig,
|
||||
TelegramTopicConfig,
|
||||
} from "../../../src/config/types.js";
|
||||
import { logVerbose } from "../../../src/globals.js";
|
||||
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,
|
||||
buildTelegramGroupPeerId,
|
||||
expandTextLinks,
|
||||
extractTelegramLocation,
|
||||
getTelegramTextParts,
|
||||
hasBotMention,
|
||||
resolveTelegramMediaPlaceholder,
|
||||
} from "./bot/helpers.js";
|
||||
import type { TelegramContext } from "./bot/types.js";
|
||||
import { isTelegramForumServiceMessage } from "./forum-service-message.js";
|
||||
|
||||
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 catalog = await loadModelCatalog({ config: params.cfg });
|
||||
const defaultModel = resolveDefaultModelForAgent({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const entry = findModelInCatalog(catalog, defaultModel.provider, defaultModel.model);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
return modelSupportsVision(entry);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveTelegramInboundBody(params: {
|
||||
cfg: OpenClawConfig;
|
||||
primaryCtx: TelegramContext;
|
||||
msg: TelegramContext["message"];
|
||||
allMedia: TelegramMediaRef[];
|
||||
isGroup: boolean;
|
||||
chatId: number | string;
|
||||
senderId: string;
|
||||
senderUsername: 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,
|
||||
senderId,
|
||||
senderUsername,
|
||||
resolvedThreadId,
|
||||
routeAgentId,
|
||||
effectiveGroupAllow,
|
||||
effectiveDmAllow,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
requireMention,
|
||||
options,
|
||||
groupHistories,
|
||||
historyLimit,
|
||||
logger,
|
||||
} = params;
|
||||
const botUsername = primaryCtx.me?.username?.toLowerCase();
|
||||
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;
|
||||
|
||||
let placeholder = resolveTelegramMediaPlaceholder(msg) ?? "";
|
||||
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;
|
||||
const hasAudio = allMedia.some((media) => media.contentType?.startsWith("audio/"));
|
||||
const disableAudioPreflight =
|
||||
(topicConfig?.disableAudioPreflight ??
|
||||
(groupConfig as TelegramGroupConfig | undefined)?.disableAudioPreflight) === true;
|
||||
|
||||
let preflightTranscript: string | undefined;
|
||||
const needsPreflightTranscription =
|
||||
isGroup &&
|
||||
requireMention &&
|
||||
hasAudio &&
|
||||
!hasUserText &&
|
||||
mentionRegexes.length > 0 &&
|
||||
!disableAudioPreflight;
|
||||
|
||||
if (needsPreflightTranscription) {
|
||||
try {
|
||||
const { transcribeFirstAudio } =
|
||||
await import("../../../src/media-understanding/audio-preflight.js");
|
||||
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 implicitMention = replyToBotMessage && !isReplyToServiceMessage;
|
||||
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
|
||||
const mentionGate = resolveMentionGatingWithBypass({
|
||||
isGroup,
|
||||
requireMention: Boolean(requireMention),
|
||||
canDetectMention,
|
||||
wasMentioned,
|
||||
implicitMention: isGroup && Boolean(requireMention) && implicitMention,
|
||||
hasAnyMention,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
commandAuthorized,
|
||||
});
|
||||
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
|
||||
if (isGroup && requireMention && canDetectMention && mentionGate.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,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
bodyText,
|
||||
rawBody,
|
||||
historyKey,
|
||||
commandAuthorized,
|
||||
effectiveWasMentioned,
|
||||
canDetectMention,
|
||||
shouldBypassMention: mentionGate.shouldBypassMention,
|
||||
stickerCacheHit,
|
||||
locationData: locationData ?? undefined,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js";
|
||||
import {
|
||||
clearRuntimeConfigSnapshot,
|
||||
setRuntimeConfigSnapshot,
|
||||
} from "../../../src/config/config.js";
|
||||
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
|
||||
|
||||
describe("buildTelegramMessageContext dm thread sessions", () => {
|
||||
@@ -3,7 +3,7 @@ import { buildTelegramMessageContextForTest } from "./bot-message-context.test-h
|
||||
|
||||
// Mock recordInboundSession to capture updateLastRoute parameter
|
||||
const recordInboundSessionMock = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("../channels/session.js", () => ({
|
||||
vi.mock("../../../src/channels/session.js", () => ({
|
||||
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearRuntimeConfigSnapshot,
|
||||
setRuntimeConfigSnapshot,
|
||||
} from "../../../src/config/config.js";
|
||||
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
|
||||
|
||||
const recordInboundSessionMock = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("../../../src/channels/session.js", () => ({
|
||||
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
|
||||
}));
|
||||
|
||||
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();
|
||||
recordInboundSessionMock.mockClear();
|
||||
});
|
||||
|
||||
function getLastUpdateLastRoute(): { sessionKey?: string } | undefined {
|
||||
const callArgs = recordInboundSessionMock.mock.calls.at(-1)?.[0] as {
|
||||
updateLastRoute?: { sessionKey?: string };
|
||||
};
|
||||
return callArgs?.updateLastRoute;
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
320
extensions/telegram/src/bot-message-context.session.ts
Normal file
320
extensions/telegram/src/bot-message-context.session.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import { normalizeCommandBody } from "../../../src/auto-reply/commands-registry.js";
|
||||
import {
|
||||
formatInboundEnvelope,
|
||||
resolveEnvelopeFormatOptions,
|
||||
} from "../../../src/auto-reply/envelope.js";
|
||||
import {
|
||||
buildPendingHistoryContextFromMap,
|
||||
type HistoryEntry,
|
||||
} from "../../../src/auto-reply/reply/history.js";
|
||||
import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js";
|
||||
import { toLocationContext } from "../../../src/channels/location.js";
|
||||
import { recordInboundSession } from "../../../src/channels/session.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { readSessionUpdatedAt, resolveStorePath } from "../../../src/config/sessions.js";
|
||||
import type {
|
||||
TelegramDirectConfig,
|
||||
TelegramGroupConfig,
|
||||
TelegramTopicConfig,
|
||||
} from "../../../src/config/types.js";
|
||||
import { logVerbose, shouldLogVerbose } from "../../../src/globals.js";
|
||||
import type { ResolvedAgentRoute } from "../../../src/routing/resolve-route.js";
|
||||
import { resolveInboundLastRouteSessionKey } from "../../../src/routing/resolve-route.js";
|
||||
import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../src/security/dm-policy-shared.js";
|
||||
import { normalizeAllowFrom } from "./bot-access.js";
|
||||
import type {
|
||||
TelegramMediaRef,
|
||||
TelegramMessageContextOptions,
|
||||
} from "./bot-message-context.types.js";
|
||||
import {
|
||||
buildGroupLabel,
|
||||
buildSenderLabel,
|
||||
buildSenderName,
|
||||
buildTelegramGroupFrom,
|
||||
describeReplyTarget,
|
||||
normalizeForwardedContext,
|
||||
type TelegramThreadSpec,
|
||||
} from "./bot/helpers.js";
|
||||
import type { TelegramContext } from "./bot/types.js";
|
||||
import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js";
|
||||
|
||||
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?: import("../../../src/channels/location.js").NormalizedLocation;
|
||||
options?: TelegramMessageContextOptions;
|
||||
dmAllowFrom?: Array<string | number>;
|
||||
}): Promise<{
|
||||
ctxPayload: ReturnType<typeof finalizeInboundContext>;
|
||||
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,
|
||||
} = params;
|
||||
const replyTarget = describeReplyTarget(msg);
|
||||
const forwardOrigin = normalizeForwardedContext(msg);
|
||||
const replyForwardAnnotation = replyTarget?.forwardedFrom
|
||||
? `[Forwarded from ${replyTarget.forwardedFrom.from}${
|
||||
replyTarget.forwardedFrom.date
|
||||
? ` at ${new Date(replyTarget.forwardedFrom.date * 1000).toISOString()}`
|
||||
: ""
|
||||
}]\n`
|
||||
: "";
|
||||
const replySuffix = replyTarget
|
||||
? replyTarget.kind === "quote"
|
||||
? `\n\n[Quoting ${replyTarget.sender}${
|
||||
replyTarget.id ? ` id:${replyTarget.id}` : ""
|
||||
}]\n${replyForwardAnnotation}"${replyTarget.body}"\n[/Quoting]`
|
||||
: `\n\n[Replying to ${replyTarget.sender}${
|
||||
replyTarget.id ? ` id:${replyTarget.id}` : ""
|
||||
}]\n${replyForwardAnnotation}${replyTarget.body}\n[/Replying]`
|
||||
: "";
|
||||
const forwardPrefix = forwardOrigin
|
||||
? `[Forwarded from ${forwardOrigin.from}${
|
||||
forwardOrigin.date ? ` at ${new Date(forwardOrigin.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 storePath = resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
|
||||
const previousTimestamp = 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: primaryCtx.me?.username?.toLowerCase(),
|
||||
});
|
||||
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 = 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: replyTarget?.id,
|
||||
ReplyToBody: replyTarget?.body,
|
||||
ReplyToSender: replyTarget?.sender,
|
||||
ReplyToIsQuote: replyTarget?.kind === "quote" ? true : undefined,
|
||||
ReplyToForwardedFrom: replyTarget?.forwardedFrom?.from,
|
||||
ReplyToForwardedFromType: replyTarget?.forwardedFrom?.fromType,
|
||||
ReplyToForwardedFromId: replyTarget?.forwardedFrom?.fromId,
|
||||
ReplyToForwardedFromUsername: replyTarget?.forwardedFrom?.fromUsername,
|
||||
ReplyToForwardedFromTitle: replyTarget?.forwardedFrom?.fromTitle,
|
||||
ReplyToForwardedDate: replyTarget?.forwardedFrom?.date
|
||||
? replyTarget.forwardedFrom.date * 1000
|
||||
: undefined,
|
||||
ForwardedFrom: forwardOrigin?.from,
|
||||
ForwardedFromType: forwardOrigin?.fromType,
|
||||
ForwardedFromId: forwardOrigin?.fromId,
|
||||
ForwardedFromUsername: forwardOrigin?.fromUsername,
|
||||
ForwardedFromTitle: forwardOrigin?.fromTitle,
|
||||
ForwardedFromSignature: forwardOrigin?.fromSignature,
|
||||
ForwardedFromChatType: forwardOrigin?.fromChatType,
|
||||
ForwardedFromMessageId: forwardOrigin?.fromMessageId,
|
||||
ForwardedDate: forwardOrigin?.date ? forwardOrigin.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,
|
||||
MessageThreadId: threadSpec.id,
|
||||
IsForum: isForum,
|
||||
OriginatingChannel: "telegram" as const,
|
||||
OriginatingTo: `telegram:${chatId}`,
|
||||
});
|
||||
|
||||
const pinnedMainDmOwner = !isGroup
|
||||
? resolvePinnedMainDmOwnerFromAllowlist({
|
||||
dmScope: cfg.session?.dmScope,
|
||||
allowFrom: dmAllowFrom,
|
||||
normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0],
|
||||
})
|
||||
: null;
|
||||
const updateLastRouteSessionKey = resolveInboundLastRouteSessionKey({
|
||||
route,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
|
||||
await recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
updateLastRoute: !isGroup
|
||||
? {
|
||||
sessionKey: updateLastRouteSessionKey,
|
||||
channel: "telegram",
|
||||
to: `telegram:${chatId}`,
|
||||
accountId: route.accountId,
|
||||
threadId: dmThreadId != null ? String(dmThreadId) : undefined,
|
||||
mainDmOwnerPin:
|
||||
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 (replyTarget && shouldLogVerbose()) {
|
||||
const preview = replyTarget.body.replace(/\s+/g, " ").slice(0, 120);
|
||||
logVerbose(
|
||||
`telegram reply-context: replyToId=${replyTarget.id} replyToSender=${replyTarget.sender} replyToBody="${preview}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (forwardOrigin && shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`telegram forward-context: forwardedFrom="${forwardOrigin.from}" type=${forwardOrigin.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,
|
||||
};
|
||||
}
|
||||
@@ -9,9 +9,9 @@ const hoisted = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../infra/outbound/session-binding-service.js", async (importOriginal) => {
|
||||
vi.mock("../../../src/infra/outbound/session-binding-service.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../infra/outbound/session-binding-service.js")>();
|
||||
await importOriginal<typeof import("../../../src/infra/outbound/session-binding-service.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getSessionBindingService: () => ({
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { loadConfig } from "../../../src/config/config.js";
|
||||
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
|
||||
|
||||
const { defaultRouteConfig } = vi.hoisted(() => ({
|
||||
@@ -12,8 +12,8 @@ const { defaultRouteConfig } = vi.hoisted(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
vi.mock("../../../src/config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: vi.fn(() => defaultRouteConfig),
|
||||
473
extensions/telegram/src/bot-message-context.ts
Normal file
473
extensions/telegram/src/bot-message-context.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
import { ensureConfiguredAcpRouteReady } from "../../../src/acp/persistent-bindings.route.js";
|
||||
import { resolveAckReaction } from "../../../src/agents/identity.js";
|
||||
import { shouldAckReaction as shouldAckReactionGate } from "../../../src/channels/ack-reactions.js";
|
||||
import { logInboundDrop } from "../../../src/channels/logging.js";
|
||||
import {
|
||||
createStatusReactionController,
|
||||
type StatusReactionController,
|
||||
} from "../../../src/channels/status-reactions.js";
|
||||
import { loadConfig } from "../../../src/config/config.js";
|
||||
import type { TelegramDirectConfig, TelegramGroupConfig } from "../../../src/config/types.js";
|
||||
import { logVerbose } from "../../../src/globals.js";
|
||||
import { recordChannelActivity } from "../../../src/infra/channel-activity.js";
|
||||
import { buildAgentSessionKey, deriveLastRoutePolicy } from "../../../src/routing/resolve-route.js";
|
||||
import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "../../../src/routing/session-key.js";
|
||||
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
||||
import { firstDefined, normalizeAllowFrom, normalizeDmAllowFromWithStore } from "./bot-access.js";
|
||||
import { resolveTelegramInboundBody } from "./bot-message-context.body.js";
|
||||
import { buildTelegramInboundContextPayload } from "./bot-message-context.session.js";
|
||||
import type { BuildTelegramMessageContextParams } from "./bot-message-context.types.js";
|
||||
import {
|
||||
buildTypingThreadParams,
|
||||
resolveTelegramDirectPeerId,
|
||||
resolveTelegramThreadSpec,
|
||||
} from "./bot/helpers.js";
|
||||
import { resolveTelegramConversationRoute } from "./conversation-route.js";
|
||||
import { enforceTelegramDmAccess } from "./dm-access.js";
|
||||
import { evaluateTelegramGroupBaseAccess } from "./group-access.js";
|
||||
import {
|
||||
buildTelegramStatusReactionVariants,
|
||||
resolveTelegramAllowedEmojiReactions,
|
||||
resolveTelegramReactionVariant,
|
||||
resolveTelegramStatusReactionEmojis,
|
||||
} from "./status-reaction-variants.js";
|
||||
|
||||
export type {
|
||||
BuildTelegramMessageContextParams,
|
||||
TelegramMediaRef,
|
||||
} from "./bot-message-context.types.js";
|
||||
|
||||
export const buildTelegramMessageContext = async ({
|
||||
primaryCtx,
|
||||
allMedia,
|
||||
replyMedia = [],
|
||||
storeAllowFrom,
|
||||
options,
|
||||
bot,
|
||||
cfg,
|
||||
account,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
dmPolicy,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
ackReactionScope,
|
||||
logger,
|
||||
resolveGroupActivation,
|
||||
resolveGroupRequireMention,
|
||||
resolveTelegramGroupConfig,
|
||||
sendChatActionHandler,
|
||||
}: BuildTelegramMessageContextParams) => {
|
||||
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 isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
|
||||
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 threadIdForConfig = resolvedThreadId ?? dmThreadId;
|
||||
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, threadIdForConfig);
|
||||
// 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 = 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 = (groupConfig as TelegramDirectConfig | undefined)?.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,
|
||||
}))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const ensureConfiguredBindingReady = async (): Promise<boolean> => {
|
||||
if (!configuredBinding) {
|
||||
return true;
|
||||
}
|
||||
const ensured = await ensureConfiguredAcpRouteReady({
|
||||
cfg: freshCfg,
|
||||
configuredBinding,
|
||||
});
|
||||
if (ensured.ok) {
|
||||
logVerbose(
|
||||
`telegram: using configured ACP binding for ${configuredBinding.spec.conversationId} -> ${configuredBindingSessionKey}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
logVerbose(
|
||||
`telegram: configured ACP binding unavailable for ${configuredBinding.spec.conversationId}: ${ensured.error}`,
|
||||
);
|
||||
logInboundDrop({
|
||||
log: logVerbose,
|
||||
channel: "telegram",
|
||||
reason: "configured ACP binding unavailable",
|
||||
target: configuredBinding.spec.conversationId,
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
||||
const baseSessionKey = isNamedAccountFallback
|
||||
? buildAgentSessionKey({
|
||||
agentId: route.agentId,
|
||||
channel: "telegram",
|
||||
accountId: route.accountId,
|
||||
peer: {
|
||||
kind: "direct",
|
||||
id: resolveTelegramDirectPeerId({
|
||||
chatId,
|
||||
senderId,
|
||||
}),
|
||||
},
|
||||
dmScope: "per-account-channel-peer",
|
||||
identityLinks: freshCfg.session?.identityLinks,
|
||||
}).toLowerCase()
|
||||
: route.sessionKey;
|
||||
// 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,
|
||||
(groupConfig as TelegramGroupConfig | undefined)?.requireMention,
|
||||
baseRequireMention,
|
||||
);
|
||||
|
||||
recordChannelActivity({
|
||||
channel: "telegram",
|
||||
accountId: account.accountId,
|
||||
direction: "inbound",
|
||||
});
|
||||
|
||||
const bodyResult = await resolveTelegramInboundBody({
|
||||
cfg,
|
||||
primaryCtx,
|
||||
msg,
|
||||
allMedia,
|
||||
isGroup,
|
||||
chatId,
|
||||
senderId,
|
||||
senderUsername,
|
||||
resolvedThreadId,
|
||||
routeAgentId: route.agentId,
|
||||
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 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,
|
||||
}),
|
||||
);
|
||||
const api = bot.api as unknown as {
|
||||
setMessageReaction?: (
|
||||
chatId: number | string,
|
||||
messageId: number,
|
||||
reactions: Array<{ type: "emoji"; emoji: string }>,
|
||||
) => Promise<void>;
|
||||
getChat?: (chatId: number | string) => Promise<unknown>;
|
||||
};
|
||||
const reactionApi =
|
||||
typeof api.setMessageReaction === "function" ? api.setMessageReaction.bind(api) : null;
|
||||
const getChatApi = typeof api.getChat === "function" ? api.getChat.bind(api) : null;
|
||||
|
||||
// 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<string> | null> | null = null;
|
||||
const statusReactionController: StatusReactionController | null =
|
||||
statusReactionsEnabled && msg.message_id
|
||||
? 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 = statusReactionController
|
||||
? shouldAckReaction()
|
||||
? Promise.resolve(statusReactionController.setQueued()).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
: null
|
||||
: shouldAckReaction() && msg.message_id && reactionApi
|
||||
? withTelegramApiErrorLogging({
|
||||
operation: "setMessageReaction",
|
||||
fn: () => reactionApi(chatId, msg.message_id, [{ type: "emoji", emoji: ackReaction }]),
|
||||
}).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,
|
||||
commandAuthorized: bodyResult.commandAuthorized,
|
||||
});
|
||||
|
||||
return {
|
||||
ctxPayload,
|
||||
primaryCtx,
|
||||
msg,
|
||||
chatId,
|
||||
isGroup,
|
||||
resolvedThreadId,
|
||||
threadSpec,
|
||||
replyThreadId,
|
||||
isForum,
|
||||
historyKey: bodyResult.historyKey,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
route,
|
||||
skillFilter,
|
||||
sendTyping,
|
||||
sendRecordVoice,
|
||||
ackReactionPromise,
|
||||
reactionApi,
|
||||
removeAckAfterReply,
|
||||
statusReactionController,
|
||||
accountId: account.accountId,
|
||||
};
|
||||
};
|
||||
|
||||
export type TelegramMessageContext = NonNullable<
|
||||
Awaited<ReturnType<typeof buildTelegramMessageContext>>
|
||||
>;
|
||||
65
extensions/telegram/src/bot-message-context.types.ts
Normal file
65
extensions/telegram/src/bot-message-context.types.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { Bot } from "grammy";
|
||||
import type { HistoryEntry } from "../../../src/auto-reply/reply/history.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type {
|
||||
DmPolicy,
|
||||
TelegramDirectConfig,
|
||||
TelegramGroupConfig,
|
||||
TelegramTopicConfig,
|
||||
} from "../../../src/config/types.js";
|
||||
import type { StickerMetadata, TelegramContext } from "./bot/types.js";
|
||||
|
||||
export type TelegramMediaRef = {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
stickerMetadata?: StickerMetadata;
|
||||
};
|
||||
|
||||
export type TelegramMessageContextOptions = {
|
||||
forceWasMentioned?: boolean;
|
||||
messageIdOverride?: string;
|
||||
};
|
||||
|
||||
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 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;
|
||||
/** Global (per-account) handler for sendChatAction 401 backoff (#27092). */
|
||||
sendChatActionHandler: import("./sendchataction-401-backoff.js").TelegramSendChatActionHandler;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "node:path";
|
||||
import type { Bot } from "grammy";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { STATE_DIR } from "../config/paths.js";
|
||||
import { STATE_DIR } from "../../../src/config/paths.js";
|
||||
import {
|
||||
createSequencedTestDraftStream,
|
||||
createTestDraftStream,
|
||||
@@ -18,7 +18,7 @@ vi.mock("./draft-stream.js", () => ({
|
||||
createTelegramDraftStream,
|
||||
}));
|
||||
|
||||
vi.mock("../auto-reply/reply/provider-dispatcher.js", () => ({
|
||||
vi.mock("../../../src/auto-reply/reply/provider-dispatcher.js", () => ({
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
}));
|
||||
|
||||
@@ -30,8 +30,8 @@ vi.mock("./send.js", () => ({
|
||||
editMessageTelegram,
|
||||
}));
|
||||
|
||||
vi.mock("../config/sessions.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/sessions.js")>();
|
||||
vi.mock("../../../src/config/sessions.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/config/sessions.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadSessionStore,
|
||||
853
extensions/telegram/src/bot-message-dispatch.ts
Normal file
853
extensions/telegram/src/bot-message-dispatch.ts
Normal file
@@ -0,0 +1,853 @@
|
||||
import type { Bot } from "grammy";
|
||||
import { resolveAgentDir } from "../../../src/agents/agent-scope.js";
|
||||
import {
|
||||
findModelInCatalog,
|
||||
loadModelCatalog,
|
||||
modelSupportsVision,
|
||||
} from "../../../src/agents/model-catalog.js";
|
||||
import { resolveDefaultModelForAgent } from "../../../src/agents/model-selection.js";
|
||||
import { resolveChunkMode } from "../../../src/auto-reply/chunk.js";
|
||||
import { clearHistoryEntriesIfEnabled } from "../../../src/auto-reply/reply/history.js";
|
||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../../../src/auto-reply/reply/provider-dispatcher.js";
|
||||
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
|
||||
import { removeAckReactionAfterReply } from "../../../src/channels/ack-reactions.js";
|
||||
import { logAckFailure, logTypingFailure } from "../../../src/channels/logging.js";
|
||||
import { createReplyPrefixOptions } from "../../../src/channels/reply-prefix.js";
|
||||
import { createTypingCallbacks } from "../../../src/channels/typing.js";
|
||||
import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveSessionStoreEntry,
|
||||
resolveStorePath,
|
||||
} from "../../../src/config/sessions.js";
|
||||
import type {
|
||||
OpenClawConfig,
|
||||
ReplyToMode,
|
||||
TelegramAccountConfig,
|
||||
} from "../../../src/config/types.js";
|
||||
import { danger, logVerbose } from "../../../src/globals.js";
|
||||
import { getAgentScopedMediaLocalRoots } from "../../../src/media/local-roots.js";
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
import type { TelegramMessageContext } from "./bot-message-context.js";
|
||||
import type { TelegramBotOptions } from "./bot.js";
|
||||
import { deliverReplies } from "./bot/delivery.js";
|
||||
import type { TelegramStreamMode } from "./bot/types.js";
|
||||
import type { TelegramInlineButtons } from "./button-types.js";
|
||||
import { createTelegramDraftStream } from "./draft-stream.js";
|
||||
import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js";
|
||||
import { renderTelegramHtmlText } from "./format.js";
|
||||
import {
|
||||
type ArchivedPreview,
|
||||
createLaneDeliveryStateTracker,
|
||||
createLaneTextDeliverer,
|
||||
type DraftLaneState,
|
||||
type LaneName,
|
||||
type LanePreviewLifecycle,
|
||||
} from "./lane-delivery.js";
|
||||
import {
|
||||
createTelegramReasoningStepState,
|
||||
splitTelegramReasoningText,
|
||||
} from "./reasoning-lane-coordinator.js";
|
||||
import { editMessageTelegram } from "./send.js";
|
||||
import { cacheSticker, describeStickerImage } from "./sticker-cache.js";
|
||||
|
||||
const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again.";
|
||||
|
||||
/** Minimum chars before sending first streaming message (improves push notification UX) */
|
||||
const DRAFT_MIN_INITIAL_CHARS = 30;
|
||||
|
||||
async function resolveStickerVisionSupport(cfg: OpenClawConfig, agentId: string) {
|
||||
try {
|
||||
const catalog = await loadModelCatalog({ config: cfg });
|
||||
const defaultModel = resolveDefaultModelForAgent({ cfg, agentId });
|
||||
const entry = findModelInCatalog(catalog, defaultModel.provider, defaultModel.model);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
return modelSupportsVision(entry);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function pruneStickerMediaFromContext(
|
||||
ctxPayload: {
|
||||
MediaPath?: string;
|
||||
MediaUrl?: string;
|
||||
MediaType?: string;
|
||||
MediaPaths?: string[];
|
||||
MediaUrls?: string[];
|
||||
MediaTypes?: string[];
|
||||
},
|
||||
opts?: { stickerMediaIncluded?: boolean },
|
||||
) {
|
||||
if (opts?.stickerMediaIncluded === false) {
|
||||
return;
|
||||
}
|
||||
const nextMediaPaths = Array.isArray(ctxPayload.MediaPaths)
|
||||
? ctxPayload.MediaPaths.slice(1)
|
||||
: undefined;
|
||||
const nextMediaUrls = Array.isArray(ctxPayload.MediaUrls)
|
||||
? ctxPayload.MediaUrls.slice(1)
|
||||
: undefined;
|
||||
const nextMediaTypes = Array.isArray(ctxPayload.MediaTypes)
|
||||
? ctxPayload.MediaTypes.slice(1)
|
||||
: undefined;
|
||||
ctxPayload.MediaPaths = nextMediaPaths && nextMediaPaths.length > 0 ? nextMediaPaths : undefined;
|
||||
ctxPayload.MediaUrls = nextMediaUrls && nextMediaUrls.length > 0 ? nextMediaUrls : undefined;
|
||||
ctxPayload.MediaTypes = nextMediaTypes && nextMediaTypes.length > 0 ? nextMediaTypes : undefined;
|
||||
ctxPayload.MediaPath = ctxPayload.MediaPaths?.[0];
|
||||
ctxPayload.MediaUrl = ctxPayload.MediaUrls?.[0] ?? ctxPayload.MediaPath;
|
||||
ctxPayload.MediaType = ctxPayload.MediaTypes?.[0];
|
||||
}
|
||||
|
||||
type DispatchTelegramMessageParams = {
|
||||
context: TelegramMessageContext;
|
||||
bot: Bot;
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
replyToMode: ReplyToMode;
|
||||
streamMode: TelegramStreamMode;
|
||||
textLimit: number;
|
||||
telegramCfg: TelegramAccountConfig;
|
||||
opts: Pick<TelegramBotOptions, "token">;
|
||||
};
|
||||
|
||||
type TelegramReasoningLevel = "off" | "on" | "stream";
|
||||
|
||||
function resolveTelegramReasoningLevel(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey?: string;
|
||||
agentId: string;
|
||||
}): TelegramReasoningLevel {
|
||||
const { cfg, sessionKey, agentId } = params;
|
||||
if (!sessionKey) {
|
||||
return "off";
|
||||
}
|
||||
try {
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||
const store = loadSessionStore(storePath, { skipCache: true });
|
||||
const entry = resolveSessionStoreEntry({ store, sessionKey }).existing;
|
||||
const level = entry?.reasoningLevel;
|
||||
if (level === "on" || level === "stream") {
|
||||
return level;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to default.
|
||||
}
|
||||
return "off";
|
||||
}
|
||||
|
||||
export const dispatchTelegramMessage = async ({
|
||||
context,
|
||||
bot,
|
||||
cfg,
|
||||
runtime,
|
||||
replyToMode,
|
||||
streamMode,
|
||||
textLimit,
|
||||
telegramCfg,
|
||||
opts,
|
||||
}: DispatchTelegramMessageParams) => {
|
||||
const {
|
||||
ctxPayload,
|
||||
msg,
|
||||
chatId,
|
||||
isGroup,
|
||||
threadSpec,
|
||||
historyKey,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
route,
|
||||
skillFilter,
|
||||
sendTyping,
|
||||
sendRecordVoice,
|
||||
ackReactionPromise,
|
||||
reactionApi,
|
||||
removeAckAfterReply,
|
||||
statusReactionController,
|
||||
} = context;
|
||||
|
||||
const draftMaxChars = Math.min(textLimit, 4096);
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
const renderDraftPreview = (text: string) => ({
|
||||
text: renderTelegramHtmlText(text, { tableMode }),
|
||||
parseMode: "HTML" as const,
|
||||
});
|
||||
const accountBlockStreamingEnabled =
|
||||
typeof telegramCfg.blockStreaming === "boolean"
|
||||
? telegramCfg.blockStreaming
|
||||
: cfg.agents?.defaults?.blockStreamingDefault === "on";
|
||||
const resolvedReasoningLevel = resolveTelegramReasoningLevel({
|
||||
cfg,
|
||||
sessionKey: ctxPayload.SessionKey,
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const forceBlockStreamingForReasoning = resolvedReasoningLevel === "on";
|
||||
const streamReasoningDraft = resolvedReasoningLevel === "stream";
|
||||
const previewStreamingEnabled = streamMode !== "off";
|
||||
const canStreamAnswerDraft =
|
||||
previewStreamingEnabled && !accountBlockStreamingEnabled && !forceBlockStreamingForReasoning;
|
||||
const canStreamReasoningDraft = canStreamAnswerDraft || streamReasoningDraft;
|
||||
const draftReplyToMessageId =
|
||||
replyToMode !== "off" && typeof msg.message_id === "number" ? msg.message_id : undefined;
|
||||
const draftMinInitialChars = DRAFT_MIN_INITIAL_CHARS;
|
||||
// Keep DM preview lanes on real message transport. Native draft previews still
|
||||
// require a draft->message materialize hop, and that overlap keeps reintroducing
|
||||
// a visible duplicate flash at finalize time.
|
||||
const useMessagePreviewTransportForDm = threadSpec?.scope === "dm" && canStreamAnswerDraft;
|
||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
|
||||
const archivedAnswerPreviews: ArchivedPreview[] = [];
|
||||
const archivedReasoningPreviewIds: number[] = [];
|
||||
const createDraftLane = (laneName: LaneName, enabled: boolean): DraftLaneState => {
|
||||
const stream = enabled
|
||||
? createTelegramDraftStream({
|
||||
api: bot.api,
|
||||
chatId,
|
||||
maxChars: draftMaxChars,
|
||||
thread: threadSpec,
|
||||
previewTransport: useMessagePreviewTransportForDm ? "message" : "auto",
|
||||
replyToMessageId: draftReplyToMessageId,
|
||||
minInitialChars: draftMinInitialChars,
|
||||
renderText: renderDraftPreview,
|
||||
onSupersededPreview:
|
||||
laneName === "answer" || laneName === "reasoning"
|
||||
? (preview) => {
|
||||
if (laneName === "reasoning") {
|
||||
if (!archivedReasoningPreviewIds.includes(preview.messageId)) {
|
||||
archivedReasoningPreviewIds.push(preview.messageId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
archivedAnswerPreviews.push({
|
||||
messageId: preview.messageId,
|
||||
textSnapshot: preview.textSnapshot,
|
||||
deleteIfUnused: true,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
log: logVerbose,
|
||||
warn: logVerbose,
|
||||
})
|
||||
: undefined;
|
||||
return {
|
||||
stream,
|
||||
lastPartialText: "",
|
||||
hasStreamedMessage: false,
|
||||
};
|
||||
};
|
||||
const lanes: Record<LaneName, DraftLaneState> = {
|
||||
answer: createDraftLane("answer", canStreamAnswerDraft),
|
||||
reasoning: createDraftLane("reasoning", canStreamReasoningDraft),
|
||||
};
|
||||
// Active preview lifecycle answers "can this current preview still be
|
||||
// finalized?" Cleanup retention is separate so archived-preview decisions do
|
||||
// not poison the active lane.
|
||||
const activePreviewLifecycleByLane: Record<LaneName, LanePreviewLifecycle> = {
|
||||
answer: "transient",
|
||||
reasoning: "transient",
|
||||
};
|
||||
const retainPreviewOnCleanupByLane: Record<LaneName, boolean> = {
|
||||
answer: false,
|
||||
reasoning: false,
|
||||
};
|
||||
const answerLane = lanes.answer;
|
||||
const reasoningLane = lanes.reasoning;
|
||||
let splitReasoningOnNextStream = false;
|
||||
let skipNextAnswerMessageStartRotation = false;
|
||||
let draftLaneEventQueue = Promise.resolve();
|
||||
const reasoningStepState = createTelegramReasoningStepState();
|
||||
const enqueueDraftLaneEvent = (task: () => Promise<void>): Promise<void> => {
|
||||
const next = draftLaneEventQueue.then(task);
|
||||
draftLaneEventQueue = next.catch((err) => {
|
||||
logVerbose(`telegram: draft lane callback failed: ${String(err)}`);
|
||||
});
|
||||
return draftLaneEventQueue;
|
||||
};
|
||||
type SplitLaneSegment = { lane: LaneName; text: string };
|
||||
type SplitLaneSegmentsResult = {
|
||||
segments: SplitLaneSegment[];
|
||||
suppressedReasoningOnly: boolean;
|
||||
};
|
||||
const splitTextIntoLaneSegments = (text?: string): SplitLaneSegmentsResult => {
|
||||
const split = splitTelegramReasoningText(text);
|
||||
const segments: SplitLaneSegment[] = [];
|
||||
const suppressReasoning = resolvedReasoningLevel === "off";
|
||||
if (split.reasoningText && !suppressReasoning) {
|
||||
segments.push({ lane: "reasoning", text: split.reasoningText });
|
||||
}
|
||||
if (split.answerText) {
|
||||
segments.push({ lane: "answer", text: split.answerText });
|
||||
}
|
||||
return {
|
||||
segments,
|
||||
suppressedReasoningOnly:
|
||||
Boolean(split.reasoningText) && suppressReasoning && !split.answerText,
|
||||
};
|
||||
};
|
||||
const resetDraftLaneState = (lane: DraftLaneState) => {
|
||||
lane.lastPartialText = "";
|
||||
lane.hasStreamedMessage = false;
|
||||
};
|
||||
const rotateAnswerLaneForNewAssistantMessage = async () => {
|
||||
let didForceNewMessage = false;
|
||||
if (answerLane.hasStreamedMessage) {
|
||||
// Materialize the current streamed draft into a permanent message
|
||||
// so it remains visible across tool boundaries.
|
||||
const materializedId = await answerLane.stream?.materialize?.();
|
||||
const previewMessageId = materializedId ?? answerLane.stream?.messageId();
|
||||
if (
|
||||
typeof previewMessageId === "number" &&
|
||||
activePreviewLifecycleByLane.answer === "transient"
|
||||
) {
|
||||
archivedAnswerPreviews.push({
|
||||
messageId: previewMessageId,
|
||||
textSnapshot: answerLane.lastPartialText,
|
||||
deleteIfUnused: false,
|
||||
});
|
||||
}
|
||||
answerLane.stream?.forceNewMessage();
|
||||
didForceNewMessage = true;
|
||||
}
|
||||
resetDraftLaneState(answerLane);
|
||||
if (didForceNewMessage) {
|
||||
// New assistant message boundary: this lane now tracks a fresh preview lifecycle.
|
||||
activePreviewLifecycleByLane.answer = "transient";
|
||||
retainPreviewOnCleanupByLane.answer = false;
|
||||
}
|
||||
return didForceNewMessage;
|
||||
};
|
||||
const updateDraftFromPartial = (lane: DraftLaneState, text: string | undefined) => {
|
||||
const laneStream = lane.stream;
|
||||
if (!laneStream || !text) {
|
||||
return;
|
||||
}
|
||||
if (text === lane.lastPartialText) {
|
||||
return;
|
||||
}
|
||||
// Mark that we've received streaming content (for forceNewMessage decision).
|
||||
lane.hasStreamedMessage = true;
|
||||
// Some providers briefly emit a shorter prefix snapshot (for example
|
||||
// "Sure." -> "Sure" -> "Sure."). Keep the longer preview to avoid
|
||||
// visible punctuation flicker.
|
||||
if (
|
||||
lane.lastPartialText &&
|
||||
lane.lastPartialText.startsWith(text) &&
|
||||
text.length < lane.lastPartialText.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lane.lastPartialText = text;
|
||||
laneStream.update(text);
|
||||
};
|
||||
const ingestDraftLaneSegments = async (text: string | undefined) => {
|
||||
const split = splitTextIntoLaneSegments(text);
|
||||
const hasAnswerSegment = split.segments.some((segment) => segment.lane === "answer");
|
||||
if (hasAnswerSegment && activePreviewLifecycleByLane.answer !== "transient") {
|
||||
// Some providers can emit the first partial of a new assistant message before
|
||||
// onAssistantMessageStart() arrives. Rotate preemptively so we do not edit
|
||||
// the previously finalized preview message with the next message's text.
|
||||
skipNextAnswerMessageStartRotation = await rotateAnswerLaneForNewAssistantMessage();
|
||||
}
|
||||
for (const segment of split.segments) {
|
||||
if (segment.lane === "reasoning") {
|
||||
reasoningStepState.noteReasoningHint();
|
||||
reasoningStepState.noteReasoningDelivered();
|
||||
}
|
||||
updateDraftFromPartial(lanes[segment.lane], segment.text);
|
||||
}
|
||||
};
|
||||
const flushDraftLane = async (lane: DraftLaneState) => {
|
||||
if (!lane.stream) {
|
||||
return;
|
||||
}
|
||||
await lane.stream.flush();
|
||||
};
|
||||
|
||||
const disableBlockStreaming = !previewStreamingEnabled
|
||||
? true
|
||||
: forceBlockStreamingForReasoning
|
||||
? false
|
||||
: typeof telegramCfg.blockStreaming === "boolean"
|
||||
? !telegramCfg.blockStreaming
|
||||
: canStreamAnswerDraft
|
||||
? true
|
||||
: undefined;
|
||||
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
channel: "telegram",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId);
|
||||
|
||||
// Handle uncached stickers: get a dedicated vision description before dispatch
|
||||
// This ensures we cache a raw description rather than a conversational response
|
||||
const sticker = ctxPayload.Sticker;
|
||||
if (sticker?.fileId && sticker.fileUniqueId && ctxPayload.MediaPath) {
|
||||
const agentDir = resolveAgentDir(cfg, route.agentId);
|
||||
const stickerSupportsVision = await resolveStickerVisionSupport(cfg, route.agentId);
|
||||
let description = sticker.cachedDescription ?? null;
|
||||
if (!description) {
|
||||
description = await describeStickerImage({
|
||||
imagePath: ctxPayload.MediaPath,
|
||||
cfg,
|
||||
agentDir,
|
||||
agentId: route.agentId,
|
||||
});
|
||||
}
|
||||
if (description) {
|
||||
// Format the description with sticker context
|
||||
const stickerContext = [sticker.emoji, sticker.setName ? `from "${sticker.setName}"` : null]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const formattedDesc = `[Sticker${stickerContext ? ` ${stickerContext}` : ""}] ${description}`;
|
||||
|
||||
sticker.cachedDescription = description;
|
||||
if (!stickerSupportsVision) {
|
||||
// Update context to use description instead of image
|
||||
ctxPayload.Body = formattedDesc;
|
||||
ctxPayload.BodyForAgent = formattedDesc;
|
||||
// Drop only the sticker attachment; keep replied media context if present.
|
||||
pruneStickerMediaFromContext(ctxPayload, {
|
||||
stickerMediaIncluded: ctxPayload.StickerMediaIncluded,
|
||||
});
|
||||
}
|
||||
|
||||
// Cache the description for future encounters
|
||||
if (sticker.fileId) {
|
||||
cacheSticker({
|
||||
fileId: sticker.fileId,
|
||||
fileUniqueId: sticker.fileUniqueId,
|
||||
emoji: sticker.emoji,
|
||||
setName: sticker.setName,
|
||||
description,
|
||||
cachedAt: new Date().toISOString(),
|
||||
receivedFrom: ctxPayload.From,
|
||||
});
|
||||
logVerbose(`telegram: cached sticker description for ${sticker.fileUniqueId}`);
|
||||
} else {
|
||||
logVerbose(`telegram: skipped sticker cache (missing fileId)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const replyQuoteText =
|
||||
ctxPayload.ReplyToIsQuote && ctxPayload.ReplyToBody
|
||||
? ctxPayload.ReplyToBody.trim() || undefined
|
||||
: undefined;
|
||||
const deliveryState = createLaneDeliveryStateTracker();
|
||||
const clearGroupHistory = () => {
|
||||
if (isGroup && historyKey) {
|
||||
clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit });
|
||||
}
|
||||
};
|
||||
const deliveryBaseOptions = {
|
||||
chatId: String(chatId),
|
||||
accountId: route.accountId,
|
||||
sessionKeyForInternalHooks: ctxPayload.SessionKey,
|
||||
mirrorIsGroup: isGroup,
|
||||
mirrorGroupId: isGroup ? String(chatId) : undefined,
|
||||
token: opts.token,
|
||||
runtime,
|
||||
bot,
|
||||
mediaLocalRoots,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
thread: threadSpec,
|
||||
tableMode,
|
||||
chunkMode,
|
||||
linkPreview: telegramCfg.linkPreview,
|
||||
replyQuoteText,
|
||||
};
|
||||
const applyTextToPayload = (payload: ReplyPayload, text: string): ReplyPayload => {
|
||||
if (payload.text === text) {
|
||||
return payload;
|
||||
}
|
||||
return { ...payload, text };
|
||||
};
|
||||
const sendPayload = async (payload: ReplyPayload) => {
|
||||
const result = await deliverReplies({
|
||||
...deliveryBaseOptions,
|
||||
replies: [payload],
|
||||
onVoiceRecording: sendRecordVoice,
|
||||
});
|
||||
if (result.delivered) {
|
||||
deliveryState.markDelivered();
|
||||
}
|
||||
return result.delivered;
|
||||
};
|
||||
const deliverLaneText = createLaneTextDeliverer({
|
||||
lanes,
|
||||
archivedAnswerPreviews,
|
||||
activePreviewLifecycleByLane,
|
||||
retainPreviewOnCleanupByLane,
|
||||
draftMaxChars,
|
||||
applyTextToPayload,
|
||||
sendPayload,
|
||||
flushDraftLane,
|
||||
stopDraftLane: async (lane) => {
|
||||
await lane.stream?.stop();
|
||||
},
|
||||
editPreview: async ({ messageId, text, previewButtons }) => {
|
||||
await editMessageTelegram(chatId, messageId, text, {
|
||||
api: bot.api,
|
||||
cfg,
|
||||
accountId: route.accountId,
|
||||
linkPreview: telegramCfg.linkPreview,
|
||||
buttons: previewButtons,
|
||||
});
|
||||
},
|
||||
deletePreviewMessage: async (messageId) => {
|
||||
await bot.api.deleteMessage(chatId, messageId);
|
||||
},
|
||||
log: logVerbose,
|
||||
markDelivered: () => {
|
||||
deliveryState.markDelivered();
|
||||
},
|
||||
});
|
||||
|
||||
let queuedFinal = false;
|
||||
|
||||
if (statusReactionController) {
|
||||
void statusReactionController.setThinking();
|
||||
}
|
||||
|
||||
const typingCallbacks = createTypingCallbacks({
|
||||
start: sendTyping,
|
||||
onStartError: (err) => {
|
||||
logTypingFailure({
|
||||
log: logVerbose,
|
||||
channel: "telegram",
|
||||
target: String(chatId),
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
let dispatchError: unknown;
|
||||
try {
|
||||
({ queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcherOptions: {
|
||||
...prefixOptions,
|
||||
typingCallbacks,
|
||||
deliver: async (payload, info) => {
|
||||
if (info.kind === "final") {
|
||||
// Assistant callbacks are fire-and-forget; ensure queued boundary
|
||||
// rotations/partials are applied before final delivery mapping.
|
||||
await enqueueDraftLaneEvent(async () => {});
|
||||
}
|
||||
if (
|
||||
shouldSuppressLocalTelegramExecApprovalPrompt({
|
||||
cfg,
|
||||
accountId: route.accountId,
|
||||
payload,
|
||||
})
|
||||
) {
|
||||
queuedFinal = true;
|
||||
return;
|
||||
}
|
||||
const previewButtons = (
|
||||
payload.channelData?.telegram as { buttons?: TelegramInlineButtons } | undefined
|
||||
)?.buttons;
|
||||
const split = splitTextIntoLaneSegments(payload.text);
|
||||
const segments = split.segments;
|
||||
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
|
||||
const flushBufferedFinalAnswer = async () => {
|
||||
const buffered = reasoningStepState.takeBufferedFinalAnswer();
|
||||
if (!buffered) {
|
||||
return;
|
||||
}
|
||||
const bufferedButtons = (
|
||||
buffered.payload.channelData?.telegram as
|
||||
| { buttons?: TelegramInlineButtons }
|
||||
| undefined
|
||||
)?.buttons;
|
||||
await deliverLaneText({
|
||||
laneName: "answer",
|
||||
text: buffered.text,
|
||||
payload: buffered.payload,
|
||||
infoKind: "final",
|
||||
previewButtons: bufferedButtons,
|
||||
});
|
||||
reasoningStepState.resetForNextStep();
|
||||
};
|
||||
|
||||
for (const segment of segments) {
|
||||
if (
|
||||
segment.lane === "answer" &&
|
||||
info.kind === "final" &&
|
||||
reasoningStepState.shouldBufferFinalAnswer()
|
||||
) {
|
||||
reasoningStepState.bufferFinalAnswer({
|
||||
payload,
|
||||
text: segment.text,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (segment.lane === "reasoning") {
|
||||
reasoningStepState.noteReasoningHint();
|
||||
}
|
||||
const result = await deliverLaneText({
|
||||
laneName: segment.lane,
|
||||
text: segment.text,
|
||||
payload,
|
||||
infoKind: info.kind,
|
||||
previewButtons,
|
||||
allowPreviewUpdateForNonFinal: segment.lane === "reasoning",
|
||||
});
|
||||
if (segment.lane === "reasoning") {
|
||||
if (result !== "skipped") {
|
||||
reasoningStepState.noteReasoningDelivered();
|
||||
await flushBufferedFinalAnswer();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (info.kind === "final") {
|
||||
if (reasoningLane.hasStreamedMessage) {
|
||||
activePreviewLifecycleByLane.reasoning = "complete";
|
||||
retainPreviewOnCleanupByLane.reasoning = true;
|
||||
}
|
||||
reasoningStepState.resetForNextStep();
|
||||
}
|
||||
}
|
||||
if (segments.length > 0) {
|
||||
return;
|
||||
}
|
||||
if (split.suppressedReasoningOnly) {
|
||||
if (hasMedia) {
|
||||
const payloadWithoutSuppressedReasoning =
|
||||
typeof payload.text === "string" ? { ...payload, text: "" } : payload;
|
||||
await sendPayload(payloadWithoutSuppressedReasoning);
|
||||
}
|
||||
if (info.kind === "final") {
|
||||
await flushBufferedFinalAnswer();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (info.kind === "final") {
|
||||
await answerLane.stream?.stop();
|
||||
await reasoningLane.stream?.stop();
|
||||
reasoningStepState.resetForNextStep();
|
||||
}
|
||||
const canSendAsIs =
|
||||
hasMedia || (typeof payload.text === "string" && payload.text.length > 0);
|
||||
if (!canSendAsIs) {
|
||||
if (info.kind === "final") {
|
||||
await flushBufferedFinalAnswer();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await sendPayload(payload);
|
||||
if (info.kind === "final") {
|
||||
await flushBufferedFinalAnswer();
|
||||
}
|
||||
},
|
||||
onSkip: (_payload, info) => {
|
||||
if (info.reason !== "silent") {
|
||||
deliveryState.markNonSilentSkip();
|
||||
}
|
||||
},
|
||||
onError: (err, info) => {
|
||||
deliveryState.markNonSilentFailure();
|
||||
runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`));
|
||||
},
|
||||
},
|
||||
replyOptions: {
|
||||
skillFilter,
|
||||
disableBlockStreaming,
|
||||
onPartialReply:
|
||||
answerLane.stream || reasoningLane.stream
|
||||
? (payload) =>
|
||||
enqueueDraftLaneEvent(async () => {
|
||||
await ingestDraftLaneSegments(payload.text);
|
||||
})
|
||||
: undefined,
|
||||
onReasoningStream: reasoningLane.stream
|
||||
? (payload) =>
|
||||
enqueueDraftLaneEvent(async () => {
|
||||
// Split between reasoning blocks only when the next reasoning
|
||||
// stream starts. Splitting at reasoning-end can orphan the active
|
||||
// preview and cause duplicate reasoning sends on reasoning final.
|
||||
if (splitReasoningOnNextStream) {
|
||||
reasoningLane.stream?.forceNewMessage();
|
||||
resetDraftLaneState(reasoningLane);
|
||||
splitReasoningOnNextStream = false;
|
||||
}
|
||||
await ingestDraftLaneSegments(payload.text);
|
||||
})
|
||||
: undefined,
|
||||
onAssistantMessageStart: answerLane.stream
|
||||
? () =>
|
||||
enqueueDraftLaneEvent(async () => {
|
||||
reasoningStepState.resetForNextStep();
|
||||
if (skipNextAnswerMessageStartRotation) {
|
||||
skipNextAnswerMessageStartRotation = false;
|
||||
activePreviewLifecycleByLane.answer = "transient";
|
||||
retainPreviewOnCleanupByLane.answer = false;
|
||||
return;
|
||||
}
|
||||
await rotateAnswerLaneForNewAssistantMessage();
|
||||
// Message-start is an explicit assistant-message boundary.
|
||||
// Even when no forceNewMessage happened (e.g. prior answer had no
|
||||
// streamed partials), the next partial belongs to a fresh lifecycle
|
||||
// and must not trigger late pre-rotation mid-message.
|
||||
activePreviewLifecycleByLane.answer = "transient";
|
||||
retainPreviewOnCleanupByLane.answer = false;
|
||||
})
|
||||
: undefined,
|
||||
onReasoningEnd: reasoningLane.stream
|
||||
? () =>
|
||||
enqueueDraftLaneEvent(async () => {
|
||||
// Split when/if a later reasoning block begins.
|
||||
splitReasoningOnNextStream = reasoningLane.hasStreamedMessage;
|
||||
})
|
||||
: undefined,
|
||||
onToolStart: statusReactionController
|
||||
? async (payload) => {
|
||||
await statusReactionController.setTool(payload.name);
|
||||
}
|
||||
: undefined,
|
||||
onCompactionStart: statusReactionController
|
||||
? () => statusReactionController.setCompacting()
|
||||
: undefined,
|
||||
onCompactionEnd: statusReactionController
|
||||
? async () => {
|
||||
statusReactionController.cancelPending();
|
||||
await statusReactionController.setThinking();
|
||||
}
|
||||
: undefined,
|
||||
onModelSelected,
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
dispatchError = err;
|
||||
runtime.error?.(danger(`telegram dispatch failed: ${String(err)}`));
|
||||
} finally {
|
||||
// Upstream assistant callbacks are fire-and-forget; drain queued lane work
|
||||
// before stream cleanup so boundary rotations/materialization complete first.
|
||||
await draftLaneEventQueue;
|
||||
// Must stop() first to flush debounced content before clear() wipes state.
|
||||
const streamCleanupStates = new Map<
|
||||
NonNullable<DraftLaneState["stream"]>,
|
||||
{ shouldClear: boolean }
|
||||
>();
|
||||
const lanesToCleanup: Array<{ laneName: LaneName; lane: DraftLaneState }> = [
|
||||
{ laneName: "answer", lane: answerLane },
|
||||
{ laneName: "reasoning", lane: reasoningLane },
|
||||
];
|
||||
for (const laneState of lanesToCleanup) {
|
||||
const stream = laneState.lane.stream;
|
||||
if (!stream) {
|
||||
continue;
|
||||
}
|
||||
// Don't clear (delete) the stream if: (a) it was finalized, or
|
||||
// (b) the active stream message is itself a boundary-finalized archive.
|
||||
const activePreviewMessageId = stream.messageId();
|
||||
const hasBoundaryFinalizedActivePreview =
|
||||
laneState.laneName === "answer" &&
|
||||
typeof activePreviewMessageId === "number" &&
|
||||
archivedAnswerPreviews.some(
|
||||
(p) => p.deleteIfUnused === false && p.messageId === activePreviewMessageId,
|
||||
);
|
||||
const shouldClear =
|
||||
!retainPreviewOnCleanupByLane[laneState.laneName] && !hasBoundaryFinalizedActivePreview;
|
||||
const existing = streamCleanupStates.get(stream);
|
||||
if (!existing) {
|
||||
streamCleanupStates.set(stream, { shouldClear });
|
||||
continue;
|
||||
}
|
||||
existing.shouldClear = existing.shouldClear && shouldClear;
|
||||
}
|
||||
for (const [stream, cleanupState] of streamCleanupStates) {
|
||||
await stream.stop();
|
||||
if (cleanupState.shouldClear) {
|
||||
await stream.clear();
|
||||
}
|
||||
}
|
||||
for (const archivedPreview of archivedAnswerPreviews) {
|
||||
if (archivedPreview.deleteIfUnused === false) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await bot.api.deleteMessage(chatId, archivedPreview.messageId);
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`telegram: archived answer preview cleanup failed (${archivedPreview.messageId}): ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const messageId of archivedReasoningPreviewIds) {
|
||||
try {
|
||||
await bot.api.deleteMessage(chatId, messageId);
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`telegram: archived reasoning preview cleanup failed (${messageId}): ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let sentFallback = false;
|
||||
const deliverySummary = deliveryState.snapshot();
|
||||
if (
|
||||
dispatchError ||
|
||||
(!deliverySummary.delivered &&
|
||||
(deliverySummary.skippedNonSilent > 0 || deliverySummary.failedNonSilent > 0))
|
||||
) {
|
||||
const fallbackText = dispatchError
|
||||
? "Something went wrong while processing your request. Please try again."
|
||||
: EMPTY_RESPONSE_FALLBACK;
|
||||
const result = await deliverReplies({
|
||||
replies: [{ text: fallbackText }],
|
||||
...deliveryBaseOptions,
|
||||
});
|
||||
sentFallback = result.delivered;
|
||||
}
|
||||
|
||||
const hasFinalResponse = queuedFinal || sentFallback;
|
||||
|
||||
if (statusReactionController && !hasFinalResponse) {
|
||||
void statusReactionController.setError().catch((err) => {
|
||||
logVerbose(`telegram: status reaction error finalize failed: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasFinalResponse) {
|
||||
clearGroupHistory();
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusReactionController) {
|
||||
void statusReactionController.setDone().catch((err) => {
|
||||
logVerbose(`telegram: status reaction finalize failed: ${String(err)}`);
|
||||
});
|
||||
} else {
|
||||
removeAckReactionAfterReply({
|
||||
removeAfterReply: removeAckAfterReply,
|
||||
ackReactionPromise,
|
||||
ackReactionValue: ackReactionPromise ? "ack" : null,
|
||||
remove: () => reactionApi?.(chatId, msg.message_id ?? 0, []) ?? Promise.resolve(),
|
||||
onError: (err) => {
|
||||
if (!msg.message_id) {
|
||||
return;
|
||||
}
|
||||
logAckFailure({
|
||||
log: logVerbose,
|
||||
channel: "telegram",
|
||||
target: `${chatId}/${msg.message_id}`,
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
clearGroupHistory();
|
||||
};
|
||||
107
extensions/telegram/src/bot-message.ts
Normal file
107
extensions/telegram/src/bot-message.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { ReplyToMode } from "../../../src/config/config.js";
|
||||
import type { TelegramAccountConfig } from "../../../src/config/types.telegram.js";
|
||||
import { danger } from "../../../src/globals.js";
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
import {
|
||||
buildTelegramMessageContext,
|
||||
type BuildTelegramMessageContextParams,
|
||||
type TelegramMediaRef,
|
||||
} from "./bot-message-context.js";
|
||||
import { dispatchTelegramMessage } from "./bot-message-dispatch.js";
|
||||
import type { TelegramBotOptions } from "./bot.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;
|
||||
opts: Pick<TelegramBotOptions, "token">;
|
||||
};
|
||||
|
||||
export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDeps) => {
|
||||
const {
|
||||
bot,
|
||||
cfg,
|
||||
account,
|
||||
telegramCfg,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
dmPolicy,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
ackReactionScope,
|
||||
logger,
|
||||
resolveGroupActivation,
|
||||
resolveGroupRequireMention,
|
||||
resolveTelegramGroupConfig,
|
||||
sendChatActionHandler,
|
||||
runtime,
|
||||
replyToMode,
|
||||
streamMode,
|
||||
textLimit,
|
||||
opts,
|
||||
} = deps;
|
||||
|
||||
return async (
|
||||
primaryCtx: TelegramContext,
|
||||
allMedia: TelegramMediaRef[],
|
||||
storeAllowFrom: string[],
|
||||
options?: { messageIdOverride?: string; forceWasMentioned?: boolean },
|
||||
replyMedia?: TelegramMediaRef[],
|
||||
) => {
|
||||
const context = await buildTelegramMessageContext({
|
||||
primaryCtx,
|
||||
allMedia,
|
||||
replyMedia,
|
||||
storeAllowFrom,
|
||||
options,
|
||||
bot,
|
||||
cfg,
|
||||
account,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
dmPolicy,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
ackReactionScope,
|
||||
logger,
|
||||
resolveGroupActivation,
|
||||
resolveGroupRequireMention,
|
||||
resolveTelegramGroupConfig,
|
||||
sendChatActionHandler,
|
||||
});
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await dispatchTelegramMessage({
|
||||
context,
|
||||
bot,
|
||||
cfg,
|
||||
runtime,
|
||||
replyToMode,
|
||||
streamMode,
|
||||
textLimit,
|
||||
telegramCfg,
|
||||
opts,
|
||||
});
|
||||
} 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.",
|
||||
context.threadSpec?.id != null ? { message_thread_id: context.threadSpec.id } : undefined,
|
||||
);
|
||||
} catch {
|
||||
// Best-effort fallback; delivery may fail if the bot was blocked or the chat is invalid.
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
254
extensions/telegram/src/bot-native-command-menu.ts
Normal file
254
extensions/telegram/src/bot-native-command-menu.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Bot } from "grammy";
|
||||
import { resolveStateDir } from "../../../src/config/paths.js";
|
||||
import {
|
||||
normalizeTelegramCommandName,
|
||||
TELEGRAM_COMMAND_NAME_PATTERN,
|
||||
} from "../../../src/config/telegram-custom-commands.js";
|
||||
import { logVerbose } from "../../../src/globals.js";
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
||||
|
||||
export const TELEGRAM_MAX_COMMANDS = 100;
|
||||
const TELEGRAM_COMMAND_RETRY_RATIO = 0.8;
|
||||
|
||||
export type TelegramMenuCommand = {
|
||||
command: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type TelegramPluginCommandSpec = {
|
||||
name: unknown;
|
||||
description: unknown;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (typeof err === "object") {
|
||||
const maybe = err as { description?: unknown; message?: unknown };
|
||||
if (typeof maybe.description === "string" && pattern.test(maybe.description)) {
|
||||
return true;
|
||||
}
|
||||
if (typeof maybe.message === "string" && pattern.test(maybe.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 = typeof spec.description === "string" ? spec.description.trim() : "";
|
||||
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;
|
||||
}): {
|
||||
commandsToRegister: TelegramMenuCommand[];
|
||||
totalCommands: number;
|
||||
maxCommands: number;
|
||||
overflowCount: number;
|
||||
} {
|
||||
const { allCommands } = params;
|
||||
const maxCommands = params.maxCommands ?? TELEGRAM_MAX_COMMANDS;
|
||||
const totalCommands = allCommands.length;
|
||||
const overflowCount = Math.max(0, totalCommands - maxCommands);
|
||||
const commandsToRegister = allCommands.slice(0, maxCommands);
|
||||
return { commandsToRegister, totalCommands, maxCommands, overflowCount };
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
function hashBotIdentity(botIdentity?: string): string {
|
||||
const normalized = botIdentity?.trim();
|
||||
if (!normalized) {
|
||||
return "no-bot";
|
||||
}
|
||||
return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function resolveCommandHashPath(accountId?: string, botIdentity?: string): string {
|
||||
const stateDir = resolveStateDir(process.env, os.homedir);
|
||||
const normalizedAccount = accountId?.trim().replace(/[^a-z0-9._-]+/gi, "_") || "default";
|
||||
const botHash = hashBotIdentity(botIdentity);
|
||||
return path.join(stateDir, "telegram", `command-hash-${normalizedAccount}-${botHash}.txt`);
|
||||
}
|
||||
|
||||
async function readCachedCommandHash(
|
||||
accountId?: string,
|
||||
botIdentity?: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
return (await fs.readFile(resolveCommandHashPath(accountId, botIdentity), "utf-8")).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCachedCommandHash(
|
||||
accountId: string | undefined,
|
||||
botIdentity: string | undefined,
|
||||
hash: string,
|
||||
): Promise<void> {
|
||||
const filePath = resolveCommandHashPath(accountId, botIdentity);
|
||||
try {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, hash, "utf-8");
|
||||
} catch {
|
||||
// Best-effort: failing to cache the hash just means the next restart
|
||||
// will sync commands again, which is the pre-fix behaviour.
|
||||
}
|
||||
}
|
||||
|
||||
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 = await 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;
|
||||
}
|
||||
await 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await 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)}`);
|
||||
});
|
||||
}
|
||||
194
extensions/telegram/src/bot-native-commands.group-auth.test.ts
Normal file
194
extensions/telegram/src/bot-native-commands.group-auth.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { ChannelGroupPolicy } from "../../../src/config/group-policy.js";
|
||||
import type { TelegramAccountConfig } from "../../../src/config/types.js";
|
||||
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: {
|
||||
commands: {
|
||||
allowFrom: {
|
||||
telegram: ["12345"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
telegramCfg: {
|
||||
groupPolicy: "disabled",
|
||||
} as TelegramAccountConfig,
|
||||
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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { TelegramAccountConfig } from "../config/types.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { TelegramAccountConfig } from "../../../src/config/types.js";
|
||||
import {
|
||||
createNativeCommandsHarness,
|
||||
deliverReplies,
|
||||
@@ -11,17 +11,19 @@ import {
|
||||
|
||||
type GetPluginCommandSpecsMock = {
|
||||
mockReturnValue: (
|
||||
value: ReturnType<typeof import("../plugins/commands.js").getPluginCommandSpecs>,
|
||||
value: ReturnType<typeof import("../../../src/plugins/commands.js").getPluginCommandSpecs>,
|
||||
) => unknown;
|
||||
};
|
||||
type MatchPluginCommandMock = {
|
||||
mockReturnValue: (
|
||||
value: ReturnType<typeof import("../plugins/commands.js").matchPluginCommand>,
|
||||
value: ReturnType<typeof import("../../../src/plugins/commands.js").matchPluginCommand>,
|
||||
) => unknown;
|
||||
};
|
||||
type ExecutePluginCommandMock = {
|
||||
mockResolvedValue: (
|
||||
value: Awaited<ReturnType<typeof import("../plugins/commands.js").executePluginCommand>>,
|
||||
value: Awaited<
|
||||
ReturnType<typeof import("../../../src/plugins/commands.js").executePluginCommand>
|
||||
>,
|
||||
) => unknown;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import {
|
||||
registerTelegramNativeCommands,
|
||||
type RegisterTelegramHandlerParams,
|
||||
@@ -10,11 +10,11 @@ type RegisterTelegramNativeCommandsParams = Parameters<typeof registerTelegramNa
|
||||
// All mocks scoped to this file only — does not affect bot-native-commands.test.ts
|
||||
|
||||
type ResolveConfiguredAcpBindingRecordFn =
|
||||
typeof import("../acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord;
|
||||
typeof import("../../../src/acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord;
|
||||
type EnsureConfiguredAcpBindingSessionFn =
|
||||
typeof import("../acp/persistent-bindings.js").ensureConfiguredAcpBindingSession;
|
||||
typeof import("../../../src/acp/persistent-bindings.js").ensureConfiguredAcpBindingSession;
|
||||
type DispatchReplyWithBufferedBlockDispatcherFn =
|
||||
typeof import("../auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher;
|
||||
typeof import("../../../src/auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher;
|
||||
type DispatchReplyWithBufferedBlockDispatcherParams =
|
||||
Parameters<DispatchReplyWithBufferedBlockDispatcherFn>[0];
|
||||
type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
|
||||
@@ -54,31 +54,31 @@ const sessionBindingMocks = vi.hoisted(() => ({
|
||||
touch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../acp/persistent-bindings.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../acp/persistent-bindings.js")>();
|
||||
vi.mock("../../../src/acp/persistent-bindings.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/acp/persistent-bindings.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveConfiguredAcpBindingRecord: persistentBindingMocks.resolveConfiguredAcpBindingRecord,
|
||||
ensureConfiguredAcpBindingSession: persistentBindingMocks.ensureConfiguredAcpBindingSession,
|
||||
};
|
||||
});
|
||||
vi.mock("../config/sessions.js", () => ({
|
||||
vi.mock("../../../src/config/sessions.js", () => ({
|
||||
recordSessionMetaFromInbound: sessionMocks.recordSessionMetaFromInbound,
|
||||
resolveStorePath: sessionMocks.resolveStorePath,
|
||||
}));
|
||||
vi.mock("../pairing/pairing-store.js", () => ({
|
||||
vi.mock("../../../src/pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: vi.fn(async () => []),
|
||||
}));
|
||||
vi.mock("../auto-reply/reply/inbound-context.js", () => ({
|
||||
vi.mock("../../../src/auto-reply/reply/inbound-context.js", () => ({
|
||||
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
|
||||
}));
|
||||
vi.mock("../auto-reply/reply/provider-dispatcher.js", () => ({
|
||||
vi.mock("../../../src/auto-reply/reply/provider-dispatcher.js", () => ({
|
||||
dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher,
|
||||
}));
|
||||
vi.mock("../channels/reply-prefix.js", () => ({
|
||||
vi.mock("../../../src/channels/reply-prefix.js", () => ({
|
||||
createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })),
|
||||
}));
|
||||
vi.mock("../infra/outbound/session-binding-service.js", () => ({
|
||||
vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({
|
||||
getSessionBindingService: () => ({
|
||||
bind: vi.fn(),
|
||||
getCapabilities: vi.fn(),
|
||||
@@ -88,11 +88,11 @@ vi.mock("../infra/outbound/session-binding-service.js", () => ({
|
||||
unbind: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("../auto-reply/skill-commands.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../auto-reply/skill-commands.js")>();
|
||||
vi.mock("../../../src/auto-reply/skill-commands.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/auto-reply/skill-commands.js")>();
|
||||
return { ...actual, listSkillCommandsForAgents: vi.fn(() => []) };
|
||||
});
|
||||
vi.mock("../plugins/commands.js", () => ({
|
||||
vi.mock("../../../src/plugins/commands.js", () => ({
|
||||
getPluginCommandSpecs: vi.fn(() => []),
|
||||
matchPluginCommand: vi.fn(() => null),
|
||||
executePluginCommand: vi.fn(async () => ({ text: "ok" })),
|
||||
@@ -300,7 +300,7 @@ function createConfiguredAcpTopicBinding(boundSessionKey: string) {
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
},
|
||||
} satisfies import("../acp/persistent-bindings.js").ResolvedConfiguredAcpBinding;
|
||||
} satisfies import("../../../src/acp/persistent-bindings.js").ResolvedConfiguredAcpBinding;
|
||||
}
|
||||
|
||||
function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType<typeof vi.fn>) {
|
||||
@@ -2,9 +2,9 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { writeSkill } from "../agents/skills.e2e-test-helpers.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { TelegramAccountConfig } from "../config/types.js";
|
||||
import { writeSkill } from "../../../src/agents/skills.e2e-test-helpers.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { TelegramAccountConfig } from "../../../src/config/types.js";
|
||||
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
|
||||
|
||||
const pluginCommandMocks = vi.hoisted(() => ({
|
||||
@@ -16,7 +16,7 @@ const deliveryMocks = vi.hoisted(() => ({
|
||||
deliverReplies: vi.fn(async () => ({ delivered: true })),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/commands.js", () => ({
|
||||
vi.mock("../../../src/plugins/commands.js", () => ({
|
||||
getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs,
|
||||
matchPluginCommand: pluginCommandMocks.matchPluginCommand,
|
||||
executePluginCommand: pluginCommandMocks.executePluginCommand,
|
||||
@@ -1,15 +1,17 @@
|
||||
import { vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { ChannelGroupPolicy } from "../config/group-policy.js";
|
||||
import type { TelegramAccountConfig } from "../config/types.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { ChannelGroupPolicy } from "../../../src/config/group-policy.js";
|
||||
import type { TelegramAccountConfig } from "../../../src/config/types.js";
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js";
|
||||
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
|
||||
|
||||
type RegisterTelegramNativeCommandsParams = Parameters<typeof registerTelegramNativeCommands>[0];
|
||||
type GetPluginCommandSpecsFn = typeof import("../plugins/commands.js").getPluginCommandSpecs;
|
||||
type MatchPluginCommandFn = typeof import("../plugins/commands.js").matchPluginCommand;
|
||||
type ExecutePluginCommandFn = typeof import("../plugins/commands.js").executePluginCommand;
|
||||
type GetPluginCommandSpecsFn =
|
||||
typeof import("../../../src/plugins/commands.js").getPluginCommandSpecs;
|
||||
type MatchPluginCommandFn = typeof import("../../../src/plugins/commands.js").matchPluginCommand;
|
||||
type ExecutePluginCommandFn =
|
||||
typeof import("../../../src/plugins/commands.js").executePluginCommand;
|
||||
type AnyMock = MockFn<(...args: unknown[]) => unknown>;
|
||||
type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise<unknown>>;
|
||||
type NativeCommandHarness = {
|
||||
@@ -35,7 +37,7 @@ export const getPluginCommandSpecs = pluginCommandMocks.getPluginCommandSpecs;
|
||||
export const matchPluginCommand = pluginCommandMocks.matchPluginCommand;
|
||||
export const executePluginCommand = pluginCommandMocks.executePluginCommand;
|
||||
|
||||
vi.mock("../plugins/commands.js", () => ({
|
||||
vi.mock("../../../src/plugins/commands.js", () => ({
|
||||
getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs,
|
||||
matchPluginCommand: pluginCommandMocks.matchPluginCommand,
|
||||
executePluginCommand: pluginCommandMocks.executePluginCommand,
|
||||
@@ -46,7 +48,7 @@ const deliveryMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
export const deliverReplies = deliveryMocks.deliverReplies;
|
||||
vi.mock("./bot/delivery.js", () => ({ deliverReplies: deliveryMocks.deliverReplies }));
|
||||
vi.mock("../pairing/pairing-store.js", () => ({
|
||||
vi.mock("../../../src/pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { STATE_DIR } from "../config/paths.js";
|
||||
import { TELEGRAM_COMMAND_NAME_PATTERN } from "../config/telegram-custom-commands.js";
|
||||
import type { TelegramAccountConfig } from "../config/types.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { STATE_DIR } from "../../../src/config/paths.js";
|
||||
import { TELEGRAM_COMMAND_NAME_PATTERN } from "../../../src/config/telegram-custom-commands.js";
|
||||
import type { TelegramAccountConfig } from "../../../src/config/types.js";
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
|
||||
|
||||
const { listSkillCommandsForAgents } = vi.hoisted(() => ({
|
||||
@@ -19,14 +19,14 @@ const deliveryMocks = vi.hoisted(() => ({
|
||||
deliverReplies: vi.fn(async () => ({ delivered: true })),
|
||||
}));
|
||||
|
||||
vi.mock("../auto-reply/skill-commands.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../auto-reply/skill-commands.js")>();
|
||||
vi.mock("../../../src/auto-reply/skill-commands.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/auto-reply/skill-commands.js")>();
|
||||
return {
|
||||
...actual,
|
||||
listSkillCommandsForAgents,
|
||||
};
|
||||
});
|
||||
vi.mock("../plugins/commands.js", () => ({
|
||||
vi.mock("../../../src/plugins/commands.js", () => ({
|
||||
getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs,
|
||||
matchPluginCommand: pluginCommandMocks.matchPluginCommand,
|
||||
executePluginCommand: pluginCommandMocks.executePluginCommand,
|
||||
900
extensions/telegram/src/bot-native-commands.ts
Normal file
900
extensions/telegram/src/bot-native-commands.ts
Normal file
@@ -0,0 +1,900 @@
|
||||
import type { Bot, Context } from "grammy";
|
||||
import { ensureConfiguredAcpRouteReady } from "../../../src/acp/persistent-bindings.route.js";
|
||||
import { resolveChunkMode } from "../../../src/auto-reply/chunk.js";
|
||||
import { resolveCommandAuthorization } from "../../../src/auto-reply/command-auth.js";
|
||||
import type { CommandArgs } from "../../../src/auto-reply/commands-registry.js";
|
||||
import {
|
||||
buildCommandTextFromArgs,
|
||||
findCommandByNativeName,
|
||||
listNativeCommandSpecs,
|
||||
listNativeCommandSpecsForConfig,
|
||||
parseCommandArgs,
|
||||
resolveCommandArgMenu,
|
||||
} from "../../../src/auto-reply/commands-registry.js";
|
||||
import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js";
|
||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../../../src/auto-reply/reply/provider-dispatcher.js";
|
||||
import { listSkillCommandsForAgents } from "../../../src/auto-reply/skill-commands.js";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "../../../src/channels/command-gating.js";
|
||||
import { resolveNativeCommandSessionTargets } from "../../../src/channels/native-command-session-targets.js";
|
||||
import { createReplyPrefixOptions } from "../../../src/channels/reply-prefix.js";
|
||||
import { recordInboundSessionMetaSafe } from "../../../src/channels/session-meta.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { ChannelGroupPolicy } from "../../../src/config/group-policy.js";
|
||||
import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js";
|
||||
import {
|
||||
normalizeTelegramCommandName,
|
||||
resolveTelegramCustomCommands,
|
||||
TELEGRAM_COMMAND_NAME_PATTERN,
|
||||
} from "../../../src/config/telegram-custom-commands.js";
|
||||
import type {
|
||||
ReplyToMode,
|
||||
TelegramAccountConfig,
|
||||
TelegramDirectConfig,
|
||||
TelegramGroupConfig,
|
||||
TelegramTopicConfig,
|
||||
} from "../../../src/config/types.js";
|
||||
import { danger, logVerbose } from "../../../src/globals.js";
|
||||
import { getChildLogger } from "../../../src/logging.js";
|
||||
import { getAgentScopedMediaLocalRoots } from "../../../src/media/local-roots.js";
|
||||
import {
|
||||
executePluginCommand,
|
||||
getPluginCommandSpecs,
|
||||
matchPluginCommand,
|
||||
} from "../../../src/plugins/commands.js";
|
||||
import { resolveAgentRoute } from "../../../src/routing/resolve-route.js";
|
||||
import { resolveThreadSessionKeys } from "../../../src/routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
||||
import { isSenderAllowed, normalizeDmAllowFromWithStore } from "./bot-access.js";
|
||||
import type { TelegramMediaRef } from "./bot-message-context.js";
|
||||
import {
|
||||
buildCappedTelegramMenuCommands,
|
||||
buildPluginTelegramMenuCommands,
|
||||
syncTelegramMenuCommands,
|
||||
} from "./bot-native-command-menu.js";
|
||||
import { TelegramUpdateKeyContext } from "./bot-updates.js";
|
||||
import { TelegramBotOptions } from "./bot.js";
|
||||
import { deliverReplies } from "./bot/delivery.js";
|
||||
import {
|
||||
buildTelegramThreadParams,
|
||||
buildSenderName,
|
||||
buildTelegramGroupFrom,
|
||||
resolveTelegramGroupAllowFromContext,
|
||||
resolveTelegramThreadSpec,
|
||||
} from "./bot/helpers.js";
|
||||
import type { TelegramContext } from "./bot/types.js";
|
||||
import { resolveTelegramConversationRoute } from "./conversation-route.js";
|
||||
import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js";
|
||||
import type { TelegramTransport } from "./fetch.js";
|
||||
import {
|
||||
evaluateTelegramGroupBaseAccess,
|
||||
evaluateTelegramGroupPolicyAccess,
|
||||
} from "./group-access.js";
|
||||
import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js";
|
||||
import { buildInlineKeyboard } from "./send.js";
|
||||
|
||||
const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again.";
|
||||
|
||||
type TelegramNativeCommandContext = Context & { match?: string };
|
||||
|
||||
type TelegramCommandAuthResult = {
|
||||
chatId: number;
|
||||
isGroup: boolean;
|
||||
isForum: boolean;
|
||||
resolvedThreadId?: number;
|
||||
senderId: string;
|
||||
senderUsername: string;
|
||||
groupConfig?: TelegramGroupConfig;
|
||||
topicConfig?: TelegramTopicConfig;
|
||||
commandAuthorized: boolean;
|
||||
};
|
||||
|
||||
export type RegisterTelegramHandlerParams = {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
bot: Bot;
|
||||
mediaMaxBytes: number;
|
||||
opts: TelegramBotOptions;
|
||||
telegramTransport?: TelegramTransport;
|
||||
runtime: RuntimeEnv;
|
||||
telegramCfg: TelegramAccountConfig;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy;
|
||||
resolveTelegramGroupConfig: (
|
||||
chatId: string | number,
|
||||
messageThreadId?: number,
|
||||
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
|
||||
shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean;
|
||||
processMessage: (
|
||||
ctx: TelegramContext,
|
||||
allMedia: TelegramMediaRef[],
|
||||
storeAllowFrom: string[],
|
||||
options?: {
|
||||
messageIdOverride?: string;
|
||||
forceWasMentioned?: boolean;
|
||||
},
|
||||
replyMedia?: TelegramMediaRef[],
|
||||
) => Promise<void>;
|
||||
logger: ReturnType<typeof getChildLogger>;
|
||||
};
|
||||
|
||||
type RegisterTelegramNativeCommandsParams = {
|
||||
bot: Bot;
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
accountId: string;
|
||||
telegramCfg: TelegramAccountConfig;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
replyToMode: ReplyToMode;
|
||||
textLimit: number;
|
||||
useAccessGroups: boolean;
|
||||
nativeEnabled: boolean;
|
||||
nativeSkillsEnabled: boolean;
|
||||
nativeDisabledExplicit: boolean;
|
||||
resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy;
|
||||
resolveTelegramGroupConfig: (
|
||||
chatId: string | number,
|
||||
messageThreadId?: number,
|
||||
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
|
||||
shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean;
|
||||
opts: { token: string };
|
||||
};
|
||||
|
||||
async function resolveTelegramCommandAuth(params: {
|
||||
msg: NonNullable<TelegramNativeCommandContext["message"]>;
|
||||
bot: Bot;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
telegramCfg: TelegramAccountConfig;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
useAccessGroups: boolean;
|
||||
resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy;
|
||||
resolveTelegramGroupConfig: (
|
||||
chatId: string | number,
|
||||
messageThreadId?: number,
|
||||
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
|
||||
requireAuth: boolean;
|
||||
}): Promise<TelegramCommandAuthResult | null> {
|
||||
const {
|
||||
msg,
|
||||
bot,
|
||||
cfg,
|
||||
accountId,
|
||||
telegramCfg,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
useAccessGroups,
|
||||
resolveGroupPolicy,
|
||||
resolveTelegramGroupConfig,
|
||||
requireAuth,
|
||||
} = params;
|
||||
const chatId = msg.chat.id;
|
||||
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
||||
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
|
||||
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
|
||||
const threadSpec = resolveTelegramThreadSpec({
|
||||
isGroup,
|
||||
isForum,
|
||||
messageThreadId,
|
||||
});
|
||||
const threadParams = buildTelegramThreadParams(threadSpec) ?? {};
|
||||
const groupAllowContext = await resolveTelegramGroupAllowFromContext({
|
||||
chatId,
|
||||
accountId,
|
||||
isGroup,
|
||||
isForum,
|
||||
messageThreadId,
|
||||
groupAllowFrom,
|
||||
resolveTelegramGroupConfig,
|
||||
});
|
||||
const {
|
||||
resolvedThreadId,
|
||||
dmThreadId,
|
||||
storeAllowFrom,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
groupAllowOverride,
|
||||
effectiveGroupAllow,
|
||||
hasGroupAllowOverride,
|
||||
} = groupAllowContext;
|
||||
// Use direct config dmPolicy override if available for DMs
|
||||
const effectiveDmPolicy =
|
||||
!isGroup && groupConfig && "dmPolicy" in groupConfig
|
||||
? (groupConfig.dmPolicy ?? telegramCfg.dmPolicy ?? "pairing")
|
||||
: (telegramCfg.dmPolicy ?? "pairing");
|
||||
const requireTopic = (groupConfig as TelegramDirectConfig | undefined)?.requireTopic;
|
||||
if (!isGroup && requireTopic === true && dmThreadId == null) {
|
||||
logVerbose(`Blocked telegram command in DM ${chatId}: requireTopic=true but no topic present`);
|
||||
return null;
|
||||
}
|
||||
// For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom
|
||||
const dmAllowFrom = groupAllowOverride ?? allowFrom;
|
||||
const senderId = msg.from?.id ? String(msg.from.id) : "";
|
||||
const senderUsername = msg.from?.username ?? "";
|
||||
const commandsAllowFrom = cfg.commands?.allowFrom;
|
||||
const commandsAllowFromConfigured =
|
||||
commandsAllowFrom != null &&
|
||||
typeof commandsAllowFrom === "object" &&
|
||||
(Array.isArray(commandsAllowFrom.telegram) || Array.isArray(commandsAllowFrom["*"]));
|
||||
const commandsAllowFromAccess = commandsAllowFromConfigured
|
||||
? resolveCommandAuthorization({
|
||||
ctx: {
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
OriginatingChannel: "telegram",
|
||||
AccountId: accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`,
|
||||
SenderId: senderId || undefined,
|
||||
SenderUsername: senderUsername || undefined,
|
||||
},
|
||||
cfg,
|
||||
// commands.allowFrom is the only auth source when configured.
|
||||
commandAuthorized: false,
|
||||
})
|
||||
: null;
|
||||
|
||||
const sendAuthMessage = async (text: string) => {
|
||||
await withTelegramApiErrorLogging({
|
||||
operation: "sendMessage",
|
||||
fn: () => bot.api.sendMessage(chatId, text, threadParams),
|
||||
});
|
||||
return null;
|
||||
};
|
||||
const rejectNotAuthorized = async () => {
|
||||
return await sendAuthMessage("You are not authorized to use this command.");
|
||||
};
|
||||
|
||||
const baseAccess = evaluateTelegramGroupBaseAccess({
|
||||
isGroup,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
hasGroupAllowOverride,
|
||||
effectiveGroupAllow,
|
||||
senderId,
|
||||
senderUsername,
|
||||
enforceAllowOverride: requireAuth,
|
||||
requireSenderForAllowOverride: true,
|
||||
});
|
||||
if (!baseAccess.allowed) {
|
||||
if (baseAccess.reason === "group-disabled") {
|
||||
return await sendAuthMessage("This group is disabled.");
|
||||
}
|
||||
if (baseAccess.reason === "topic-disabled") {
|
||||
return await sendAuthMessage("This topic is disabled.");
|
||||
}
|
||||
return await rejectNotAuthorized();
|
||||
}
|
||||
|
||||
const policyAccess = evaluateTelegramGroupPolicyAccess({
|
||||
isGroup,
|
||||
chatId,
|
||||
cfg,
|
||||
telegramCfg,
|
||||
topicConfig,
|
||||
groupConfig,
|
||||
effectiveGroupAllow,
|
||||
senderId,
|
||||
senderUsername,
|
||||
resolveGroupPolicy,
|
||||
enforcePolicy: useAccessGroups,
|
||||
useTopicAndGroupOverrides: false,
|
||||
enforceAllowlistAuthorization: requireAuth && !commandsAllowFromConfigured,
|
||||
allowEmptyAllowlistEntries: true,
|
||||
requireSenderForAllowlistAuthorization: true,
|
||||
checkChatAllowlist: useAccessGroups,
|
||||
});
|
||||
if (!policyAccess.allowed) {
|
||||
if (policyAccess.reason === "group-policy-disabled") {
|
||||
return await sendAuthMessage("Telegram group commands are disabled.");
|
||||
}
|
||||
if (
|
||||
policyAccess.reason === "group-policy-allowlist-no-sender" ||
|
||||
policyAccess.reason === "group-policy-allowlist-unauthorized"
|
||||
) {
|
||||
return await rejectNotAuthorized();
|
||||
}
|
||||
if (policyAccess.reason === "group-chat-not-allowed") {
|
||||
return await sendAuthMessage("This group is not allowed.");
|
||||
}
|
||||
}
|
||||
|
||||
const dmAllow = normalizeDmAllowFromWithStore({
|
||||
allowFrom: dmAllowFrom,
|
||||
storeAllowFrom: isGroup ? [] : storeAllowFrom,
|
||||
dmPolicy: effectiveDmPolicy,
|
||||
});
|
||||
const senderAllowed = isSenderAllowed({
|
||||
allow: dmAllow,
|
||||
senderId,
|
||||
senderUsername,
|
||||
});
|
||||
const groupSenderAllowed = isGroup
|
||||
? isSenderAllowed({ allow: effectiveGroupAllow, senderId, senderUsername })
|
||||
: false;
|
||||
const commandAuthorized = commandsAllowFromConfigured
|
||||
? Boolean(commandsAllowFromAccess?.isAuthorizedSender)
|
||||
: resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: dmAllow.hasEntries, allowed: senderAllowed },
|
||||
...(isGroup
|
||||
? [{ configured: effectiveGroupAllow.hasEntries, allowed: groupSenderAllowed }]
|
||||
: []),
|
||||
],
|
||||
modeWhenAccessGroupsOff: "configured",
|
||||
});
|
||||
if (requireAuth && !commandAuthorized) {
|
||||
return await rejectNotAuthorized();
|
||||
}
|
||||
|
||||
return {
|
||||
chatId,
|
||||
isGroup,
|
||||
isForum,
|
||||
resolvedThreadId,
|
||||
senderId,
|
||||
senderUsername,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
commandAuthorized,
|
||||
};
|
||||
}
|
||||
|
||||
export const registerTelegramNativeCommands = ({
|
||||
bot,
|
||||
cfg,
|
||||
runtime,
|
||||
accountId,
|
||||
telegramCfg,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
useAccessGroups,
|
||||
nativeEnabled,
|
||||
nativeSkillsEnabled,
|
||||
nativeDisabledExplicit,
|
||||
resolveGroupPolicy,
|
||||
resolveTelegramGroupConfig,
|
||||
shouldSkipUpdate,
|
||||
opts,
|
||||
}: RegisterTelegramNativeCommandsParams) => {
|
||||
const boundRoute =
|
||||
nativeEnabled && nativeSkillsEnabled
|
||||
? resolveAgentRoute({ cfg, channel: "telegram", accountId })
|
||||
: null;
|
||||
if (nativeEnabled && nativeSkillsEnabled && !boundRoute) {
|
||||
runtime.log?.(
|
||||
"nativeSkillsEnabled is true but no agent route is bound for this Telegram account; skill commands will not appear in the native menu.",
|
||||
);
|
||||
}
|
||||
const skillCommands =
|
||||
nativeEnabled && nativeSkillsEnabled && boundRoute
|
||||
? listSkillCommandsForAgents({ cfg, agentIds: [boundRoute.agentId] })
|
||||
: [];
|
||||
const nativeCommands = nativeEnabled
|
||||
? listNativeCommandSpecsForConfig(cfg, {
|
||||
skillCommands,
|
||||
provider: "telegram",
|
||||
})
|
||||
: [];
|
||||
const reservedCommands = new Set(
|
||||
listNativeCommandSpecs().map((command) => normalizeTelegramCommandName(command.name)),
|
||||
);
|
||||
for (const command of skillCommands) {
|
||||
reservedCommands.add(command.name.toLowerCase());
|
||||
}
|
||||
const customResolution = resolveTelegramCustomCommands({
|
||||
commands: telegramCfg.customCommands,
|
||||
reservedCommands,
|
||||
});
|
||||
for (const issue of customResolution.issues) {
|
||||
runtime.error?.(danger(issue.message));
|
||||
}
|
||||
const customCommands = customResolution.commands;
|
||||
const pluginCommandSpecs = getPluginCommandSpecs("telegram");
|
||||
const existingCommands = new Set(
|
||||
[
|
||||
...nativeCommands.map((command) => normalizeTelegramCommandName(command.name)),
|
||||
...customCommands.map((command) => command.command),
|
||||
].map((command) => command.toLowerCase()),
|
||||
);
|
||||
const pluginCatalog = buildPluginTelegramMenuCommands({
|
||||
specs: pluginCommandSpecs,
|
||||
existingCommands,
|
||||
});
|
||||
for (const issue of pluginCatalog.issues) {
|
||||
runtime.error?.(danger(issue));
|
||||
}
|
||||
const allCommandsFull: Array<{ command: string; description: string }> = [
|
||||
...nativeCommands
|
||||
.map((command) => {
|
||||
const normalized = normalizeTelegramCommandName(command.name);
|
||||
if (!TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) {
|
||||
runtime.error?.(
|
||||
danger(
|
||||
`Native command "${command.name}" is invalid for Telegram (resolved to "${normalized}"). Skipping.`,
|
||||
),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
command: normalized,
|
||||
description: command.description,
|
||||
};
|
||||
})
|
||||
.filter((cmd): cmd is { command: string; description: string } => cmd !== null),
|
||||
...(nativeEnabled ? pluginCatalog.commands : []),
|
||||
...customCommands,
|
||||
];
|
||||
const { commandsToRegister, totalCommands, maxCommands, overflowCount } =
|
||||
buildCappedTelegramMenuCommands({
|
||||
allCommands: allCommandsFull,
|
||||
});
|
||||
if (overflowCount > 0) {
|
||||
runtime.log?.(
|
||||
`Telegram limits bots to ${maxCommands} commands. ` +
|
||||
`${totalCommands} configured; registering first ${maxCommands}. ` +
|
||||
`Use channels.telegram.commands.native: false to disable, or reduce plugin/skill/custom commands.`,
|
||||
);
|
||||
}
|
||||
// Telegram only limits the setMyCommands payload (menu entries).
|
||||
// Keep hidden commands callable by registering handlers for the full catalog.
|
||||
syncTelegramMenuCommands({
|
||||
bot,
|
||||
runtime,
|
||||
commandsToRegister,
|
||||
accountId,
|
||||
botIdentity: opts.token,
|
||||
});
|
||||
|
||||
const resolveCommandRuntimeContext = async (params: {
|
||||
msg: NonNullable<TelegramNativeCommandContext["message"]>;
|
||||
isGroup: boolean;
|
||||
isForum: boolean;
|
||||
resolvedThreadId?: number;
|
||||
senderId?: string;
|
||||
topicAgentId?: string;
|
||||
}): Promise<{
|
||||
chatId: number;
|
||||
threadSpec: ReturnType<typeof resolveTelegramThreadSpec>;
|
||||
route: ReturnType<typeof resolveTelegramConversationRoute>["route"];
|
||||
mediaLocalRoots: readonly string[] | undefined;
|
||||
tableMode: ReturnType<typeof resolveMarkdownTableMode>;
|
||||
chunkMode: ReturnType<typeof resolveChunkMode>;
|
||||
} | null> => {
|
||||
const { msg, isGroup, isForum, resolvedThreadId, senderId, topicAgentId } = params;
|
||||
const chatId = msg.chat.id;
|
||||
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
|
||||
const threadSpec = resolveTelegramThreadSpec({
|
||||
isGroup,
|
||||
isForum,
|
||||
messageThreadId,
|
||||
});
|
||||
let { route, configuredBinding } = resolveTelegramConversationRoute({
|
||||
cfg,
|
||||
accountId,
|
||||
chatId,
|
||||
isGroup,
|
||||
resolvedThreadId,
|
||||
replyThreadId: threadSpec.id,
|
||||
senderId,
|
||||
topicAgentId,
|
||||
});
|
||||
if (configuredBinding) {
|
||||
const ensured = await ensureConfiguredAcpRouteReady({
|
||||
cfg,
|
||||
configuredBinding,
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
logVerbose(
|
||||
`telegram native command: configured ACP binding unavailable for topic ${configuredBinding.spec.conversationId}: ${ensured.error}`,
|
||||
);
|
||||
await withTelegramApiErrorLogging({
|
||||
operation: "sendMessage",
|
||||
runtime,
|
||||
fn: () =>
|
||||
bot.api.sendMessage(
|
||||
chatId,
|
||||
"Configured ACP binding is unavailable right now. Please try again.",
|
||||
buildTelegramThreadParams(threadSpec) ?? {},
|
||||
),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId);
|
||||
return { chatId, threadSpec, route, mediaLocalRoots, tableMode, chunkMode };
|
||||
};
|
||||
const buildCommandDeliveryBaseOptions = (params: {
|
||||
chatId: string | number;
|
||||
accountId: string;
|
||||
sessionKeyForInternalHooks?: string;
|
||||
mirrorIsGroup?: boolean;
|
||||
mirrorGroupId?: string;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
threadSpec: ReturnType<typeof resolveTelegramThreadSpec>;
|
||||
tableMode: ReturnType<typeof resolveMarkdownTableMode>;
|
||||
chunkMode: ReturnType<typeof resolveChunkMode>;
|
||||
}) => ({
|
||||
chatId: String(params.chatId),
|
||||
accountId: params.accountId,
|
||||
sessionKeyForInternalHooks: params.sessionKeyForInternalHooks,
|
||||
mirrorIsGroup: params.mirrorIsGroup,
|
||||
mirrorGroupId: params.mirrorGroupId,
|
||||
token: opts.token,
|
||||
runtime,
|
||||
bot,
|
||||
mediaLocalRoots: params.mediaLocalRoots,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
thread: params.threadSpec,
|
||||
tableMode: params.tableMode,
|
||||
chunkMode: params.chunkMode,
|
||||
linkPreview: telegramCfg.linkPreview,
|
||||
});
|
||||
|
||||
if (commandsToRegister.length > 0 || pluginCatalog.commands.length > 0) {
|
||||
if (typeof (bot as unknown as { command?: unknown }).command !== "function") {
|
||||
logVerbose("telegram: bot.command unavailable; skipping native handlers");
|
||||
} else {
|
||||
for (const command of nativeCommands) {
|
||||
const normalizedCommandName = normalizeTelegramCommandName(command.name);
|
||||
bot.command(normalizedCommandName, async (ctx: TelegramNativeCommandContext) => {
|
||||
const msg = ctx.message;
|
||||
if (!msg) {
|
||||
return;
|
||||
}
|
||||
if (shouldSkipUpdate(ctx)) {
|
||||
return;
|
||||
}
|
||||
const auth = await resolveTelegramCommandAuth({
|
||||
msg,
|
||||
bot,
|
||||
cfg,
|
||||
accountId,
|
||||
telegramCfg,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
useAccessGroups,
|
||||
resolveGroupPolicy,
|
||||
resolveTelegramGroupConfig,
|
||||
requireAuth: true,
|
||||
});
|
||||
if (!auth) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
chatId,
|
||||
isGroup,
|
||||
isForum,
|
||||
resolvedThreadId,
|
||||
senderId,
|
||||
senderUsername,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
commandAuthorized,
|
||||
} = auth;
|
||||
const runtimeContext = await resolveCommandRuntimeContext({
|
||||
msg,
|
||||
isGroup,
|
||||
isForum,
|
||||
resolvedThreadId,
|
||||
senderId,
|
||||
topicAgentId: topicConfig?.agentId,
|
||||
});
|
||||
if (!runtimeContext) {
|
||||
return;
|
||||
}
|
||||
const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } = runtimeContext;
|
||||
const threadParams = buildTelegramThreadParams(threadSpec) ?? {};
|
||||
|
||||
const commandDefinition = findCommandByNativeName(command.name, "telegram");
|
||||
const rawText = ctx.match?.trim() ?? "";
|
||||
const commandArgs = commandDefinition
|
||||
? parseCommandArgs(commandDefinition, rawText)
|
||||
: rawText
|
||||
? ({ raw: rawText } satisfies CommandArgs)
|
||||
: undefined;
|
||||
const prompt = commandDefinition
|
||||
? buildCommandTextFromArgs(commandDefinition, commandArgs)
|
||||
: rawText
|
||||
? `/${command.name} ${rawText}`
|
||||
: `/${command.name}`;
|
||||
const menu = commandDefinition
|
||||
? resolveCommandArgMenu({
|
||||
command: commandDefinition,
|
||||
args: commandArgs,
|
||||
cfg,
|
||||
})
|
||||
: null;
|
||||
if (menu && commandDefinition) {
|
||||
const title =
|
||||
menu.title ??
|
||||
`Choose ${menu.arg.description || menu.arg.name} for /${commandDefinition.nativeName ?? commandDefinition.key}.`;
|
||||
const rows: Array<Array<{ text: string; callback_data: string }>> = [];
|
||||
for (let i = 0; i < menu.choices.length; i += 2) {
|
||||
const slice = menu.choices.slice(i, i + 2);
|
||||
rows.push(
|
||||
slice.map((choice) => {
|
||||
const args: CommandArgs = {
|
||||
values: { [menu.arg.name]: choice.value },
|
||||
};
|
||||
return {
|
||||
text: choice.label,
|
||||
callback_data: buildCommandTextFromArgs(commandDefinition, args),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
const replyMarkup = buildInlineKeyboard(rows);
|
||||
await withTelegramApiErrorLogging({
|
||||
operation: "sendMessage",
|
||||
runtime,
|
||||
fn: () =>
|
||||
bot.api.sendMessage(chatId, title, {
|
||||
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||
...threadParams,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const baseSessionKey = route.sessionKey;
|
||||
// DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums)
|
||||
const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined;
|
||||
const threadKeys =
|
||||
dmThreadId != null
|
||||
? resolveThreadSessionKeys({
|
||||
baseSessionKey,
|
||||
threadId: `${chatId}:${dmThreadId}`,
|
||||
})
|
||||
: null;
|
||||
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
|
||||
const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
});
|
||||
const { sessionKey: commandSessionKey, commandTargetSessionKey } =
|
||||
resolveNativeCommandSessionTargets({
|
||||
agentId: route.agentId,
|
||||
sessionPrefix: "telegram:slash",
|
||||
userId: String(senderId || chatId),
|
||||
targetSessionKey: sessionKey,
|
||||
});
|
||||
const deliveryBaseOptions = buildCommandDeliveryBaseOptions({
|
||||
chatId,
|
||||
accountId: route.accountId,
|
||||
sessionKeyForInternalHooks: commandSessionKey,
|
||||
mirrorIsGroup: isGroup,
|
||||
mirrorGroupId: isGroup ? String(chatId) : undefined,
|
||||
mediaLocalRoots,
|
||||
threadSpec,
|
||||
tableMode,
|
||||
chunkMode,
|
||||
});
|
||||
const conversationLabel = isGroup
|
||||
? msg.chat.title
|
||||
? `${msg.chat.title} id:${chatId}`
|
||||
: `group:${chatId}`
|
||||
: (buildSenderName(msg) ?? String(senderId || chatId));
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
Body: prompt,
|
||||
BodyForAgent: prompt,
|
||||
RawBody: prompt,
|
||||
CommandBody: prompt,
|
||||
CommandArgs: commandArgs,
|
||||
From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`,
|
||||
To: `slash:${senderId || chatId}`,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
ConversationLabel: conversationLabel,
|
||||
GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined,
|
||||
GroupSystemPrompt: isGroup || (!isGroup && groupConfig) ? groupSystemPrompt : undefined,
|
||||
SenderName: buildSenderName(msg),
|
||||
SenderId: senderId || undefined,
|
||||
SenderUsername: senderUsername || undefined,
|
||||
Surface: "telegram",
|
||||
Provider: "telegram",
|
||||
MessageSid: String(msg.message_id),
|
||||
Timestamp: msg.date ? msg.date * 1000 : undefined,
|
||||
WasMentioned: true,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
CommandSource: "native" as const,
|
||||
SessionKey: commandSessionKey,
|
||||
AccountId: route.accountId,
|
||||
CommandTargetSessionKey: commandTargetSessionKey,
|
||||
MessageThreadId: threadSpec.id,
|
||||
IsForum: isForum,
|
||||
// Originating context for sub-agent announce routing
|
||||
OriginatingChannel: "telegram" as const,
|
||||
OriginatingTo: `telegram:${chatId}`,
|
||||
});
|
||||
|
||||
await recordInboundSessionMetaSafe({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
onError: (err) =>
|
||||
runtime.error?.(
|
||||
danger(`telegram slash: failed updating session meta: ${String(err)}`),
|
||||
),
|
||||
});
|
||||
|
||||
const disableBlockStreaming =
|
||||
typeof telegramCfg.blockStreaming === "boolean"
|
||||
? !telegramCfg.blockStreaming
|
||||
: undefined;
|
||||
|
||||
const deliveryState = {
|
||||
delivered: false,
|
||||
skippedNonSilent: 0,
|
||||
};
|
||||
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
channel: "telegram",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
|
||||
await dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcherOptions: {
|
||||
...prefixOptions,
|
||||
deliver: async (payload, _info) => {
|
||||
if (
|
||||
shouldSuppressLocalTelegramExecApprovalPrompt({
|
||||
cfg,
|
||||
accountId: route.accountId,
|
||||
payload,
|
||||
})
|
||||
) {
|
||||
deliveryState.delivered = true;
|
||||
return;
|
||||
}
|
||||
const result = await deliverReplies({
|
||||
replies: [payload],
|
||||
...deliveryBaseOptions,
|
||||
});
|
||||
if (result.delivered) {
|
||||
deliveryState.delivered = true;
|
||||
}
|
||||
},
|
||||
onSkip: (_payload, info) => {
|
||||
if (info.reason !== "silent") {
|
||||
deliveryState.skippedNonSilent += 1;
|
||||
}
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(danger(`telegram slash ${info.kind} reply failed: ${String(err)}`));
|
||||
},
|
||||
},
|
||||
replyOptions: {
|
||||
skillFilter,
|
||||
disableBlockStreaming,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) {
|
||||
await deliverReplies({
|
||||
replies: [{ text: EMPTY_RESPONSE_FALLBACK }],
|
||||
...deliveryBaseOptions,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const pluginCommand of pluginCatalog.commands) {
|
||||
bot.command(pluginCommand.command, async (ctx: TelegramNativeCommandContext) => {
|
||||
const msg = ctx.message;
|
||||
if (!msg) {
|
||||
return;
|
||||
}
|
||||
if (shouldSkipUpdate(ctx)) {
|
||||
return;
|
||||
}
|
||||
const chatId = msg.chat.id;
|
||||
const rawText = ctx.match?.trim() ?? "";
|
||||
const commandBody = `/${pluginCommand.command}${rawText ? ` ${rawText}` : ""}`;
|
||||
const match = matchPluginCommand(commandBody);
|
||||
if (!match) {
|
||||
await withTelegramApiErrorLogging({
|
||||
operation: "sendMessage",
|
||||
runtime,
|
||||
fn: () => bot.api.sendMessage(chatId, "Command not found."),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const auth = await resolveTelegramCommandAuth({
|
||||
msg,
|
||||
bot,
|
||||
cfg,
|
||||
accountId,
|
||||
telegramCfg,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
useAccessGroups,
|
||||
resolveGroupPolicy,
|
||||
resolveTelegramGroupConfig,
|
||||
requireAuth: match.command.requireAuth !== false,
|
||||
});
|
||||
if (!auth) {
|
||||
return;
|
||||
}
|
||||
const { senderId, commandAuthorized, isGroup, isForum, resolvedThreadId } = auth;
|
||||
const runtimeContext = await resolveCommandRuntimeContext({
|
||||
msg,
|
||||
isGroup,
|
||||
isForum,
|
||||
resolvedThreadId,
|
||||
senderId,
|
||||
topicAgentId: auth.topicConfig?.agentId,
|
||||
});
|
||||
if (!runtimeContext) {
|
||||
return;
|
||||
}
|
||||
const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } = runtimeContext;
|
||||
const deliveryBaseOptions = buildCommandDeliveryBaseOptions({
|
||||
chatId,
|
||||
accountId: route.accountId,
|
||||
sessionKeyForInternalHooks: route.sessionKey,
|
||||
mirrorIsGroup: isGroup,
|
||||
mirrorGroupId: isGroup ? String(chatId) : undefined,
|
||||
mediaLocalRoots,
|
||||
threadSpec,
|
||||
tableMode,
|
||||
chunkMode,
|
||||
});
|
||||
const from = isGroup
|
||||
? buildTelegramGroupFrom(chatId, threadSpec.id)
|
||||
: `telegram:${chatId}`;
|
||||
const to = `telegram:${chatId}`;
|
||||
|
||||
const result = await executePluginCommand({
|
||||
command: match.command,
|
||||
args: match.args,
|
||||
senderId,
|
||||
channel: "telegram",
|
||||
isAuthorizedSender: commandAuthorized,
|
||||
commandBody,
|
||||
config: cfg,
|
||||
from,
|
||||
to,
|
||||
accountId,
|
||||
messageThreadId: threadSpec.id,
|
||||
});
|
||||
|
||||
if (
|
||||
!shouldSuppressLocalTelegramExecApprovalPrompt({
|
||||
cfg,
|
||||
accountId: route.accountId,
|
||||
payload: result,
|
||||
})
|
||||
) {
|
||||
await deliverReplies({
|
||||
replies: [result],
|
||||
...deliveryBaseOptions,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (nativeDisabledExplicit) {
|
||||
withTelegramApiErrorLogging({
|
||||
operation: "setMyCommands",
|
||||
runtime,
|
||||
fn: () => bot.api.setMyCommands([]),
|
||||
}).catch(() => {});
|
||||
}
|
||||
};
|
||||
67
extensions/telegram/src/bot-updates.ts
Normal file
67
extensions/telegram/src/bot-updates.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { Message } from "@grammyjs/types";
|
||||
import { createDedupeCache } from "../../../src/infra/dedupe.js";
|
||||
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,9 +1,9 @@
|
||||
import { beforeEach, vi } from "vitest";
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import type { MsgContext } from "../auto-reply/templating.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../auto-reply/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
|
||||
import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js";
|
||||
import type { MsgContext } from "../../../src/auto-reply/templating.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../../../src/auto-reply/types.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js";
|
||||
|
||||
type AnyMock = MockFn<(...args: unknown[]) => unknown>;
|
||||
type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise<unknown>>;
|
||||
@@ -20,7 +20,7 @@ export function getLoadWebMediaMock(): AnyMock {
|
||||
return loadWebMedia;
|
||||
}
|
||||
|
||||
vi.mock("../web/media.js", () => ({
|
||||
vi.mock("../../../src/web/media.js", () => ({
|
||||
loadWebMedia,
|
||||
}));
|
||||
|
||||
@@ -31,16 +31,16 @@ const { loadConfig } = vi.hoisted((): { loadConfig: AnyMock } => ({
|
||||
export function getLoadConfigMock(): AnyMock {
|
||||
return loadConfig;
|
||||
}
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
vi.mock("../../../src/config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../config/sessions.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/sessions.js")>();
|
||||
vi.mock("../../../src/config/sessions.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/config/sessions.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath),
|
||||
@@ -68,7 +68,7 @@ export function getUpsertChannelPairingRequestMock(): AnyAsyncMock {
|
||||
return upsertChannelPairingRequest;
|
||||
}
|
||||
|
||||
vi.mock("../pairing/pairing-store.js", () => ({
|
||||
vi.mock("../../../src/pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore,
|
||||
upsertChannelPairingRequest,
|
||||
}));
|
||||
@@ -78,7 +78,7 @@ const skillCommandsHoisted = vi.hoisted(() => ({
|
||||
}));
|
||||
export const listSkillCommandsForAgents = skillCommandsHoisted.listSkillCommandsForAgents;
|
||||
|
||||
vi.mock("../auto-reply/skill-commands.js", () => ({
|
||||
vi.mock("../../../src/auto-reply/skill-commands.js", () => ({
|
||||
listSkillCommandsForAgents,
|
||||
}));
|
||||
|
||||
@@ -87,7 +87,7 @@ const systemEventsHoisted = vi.hoisted(() => ({
|
||||
}));
|
||||
export const enqueueSystemEventSpy: AnyMock = systemEventsHoisted.enqueueSystemEventSpy;
|
||||
|
||||
vi.mock("../infra/system-events.js", () => ({
|
||||
vi.mock("../../../src/infra/system-events.js", () => ({
|
||||
enqueueSystemEvent: enqueueSystemEventSpy,
|
||||
}));
|
||||
|
||||
@@ -201,7 +201,7 @@ export const replySpy: MockFn<
|
||||
return undefined;
|
||||
});
|
||||
|
||||
vi.mock("../auto-reply/reply.js", () => ({
|
||||
vi.mock("../../../src/auto-reply/reply.js", () => ({
|
||||
getReplyFromConfig: replySpy,
|
||||
__replySpy: replySpy,
|
||||
}));
|
||||
@@ -2,9 +2,9 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { useFrozenTime, useRealTime } from "../test-utils/frozen-time.js";
|
||||
import { withEnvAsync } from "../../../src/test-utils/env.js";
|
||||
import { useFrozenTime, useRealTime } from "../../../src/test-utils/frozen-time.js";
|
||||
import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js";
|
||||
import {
|
||||
answerCallbackQuerySpy,
|
||||
botCtorSpy,
|
||||
79
extensions/telegram/src/bot.fetch-abort.test.ts
Normal file
79
extensions/telegram/src/bot.fetch-abort.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { botCtorSpy } from "./bot.create-telegram-bot.test-harness.js";
|
||||
import { createTelegramBot } from "./bot.js";
|
||||
import { getTelegramNetworkErrorOrigin } from "./network-errors.js";
|
||||
|
||||
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("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();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, vi, type Mock } from "vitest";
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js";
|
||||
|
||||
export const useSpy: Mock = vi.fn();
|
||||
export const middlewareUseSpy: Mock = vi.fn();
|
||||
@@ -92,8 +92,8 @@ vi.mock("undici", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../media/store.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../media/store.js")>();
|
||||
vi.mock("../../../src/media/store.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/media/store.js")>();
|
||||
const mockModule = Object.create(null) as Record<string, unknown>;
|
||||
Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual));
|
||||
Object.defineProperty(mockModule, "saveMediaBuffer", {
|
||||
@@ -105,8 +105,8 @@ vi.mock("../media/store.js", async (importOriginal) => {
|
||||
return mockModule;
|
||||
});
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
vi.mock("../../../src/config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({
|
||||
@@ -115,15 +115,15 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../config/sessions.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/sessions.js")>();
|
||||
vi.mock("../../../src/config/sessions.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/config/sessions.js")>();
|
||||
return {
|
||||
...actual,
|
||||
updateLastRoute: vi.fn(async () => undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../pairing/pairing-store.js", () => ({
|
||||
vi.mock("../../../src/pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: vi.fn(async () => [] as string[]),
|
||||
upsertChannelPairingRequest: vi.fn(async () => ({
|
||||
code: "PAIRCODE",
|
||||
@@ -131,7 +131,7 @@ vi.mock("../pairing/pairing-store.js", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../auto-reply/reply.js", () => {
|
||||
vi.mock("../../../src/auto-reply/reply.js", () => {
|
||||
const replySpy = vi.fn(async (_ctx, opts) => {
|
||||
await opts?.onReplyStart?.();
|
||||
return undefined;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeAll, beforeEach, expect, vi, type Mock } from "vitest";
|
||||
import * as ssrf from "../infra/net/ssrf.js";
|
||||
import * as ssrf from "../../../src/infra/net/ssrf.js";
|
||||
import { onSpy, sendChatActionSpy } from "./bot.media.e2e-harness.js";
|
||||
|
||||
type StickerSpy = Mock<(...args: unknown[]) => unknown>;
|
||||
@@ -103,7 +103,7 @@ afterEach(() => {
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createTelegramBot: createTelegramBotRef } = await import("./bot.js"));
|
||||
const replyModule = await import("../auto-reply/reply.js");
|
||||
const replyModule = await import("../../../src/auto-reply/reply.js");
|
||||
replySpyRef = (replyModule as unknown as { __replySpy: ReturnType<typeof vi.fn> }).__replySpy;
|
||||
}, TELEGRAM_BOT_IMPORT_TIMEOUT_MS);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
|
||||
import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js";
|
||||
import {
|
||||
listNativeCommandSpecs,
|
||||
listNativeCommandSpecsForConfig,
|
||||
} from "../auto-reply/commands-registry.js";
|
||||
import { loadSessionStore } from "../config/sessions.js";
|
||||
import { normalizeTelegramCommandName } from "../config/telegram-custom-commands.js";
|
||||
} from "../../../src/auto-reply/commands-registry.js";
|
||||
import { loadSessionStore } from "../../../src/config/sessions.js";
|
||||
import { normalizeTelegramCommandName } from "../../../src/config/telegram-custom-commands.js";
|
||||
import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js";
|
||||
import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js";
|
||||
import {
|
||||
answerCallbackQuerySpy,
|
||||
commandSpy,
|
||||
521
extensions/telegram/src/bot.ts
Normal file
521
extensions/telegram/src/bot.ts
Normal file
@@ -0,0 +1,521 @@
|
||||
import { sequentialize } from "@grammyjs/runner";
|
||||
import { apiThrottler } from "@grammyjs/transformer-throttler";
|
||||
import type { ApiClientOptions } from "grammy";
|
||||
import { Bot } from "grammy";
|
||||
import { resolveDefaultAgentId } from "../../../src/agents/agent-scope.js";
|
||||
import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js";
|
||||
import {
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
type HistoryEntry,
|
||||
} from "../../../src/auto-reply/reply/history.js";
|
||||
import {
|
||||
resolveThreadBindingIdleTimeoutMsForChannel,
|
||||
resolveThreadBindingMaxAgeMsForChannel,
|
||||
resolveThreadBindingSpawnPolicy,
|
||||
} from "../../../src/channels/thread-bindings-policy.js";
|
||||
import {
|
||||
isNativeCommandsExplicitlyDisabled,
|
||||
resolveNativeCommandsEnabled,
|
||||
resolveNativeSkillsEnabled,
|
||||
} from "../../../src/config/commands.js";
|
||||
import type { OpenClawConfig, ReplyToMode } from "../../../src/config/config.js";
|
||||
import { loadConfig } from "../../../src/config/config.js";
|
||||
import {
|
||||
resolveChannelGroupPolicy,
|
||||
resolveChannelGroupRequireMention,
|
||||
} from "../../../src/config/group-policy.js";
|
||||
import { loadSessionStore, resolveStorePath } from "../../../src/config/sessions.js";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "../../../src/globals.js";
|
||||
import { formatUncaughtError } from "../../../src/infra/errors.js";
|
||||
import { getChildLogger } from "../../../src/logging.js";
|
||||
import { createSubsystemLogger } from "../../../src/logging/subsystem.js";
|
||||
import { createNonExitingRuntime, type RuntimeEnv } from "../../../src/runtime.js";
|
||||
import { resolveTelegramAccount } from "./accounts.js";
|
||||
import { registerTelegramHandlers } from "./bot-handlers.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 { buildTelegramGroupPeerId, resolveTelegramStreamMode } from "./bot/helpers.js";
|
||||
import { resolveTelegramTransport } from "./fetch.js";
|
||||
import { tagTelegramNetworkError } from "./network-errors.js";
|
||||
import { createTelegramSendChatActionHandler } from "./sendchataction-401-backoff.js";
|
||||
import { getTelegramSequentialKey } from "./sequential-key.js";
|
||||
import { createTelegramThreadBindingManager } from "./thread-bindings.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;
|
||||
};
|
||||
};
|
||||
|
||||
export { getTelegramSequentialKey };
|
||||
|
||||
type TelegramFetchInput = Parameters<NonNullable<ApiClientOptions["fetch"]>>[0];
|
||||
type TelegramFetchInit = Parameters<NonNullable<ApiClientOptions["fetch"]>>[1];
|
||||
type GlobalFetchInput = Parameters<typeof globalThis.fetch>[0];
|
||||
type GlobalFetchInit = Parameters<typeof globalThis.fetch>[1];
|
||||
|
||||
function readRequestUrl(input: TelegramFetchInput): string | null {
|
||||
if (typeof input === "string") {
|
||||
return input;
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
return input.toString();
|
||||
}
|
||||
if (typeof input === "object" && input !== null && "url" in input) {
|
||||
const url = (input as { url?: unknown }).url;
|
||||
return typeof url === "string" ? url : null;
|
||||
}
|
||||
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);
|
||||
return segments.length > 0 ? (segments.at(-1) ?? null) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime();
|
||||
const cfg = opts.config ?? 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 = 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 as unknown as NonNullable<
|
||||
ApiClientOptions["fetch"]
|
||||
>;
|
||||
|
||||
// When a shutdown abort signal is provided, wrap fetch so every Telegram API request
|
||||
// (especially long-polling getUpdates) aborts immediately on shutdown. Without this,
|
||||
// the in-flight getUpdates hangs for up to 30s, and a new gateway instance starting
|
||||
// its own poll triggers a 409 Conflict from Telegram.
|
||||
let finalFetch = shouldProvideFetch ? fetchForClient : undefined;
|
||||
if (opts.fetchAbortSignal) {
|
||||
const baseFetch =
|
||||
finalFetch ?? (globalThis.fetch as unknown as NonNullable<ApiClientOptions["fetch"]>);
|
||||
const shutdownSignal = opts.fetchAbortSignal;
|
||||
// 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 as unknown as typeof globalThis.fetch;
|
||||
// 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 onShutdown = () => abortWith(shutdownSignal);
|
||||
let onRequestAbort: (() => void) | undefined;
|
||||
if (shutdownSignal.aborted) {
|
||||
abortWith(shutdownSignal);
|
||||
} else {
|
||||
shutdownSignal.addEventListener("abort", onShutdown, { once: true });
|
||||
}
|
||||
if (init?.signal) {
|
||||
if (init.signal.aborted) {
|
||||
abortWith(init.signal as unknown as AbortSignal);
|
||||
} else {
|
||||
onRequestAbort = () => abortWith(init.signal as AbortSignal);
|
||||
init.signal.addEventListener("abort", onRequestAbort);
|
||||
}
|
||||
}
|
||||
return callFetch(input as GlobalFetchInput, {
|
||||
...(init as GlobalFetchInit),
|
||||
signal: controller.signal,
|
||||
}).finally(() => {
|
||||
shutdownSignal.removeEventListener("abort", onShutdown);
|
||||
if (init?.signal && onRequestAbort) {
|
||||
init.signal.removeEventListener("abort", onRequestAbort);
|
||||
}
|
||||
});
|
||||
}) as unknown as NonNullable<ApiClientOptions["fetch"]>;
|
||||
}
|
||||
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;
|
||||
});
|
||||
}) as unknown as NonNullable<ApiClientOptions["fetch"]>;
|
||||
}
|
||||
|
||||
const timeoutSeconds =
|
||||
typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds)
|
||||
? Math.max(1, Math.floor(telegramCfg.timeoutSeconds))
|
||||
: undefined;
|
||||
const client: ApiClientOptions | undefined =
|
||||
finalFetch || timeoutSeconds
|
||||
? {
|
||||
...(finalFetch ? { fetch: finalFetch } : {}),
|
||||
...(timeoutSeconds ? { timeoutSeconds } : {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const bot = new Bot(opts.token, client ? { client } : undefined);
|
||||
bot.api.config.use(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 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>();
|
||||
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 (highestPersistedUpdateId !== null && safe <= highestPersistedUpdateId) {
|
||||
return;
|
||||
}
|
||||
highestPersistedUpdateId = safe;
|
||||
void opts.updateOffset.onUpdateId(safe);
|
||||
};
|
||||
|
||||
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);
|
||||
const skipped = recentUpdates.check(key);
|
||||
if (skipped && key && shouldLogVerbose()) {
|
||||
logVerbose(`telegram dedupe: skipped ${key}`);
|
||||
}
|
||||
return skipped;
|
||||
};
|
||||
|
||||
bot.use(async (ctx, next) => {
|
||||
const updateId = resolveTelegramUpdateId(ctx);
|
||||
if (typeof updateId === "number") {
|
||||
pendingUpdateIds.add(updateId);
|
||||
}
|
||||
try {
|
||||
await next();
|
||||
} finally {
|
||||
if (typeof updateId === "number") {
|
||||
pendingUpdateIds.delete(updateId);
|
||||
if (highestCompletedUpdateId === null || updateId > highestCompletedUpdateId) {
|
||||
highestCompletedUpdateId = updateId;
|
||||
}
|
||||
maybePersistSafeWatermark();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
bot.use(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 = resolveStorePath(cfg.session?.store, { agentId });
|
||||
try {
|
||||
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 resolveTelegramGroupConfig = (chatId: string | number, messageThreadId?: number) => {
|
||||
const groups = telegramCfg.groups;
|
||||
const direct = telegramCfg.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 as Parameters<typeof bot.api.sendChatAction>[2],
|
||||
),
|
||||
logger: (message) => logVerbose(`telegram: ${message}`),
|
||||
});
|
||||
|
||||
const processMessage = createTelegramMessageProcessor({
|
||||
bot,
|
||||
cfg,
|
||||
account,
|
||||
telegramCfg,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
dmPolicy,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
ackReactionScope,
|
||||
logger,
|
||||
resolveGroupActivation,
|
||||
resolveGroupRequireMention,
|
||||
resolveTelegramGroupConfig,
|
||||
sendChatActionHandler,
|
||||
runtime,
|
||||
replyToMode,
|
||||
streamMode,
|
||||
textLimit,
|
||||
opts,
|
||||
});
|
||||
|
||||
registerTelegramNativeCommands({
|
||||
bot,
|
||||
cfg,
|
||||
runtime,
|
||||
accountId: account.accountId,
|
||||
telegramCfg,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
useAccessGroups,
|
||||
nativeEnabled,
|
||||
nativeSkillsEnabled,
|
||||
nativeDisabledExplicit,
|
||||
resolveGroupPolicy,
|
||||
resolveTelegramGroupConfig,
|
||||
shouldSkipUpdate,
|
||||
opts,
|
||||
});
|
||||
|
||||
registerTelegramHandlers({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
bot,
|
||||
opts,
|
||||
telegramTransport,
|
||||
runtime,
|
||||
mediaMaxBytes,
|
||||
telegramCfg,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
resolveGroupPolicy,
|
||||
resolveTelegramGroupConfig,
|
||||
shouldSkipUpdate,
|
||||
processMessage,
|
||||
logger,
|
||||
});
|
||||
|
||||
const originalStop = bot.stop.bind(bot);
|
||||
bot.stop = ((...args: Parameters<typeof originalStop>) => {
|
||||
threadBindingManager?.stop();
|
||||
return originalStop(...args);
|
||||
}) as typeof bot.stop;
|
||||
|
||||
return bot;
|
||||
}
|
||||
702
extensions/telegram/src/bot/delivery.replies.ts
Normal file
702
extensions/telegram/src/bot/delivery.replies.ts
Normal file
@@ -0,0 +1,702 @@
|
||||
import { type Bot, GrammyError, InputFile } from "grammy";
|
||||
import { chunkMarkdownTextWithMode, type ChunkMode } from "../../../../src/auto-reply/chunk.js";
|
||||
import type { ReplyPayload } from "../../../../src/auto-reply/types.js";
|
||||
import type { ReplyToMode } from "../../../../src/config/config.js";
|
||||
import type { MarkdownTableMode } from "../../../../src/config/types.base.js";
|
||||
import { danger, logVerbose } from "../../../../src/globals.js";
|
||||
import { fireAndForgetHook } from "../../../../src/hooks/fire-and-forget.js";
|
||||
import {
|
||||
createInternalHookEvent,
|
||||
triggerInternalHook,
|
||||
} from "../../../../src/hooks/internal-hooks.js";
|
||||
import {
|
||||
buildCanonicalSentMessageHookContext,
|
||||
toInternalMessageSentContext,
|
||||
toPluginMessageContext,
|
||||
toPluginMessageSentEvent,
|
||||
} from "../../../../src/hooks/message-hook-mappers.js";
|
||||
import { formatErrorMessage } from "../../../../src/infra/errors.js";
|
||||
import { buildOutboundMediaLoadOptions } from "../../../../src/media/load-options.js";
|
||||
import { isGifMedia, kindFromMime } from "../../../../src/media/mime.js";
|
||||
import { getGlobalHookRunner } from "../../../../src/plugins/hook-runner-global.js";
|
||||
import type { RuntimeEnv } from "../../../../src/runtime.js";
|
||||
import { loadWebMedia } from "../../../../src/web/media.js";
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
}): Promise<number | undefined> {
|
||||
let firstDeliveredMessageId: number | undefined;
|
||||
await sendChunkedTelegramReplyText({
|
||||
chunks: params.chunkText(params.replyText),
|
||||
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,
|
||||
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;
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
}): Promise<void> {
|
||||
await sendChunkedTelegramReplyText({
|
||||
chunks: params.chunkText(params.text),
|
||||
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,
|
||||
replyMarkup,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isVoiceMessagesForbidden(err: unknown): boolean {
|
||||
if (err instanceof GrammyError) {
|
||||
return VOICE_FORBIDDEN_RE.test(err.description);
|
||||
}
|
||||
return VOICE_FORBIDDEN_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
|
||||
function isCaptionTooLong(err: unknown): boolean {
|
||||
if (err instanceof GrammyError) {
|
||||
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;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
replyQuoteText?: string;
|
||||
}): Promise<number | undefined> {
|
||||
let firstDeliveredMessageId: number | undefined;
|
||||
const chunks = 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,
|
||||
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;
|
||||
onVoiceRecording?: () => Promise<void> | void;
|
||||
linkPreview?: 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 loadWebMedia(
|
||||
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,
|
||||
}),
|
||||
};
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function emitMessageSentHooks(params: {
|
||||
hookRunner: ReturnType<typeof getGlobalHookRunner>;
|
||||
enabled: boolean;
|
||||
sessionKeyForInternalHooks?: string;
|
||||
chatId: string;
|
||||
accountId?: string;
|
||||
content: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
messageId?: number;
|
||||
isGroup?: boolean;
|
||||
groupId?: string;
|
||||
}): void {
|
||||
if (!params.enabled && !params.sessionKeyForInternalHooks) {
|
||||
return;
|
||||
}
|
||||
const canonical = 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,
|
||||
});
|
||||
if (params.enabled) {
|
||||
fireAndForgetHook(
|
||||
Promise.resolve(
|
||||
params.hookRunner!.runMessageSent(
|
||||
toPluginMessageSentEvent(canonical),
|
||||
toPluginMessageContext(canonical),
|
||||
),
|
||||
),
|
||||
"telegram: message_sent plugin hook failed",
|
||||
);
|
||||
}
|
||||
if (!params.sessionKeyForInternalHooks) {
|
||||
return;
|
||||
}
|
||||
fireAndForgetHook(
|
||||
triggerInternalHook(
|
||||
createInternalHookEvent(
|
||||
"message",
|
||||
"sent",
|
||||
params.sessionKeyForInternalHooks,
|
||||
toInternalMessageSentContext(canonical),
|
||||
),
|
||||
),
|
||||
"telegram: message:sent internal hook failed",
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
/** Optional quote text for Telegram reply_parameters. */
|
||||
replyQuoteText?: string;
|
||||
}): Promise<{ delivered: boolean }> {
|
||||
const progress: DeliveryProgress = {
|
||||
hasReplied: false,
|
||||
hasDelivered: false,
|
||||
deliveredCount: 0,
|
||||
};
|
||||
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,
|
||||
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,
|
||||
onVoiceRecording: params.onVoiceRecording,
|
||||
linkPreview: params.linkPreview,
|
||||
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: error instanceof Error ? error.message : String(error),
|
||||
isGroup: params.mirrorIsGroup,
|
||||
groupId: params.mirrorGroupId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { delivered: progress.hasDelivered };
|
||||
}
|
||||
@@ -6,19 +6,19 @@ import type { TelegramContext } from "./types.js";
|
||||
const saveMediaBuffer = vi.fn();
|
||||
const fetchRemoteMedia = vi.fn();
|
||||
|
||||
vi.mock("../../media/store.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../media/store.js")>();
|
||||
vi.mock("../../../../src/media/store.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/media/store.js")>();
|
||||
return {
|
||||
...actual,
|
||||
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../media/fetch.js", () => ({
|
||||
vi.mock("../../../../src/media/fetch.js", () => ({
|
||||
fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../globals.js", () => ({
|
||||
vi.mock("../../../../src/globals.js", () => ({
|
||||
danger: (s: string) => s,
|
||||
warn: (s: string) => s,
|
||||
logVerbose: () => {},
|
||||
290
extensions/telegram/src/bot/delivery.resolve-media.ts
Normal file
290
extensions/telegram/src/bot/delivery.resolve-media.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { GrammyError } from "grammy";
|
||||
import { logVerbose, warn } from "../../../../src/globals.js";
|
||||
import { formatErrorMessage } from "../../../../src/infra/errors.js";
|
||||
import { retryAsync } from "../../../../src/infra/retry.js";
|
||||
import { fetchRemoteMedia } from "../../../../src/media/fetch.js";
|
||||
import { saveMediaBuffer } from "../../../../src/media/store.js";
|
||||
import { shouldRetryTelegramIpv4Fallback, type TelegramTransport } from "../fetch.js";
|
||||
import { cacheSticker, getCachedSticker } from "../sticker-cache.js";
|
||||
import { resolveTelegramMediaPlaceholder } from "./helpers.js";
|
||||
import type { StickerMetadata, TelegramContext } from "./types.js";
|
||||
|
||||
const FILE_TOO_BIG_RE = /file is too big/i;
|
||||
const TELEGRAM_MEDIA_SSRF_POLICY = {
|
||||
// Telegram file downloads should trust api.telegram.org even when DNS/proxy
|
||||
// resolution maps to private/internal ranges in restricted networks.
|
||||
allowedHostnames: ["api.telegram.org"],
|
||||
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 (err instanceof GrammyError) {
|
||||
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;
|
||||
}
|
||||
|
||||
function resolveMediaFileRef(msg: TelegramContext["message"]) {
|
||||
return (
|
||||
msg.photo?.[msg.photo.length - 1] ??
|
||||
msg.video ??
|
||||
msg.video_note ??
|
||||
msg.document ??
|
||||
msg.audio ??
|
||||
msg.voice
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTelegramFileName(msg: TelegramContext["message"]): string | undefined {
|
||||
return (
|
||||
msg.document?.file_name ??
|
||||
msg.audio?.file_name ??
|
||||
msg.video?.file_name ??
|
||||
msg.animation?.file_name
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOptionalTelegramTransport(transport?: TelegramTransport): TelegramTransport | null {
|
||||
try {
|
||||
return resolveRequiredTelegramTransport(transport);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Default idle timeout for Telegram media downloads (30 seconds). */
|
||||
const TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS = 30_000;
|
||||
|
||||
async function downloadAndSaveTelegramFile(params: {
|
||||
filePath: string;
|
||||
token: string;
|
||||
transport: TelegramTransport;
|
||||
maxBytes: number;
|
||||
telegramFileName?: string;
|
||||
}) {
|
||||
const url = `https://api.telegram.org/file/bot${params.token}/${params.filePath}`;
|
||||
const fetched = await fetchRemoteMedia({
|
||||
url,
|
||||
fetchImpl: params.transport.sourceFetch,
|
||||
dispatcherPolicy: params.transport.pinnedDispatcherPolicy,
|
||||
fallbackDispatcherPolicy: params.transport.fallbackPinnedDispatcherPolicy,
|
||||
shouldRetryFetchError: shouldRetryTelegramIpv4Fallback,
|
||||
filePathHint: params.filePath,
|
||||
maxBytes: params.maxBytes,
|
||||
readIdleTimeoutMs: TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS,
|
||||
ssrfPolicy: TELEGRAM_MEDIA_SSRF_POLICY,
|
||||
});
|
||||
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;
|
||||
}): 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 resolvedTransport = resolveOptionalTelegramTransport(transport);
|
||||
if (!resolvedTransport) {
|
||||
logVerbose("telegram: fetch not available for sticker download");
|
||||
return null;
|
||||
}
|
||||
const saved = await downloadAndSaveTelegramFile({
|
||||
filePath: file.file_path,
|
||||
token,
|
||||
transport: resolvedTransport,
|
||||
maxBytes,
|
||||
});
|
||||
|
||||
// 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(
|
||||
ctx: TelegramContext,
|
||||
maxBytes: number,
|
||||
token: string,
|
||||
transport?: TelegramTransport,
|
||||
): Promise<{
|
||||
path: string;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
stickerMetadata?: StickerMetadata;
|
||||
} | null> {
|
||||
const msg = ctx.message;
|
||||
const stickerResolved = await resolveStickerMedia({
|
||||
msg,
|
||||
ctx,
|
||||
maxBytes,
|
||||
token,
|
||||
transport,
|
||||
});
|
||||
if (stickerResolved !== undefined) {
|
||||
return stickerResolved;
|
||||
}
|
||||
|
||||
const m = resolveMediaFileRef(msg);
|
||||
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: resolveRequiredTelegramTransport(transport),
|
||||
maxBytes,
|
||||
telegramFileName: resolveTelegramFileName(msg),
|
||||
});
|
||||
const placeholder = resolveTelegramMediaPlaceholder(msg) ?? "<media:document>";
|
||||
return { path: saved.path, contentType: saved.contentType, placeholder };
|
||||
}
|
||||
172
extensions/telegram/src/bot/delivery.send.ts
Normal file
172
extensions/telegram/src/bot/delivery.send.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { type Bot, GrammyError } from "grammy";
|
||||
import { formatErrorMessage } from "../../../../src/infra/errors.js";
|
||||
import type { RuntimeEnv } from "../../../../src/runtime.js";
|
||||
import { withTelegramApiErrorLogging } from "../api-logging.js";
|
||||
import { markdownToTelegramHtml } from "../format.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;
|
||||
|
||||
function isTelegramThreadNotFoundError(err: unknown): boolean {
|
||||
if (err instanceof GrammyError) {
|
||||
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;
|
||||
}): Record<string, unknown> {
|
||||
const threadParams = buildTelegramThreadParams(opts?.thread);
|
||||
const params: Record<string, unknown> = {};
|
||||
if (opts?.replyToMessageId) {
|
||||
params.reply_to_message_id = opts.replyToMessageId;
|
||||
}
|
||||
if (threadParams) {
|
||||
params.message_thread_id = threadParams.message_thread_id;
|
||||
}
|
||||
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;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
},
|
||||
): Promise<number> {
|
||||
const baseParams = buildTelegramSendParams({
|
||||
replyToMessageId: opts?.replyToMessageId,
|
||||
thread: opts?.thread,
|
||||
});
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Bot } from "grammy";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import type { RuntimeEnv } from "../../../../src/runtime.js";
|
||||
import { deliverReplies } from "./delivery.js";
|
||||
|
||||
const loadWebMedia = vi.fn();
|
||||
@@ -24,17 +24,17 @@ type DeliverWithParams = Omit<
|
||||
Partial<Pick<DeliverRepliesParams, "replyToMode" | "textLimit">>;
|
||||
type RuntimeStub = Pick<RuntimeEnv, "error" | "log" | "exit">;
|
||||
|
||||
vi.mock("../../../extensions/whatsapp/src/media.js", () => ({
|
||||
vi.mock("../../../whatsapp/src/media.js", () => ({
|
||||
loadWebMedia: (...args: unknown[]) => loadWebMedia(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/hook-runner-global.js", () => ({
|
||||
vi.mock("../../../../src/plugins/hook-runner-global.js", () => ({
|
||||
getGlobalHookRunner: () => messageHookRunner,
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/internal-hooks.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../hooks/internal-hooks.js")>(
|
||||
"../../hooks/internal-hooks.js",
|
||||
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,
|
||||
2
extensions/telegram/src/bot/delivery.ts
Normal file
2
extensions/telegram/src/bot/delivery.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { deliverReplies } from "./delivery.replies.js";
|
||||
export { resolveMedia } from "./delivery.resolve-media.js";
|
||||
607
extensions/telegram/src/bot/helpers.ts
Normal file
607
extensions/telegram/src/bot/helpers.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
import type { Chat, Message, MessageOrigin, User } from "@grammyjs/types";
|
||||
import { formatLocationText, type NormalizedLocation } from "../../../../src/channels/location.js";
|
||||
import { resolveTelegramPreviewStreamMode } from "../../../../src/config/discord-preview-streaming.js";
|
||||
import type {
|
||||
TelegramDirectConfig,
|
||||
TelegramGroupConfig,
|
||||
TelegramTopicConfig,
|
||||
} from "../../../../src/config/types.js";
|
||||
import { readChannelAllowFromStore } from "../../../../src/pairing/pairing-store.js";
|
||||
import { normalizeAccountId } from "../../../../src/routing/session-key.js";
|
||||
import { firstDefined, normalizeAllowFrom, type NormalizedAllowFrom } from "../bot-access.js";
|
||||
import type { TelegramStreamMode } from "./types.js";
|
||||
|
||||
const TELEGRAM_GENERAL_TOPIC_ID = 1;
|
||||
|
||||
export type TelegramThreadSpec = {
|
||||
id?: number;
|
||||
scope: "dm" | "forum" | "none";
|
||||
};
|
||||
|
||||
export async function resolveTelegramGroupAllowFromContext(params: {
|
||||
chatId: string | number;
|
||||
accountId?: string;
|
||||
isGroup?: boolean;
|
||||
isForum?: boolean;
|
||||
messageThreadId?: number | null;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
resolveTelegramGroupConfig: (
|
||||
chatId: string | number,
|
||||
messageThreadId?: number,
|
||||
) => {
|
||||
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||
topicConfig?: TelegramTopicConfig;
|
||||
};
|
||||
}): Promise<{
|
||||
resolvedThreadId?: number;
|
||||
dmThreadId?: number;
|
||||
storeAllowFrom: string[];
|
||||
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||
topicConfig?: TelegramTopicConfig;
|
||||
groupAllowOverride?: Array<string | number>;
|
||||
effectiveGroupAllow: NormalizedAllowFrom;
|
||||
hasGroupAllowOverride: boolean;
|
||||
}> {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
// Use resolveTelegramThreadSpec to handle both forum groups AND DM topics
|
||||
const threadSpec = resolveTelegramThreadSpec({
|
||||
isGroup: params.isGroup ?? false,
|
||||
isForum: params.isForum,
|
||||
messageThreadId: params.messageThreadId,
|
||||
});
|
||||
const resolvedThreadId = threadSpec.scope === "forum" ? threadSpec.id : undefined;
|
||||
const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined;
|
||||
const threadIdForConfig = resolvedThreadId ?? dmThreadId;
|
||||
const storeAllowFrom = await readChannelAllowFromStore("telegram", process.env, accountId).catch(
|
||||
() => [],
|
||||
);
|
||||
const { groupConfig, topicConfig } = params.resolveTelegramGroupConfig(
|
||||
params.chatId,
|
||||
threadIdForConfig,
|
||||
);
|
||||
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
|
||||
// Group sender access must remain explicit (groupAllowFrom/per-group allowFrom only).
|
||||
// DM pairing store entries are not a group authorization source.
|
||||
const effectiveGroupAllow = normalizeAllowFrom(groupAllowOverride ?? params.groupAllowFrom);
|
||||
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
|
||||
return {
|
||||
resolvedThreadId,
|
||||
dmThreadId,
|
||||
storeAllowFrom,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
groupAllowOverride,
|
||||
effectiveGroupAllow,
|
||||
hasGroupAllowOverride,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the thread ID for Telegram forum topics.
|
||||
* For non-forum groups, returns undefined even if messageThreadId is present
|
||||
* (reply threads in regular groups should not create separate sessions).
|
||||
* For forum groups, returns the topic ID (or General topic ID=1 if unspecified).
|
||||
*/
|
||||
export function resolveTelegramForumThreadId(params: {
|
||||
isForum?: boolean;
|
||||
messageThreadId?: number | null;
|
||||
}) {
|
||||
// Non-forum groups: ignore message_thread_id (reply threads are not real topics)
|
||||
if (!params.isForum) {
|
||||
return undefined;
|
||||
}
|
||||
// Forum groups: use the topic ID, defaulting to General topic
|
||||
if (params.messageThreadId == null) {
|
||||
return TELEGRAM_GENERAL_TOPIC_ID;
|
||||
}
|
||||
return params.messageThreadId;
|
||||
}
|
||||
|
||||
export function resolveTelegramThreadSpec(params: {
|
||||
isGroup: boolean;
|
||||
isForum?: boolean;
|
||||
messageThreadId?: number | null;
|
||||
}): TelegramThreadSpec {
|
||||
if (params.isGroup) {
|
||||
const id = resolveTelegramForumThreadId({
|
||||
isForum: params.isForum,
|
||||
messageThreadId: params.messageThreadId,
|
||||
});
|
||||
return {
|
||||
id,
|
||||
scope: params.isForum ? "forum" : "none",
|
||||
};
|
||||
}
|
||||
if (params.messageThreadId == null) {
|
||||
return { scope: "dm" };
|
||||
}
|
||||
return {
|
||||
id: params.messageThreadId,
|
||||
scope: "dm",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build thread params for Telegram API calls (messages, media).
|
||||
*
|
||||
* IMPORTANT: Thread IDs behave differently based on chat type:
|
||||
* - DMs (private chats): Include message_thread_id when present (DM topics)
|
||||
* - Forum topics: Skip thread_id=1 (General topic), include others
|
||||
* - Regular groups: Thread IDs are ignored by Telegram
|
||||
*
|
||||
* General forum topic (id=1) must be treated like a regular supergroup send:
|
||||
* Telegram rejects sendMessage/sendMedia with message_thread_id=1 ("thread not found").
|
||||
*
|
||||
* @param thread - Thread specification with ID and scope
|
||||
* @returns API params object or undefined if thread_id should be omitted
|
||||
*/
|
||||
export function buildTelegramThreadParams(thread?: TelegramThreadSpec | null) {
|
||||
if (thread?.id == null) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = Math.trunc(thread.id);
|
||||
|
||||
if (thread.scope === "dm") {
|
||||
return normalized > 0 ? { message_thread_id: normalized } : undefined;
|
||||
}
|
||||
|
||||
// Telegram rejects message_thread_id=1 for General forum topic
|
||||
if (normalized === TELEGRAM_GENERAL_TOPIC_ID) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { message_thread_id: normalized };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build thread params for typing indicators (sendChatAction).
|
||||
* Empirically, General topic (id=1) needs message_thread_id for typing to appear.
|
||||
*/
|
||||
export function buildTypingThreadParams(messageThreadId?: number) {
|
||||
if (messageThreadId == null) {
|
||||
return undefined;
|
||||
}
|
||||
return { message_thread_id: Math.trunc(messageThreadId) };
|
||||
}
|
||||
|
||||
export function resolveTelegramStreamMode(telegramCfg?: {
|
||||
streaming?: unknown;
|
||||
streamMode?: unknown;
|
||||
}): TelegramStreamMode {
|
||||
return resolveTelegramPreviewStreamMode(telegramCfg);
|
||||
}
|
||||
|
||||
export function buildTelegramGroupPeerId(chatId: number | string, messageThreadId?: number) {
|
||||
return messageThreadId != null ? `${chatId}:topic:${messageThreadId}` : String(chatId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the direct-message peer identifier for Telegram routing/session keys.
|
||||
*
|
||||
* In some Telegram DM deliveries (for example certain business/chat bridge flows),
|
||||
* `chat.id` can differ from the actual sender user id. Prefer sender id when present
|
||||
* so per-peer DM scopes isolate users correctly.
|
||||
*/
|
||||
export function resolveTelegramDirectPeerId(params: {
|
||||
chatId: number | string;
|
||||
senderId?: number | string | null;
|
||||
}) {
|
||||
const senderId = params.senderId != null ? String(params.senderId).trim() : "";
|
||||
if (senderId) {
|
||||
return senderId;
|
||||
}
|
||||
return String(params.chatId);
|
||||
}
|
||||
|
||||
export function buildTelegramGroupFrom(chatId: number | string, messageThreadId?: number) {
|
||||
return `telegram:group:${buildTelegramGroupPeerId(chatId, messageThreadId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build parentPeer for forum topic binding inheritance.
|
||||
* When a message comes from a forum topic, the peer ID includes the topic suffix
|
||||
* (e.g., `-1001234567890:topic:99`). To allow bindings configured for the base
|
||||
* group ID to match, we provide the parent group as `parentPeer` so the routing
|
||||
* layer can fall back to it when the exact peer doesn't match.
|
||||
*/
|
||||
export function buildTelegramParentPeer(params: {
|
||||
isGroup: boolean;
|
||||
resolvedThreadId?: number;
|
||||
chatId: number | string;
|
||||
}): { kind: "group"; id: string } | undefined {
|
||||
if (!params.isGroup || params.resolvedThreadId == null) {
|
||||
return undefined;
|
||||
}
|
||||
return { kind: "group", id: String(params.chatId) };
|
||||
}
|
||||
|
||||
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 resolveTelegramMediaPlaceholder(
|
||||
msg:
|
||||
| Pick<Message, "photo" | "video" | "video_note" | "audio" | "voice" | "document" | "sticker">
|
||||
| undefined
|
||||
| null,
|
||||
): string | undefined {
|
||||
if (!msg) {
|
||||
return undefined;
|
||||
}
|
||||
if (msg.photo) {
|
||||
return "<media:image>";
|
||||
}
|
||||
if (msg.video || msg.video_note) {
|
||||
return "<media:video>";
|
||||
}
|
||||
if (msg.audio || msg.voice) {
|
||||
return "<media:audio>";
|
||||
}
|
||||
if (msg.document) {
|
||||
return "<media:document>";
|
||||
}
|
||||
if (msg.sticker) {
|
||||
return "<media:sticker>";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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 && `${senderId}`.trim() ? `${senderId}`.trim() : 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 function buildGroupLabel(msg: Message, chatId: number | string, messageThreadId?: number) {
|
||||
const title = msg.chat?.title;
|
||||
const topicSuffix = messageThreadId != null ? ` topic:${messageThreadId}` : "";
|
||||
if (title) {
|
||||
return `${title} id:${chatId}${topicSuffix}`;
|
||||
}
|
||||
return `group:${chatId}${topicSuffix}`;
|
||||
}
|
||||
|
||||
export type TelegramTextEntity = NonNullable<Message["entities"]>[number];
|
||||
|
||||
export function getTelegramTextParts(
|
||||
msg: Pick<Message, "text" | "caption" | "entities" | "caption_entities">,
|
||||
): {
|
||||
text: string;
|
||||
entities: TelegramTextEntity[];
|
||||
} {
|
||||
const text = msg.text ?? msg.caption ?? "";
|
||||
const entities = 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 = `@${botUsername}`.toLowerCase();
|
||||
if (hasStandaloneTelegramMention(text.toLowerCase(), mention)) {
|
||||
return true;
|
||||
}
|
||||
for (const ent of entities) {
|
||||
if (ent.type !== "mention") {
|
||||
continue;
|
||||
}
|
||||
const slice = text.slice(ent.offset, ent.offset + ent.length);
|
||||
if (slice.toLowerCase() === 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 function resolveTelegramReplyId(raw?: string): number | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export type TelegramReplyTarget = {
|
||||
id?: string;
|
||||
sender: string;
|
||||
body: string;
|
||||
kind: "reply" | "quote";
|
||||
/** Forward context if the reply target was itself a forwarded message (issue #9619). */
|
||||
forwardedFrom?: TelegramForwardedContext;
|
||||
};
|
||||
|
||||
export function describeReplyTarget(msg: Message): TelegramReplyTarget | null {
|
||||
const reply = msg.reply_to_message;
|
||||
const externalReply = (msg as Message & { external_reply?: Message }).external_reply;
|
||||
const quoteText =
|
||||
msg.quote?.text ??
|
||||
(externalReply as (Message & { quote?: { text?: string } }) | undefined)?.quote?.text;
|
||||
let body = "";
|
||||
let kind: TelegramReplyTarget["kind"] = "reply";
|
||||
|
||||
if (typeof quoteText === "string") {
|
||||
body = quoteText.trim();
|
||||
if (body) {
|
||||
kind = "quote";
|
||||
}
|
||||
}
|
||||
|
||||
const replyLike = reply ?? externalReply;
|
||||
if (!body && replyLike) {
|
||||
const replyBody = (replyLike.text ?? replyLike.caption ?? "").trim();
|
||||
body = replyBody;
|
||||
if (!body) {
|
||||
body = resolveTelegramMediaPlaceholder(replyLike) ?? "";
|
||||
if (!body) {
|
||||
const locationData = extractTelegramLocation(replyLike);
|
||||
if (locationData) {
|
||||
body = formatLocationText(locationData);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
const sender = replyLike ? buildSenderName(replyLike) : undefined;
|
||||
const senderLabel = sender ?? "unknown sender";
|
||||
|
||||
// Extract forward context from the resolved reply target (reply_to_message or external_reply).
|
||||
const forwardedFrom = replyLike?.forward_origin
|
||||
? (resolveForwardOrigin(replyLike.forward_origin) ?? undefined)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: replyLike?.message_id ? String(replyLike.message_id) : undefined,
|
||||
sender: senderLabel,
|
||||
body,
|
||||
kind,
|
||||
forwardedFrom,
|
||||
};
|
||||
}
|
||||
|
||||
export type TelegramForwardedContext = {
|
||||
from: string;
|
||||
date?: number;
|
||||
fromType: string;
|
||||
fromId?: string;
|
||||
fromUsername?: string;
|
||||
fromTitle?: string;
|
||||
fromSignature?: string;
|
||||
/** Original chat type from forward_from_chat (e.g. "channel", "supergroup", "group"). */
|
||||
fromChatType?: Chat["type"];
|
||||
/** Original message ID in the source chat (channel forwards). */
|
||||
fromMessageId?: number;
|
||||
};
|
||||
|
||||
function normalizeForwardedUserLabel(user: User) {
|
||||
const name = [user.first_name, user.last_name].filter(Boolean).join(" ").trim();
|
||||
const username = user.username?.trim() || undefined;
|
||||
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 = chat.title?.trim() || undefined;
|
||||
const username = chat.username?.trim() || undefined;
|
||||
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 = params.signature?.trim() || undefined;
|
||||
const from = signature ? `${display} (${signature})` : display;
|
||||
const chatType = (params.chat.type?.trim() || undefined) 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:
|
||||
// Exhaustiveness guard: if Grammy adds a new MessageOrigin variant,
|
||||
// TypeScript will flag this assignment as an error.
|
||||
origin satisfies never;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract forwarded message origin info from Telegram message. */
|
||||
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;
|
||||
}
|
||||
82
extensions/telegram/src/bot/reply-threading.ts
Normal file
82
extensions/telegram/src/bot/reply-threading.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { ReplyToMode } from "../../../../src/config/config.js";
|
||||
|
||||
export type DeliveryProgress = {
|
||||
hasReplied: boolean;
|
||||
hasDelivered: boolean;
|
||||
};
|
||||
|
||||
export function createDeliveryProgress(): DeliveryProgress {
|
||||
return {
|
||||
hasReplied: false,
|
||||
hasDelivered: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveReplyToForSend(params: {
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
}): number | undefined {
|
||||
return params.replyToId && (params.replyToMode === "all" || !params.progress.hasReplied)
|
||||
? params.replyToId
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function markReplyApplied(progress: DeliveryProgress, replyToId?: number): void {
|
||||
if (replyToId && !progress.hasReplied) {
|
||||
progress.hasReplied = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function markDelivered(progress: DeliveryProgress): void {
|
||||
progress.hasDelivered = true;
|
||||
}
|
||||
|
||||
export async function sendChunkedTelegramReplyText<
|
||||
TChunk,
|
||||
TReplyMarkup = unknown,
|
||||
TProgress extends DeliveryProgress = DeliveryProgress,
|
||||
>(params: {
|
||||
chunks: readonly TChunk[];
|
||||
progress: TProgress;
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
replyMarkup?: TReplyMarkup;
|
||||
replyQuoteText?: string;
|
||||
quoteOnlyOnFirstChunk?: boolean;
|
||||
markDelivered?: (progress: TProgress) => void;
|
||||
sendChunk: (opts: {
|
||||
chunk: TChunk;
|
||||
isFirstChunk: boolean;
|
||||
replyToMessageId?: number;
|
||||
replyMarkup?: TReplyMarkup;
|
||||
replyQuoteText?: string;
|
||||
}) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const applyDelivered = params.markDelivered ?? markDelivered;
|
||||
for (let i = 0; i < params.chunks.length; i += 1) {
|
||||
const chunk = params.chunks[i];
|
||||
if (!chunk) {
|
||||
continue;
|
||||
}
|
||||
const isFirstChunk = i === 0;
|
||||
const replyToMessageId = resolveReplyToForSend({
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
});
|
||||
const shouldAttachQuote =
|
||||
Boolean(replyToMessageId) &&
|
||||
Boolean(params.replyQuoteText) &&
|
||||
(params.quoteOnlyOnFirstChunk !== true || isFirstChunk);
|
||||
await params.sendChunk({
|
||||
chunk,
|
||||
isFirstChunk,
|
||||
replyToMessageId,
|
||||
replyMarkup: isFirstChunk ? params.replyMarkup : undefined,
|
||||
replyQuoteText: shouldAttachQuote ? params.replyQuoteText : undefined,
|
||||
});
|
||||
markReplyApplied(params.progress, replyToMessageId);
|
||||
applyDelivered(params.progress);
|
||||
}
|
||||
}
|
||||
29
extensions/telegram/src/bot/types.ts
Normal file
29
extensions/telegram/src/bot/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Message, UserFromGetMe } from "@grammyjs/types";
|
||||
|
||||
/** App-specific stream mode for Telegram stream previews. */
|
||||
export type TelegramStreamMode = "off" | "partial" | "block";
|
||||
|
||||
/**
|
||||
* Minimal context projection from Grammy's Context class.
|
||||
* Decouples the message processing pipeline from Grammy's full Context,
|
||||
* and allows constructing synthetic contexts for debounced/combined messages.
|
||||
*/
|
||||
export type TelegramContext = {
|
||||
message: Message;
|
||||
me?: UserFromGetMe;
|
||||
getFile: () => Promise<{ file_path?: string }>;
|
||||
};
|
||||
|
||||
/** Telegram sticker metadata for context enrichment and caching. */
|
||||
export interface StickerMetadata {
|
||||
/** Emoji associated with the sticker. */
|
||||
emoji?: string;
|
||||
/** Name of the sticker set the sticker belongs to. */
|
||||
setName?: string;
|
||||
/** Telegram file_id for sending the sticker back. */
|
||||
fileId?: string;
|
||||
/** Stable file_unique_id for cache deduplication. */
|
||||
fileUniqueId?: string;
|
||||
/** Cached description from previous vision processing (skip re-processing if present). */
|
||||
cachedDescription?: string;
|
||||
}
|
||||
9
extensions/telegram/src/button-types.ts
Normal file
9
extensions/telegram/src/button-types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type TelegramButtonStyle = "danger" | "success" | "primary";
|
||||
|
||||
export type TelegramInlineButton = {
|
||||
text: string;
|
||||
callback_data: string;
|
||||
style?: TelegramButtonStyle;
|
||||
};
|
||||
|
||||
export type TelegramInlineButtons = ReadonlyArray<ReadonlyArray<TelegramInlineButton>>;
|
||||
15
extensions/telegram/src/caption.ts
Normal file
15
extensions/telegram/src/caption.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const TELEGRAM_MAX_CAPTION_LENGTH = 1024;
|
||||
|
||||
export function splitTelegramCaption(text?: string): {
|
||||
caption?: string;
|
||||
followUpText?: string;
|
||||
} {
|
||||
const trimmed = text?.trim() ?? "";
|
||||
if (!trimmed) {
|
||||
return { caption: undefined, followUpText: undefined };
|
||||
}
|
||||
if (trimmed.length > TELEGRAM_MAX_CAPTION_LENGTH) {
|
||||
return { caption: undefined, followUpText: trimmed };
|
||||
}
|
||||
return { caption: trimmed, followUpText: undefined };
|
||||
}
|
||||
293
extensions/telegram/src/channel-actions.ts
Normal file
293
extensions/telegram/src/channel-actions.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
readNumberParam,
|
||||
readStringArrayParam,
|
||||
readStringOrNumberParam,
|
||||
readStringParam,
|
||||
} from "../../../src/agents/tools/common.js";
|
||||
import { handleTelegramAction } from "../../../src/agents/tools/telegram-actions.js";
|
||||
import { resolveReactionMessageId } from "../../../src/channels/plugins/actions/reaction-message-id.js";
|
||||
import {
|
||||
createUnionActionGate,
|
||||
listTokenSourcedAccounts,
|
||||
} from "../../../src/channels/plugins/actions/shared.js";
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
} from "../../../src/channels/plugins/types.js";
|
||||
import type { TelegramActionConfig } from "../../../src/config/types.telegram.js";
|
||||
import { readBooleanParam } from "../../../src/plugin-sdk/boolean-param.js";
|
||||
import { extractToolSend } from "../../../src/plugin-sdk/tool-send.js";
|
||||
import { resolveTelegramPollVisibility } from "../../../src/poll-params.js";
|
||||
import {
|
||||
createTelegramActionGate,
|
||||
listEnabledTelegramAccounts,
|
||||
resolveTelegramPollActionGateState,
|
||||
} from "./accounts.js";
|
||||
import { isTelegramInlineButtonsEnabled } from "./inline-buttons.js";
|
||||
|
||||
const providerId = "telegram";
|
||||
|
||||
function readTelegramSendParams(params: Record<string, unknown>) {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||
const message = readStringParam(params, "message", { required: !mediaUrl, allowEmpty: true });
|
||||
const caption = readStringParam(params, "caption", { allowEmpty: true });
|
||||
const content = message || caption || "";
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
const threadId = readStringParam(params, "threadId");
|
||||
const buttons = params.buttons;
|
||||
const asVoice = readBooleanParam(params, "asVoice");
|
||||
const silent = readBooleanParam(params, "silent");
|
||||
const quoteText = readStringParam(params, "quoteText");
|
||||
return {
|
||||
to,
|
||||
content,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
replyToMessageId: replyTo ?? undefined,
|
||||
messageThreadId: threadId ?? undefined,
|
||||
buttons,
|
||||
asVoice,
|
||||
silent,
|
||||
quoteText: quoteText ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function readTelegramChatIdParam(params: Record<string, unknown>): string | number {
|
||||
return (
|
||||
readStringOrNumberParam(params, "chatId") ??
|
||||
readStringOrNumberParam(params, "channelId") ??
|
||||
readStringParam(params, "to", { required: true })
|
||||
);
|
||||
}
|
||||
|
||||
function readTelegramMessageIdParam(params: Record<string, unknown>): number {
|
||||
const messageId = readNumberParam(params, "messageId", {
|
||||
required: true,
|
||||
integer: true,
|
||||
});
|
||||
if (typeof messageId !== "number") {
|
||||
throw new Error("messageId is required.");
|
||||
}
|
||||
return messageId;
|
||||
}
|
||||
|
||||
export const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: ({ cfg }) => {
|
||||
const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg));
|
||||
if (accounts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
// Union of all accounts' action gates (any account enabling an action makes it available)
|
||||
const gate = createUnionActionGate(accounts, (account) =>
|
||||
createTelegramActionGate({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
}),
|
||||
);
|
||||
const isEnabled = (key: keyof TelegramActionConfig, defaultValue = true) =>
|
||||
gate(key, defaultValue);
|
||||
const actions = new Set<ChannelMessageActionName>(["send"]);
|
||||
const pollEnabledForAnyAccount = accounts.some((account) => {
|
||||
const accountGate = createTelegramActionGate({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
return resolveTelegramPollActionGateState(accountGate).enabled;
|
||||
});
|
||||
if (pollEnabledForAnyAccount) {
|
||||
actions.add("poll");
|
||||
}
|
||||
if (isEnabled("reactions")) {
|
||||
actions.add("react");
|
||||
}
|
||||
if (isEnabled("deleteMessage")) {
|
||||
actions.add("delete");
|
||||
}
|
||||
if (isEnabled("editMessage")) {
|
||||
actions.add("edit");
|
||||
}
|
||||
if (isEnabled("sticker", false)) {
|
||||
actions.add("sticker");
|
||||
actions.add("sticker-search");
|
||||
}
|
||||
if (isEnabled("createForumTopic")) {
|
||||
actions.add("topic-create");
|
||||
}
|
||||
return Array.from(actions);
|
||||
},
|
||||
supportsButtons: ({ cfg }) => {
|
||||
const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg));
|
||||
if (accounts.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return accounts.some((account) =>
|
||||
isTelegramInlineButtonsEnabled({ cfg, accountId: account.accountId }),
|
||||
);
|
||||
},
|
||||
extractToolSend: ({ args }) => {
|
||||
return extractToolSend(args, "sendMessage");
|
||||
},
|
||||
handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots, toolContext }) => {
|
||||
if (action === "send") {
|
||||
const sendParams = readTelegramSendParams(params);
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
...sendParams,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
{ mediaLocalRoots },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "react") {
|
||||
const messageId = resolveReactionMessageId({ args: params, toolContext });
|
||||
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
|
||||
const remove = readBooleanParam(params, "remove");
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "react",
|
||||
chatId: readTelegramChatIdParam(params),
|
||||
messageId,
|
||||
emoji,
|
||||
remove,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
{ mediaLocalRoots },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "poll") {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const question = readStringParam(params, "pollQuestion", { required: true });
|
||||
const answers = readStringArrayParam(params, "pollOption", { required: true });
|
||||
const durationHours = readNumberParam(params, "pollDurationHours", {
|
||||
integer: true,
|
||||
strict: true,
|
||||
});
|
||||
const durationSeconds = readNumberParam(params, "pollDurationSeconds", {
|
||||
integer: true,
|
||||
strict: true,
|
||||
});
|
||||
const replyToMessageId = readNumberParam(params, "replyTo", { integer: true });
|
||||
const messageThreadId = readNumberParam(params, "threadId", { integer: true });
|
||||
const allowMultiselect = readBooleanParam(params, "pollMulti");
|
||||
const pollAnonymous = readBooleanParam(params, "pollAnonymous");
|
||||
const pollPublic = readBooleanParam(params, "pollPublic");
|
||||
const isAnonymous = resolveTelegramPollVisibility({ pollAnonymous, pollPublic });
|
||||
const silent = readBooleanParam(params, "silent");
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "poll",
|
||||
to,
|
||||
question,
|
||||
answers,
|
||||
allowMultiselect,
|
||||
durationHours: durationHours ?? undefined,
|
||||
durationSeconds: durationSeconds ?? undefined,
|
||||
replyToMessageId: replyToMessageId ?? undefined,
|
||||
messageThreadId: messageThreadId ?? undefined,
|
||||
isAnonymous,
|
||||
silent,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
{ mediaLocalRoots },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "delete") {
|
||||
const chatId = readTelegramChatIdParam(params);
|
||||
const messageId = readTelegramMessageIdParam(params);
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "deleteMessage",
|
||||
chatId,
|
||||
messageId,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
{ mediaLocalRoots },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "edit") {
|
||||
const chatId = readTelegramChatIdParam(params);
|
||||
const messageId = readTelegramMessageIdParam(params);
|
||||
const message = readStringParam(params, "message", { required: true, allowEmpty: false });
|
||||
const buttons = params.buttons;
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "editMessage",
|
||||
chatId,
|
||||
messageId,
|
||||
content: message,
|
||||
buttons,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
{ mediaLocalRoots },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "sticker") {
|
||||
const to =
|
||||
readStringParam(params, "to") ?? readStringParam(params, "target", { required: true });
|
||||
// Accept stickerId (array from shared schema) and use first element as fileId
|
||||
const stickerIds = readStringArrayParam(params, "stickerId");
|
||||
const fileId = stickerIds?.[0] ?? readStringParam(params, "fileId", { required: true });
|
||||
const replyToMessageId = readNumberParam(params, "replyTo", { integer: true });
|
||||
const messageThreadId = readNumberParam(params, "threadId", { integer: true });
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "sendSticker",
|
||||
to,
|
||||
fileId,
|
||||
replyToMessageId: replyToMessageId ?? undefined,
|
||||
messageThreadId: messageThreadId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
{ mediaLocalRoots },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "sticker-search") {
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "searchSticker",
|
||||
query,
|
||||
limit: limit ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
{ mediaLocalRoots },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "topic-create") {
|
||||
const chatId = readTelegramChatIdParam(params);
|
||||
const name = readStringParam(params, "name", { required: true });
|
||||
const iconColor = readNumberParam(params, "iconColor", { integer: true });
|
||||
const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId");
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "createForumTopic",
|
||||
chatId,
|
||||
name,
|
||||
iconColor: iconColor ?? undefined,
|
||||
iconCustomEmojiId: iconCustomEmojiId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
{ mediaLocalRoots },
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
|
||||
},
|
||||
};
|
||||
143
extensions/telegram/src/conversation-route.ts
Normal file
143
extensions/telegram/src/conversation-route.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { resolveConfiguredAcpRoute } from "../../../src/acp/persistent-bindings.route.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { logVerbose } from "../../../src/globals.js";
|
||||
import { getSessionBindingService } from "../../../src/infra/outbound/session-binding-service.js";
|
||||
import {
|
||||
buildAgentSessionKey,
|
||||
deriveLastRoutePolicy,
|
||||
pickFirstExistingAgentId,
|
||||
resolveAgentRoute,
|
||||
} from "../../../src/routing/resolve-route.js";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
resolveAgentIdFromSessionKey,
|
||||
} from "../../../src/routing/session-key.js";
|
||||
import {
|
||||
buildTelegramGroupPeerId,
|
||||
buildTelegramParentPeer,
|
||||
resolveTelegramDirectPeerId,
|
||||
} from "./bot/helpers.js";
|
||||
|
||||
export function resolveTelegramConversationRoute(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
chatId: number | string;
|
||||
isGroup: boolean;
|
||||
resolvedThreadId?: number;
|
||||
replyThreadId?: number;
|
||||
senderId?: string | number | null;
|
||||
topicAgentId?: string | null;
|
||||
}): {
|
||||
route: ReturnType<typeof resolveAgentRoute>;
|
||||
configuredBinding: ReturnType<typeof resolveConfiguredAcpRoute>["configuredBinding"];
|
||||
configuredBindingSessionKey: string;
|
||||
} {
|
||||
const peerId = params.isGroup
|
||||
? buildTelegramGroupPeerId(params.chatId, params.resolvedThreadId)
|
||||
: resolveTelegramDirectPeerId({
|
||||
chatId: params.chatId,
|
||||
senderId: params.senderId,
|
||||
});
|
||||
const parentPeer = buildTelegramParentPeer({
|
||||
isGroup: params.isGroup,
|
||||
resolvedThreadId: params.resolvedThreadId,
|
||||
chatId: params.chatId,
|
||||
});
|
||||
let route = resolveAgentRoute({
|
||||
cfg: params.cfg,
|
||||
channel: "telegram",
|
||||
accountId: params.accountId,
|
||||
peer: {
|
||||
kind: params.isGroup ? "group" : "direct",
|
||||
id: peerId,
|
||||
},
|
||||
parentPeer,
|
||||
});
|
||||
|
||||
const rawTopicAgentId = params.topicAgentId?.trim();
|
||||
if (rawTopicAgentId) {
|
||||
const topicAgentId = pickFirstExistingAgentId(params.cfg, rawTopicAgentId);
|
||||
route = {
|
||||
...route,
|
||||
agentId: topicAgentId,
|
||||
sessionKey: buildAgentSessionKey({
|
||||
agentId: topicAgentId,
|
||||
channel: "telegram",
|
||||
accountId: params.accountId,
|
||||
peer: { kind: params.isGroup ? "group" : "direct", id: peerId },
|
||||
dmScope: params.cfg.session?.dmScope,
|
||||
identityLinks: params.cfg.session?.identityLinks,
|
||||
}).toLowerCase(),
|
||||
mainSessionKey: buildAgentMainSessionKey({
|
||||
agentId: topicAgentId,
|
||||
}).toLowerCase(),
|
||||
lastRoutePolicy: deriveLastRoutePolicy({
|
||||
sessionKey: buildAgentSessionKey({
|
||||
agentId: topicAgentId,
|
||||
channel: "telegram",
|
||||
accountId: params.accountId,
|
||||
peer: { kind: params.isGroup ? "group" : "direct", id: peerId },
|
||||
dmScope: params.cfg.session?.dmScope,
|
||||
identityLinks: params.cfg.session?.identityLinks,
|
||||
}).toLowerCase(),
|
||||
mainSessionKey: buildAgentMainSessionKey({
|
||||
agentId: topicAgentId,
|
||||
}).toLowerCase(),
|
||||
}),
|
||||
};
|
||||
logVerbose(
|
||||
`telegram: topic route override: topic=${params.resolvedThreadId ?? params.replyThreadId} agent=${topicAgentId} sessionKey=${route.sessionKey}`,
|
||||
);
|
||||
}
|
||||
|
||||
const configuredRoute = resolveConfiguredAcpRoute({
|
||||
cfg: params.cfg,
|
||||
route,
|
||||
channel: "telegram",
|
||||
accountId: params.accountId,
|
||||
conversationId: peerId,
|
||||
parentConversationId: params.isGroup ? String(params.chatId) : undefined,
|
||||
});
|
||||
let configuredBinding = configuredRoute.configuredBinding;
|
||||
let configuredBindingSessionKey = configuredRoute.boundSessionKey ?? "";
|
||||
route = configuredRoute.route;
|
||||
|
||||
const threadBindingConversationId =
|
||||
params.replyThreadId != null
|
||||
? `${params.chatId}:topic:${params.replyThreadId}`
|
||||
: !params.isGroup
|
||||
? String(params.chatId)
|
||||
: undefined;
|
||||
if (threadBindingConversationId) {
|
||||
const threadBinding = getSessionBindingService().resolveByConversation({
|
||||
channel: "telegram",
|
||||
accountId: params.accountId,
|
||||
conversationId: threadBindingConversationId,
|
||||
});
|
||||
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
|
||||
if (threadBinding && boundSessionKey) {
|
||||
route = {
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: resolveAgentIdFromSessionKey(boundSessionKey),
|
||||
lastRoutePolicy: deriveLastRoutePolicy({
|
||||
sessionKey: boundSessionKey,
|
||||
mainSessionKey: route.mainSessionKey,
|
||||
}),
|
||||
matchedBy: "binding.channel",
|
||||
};
|
||||
configuredBinding = null;
|
||||
configuredBindingSessionKey = "";
|
||||
getSessionBindingService().touch(threadBinding.bindingId);
|
||||
logVerbose(
|
||||
`telegram: routed via bound conversation ${threadBindingConversationId} -> ${boundSessionKey}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
route,
|
||||
configuredBinding,
|
||||
configuredBindingSessionKey,
|
||||
};
|
||||
}
|
||||
123
extensions/telegram/src/dm-access.ts
Normal file
123
extensions/telegram/src/dm-access.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { Message } from "@grammyjs/types";
|
||||
import type { Bot } from "grammy";
|
||||
import type { DmPolicy } from "../../../src/config/types.js";
|
||||
import { logVerbose } from "../../../src/globals.js";
|
||||
import { issuePairingChallenge } from "../../../src/pairing/pairing-challenge.js";
|
||||
import { upsertChannelPairingRequest } from "../../../src/pairing/pairing-store.js";
|
||||
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
||||
import { resolveSenderAllowMatch, type NormalizedAllowFrom } from "./bot-access.js";
|
||||
|
||||
type TelegramDmAccessLogger = {
|
||||
info: (obj: Record<string, unknown>, msg: string) => void;
|
||||
};
|
||||
|
||||
type TelegramSenderIdentity = {
|
||||
username: string;
|
||||
userId: string | null;
|
||||
candidateId: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
};
|
||||
|
||||
function resolveTelegramSenderIdentity(msg: Message, chatId: number): TelegramSenderIdentity {
|
||||
const from = msg.from;
|
||||
const userId = from?.id != null ? String(from.id) : null;
|
||||
return {
|
||||
username: from?.username ?? "",
|
||||
userId,
|
||||
candidateId: userId ?? String(chatId),
|
||||
firstName: from?.first_name,
|
||||
lastName: from?.last_name,
|
||||
};
|
||||
}
|
||||
|
||||
export async function enforceTelegramDmAccess(params: {
|
||||
isGroup: boolean;
|
||||
dmPolicy: DmPolicy;
|
||||
msg: Message;
|
||||
chatId: number;
|
||||
effectiveDmAllow: NormalizedAllowFrom;
|
||||
accountId: string;
|
||||
bot: Bot;
|
||||
logger: TelegramDmAccessLogger;
|
||||
}): Promise<boolean> {
|
||||
const { isGroup, dmPolicy, msg, chatId, effectiveDmAllow, accountId, bot, logger } = params;
|
||||
if (isGroup) {
|
||||
return true;
|
||||
}
|
||||
if (dmPolicy === "disabled") {
|
||||
return false;
|
||||
}
|
||||
if (dmPolicy === "open") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const sender = resolveTelegramSenderIdentity(msg, chatId);
|
||||
const allowMatch = resolveSenderAllowMatch({
|
||||
allow: effectiveDmAllow,
|
||||
senderId: sender.candidateId,
|
||||
senderUsername: sender.username,
|
||||
});
|
||||
const allowMatchMeta = `matchKey=${allowMatch.matchKey ?? "none"} matchSource=${
|
||||
allowMatch.matchSource ?? "none"
|
||||
}`;
|
||||
const allowed =
|
||||
effectiveDmAllow.hasWildcard || (effectiveDmAllow.hasEntries && allowMatch.allowed);
|
||||
if (allowed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (dmPolicy === "pairing") {
|
||||
try {
|
||||
const telegramUserId = sender.userId ?? sender.candidateId;
|
||||
await issuePairingChallenge({
|
||||
channel: "telegram",
|
||||
senderId: telegramUserId,
|
||||
senderIdLine: `Your Telegram user id: ${telegramUserId}`,
|
||||
meta: {
|
||||
username: sender.username || undefined,
|
||||
firstName: sender.firstName,
|
||||
lastName: sender.lastName,
|
||||
},
|
||||
upsertPairingRequest: async ({ id, meta }) =>
|
||||
await upsertChannelPairingRequest({
|
||||
channel: "telegram",
|
||||
id,
|
||||
accountId,
|
||||
meta,
|
||||
}),
|
||||
onCreated: () => {
|
||||
logger.info(
|
||||
{
|
||||
chatId: String(chatId),
|
||||
senderUserId: sender.userId ?? undefined,
|
||||
username: sender.username || undefined,
|
||||
firstName: sender.firstName,
|
||||
lastName: sender.lastName,
|
||||
matchKey: allowMatch.matchKey ?? "none",
|
||||
matchSource: allowMatch.matchSource ?? "none",
|
||||
},
|
||||
"telegram pairing request",
|
||||
);
|
||||
},
|
||||
sendPairingReply: async (text) => {
|
||||
await withTelegramApiErrorLogging({
|
||||
operation: "sendMessage",
|
||||
fn: () => bot.api.sendMessage(chatId, text),
|
||||
});
|
||||
},
|
||||
onReplyError: (err) => {
|
||||
logVerbose(`telegram pairing reply failed for chat ${chatId}: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(`telegram pairing reply failed for chat ${chatId}: ${String(err)}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
logVerbose(
|
||||
`Blocked unauthorized telegram sender ${sender.candidateId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
|
||||
|
||||
describe("resolveTelegramDraftStreamingChunking", () => {
|
||||
41
extensions/telegram/src/draft-chunking.ts
Normal file
41
extensions/telegram/src/draft-chunking.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js";
|
||||
import { getChannelDock } from "../../../src/channels/dock.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { resolveAccountEntry } from "../../../src/routing/account-lookup.js";
|
||||
import { normalizeAccountId } from "../../../src/routing/session-key.js";
|
||||
|
||||
const DEFAULT_TELEGRAM_DRAFT_STREAM_MIN = 200;
|
||||
const DEFAULT_TELEGRAM_DRAFT_STREAM_MAX = 800;
|
||||
|
||||
export function resolveTelegramDraftStreamingChunking(
|
||||
cfg: OpenClawConfig | undefined,
|
||||
accountId?: string | null,
|
||||
): {
|
||||
minChars: number;
|
||||
maxChars: number;
|
||||
breakPreference: "paragraph" | "newline" | "sentence";
|
||||
} {
|
||||
const providerChunkLimit = getChannelDock("telegram")?.outbound?.textChunkLimit;
|
||||
const textLimit = resolveTextChunkLimit(cfg, "telegram", accountId, {
|
||||
fallbackLimit: providerChunkLimit,
|
||||
});
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const accountCfg = resolveAccountEntry(cfg?.channels?.telegram?.accounts, normalizedAccountId);
|
||||
const draftCfg = accountCfg?.draftChunk ?? cfg?.channels?.telegram?.draftChunk;
|
||||
|
||||
const maxRequested = Math.max(
|
||||
1,
|
||||
Math.floor(draftCfg?.maxChars ?? DEFAULT_TELEGRAM_DRAFT_STREAM_MAX),
|
||||
);
|
||||
const maxChars = Math.max(1, Math.min(maxRequested, textLimit));
|
||||
const minRequested = Math.max(
|
||||
1,
|
||||
Math.floor(draftCfg?.minChars ?? DEFAULT_TELEGRAM_DRAFT_STREAM_MIN),
|
||||
);
|
||||
const minChars = Math.min(minRequested, maxChars);
|
||||
const breakPreference =
|
||||
draftCfg?.breakPreference === "newline" || draftCfg?.breakPreference === "sentence"
|
||||
? draftCfg.breakPreference
|
||||
: "paragraph";
|
||||
return { minChars, maxChars, breakPreference };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Bot } from "grammy";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { importFreshModule } from "../../test/helpers/import-fresh.js";
|
||||
import { importFreshModule } from "../../../test/helpers/import-fresh.js";
|
||||
import { __testing, createTelegramDraftStream } from "./draft-stream.js";
|
||||
|
||||
type TelegramDraftStreamParams = Parameters<typeof createTelegramDraftStream>[0];
|
||||
459
extensions/telegram/src/draft-stream.ts
Normal file
459
extensions/telegram/src/draft-stream.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import type { Bot } from "grammy";
|
||||
import { createFinalizableDraftLifecycle } from "../../../src/channels/draft-stream-controls.js";
|
||||
import { resolveGlobalSingleton } from "../../../src/shared/global-singleton.js";
|
||||
import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helpers.js";
|
||||
import { isSafeToRetrySendError, isTelegramClientRejection } from "./network-errors.js";
|
||||
|
||||
const TELEGRAM_STREAM_MAX_CHARS = 4096;
|
||||
const DEFAULT_THROTTLE_MS = 1000;
|
||||
const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647;
|
||||
const THREAD_NOT_FOUND_RE = /400:\s*Bad Request:\s*message thread not found/i;
|
||||
const DRAFT_METHOD_UNAVAILABLE_RE =
|
||||
/(unknown method|method .*not (found|available|supported)|unsupported)/i;
|
||||
const DRAFT_CHAT_UNSUPPORTED_RE = /(can't be used|can be used only)/i;
|
||||
|
||||
type TelegramSendMessageDraft = (
|
||||
chatId: number,
|
||||
draftId: number,
|
||||
text: string,
|
||||
params?: {
|
||||
message_thread_id?: number;
|
||||
parse_mode?: "HTML";
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Keep draft-id allocation shared across bundled chunks so concurrent preview
|
||||
* lanes do not accidentally reuse draft ids when code-split entries coexist.
|
||||
*/
|
||||
const TELEGRAM_DRAFT_STREAM_STATE_KEY = Symbol.for("openclaw.telegramDraftStreamState");
|
||||
|
||||
const draftStreamState = resolveGlobalSingleton(TELEGRAM_DRAFT_STREAM_STATE_KEY, () => ({
|
||||
nextDraftId: 0,
|
||||
}));
|
||||
|
||||
function allocateTelegramDraftId(): number {
|
||||
draftStreamState.nextDraftId =
|
||||
draftStreamState.nextDraftId >= TELEGRAM_DRAFT_ID_MAX ? 1 : draftStreamState.nextDraftId + 1;
|
||||
return draftStreamState.nextDraftId;
|
||||
}
|
||||
|
||||
function resolveSendMessageDraftApi(api: Bot["api"]): TelegramSendMessageDraft | undefined {
|
||||
const sendMessageDraft = (api as Bot["api"] & { sendMessageDraft?: TelegramSendMessageDraft })
|
||||
.sendMessageDraft;
|
||||
if (typeof sendMessageDraft !== "function") {
|
||||
return undefined;
|
||||
}
|
||||
return sendMessageDraft.bind(api as object);
|
||||
}
|
||||
|
||||
function shouldFallbackFromDraftTransport(err: unknown): boolean {
|
||||
const text =
|
||||
typeof err === "string"
|
||||
? err
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: typeof err === "object" && err && "description" in err
|
||||
? typeof err.description === "string"
|
||||
? err.description
|
||||
: ""
|
||||
: "";
|
||||
if (!/sendMessageDraft/i.test(text)) {
|
||||
return false;
|
||||
}
|
||||
return DRAFT_METHOD_UNAVAILABLE_RE.test(text) || DRAFT_CHAT_UNSUPPORTED_RE.test(text);
|
||||
}
|
||||
|
||||
export type TelegramDraftStream = {
|
||||
update: (text: string) => void;
|
||||
flush: () => Promise<void>;
|
||||
messageId: () => number | undefined;
|
||||
previewMode?: () => "message" | "draft";
|
||||
previewRevision?: () => number;
|
||||
lastDeliveredText?: () => string;
|
||||
clear: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
/** Convert the current draft preview into a permanent message (sendMessage). */
|
||||
materialize?: () => Promise<number | undefined>;
|
||||
/** Reset internal state so the next update creates a new message instead of editing. */
|
||||
forceNewMessage: () => void;
|
||||
/** True when a preview sendMessage was attempted but the response was lost. */
|
||||
sendMayHaveLanded?: () => boolean;
|
||||
};
|
||||
|
||||
type TelegramDraftPreview = {
|
||||
text: string;
|
||||
parseMode?: "HTML";
|
||||
};
|
||||
|
||||
type SupersededTelegramPreview = {
|
||||
messageId: number;
|
||||
textSnapshot: string;
|
||||
parseMode?: "HTML";
|
||||
};
|
||||
|
||||
export function createTelegramDraftStream(params: {
|
||||
api: Bot["api"];
|
||||
chatId: number;
|
||||
maxChars?: number;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
previewTransport?: "auto" | "message" | "draft";
|
||||
replyToMessageId?: number;
|
||||
throttleMs?: number;
|
||||
/** Minimum chars before sending first message (debounce for push notifications) */
|
||||
minInitialChars?: number;
|
||||
/** Optional preview renderer (e.g. markdown -> HTML + parse mode). */
|
||||
renderText?: (text: string) => TelegramDraftPreview;
|
||||
/** Called when a late send resolves after forceNewMessage() switched generations. */
|
||||
onSupersededPreview?: (preview: SupersededTelegramPreview) => void;
|
||||
log?: (message: string) => void;
|
||||
warn?: (message: string) => void;
|
||||
}): TelegramDraftStream {
|
||||
const maxChars = Math.min(
|
||||
params.maxChars ?? TELEGRAM_STREAM_MAX_CHARS,
|
||||
TELEGRAM_STREAM_MAX_CHARS,
|
||||
);
|
||||
const throttleMs = Math.max(250, params.throttleMs ?? DEFAULT_THROTTLE_MS);
|
||||
const minInitialChars = params.minInitialChars;
|
||||
const chatId = params.chatId;
|
||||
const requestedPreviewTransport = params.previewTransport ?? "auto";
|
||||
const prefersDraftTransport =
|
||||
requestedPreviewTransport === "draft"
|
||||
? true
|
||||
: requestedPreviewTransport === "message"
|
||||
? false
|
||||
: params.thread?.scope === "dm";
|
||||
const threadParams = buildTelegramThreadParams(params.thread);
|
||||
const replyParams =
|
||||
params.replyToMessageId != null
|
||||
? { ...threadParams, reply_to_message_id: params.replyToMessageId }
|
||||
: threadParams;
|
||||
const resolvedDraftApi = prefersDraftTransport
|
||||
? resolveSendMessageDraftApi(params.api)
|
||||
: undefined;
|
||||
const usesDraftTransport = Boolean(prefersDraftTransport && resolvedDraftApi);
|
||||
if (prefersDraftTransport && !usesDraftTransport) {
|
||||
params.warn?.(
|
||||
"telegram stream preview: sendMessageDraft unavailable; falling back to sendMessage/editMessageText",
|
||||
);
|
||||
}
|
||||
|
||||
const streamState = { stopped: false, final: false };
|
||||
let messageSendAttempted = false;
|
||||
let streamMessageId: number | undefined;
|
||||
let streamDraftId = usesDraftTransport ? allocateTelegramDraftId() : undefined;
|
||||
let previewTransport: "message" | "draft" = usesDraftTransport ? "draft" : "message";
|
||||
let lastSentText = "";
|
||||
let lastDeliveredText = "";
|
||||
let lastSentParseMode: "HTML" | undefined;
|
||||
let previewRevision = 0;
|
||||
let generation = 0;
|
||||
type PreviewSendParams = {
|
||||
renderedText: string;
|
||||
renderedParseMode: "HTML" | undefined;
|
||||
sendGeneration: number;
|
||||
};
|
||||
const sendRenderedMessageWithThreadFallback = async (sendArgs: {
|
||||
renderedText: string;
|
||||
renderedParseMode: "HTML" | undefined;
|
||||
fallbackWarnMessage: string;
|
||||
}) => {
|
||||
const sendParams = sendArgs.renderedParseMode
|
||||
? {
|
||||
...replyParams,
|
||||
parse_mode: sendArgs.renderedParseMode,
|
||||
}
|
||||
: replyParams;
|
||||
const usedThreadParams =
|
||||
"message_thread_id" in (sendParams ?? {}) &&
|
||||
typeof (sendParams as { message_thread_id?: unknown }).message_thread_id === "number";
|
||||
try {
|
||||
return {
|
||||
sent: await params.api.sendMessage(chatId, sendArgs.renderedText, sendParams),
|
||||
usedThreadParams,
|
||||
};
|
||||
} catch (err) {
|
||||
if (!usedThreadParams || !THREAD_NOT_FOUND_RE.test(String(err))) {
|
||||
throw err;
|
||||
}
|
||||
const threadlessParams = {
|
||||
...(sendParams as Record<string, unknown>),
|
||||
};
|
||||
delete threadlessParams.message_thread_id;
|
||||
params.warn?.(sendArgs.fallbackWarnMessage);
|
||||
return {
|
||||
sent: await params.api.sendMessage(
|
||||
chatId,
|
||||
sendArgs.renderedText,
|
||||
Object.keys(threadlessParams).length > 0 ? threadlessParams : undefined,
|
||||
),
|
||||
usedThreadParams: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
const sendMessageTransportPreview = async ({
|
||||
renderedText,
|
||||
renderedParseMode,
|
||||
sendGeneration,
|
||||
}: PreviewSendParams): Promise<boolean> => {
|
||||
if (typeof streamMessageId === "number") {
|
||||
if (renderedParseMode) {
|
||||
await params.api.editMessageText(chatId, streamMessageId, renderedText, {
|
||||
parse_mode: renderedParseMode,
|
||||
});
|
||||
} else {
|
||||
await params.api.editMessageText(chatId, streamMessageId, renderedText);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
messageSendAttempted = true;
|
||||
let sent: Awaited<ReturnType<typeof sendRenderedMessageWithThreadFallback>>["sent"];
|
||||
try {
|
||||
({ sent } = await sendRenderedMessageWithThreadFallback({
|
||||
renderedText,
|
||||
renderedParseMode,
|
||||
fallbackWarnMessage:
|
||||
"telegram stream preview send failed with message_thread_id, retrying without thread",
|
||||
}));
|
||||
} catch (err) {
|
||||
// Pre-connect failures (DNS, refused) and explicit Telegram rejections (4xx)
|
||||
// guarantee the message was never delivered — clear the flag so
|
||||
// sendMayHaveLanded() doesn't suppress fallback.
|
||||
if (isSafeToRetrySendError(err) || isTelegramClientRejection(err)) {
|
||||
messageSendAttempted = false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const sentMessageId = sent?.message_id;
|
||||
if (typeof sentMessageId !== "number" || !Number.isFinite(sentMessageId)) {
|
||||
streamState.stopped = true;
|
||||
params.warn?.("telegram stream preview stopped (missing message id from sendMessage)");
|
||||
return false;
|
||||
}
|
||||
const normalizedMessageId = Math.trunc(sentMessageId);
|
||||
if (sendGeneration !== generation) {
|
||||
params.onSupersededPreview?.({
|
||||
messageId: normalizedMessageId,
|
||||
textSnapshot: renderedText,
|
||||
parseMode: renderedParseMode,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
streamMessageId = normalizedMessageId;
|
||||
return true;
|
||||
};
|
||||
const sendDraftTransportPreview = async ({
|
||||
renderedText,
|
||||
renderedParseMode,
|
||||
}: PreviewSendParams): Promise<boolean> => {
|
||||
const draftId = streamDraftId ?? allocateTelegramDraftId();
|
||||
streamDraftId = draftId;
|
||||
const draftParams = {
|
||||
...(threadParams?.message_thread_id != null
|
||||
? { message_thread_id: threadParams.message_thread_id }
|
||||
: {}),
|
||||
...(renderedParseMode ? { parse_mode: renderedParseMode } : {}),
|
||||
};
|
||||
await resolvedDraftApi!(
|
||||
chatId,
|
||||
draftId,
|
||||
renderedText,
|
||||
Object.keys(draftParams).length > 0 ? draftParams : undefined,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
const sendOrEditStreamMessage = async (text: string): Promise<boolean> => {
|
||||
// Allow final flush even if stopped (e.g., after clear()).
|
||||
if (streamState.stopped && !streamState.final) {
|
||||
return false;
|
||||
}
|
||||
const trimmed = text.trimEnd();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const rendered = params.renderText?.(trimmed) ?? { text: trimmed };
|
||||
const renderedText = rendered.text.trimEnd();
|
||||
const renderedParseMode = rendered.parseMode;
|
||||
if (!renderedText) {
|
||||
return false;
|
||||
}
|
||||
if (renderedText.length > maxChars) {
|
||||
// Telegram text messages/edits cap at 4096 chars.
|
||||
// Stop streaming once we exceed the cap to avoid repeated API failures.
|
||||
streamState.stopped = true;
|
||||
params.warn?.(
|
||||
`telegram stream preview stopped (text length ${renderedText.length} > ${maxChars})`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (renderedText === lastSentText && renderedParseMode === lastSentParseMode) {
|
||||
return true;
|
||||
}
|
||||
const sendGeneration = generation;
|
||||
|
||||
// Debounce first preview send for better push notification quality.
|
||||
if (typeof streamMessageId !== "number" && minInitialChars != null && !streamState.final) {
|
||||
if (renderedText.length < minInitialChars) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
lastSentText = renderedText;
|
||||
lastSentParseMode = renderedParseMode;
|
||||
try {
|
||||
let sent = false;
|
||||
if (previewTransport === "draft") {
|
||||
try {
|
||||
sent = await sendDraftTransportPreview({
|
||||
renderedText,
|
||||
renderedParseMode,
|
||||
sendGeneration,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!shouldFallbackFromDraftTransport(err)) {
|
||||
throw err;
|
||||
}
|
||||
previewTransport = "message";
|
||||
streamDraftId = undefined;
|
||||
params.warn?.(
|
||||
"telegram stream preview: sendMessageDraft rejected by API; falling back to sendMessage/editMessageText",
|
||||
);
|
||||
sent = await sendMessageTransportPreview({
|
||||
renderedText,
|
||||
renderedParseMode,
|
||||
sendGeneration,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
sent = await sendMessageTransportPreview({
|
||||
renderedText,
|
||||
renderedParseMode,
|
||||
sendGeneration,
|
||||
});
|
||||
}
|
||||
if (sent) {
|
||||
previewRevision += 1;
|
||||
lastDeliveredText = trimmed;
|
||||
}
|
||||
return sent;
|
||||
} catch (err) {
|
||||
streamState.stopped = true;
|
||||
params.warn?.(
|
||||
`telegram stream preview failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const { loop, update, stop, clear } = createFinalizableDraftLifecycle({
|
||||
throttleMs,
|
||||
state: streamState,
|
||||
sendOrEditStreamMessage,
|
||||
readMessageId: () => streamMessageId,
|
||||
clearMessageId: () => {
|
||||
streamMessageId = undefined;
|
||||
},
|
||||
isValidMessageId: (value): value is number =>
|
||||
typeof value === "number" && Number.isFinite(value),
|
||||
deleteMessage: async (messageId) => {
|
||||
await params.api.deleteMessage(chatId, messageId);
|
||||
},
|
||||
onDeleteSuccess: (messageId) => {
|
||||
params.log?.(`telegram stream preview deleted (chat=${chatId}, message=${messageId})`);
|
||||
},
|
||||
warn: params.warn,
|
||||
warnPrefix: "telegram stream preview cleanup failed",
|
||||
});
|
||||
|
||||
const forceNewMessage = () => {
|
||||
// Boundary rotation may call stop() to finalize the previous draft.
|
||||
// Re-open the stream lifecycle for the next assistant segment.
|
||||
streamState.final = false;
|
||||
generation += 1;
|
||||
messageSendAttempted = false;
|
||||
streamMessageId = undefined;
|
||||
if (previewTransport === "draft") {
|
||||
streamDraftId = allocateTelegramDraftId();
|
||||
}
|
||||
lastSentText = "";
|
||||
lastSentParseMode = undefined;
|
||||
loop.resetPending();
|
||||
loop.resetThrottleWindow();
|
||||
};
|
||||
|
||||
/**
|
||||
* Materialize the current draft into a permanent message.
|
||||
* For draft transport: sends the accumulated text as a real sendMessage.
|
||||
* For message transport: the message is already permanent (noop).
|
||||
* Returns the permanent message id, or undefined if nothing to materialize.
|
||||
*/
|
||||
const materialize = async (): Promise<number | undefined> => {
|
||||
await stop();
|
||||
// If using message transport, the streamMessageId is already a real message.
|
||||
if (previewTransport === "message" && typeof streamMessageId === "number") {
|
||||
return streamMessageId;
|
||||
}
|
||||
// For draft transport, use the rendered snapshot first so parse_mode stays
|
||||
// aligned with the text being materialized.
|
||||
const renderedText = lastSentText || lastDeliveredText;
|
||||
if (!renderedText) {
|
||||
return undefined;
|
||||
}
|
||||
const renderedParseMode = lastSentText ? lastSentParseMode : undefined;
|
||||
try {
|
||||
const { sent, usedThreadParams } = await sendRenderedMessageWithThreadFallback({
|
||||
renderedText,
|
||||
renderedParseMode,
|
||||
fallbackWarnMessage:
|
||||
"telegram stream preview materialize send failed with message_thread_id, retrying without thread",
|
||||
});
|
||||
const sentId = sent?.message_id;
|
||||
if (typeof sentId === "number" && Number.isFinite(sentId)) {
|
||||
streamMessageId = Math.trunc(sentId);
|
||||
// Clear the draft so Telegram's input area doesn't briefly show a
|
||||
// stale copy alongside the newly materialized real message.
|
||||
if (resolvedDraftApi != null && streamDraftId != null) {
|
||||
const clearDraftId = streamDraftId;
|
||||
const clearThreadParams =
|
||||
usedThreadParams && threadParams?.message_thread_id != null
|
||||
? { message_thread_id: threadParams.message_thread_id }
|
||||
: undefined;
|
||||
try {
|
||||
await resolvedDraftApi(chatId, clearDraftId, "", clearThreadParams);
|
||||
} catch {
|
||||
// Best-effort cleanup; draft clear failure is cosmetic.
|
||||
}
|
||||
}
|
||||
return streamMessageId;
|
||||
}
|
||||
} catch (err) {
|
||||
params.warn?.(
|
||||
`telegram stream preview materialize failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
params.log?.(`telegram stream preview ready (maxChars=${maxChars}, throttleMs=${throttleMs})`);
|
||||
|
||||
return {
|
||||
update,
|
||||
flush: loop.flush,
|
||||
messageId: () => streamMessageId,
|
||||
previewMode: () => previewTransport,
|
||||
previewRevision: () => previewRevision,
|
||||
lastDeliveredText: () => lastDeliveredText,
|
||||
clear,
|
||||
stop,
|
||||
materialize,
|
||||
forceNewMessage,
|
||||
sendMayHaveLanded: () => messageSendAttempted && typeof streamMessageId !== "number",
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resetTelegramDraftStreamForTests() {
|
||||
draftStreamState.nextDraftId = 0;
|
||||
},
|
||||
};
|
||||
156
extensions/telegram/src/exec-approvals-handler.test.ts
Normal file
156
extensions/telegram/src/exec-approvals-handler.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { TelegramExecApprovalHandler } from "./exec-approvals-handler.js";
|
||||
|
||||
const baseRequest = {
|
||||
id: "9f1c7d5d-b1fb-46ef-ac45-662723b65bb7",
|
||||
request: {
|
||||
command: "npm view diver name version description",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:group:-1003841603622:topic:928",
|
||||
turnSourceChannel: "telegram",
|
||||
turnSourceTo: "-1003841603622",
|
||||
turnSourceThreadId: "928",
|
||||
turnSourceAccountId: "default",
|
||||
},
|
||||
createdAtMs: 1000,
|
||||
expiresAtMs: 61_000,
|
||||
};
|
||||
|
||||
function createHandler(cfg: OpenClawConfig) {
|
||||
const sendTyping = vi.fn().mockResolvedValue({ ok: true });
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ messageId: "m1", chatId: "-1003841603622" })
|
||||
.mockResolvedValue({ messageId: "m2", chatId: "8460800771" });
|
||||
const editReplyMarkup = vi.fn().mockResolvedValue({ ok: true });
|
||||
const handler = new TelegramExecApprovalHandler(
|
||||
{
|
||||
token: "tg-token",
|
||||
accountId: "default",
|
||||
cfg,
|
||||
},
|
||||
{
|
||||
nowMs: () => 1000,
|
||||
sendTyping,
|
||||
sendMessage,
|
||||
editReplyMarkup,
|
||||
},
|
||||
);
|
||||
return { handler, sendTyping, sendMessage, editReplyMarkup };
|
||||
}
|
||||
|
||||
describe("TelegramExecApprovalHandler", () => {
|
||||
it("sends approval prompts to the originating telegram topic when target=channel", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["8460800771"],
|
||||
target: "channel",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const { handler, sendTyping, sendMessage } = createHandler(cfg);
|
||||
|
||||
await handler.handleRequested(baseRequest);
|
||||
|
||||
expect(sendTyping).toHaveBeenCalledWith(
|
||||
"-1003841603622",
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
messageThreadId: 928,
|
||||
}),
|
||||
);
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"-1003841603622",
|
||||
expect.stringContaining("/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once"),
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
messageThreadId: 928,
|
||||
buttons: [
|
||||
[
|
||||
{
|
||||
text: "Allow Once",
|
||||
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once",
|
||||
},
|
||||
{
|
||||
text: "Allow Always",
|
||||
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-always",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
text: "Deny",
|
||||
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 deny",
|
||||
},
|
||||
],
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to approver DMs when channel routing is unavailable", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["111", "222"],
|
||||
target: "channel",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const { handler, sendMessage } = createHandler(cfg);
|
||||
|
||||
await handler.handleRequested({
|
||||
...baseRequest,
|
||||
request: {
|
||||
...baseRequest.request,
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceTo: "U1",
|
||||
turnSourceAccountId: null,
|
||||
turnSourceThreadId: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(2);
|
||||
expect(sendMessage.mock.calls.map((call) => call[0])).toEqual(["111", "222"]);
|
||||
});
|
||||
|
||||
it("clears buttons from tracked approval messages when resolved", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["8460800771"],
|
||||
target: "both",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const { handler, editReplyMarkup } = createHandler(cfg);
|
||||
|
||||
await handler.handleRequested(baseRequest);
|
||||
await handler.handleResolved({
|
||||
id: baseRequest.id,
|
||||
decision: "allow-once",
|
||||
resolvedBy: "telegram:8460800771",
|
||||
ts: 2000,
|
||||
});
|
||||
|
||||
expect(editReplyMarkup).toHaveBeenCalled();
|
||||
expect(editReplyMarkup).toHaveBeenCalledWith(
|
||||
"-1003841603622",
|
||||
"m1",
|
||||
[],
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
372
extensions/telegram/src/exec-approvals-handler.ts
Normal file
372
extensions/telegram/src/exec-approvals-handler.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { GatewayClient } from "../../../src/gateway/client.js";
|
||||
import { createOperatorApprovalsGatewayClient } from "../../../src/gateway/operator-approvals-client.js";
|
||||
import type { EventFrame } from "../../../src/gateway/protocol/index.js";
|
||||
import { resolveExecApprovalCommandDisplay } from "../../../src/infra/exec-approval-command-display.js";
|
||||
import {
|
||||
buildExecApprovalPendingReplyPayload,
|
||||
type ExecApprovalPendingReplyParams,
|
||||
} from "../../../src/infra/exec-approval-reply.js";
|
||||
import { resolveExecApprovalSessionTarget } from "../../../src/infra/exec-approval-session-target.js";
|
||||
import type {
|
||||
ExecApprovalRequest,
|
||||
ExecApprovalResolved,
|
||||
} from "../../../src/infra/exec-approvals.js";
|
||||
import { createSubsystemLogger } from "../../../src/logging/subsystem.js";
|
||||
import { normalizeAccountId, parseAgentSessionKey } from "../../../src/routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
import { compileSafeRegex, testRegexWithBoundedInput } from "../../../src/security/safe-regex.js";
|
||||
import { buildTelegramExecApprovalButtons } from "./approval-buttons.js";
|
||||
import {
|
||||
getTelegramExecApprovalApprovers,
|
||||
resolveTelegramExecApprovalConfig,
|
||||
resolveTelegramExecApprovalTarget,
|
||||
} from "./exec-approvals.js";
|
||||
import { editMessageReplyMarkupTelegram, sendMessageTelegram, sendTypingTelegram } from "./send.js";
|
||||
|
||||
const log = createSubsystemLogger("telegram/exec-approvals");
|
||||
|
||||
type PendingMessage = {
|
||||
chatId: string;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
type PendingApproval = {
|
||||
timeoutId: NodeJS.Timeout;
|
||||
messages: PendingMessage[];
|
||||
};
|
||||
|
||||
type TelegramApprovalTarget = {
|
||||
to: string;
|
||||
threadId?: number;
|
||||
};
|
||||
|
||||
export type TelegramExecApprovalHandlerOpts = {
|
||||
token: string;
|
||||
accountId: string;
|
||||
cfg: OpenClawConfig;
|
||||
gatewayUrl?: string;
|
||||
runtime?: RuntimeEnv;
|
||||
};
|
||||
|
||||
export type TelegramExecApprovalHandlerDeps = {
|
||||
nowMs?: () => number;
|
||||
sendTyping?: typeof sendTypingTelegram;
|
||||
sendMessage?: typeof sendMessageTelegram;
|
||||
editReplyMarkup?: typeof editMessageReplyMarkupTelegram;
|
||||
};
|
||||
|
||||
function matchesFilters(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
request: ExecApprovalRequest;
|
||||
}): boolean {
|
||||
const config = resolveTelegramExecApprovalConfig({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if (!config?.enabled) {
|
||||
return false;
|
||||
}
|
||||
const approvers = getTelegramExecApprovalApprovers({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if (approvers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (config.agentFilter?.length) {
|
||||
const agentId =
|
||||
params.request.request.agentId ??
|
||||
parseAgentSessionKey(params.request.request.sessionKey)?.agentId;
|
||||
if (!agentId || !config.agentFilter.includes(agentId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (config.sessionFilter?.length) {
|
||||
const sessionKey = params.request.request.sessionKey;
|
||||
if (!sessionKey) {
|
||||
return false;
|
||||
}
|
||||
const matches = config.sessionFilter.some((pattern) => {
|
||||
if (sessionKey.includes(pattern)) {
|
||||
return true;
|
||||
}
|
||||
const regex = compileSafeRegex(pattern);
|
||||
return regex ? testRegexWithBoundedInput(regex, sessionKey) : false;
|
||||
});
|
||||
if (!matches) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isHandlerConfigured(params: { cfg: OpenClawConfig; accountId: string }): boolean {
|
||||
const config = resolveTelegramExecApprovalConfig({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if (!config?.enabled) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
getTelegramExecApprovalApprovers({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
}).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRequestSessionTarget(params: {
|
||||
cfg: OpenClawConfig;
|
||||
request: ExecApprovalRequest;
|
||||
}): { to: string; accountId?: string; threadId?: number; channel?: string } | null {
|
||||
return resolveExecApprovalSessionTarget({
|
||||
cfg: params.cfg,
|
||||
request: params.request,
|
||||
turnSourceChannel: params.request.request.turnSourceChannel ?? undefined,
|
||||
turnSourceTo: params.request.request.turnSourceTo ?? undefined,
|
||||
turnSourceAccountId: params.request.request.turnSourceAccountId ?? undefined,
|
||||
turnSourceThreadId: params.request.request.turnSourceThreadId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveTelegramSourceTarget(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
request: ExecApprovalRequest;
|
||||
}): TelegramApprovalTarget | null {
|
||||
const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || "";
|
||||
const turnSourceTo = params.request.request.turnSourceTo?.trim() || "";
|
||||
const turnSourceAccountId = params.request.request.turnSourceAccountId?.trim() || "";
|
||||
if (turnSourceChannel === "telegram" && turnSourceTo) {
|
||||
if (
|
||||
turnSourceAccountId &&
|
||||
normalizeAccountId(turnSourceAccountId) !== normalizeAccountId(params.accountId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const threadId =
|
||||
typeof params.request.request.turnSourceThreadId === "number"
|
||||
? params.request.request.turnSourceThreadId
|
||||
: typeof params.request.request.turnSourceThreadId === "string"
|
||||
? Number.parseInt(params.request.request.turnSourceThreadId, 10)
|
||||
: undefined;
|
||||
return { to: turnSourceTo, threadId: Number.isFinite(threadId) ? threadId : undefined };
|
||||
}
|
||||
|
||||
const sessionTarget = resolveRequestSessionTarget(params);
|
||||
if (!sessionTarget || sessionTarget.channel !== "telegram") {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
sessionTarget.accountId &&
|
||||
normalizeAccountId(sessionTarget.accountId) !== normalizeAccountId(params.accountId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to: sessionTarget.to,
|
||||
threadId: sessionTarget.threadId,
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeTargets(targets: TelegramApprovalTarget[]): TelegramApprovalTarget[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: TelegramApprovalTarget[] = [];
|
||||
for (const target of targets) {
|
||||
const key = `${target.to}:${target.threadId ?? ""}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
deduped.push(target);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export class TelegramExecApprovalHandler {
|
||||
private gatewayClient: GatewayClient | null = null;
|
||||
private pending = new Map<string, PendingApproval>();
|
||||
private started = false;
|
||||
private readonly nowMs: () => number;
|
||||
private readonly sendTyping: typeof sendTypingTelegram;
|
||||
private readonly sendMessage: typeof sendMessageTelegram;
|
||||
private readonly editReplyMarkup: typeof editMessageReplyMarkupTelegram;
|
||||
|
||||
constructor(
|
||||
private readonly opts: TelegramExecApprovalHandlerOpts,
|
||||
deps: TelegramExecApprovalHandlerDeps = {},
|
||||
) {
|
||||
this.nowMs = deps.nowMs ?? Date.now;
|
||||
this.sendTyping = deps.sendTyping ?? sendTypingTelegram;
|
||||
this.sendMessage = deps.sendMessage ?? sendMessageTelegram;
|
||||
this.editReplyMarkup = deps.editReplyMarkup ?? editMessageReplyMarkupTelegram;
|
||||
}
|
||||
|
||||
shouldHandle(request: ExecApprovalRequest): boolean {
|
||||
return matchesFilters({
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
request,
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.started) {
|
||||
return;
|
||||
}
|
||||
this.started = true;
|
||||
|
||||
if (!isHandlerConfigured({ cfg: this.opts.cfg, accountId: this.opts.accountId })) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.gatewayClient = await createOperatorApprovalsGatewayClient({
|
||||
config: this.opts.cfg,
|
||||
gatewayUrl: this.opts.gatewayUrl,
|
||||
clientDisplayName: `Telegram Exec Approvals (${this.opts.accountId})`,
|
||||
onEvent: (evt) => this.handleGatewayEvent(evt),
|
||||
onConnectError: (err) => {
|
||||
log.error(`telegram exec approvals: connect error: ${err.message}`);
|
||||
},
|
||||
});
|
||||
this.gatewayClient.start();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.started) {
|
||||
return;
|
||||
}
|
||||
this.started = false;
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
}
|
||||
this.pending.clear();
|
||||
this.gatewayClient?.stop();
|
||||
this.gatewayClient = null;
|
||||
}
|
||||
|
||||
async handleRequested(request: ExecApprovalRequest): Promise<void> {
|
||||
if (!this.shouldHandle(request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetMode = resolveTelegramExecApprovalTarget({
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
});
|
||||
const targets: TelegramApprovalTarget[] = [];
|
||||
const sourceTarget = resolveTelegramSourceTarget({
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
request,
|
||||
});
|
||||
let fallbackToDm = false;
|
||||
if (targetMode === "channel" || targetMode === "both") {
|
||||
if (sourceTarget) {
|
||||
targets.push(sourceTarget);
|
||||
} else {
|
||||
fallbackToDm = true;
|
||||
}
|
||||
}
|
||||
if (targetMode === "dm" || targetMode === "both" || fallbackToDm) {
|
||||
for (const approver of getTelegramExecApprovalApprovers({
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
})) {
|
||||
targets.push({ to: approver });
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedTargets = dedupeTargets(targets);
|
||||
if (resolvedTargets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadParams: ExecApprovalPendingReplyParams = {
|
||||
approvalId: request.id,
|
||||
approvalSlug: request.id.slice(0, 8),
|
||||
approvalCommandId: request.id,
|
||||
command: resolveExecApprovalCommandDisplay(request.request).commandText,
|
||||
cwd: request.request.cwd ?? undefined,
|
||||
host: request.request.host === "node" ? "node" : "gateway",
|
||||
nodeId: request.request.nodeId ?? undefined,
|
||||
expiresAtMs: request.expiresAtMs,
|
||||
nowMs: this.nowMs(),
|
||||
};
|
||||
const payload = buildExecApprovalPendingReplyPayload(payloadParams);
|
||||
const buttons = buildTelegramExecApprovalButtons(request.id);
|
||||
const sentMessages: PendingMessage[] = [];
|
||||
|
||||
for (const target of resolvedTargets) {
|
||||
try {
|
||||
await this.sendTyping(target.to, {
|
||||
cfg: this.opts.cfg,
|
||||
token: this.opts.token,
|
||||
accountId: this.opts.accountId,
|
||||
...(typeof target.threadId === "number" ? { messageThreadId: target.threadId } : {}),
|
||||
}).catch(() => {});
|
||||
|
||||
const result = await this.sendMessage(target.to, payload.text ?? "", {
|
||||
cfg: this.opts.cfg,
|
||||
token: this.opts.token,
|
||||
accountId: this.opts.accountId,
|
||||
buttons,
|
||||
...(typeof target.threadId === "number" ? { messageThreadId: target.threadId } : {}),
|
||||
});
|
||||
sentMessages.push({
|
||||
chatId: result.chatId,
|
||||
messageId: result.messageId,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error(`telegram exec approvals: failed to send request ${request.id}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (sentMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutMs = Math.max(0, request.expiresAtMs - this.nowMs());
|
||||
const timeoutId = setTimeout(() => {
|
||||
void this.handleResolved({ id: request.id, decision: "deny", ts: Date.now() });
|
||||
}, timeoutMs);
|
||||
timeoutId.unref?.();
|
||||
|
||||
this.pending.set(request.id, {
|
||||
timeoutId,
|
||||
messages: sentMessages,
|
||||
});
|
||||
}
|
||||
|
||||
async handleResolved(resolved: ExecApprovalResolved): Promise<void> {
|
||||
const pending = this.pending.get(resolved.id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timeoutId);
|
||||
this.pending.delete(resolved.id);
|
||||
|
||||
await Promise.allSettled(
|
||||
pending.messages.map(async (message) => {
|
||||
await this.editReplyMarkup(message.chatId, message.messageId, [], {
|
||||
cfg: this.opts.cfg,
|
||||
token: this.opts.token,
|
||||
accountId: this.opts.accountId,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private handleGatewayEvent(evt: EventFrame): void {
|
||||
if (evt.event === "exec.approval.requested") {
|
||||
void this.handleRequested(evt.payload as ExecApprovalRequest);
|
||||
return;
|
||||
}
|
||||
if (evt.event === "exec.approval.resolved") {
|
||||
void this.handleResolved(evt.payload as ExecApprovalResolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
92
extensions/telegram/src/exec-approvals.test.ts
Normal file
92
extensions/telegram/src/exec-approvals.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import {
|
||||
isTelegramExecApprovalApprover,
|
||||
isTelegramExecApprovalClientEnabled,
|
||||
resolveTelegramExecApprovalTarget,
|
||||
shouldEnableTelegramExecApprovalButtons,
|
||||
shouldInjectTelegramExecApprovalButtons,
|
||||
} from "./exec-approvals.js";
|
||||
|
||||
function buildConfig(
|
||||
execApprovals?: NonNullable<NonNullable<OpenClawConfig["channels"]>["telegram"]>["execApprovals"],
|
||||
): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "tok",
|
||||
execApprovals,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
describe("telegram exec approvals", () => {
|
||||
it("requires enablement and at least one approver", () => {
|
||||
expect(isTelegramExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false);
|
||||
expect(
|
||||
isTelegramExecApprovalClientEnabled({
|
||||
cfg: buildConfig({ enabled: true }),
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isTelegramExecApprovalClientEnabled({
|
||||
cfg: buildConfig({ enabled: true, approvers: ["123"] }),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches approvers by normalized sender id", () => {
|
||||
const cfg = buildConfig({ enabled: true, approvers: [123, "456"] });
|
||||
expect(isTelegramExecApprovalApprover({ cfg, senderId: "123" })).toBe(true);
|
||||
expect(isTelegramExecApprovalApprover({ cfg, senderId: "456" })).toBe(true);
|
||||
expect(isTelegramExecApprovalApprover({ cfg, senderId: "789" })).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults target to dm", () => {
|
||||
expect(
|
||||
resolveTelegramExecApprovalTarget({ cfg: buildConfig({ enabled: true, approvers: ["1"] }) }),
|
||||
).toBe("dm");
|
||||
});
|
||||
|
||||
it("only injects approval buttons on eligible telegram targets", () => {
|
||||
const dmCfg = buildConfig({ enabled: true, approvers: ["123"], target: "dm" });
|
||||
const channelCfg = buildConfig({ enabled: true, approvers: ["123"], target: "channel" });
|
||||
const bothCfg = buildConfig({ enabled: true, approvers: ["123"], target: "both" });
|
||||
|
||||
expect(shouldInjectTelegramExecApprovalButtons({ cfg: dmCfg, to: "123" })).toBe(true);
|
||||
expect(shouldInjectTelegramExecApprovalButtons({ cfg: dmCfg, to: "-100123" })).toBe(false);
|
||||
expect(shouldInjectTelegramExecApprovalButtons({ cfg: channelCfg, to: "-100123" })).toBe(true);
|
||||
expect(shouldInjectTelegramExecApprovalButtons({ cfg: channelCfg, to: "123" })).toBe(false);
|
||||
expect(shouldInjectTelegramExecApprovalButtons({ cfg: bothCfg, to: "123" })).toBe(true);
|
||||
expect(shouldInjectTelegramExecApprovalButtons({ cfg: bothCfg, to: "-100123" })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not require generic inlineButtons capability to enable exec approval buttons", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "tok",
|
||||
capabilities: ["vision"],
|
||||
execApprovals: { enabled: true, approvers: ["123"], target: "dm" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(shouldEnableTelegramExecApprovalButtons({ cfg, to: "123" })).toBe(true);
|
||||
});
|
||||
|
||||
it("still respects explicit inlineButtons off for exec approval buttons", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "tok",
|
||||
capabilities: { inlineButtons: "off" },
|
||||
execApprovals: { enabled: true, approvers: ["123"], target: "dm" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(shouldEnableTelegramExecApprovalButtons({ cfg, to: "123" })).toBe(false);
|
||||
});
|
||||
});
|
||||
106
extensions/telegram/src/exec-approvals.ts
Normal file
106
extensions/telegram/src/exec-approvals.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { TelegramExecApprovalConfig } from "../../../src/config/types.telegram.js";
|
||||
import { getExecApprovalReplyMetadata } from "../../../src/infra/exec-approval-reply.js";
|
||||
import { resolveTelegramAccount } from "./accounts.js";
|
||||
import { resolveTelegramTargetChatType } from "./targets.js";
|
||||
|
||||
function normalizeApproverId(value: string | number): string {
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
export function resolveTelegramExecApprovalConfig(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): TelegramExecApprovalConfig | undefined {
|
||||
return resolveTelegramAccount(params).config.execApprovals;
|
||||
}
|
||||
|
||||
export function getTelegramExecApprovalApprovers(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): string[] {
|
||||
return (resolveTelegramExecApprovalConfig(params)?.approvers ?? [])
|
||||
.map(normalizeApproverId)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function isTelegramExecApprovalClientEnabled(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): boolean {
|
||||
const config = resolveTelegramExecApprovalConfig(params);
|
||||
return Boolean(config?.enabled && getTelegramExecApprovalApprovers(params).length > 0);
|
||||
}
|
||||
|
||||
export function isTelegramExecApprovalApprover(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
senderId?: string | null;
|
||||
}): boolean {
|
||||
const senderId = params.senderId?.trim();
|
||||
if (!senderId) {
|
||||
return false;
|
||||
}
|
||||
const approvers = getTelegramExecApprovalApprovers(params);
|
||||
return approvers.includes(senderId);
|
||||
}
|
||||
|
||||
export function resolveTelegramExecApprovalTarget(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): "dm" | "channel" | "both" {
|
||||
return resolveTelegramExecApprovalConfig(params)?.target ?? "dm";
|
||||
}
|
||||
|
||||
export function shouldInjectTelegramExecApprovalButtons(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
}): boolean {
|
||||
if (!isTelegramExecApprovalClientEnabled(params)) {
|
||||
return false;
|
||||
}
|
||||
const target = resolveTelegramExecApprovalTarget(params);
|
||||
const chatType = resolveTelegramTargetChatType(params.to);
|
||||
if (chatType === "direct") {
|
||||
return target === "dm" || target === "both";
|
||||
}
|
||||
if (chatType === "group") {
|
||||
return target === "channel" || target === "both";
|
||||
}
|
||||
return target === "both";
|
||||
}
|
||||
|
||||
function resolveExecApprovalButtonsExplicitlyDisabled(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): boolean {
|
||||
const capabilities = resolveTelegramAccount(params).config.capabilities;
|
||||
if (!capabilities || Array.isArray(capabilities) || typeof capabilities !== "object") {
|
||||
return false;
|
||||
}
|
||||
const inlineButtons = (capabilities as { inlineButtons?: unknown }).inlineButtons;
|
||||
return typeof inlineButtons === "string" && inlineButtons.trim().toLowerCase() === "off";
|
||||
}
|
||||
|
||||
export function shouldEnableTelegramExecApprovalButtons(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
}): boolean {
|
||||
if (!shouldInjectTelegramExecApprovalButtons(params)) {
|
||||
return false;
|
||||
}
|
||||
return !resolveExecApprovalButtonsExplicitlyDisabled(params);
|
||||
}
|
||||
|
||||
export function shouldSuppressLocalTelegramExecApprovalPrompt(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
payload: ReplyPayload;
|
||||
}): boolean {
|
||||
void params.cfg;
|
||||
void params.accountId;
|
||||
return getExecApprovalReplyMetadata(params.payload) !== null;
|
||||
}
|
||||
58
extensions/telegram/src/fetch.env-proxy-runtime.test.ts
Normal file
58
extensions/telegram/src/fetch.env-proxy-runtime.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveFetch } from "../infra/fetch.js";
|
||||
import { resolveFetch } from "../../../src/infra/fetch.js";
|
||||
import { resolveTelegramFetch, resolveTelegramTransport } from "./fetch.js";
|
||||
|
||||
const setDefaultResultOrder = vi.hoisted(() => vi.fn());
|
||||
514
extensions/telegram/src/fetch.ts
Normal file
514
extensions/telegram/src/fetch.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
import * as dns from "node:dns";
|
||||
import { Agent, EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from "undici";
|
||||
import type { TelegramNetworkConfig } from "../../../src/config/types.telegram.js";
|
||||
import { resolveFetch } from "../../../src/infra/fetch.js";
|
||||
import { hasEnvHttpProxyConfigured } from "../../../src/infra/net/proxy-env.js";
|
||||
import type { PinnedDispatcherPolicy } from "../../../src/infra/net/ssrf.js";
|
||||
import { createSubsystemLogger } from "../../../src/logging/subsystem.js";
|
||||
import {
|
||||
resolveTelegramAutoSelectFamilyDecision,
|
||||
resolveTelegramDnsResultOrderDecision,
|
||||
} from "./network-config.js";
|
||||
import { getProxyUrlFromFetch } from "./proxy.js";
|
||||
|
||||
const log = createSubsystemLogger("telegram/network");
|
||||
|
||||
const TELEGRAM_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS = 300;
|
||||
const TELEGRAM_API_HOSTNAME = "api.telegram.org";
|
||||
|
||||
type RequestInitWithDispatcher = RequestInit & {
|
||||
dispatcher?: unknown;
|
||||
};
|
||||
|
||||
type TelegramDispatcher = Agent | EnvHttpProxyAgent | ProxyAgent;
|
||||
|
||||
type TelegramDispatcherMode = "direct" | "env-proxy" | "explicit-proxy";
|
||||
|
||||
type TelegramDnsResultOrder = "ipv4first" | "verbatim";
|
||||
|
||||
type LookupCallback =
|
||||
| ((err: NodeJS.ErrnoException | null, address: string, family: number) => void)
|
||||
| ((err: NodeJS.ErrnoException | null, addresses: dns.LookupAddress[]) => void);
|
||||
|
||||
type LookupOptions = (dns.LookupOneOptions | dns.LookupAllOptions) & {
|
||||
order?: TelegramDnsResultOrder;
|
||||
verbatim?: boolean;
|
||||
};
|
||||
|
||||
type LookupFunction = (
|
||||
hostname: string,
|
||||
options: number | dns.LookupOneOptions | dns.LookupAllOptions | undefined,
|
||||
callback: LookupCallback,
|
||||
) => void;
|
||||
|
||||
const FALLBACK_RETRY_ERROR_CODES = [
|
||||
"ETIMEDOUT",
|
||||
"ENETUNREACH",
|
||||
"EHOSTUNREACH",
|
||||
"UND_ERR_CONNECT_TIMEOUT",
|
||||
"UND_ERR_SOCKET",
|
||||
] as const;
|
||||
|
||||
type Ipv4FallbackContext = {
|
||||
message: string;
|
||||
codes: Set<string>;
|
||||
};
|
||||
|
||||
type Ipv4FallbackRule = {
|
||||
name: string;
|
||||
matches: (ctx: Ipv4FallbackContext) => boolean;
|
||||
};
|
||||
|
||||
const IPV4_FALLBACK_RULES: readonly Ipv4FallbackRule[] = [
|
||||
{
|
||||
name: "fetch-failed-envelope",
|
||||
matches: ({ message }) => message.includes("fetch failed"),
|
||||
},
|
||||
{
|
||||
name: "known-network-code",
|
||||
matches: ({ codes }) => FALLBACK_RETRY_ERROR_CODES.some((code) => codes.has(code)),
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeDnsResultOrder(value: string | null): TelegramDnsResultOrder | null {
|
||||
if (value === "ipv4first" || value === "verbatim") {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createDnsResultOrderLookup(
|
||||
order: TelegramDnsResultOrder | null,
|
||||
): LookupFunction | undefined {
|
||||
if (!order) {
|
||||
return undefined;
|
||||
}
|
||||
const lookup = dns.lookup as unknown as (
|
||||
hostname: string,
|
||||
options: LookupOptions,
|
||||
callback: LookupCallback,
|
||||
) => void;
|
||||
return (hostname, options, callback) => {
|
||||
const baseOptions: LookupOptions =
|
||||
typeof options === "number"
|
||||
? { family: options }
|
||||
: options
|
||||
? { ...(options as LookupOptions) }
|
||||
: {};
|
||||
const lookupOptions: LookupOptions = {
|
||||
...baseOptions,
|
||||
order,
|
||||
// Keep `verbatim` for compatibility with Node runtimes that ignore `order`.
|
||||
verbatim: order === "verbatim",
|
||||
};
|
||||
lookup(hostname, lookupOptions, callback);
|
||||
};
|
||||
}
|
||||
|
||||
function buildTelegramConnectOptions(params: {
|
||||
autoSelectFamily: boolean | null;
|
||||
dnsResultOrder: TelegramDnsResultOrder | null;
|
||||
forceIpv4: boolean;
|
||||
}): {
|
||||
autoSelectFamily?: boolean;
|
||||
autoSelectFamilyAttemptTimeout?: number;
|
||||
family?: number;
|
||||
lookup?: LookupFunction;
|
||||
} | null {
|
||||
const connect: {
|
||||
autoSelectFamily?: boolean;
|
||||
autoSelectFamilyAttemptTimeout?: number;
|
||||
family?: number;
|
||||
lookup?: LookupFunction;
|
||||
} = {};
|
||||
|
||||
if (params.forceIpv4) {
|
||||
connect.family = 4;
|
||||
connect.autoSelectFamily = false;
|
||||
} else if (typeof params.autoSelectFamily === "boolean") {
|
||||
connect.autoSelectFamily = params.autoSelectFamily;
|
||||
connect.autoSelectFamilyAttemptTimeout = TELEGRAM_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
const lookup = createDnsResultOrderLookup(params.dnsResultOrder);
|
||||
if (lookup) {
|
||||
connect.lookup = lookup;
|
||||
}
|
||||
|
||||
return Object.keys(connect).length > 0 ? connect : null;
|
||||
}
|
||||
|
||||
function shouldBypassEnvProxyForTelegramApi(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
// We need this classification before dispatch to decide whether sticky IPv4 fallback
|
||||
// can safely arm. EnvHttpProxyAgent does not expose route decisions (proxy vs direct
|
||||
// NO_PROXY bypass), so we mirror undici's parsing/matching behavior for this host.
|
||||
// Match EnvHttpProxyAgent behavior (undici):
|
||||
// - lower-case no_proxy takes precedence over NO_PROXY
|
||||
// - entries split by comma or whitespace
|
||||
// - wildcard handling is exact-string "*" only
|
||||
// - leading "." and "*." are normalized the same way
|
||||
const noProxyValue = env.no_proxy ?? env.NO_PROXY ?? "";
|
||||
if (!noProxyValue) {
|
||||
return false;
|
||||
}
|
||||
if (noProxyValue === "*") {
|
||||
return true;
|
||||
}
|
||||
const targetHostname = TELEGRAM_API_HOSTNAME.toLowerCase();
|
||||
const targetPort = 443;
|
||||
const noProxyEntries = noProxyValue.split(/[,\s]/);
|
||||
for (let i = 0; i < noProxyEntries.length; i++) {
|
||||
const entry = noProxyEntries[i];
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const parsed = entry.match(/^(.+):(\d+)$/);
|
||||
const entryHostname = (parsed ? parsed[1] : entry).replace(/^\*?\./, "").toLowerCase();
|
||||
const entryPort = parsed ? Number.parseInt(parsed[2], 10) : 0;
|
||||
if (entryPort && entryPort !== targetPort) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
targetHostname === entryHostname ||
|
||||
targetHostname.slice(-(entryHostname.length + 1)) === `.${entryHostname}`
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasEnvHttpProxyForTelegramApi(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return hasEnvHttpProxyConfigured("https", env);
|
||||
}
|
||||
|
||||
function resolveTelegramDispatcherPolicy(params: {
|
||||
autoSelectFamily: boolean | null;
|
||||
dnsResultOrder: TelegramDnsResultOrder | null;
|
||||
useEnvProxy: boolean;
|
||||
forceIpv4: boolean;
|
||||
proxyUrl?: string;
|
||||
}): { policy: PinnedDispatcherPolicy; mode: TelegramDispatcherMode } {
|
||||
const connect = buildTelegramConnectOptions({
|
||||
autoSelectFamily: params.autoSelectFamily,
|
||||
dnsResultOrder: params.dnsResultOrder,
|
||||
forceIpv4: params.forceIpv4,
|
||||
});
|
||||
const explicitProxyUrl = params.proxyUrl?.trim();
|
||||
if (explicitProxyUrl) {
|
||||
return {
|
||||
policy: connect
|
||||
? {
|
||||
mode: "explicit-proxy",
|
||||
proxyUrl: explicitProxyUrl,
|
||||
proxyTls: { ...connect },
|
||||
}
|
||||
: {
|
||||
mode: "explicit-proxy",
|
||||
proxyUrl: explicitProxyUrl,
|
||||
},
|
||||
mode: "explicit-proxy",
|
||||
};
|
||||
}
|
||||
if (params.useEnvProxy) {
|
||||
return {
|
||||
policy: {
|
||||
mode: "env-proxy",
|
||||
...(connect ? { connect: { ...connect }, proxyTls: { ...connect } } : {}),
|
||||
},
|
||||
mode: "env-proxy",
|
||||
};
|
||||
}
|
||||
return {
|
||||
policy: {
|
||||
mode: "direct",
|
||||
...(connect ? { connect: { ...connect } } : {}),
|
||||
},
|
||||
mode: "direct",
|
||||
};
|
||||
}
|
||||
|
||||
function createTelegramDispatcher(policy: PinnedDispatcherPolicy): {
|
||||
dispatcher: TelegramDispatcher;
|
||||
mode: TelegramDispatcherMode;
|
||||
effectivePolicy: PinnedDispatcherPolicy;
|
||||
} {
|
||||
if (policy.mode === "explicit-proxy") {
|
||||
const proxyOptions = policy.proxyTls
|
||||
? ({
|
||||
uri: policy.proxyUrl,
|
||||
proxyTls: { ...policy.proxyTls },
|
||||
} satisfies ConstructorParameters<typeof ProxyAgent>[0])
|
||||
: policy.proxyUrl;
|
||||
try {
|
||||
return {
|
||||
dispatcher: new ProxyAgent(proxyOptions),
|
||||
mode: "explicit-proxy",
|
||||
effectivePolicy: policy,
|
||||
};
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`explicit proxy dispatcher init failed: ${reason}`, { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
if (policy.mode === "env-proxy") {
|
||||
const proxyOptions =
|
||||
policy.connect || policy.proxyTls
|
||||
? ({
|
||||
...(policy.connect ? { connect: { ...policy.connect } } : {}),
|
||||
// undici's EnvHttpProxyAgent passes `connect` only to the no-proxy Agent.
|
||||
// Real proxied HTTPS traffic reads transport settings from ProxyAgent.proxyTls.
|
||||
...(policy.proxyTls ? { proxyTls: { ...policy.proxyTls } } : {}),
|
||||
} satisfies ConstructorParameters<typeof EnvHttpProxyAgent>[0])
|
||||
: undefined;
|
||||
try {
|
||||
return {
|
||||
dispatcher: new EnvHttpProxyAgent(proxyOptions),
|
||||
mode: "env-proxy",
|
||||
effectivePolicy: policy,
|
||||
};
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`env proxy dispatcher init failed; falling back to direct dispatcher: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
const directPolicy: PinnedDispatcherPolicy = {
|
||||
mode: "direct",
|
||||
...(policy.connect ? { connect: { ...policy.connect } } : {}),
|
||||
};
|
||||
return {
|
||||
dispatcher: new Agent(
|
||||
directPolicy.connect
|
||||
? ({
|
||||
connect: { ...directPolicy.connect },
|
||||
} satisfies ConstructorParameters<typeof Agent>[0])
|
||||
: undefined,
|
||||
),
|
||||
mode: "direct",
|
||||
effectivePolicy: directPolicy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dispatcher: new Agent(
|
||||
policy.connect
|
||||
? ({
|
||||
connect: { ...policy.connect },
|
||||
} satisfies ConstructorParameters<typeof Agent>[0])
|
||||
: undefined,
|
||||
),
|
||||
mode: "direct",
|
||||
effectivePolicy: policy,
|
||||
};
|
||||
}
|
||||
|
||||
function withDispatcherIfMissing(
|
||||
init: RequestInit | undefined,
|
||||
dispatcher: TelegramDispatcher,
|
||||
): RequestInitWithDispatcher {
|
||||
const withDispatcher = init as RequestInitWithDispatcher | undefined;
|
||||
if (withDispatcher?.dispatcher) {
|
||||
return init ?? {};
|
||||
}
|
||||
return init ? { ...init, dispatcher } : { dispatcher };
|
||||
}
|
||||
|
||||
function resolveWrappedFetch(fetchImpl: typeof fetch): typeof fetch {
|
||||
return resolveFetch(fetchImpl) ?? fetchImpl;
|
||||
}
|
||||
|
||||
function logResolverNetworkDecisions(params: {
|
||||
autoSelectDecision: ReturnType<typeof resolveTelegramAutoSelectFamilyDecision>;
|
||||
dnsDecision: ReturnType<typeof resolveTelegramDnsResultOrderDecision>;
|
||||
}): void {
|
||||
if (params.autoSelectDecision.value !== null) {
|
||||
const sourceLabel = params.autoSelectDecision.source
|
||||
? ` (${params.autoSelectDecision.source})`
|
||||
: "";
|
||||
log.info(`autoSelectFamily=${params.autoSelectDecision.value}${sourceLabel}`);
|
||||
}
|
||||
if (params.dnsDecision.value !== null) {
|
||||
const sourceLabel = params.dnsDecision.source ? ` (${params.dnsDecision.source})` : "";
|
||||
log.info(`dnsResultOrder=${params.dnsDecision.value}${sourceLabel}`);
|
||||
}
|
||||
}
|
||||
|
||||
function collectErrorCodes(err: unknown): Set<string> {
|
||||
const codes = new Set<string>();
|
||||
const queue: unknown[] = [err];
|
||||
const seen = new Set<unknown>();
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current || seen.has(current)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(current);
|
||||
if (typeof current === "object") {
|
||||
const code = (current as { code?: unknown }).code;
|
||||
if (typeof code === "string" && code.trim()) {
|
||||
codes.add(code.trim().toUpperCase());
|
||||
}
|
||||
const cause = (current as { cause?: unknown }).cause;
|
||||
if (cause && !seen.has(cause)) {
|
||||
queue.push(cause);
|
||||
}
|
||||
const errors = (current as { errors?: unknown }).errors;
|
||||
if (Array.isArray(errors)) {
|
||||
for (const nested of errors) {
|
||||
if (nested && !seen.has(nested)) {
|
||||
queue.push(nested);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
function formatErrorCodes(err: unknown): string {
|
||||
const codes = [...collectErrorCodes(err)];
|
||||
return codes.length > 0 ? codes.join(",") : "none";
|
||||
}
|
||||
|
||||
function shouldRetryWithIpv4Fallback(err: unknown): boolean {
|
||||
const ctx: Ipv4FallbackContext = {
|
||||
message:
|
||||
err && typeof err === "object" && "message" in err ? String(err.message).toLowerCase() : "",
|
||||
codes: collectErrorCodes(err),
|
||||
};
|
||||
for (const rule of IPV4_FALLBACK_RULES) {
|
||||
if (!rule.matches(ctx)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldRetryTelegramIpv4Fallback(err: unknown): boolean {
|
||||
return shouldRetryWithIpv4Fallback(err);
|
||||
}
|
||||
|
||||
// Prefer wrapped fetch when available to normalize AbortSignal across runtimes.
|
||||
export type TelegramTransport = {
|
||||
fetch: typeof fetch;
|
||||
sourceFetch: typeof fetch;
|
||||
pinnedDispatcherPolicy?: PinnedDispatcherPolicy;
|
||||
fallbackPinnedDispatcherPolicy?: PinnedDispatcherPolicy;
|
||||
};
|
||||
|
||||
export function resolveTelegramTransport(
|
||||
proxyFetch?: typeof fetch,
|
||||
options?: { network?: TelegramNetworkConfig },
|
||||
): TelegramTransport {
|
||||
const autoSelectDecision = resolveTelegramAutoSelectFamilyDecision({
|
||||
network: options?.network,
|
||||
});
|
||||
const dnsDecision = resolveTelegramDnsResultOrderDecision({
|
||||
network: options?.network,
|
||||
});
|
||||
logResolverNetworkDecisions({
|
||||
autoSelectDecision,
|
||||
dnsDecision,
|
||||
});
|
||||
|
||||
const explicitProxyUrl = proxyFetch ? getProxyUrlFromFetch(proxyFetch) : undefined;
|
||||
const undiciSourceFetch = resolveWrappedFetch(undiciFetch as unknown as typeof fetch);
|
||||
const sourceFetch = explicitProxyUrl
|
||||
? undiciSourceFetch
|
||||
: proxyFetch
|
||||
? resolveWrappedFetch(proxyFetch)
|
||||
: undiciSourceFetch;
|
||||
const dnsResultOrder = normalizeDnsResultOrder(dnsDecision.value);
|
||||
// Preserve fully caller-owned custom fetch implementations.
|
||||
if (proxyFetch && !explicitProxyUrl) {
|
||||
return { fetch: sourceFetch, sourceFetch };
|
||||
}
|
||||
|
||||
const useEnvProxy = !explicitProxyUrl && hasEnvHttpProxyForTelegramApi();
|
||||
const defaultDispatcherResolution = resolveTelegramDispatcherPolicy({
|
||||
autoSelectFamily: autoSelectDecision.value,
|
||||
dnsResultOrder,
|
||||
useEnvProxy,
|
||||
forceIpv4: false,
|
||||
proxyUrl: explicitProxyUrl,
|
||||
});
|
||||
const defaultDispatcher = createTelegramDispatcher(defaultDispatcherResolution.policy);
|
||||
const shouldBypassEnvProxy = shouldBypassEnvProxyForTelegramApi();
|
||||
const allowStickyIpv4Fallback =
|
||||
defaultDispatcher.mode === "direct" ||
|
||||
(defaultDispatcher.mode === "env-proxy" && shouldBypassEnvProxy);
|
||||
const stickyShouldUseEnvProxy = defaultDispatcher.mode === "env-proxy";
|
||||
const fallbackPinnedDispatcherPolicy = allowStickyIpv4Fallback
|
||||
? resolveTelegramDispatcherPolicy({
|
||||
autoSelectFamily: false,
|
||||
dnsResultOrder: "ipv4first",
|
||||
useEnvProxy: stickyShouldUseEnvProxy,
|
||||
forceIpv4: true,
|
||||
proxyUrl: explicitProxyUrl,
|
||||
}).policy
|
||||
: undefined;
|
||||
|
||||
let stickyIpv4FallbackEnabled = false;
|
||||
let stickyIpv4Dispatcher: TelegramDispatcher | null = null;
|
||||
const resolveStickyIpv4Dispatcher = () => {
|
||||
if (!stickyIpv4Dispatcher) {
|
||||
if (!fallbackPinnedDispatcherPolicy) {
|
||||
return defaultDispatcher.dispatcher;
|
||||
}
|
||||
stickyIpv4Dispatcher = createTelegramDispatcher(fallbackPinnedDispatcherPolicy).dispatcher;
|
||||
}
|
||||
return stickyIpv4Dispatcher;
|
||||
};
|
||||
|
||||
const resolvedFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const callerProvidedDispatcher = Boolean(
|
||||
(init as RequestInitWithDispatcher | undefined)?.dispatcher,
|
||||
);
|
||||
const initialInit = withDispatcherIfMissing(
|
||||
init,
|
||||
stickyIpv4FallbackEnabled ? resolveStickyIpv4Dispatcher() : defaultDispatcher.dispatcher,
|
||||
);
|
||||
try {
|
||||
return await sourceFetch(input, initialInit);
|
||||
} catch (err) {
|
||||
if (shouldRetryWithIpv4Fallback(err)) {
|
||||
// Preserve caller-owned dispatchers on retry.
|
||||
if (callerProvidedDispatcher) {
|
||||
return sourceFetch(input, init ?? {});
|
||||
}
|
||||
// Proxy routes should not arm sticky IPv4 mode; `family=4` would constrain
|
||||
// proxy-connect behavior instead of Telegram endpoint selection.
|
||||
if (!allowStickyIpv4Fallback) {
|
||||
throw err;
|
||||
}
|
||||
if (!stickyIpv4FallbackEnabled) {
|
||||
stickyIpv4FallbackEnabled = true;
|
||||
log.warn(
|
||||
`fetch fallback: enabling sticky IPv4-only dispatcher (codes=${formatErrorCodes(err)})`,
|
||||
);
|
||||
}
|
||||
return sourceFetch(input, withDispatcherIfMissing(init, resolveStickyIpv4Dispatcher()));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}) as typeof fetch;
|
||||
|
||||
return {
|
||||
fetch: resolvedFetch,
|
||||
sourceFetch,
|
||||
pinnedDispatcherPolicy: defaultDispatcher.effectivePolicy,
|
||||
fallbackPinnedDispatcherPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveTelegramFetch(
|
||||
proxyFetch?: typeof fetch,
|
||||
options?: { network?: TelegramNetworkConfig },
|
||||
): typeof fetch {
|
||||
return resolveTelegramTransport(proxyFetch, options).fetch;
|
||||
}
|
||||
582
extensions/telegram/src/format.ts
Normal file
582
extensions/telegram/src/format.ts
Normal file
@@ -0,0 +1,582 @@
|
||||
import type { MarkdownTableMode } from "../../../src/config/types.base.js";
|
||||
import {
|
||||
chunkMarkdownIR,
|
||||
markdownToIR,
|
||||
type MarkdownLinkSpan,
|
||||
type MarkdownIR,
|
||||
} from "../../../src/markdown/ir.js";
|
||||
import { renderMarkdownWithMarkers } from "../../../src/markdown/render.js";
|
||||
|
||||
export type TelegramFormattedChunk = {
|
||||
html: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function escapeHtmlAttr(text: string): string {
|
||||
return escapeHtml(text).replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* File extensions that share TLDs and commonly appear in code/documentation.
|
||||
* These are wrapped in <code> tags to prevent Telegram from generating
|
||||
* spurious domain registrar previews.
|
||||
*
|
||||
* Only includes extensions that are:
|
||||
* 1. Commonly used as file extensions in code/docs
|
||||
* 2. Rarely used as intentional domain references
|
||||
*
|
||||
* Excluded: .ai, .io, .tv, .fm (popular domain TLDs like x.ai, vercel.io, github.io)
|
||||
*/
|
||||
const FILE_EXTENSIONS_WITH_TLD = new Set([
|
||||
"md", // Markdown (Moldova) - very common in repos
|
||||
"go", // Go language - common in Go projects
|
||||
"py", // Python (Paraguay) - common in Python projects
|
||||
"pl", // Perl (Poland) - common in Perl projects
|
||||
"sh", // Shell (Saint Helena) - common for scripts
|
||||
"am", // Automake files (Armenia)
|
||||
"at", // Assembly (Austria)
|
||||
"be", // Backend files (Belgium)
|
||||
"cc", // C++ source (Cocos Islands)
|
||||
]);
|
||||
|
||||
/** Detects when markdown-it linkify auto-generated a link from a bare filename (e.g. README.md → http://README.md) */
|
||||
function isAutoLinkedFileRef(href: string, label: string): boolean {
|
||||
const stripped = href.replace(/^https?:\/\//i, "");
|
||||
if (stripped !== label) {
|
||||
return false;
|
||||
}
|
||||
const dotIndex = label.lastIndexOf(".");
|
||||
if (dotIndex < 1) {
|
||||
return false;
|
||||
}
|
||||
const ext = label.slice(dotIndex + 1).toLowerCase();
|
||||
if (!FILE_EXTENSIONS_WITH_TLD.has(ext)) {
|
||||
return false;
|
||||
}
|
||||
// Reject if any path segment before the filename contains a dot (looks like a domain)
|
||||
const segments = label.split("/");
|
||||
if (segments.length > 1) {
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
if (segments[i].includes(".")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildTelegramLink(link: MarkdownLinkSpan, text: string) {
|
||||
const href = link.href.trim();
|
||||
if (!href) {
|
||||
return null;
|
||||
}
|
||||
if (link.start === link.end) {
|
||||
return null;
|
||||
}
|
||||
// Suppress auto-linkified file references (e.g. README.md → http://README.md)
|
||||
const label = text.slice(link.start, link.end);
|
||||
if (isAutoLinkedFileRef(href, label)) {
|
||||
return null;
|
||||
}
|
||||
const safeHref = escapeHtmlAttr(href);
|
||||
return {
|
||||
start: link.start,
|
||||
end: link.end,
|
||||
open: `<a href="${safeHref}">`,
|
||||
close: "</a>",
|
||||
};
|
||||
}
|
||||
|
||||
function renderTelegramHtml(ir: MarkdownIR): string {
|
||||
return renderMarkdownWithMarkers(ir, {
|
||||
styleMarkers: {
|
||||
bold: { open: "<b>", close: "</b>" },
|
||||
italic: { open: "<i>", close: "</i>" },
|
||||
strikethrough: { open: "<s>", close: "</s>" },
|
||||
code: { open: "<code>", close: "</code>" },
|
||||
code_block: { open: "<pre><code>", close: "</code></pre>" },
|
||||
spoiler: { open: "<tg-spoiler>", close: "</tg-spoiler>" },
|
||||
blockquote: { open: "<blockquote>", close: "</blockquote>" },
|
||||
},
|
||||
escapeText: escapeHtml,
|
||||
buildLink: buildTelegramLink,
|
||||
});
|
||||
}
|
||||
|
||||
export function markdownToTelegramHtml(
|
||||
markdown: string,
|
||||
options: { tableMode?: MarkdownTableMode; wrapFileRefs?: boolean } = {},
|
||||
): string {
|
||||
const ir = markdownToIR(markdown ?? "", {
|
||||
linkify: true,
|
||||
enableSpoilers: true,
|
||||
headingStyle: "none",
|
||||
blockquotePrefix: "",
|
||||
tableMode: options.tableMode,
|
||||
});
|
||||
const html = renderTelegramHtml(ir);
|
||||
// Apply file reference wrapping if requested (for chunked rendering)
|
||||
if (options.wrapFileRefs !== false) {
|
||||
return wrapFileReferencesInHtml(html);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps standalone file references (with TLD extensions) in <code> tags.
|
||||
* This prevents Telegram from treating them as URLs and generating
|
||||
* irrelevant domain registrar previews.
|
||||
*
|
||||
* Runs AFTER markdown→HTML conversion to avoid modifying HTML attributes.
|
||||
* Skips content inside <code>, <pre>, and <a> tags to avoid nesting issues.
|
||||
*/
|
||||
/** Escape regex metacharacters in a string */
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
const FILE_EXTENSIONS_PATTERN = Array.from(FILE_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|");
|
||||
const AUTO_LINKED_ANCHOR_PATTERN = /<a\s+href="https?:\/\/([^"]+)"[^>]*>\1<\/a>/gi;
|
||||
const FILE_REFERENCE_PATTERN = new RegExp(
|
||||
`(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=$|[^a-zA-Z0-9_\\-/])`,
|
||||
"gi",
|
||||
);
|
||||
const ORPHANED_TLD_PATTERN = new RegExp(
|
||||
`([^a-zA-Z0-9]|^)([A-Za-z]\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=[^a-zA-Z0-9/]|$)`,
|
||||
"g",
|
||||
);
|
||||
const HTML_TAG_PATTERN = /(<\/?)([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?>/gi;
|
||||
|
||||
function wrapStandaloneFileRef(match: string, prefix: string, filename: string): string {
|
||||
if (filename.startsWith("//")) {
|
||||
return match;
|
||||
}
|
||||
if (/https?:\/\/$/i.test(prefix)) {
|
||||
return match;
|
||||
}
|
||||
return `${prefix}<code>${escapeHtml(filename)}</code>`;
|
||||
}
|
||||
|
||||
function wrapSegmentFileRefs(
|
||||
text: string,
|
||||
codeDepth: number,
|
||||
preDepth: number,
|
||||
anchorDepth: number,
|
||||
): string {
|
||||
if (!text || codeDepth > 0 || preDepth > 0 || anchorDepth > 0) {
|
||||
return text;
|
||||
}
|
||||
const wrappedStandalone = text.replace(FILE_REFERENCE_PATTERN, wrapStandaloneFileRef);
|
||||
return wrappedStandalone.replace(ORPHANED_TLD_PATTERN, (match, prefix: string, tld: string) =>
|
||||
prefix === ">" ? match : `${prefix}<code>${escapeHtml(tld)}</code>`,
|
||||
);
|
||||
}
|
||||
|
||||
export function wrapFileReferencesInHtml(html: string): string {
|
||||
// Safety-net: de-linkify auto-generated anchors where href="http://<label>" (defense in depth for textMode: "html")
|
||||
AUTO_LINKED_ANCHOR_PATTERN.lastIndex = 0;
|
||||
const deLinkified = html.replace(AUTO_LINKED_ANCHOR_PATTERN, (_match, label: string) => {
|
||||
if (!isAutoLinkedFileRef(`http://${label}`, label)) {
|
||||
return _match;
|
||||
}
|
||||
return `<code>${escapeHtml(label)}</code>`;
|
||||
});
|
||||
|
||||
// Track nesting depth for tags that should not be modified
|
||||
let codeDepth = 0;
|
||||
let preDepth = 0;
|
||||
let anchorDepth = 0;
|
||||
let result = "";
|
||||
let lastIndex = 0;
|
||||
|
||||
// Process tags token-by-token so we can skip protected regions while wrapping plain text.
|
||||
HTML_TAG_PATTERN.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = HTML_TAG_PATTERN.exec(deLinkified)) !== null) {
|
||||
const tagStart = match.index;
|
||||
const tagEnd = HTML_TAG_PATTERN.lastIndex;
|
||||
const isClosing = match[1] === "</";
|
||||
const tagName = match[2].toLowerCase();
|
||||
|
||||
// Process text before this tag
|
||||
const textBefore = deLinkified.slice(lastIndex, tagStart);
|
||||
result += wrapSegmentFileRefs(textBefore, codeDepth, preDepth, anchorDepth);
|
||||
|
||||
// Update tag depth (clamp at 0 for malformed HTML with stray closing tags)
|
||||
if (tagName === "code") {
|
||||
codeDepth = isClosing ? Math.max(0, codeDepth - 1) : codeDepth + 1;
|
||||
} else if (tagName === "pre") {
|
||||
preDepth = isClosing ? Math.max(0, preDepth - 1) : preDepth + 1;
|
||||
} else if (tagName === "a") {
|
||||
anchorDepth = isClosing ? Math.max(0, anchorDepth - 1) : anchorDepth + 1;
|
||||
}
|
||||
|
||||
// Add the tag itself
|
||||
result += deLinkified.slice(tagStart, tagEnd);
|
||||
lastIndex = tagEnd;
|
||||
}
|
||||
|
||||
// Process remaining text
|
||||
const remainingText = deLinkified.slice(lastIndex);
|
||||
result += wrapSegmentFileRefs(remainingText, codeDepth, preDepth, anchorDepth);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function renderTelegramHtmlText(
|
||||
text: string,
|
||||
options: { textMode?: "markdown" | "html"; tableMode?: MarkdownTableMode } = {},
|
||||
): string {
|
||||
const textMode = options.textMode ?? "markdown";
|
||||
if (textMode === "html") {
|
||||
// For HTML mode, trust caller markup - don't modify
|
||||
return text;
|
||||
}
|
||||
// markdownToTelegramHtml already wraps file references by default
|
||||
return markdownToTelegramHtml(text, { tableMode: options.tableMode });
|
||||
}
|
||||
|
||||
type TelegramHtmlTag = {
|
||||
name: string;
|
||||
openTag: string;
|
||||
closeTag: string;
|
||||
};
|
||||
|
||||
const TELEGRAM_SELF_CLOSING_HTML_TAGS = new Set(["br"]);
|
||||
|
||||
function buildTelegramHtmlOpenPrefix(tags: TelegramHtmlTag[]): string {
|
||||
return tags.map((tag) => tag.openTag).join("");
|
||||
}
|
||||
|
||||
function buildTelegramHtmlCloseSuffix(tags: TelegramHtmlTag[]): string {
|
||||
return tags
|
||||
.slice()
|
||||
.toReversed()
|
||||
.map((tag) => tag.closeTag)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function buildTelegramHtmlCloseSuffixLength(tags: TelegramHtmlTag[]): number {
|
||||
return tags.reduce((total, tag) => total + tag.closeTag.length, 0);
|
||||
}
|
||||
|
||||
function findTelegramHtmlEntityEnd(text: string, start: number): number {
|
||||
if (text[start] !== "&") {
|
||||
return -1;
|
||||
}
|
||||
let index = start + 1;
|
||||
if (index >= text.length) {
|
||||
return -1;
|
||||
}
|
||||
if (text[index] === "#") {
|
||||
index += 1;
|
||||
if (index >= text.length) {
|
||||
return -1;
|
||||
}
|
||||
const isHex = text[index] === "x" || text[index] === "X";
|
||||
if (isHex) {
|
||||
index += 1;
|
||||
const hexStart = index;
|
||||
while (/[0-9A-Fa-f]/.test(text[index] ?? "")) {
|
||||
index += 1;
|
||||
}
|
||||
if (index === hexStart) {
|
||||
return -1;
|
||||
}
|
||||
} else {
|
||||
const digitStart = index;
|
||||
while (/[0-9]/.test(text[index] ?? "")) {
|
||||
index += 1;
|
||||
}
|
||||
if (index === digitStart) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const nameStart = index;
|
||||
while (/[A-Za-z0-9]/.test(text[index] ?? "")) {
|
||||
index += 1;
|
||||
}
|
||||
if (index === nameStart) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return text[index] === ";" ? index : -1;
|
||||
}
|
||||
|
||||
function findTelegramHtmlSafeSplitIndex(text: string, maxLength: number): number {
|
||||
if (text.length <= maxLength) {
|
||||
return text.length;
|
||||
}
|
||||
const normalizedMaxLength = Math.max(1, Math.floor(maxLength));
|
||||
const lastAmpersand = text.lastIndexOf("&", normalizedMaxLength - 1);
|
||||
if (lastAmpersand === -1) {
|
||||
return normalizedMaxLength;
|
||||
}
|
||||
const lastSemicolon = text.lastIndexOf(";", normalizedMaxLength - 1);
|
||||
if (lastAmpersand < lastSemicolon) {
|
||||
return normalizedMaxLength;
|
||||
}
|
||||
const entityEnd = findTelegramHtmlEntityEnd(text, lastAmpersand);
|
||||
if (entityEnd === -1 || entityEnd < normalizedMaxLength) {
|
||||
return normalizedMaxLength;
|
||||
}
|
||||
return lastAmpersand;
|
||||
}
|
||||
|
||||
function popTelegramHtmlTag(tags: TelegramHtmlTag[], name: string): void {
|
||||
for (let index = tags.length - 1; index >= 0; index -= 1) {
|
||||
if (tags[index]?.name === name) {
|
||||
tags.splice(index, 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function splitTelegramHtmlChunks(html: string, limit: number): string[] {
|
||||
if (!html) {
|
||||
return [];
|
||||
}
|
||||
const normalizedLimit = Math.max(1, Math.floor(limit));
|
||||
if (html.length <= normalizedLimit) {
|
||||
return [html];
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
const openTags: TelegramHtmlTag[] = [];
|
||||
let current = "";
|
||||
let chunkHasPayload = false;
|
||||
|
||||
const resetCurrent = () => {
|
||||
current = buildTelegramHtmlOpenPrefix(openTags);
|
||||
chunkHasPayload = false;
|
||||
};
|
||||
|
||||
const flushCurrent = () => {
|
||||
if (!chunkHasPayload) {
|
||||
return;
|
||||
}
|
||||
chunks.push(`${current}${buildTelegramHtmlCloseSuffix(openTags)}`);
|
||||
resetCurrent();
|
||||
};
|
||||
|
||||
const appendText = (segment: string) => {
|
||||
let remaining = segment;
|
||||
while (remaining.length > 0) {
|
||||
const available =
|
||||
normalizedLimit - current.length - buildTelegramHtmlCloseSuffixLength(openTags);
|
||||
if (available <= 0) {
|
||||
if (!chunkHasPayload) {
|
||||
throw new Error(
|
||||
`Telegram HTML chunk limit exceeded by tag overhead (limit=${normalizedLimit})`,
|
||||
);
|
||||
}
|
||||
flushCurrent();
|
||||
continue;
|
||||
}
|
||||
if (remaining.length <= available) {
|
||||
current += remaining;
|
||||
chunkHasPayload = true;
|
||||
break;
|
||||
}
|
||||
const splitAt = findTelegramHtmlSafeSplitIndex(remaining, available);
|
||||
if (splitAt <= 0) {
|
||||
if (!chunkHasPayload) {
|
||||
throw new Error(
|
||||
`Telegram HTML chunk limit exceeded by leading entity (limit=${normalizedLimit})`,
|
||||
);
|
||||
}
|
||||
flushCurrent();
|
||||
continue;
|
||||
}
|
||||
current += remaining.slice(0, splitAt);
|
||||
chunkHasPayload = true;
|
||||
remaining = remaining.slice(splitAt);
|
||||
flushCurrent();
|
||||
}
|
||||
};
|
||||
|
||||
resetCurrent();
|
||||
HTML_TAG_PATTERN.lastIndex = 0;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = HTML_TAG_PATTERN.exec(html)) !== null) {
|
||||
const tagStart = match.index;
|
||||
const tagEnd = HTML_TAG_PATTERN.lastIndex;
|
||||
appendText(html.slice(lastIndex, tagStart));
|
||||
|
||||
const rawTag = match[0];
|
||||
const isClosing = match[1] === "</";
|
||||
const tagName = match[2].toLowerCase();
|
||||
const isSelfClosing =
|
||||
!isClosing &&
|
||||
(TELEGRAM_SELF_CLOSING_HTML_TAGS.has(tagName) || rawTag.trimEnd().endsWith("/>"));
|
||||
|
||||
if (!isClosing) {
|
||||
const nextCloseLength = isSelfClosing ? 0 : `</${tagName}>`.length;
|
||||
if (
|
||||
chunkHasPayload &&
|
||||
current.length +
|
||||
rawTag.length +
|
||||
buildTelegramHtmlCloseSuffixLength(openTags) +
|
||||
nextCloseLength >
|
||||
normalizedLimit
|
||||
) {
|
||||
flushCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
current += rawTag;
|
||||
if (isSelfClosing) {
|
||||
chunkHasPayload = true;
|
||||
}
|
||||
if (isClosing) {
|
||||
popTelegramHtmlTag(openTags, tagName);
|
||||
} else if (!isSelfClosing) {
|
||||
openTags.push({
|
||||
name: tagName,
|
||||
openTag: rawTag,
|
||||
closeTag: `</${tagName}>`,
|
||||
});
|
||||
}
|
||||
lastIndex = tagEnd;
|
||||
}
|
||||
|
||||
appendText(html.slice(lastIndex));
|
||||
flushCurrent();
|
||||
return chunks.length > 0 ? chunks : [html];
|
||||
}
|
||||
|
||||
function splitTelegramChunkByHtmlLimit(
|
||||
chunk: MarkdownIR,
|
||||
htmlLimit: number,
|
||||
renderedHtmlLength: number,
|
||||
): MarkdownIR[] {
|
||||
const currentTextLength = chunk.text.length;
|
||||
if (currentTextLength <= 1) {
|
||||
return [chunk];
|
||||
}
|
||||
const proportionalLimit = Math.floor(
|
||||
(currentTextLength * htmlLimit) / Math.max(renderedHtmlLength, 1),
|
||||
);
|
||||
const candidateLimit = Math.min(currentTextLength - 1, proportionalLimit);
|
||||
const splitLimit =
|
||||
Number.isFinite(candidateLimit) && candidateLimit > 0
|
||||
? candidateLimit
|
||||
: Math.max(1, Math.floor(currentTextLength / 2));
|
||||
const split = splitMarkdownIRPreserveWhitespace(chunk, splitLimit);
|
||||
if (split.length > 1) {
|
||||
return split;
|
||||
}
|
||||
return splitMarkdownIRPreserveWhitespace(chunk, Math.max(1, Math.floor(currentTextLength / 2)));
|
||||
}
|
||||
|
||||
function sliceStyleSpans(
|
||||
styles: MarkdownIR["styles"],
|
||||
start: number,
|
||||
end: number,
|
||||
): MarkdownIR["styles"] {
|
||||
return styles.flatMap((span) => {
|
||||
if (span.end <= start || span.start >= end) {
|
||||
return [];
|
||||
}
|
||||
const nextStart = Math.max(span.start, start) - start;
|
||||
const nextEnd = Math.min(span.end, end) - start;
|
||||
if (nextEnd <= nextStart) {
|
||||
return [];
|
||||
}
|
||||
return [{ ...span, start: nextStart, end: nextEnd }];
|
||||
});
|
||||
}
|
||||
|
||||
function sliceLinkSpans(
|
||||
links: MarkdownIR["links"],
|
||||
start: number,
|
||||
end: number,
|
||||
): MarkdownIR["links"] {
|
||||
return links.flatMap((link) => {
|
||||
if (link.end <= start || link.start >= end) {
|
||||
return [];
|
||||
}
|
||||
const nextStart = Math.max(link.start, start) - start;
|
||||
const nextEnd = Math.min(link.end, end) - start;
|
||||
if (nextEnd <= nextStart) {
|
||||
return [];
|
||||
}
|
||||
return [{ ...link, start: nextStart, end: nextEnd }];
|
||||
});
|
||||
}
|
||||
|
||||
function splitMarkdownIRPreserveWhitespace(ir: MarkdownIR, limit: number): MarkdownIR[] {
|
||||
if (!ir.text) {
|
||||
return [];
|
||||
}
|
||||
const normalizedLimit = Math.max(1, Math.floor(limit));
|
||||
if (normalizedLimit <= 0 || ir.text.length <= normalizedLimit) {
|
||||
return [ir];
|
||||
}
|
||||
const chunks: MarkdownIR[] = [];
|
||||
let cursor = 0;
|
||||
while (cursor < ir.text.length) {
|
||||
const end = Math.min(ir.text.length, cursor + normalizedLimit);
|
||||
chunks.push({
|
||||
text: ir.text.slice(cursor, end),
|
||||
styles: sliceStyleSpans(ir.styles, cursor, end),
|
||||
links: sliceLinkSpans(ir.links, cursor, end),
|
||||
});
|
||||
cursor = end;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function renderTelegramChunksWithinHtmlLimit(
|
||||
ir: MarkdownIR,
|
||||
limit: number,
|
||||
): TelegramFormattedChunk[] {
|
||||
const normalizedLimit = Math.max(1, Math.floor(limit));
|
||||
const pending = chunkMarkdownIR(ir, normalizedLimit);
|
||||
const rendered: TelegramFormattedChunk[] = [];
|
||||
while (pending.length > 0) {
|
||||
const chunk = pending.shift();
|
||||
if (!chunk) {
|
||||
continue;
|
||||
}
|
||||
const html = wrapFileReferencesInHtml(renderTelegramHtml(chunk));
|
||||
if (html.length <= normalizedLimit || chunk.text.length <= 1) {
|
||||
rendered.push({ html, text: chunk.text });
|
||||
continue;
|
||||
}
|
||||
const split = splitTelegramChunkByHtmlLimit(chunk, normalizedLimit, html.length);
|
||||
if (split.length <= 1) {
|
||||
// Worst-case safety: avoid retry loops, deliver the chunk as-is.
|
||||
rendered.push({ html, text: chunk.text });
|
||||
continue;
|
||||
}
|
||||
pending.unshift(...split);
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
export function markdownToTelegramChunks(
|
||||
markdown: string,
|
||||
limit: number,
|
||||
options: { tableMode?: MarkdownTableMode } = {},
|
||||
): TelegramFormattedChunk[] {
|
||||
const ir = markdownToIR(markdown ?? "", {
|
||||
linkify: true,
|
||||
enableSpoilers: true,
|
||||
headingStyle: "none",
|
||||
blockquotePrefix: "",
|
||||
tableMode: options.tableMode,
|
||||
});
|
||||
return renderTelegramChunksWithinHtmlLimit(ir, limit);
|
||||
}
|
||||
|
||||
export function markdownToTelegramHtmlChunks(markdown: string, limit: number): string[] {
|
||||
return markdownToTelegramChunks(markdown, limit).map((chunk) => chunk.html);
|
||||
}
|
||||
23
extensions/telegram/src/forum-service-message.ts
Normal file
23
extensions/telegram/src/forum-service-message.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/** Telegram forum-topic service-message fields (Bot API). */
|
||||
export const TELEGRAM_FORUM_SERVICE_FIELDS = [
|
||||
"forum_topic_created",
|
||||
"forum_topic_edited",
|
||||
"forum_topic_closed",
|
||||
"forum_topic_reopened",
|
||||
"general_forum_topic_hidden",
|
||||
"general_forum_topic_unhidden",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Returns `true` when the message is a Telegram forum service message (e.g.
|
||||
* "Topic created"). These auto-generated messages carry one of the
|
||||
* `forum_topic_*` / `general_forum_topic_*` fields and should not count as
|
||||
* regular bot replies for implicit-mention purposes.
|
||||
*/
|
||||
export function isTelegramForumServiceMessage(msg: unknown): boolean {
|
||||
if (!msg || typeof msg !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = msg as Record<string, unknown>;
|
||||
return TELEGRAM_FORUM_SERVICE_FIELDS.some((field) => record[field] != null);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe } from "vitest";
|
||||
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../test-utils/runtime-group-policy-contract.js";
|
||||
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../src/test-utils/runtime-group-policy-contract.js";
|
||||
import { resolveTelegramRuntimeGroupPolicy } from "./group-access.js";
|
||||
|
||||
describe("resolveTelegramRuntimeGroupPolicy", () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { TelegramAccountConfig } from "../config/types.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { TelegramAccountConfig } from "../../../src/config/types.js";
|
||||
import { evaluateTelegramGroupPolicyAccess } from "./group-access.js";
|
||||
|
||||
/**
|
||||
205
extensions/telegram/src/group-access.ts
Normal file
205
extensions/telegram/src/group-access.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { ChannelGroupPolicy } from "../../../src/config/group-policy.js";
|
||||
import { resolveOpenProviderRuntimeGroupPolicy } from "../../../src/config/runtime-group-policy.js";
|
||||
import type {
|
||||
TelegramAccountConfig,
|
||||
TelegramDirectConfig,
|
||||
TelegramGroupConfig,
|
||||
TelegramTopicConfig,
|
||||
} from "../../../src/config/types.js";
|
||||
import { evaluateMatchedGroupAccessForPolicy } from "../../../src/plugin-sdk/group-access.js";
|
||||
import { isSenderAllowed, type NormalizedAllowFrom } from "./bot-access.js";
|
||||
import { firstDefined } from "./bot-access.js";
|
||||
|
||||
export type TelegramGroupBaseBlockReason =
|
||||
| "group-disabled"
|
||||
| "topic-disabled"
|
||||
| "group-override-unauthorized";
|
||||
|
||||
export type TelegramGroupBaseAccessResult =
|
||||
| { allowed: true }
|
||||
| { allowed: false; reason: TelegramGroupBaseBlockReason };
|
||||
|
||||
function isGroupAllowOverrideAuthorized(params: {
|
||||
effectiveGroupAllow: NormalizedAllowFrom;
|
||||
senderId?: string;
|
||||
senderUsername?: string;
|
||||
requireSenderForAllowOverride: boolean;
|
||||
}): boolean {
|
||||
if (!params.effectiveGroupAllow.hasEntries) {
|
||||
return false;
|
||||
}
|
||||
const senderId = params.senderId ?? "";
|
||||
if (params.requireSenderForAllowOverride && !senderId) {
|
||||
return false;
|
||||
}
|
||||
return isSenderAllowed({
|
||||
allow: params.effectiveGroupAllow,
|
||||
senderId,
|
||||
senderUsername: params.senderUsername ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
export const evaluateTelegramGroupBaseAccess = (params: {
|
||||
isGroup: boolean;
|
||||
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||
topicConfig?: TelegramTopicConfig;
|
||||
hasGroupAllowOverride: boolean;
|
||||
effectiveGroupAllow: NormalizedAllowFrom;
|
||||
senderId?: string;
|
||||
senderUsername?: string;
|
||||
enforceAllowOverride: boolean;
|
||||
requireSenderForAllowOverride: boolean;
|
||||
}): TelegramGroupBaseAccessResult => {
|
||||
// Check enabled flags for both groups and DMs
|
||||
if (params.groupConfig?.enabled === false) {
|
||||
return { allowed: false, reason: "group-disabled" };
|
||||
}
|
||||
if (params.topicConfig?.enabled === false) {
|
||||
return { allowed: false, reason: "topic-disabled" };
|
||||
}
|
||||
if (!params.isGroup) {
|
||||
// For DMs, check allowFrom override if present
|
||||
if (params.enforceAllowOverride && params.hasGroupAllowOverride) {
|
||||
if (
|
||||
!isGroupAllowOverrideAuthorized({
|
||||
effectiveGroupAllow: params.effectiveGroupAllow,
|
||||
senderId: params.senderId,
|
||||
senderUsername: params.senderUsername,
|
||||
requireSenderForAllowOverride: params.requireSenderForAllowOverride,
|
||||
})
|
||||
) {
|
||||
return { allowed: false, reason: "group-override-unauthorized" };
|
||||
}
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
if (!params.enforceAllowOverride || !params.hasGroupAllowOverride) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
if (
|
||||
!isGroupAllowOverrideAuthorized({
|
||||
effectiveGroupAllow: params.effectiveGroupAllow,
|
||||
senderId: params.senderId,
|
||||
senderUsername: params.senderUsername,
|
||||
requireSenderForAllowOverride: params.requireSenderForAllowOverride,
|
||||
})
|
||||
) {
|
||||
return { allowed: false, reason: "group-override-unauthorized" };
|
||||
}
|
||||
return { allowed: true };
|
||||
};
|
||||
|
||||
export type TelegramGroupPolicyBlockReason =
|
||||
| "group-policy-disabled"
|
||||
| "group-policy-allowlist-no-sender"
|
||||
| "group-policy-allowlist-empty"
|
||||
| "group-policy-allowlist-unauthorized"
|
||||
| "group-chat-not-allowed";
|
||||
|
||||
export type TelegramGroupPolicyAccessResult =
|
||||
| { allowed: true; groupPolicy: "open" | "disabled" | "allowlist" }
|
||||
| {
|
||||
allowed: false;
|
||||
reason: TelegramGroupPolicyBlockReason;
|
||||
groupPolicy: "open" | "disabled" | "allowlist";
|
||||
};
|
||||
|
||||
export const resolveTelegramRuntimeGroupPolicy = (params: {
|
||||
providerConfigPresent: boolean;
|
||||
groupPolicy?: TelegramAccountConfig["groupPolicy"];
|
||||
defaultGroupPolicy?: TelegramAccountConfig["groupPolicy"];
|
||||
}) =>
|
||||
resolveOpenProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: params.providerConfigPresent,
|
||||
groupPolicy: params.groupPolicy,
|
||||
defaultGroupPolicy: params.defaultGroupPolicy,
|
||||
});
|
||||
|
||||
export const evaluateTelegramGroupPolicyAccess = (params: {
|
||||
isGroup: boolean;
|
||||
chatId: string | number;
|
||||
cfg: OpenClawConfig;
|
||||
telegramCfg: TelegramAccountConfig;
|
||||
topicConfig?: TelegramTopicConfig;
|
||||
groupConfig?: TelegramGroupConfig;
|
||||
effectiveGroupAllow: NormalizedAllowFrom;
|
||||
senderId?: string;
|
||||
senderUsername?: string;
|
||||
resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy;
|
||||
enforcePolicy: boolean;
|
||||
useTopicAndGroupOverrides: boolean;
|
||||
enforceAllowlistAuthorization: boolean;
|
||||
allowEmptyAllowlistEntries: boolean;
|
||||
requireSenderForAllowlistAuthorization: boolean;
|
||||
checkChatAllowlist: boolean;
|
||||
}): TelegramGroupPolicyAccessResult => {
|
||||
const { groupPolicy: runtimeFallbackPolicy } = resolveTelegramRuntimeGroupPolicy({
|
||||
providerConfigPresent: params.cfg.channels?.telegram !== undefined,
|
||||
groupPolicy: params.telegramCfg.groupPolicy,
|
||||
defaultGroupPolicy: params.cfg.channels?.defaults?.groupPolicy,
|
||||
});
|
||||
const fallbackPolicy =
|
||||
firstDefined(params.telegramCfg.groupPolicy, params.cfg.channels?.defaults?.groupPolicy) ??
|
||||
runtimeFallbackPolicy;
|
||||
const groupPolicy = params.useTopicAndGroupOverrides
|
||||
? (firstDefined(
|
||||
params.topicConfig?.groupPolicy,
|
||||
params.groupConfig?.groupPolicy,
|
||||
params.telegramCfg.groupPolicy,
|
||||
params.cfg.channels?.defaults?.groupPolicy,
|
||||
) ?? runtimeFallbackPolicy)
|
||||
: fallbackPolicy;
|
||||
|
||||
if (!params.isGroup || !params.enforcePolicy) {
|
||||
return { allowed: true, groupPolicy };
|
||||
}
|
||||
if (groupPolicy === "disabled") {
|
||||
return { allowed: false, reason: "group-policy-disabled", groupPolicy };
|
||||
}
|
||||
// Check chat-level allowlist first so that groups explicitly listed in the
|
||||
// `groups` config are not blocked by the sender-level "empty allowlist" guard.
|
||||
let chatExplicitlyAllowed = false;
|
||||
if (params.checkChatAllowlist) {
|
||||
const groupAllowlist = params.resolveGroupPolicy(params.chatId);
|
||||
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
|
||||
return { allowed: false, reason: "group-chat-not-allowed", groupPolicy };
|
||||
}
|
||||
// The chat is explicitly allowed when it has a dedicated entry in the groups
|
||||
// config (groupConfig is set). A wildcard ("*") match alone does not count
|
||||
// because it only enables the group — sender-level filtering still applies.
|
||||
if (groupAllowlist.allowlistEnabled && groupAllowlist.allowed && groupAllowlist.groupConfig) {
|
||||
chatExplicitlyAllowed = true;
|
||||
}
|
||||
}
|
||||
if (groupPolicy === "allowlist" && params.enforceAllowlistAuthorization) {
|
||||
const senderId = params.senderId ?? "";
|
||||
const senderAuthorization = evaluateMatchedGroupAccessForPolicy({
|
||||
groupPolicy,
|
||||
requireMatchInput: params.requireSenderForAllowlistAuthorization,
|
||||
hasMatchInput: Boolean(senderId),
|
||||
allowlistConfigured:
|
||||
chatExplicitlyAllowed ||
|
||||
params.allowEmptyAllowlistEntries ||
|
||||
params.effectiveGroupAllow.hasEntries,
|
||||
allowlistMatched:
|
||||
(chatExplicitlyAllowed && !params.effectiveGroupAllow.hasEntries) ||
|
||||
isSenderAllowed({
|
||||
allow: params.effectiveGroupAllow,
|
||||
senderId,
|
||||
senderUsername: params.senderUsername ?? "",
|
||||
}),
|
||||
});
|
||||
if (!senderAuthorization.allowed && senderAuthorization.reason === "missing_match_input") {
|
||||
return { allowed: false, reason: "group-policy-allowlist-no-sender", groupPolicy };
|
||||
}
|
||||
if (!senderAuthorization.allowed && senderAuthorization.reason === "empty_allowlist") {
|
||||
return { allowed: false, reason: "group-policy-allowlist-empty", groupPolicy };
|
||||
}
|
||||
if (!senderAuthorization.allowed && senderAuthorization.reason === "not_allowlisted") {
|
||||
return { allowed: false, reason: "group-policy-allowlist-unauthorized", groupPolicy };
|
||||
}
|
||||
}
|
||||
return { allowed: true, groupPolicy };
|
||||
};
|
||||
23
extensions/telegram/src/group-config-helpers.ts
Normal file
23
extensions/telegram/src/group-config-helpers.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type {
|
||||
TelegramDirectConfig,
|
||||
TelegramGroupConfig,
|
||||
TelegramTopicConfig,
|
||||
} from "../../../src/config/types.js";
|
||||
import { firstDefined } from "./bot-access.js";
|
||||
|
||||
export function resolveTelegramGroupPromptSettings(params: {
|
||||
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||
topicConfig?: TelegramTopicConfig;
|
||||
}): {
|
||||
skillFilter: string[] | undefined;
|
||||
groupSystemPrompt: string | undefined;
|
||||
} {
|
||||
const skillFilter = firstDefined(params.topicConfig?.skills, params.groupConfig?.skills);
|
||||
const systemPromptParts = [
|
||||
params.groupConfig?.systemPrompt?.trim() || null,
|
||||
params.topicConfig?.systemPrompt?.trim() || null,
|
||||
].filter((entry): entry is string => Boolean(entry));
|
||||
const groupSystemPrompt =
|
||||
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
|
||||
return { skillFilter, groupSystemPrompt };
|
||||
}
|
||||
89
extensions/telegram/src/group-migration.ts
Normal file
89
extensions/telegram/src/group-migration.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { TelegramGroupConfig } from "../../../src/config/types.telegram.js";
|
||||
import { normalizeAccountId } from "../../../src/routing/session-key.js";
|
||||
|
||||
type TelegramGroups = Record<string, TelegramGroupConfig>;
|
||||
|
||||
type MigrationScope = "account" | "global";
|
||||
|
||||
export type TelegramGroupMigrationResult = {
|
||||
migrated: boolean;
|
||||
skippedExisting: boolean;
|
||||
scopes: MigrationScope[];
|
||||
};
|
||||
|
||||
function resolveAccountGroups(
|
||||
cfg: OpenClawConfig,
|
||||
accountId?: string | null,
|
||||
): { groups?: TelegramGroups } {
|
||||
if (!accountId) {
|
||||
return {};
|
||||
}
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
const accounts = cfg.channels?.telegram?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") {
|
||||
return {};
|
||||
}
|
||||
const exact = accounts[normalized];
|
||||
if (exact?.groups) {
|
||||
return { groups: exact.groups };
|
||||
}
|
||||
const matchKey = Object.keys(accounts).find(
|
||||
(key) => key.toLowerCase() === normalized.toLowerCase(),
|
||||
);
|
||||
return { groups: matchKey ? accounts[matchKey]?.groups : undefined };
|
||||
}
|
||||
|
||||
export function migrateTelegramGroupsInPlace(
|
||||
groups: TelegramGroups | undefined,
|
||||
oldChatId: string,
|
||||
newChatId: string,
|
||||
): { migrated: boolean; skippedExisting: boolean } {
|
||||
if (!groups) {
|
||||
return { migrated: false, skippedExisting: false };
|
||||
}
|
||||
if (oldChatId === newChatId) {
|
||||
return { migrated: false, skippedExisting: false };
|
||||
}
|
||||
if (!Object.hasOwn(groups, oldChatId)) {
|
||||
return { migrated: false, skippedExisting: false };
|
||||
}
|
||||
if (Object.hasOwn(groups, newChatId)) {
|
||||
return { migrated: false, skippedExisting: true };
|
||||
}
|
||||
groups[newChatId] = groups[oldChatId];
|
||||
delete groups[oldChatId];
|
||||
return { migrated: true, skippedExisting: false };
|
||||
}
|
||||
|
||||
export function migrateTelegramGroupConfig(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
oldChatId: string;
|
||||
newChatId: string;
|
||||
}): TelegramGroupMigrationResult {
|
||||
const scopes: MigrationScope[] = [];
|
||||
let migrated = false;
|
||||
let skippedExisting = false;
|
||||
|
||||
const migrationTargets: Array<{
|
||||
scope: MigrationScope;
|
||||
groups: TelegramGroups | undefined;
|
||||
}> = [
|
||||
{ scope: "account", groups: resolveAccountGroups(params.cfg, params.accountId).groups },
|
||||
{ scope: "global", groups: params.cfg.channels?.telegram?.groups },
|
||||
];
|
||||
|
||||
for (const target of migrationTargets) {
|
||||
const result = migrateTelegramGroupsInPlace(target.groups, params.oldChatId, params.newChatId);
|
||||
if (result.migrated) {
|
||||
migrated = true;
|
||||
scopes.push(target.scope);
|
||||
}
|
||||
if (result.skippedExisting) {
|
||||
skippedExisting = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { migrated, skippedExisting, scopes };
|
||||
}
|
||||
67
extensions/telegram/src/inline-buttons.ts
Normal file
67
extensions/telegram/src/inline-buttons.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { TelegramInlineButtonsScope } from "../../../src/config/types.telegram.js";
|
||||
import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js";
|
||||
|
||||
const DEFAULT_INLINE_BUTTONS_SCOPE: TelegramInlineButtonsScope = "allowlist";
|
||||
|
||||
function normalizeInlineButtonsScope(value: unknown): TelegramInlineButtonsScope | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
if (
|
||||
trimmed === "off" ||
|
||||
trimmed === "dm" ||
|
||||
trimmed === "group" ||
|
||||
trimmed === "all" ||
|
||||
trimmed === "allowlist"
|
||||
) {
|
||||
return trimmed as TelegramInlineButtonsScope;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveInlineButtonsScopeFromCapabilities(
|
||||
capabilities: unknown,
|
||||
): TelegramInlineButtonsScope {
|
||||
if (!capabilities) {
|
||||
return DEFAULT_INLINE_BUTTONS_SCOPE;
|
||||
}
|
||||
if (Array.isArray(capabilities)) {
|
||||
const enabled = capabilities.some(
|
||||
(entry) => String(entry).trim().toLowerCase() === "inlinebuttons",
|
||||
);
|
||||
return enabled ? "all" : "off";
|
||||
}
|
||||
if (typeof capabilities === "object") {
|
||||
const inlineButtons = (capabilities as { inlineButtons?: unknown }).inlineButtons;
|
||||
return normalizeInlineButtonsScope(inlineButtons) ?? DEFAULT_INLINE_BUTTONS_SCOPE;
|
||||
}
|
||||
return DEFAULT_INLINE_BUTTONS_SCOPE;
|
||||
}
|
||||
|
||||
export function resolveTelegramInlineButtonsScope(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): TelegramInlineButtonsScope {
|
||||
const account = resolveTelegramAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
return resolveInlineButtonsScopeFromCapabilities(account.config.capabilities);
|
||||
}
|
||||
|
||||
export function isTelegramInlineButtonsEnabled(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): boolean {
|
||||
if (params.accountId) {
|
||||
return resolveTelegramInlineButtonsScope(params) !== "off";
|
||||
}
|
||||
const accountIds = listTelegramAccountIds(params.cfg);
|
||||
if (accountIds.length === 0) {
|
||||
return resolveTelegramInlineButtonsScope(params) !== "off";
|
||||
}
|
||||
return accountIds.some(
|
||||
(accountId) => resolveTelegramInlineButtonsScope({ cfg: params.cfg, accountId }) !== "off",
|
||||
);
|
||||
}
|
||||
|
||||
export { resolveTelegramTargetChatType } from "./targets.js";
|
||||
32
extensions/telegram/src/lane-delivery-state.ts
Normal file
32
extensions/telegram/src/lane-delivery-state.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export type LaneDeliverySnapshot = {
|
||||
delivered: boolean;
|
||||
skippedNonSilent: number;
|
||||
failedNonSilent: number;
|
||||
};
|
||||
|
||||
export type LaneDeliveryStateTracker = {
|
||||
markDelivered: () => void;
|
||||
markNonSilentSkip: () => void;
|
||||
markNonSilentFailure: () => void;
|
||||
snapshot: () => LaneDeliverySnapshot;
|
||||
};
|
||||
|
||||
export function createLaneDeliveryStateTracker(): LaneDeliveryStateTracker {
|
||||
const state: LaneDeliverySnapshot = {
|
||||
delivered: false,
|
||||
skippedNonSilent: 0,
|
||||
failedNonSilent: 0,
|
||||
};
|
||||
return {
|
||||
markDelivered: () => {
|
||||
state.delivered = true;
|
||||
},
|
||||
markNonSilentSkip: () => {
|
||||
state.skippedNonSilent += 1;
|
||||
},
|
||||
markNonSilentFailure: () => {
|
||||
state.failedNonSilent += 1;
|
||||
},
|
||||
snapshot: () => ({ ...state }),
|
||||
};
|
||||
}
|
||||
574
extensions/telegram/src/lane-delivery-text-deliverer.ts
Normal file
574
extensions/telegram/src/lane-delivery-text-deliverer.ts
Normal file
@@ -0,0 +1,574 @@
|
||||
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
|
||||
import type { TelegramInlineButtons } from "./button-types.js";
|
||||
import type { TelegramDraftStream } from "./draft-stream.js";
|
||||
import {
|
||||
isRecoverableTelegramNetworkError,
|
||||
isSafeToRetrySendError,
|
||||
isTelegramClientRejection,
|
||||
} from "./network-errors.js";
|
||||
|
||||
const MESSAGE_NOT_MODIFIED_RE =
|
||||
/400:\s*Bad Request:\s*message is not modified|MESSAGE_NOT_MODIFIED/i;
|
||||
const MESSAGE_NOT_FOUND_RE =
|
||||
/400:\s*Bad Request:\s*message to edit not found|MESSAGE_ID_INVALID|message can't be edited/i;
|
||||
|
||||
function extractErrorText(err: unknown): string {
|
||||
return typeof err === "string"
|
||||
? err
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: typeof err === "object" && err && "description" in err
|
||||
? typeof err.description === "string"
|
||||
? err.description
|
||||
: ""
|
||||
: "";
|
||||
}
|
||||
|
||||
function isMessageNotModifiedError(err: unknown): boolean {
|
||||
return MESSAGE_NOT_MODIFIED_RE.test(extractErrorText(err));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when Telegram rejects an edit because the target message can no
|
||||
* longer be resolved or edited. The caller still needs preview context to
|
||||
* decide whether to retain a different visible preview or fall back to send.
|
||||
*/
|
||||
function isMissingPreviewMessageError(err: unknown): boolean {
|
||||
return MESSAGE_NOT_FOUND_RE.test(extractErrorText(err));
|
||||
}
|
||||
|
||||
export type LaneName = "answer" | "reasoning";
|
||||
|
||||
export type DraftLaneState = {
|
||||
stream: TelegramDraftStream | undefined;
|
||||
lastPartialText: string;
|
||||
hasStreamedMessage: boolean;
|
||||
};
|
||||
|
||||
export type ArchivedPreview = {
|
||||
messageId: number;
|
||||
textSnapshot: string;
|
||||
// Boundary-finalized previews should remain visible even if no matching
|
||||
// final edit arrives; superseded previews can be safely deleted.
|
||||
deleteIfUnused?: boolean;
|
||||
};
|
||||
|
||||
export type LanePreviewLifecycle = "transient" | "complete";
|
||||
|
||||
export type LaneDeliveryResult =
|
||||
| "preview-finalized"
|
||||
| "preview-retained"
|
||||
| "preview-updated"
|
||||
| "sent"
|
||||
| "skipped";
|
||||
|
||||
type CreateLaneTextDelivererParams = {
|
||||
lanes: Record<LaneName, DraftLaneState>;
|
||||
archivedAnswerPreviews: ArchivedPreview[];
|
||||
activePreviewLifecycleByLane: Record<LaneName, LanePreviewLifecycle>;
|
||||
retainPreviewOnCleanupByLane: Record<LaneName, boolean>;
|
||||
draftMaxChars: number;
|
||||
applyTextToPayload: (payload: ReplyPayload, text: string) => ReplyPayload;
|
||||
sendPayload: (payload: ReplyPayload) => Promise<boolean>;
|
||||
flushDraftLane: (lane: DraftLaneState) => Promise<void>;
|
||||
stopDraftLane: (lane: DraftLaneState) => Promise<void>;
|
||||
editPreview: (params: {
|
||||
laneName: LaneName;
|
||||
messageId: number;
|
||||
text: string;
|
||||
context: "final" | "update";
|
||||
previewButtons?: TelegramInlineButtons;
|
||||
}) => Promise<void>;
|
||||
deletePreviewMessage: (messageId: number) => Promise<void>;
|
||||
log: (message: string) => void;
|
||||
markDelivered: () => void;
|
||||
};
|
||||
|
||||
type DeliverLaneTextParams = {
|
||||
laneName: LaneName;
|
||||
text: string;
|
||||
payload: ReplyPayload;
|
||||
infoKind: string;
|
||||
previewButtons?: TelegramInlineButtons;
|
||||
allowPreviewUpdateForNonFinal?: boolean;
|
||||
};
|
||||
|
||||
type TryUpdatePreviewParams = {
|
||||
lane: DraftLaneState;
|
||||
laneName: LaneName;
|
||||
text: string;
|
||||
previewButtons?: TelegramInlineButtons;
|
||||
stopBeforeEdit?: boolean;
|
||||
updateLaneSnapshot?: boolean;
|
||||
skipRegressive: "always" | "existingOnly";
|
||||
context: "final" | "update";
|
||||
previewMessageId?: number;
|
||||
previewTextSnapshot?: string;
|
||||
};
|
||||
|
||||
type PreviewEditResult = "edited" | "retained" | "fallback";
|
||||
|
||||
type ConsumeArchivedAnswerPreviewParams = {
|
||||
lane: DraftLaneState;
|
||||
text: string;
|
||||
payload: ReplyPayload;
|
||||
previewButtons?: TelegramInlineButtons;
|
||||
canEditViaPreview: boolean;
|
||||
};
|
||||
|
||||
type PreviewUpdateContext = "final" | "update";
|
||||
type RegressiveSkipMode = "always" | "existingOnly";
|
||||
|
||||
type ResolvePreviewTargetParams = {
|
||||
lane: DraftLaneState;
|
||||
previewMessageIdOverride?: number;
|
||||
stopBeforeEdit: boolean;
|
||||
context: PreviewUpdateContext;
|
||||
};
|
||||
|
||||
type PreviewTargetResolution = {
|
||||
hadPreviewMessage: boolean;
|
||||
previewMessageId: number | undefined;
|
||||
stopCreatesFirstPreview: boolean;
|
||||
};
|
||||
|
||||
function shouldSkipRegressivePreviewUpdate(args: {
|
||||
currentPreviewText: string | undefined;
|
||||
text: string;
|
||||
skipRegressive: RegressiveSkipMode;
|
||||
hadPreviewMessage: boolean;
|
||||
}): boolean {
|
||||
const currentPreviewText = args.currentPreviewText;
|
||||
if (currentPreviewText === undefined) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
currentPreviewText.startsWith(args.text) &&
|
||||
args.text.length < currentPreviewText.length &&
|
||||
(args.skipRegressive === "always" || args.hadPreviewMessage)
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePreviewTarget(params: ResolvePreviewTargetParams): PreviewTargetResolution {
|
||||
const lanePreviewMessageId = params.lane.stream?.messageId();
|
||||
const previewMessageId =
|
||||
typeof params.previewMessageIdOverride === "number"
|
||||
? params.previewMessageIdOverride
|
||||
: lanePreviewMessageId;
|
||||
const hadPreviewMessage =
|
||||
typeof params.previewMessageIdOverride === "number" || typeof lanePreviewMessageId === "number";
|
||||
return {
|
||||
hadPreviewMessage,
|
||||
previewMessageId: typeof previewMessageId === "number" ? previewMessageId : undefined,
|
||||
stopCreatesFirstPreview:
|
||||
params.stopBeforeEdit && !hadPreviewMessage && params.context === "final",
|
||||
};
|
||||
}
|
||||
|
||||
export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
const getLanePreviewText = (lane: DraftLaneState) => lane.lastPartialText;
|
||||
const markActivePreviewComplete = (laneName: LaneName) => {
|
||||
params.activePreviewLifecycleByLane[laneName] = "complete";
|
||||
params.retainPreviewOnCleanupByLane[laneName] = true;
|
||||
};
|
||||
const isDraftPreviewLane = (lane: DraftLaneState) => lane.stream?.previewMode?.() === "draft";
|
||||
const canMaterializeDraftFinal = (
|
||||
lane: DraftLaneState,
|
||||
previewButtons?: TelegramInlineButtons,
|
||||
) => {
|
||||
const hasPreviewButtons = Boolean(previewButtons && previewButtons.length > 0);
|
||||
return (
|
||||
isDraftPreviewLane(lane) &&
|
||||
!hasPreviewButtons &&
|
||||
typeof lane.stream?.materialize === "function"
|
||||
);
|
||||
};
|
||||
|
||||
const tryMaterializeDraftPreviewForFinal = async (args: {
|
||||
lane: DraftLaneState;
|
||||
laneName: LaneName;
|
||||
text: string;
|
||||
}): Promise<boolean> => {
|
||||
const stream = args.lane.stream;
|
||||
if (!stream || !isDraftPreviewLane(args.lane)) {
|
||||
return false;
|
||||
}
|
||||
// Draft previews have no message_id to edit; materialize the final text
|
||||
// into a real message and treat that as the finalized delivery.
|
||||
stream.update(args.text);
|
||||
const materializedMessageId = await stream.materialize?.();
|
||||
if (typeof materializedMessageId !== "number") {
|
||||
params.log(
|
||||
`telegram: ${args.laneName} draft preview materialize produced no message id; falling back to standard send`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
args.lane.lastPartialText = args.text;
|
||||
params.markDelivered();
|
||||
return true;
|
||||
};
|
||||
|
||||
const tryEditPreviewMessage = async (args: {
|
||||
laneName: LaneName;
|
||||
messageId: number;
|
||||
text: string;
|
||||
context: "final" | "update";
|
||||
previewButtons?: TelegramInlineButtons;
|
||||
updateLaneSnapshot: boolean;
|
||||
lane: DraftLaneState;
|
||||
finalTextAlreadyLanded: boolean;
|
||||
retainAlternatePreviewOnMissingTarget: boolean;
|
||||
}): Promise<PreviewEditResult> => {
|
||||
try {
|
||||
await params.editPreview({
|
||||
laneName: args.laneName,
|
||||
messageId: args.messageId,
|
||||
text: args.text,
|
||||
previewButtons: args.previewButtons,
|
||||
context: args.context,
|
||||
});
|
||||
if (args.updateLaneSnapshot) {
|
||||
args.lane.lastPartialText = args.text;
|
||||
}
|
||||
params.markDelivered();
|
||||
return "edited";
|
||||
} catch (err) {
|
||||
if (isMessageNotModifiedError(err)) {
|
||||
params.log(
|
||||
`telegram: ${args.laneName} preview ${args.context} edit returned "message is not modified"; treating as delivered`,
|
||||
);
|
||||
params.markDelivered();
|
||||
return "edited";
|
||||
}
|
||||
if (args.context === "final") {
|
||||
if (args.finalTextAlreadyLanded) {
|
||||
params.log(
|
||||
`telegram: ${args.laneName} preview final edit failed after stop flush; keeping existing preview (${String(err)})`,
|
||||
);
|
||||
params.markDelivered();
|
||||
return "retained";
|
||||
}
|
||||
if (isSafeToRetrySendError(err)) {
|
||||
params.log(
|
||||
`telegram: ${args.laneName} preview final edit failed before reaching Telegram; falling back to standard send (${String(err)})`,
|
||||
);
|
||||
return "fallback";
|
||||
}
|
||||
if (isMissingPreviewMessageError(err)) {
|
||||
if (args.retainAlternatePreviewOnMissingTarget) {
|
||||
params.log(
|
||||
`telegram: ${args.laneName} preview final edit target missing; keeping alternate preview without fallback (${String(err)})`,
|
||||
);
|
||||
params.markDelivered();
|
||||
return "retained";
|
||||
}
|
||||
params.log(
|
||||
`telegram: ${args.laneName} preview final edit target missing with no alternate preview; falling back to standard send (${String(err)})`,
|
||||
);
|
||||
return "fallback";
|
||||
}
|
||||
if (isRecoverableTelegramNetworkError(err, { allowMessageMatch: true })) {
|
||||
params.log(
|
||||
`telegram: ${args.laneName} preview final edit may have landed despite network error; keeping existing preview (${String(err)})`,
|
||||
);
|
||||
params.markDelivered();
|
||||
return "retained";
|
||||
}
|
||||
if (isTelegramClientRejection(err)) {
|
||||
params.log(
|
||||
`telegram: ${args.laneName} preview final edit rejected by Telegram (client error); falling back to standard send (${String(err)})`,
|
||||
);
|
||||
return "fallback";
|
||||
}
|
||||
// Default: ambiguous error — prefer incomplete over duplicate
|
||||
params.log(
|
||||
`telegram: ${args.laneName} preview final edit failed with ambiguous error; keeping existing preview to avoid duplicate (${String(err)})`,
|
||||
);
|
||||
params.markDelivered();
|
||||
return "retained";
|
||||
}
|
||||
params.log(
|
||||
`telegram: ${args.laneName} preview ${args.context} edit failed; falling back to standard send (${String(err)})`,
|
||||
);
|
||||
return "fallback";
|
||||
}
|
||||
};
|
||||
|
||||
const tryUpdatePreviewForLane = async ({
|
||||
lane,
|
||||
laneName,
|
||||
text,
|
||||
previewButtons,
|
||||
stopBeforeEdit = false,
|
||||
updateLaneSnapshot = false,
|
||||
skipRegressive,
|
||||
context,
|
||||
previewMessageId: previewMessageIdOverride,
|
||||
previewTextSnapshot,
|
||||
}: TryUpdatePreviewParams): Promise<PreviewEditResult> => {
|
||||
const editPreview = (
|
||||
messageId: number,
|
||||
finalTextAlreadyLanded: boolean,
|
||||
retainAlternatePreviewOnMissingTarget: boolean,
|
||||
) =>
|
||||
tryEditPreviewMessage({
|
||||
laneName,
|
||||
messageId,
|
||||
text,
|
||||
context,
|
||||
previewButtons,
|
||||
updateLaneSnapshot,
|
||||
lane,
|
||||
finalTextAlreadyLanded,
|
||||
retainAlternatePreviewOnMissingTarget,
|
||||
});
|
||||
const finalizePreview = (
|
||||
previewMessageId: number,
|
||||
finalTextAlreadyLanded: boolean,
|
||||
hadPreviewMessage: boolean,
|
||||
retainAlternatePreviewOnMissingTarget = false,
|
||||
): PreviewEditResult | Promise<PreviewEditResult> => {
|
||||
const currentPreviewText = previewTextSnapshot ?? getLanePreviewText(lane);
|
||||
const shouldSkipRegressive = shouldSkipRegressivePreviewUpdate({
|
||||
currentPreviewText,
|
||||
text,
|
||||
skipRegressive,
|
||||
hadPreviewMessage,
|
||||
});
|
||||
if (shouldSkipRegressive) {
|
||||
params.markDelivered();
|
||||
return "edited";
|
||||
}
|
||||
return editPreview(
|
||||
previewMessageId,
|
||||
finalTextAlreadyLanded,
|
||||
retainAlternatePreviewOnMissingTarget,
|
||||
);
|
||||
};
|
||||
if (!lane.stream) {
|
||||
return "fallback";
|
||||
}
|
||||
const previewTargetBeforeStop = resolvePreviewTarget({
|
||||
lane,
|
||||
previewMessageIdOverride,
|
||||
stopBeforeEdit,
|
||||
context,
|
||||
});
|
||||
if (previewTargetBeforeStop.stopCreatesFirstPreview) {
|
||||
// Final stop() can create the first visible preview message.
|
||||
// Prime pending text so the stop flush sends the final text snapshot.
|
||||
lane.stream.update(text);
|
||||
await params.stopDraftLane(lane);
|
||||
const previewTargetAfterStop = resolvePreviewTarget({
|
||||
lane,
|
||||
stopBeforeEdit: false,
|
||||
context,
|
||||
});
|
||||
if (typeof previewTargetAfterStop.previewMessageId !== "number") {
|
||||
return "fallback";
|
||||
}
|
||||
return finalizePreview(previewTargetAfterStop.previewMessageId, true, false);
|
||||
}
|
||||
if (stopBeforeEdit) {
|
||||
await params.stopDraftLane(lane);
|
||||
}
|
||||
const previewTargetAfterStop = resolvePreviewTarget({
|
||||
lane,
|
||||
previewMessageIdOverride,
|
||||
stopBeforeEdit: false,
|
||||
context,
|
||||
});
|
||||
if (typeof previewTargetAfterStop.previewMessageId !== "number") {
|
||||
// Only retain for final delivery when a prior preview is already visible
|
||||
// to the user — otherwise falling back is safer than silence. For updates,
|
||||
// always fall back so the caller can attempt sendPayload without stale
|
||||
// markDelivered() state.
|
||||
if (context === "final" && lane.hasStreamedMessage && lane.stream?.sendMayHaveLanded?.()) {
|
||||
params.log(
|
||||
`telegram: ${laneName} preview send may have landed despite missing message id; keeping to avoid duplicate`,
|
||||
);
|
||||
params.markDelivered();
|
||||
return "retained";
|
||||
}
|
||||
return "fallback";
|
||||
}
|
||||
const activePreviewMessageId = lane.stream?.messageId();
|
||||
return finalizePreview(
|
||||
previewTargetAfterStop.previewMessageId,
|
||||
false,
|
||||
previewTargetAfterStop.hadPreviewMessage,
|
||||
typeof activePreviewMessageId === "number" &&
|
||||
activePreviewMessageId !== previewTargetAfterStop.previewMessageId,
|
||||
);
|
||||
};
|
||||
|
||||
const consumeArchivedAnswerPreviewForFinal = async ({
|
||||
lane,
|
||||
text,
|
||||
payload,
|
||||
previewButtons,
|
||||
canEditViaPreview,
|
||||
}: ConsumeArchivedAnswerPreviewParams): Promise<LaneDeliveryResult | undefined> => {
|
||||
const archivedPreview = params.archivedAnswerPreviews.shift();
|
||||
if (!archivedPreview) {
|
||||
return undefined;
|
||||
}
|
||||
if (canEditViaPreview) {
|
||||
const finalized = await tryUpdatePreviewForLane({
|
||||
lane,
|
||||
laneName: "answer",
|
||||
text,
|
||||
previewButtons,
|
||||
stopBeforeEdit: false,
|
||||
skipRegressive: "existingOnly",
|
||||
context: "final",
|
||||
previewMessageId: archivedPreview.messageId,
|
||||
previewTextSnapshot: archivedPreview.textSnapshot,
|
||||
});
|
||||
if (finalized === "edited") {
|
||||
return "preview-finalized";
|
||||
}
|
||||
if (finalized === "retained") {
|
||||
params.retainPreviewOnCleanupByLane.answer = true;
|
||||
return "preview-retained";
|
||||
}
|
||||
}
|
||||
// Send the replacement message first, then clean up the old preview.
|
||||
// This avoids the visual "disappear then reappear" flash.
|
||||
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
|
||||
// Once this archived preview is consumed by a fallback final send, delete it
|
||||
// regardless of deleteIfUnused. That flag only applies to unconsumed boundaries.
|
||||
if (delivered || archivedPreview.deleteIfUnused !== false) {
|
||||
try {
|
||||
await params.deletePreviewMessage(archivedPreview.messageId);
|
||||
} catch (err) {
|
||||
params.log(
|
||||
`telegram: archived answer preview cleanup failed (${archivedPreview.messageId}): ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return delivered ? "sent" : "skipped";
|
||||
};
|
||||
|
||||
return async ({
|
||||
laneName,
|
||||
text,
|
||||
payload,
|
||||
infoKind,
|
||||
previewButtons,
|
||||
allowPreviewUpdateForNonFinal = false,
|
||||
}: DeliverLaneTextParams): Promise<LaneDeliveryResult> => {
|
||||
const lane = params.lanes[laneName];
|
||||
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
const canEditViaPreview =
|
||||
!hasMedia && text.length > 0 && text.length <= params.draftMaxChars && !payload.isError;
|
||||
|
||||
if (infoKind === "final") {
|
||||
// Transient previews must decide cleanup retention per final attempt.
|
||||
// Completed previews intentionally stay retained so later extra payloads
|
||||
// do not clear the already-finalized message.
|
||||
if (params.activePreviewLifecycleByLane[laneName] === "transient") {
|
||||
params.retainPreviewOnCleanupByLane[laneName] = false;
|
||||
}
|
||||
if (laneName === "answer") {
|
||||
const archivedResult = await consumeArchivedAnswerPreviewForFinal({
|
||||
lane,
|
||||
text,
|
||||
payload,
|
||||
previewButtons,
|
||||
canEditViaPreview,
|
||||
});
|
||||
if (archivedResult) {
|
||||
return archivedResult;
|
||||
}
|
||||
}
|
||||
if (canEditViaPreview && params.activePreviewLifecycleByLane[laneName] === "transient") {
|
||||
await params.flushDraftLane(lane);
|
||||
if (laneName === "answer") {
|
||||
const archivedResultAfterFlush = await consumeArchivedAnswerPreviewForFinal({
|
||||
lane,
|
||||
text,
|
||||
payload,
|
||||
previewButtons,
|
||||
canEditViaPreview,
|
||||
});
|
||||
if (archivedResultAfterFlush) {
|
||||
return archivedResultAfterFlush;
|
||||
}
|
||||
}
|
||||
if (canMaterializeDraftFinal(lane, previewButtons)) {
|
||||
const materialized = await tryMaterializeDraftPreviewForFinal({
|
||||
lane,
|
||||
laneName,
|
||||
text,
|
||||
});
|
||||
if (materialized) {
|
||||
markActivePreviewComplete(laneName);
|
||||
return "preview-finalized";
|
||||
}
|
||||
}
|
||||
const finalized = await tryUpdatePreviewForLane({
|
||||
lane,
|
||||
laneName,
|
||||
text,
|
||||
previewButtons,
|
||||
stopBeforeEdit: true,
|
||||
skipRegressive: "existingOnly",
|
||||
context: "final",
|
||||
});
|
||||
if (finalized === "edited") {
|
||||
markActivePreviewComplete(laneName);
|
||||
return "preview-finalized";
|
||||
}
|
||||
if (finalized === "retained") {
|
||||
markActivePreviewComplete(laneName);
|
||||
return "preview-retained";
|
||||
}
|
||||
} else if (!hasMedia && !payload.isError && text.length > params.draftMaxChars) {
|
||||
params.log(
|
||||
`telegram: preview final too long for edit (${text.length} > ${params.draftMaxChars}); falling back to standard send`,
|
||||
);
|
||||
}
|
||||
await params.stopDraftLane(lane);
|
||||
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
|
||||
return delivered ? "sent" : "skipped";
|
||||
}
|
||||
|
||||
if (allowPreviewUpdateForNonFinal && canEditViaPreview) {
|
||||
if (isDraftPreviewLane(lane)) {
|
||||
// DM draft flow has no message_id to edit; updates are sent via sendMessageDraft.
|
||||
// Only mark as updated when the draft flush actually emits an update.
|
||||
const previewRevisionBeforeFlush = lane.stream?.previewRevision?.() ?? 0;
|
||||
lane.stream?.update(text);
|
||||
await params.flushDraftLane(lane);
|
||||
const previewUpdated = (lane.stream?.previewRevision?.() ?? 0) > previewRevisionBeforeFlush;
|
||||
if (!previewUpdated) {
|
||||
params.log(
|
||||
`telegram: ${laneName} draft preview update not emitted; falling back to standard send`,
|
||||
);
|
||||
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
|
||||
return delivered ? "sent" : "skipped";
|
||||
}
|
||||
lane.lastPartialText = text;
|
||||
params.markDelivered();
|
||||
return "preview-updated";
|
||||
}
|
||||
const updated = await tryUpdatePreviewForLane({
|
||||
lane,
|
||||
laneName,
|
||||
text,
|
||||
previewButtons,
|
||||
stopBeforeEdit: false,
|
||||
updateLaneSnapshot: true,
|
||||
skipRegressive: "always",
|
||||
context: "update",
|
||||
});
|
||||
if (updated === "edited") {
|
||||
return "preview-updated";
|
||||
}
|
||||
}
|
||||
|
||||
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
|
||||
return delivered ? "sent" : "skipped";
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
|
||||
import { createTestDraftStream } from "./draft-stream.test-helpers.js";
|
||||
import { createLaneTextDeliverer, type DraftLaneState, type LaneName } from "./lane-delivery.js";
|
||||
|
||||
13
extensions/telegram/src/lane-delivery.ts
Normal file
13
extensions/telegram/src/lane-delivery.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
type ArchivedPreview,
|
||||
createLaneTextDeliverer,
|
||||
type DraftLaneState,
|
||||
type LaneDeliveryResult,
|
||||
type LaneName,
|
||||
type LanePreviewLifecycle,
|
||||
} from "./lane-delivery-text-deliverer.js";
|
||||
export {
|
||||
createLaneDeliveryStateTracker,
|
||||
type LaneDeliverySnapshot,
|
||||
type LaneDeliveryStateTracker,
|
||||
} from "./lane-delivery-state.js";
|
||||
284
extensions/telegram/src/model-buttons.ts
Normal file
284
extensions/telegram/src/model-buttons.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Telegram inline button utilities for model selection.
|
||||
*
|
||||
* Callback data patterns (max 64 bytes for Telegram):
|
||||
* - mdl_prov - show providers list
|
||||
* - mdl_list_{prov}_{pg} - show models for provider (page N, 1-indexed)
|
||||
* - mdl_sel_{provider/id} - select model (standard)
|
||||
* - mdl_sel/{model} - select model (compact fallback when standard is >64 bytes)
|
||||
* - mdl_back - back to providers list
|
||||
*/
|
||||
|
||||
export type ButtonRow = Array<{ text: string; callback_data: string }>;
|
||||
|
||||
export type ParsedModelCallback =
|
||||
| { type: "providers" }
|
||||
| { type: "list"; provider: string; page: number }
|
||||
| { type: "select"; provider?: string; model: string }
|
||||
| { type: "back" };
|
||||
|
||||
export type ProviderInfo = {
|
||||
id: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type ResolveModelSelectionResult =
|
||||
| { kind: "resolved"; provider: string; model: string }
|
||||
| { kind: "ambiguous"; model: string; matchingProviders: string[] };
|
||||
|
||||
export type ModelsKeyboardParams = {
|
||||
provider: string;
|
||||
models: readonly string[];
|
||||
currentModel?: string;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
const MODELS_PAGE_SIZE = 8;
|
||||
const MAX_CALLBACK_DATA_BYTES = 64;
|
||||
const CALLBACK_PREFIX = {
|
||||
providers: "mdl_prov",
|
||||
back: "mdl_back",
|
||||
list: "mdl_list_",
|
||||
selectStandard: "mdl_sel_",
|
||||
selectCompact: "mdl_sel/",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Parse a model callback_data string into a structured object.
|
||||
* Returns null if the data doesn't match a known pattern.
|
||||
*/
|
||||
export function parseModelCallbackData(data: string): ParsedModelCallback | null {
|
||||
const trimmed = data.trim();
|
||||
if (!trimmed.startsWith("mdl_")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed === CALLBACK_PREFIX.providers || trimmed === CALLBACK_PREFIX.back) {
|
||||
return { type: trimmed === CALLBACK_PREFIX.providers ? "providers" : "back" };
|
||||
}
|
||||
|
||||
// mdl_list_{provider}_{page}
|
||||
const listMatch = trimmed.match(/^mdl_list_([a-z0-9_-]+)_(\d+)$/i);
|
||||
if (listMatch) {
|
||||
const [, provider, pageStr] = listMatch;
|
||||
const page = Number.parseInt(pageStr ?? "1", 10);
|
||||
if (provider && Number.isFinite(page) && page >= 1) {
|
||||
return { type: "list", provider, page };
|
||||
}
|
||||
}
|
||||
|
||||
// mdl_sel/{model} (compact fallback)
|
||||
const compactSelMatch = trimmed.match(/^mdl_sel\/(.+)$/);
|
||||
if (compactSelMatch) {
|
||||
const modelRef = compactSelMatch[1];
|
||||
if (modelRef) {
|
||||
return {
|
||||
type: "select",
|
||||
model: modelRef,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// mdl_sel_{provider/model}
|
||||
const selMatch = trimmed.match(/^mdl_sel_(.+)$/);
|
||||
if (selMatch) {
|
||||
const modelRef = selMatch[1];
|
||||
if (modelRef) {
|
||||
const slashIndex = modelRef.indexOf("/");
|
||||
if (slashIndex > 0 && slashIndex < modelRef.length - 1) {
|
||||
return {
|
||||
type: "select",
|
||||
provider: modelRef.slice(0, slashIndex),
|
||||
model: modelRef.slice(slashIndex + 1),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildModelSelectionCallbackData(params: {
|
||||
provider: string;
|
||||
model: string;
|
||||
}): string | null {
|
||||
const fullCallbackData = `${CALLBACK_PREFIX.selectStandard}${params.provider}/${params.model}`;
|
||||
if (Buffer.byteLength(fullCallbackData, "utf8") <= MAX_CALLBACK_DATA_BYTES) {
|
||||
return fullCallbackData;
|
||||
}
|
||||
const compactCallbackData = `${CALLBACK_PREFIX.selectCompact}${params.model}`;
|
||||
return Buffer.byteLength(compactCallbackData, "utf8") <= MAX_CALLBACK_DATA_BYTES
|
||||
? compactCallbackData
|
||||
: null;
|
||||
}
|
||||
|
||||
export function resolveModelSelection(params: {
|
||||
callback: Extract<ParsedModelCallback, { type: "select" }>;
|
||||
providers: readonly string[];
|
||||
byProvider: ReadonlyMap<string, ReadonlySet<string>>;
|
||||
}): ResolveModelSelectionResult {
|
||||
if (params.callback.provider) {
|
||||
return {
|
||||
kind: "resolved",
|
||||
provider: params.callback.provider,
|
||||
model: params.callback.model,
|
||||
};
|
||||
}
|
||||
const matchingProviders = params.providers.filter((id) =>
|
||||
params.byProvider.get(id)?.has(params.callback.model),
|
||||
);
|
||||
if (matchingProviders.length === 1) {
|
||||
return {
|
||||
kind: "resolved",
|
||||
provider: matchingProviders[0],
|
||||
model: params.callback.model,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "ambiguous",
|
||||
model: params.callback.model,
|
||||
matchingProviders,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build provider selection keyboard with 2 providers per row.
|
||||
*/
|
||||
export function buildProviderKeyboard(providers: ProviderInfo[]): ButtonRow[] {
|
||||
if (providers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows: ButtonRow[] = [];
|
||||
let currentRow: ButtonRow = [];
|
||||
|
||||
for (const provider of providers) {
|
||||
const button = {
|
||||
text: `${provider.id} (${provider.count})`,
|
||||
callback_data: `mdl_list_${provider.id}_1`,
|
||||
};
|
||||
|
||||
currentRow.push(button);
|
||||
|
||||
if (currentRow.length === 2) {
|
||||
rows.push(currentRow);
|
||||
currentRow = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Push any remaining button
|
||||
if (currentRow.length > 0) {
|
||||
rows.push(currentRow);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build model list keyboard with pagination and back button.
|
||||
*/
|
||||
export function buildModelsKeyboard(params: ModelsKeyboardParams): ButtonRow[] {
|
||||
const { provider, models, currentModel, currentPage, totalPages } = params;
|
||||
const pageSize = params.pageSize ?? MODELS_PAGE_SIZE;
|
||||
|
||||
if (models.length === 0) {
|
||||
return [[{ text: "<< Back", callback_data: CALLBACK_PREFIX.back }]];
|
||||
}
|
||||
|
||||
const rows: ButtonRow[] = [];
|
||||
|
||||
// Calculate page slice
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = Math.min(startIndex + pageSize, models.length);
|
||||
const pageModels = models.slice(startIndex, endIndex);
|
||||
|
||||
// Model buttons - one per row
|
||||
const currentModelId = currentModel?.includes("/")
|
||||
? currentModel.split("/").slice(1).join("/")
|
||||
: currentModel;
|
||||
|
||||
for (const model of pageModels) {
|
||||
const callbackData = buildModelSelectionCallbackData({ provider, model });
|
||||
// Skip models that still exceed Telegram's callback_data limit.
|
||||
if (!callbackData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isCurrentModel = model === currentModelId;
|
||||
const displayText = truncateModelId(model, 38);
|
||||
const text = isCurrentModel ? `${displayText} ✓` : displayText;
|
||||
|
||||
rows.push([
|
||||
{
|
||||
text,
|
||||
callback_data: callbackData,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// Pagination row
|
||||
if (totalPages > 1) {
|
||||
const paginationRow: ButtonRow = [];
|
||||
|
||||
if (currentPage > 1) {
|
||||
paginationRow.push({
|
||||
text: "◀ Prev",
|
||||
callback_data: `${CALLBACK_PREFIX.list}${provider}_${currentPage - 1}`,
|
||||
});
|
||||
}
|
||||
|
||||
paginationRow.push({
|
||||
text: `${currentPage}/${totalPages}`,
|
||||
callback_data: `${CALLBACK_PREFIX.list}${provider}_${currentPage}`, // noop
|
||||
});
|
||||
|
||||
if (currentPage < totalPages) {
|
||||
paginationRow.push({
|
||||
text: "Next ▶",
|
||||
callback_data: `${CALLBACK_PREFIX.list}${provider}_${currentPage + 1}`,
|
||||
});
|
||||
}
|
||||
|
||||
rows.push(paginationRow);
|
||||
}
|
||||
|
||||
// Back button
|
||||
rows.push([{ text: "<< Back", callback_data: CALLBACK_PREFIX.back }]);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build "Browse providers" button for /model summary.
|
||||
*/
|
||||
export function buildBrowseProvidersButton(): ButtonRow[] {
|
||||
return [[{ text: "Browse providers", callback_data: CALLBACK_PREFIX.providers }]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate model ID for display, preserving end if too long.
|
||||
*/
|
||||
function truncateModelId(modelId: string, maxLen: number): string {
|
||||
if (modelId.length <= maxLen) {
|
||||
return modelId;
|
||||
}
|
||||
// Show last part with ellipsis prefix
|
||||
return `…${modelId.slice(-(maxLen - 1))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get page size for model list pagination.
|
||||
*/
|
||||
export function getModelsPageSize(): number {
|
||||
return MODELS_PAGE_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total pages for a model list.
|
||||
*/
|
||||
export function calculateTotalPages(totalModels: number, pageSize?: number): number {
|
||||
const size = pageSize ?? MODELS_PAGE_SIZE;
|
||||
return size > 0 ? Math.ceil(totalModels / size) : 1;
|
||||
}
|
||||
@@ -209,8 +209,8 @@ async function monitorWithAutoAbort(
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
vi.mock("../../../src/config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig,
|
||||
@@ -254,12 +254,12 @@ vi.mock("@grammyjs/runner", () => ({
|
||||
run: runSpy,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/backoff.js", () => ({
|
||||
vi.mock("../../../src/infra/backoff.js", () => ({
|
||||
computeBackoff,
|
||||
sleepWithAbort,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/unhandled-rejections.js", () => ({
|
||||
vi.mock("../../../src/infra/unhandled-rejections.js", () => ({
|
||||
registerUnhandledRejectionHandler: registerUnhandledRejectionHandlerMock,
|
||||
}));
|
||||
|
||||
@@ -272,7 +272,7 @@ vi.mock("./update-offset-store.js", () => ({
|
||||
writeTelegramUpdateOffset: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../auto-reply/reply.js", () => ({
|
||||
vi.mock("../../../src/auto-reply/reply.js", () => ({
|
||||
getReplyFromConfig: async (ctx: { Body?: string }) => ({
|
||||
text: `echo:${ctx.Body}`,
|
||||
}),
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user