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
main
Christian Klotz 2026-02-04 10:09:28 +00:00 committed by GitHub
parent 6341819d74
commit da6de49815
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 110 additions and 270 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import type {
TelegramTopicConfig, TelegramTopicConfig,
} from "../config/types.js"; } from "../config/types.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import type { TelegramContext } from "./bot/types.js";
import { resolveEffectiveMessagesConfig } from "../agents/identity.js"; import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
import { resolveChunkMode } from "../auto-reply/chunk.js"; import { resolveChunkMode } from "../auto-reply/chunk.js";
import { import {
@ -86,7 +87,7 @@ export type RegisterTelegramHandlerParams = {
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig }; ) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean; shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean;
processMessage: ( processMessage: (
ctx: unknown, ctx: TelegramContext,
allMedia: Array<{ path: string; contentType?: string }>, allMedia: Array<{ path: string; contentType?: string }>,
storeAllowFrom: string[], storeAllowFrom: string[],
options?: { 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"; import { createDedupeCache } from "../infra/dedupe.js";
const MEDIA_GROUP_TIMEOUT_MS = 500; const MEDIA_GROUP_TIMEOUT_MS = 500;
@ -7,7 +8,7 @@ const RECENT_TELEGRAM_UPDATE_MAX = 2000;
export type MediaGroupEntry = { export type MediaGroupEntry = {
messages: Array<{ messages: Array<{
msg: TelegramMessage; msg: Message;
ctx: TelegramContext; ctx: TelegramContext;
}>; }>;
timer: ReturnType<typeof setTimeout>; timer: ReturnType<typeof setTimeout>;
@ -16,12 +17,12 @@ export type MediaGroupEntry = {
export type TelegramUpdateKeyContext = { export type TelegramUpdateKeyContext = {
update?: { update?: {
update_id?: number; update_id?: number;
message?: TelegramMessage; message?: Message;
edited_message?: TelegramMessage; edited_message?: Message;
}; };
update_id?: number; update_id?: number;
message?: TelegramMessage; message?: Message;
callbackQuery?: { id?: string; message?: TelegramMessage }; callbackQuery?: { id?: string; message?: Message };
}; };
export const resolveTelegramUpdateId = (ctx: TelegramUpdateKeyContext) => export const resolveTelegramUpdateId = (ctx: TelegramUpdateKeyContext) =>

View File

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

View File

@ -100,40 +100,6 @@ describe("normalizeForwardedContext", () => {
expect(ctx?.fromTitle).toBe("Hidden Name"); expect(ctx?.fromTitle).toBe("Hidden Name");
expect(ctx?.date).toBe(456); 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", () => { describe("expandTextLinks", () => {

View File

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

View File

@ -1,80 +1,20 @@
import type { Message } from "@grammyjs/types"; import type { Message } from "@grammyjs/types";
export type TelegramQuote = { /** App-specific stream mode for Telegram draft streaming. */
text?: string;
};
export type TelegramMessage = Message & {
quote?: TelegramQuote;
};
export type TelegramStreamMode = "off" | "partial" | "block"; export type TelegramStreamMode = "off" | "partial" | "block";
export type TelegramForwardOriginType = "user" | "hidden_user" | "chat" | "channel"; /**
* Minimal context projection from Grammy's Context class.
export type TelegramForwardUser = { * Decouples the message processing pipeline from Grammy's full Context,
first_name?: string; * and allows constructing synthetic contexts for debounced/combined messages.
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;
export type TelegramContext = { export type TelegramContext = {
message: TelegramMessage; message: Message;
me?: { id?: number; username?: string }; me?: { id?: number; username?: string };
getFile: () => Promise<{ getFile: () => Promise<{ file_path?: string }>;
file_path?: string;
}>;
}; };
/** Telegram Location object */ /** Telegram sticker metadata for context enrichment and caching. */
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. */
export interface StickerMetadata { export interface StickerMetadata {
/** Emoji associated with the sticker. */ /** Emoji associated with the sticker. */
emoji?: string; emoji?: string;

View File

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