Telegram: use Grammy types directly, add typed Probe/Audit to plugin interface (#8403)

* Telegram: replace duplicated types with Grammy imports, add Probe/Audit generics to plugin interface

* Telegram: remove legacy forward metadata (deprecated in Bot API 7.0), simplify required-field checks

* Telegram: clean up remaining legacy references and unnecessary casts

* Telegram: keep RequestInit parameter type in proxy fetch (addresses review feedback)

* Telegram: add exhaustiveness guard to resolveForwardOrigin switch
This commit is contained in:
Christian Klotz
2026-02-04 10:09:28 +00:00
committed by GitHub
parent 6341819d74
commit da6de49815
12 changed files with 110 additions and 270 deletions

View File

@@ -25,6 +25,7 @@ import {
type ChannelPlugin,
type OpenClawConfig,
type ResolvedTelegramAccount,
type TelegramProbe,
} from "openclaw/plugin-sdk";
import { getTelegramRuntime } from "./runtime.js";
@@ -60,7 +61,7 @@ function parseThreadId(threadId?: string | number | null) {
const parsed = Number.parseInt(trimmed, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProbe> = {
id: "telegram",
meta: {
...meta,
@@ -327,11 +328,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
if (!groupIds.length && unresolvedGroups === 0 && !hasWildcardUnmentionedGroups) {
return undefined;
}
const botId =
(probe as { ok?: boolean; bot?: { id?: number } })?.ok &&
(probe as { bot?: { id?: number } }).bot?.id != null
? (probe as { bot: { id: number } }).bot.id
: null;
const botId = probe?.ok && probe.bot?.id != null ? probe.bot.id : null;
if (!botId) {
return {
ok: unresolvedGroups === 0 && !hasWildcardUnmentionedGroups,
@@ -357,15 +354,9 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ??
cfg.channels?.telegram?.groups;
const allowUnmentionedGroups =
Boolean(
groups?.["*"] && (groups["*"] as { requireMention?: boolean }).requireMention === false,
) ||
groups?.["*"]?.requireMention === false ||
Object.entries(groups ?? {}).some(
([key, value]) =>
key !== "*" &&
Boolean(value) &&
typeof value === "object" &&
(value as { requireMention?: boolean }).requireMention === false,
([key, value]) => key !== "*" && value?.requireMention === false,
);
return {
accountId: account.accountId,

View File

@@ -105,7 +105,7 @@ export type ChannelOutboundAdapter = {
sendPoll?: (ctx: ChannelPollContext) => Promise<ChannelPollResult>;
};
export type ChannelStatusAdapter<ResolvedAccount> = {
export type ChannelStatusAdapter<ResolvedAccount, Probe = unknown, Audit = unknown> = {
defaultRuntime?: ChannelAccountSnapshot;
buildChannelSummary?: (params: {
account: ResolvedAccount;
@@ -117,19 +117,19 @@ export type ChannelStatusAdapter<ResolvedAccount> = {
account: ResolvedAccount;
timeoutMs: number;
cfg: OpenClawConfig;
}) => Promise<unknown>;
}) => Promise<Probe>;
auditAccount?: (params: {
account: ResolvedAccount;
timeoutMs: number;
cfg: OpenClawConfig;
probe?: unknown;
}) => Promise<unknown>;
probe?: Probe;
}) => Promise<Audit>;
buildAccountSnapshot?: (params: {
account: ResolvedAccount;
cfg: OpenClawConfig;
runtime?: ChannelAccountSnapshot;
probe?: unknown;
audit?: unknown;
probe?: Probe;
audit?: Audit;
}) => ChannelAccountSnapshot | Promise<ChannelAccountSnapshot>;
logSelfId?: (params: {
account: ResolvedAccount;

View File

@@ -45,7 +45,7 @@ export type ChannelConfigSchema = {
};
// oxlint-disable-next-line typescript/no-explicit-any
export type ChannelPlugin<ResolvedAccount = any> = {
export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknown> = {
id: ChannelId;
meta: ChannelMeta;
capabilities: ChannelCapabilities;
@@ -65,7 +65,7 @@ export type ChannelPlugin<ResolvedAccount = any> = {
groups?: ChannelGroupAdapter;
mentions?: ChannelMentionAdapter;
outbound?: ChannelOutboundAdapter;
status?: ChannelStatusAdapter<ResolvedAccount>;
status?: ChannelStatusAdapter<ResolvedAccount, Probe, Audit>;
gatewayMethods?: string[];
gateway?: ChannelGatewayAdapter<ResolvedAccount>;
auth?: ChannelAuthAdapter;

View File

@@ -306,6 +306,7 @@ export {
normalizeTelegramMessagingTarget,
} from "../channels/plugins/normalize/telegram.js";
export { collectTelegramStatusIssues } from "../channels/plugins/status-issues/telegram.js";
export { type TelegramProbe } from "../telegram/probe.js";
// Channel: Signal
export {

View File

@@ -1,6 +1,6 @@
import type { TelegramMessage } from "./bot/types.js";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
// @ts-nocheck
import type { Message } from "@grammyjs/types";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import {
createInboundDebouncer,
@@ -63,7 +63,7 @@ export const registerTelegramHandlers = ({
type TextFragmentEntry = {
key: string;
messages: Array<{ msg: TelegramMessage; ctx: unknown; receivedAtMs: number }>;
messages: Array<{ msg: Message; ctx: unknown; receivedAtMs: number }>;
timer: ReturnType<typeof setTimeout>;
};
const textFragmentBuffer = new Map<string, TextFragmentEntry>();
@@ -72,7 +72,7 @@ export const registerTelegramHandlers = ({
const debounceMs = resolveInboundDebounceMs({ cfg, channel: "telegram" });
type TelegramDebounceEntry = {
ctx: unknown;
msg: TelegramMessage;
msg: Message;
allMedia: Array<{ path: string; contentType?: string }>;
storeAllowFrom: string[];
debounceKey: string | null;
@@ -111,7 +111,7 @@ export const registerTelegramHandlers = ({
const baseCtx = first.ctx as { me?: unknown; getFile?: unknown } & Record<string, unknown>;
const getFile =
typeof baseCtx.getFile === "function" ? baseCtx.getFile.bind(baseCtx) : async () => ({});
const syntheticMessage: TelegramMessage = {
const syntheticMessage: Message = {
...first.msg,
text: combinedText,
caption: undefined,
@@ -231,7 +231,7 @@ export const registerTelegramHandlers = ({
return;
}
const syntheticMessage: TelegramMessage = {
const syntheticMessage: Message = {
...first.msg,
text: combinedText,
caption: undefined,
@@ -557,7 +557,7 @@ export const registerTelegramHandlers = ({
if (modelCallback.type === "select") {
const { provider, model } = modelCallback;
// Process model selection as a synthetic message with /model command
const syntheticMessage: TelegramMessage = {
const syntheticMessage: Message = {
...callbackMessage,
from: callback.from,
text: `/model ${provider}/${model}`,
@@ -582,7 +582,7 @@ export const registerTelegramHandlers = ({
return;
}
const syntheticMessage: TelegramMessage = {
const syntheticMessage: Message = {
...callbackMessage,
from: callback.from,
text: data,

View File

@@ -9,6 +9,7 @@ import type {
TelegramTopicConfig,
} from "../config/types.js";
import type { RuntimeEnv } from "../runtime.js";
import type { TelegramContext } from "./bot/types.js";
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
import { resolveChunkMode } from "../auto-reply/chunk.js";
import {
@@ -86,7 +87,7 @@ export type RegisterTelegramHandlerParams = {
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean;
processMessage: (
ctx: unknown,
ctx: TelegramContext,
allMedia: Array<{ path: string; contentType?: string }>,
storeAllowFrom: string[],
options?: {

View File

@@ -1,4 +1,5 @@
import type { TelegramContext, TelegramMessage } from "./bot/types.js";
import type { Message } from "@grammyjs/types";
import type { TelegramContext } from "./bot/types.js";
import { createDedupeCache } from "../infra/dedupe.js";
const MEDIA_GROUP_TIMEOUT_MS = 500;
@@ -7,7 +8,7 @@ const RECENT_TELEGRAM_UPDATE_MAX = 2000;
export type MediaGroupEntry = {
messages: Array<{
msg: TelegramMessage;
msg: Message;
ctx: TelegramContext;
}>;
timer: ReturnType<typeof setTimeout>;
@@ -16,12 +17,12 @@ export type MediaGroupEntry = {
export type TelegramUpdateKeyContext = {
update?: {
update_id?: number;
message?: TelegramMessage;
edited_message?: TelegramMessage;
message?: Message;
edited_message?: Message;
};
update_id?: number;
message?: TelegramMessage;
callbackQuery?: { id?: string; message?: TelegramMessage };
message?: Message;
callbackQuery?: { id?: string; message?: Message };
};
export const resolveTelegramUpdateId = (ctx: TelegramUpdateKeyContext) =>

View File

@@ -2,11 +2,11 @@ import type { ApiClientOptions } from "grammy";
// @ts-nocheck
import { sequentialize } from "@grammyjs/runner";
import { apiThrottler } from "@grammyjs/transformer-throttler";
import { ReactionTypeEmoji } from "@grammyjs/types";
import { type Message, ReactionTypeEmoji } from "@grammyjs/types";
import { Bot, webhookCallback } from "grammy";
import type { OpenClawConfig, ReplyToMode } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import type { TelegramContext, TelegramMessage } from "./bot/types.js";
import type { TelegramContext } from "./bot/types.js";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { isControlCommandMessage } from "../auto-reply/command-detection.js";
@@ -67,11 +67,11 @@ export type TelegramBotOptions = {
export function getTelegramSequentialKey(ctx: {
chat?: { id?: number };
message?: TelegramMessage;
message?: Message;
update?: {
message?: TelegramMessage;
edited_message?: TelegramMessage;
callback_query?: { message?: TelegramMessage };
message?: Message;
edited_message?: Message;
callback_query?: { message?: Message };
message_reaction?: { chat?: { id?: number } };
};
}): string {

View File

@@ -100,40 +100,6 @@ describe("normalizeForwardedContext", () => {
expect(ctx?.fromTitle).toBe("Hidden Name");
expect(ctx?.date).toBe(456);
});
it("handles legacy forwards with signatures", () => {
const ctx = normalizeForwardedContext({
forward_from_chat: {
title: "OpenClaw Updates",
username: "openclaw",
id: 99,
type: "channel",
},
forward_signature: "Stan",
forward_date: 789,
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
expect(ctx).not.toBeNull();
expect(ctx?.from).toBe("OpenClaw Updates (Stan)");
expect(ctx?.fromType).toBe("legacy_channel");
expect(ctx?.fromId).toBe("99");
expect(ctx?.fromUsername).toBe("openclaw");
expect(ctx?.fromTitle).toBe("OpenClaw Updates");
expect(ctx?.fromSignature).toBe("Stan");
expect(ctx?.date).toBe(789);
});
it("handles legacy hidden sender names", () => {
const ctx = normalizeForwardedContext({
forward_sender_name: "Legacy Hidden",
forward_date: 111,
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
expect(ctx).not.toBeNull();
expect(ctx?.from).toBe("Legacy Hidden");
expect(ctx?.fromType).toBe("legacy_hidden_user");
expect(ctx?.date).toBe(111);
});
});
describe("expandTextLinks", () => {

View File

@@ -1,13 +1,5 @@
import type {
TelegramForwardChat,
TelegramForwardOrigin,
TelegramForwardUser,
TelegramForwardedMessage,
TelegramLocation,
TelegramMessage,
TelegramStreamMode,
TelegramVenue,
} from "./types.js";
import type { Chat, Message, MessageOrigin, User } from "@grammyjs/types";
import type { TelegramStreamMode } from "./types.js";
import { formatLocationText, type NormalizedLocation } from "../../channels/location.js";
const TELEGRAM_GENERAL_TOPIC_ID = 1;
@@ -107,14 +99,14 @@ export function buildTelegramGroupFrom(chatId: number | string, messageThreadId?
return `telegram:group:${buildTelegramGroupPeerId(chatId, messageThreadId)}`;
}
export function buildSenderName(msg: TelegramMessage) {
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 buildSenderLabel(msg: TelegramMessage, senderId?: number | string) {
export function buildSenderLabel(msg: Message, senderId?: number | string) {
const name = buildSenderName(msg);
const username = msg.from?.username ? `@${msg.from.username}` : undefined;
let label = name;
@@ -136,11 +128,7 @@ export function buildSenderLabel(msg: TelegramMessage, senderId?: number | strin
return idPart ?? "id:unknown";
}
export function buildGroupLabel(
msg: TelegramMessage,
chatId: number | string,
messageThreadId?: number,
) {
export function buildGroupLabel(msg: Message, chatId: number | string, messageThreadId?: number) {
const title = msg.chat?.title;
const topicSuffix = messageThreadId != null ? ` topic:${messageThreadId}` : "";
if (title) {
@@ -149,7 +137,7 @@ export function buildGroupLabel(
return `group:${chatId}${topicSuffix}`;
}
export function hasBotMention(msg: TelegramMessage, botUsername: string) {
export function hasBotMention(msg: Message, botUsername: string) {
const text = (msg.text ?? msg.caption ?? "").toLowerCase();
if (text.includes(`@${botUsername}`)) {
return true;
@@ -218,7 +206,7 @@ export type TelegramReplyTarget = {
kind: "reply" | "quote";
};
export function describeReplyTarget(msg: TelegramMessage): TelegramReplyTarget | null {
export function describeReplyTarget(msg: Message): TelegramReplyTarget | null {
const reply = msg.reply_to_message;
const quote = msg.quote;
let body = "";
@@ -275,28 +263,27 @@ export type TelegramForwardedContext = {
fromSignature?: string;
};
function normalizeForwardedUserLabel(user: TelegramForwardUser) {
function normalizeForwardedUserLabel(user: User) {
const name = [user.first_name, user.last_name].filter(Boolean).join(" ").trim();
const username = user.username?.trim() || undefined;
const id = user.id != null ? String(user.id) : undefined;
const id = String(user.id);
const display =
(name && username
? `${name} (@${username})`
: name || (username ? `@${username}` : undefined)) || (id ? `user:${id}` : undefined);
: name || (username ? `@${username}` : undefined)) || `user:${id}`;
return { display, name: name || undefined, username, id };
}
function normalizeForwardedChatLabel(chat: TelegramForwardChat, fallbackKind: "chat" | "channel") {
function normalizeForwardedChatLabel(chat: Chat, fallbackKind: "chat" | "channel") {
const title = chat.title?.trim() || undefined;
const username = chat.username?.trim() || undefined;
const id = chat.id != null ? String(chat.id) : undefined;
const display =
title || (username ? `@${username}` : undefined) || (id ? `${fallbackKind}:${id}` : undefined);
const id = String(chat.id);
const display = title || (username ? `@${username}` : undefined) || `${fallbackKind}:${id}`;
return { display, title, username, id };
}
function buildForwardedContextFromUser(params: {
user: TelegramForwardUser;
user: User;
date?: number;
type: string;
}): TelegramForwardedContext | null {
@@ -332,13 +319,12 @@ function buildForwardedContextFromHiddenName(params: {
}
function buildForwardedContextFromChat(params: {
chat: TelegramForwardChat;
chat: Chat;
date?: number;
type: string;
signature?: string;
}): TelegramForwardedContext | null {
const fallbackKind =
params.type === "channel" || params.type === "legacy_channel" ? "channel" : "chat";
const fallbackKind = params.type === "channel" ? "channel" : "chat";
const { display, title, username, id } = normalizeForwardedChatLabel(params.chat, fallbackKind);
if (!display) {
return null;
@@ -356,101 +342,52 @@ function buildForwardedContextFromChat(params: {
};
}
function resolveForwardOrigin(
origin: TelegramForwardOrigin,
signature?: string,
): TelegramForwardedContext | null {
if (origin.type === "user" && origin.sender_user) {
return buildForwardedContextFromUser({
user: origin.sender_user,
date: origin.date,
type: "user",
});
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,
});
default:
// Exhaustiveness guard: if Grammy adds a new MessageOrigin variant,
// TypeScript will flag this assignment as an error.
origin satisfies never;
return null;
}
if (origin.type === "hidden_user") {
return buildForwardedContextFromHiddenName({
name: origin.sender_user_name,
date: origin.date,
type: "hidden_user",
});
}
if (origin.type === "chat" && origin.sender_chat) {
return buildForwardedContextFromChat({
chat: origin.sender_chat,
date: origin.date,
type: "chat",
signature,
});
}
if (origin.type === "channel" && origin.chat) {
return buildForwardedContextFromChat({
chat: origin.chat,
date: origin.date,
type: "channel",
signature,
});
}
return null;
}
/**
* Extract forwarded message origin info from Telegram message.
* Supports both new forward_origin API and legacy forward_from/forward_from_chat fields.
*/
export function normalizeForwardedContext(msg: TelegramMessage): TelegramForwardedContext | null {
const forwardMsg = msg as TelegramForwardedMessage;
const signature = forwardMsg.forward_signature?.trim() || undefined;
if (forwardMsg.forward_origin) {
const originContext = resolveForwardOrigin(forwardMsg.forward_origin, signature);
if (originContext) {
return originContext;
}
/** Extract forwarded message origin info from Telegram message. */
export function normalizeForwardedContext(msg: Message): TelegramForwardedContext | null {
if (!msg.forward_origin) {
return null;
}
if (forwardMsg.forward_from_chat) {
const legacyType =
forwardMsg.forward_from_chat.type === "channel" ? "legacy_channel" : "legacy_chat";
const legacyContext = buildForwardedContextFromChat({
chat: forwardMsg.forward_from_chat,
date: forwardMsg.forward_date,
type: legacyType,
signature,
});
if (legacyContext) {
return legacyContext;
}
}
if (forwardMsg.forward_from) {
const legacyContext = buildForwardedContextFromUser({
user: forwardMsg.forward_from,
date: forwardMsg.forward_date,
type: "legacy_user",
});
if (legacyContext) {
return legacyContext;
}
}
const hiddenContext = buildForwardedContextFromHiddenName({
name: forwardMsg.forward_sender_name,
date: forwardMsg.forward_date,
type: "legacy_hidden_user",
});
if (hiddenContext) {
return hiddenContext;
}
return null;
return resolveForwardOrigin(msg.forward_origin);
}
export function extractTelegramLocation(msg: TelegramMessage): NormalizedLocation | null {
const msgWithLocation = msg as {
location?: TelegramLocation;
venue?: TelegramVenue;
};
const { venue, location } = msgWithLocation;
export function extractTelegramLocation(msg: Message): NormalizedLocation | null {
const { venue, location } = msg;
if (venue) {
return {

View File

@@ -1,80 +1,20 @@
import type { Message } from "@grammyjs/types";
export type TelegramQuote = {
text?: string;
};
export type TelegramMessage = Message & {
quote?: TelegramQuote;
};
/** App-specific stream mode for Telegram draft streaming. */
export type TelegramStreamMode = "off" | "partial" | "block";
export type TelegramForwardOriginType = "user" | "hidden_user" | "chat" | "channel";
export type TelegramForwardUser = {
first_name?: string;
last_name?: string;
username?: string;
id?: number;
};
export type TelegramForwardChat = {
title?: string;
id?: number;
username?: string;
type?: string;
};
export type TelegramForwardOrigin = {
type: TelegramForwardOriginType;
sender_user?: TelegramForwardUser;
sender_user_name?: string;
sender_chat?: TelegramForwardChat;
chat?: TelegramForwardChat;
date?: number;
};
export type TelegramForwardMetadata = {
forward_origin?: TelegramForwardOrigin;
forward_from?: TelegramForwardUser;
forward_from_chat?: TelegramForwardChat;
forward_sender_name?: string;
forward_signature?: string;
forward_date?: number;
};
export type TelegramForwardedMessage = TelegramMessage & TelegramForwardMetadata;
/**
* 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: TelegramMessage;
message: Message;
me?: { id?: number; username?: string };
getFile: () => Promise<{
file_path?: string;
}>;
getFile: () => Promise<{ file_path?: string }>;
};
/** Telegram Location object */
export interface TelegramLocation {
latitude: number;
longitude: number;
horizontal_accuracy?: number;
live_period?: number;
heading?: number;
}
/** Telegram Venue object */
export interface TelegramVenue {
location: TelegramLocation;
title: string;
address: string;
foursquare_id?: string;
foursquare_type?: string;
google_place_id?: string;
google_place_type?: string;
}
/** Telegram sticker metadata for context enrichment. */
/** Telegram sticker metadata for context enrichment and caching. */
export interface StickerMetadata {
/** Emoji associated with the sticker. */
emoji?: string;

View File

@@ -1,11 +1,14 @@
// @ts-nocheck
import { ProxyAgent, fetch as undiciFetch } from "undici";
import { wrapFetchWithAbortSignal } from "../infra/fetch.js";
export function makeProxyFetch(proxyUrl: string): typeof fetch {
const agent = new ProxyAgent(proxyUrl);
return wrapFetchWithAbortSignal((input: RequestInfo | URL, init?: RequestInit) => {
const base = init ? { ...init } : {};
return undiciFetch(input, { ...base, dispatcher: agent });
});
// undici's fetch is runtime-compatible with global fetch but the types diverge
// on stream/body internals. Single cast at the boundary keeps the rest type-safe.
const fetcher = (input: RequestInfo | URL, init?: RequestInit) =>
undiciFetch(input as string | URL, {
...(init as Record<string, unknown>),
dispatcher: agent,
}) as unknown as Promise<Response>;
return wrapFetchWithAbortSignal(fetcher);
}