Files
oh-my-pi-telegram/lib/updates.ts
evandrodevbr 057281a3bb feat: human error states, operator toasts and a queue/origin view in the omp UI
- classify transport and API failures into a short state with the next step
- count undelivered messages and show them next to the sent counter
- toast on owner paired, connection recovered and reply not delivered (throttled)
- second status line previews the next queued turns
- host spinner names the Telegram prompt while its turn runs
- sanitize every Telegram-controlled string that reaches the widget
2026-10-01 17:33:37 -03:00

1340 lines
41 KiB
TypeScript

/**
* Telegram updates domain helpers
* Zones: telegram inbound, authorization, routing plans
* Owns update extraction, authorization, classification, execution planning, runtime execution, and the public update-handler registry
*/
import {
createTelegramPrivateTarget,
createTelegramThreadTarget,
type TelegramTarget,
} from "./target.ts";
import type { TelegramMessageOwnershipStore } from "./ownership.ts";
import {
createTelegramUserPairingRuntime,
getTelegramAuthorizationState,
type TelegramAuthorizationState,
type TelegramUserPairingRuntimeDeps,
} from "./config.ts";
// --- Extraction ---
export interface TelegramReactionTypeEmoji {
type: "emoji";
emoji: string;
}
export interface TelegramReactionTypeNonEmoji {
type: string;
}
export type TelegramReactionType =
| TelegramReactionTypeEmoji
| TelegramReactionTypeNonEmoji;
export const TELEGRAM_PRIORITY_REACTIONS = [
{ id: 10, name: "like", emoji: "👍" },
{ id: 11, name: "lightning", emoji: "⚡" },
{ id: 12, name: "heart", emoji: "❤" },
{ id: 13, name: "dove", emoji: "🕊" },
{ id: 14, name: "fire", emoji: "🔥" },
] as const;
export const TELEGRAM_REMOVAL_REACTIONS = [
{ id: 20, name: "dislike", emoji: "👎" },
{ id: 21, name: "ghost", emoji: "👻" },
{ id: 22, name: "broken-heart", emoji: "💔" },
{ id: 23, name: "poop", emoji: "💩" },
{ id: 24, name: "wastebasket", emoji: "🗑" },
] as const;
export const TELEGRAM_PRIORITY_REACTION_EMOJIS =
TELEGRAM_PRIORITY_REACTIONS.map((reaction) => reaction.emoji);
export const TELEGRAM_REMOVAL_REACTION_EMOJIS = TELEGRAM_REMOVAL_REACTIONS.map(
(reaction) => reaction.emoji,
);
export interface TelegramUpdateDeletion {
deleted_business_messages?: { message_ids?: unknown };
}
function isTelegramMessageIdList(value: unknown): value is number[] {
return Array.isArray(value) && value.every((item) => Number.isInteger(item));
}
export function normalizeTelegramReactionEmoji(emoji: string): string {
return emoji.replace(/\uFE0F/g, "");
}
export function collectTelegramReactionEmojis(
reactions: TelegramReactionType[],
): Set<string> {
return new Set(
reactions
.filter(
(reaction): reaction is TelegramReactionTypeEmoji =>
reaction.type === "emoji",
)
.map((reaction) => normalizeTelegramReactionEmoji(reaction.emoji)),
);
}
function hasAnyTelegramReactionEmoji(
emojis: Set<string>,
candidates: readonly string[],
): boolean {
return candidates.some((emoji) => emojis.has(emoji));
}
function getAddedTelegramReactionEmoji(
oldEmojis: Set<string>,
newEmojis: Set<string>,
candidates: readonly string[],
): string | undefined {
return candidates.find(
(emoji) => !oldEmojis.has(emoji) && newEmojis.has(emoji),
);
}
function hasAddedTelegramReactionEmoji(
oldEmojis: Set<string>,
newEmojis: Set<string>,
candidates: readonly string[],
): boolean {
return !!getAddedTelegramReactionEmoji(oldEmojis, newEmojis, candidates);
}
export function extractDeletedTelegramMessageIds(
update: TelegramUpdateDeletion,
): number[] {
const deletedBusinessMessageIds =
update.deleted_business_messages?.message_ids;
if (isTelegramMessageIdList(deletedBusinessMessageIds)) {
return deletedBusinessMessageIds;
}
return [];
}
// --- Routing ---
export interface TelegramUser {
id: number;
is_bot: boolean;
}
export interface TelegramChat {
id?: number;
type: string;
}
export interface TelegramUpdateMessage {
chat: TelegramChat;
from?: TelegramUser;
message_id?: number;
message_thread_id?: number;
forum_topic_created?: unknown;
forum_topic_closed?: unknown;
forum_topic_reopened?: unknown;
}
export type TelegramTopicLifecycleKind = "created" | "closed" | "reopened";
export interface TelegramTopicLifecycleUpdate<
TMessage = TelegramUpdateMessage,
> {
kind: TelegramTopicLifecycleKind;
message: TMessage;
target: TelegramTarget & { threadId: number };
}
export function getTelegramTopicLifecycleUpdate<
TMessage extends TelegramUpdateMessage,
>(
message: TMessage | undefined,
): TelegramTopicLifecycleUpdate<TMessage> | undefined {
if (
!message ||
typeof message.chat.id !== "number" ||
typeof message.message_thread_id !== "number"
) {
return undefined;
}
const target: TelegramTarget & { threadId: number } = {
...createTelegramThreadTarget(message.chat.id, message.message_thread_id),
threadId: message.message_thread_id,
};
if (message.forum_topic_created !== undefined) {
return { kind: "created", message, target };
}
if (message.forum_topic_closed !== undefined) {
return { kind: "closed", message, target };
}
if (message.forum_topic_reopened !== undefined) {
return { kind: "reopened", message, target };
}
return undefined;
}
export interface TelegramCallbackQuery {
id?: string;
from: TelegramUser;
message?: TelegramUpdateMessage;
}
export interface TelegramGuestMessage {
guest_query_id: string;
chat: TelegramChat;
from?: TelegramUser;
message_id?: number;
text?: string;
reply_to_message?: TelegramUpdateMessage;
}
export function getTelegramMessageTarget(
message: TelegramUpdateMessage,
): TelegramTarget | undefined {
if (typeof message.chat.id !== "number") return undefined;
return typeof message.message_thread_id === "number"
? createTelegramThreadTarget(message.chat.id, message.message_thread_id)
: createTelegramPrivateTarget(message.chat.id);
}
export interface TelegramUpdateRouting {
message?: TelegramUpdateMessage;
edited_message?: TelegramUpdateMessage;
callback_query?: TelegramCallbackQuery;
guest_message?: TelegramGuestMessage;
}
export function getAuthorizedTelegramCallbackQuery(
update: TelegramUpdateRouting,
allowedUserId?: number,
): TelegramCallbackQuery | undefined {
const query = update.callback_query;
if (!query || query.from.is_bot) return undefined;
const message = query.message;
if (!message) return undefined;
if (message.chat.type === "private") return query;
return query.from.id === allowedUserId ? query : undefined;
}
export function getAuthorizedTelegramMessage(
update: TelegramUpdateRouting,
allowedUserId?: number,
): TelegramUpdateMessage | undefined {
const message = update.message;
if (!message || !message.from || message.from.is_bot) return undefined;
if (message.chat.type === "private") return message;
return message.from.id === allowedUserId ? message : undefined;
}
export function getAuthorizedTelegramEditedMessage(
update: TelegramUpdateRouting,
allowedUserId?: number,
): TelegramUpdateMessage | undefined {
const message = update.edited_message;
if (!message || !message.from || message.from.is_bot) return undefined;
if (message.chat.type === "private") return message;
return message.from.id === allowedUserId ? message : undefined;
}
export function getAuthorizedTelegramGuestMessage(
update: TelegramUpdateRouting,
): TelegramGuestMessage | undefined {
const guestMessage = update.guest_message;
if (!guestMessage || !guestMessage.from || guestMessage.from.is_bot) {
return undefined;
}
return guestMessage;
}
// --- Flow ---
export interface TelegramMessageOwnershipView {
instanceId: string;
}
export type TelegramMessageOwnershipLookup = (
chatId: number,
messageId: number,
) => TelegramMessageOwnershipView | undefined;
export interface TelegramTargetOwnershipView {
instanceId: string;
}
export type TelegramTargetOwnershipLookup = (
target: TelegramTarget,
) => TelegramTargetOwnershipView | undefined;
export interface TelegramForeignOwnedUpdateForwarder<
TContext,
TReactionUpdate extends TelegramMessageReactionUpdated =
TelegramMessageReactionUpdated,
TCallbackQuery extends TelegramCallbackQuery = TelegramCallbackQuery,
TMessage extends TelegramUpdateMessage = TelegramUpdateMessage,
> {
forwardCallback?: (input: {
query: TCallbackQuery;
ownership: TelegramMessageOwnershipView;
ctx: TContext;
}) => Promise<boolean> | boolean;
forwardReaction?: (input: {
reactionUpdate: TReactionUpdate;
ownership: TelegramMessageOwnershipView;
ctx: TContext;
}) => Promise<boolean> | boolean;
forwardMessage?: (input: {
message: TMessage;
ownership: TelegramTargetOwnershipView;
ctx: TContext;
}) => Promise<boolean> | boolean;
forwardEditedMessage?: (input: {
message: TMessage;
ownership: TelegramTargetOwnershipView;
ctx: TContext;
}) => Promise<boolean> | boolean;
}
export interface TelegramMessageReactionUpdated {
chat: { id?: number; type: string };
user?: TelegramUser;
message_id: number;
old_reaction: TelegramReactionType[];
new_reaction: TelegramReactionType[];
}
export interface TelegramUpdateFlow
extends TelegramUpdateRouting, TelegramUpdateDeletion {
message_reaction?: TelegramMessageReactionUpdated;
}
export type TelegramUpdateFlowAction<
TReactionUpdate extends TelegramMessageReactionUpdated =
TelegramMessageReactionUpdated,
TCallbackQuery extends TelegramCallbackQuery = TelegramCallbackQuery,
TMessage extends TelegramUpdateMessage = TelegramUpdateMessage,
TGuestMessage extends TelegramGuestMessage = TelegramGuestMessage,
> =
| { kind: "ignore" }
| { kind: "deleted"; messageIds: number[] }
| { kind: "reaction"; reactionUpdate: TReactionUpdate }
| {
kind: "topic-lifecycle";
lifecycle: TelegramTopicLifecycleUpdate<TMessage>;
}
| {
kind: "callback";
query: TCallbackQuery;
authorization: TelegramAuthorizationState;
}
| {
kind: "message";
message: TMessage & { from: TelegramUser };
authorization: TelegramAuthorizationState;
}
| {
kind: "edited-message";
message: TMessage & { from: TelegramUser };
authorization: TelegramAuthorizationState;
}
| {
kind: "guest";
guestMessage: TGuestMessage & { from: TelegramUser };
authorization: TelegramAuthorizationState;
};
export function buildTelegramUpdateFlowAction<
TUpdate extends TelegramUpdateFlow,
>(
update: TUpdate,
allowedUserId?: number,
): TelegramUpdateFlowAction<
NonNullable<TUpdate["message_reaction"]>,
NonNullable<TUpdate["callback_query"]>,
NonNullable<TUpdate["message"] | TUpdate["edited_message"]>,
NonNullable<TUpdate["guest_message"]>
> {
const deletedMessageIds = extractDeletedTelegramMessageIds(update);
if (deletedMessageIds.length > 0) {
return { kind: "deleted", messageIds: deletedMessageIds };
}
if (update.message_reaction) {
return { kind: "reaction", reactionUpdate: update.message_reaction };
}
const topicLifecycle = getTelegramTopicLifecycleUpdate(update.message);
if (topicLifecycle) {
return { kind: "topic-lifecycle", lifecycle: topicLifecycle };
}
const query = getAuthorizedTelegramCallbackQuery(update, allowedUserId);
if (query) {
return {
kind: "callback",
query: query as NonNullable<TUpdate["callback_query"]>,
authorization: getTelegramAuthorizationState(
query.from.id,
allowedUserId,
),
};
}
const message = getAuthorizedTelegramMessage(update, allowedUserId);
if (message?.from) {
return {
kind: "message",
message: message as NonNullable<
TUpdate["message"] | TUpdate["edited_message"]
> & { from: TelegramUser },
authorization: getTelegramAuthorizationState(
message.from.id,
allowedUserId,
),
};
}
const editedMessage = getAuthorizedTelegramEditedMessage(
update,
allowedUserId,
);
if (editedMessage?.from) {
return {
kind: "edited-message",
message: editedMessage as NonNullable<
TUpdate["message"] | TUpdate["edited_message"]
> & { from: TelegramUser },
authorization: getTelegramAuthorizationState(
editedMessage.from.id,
allowedUserId,
),
};
}
const guestMessage = getAuthorizedTelegramGuestMessage(update);
if (guestMessage?.from) {
return {
kind: "guest",
guestMessage: guestMessage as NonNullable<TUpdate["guest_message"]> & {
from: TelegramUser;
},
authorization: getTelegramAuthorizationState(
guestMessage.from.id,
allowedUserId,
),
};
}
return { kind: "ignore" };
}
// --- Execution Planning ---
export type TelegramUpdateExecutionPlan<
TReactionUpdate extends TelegramMessageReactionUpdated =
TelegramMessageReactionUpdated,
TCallbackQuery extends TelegramCallbackQuery = TelegramCallbackQuery,
TMessage extends TelegramUpdateMessage = TelegramUpdateMessage,
TGuestMessage extends TelegramGuestMessage = TelegramGuestMessage,
> =
| { kind: "ignore" }
| { kind: "deleted"; messageIds: number[] }
| {
kind: "reaction";
reactionUpdate: TReactionUpdate;
}
| {
kind: "topic-lifecycle";
lifecycle: TelegramTopicLifecycleUpdate<TMessage>;
}
| {
kind: "callback";
query: TCallbackQuery;
shouldPair: boolean;
shouldDeny: boolean;
}
| {
kind: "message";
message: TMessage & { from: TelegramUser };
shouldPair: boolean;
shouldNotifyPaired: boolean;
shouldDeny: boolean;
}
| {
kind: "edited-message";
message: TMessage & { from: TelegramUser };
shouldPair: boolean;
shouldDeny: boolean;
}
| {
kind: "guest";
guestMessage: TGuestMessage & { from: TelegramUser };
shouldDeny: boolean;
};
export function buildTelegramUpdateExecutionPlan<
TReactionUpdate extends TelegramMessageReactionUpdated,
TCallbackQuery extends TelegramCallbackQuery,
TMessage extends TelegramUpdateMessage,
TGuestMessage extends TelegramGuestMessage,
>(
action: TelegramUpdateFlowAction<
TReactionUpdate,
TCallbackQuery,
TMessage,
TGuestMessage
>,
): TelegramUpdateExecutionPlan<
TReactionUpdate,
TCallbackQuery,
TMessage,
TGuestMessage
> {
switch (action.kind) {
case "ignore":
return { kind: "ignore" };
case "deleted":
return { kind: "deleted", messageIds: action.messageIds };
case "reaction":
return { kind: "reaction", reactionUpdate: action.reactionUpdate };
case "topic-lifecycle":
return { kind: "topic-lifecycle", lifecycle: action.lifecycle };
case "callback":
return {
kind: "callback",
query: action.query,
shouldPair: action.authorization.kind === "pair",
shouldDeny: action.authorization.kind === "deny",
};
case "message":
return {
kind: "message",
message: action.message,
shouldPair: action.authorization.kind === "pair",
shouldNotifyPaired: action.authorization.kind === "pair",
shouldDeny: action.authorization.kind === "deny",
};
case "edited-message":
return {
kind: "edited-message",
message: action.message,
shouldPair: action.authorization.kind === "pair",
shouldDeny: action.authorization.kind === "deny",
};
case "guest":
return {
kind: "guest",
guestMessage: action.guestMessage,
// Guest mode is an extension of an already paired bridge, not a pairing surface.
shouldDeny: action.authorization.kind !== "allow",
};
}
}
export function buildTelegramUpdateExecutionPlanFromUpdate<
TUpdate extends TelegramUpdateFlow,
>(
update: TUpdate,
allowedUserId?: number,
): TelegramUpdateExecutionPlan<
NonNullable<TUpdate["message_reaction"]>,
NonNullable<TUpdate["callback_query"]>,
NonNullable<TUpdate["message"] | TUpdate["edited_message"]>
> {
return buildTelegramUpdateExecutionPlan(
buildTelegramUpdateFlowAction(update, allowedUserId),
);
}
// --- Runtime ---
export type TelegramMessageOwnershipRecorderInput = Parameters<
TelegramMessageOwnershipStore["record"]
>[0];
export type TelegramMessageOwnershipRecorder = (
input: TelegramMessageOwnershipRecorderInput,
) => void;
export interface TelegramUpdateRuntimeDeps<
TContext = unknown,
TReactionUpdate extends TelegramMessageReactionUpdated =
TelegramMessageReactionUpdated,
TCallbackQuery extends TelegramCallbackQuery = TelegramCallbackQuery,
TMessage extends TelegramUpdateMessage = TelegramUpdateMessage,
> {
ctx: TContext;
getCurrentInstanceId?: () => string | undefined;
getMessageOwnership?: TelegramMessageOwnershipLookup;
getTargetOwnership?: TelegramTargetOwnershipLookup;
recordMessageOwnership?: TelegramMessageOwnershipRecorder;
foreignOwnedUpdateForwarder?: TelegramForeignOwnedUpdateForwarder<
TContext,
TReactionUpdate,
TCallbackQuery,
TMessage
>;
removePendingMediaGroupMessages: (messageIds: number[]) => void;
removeQueuedTelegramTurnsByMessageIds: (
messageIds: number[],
ctx: TContext,
) => number;
handleAuthorizedTelegramReactionUpdate: (
reactionUpdate: TReactionUpdate,
ctx: TContext,
) => Promise<void>;
handleTelegramTopicLifecycleUpdate?: (
lifecycle: TelegramTopicLifecycleUpdate<TMessage>,
ctx: TContext,
) => Promise<void> | void;
pairTelegramUserIfNeeded: (userId: number, ctx: TContext) => Promise<boolean>;
answerCallbackQuery: (
callbackQueryId: string,
text?: string,
) => Promise<void>;
answerGuestQuery: (guestQueryId: string, text?: string) => Promise<void>;
handleAuthorizedTelegramCallbackQuery: (
query: TCallbackQuery,
ctx: TContext,
) => Promise<void>;
sendTextReply: (
chatId: number,
replyToMessageId: number,
text: string,
options?: { target?: { chatId: number; threadId?: number } },
) => Promise<number | undefined>;
handleAuthorizedTelegramMessage: (
message: TMessage,
ctx: TContext,
) => Promise<void>;
handleAuthorizedTelegramEditedMessage: (
message: TMessage,
ctx: TContext,
) => unknown;
handleAuthorizedTelegramGuestMessage?: (
guestMessage: TelegramGuestMessage & { from: TelegramUser },
ctx: TContext,
) => Promise<void>;
/** Called when the owner writes in an unbound thread no live instance owns. */
handleUnboundTelegramTopicMessage?: (
message: TMessage & { from: TelegramUser },
ctx: TContext,
) => Promise<void>;
}
export interface TelegramUpdateRuntimeControllerDeps<
TContext = unknown,
TCallbackQuery extends TelegramCallbackQuery = TelegramCallbackQuery,
TMessage extends TelegramUpdateMessage = TelegramUpdateMessage,
> {
getAllowedUserId: () => number | undefined;
getCurrentInstanceId?: () => string | undefined;
getMessageOwnership?: TelegramMessageOwnershipLookup;
getTargetOwnership?: TelegramTargetOwnershipLookup;
recordMessageOwnership?: TelegramMessageOwnershipRecorder;
foreignOwnedUpdateForwarder?: TelegramForeignOwnedUpdateForwarder<
TContext,
TelegramMessageReactionUpdated,
TCallbackQuery,
TMessage
>;
removePendingMediaGroupMessages: (messageIds: number[]) => void;
removeQueuedTelegramTurnsByMessageIds: (
messageIds: number[],
ctx: TContext,
scope?: { chatId?: number; threadId?: number },
) => number;
clearQueuedTelegramTurnPriorityByMessageId: (
messageId: number,
ctx: TContext,
scope?: { chatId?: number; threadId?: number },
) => boolean;
prioritizeQueuedTelegramTurnByMessageId: (
messageId: number,
ctx: TContext,
priorityEmoji?: string,
scope?: { chatId?: number; threadId?: number },
) => boolean;
pairTelegramUserIfNeeded: (userId: number, ctx: TContext) => Promise<boolean>;
answerCallbackQuery: (
callbackQueryId: string,
text?: string,
) => Promise<void>;
answerGuestQuery: (guestQueryId: string, text?: string) => Promise<void>;
handleAuthorizedTelegramCallbackQuery: (
query: TCallbackQuery,
ctx: TContext,
) => Promise<void>;
sendTextReply: (
chatId: number,
replyToMessageId: number,
text: string,
options?: { target?: { chatId: number; threadId?: number } },
) => Promise<number | undefined>;
handleAuthorizedTelegramMessage: (
message: TMessage,
ctx: TContext,
) => Promise<void>;
handleAuthorizedTelegramEditedMessage: (
message: TMessage,
ctx: TContext,
) => unknown;
handleAuthorizedTelegramGuestMessage?: (
guestMessage: TelegramGuestMessage & { from: TelegramUser },
ctx: TContext,
) => Promise<void>;
handleTelegramTopicLifecycleUpdate?: (
lifecycle: TelegramTopicLifecycleUpdate<TMessage>,
ctx: TContext,
) => Promise<void> | void;
/** Called when the owner writes in an unbound thread no live instance owns. */
handleUnboundTelegramTopicMessage?: (
message: TMessage & { from: TelegramUser },
ctx: TContext,
) => Promise<void>;
}
export interface TelegramUpdateRuntimeController<
TContext = unknown,
TUpdate extends TelegramUpdateFlow = TelegramUpdateFlow,
> {
handleAuthorizedReactionUpdate: (
reactionUpdate: NonNullable<TUpdate["message_reaction"]>,
ctx: TContext,
) => Promise<void>;
handleUpdate: (update: TUpdate, ctx: TContext) => Promise<void>;
}
function getTelegramCallbackQueryId(
query: TelegramCallbackQuery,
): string | undefined {
return typeof query.id === "string" ? query.id : undefined;
}
function getTelegramMessageReplyTarget(
message: TelegramUpdateMessage,
): { chatId: number; messageId: number; threadId?: number } | undefined {
if (
typeof message.chat.id !== "number" ||
typeof message.message_id !== "number"
) {
return undefined;
}
return {
chatId: message.chat.id,
messageId: message.message_id,
...(typeof message.message_thread_id === "number"
? { threadId: message.message_thread_id }
: {}),
};
}
function getForeignTelegramMessageOwnership(
target: { chatId: number; messageId: number } | undefined,
deps: {
getCurrentInstanceId?: () => string | undefined;
getMessageOwnership?: TelegramMessageOwnershipLookup;
},
): TelegramMessageOwnershipView | undefined {
if (!target || !deps.getMessageOwnership || !deps.getCurrentInstanceId) {
return undefined;
}
const currentInstanceId = deps.getCurrentInstanceId();
if (!currentInstanceId) return undefined;
const ownership = deps.getMessageOwnership(target.chatId, target.messageId);
return ownership && ownership.instanceId !== currentInstanceId
? ownership
: undefined;
}
function getForeignTelegramCallbackOwnership(
query: TelegramCallbackQuery,
deps: {
getCurrentInstanceId?: () => string | undefined;
getMessageOwnership?: TelegramMessageOwnershipLookup;
getTargetOwnership?: TelegramTargetOwnershipLookup;
},
): TelegramMessageOwnershipView | undefined {
return (
getForeignTelegramMessageOwnership(
getTelegramCallbackMessageTarget(query),
deps,
) ??
getForeignTelegramTargetOwnership(
query.message ? getTelegramMessageTarget(query.message) : undefined,
deps,
)
);
}
function getTelegramCallbackMessageTarget(
query: TelegramCallbackQuery,
): { chatId: number; messageId: number } | undefined {
return query.message
? getTelegramMessageReplyTarget(query.message)
: undefined;
}
function getTelegramReactionMessageTarget(
reactionUpdate: TelegramMessageReactionUpdated,
): { chatId: number; messageId: number } | undefined {
return typeof reactionUpdate.chat.id === "number"
? { chatId: reactionUpdate.chat.id, messageId: reactionUpdate.message_id }
: undefined;
}
function getForeignTelegramTargetOwnership(
target: TelegramTarget | undefined,
deps: {
getCurrentInstanceId?: () => string | undefined;
getTargetOwnership?: TelegramTargetOwnershipLookup;
},
): TelegramTargetOwnershipView | undefined {
if (!target || !deps.getTargetOwnership || !deps.getCurrentInstanceId) {
return undefined;
}
const currentInstanceId = deps.getCurrentInstanceId();
if (!currentInstanceId) return undefined;
const ownership = deps.getTargetOwnership(target);
return ownership && ownership.instanceId !== currentInstanceId
? ownership
: undefined;
}
export async function executeTelegramUpdate<
TUpdate extends TelegramUpdateFlow,
TContext = unknown,
>(
update: TUpdate,
allowedUserId: number | undefined,
deps: TelegramUpdateRuntimeDeps<
TContext,
NonNullable<TUpdate["message_reaction"]>,
NonNullable<TUpdate["callback_query"]>,
NonNullable<TUpdate["message"] | TUpdate["edited_message"]>
>,
): Promise<void> {
await executeTelegramUpdatePlan(
buildTelegramUpdateExecutionPlanFromUpdate(update, allowedUserId),
deps,
);
}
export type TelegramPairedUpdateRuntimeControllerDeps<
TContext = unknown,
TUpdate extends TelegramUpdateFlow = TelegramUpdateFlow,
> = Omit<
TelegramUpdateRuntimeControllerDeps<
TContext,
NonNullable<TUpdate["callback_query"]>,
NonNullable<TUpdate["message"] | TUpdate["edited_message"]>
>,
"pairTelegramUserIfNeeded"
> &
TelegramUserPairingRuntimeDeps<TContext>;
export function createTelegramPairedUpdateRuntime<
TContext = unknown,
TUpdate extends TelegramUpdateFlow = TelegramUpdateFlow,
>(
deps: TelegramPairedUpdateRuntimeControllerDeps<TContext, TUpdate>,
): TelegramUpdateRuntimeController<TContext, TUpdate> {
return createTelegramUpdateRuntime({
getAllowedUserId: deps.getAllowedUserId,
getCurrentInstanceId: deps.getCurrentInstanceId,
getMessageOwnership: deps.getMessageOwnership,
getTargetOwnership: deps.getTargetOwnership,
recordMessageOwnership: deps.recordMessageOwnership,
handleTelegramTopicLifecycleUpdate: deps.handleTelegramTopicLifecycleUpdate,
foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
removePendingMediaGroupMessages: deps.removePendingMediaGroupMessages,
removeQueuedTelegramTurnsByMessageIds:
deps.removeQueuedTelegramTurnsByMessageIds,
clearQueuedTelegramTurnPriorityByMessageId:
deps.clearQueuedTelegramTurnPriorityByMessageId,
prioritizeQueuedTelegramTurnByMessageId:
deps.prioritizeQueuedTelegramTurnByMessageId,
pairTelegramUserIfNeeded: createTelegramUserPairingRuntime({
getAllowedUserId: deps.getAllowedUserId,
setAllowedUserId: deps.setAllowedUserId,
persistConfig: deps.persistConfig,
updateStatus: deps.updateStatus,
onPaired: deps.onPaired,
}).pairIfNeeded,
answerCallbackQuery: deps.answerCallbackQuery,
answerGuestQuery: deps.answerGuestQuery,
handleAuthorizedTelegramCallbackQuery:
deps.handleAuthorizedTelegramCallbackQuery,
sendTextReply: deps.sendTextReply,
handleAuthorizedTelegramMessage: deps.handleAuthorizedTelegramMessage,
handleAuthorizedTelegramEditedMessage:
deps.handleAuthorizedTelegramEditedMessage,
handleAuthorizedTelegramGuestMessage:
deps.handleAuthorizedTelegramGuestMessage,
handleUnboundTelegramTopicMessage: deps.handleUnboundTelegramTopicMessage,
});
}
export function createTelegramUpdateRuntime<
TContext = unknown,
TUpdate extends TelegramUpdateFlow = TelegramUpdateFlow,
>(
deps: TelegramUpdateRuntimeControllerDeps<
TContext,
NonNullable<TUpdate["callback_query"]>,
NonNullable<TUpdate["message"] | TUpdate["edited_message"]>
>,
): TelegramUpdateRuntimeController<TContext, TUpdate> {
const handleAuthorizedReactionUpdate = async (
reactionUpdate: NonNullable<TUpdate["message_reaction"]>,
ctx: TContext,
): Promise<void> => {
await handleAuthorizedTelegramReactionUpdate(reactionUpdate, {
allowedUserId: deps.getAllowedUserId(),
ctx,
removePendingMediaGroupMessages: deps.removePendingMediaGroupMessages,
removeQueuedTelegramTurnsByMessageIds:
deps.removeQueuedTelegramTurnsByMessageIds,
getCurrentInstanceId: deps.getCurrentInstanceId,
getMessageOwnership: deps.getMessageOwnership,
foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
clearQueuedTelegramTurnPriorityByMessageId:
deps.clearQueuedTelegramTurnPriorityByMessageId,
prioritizeQueuedTelegramTurnByMessageId:
deps.prioritizeQueuedTelegramTurnByMessageId,
});
};
return {
handleAuthorizedReactionUpdate,
handleUpdate: (update, ctx) =>
executeTelegramUpdate(update, deps.getAllowedUserId(), {
ctx,
getCurrentInstanceId: deps.getCurrentInstanceId,
getMessageOwnership: deps.getMessageOwnership,
getTargetOwnership: deps.getTargetOwnership,
recordMessageOwnership: deps.recordMessageOwnership,
foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
removePendingMediaGroupMessages: deps.removePendingMediaGroupMessages,
removeQueuedTelegramTurnsByMessageIds:
deps.removeQueuedTelegramTurnsByMessageIds,
handleAuthorizedTelegramReactionUpdate: handleAuthorizedReactionUpdate,
handleTelegramTopicLifecycleUpdate:
deps.handleTelegramTopicLifecycleUpdate,
pairTelegramUserIfNeeded: deps.pairTelegramUserIfNeeded,
answerCallbackQuery: deps.answerCallbackQuery,
answerGuestQuery: deps.answerGuestQuery,
handleAuthorizedTelegramCallbackQuery:
deps.handleAuthorizedTelegramCallbackQuery,
sendTextReply: deps.sendTextReply,
handleAuthorizedTelegramMessage: deps.handleAuthorizedTelegramMessage,
handleAuthorizedTelegramEditedMessage:
deps.handleAuthorizedTelegramEditedMessage,
handleAuthorizedTelegramGuestMessage:
deps.handleAuthorizedTelegramGuestMessage,
handleUnboundTelegramTopicMessage:
deps.handleUnboundTelegramTopicMessage,
}),
};
}
export interface AuthorizedTelegramReactionUpdateDeps<TContext> {
allowedUserId?: number;
ctx: TContext;
getCurrentInstanceId?: () => string | undefined;
getMessageOwnership?: TelegramMessageOwnershipLookup;
foreignOwnedUpdateForwarder?: TelegramForeignOwnedUpdateForwarder<TContext>;
removePendingMediaGroupMessages: (messageIds: number[]) => void;
removeQueuedTelegramTurnsByMessageIds: (
messageIds: number[],
ctx: TContext,
scope?: { chatId?: number; threadId?: number },
) => number;
clearQueuedTelegramTurnPriorityByMessageId: (
messageId: number,
ctx: TContext,
scope?: { chatId?: number; threadId?: number },
) => boolean;
prioritizeQueuedTelegramTurnByMessageId: (
messageId: number,
ctx: TContext,
priorityEmoji?: string,
scope?: { chatId?: number; threadId?: number },
) => boolean;
}
export async function handleAuthorizedTelegramReactionUpdate<TContext>(
reactionUpdate: TelegramMessageReactionUpdated,
deps: AuthorizedTelegramReactionUpdateDeps<TContext>,
): Promise<void> {
const foreignOwnership = getForeignTelegramMessageOwnership(
getTelegramReactionMessageTarget(reactionUpdate),
deps,
);
if (foreignOwnership) {
await deps.foreignOwnedUpdateForwarder?.forwardReaction?.({
reactionUpdate,
ownership: foreignOwnership,
ctx: deps.ctx,
});
return;
}
const reactionUser = reactionUpdate.user;
if (!reactionUser || reactionUser.is_bot) return;
if (
reactionUpdate.chat.type !== "private" &&
reactionUser.id !== deps.allowedUserId
) {
return;
}
const reactionScope =
typeof reactionUpdate.chat.id === "number"
? { chatId: reactionUpdate.chat.id }
: undefined;
const oldEmojis = collectTelegramReactionEmojis(reactionUpdate.old_reaction);
const newEmojis = collectTelegramReactionEmojis(reactionUpdate.new_reaction);
if (
hasAddedTelegramReactionEmoji(
oldEmojis,
newEmojis,
TELEGRAM_REMOVAL_REACTION_EMOJIS,
)
) {
deps.removePendingMediaGroupMessages([reactionUpdate.message_id]);
deps.removeQueuedTelegramTurnsByMessageIds(
[reactionUpdate.message_id],
deps.ctx,
reactionScope,
);
return;
}
const hadPriorityReaction = hasAnyTelegramReactionEmoji(
oldEmojis,
TELEGRAM_PRIORITY_REACTION_EMOJIS,
);
const hasPriorityReaction = hasAnyTelegramReactionEmoji(
newEmojis,
TELEGRAM_PRIORITY_REACTION_EMOJIS,
);
if (hadPriorityReaction && !hasPriorityReaction) {
deps.clearQueuedTelegramTurnPriorityByMessageId(
reactionUpdate.message_id,
deps.ctx,
reactionScope,
);
}
const addedPriorityEmoji = getAddedTelegramReactionEmoji(
oldEmojis,
newEmojis,
TELEGRAM_PRIORITY_REACTION_EMOJIS,
);
if (!addedPriorityEmoji) return;
deps.prioritizeQueuedTelegramTurnByMessageId(
reactionUpdate.message_id,
deps.ctx,
addedPriorityEmoji,
reactionScope,
);
}
function isTelegramStaleContextError(error: unknown): boolean {
return (
error instanceof Error &&
(error.message.includes("stale after session") ||
error.message.includes("stale ctx"))
);
}
export async function executeTelegramUpdatePlan<
TContext = unknown,
TReactionUpdate extends TelegramMessageReactionUpdated =
TelegramMessageReactionUpdated,
TCallbackQuery extends TelegramCallbackQuery = TelegramCallbackQuery,
TMessage extends TelegramUpdateMessage = TelegramUpdateMessage,
>(
plan: TelegramUpdateExecutionPlan<TReactionUpdate, TCallbackQuery, TMessage>,
deps: TelegramUpdateRuntimeDeps<
TContext,
TReactionUpdate,
TCallbackQuery,
TMessage
>,
): Promise<void> {
try {
if (plan.kind === "ignore") return;
if (plan.kind === "deleted") {
deps.removePendingMediaGroupMessages(plan.messageIds);
deps.removeQueuedTelegramTurnsByMessageIds(plan.messageIds, deps.ctx);
return;
}
if (plan.kind === "reaction") {
await deps.handleAuthorizedTelegramReactionUpdate(
plan.reactionUpdate,
deps.ctx,
);
return;
}
if (plan.kind === "topic-lifecycle") {
await deps.handleTelegramTopicLifecycleUpdate?.(plan.lifecycle, deps.ctx);
return;
}
if (plan.kind === "callback") {
const foreignOwnership = getForeignTelegramCallbackOwnership(
plan.query,
deps,
);
if (foreignOwnership) {
const forwarded =
(await deps.foreignOwnedUpdateForwarder?.forwardCallback?.({
query: plan.query,
ownership: foreignOwnership,
ctx: deps.ctx,
})) ?? false;
if (!forwarded) {
const callbackQueryId = getTelegramCallbackQueryId(plan.query);
if (callbackQueryId) {
await deps.answerCallbackQuery(
callbackQueryId,
"This Telegram message belongs to another Pi instance.",
);
}
}
return;
}
if (plan.shouldPair) {
await deps.pairTelegramUserIfNeeded(plan.query.from.id, deps.ctx);
}
if (plan.shouldDeny) {
const callbackQueryId = getTelegramCallbackQueryId(plan.query);
if (callbackQueryId) {
await deps.answerCallbackQuery(
callbackQueryId,
"This bot is not authorized for your account.",
);
}
return;
}
await deps.handleAuthorizedTelegramCallbackQuery(plan.query, deps.ctx);
return;
}
if (plan.kind === "guest") {
if (plan.shouldDeny) {
await deps.answerGuestQuery(
plan.guestMessage.guest_query_id,
"🚫 Access denied.",
);
return;
}
if (deps.handleAuthorizedTelegramGuestMessage) {
await deps.handleAuthorizedTelegramGuestMessage(
plan.guestMessage,
deps.ctx,
);
}
return;
}
const foreignMessageOwnership = getForeignTelegramMessageOwnership(
getTelegramMessageReplyTarget(plan.message),
deps,
);
if (foreignMessageOwnership) {
if (plan.kind === "edited-message") {
await deps.foreignOwnedUpdateForwarder?.forwardEditedMessage?.({
message: plan.message,
ownership: foreignMessageOwnership,
ctx: deps.ctx,
});
} else {
await deps.foreignOwnedUpdateForwarder?.forwardMessage?.({
message: plan.message,
ownership: foreignMessageOwnership,
ctx: deps.ctx,
});
}
return;
}
const messageTarget = getTelegramMessageTarget(plan.message);
const foreignTargetOwnership = getForeignTelegramTargetOwnership(
messageTarget,
deps,
);
if (foreignTargetOwnership) {
if (typeof plan.message.message_id === "number") {
deps.recordMessageOwnership?.({
chatId: messageTarget!.chatId,
messageId: plan.message.message_id,
target: messageTarget,
instanceId: foreignTargetOwnership.instanceId,
});
}
if (plan.kind === "edited-message") {
await deps.foreignOwnedUpdateForwarder?.forwardEditedMessage?.({
message: plan.message,
ownership: foreignTargetOwnership,
ctx: deps.ctx,
});
} else {
await deps.foreignOwnedUpdateForwarder?.forwardMessage?.({
message: plan.message,
ownership: foreignTargetOwnership,
ctx: deps.ctx,
});
}
return;
}
if (
plan.kind === "message" &&
messageTarget?.threadId != null &&
deps.handleUnboundTelegramTopicMessage
) {
await deps.handleUnboundTelegramTopicMessage(plan.message, deps.ctx);
return;
}
const pairedNow = plan.shouldPair
? await deps.pairTelegramUserIfNeeded(plan.message.from.id, deps.ctx)
: false;
const replyTarget = getTelegramMessageReplyTarget(plan.message);
if (
plan.kind === "message" &&
pairedNow &&
plan.shouldNotifyPaired &&
replyTarget
) {
await deps.sendTextReply(
replyTarget.chatId,
replyTarget.messageId,
"Telegram bridge paired with this account.",
{ target: replyTarget },
);
}
if (plan.shouldDeny) {
if (replyTarget) {
await deps.sendTextReply(
replyTarget.chatId,
replyTarget.messageId,
"This bot is not authorized for your account.",
{ target: replyTarget },
);
}
return;
}
if (plan.kind === "edited-message") {
await deps.handleAuthorizedTelegramEditedMessage(plan.message, deps.ctx);
return;
}
await deps.handleAuthorizedTelegramMessage(plan.message, deps.ctx);
} catch (error) {
if (!isTelegramStaleContextError(error)) throw error;
}
}
// --- Public update handler registry ---
/**
* Verdict returned by a public Telegram update handler.
*
* - `"consume"` — the handler processed this update; pi-telegram skips default routing.
* - `"pass"` (or `void`/`undefined`) — pi-telegram routes the update normally.
*/
export type TelegramUpdateHandlerVerdict = "consume" | "pass";
export type TelegramUpdateHandler = (
update: unknown,
) =>
| TelegramUpdateHandlerVerdict
| void
| Promise<TelegramUpdateHandlerVerdict | void>;
export interface TelegramUpdateHandlerRegistry {
/** Schema version of this registry shape. */
readonly version: 1;
/**
* Register an update handler. Returns a disposer that removes it.
*
* Handlers are invoked in registration order on every Telegram update,
* before pi-telegram's own routing. The first handler that returns
* `"consume"` wins and stops the chain for that update.
*/
add: (handler: TelegramUpdateHandler) => () => void;
/**
* Run all registered handlers against an update.
*
* Used by pi-telegram's polling runtime; extension consumers should call
* {@link registerTelegramUpdateHandler} or `add` instead of dispatching directly.
*/
dispatch: (update: unknown) => Promise<TelegramUpdateHandlerVerdict>;
}
const UPDATE_HANDLER_REGISTRY_KEY = "__piTelegramUpdateHandlerRegistry__";
function isValidV1UpdateHandlerRegistry(
candidate: unknown,
): candidate is TelegramUpdateHandlerRegistry {
if (!candidate || typeof candidate !== "object") return false;
const r = candidate as Partial<TelegramUpdateHandlerRegistry>;
return (
r.version === 1 &&
typeof r.add === "function" &&
typeof r.dispatch === "function"
);
}
function getOrCreateUpdateHandlerRegistry(): TelegramUpdateHandlerRegistry {
const g = globalThis as Record<string, unknown>;
const existing = g[UPDATE_HANDLER_REGISTRY_KEY];
if (isValidV1UpdateHandlerRegistry(existing)) return existing;
const handlers = new Set<TelegramUpdateHandler>();
const registry: TelegramUpdateHandlerRegistry = {
version: 1,
add(handler) {
handlers.add(handler);
return () => handlers.delete(handler);
},
async dispatch(update) {
for (const handler of handlers) {
try {
const result = await handler(update);
if (result === "consume") return "consume";
} catch {
// Update handler errors must not break polling.
}
}
return "pass";
},
};
g[UPDATE_HANDLER_REGISTRY_KEY] = registry;
return registry;
}
/**
* Called by pi-telegram's own runtime to obtain the registry it dispatches
* through. Extension consumers should not call this; use
* {@link registerTelegramUpdateHandler} instead.
*/
export function getTelegramUpdateHandlerRegistry(): TelegramUpdateHandlerRegistry {
return getOrCreateUpdateHandlerRegistry();
}
export interface TelegramUpdateHandlerWrapDeps<TUpdate, TContext> {
defaultHandle: (update: TUpdate, ctx: TContext) => Promise<void>;
registry?: TelegramUpdateHandlerRegistry;
}
/**
* Wrap a default polling `handleUpdate` with the public update handler registry.
*/
export function createTelegramUpdateHandle<TUpdate, TContext>(
deps: TelegramUpdateHandlerWrapDeps<TUpdate, TContext>,
): (update: TUpdate, ctx: TContext) => Promise<void> {
const registry = deps.registry ?? getOrCreateUpdateHandlerRegistry();
const { defaultHandle } = deps;
return async (update, ctx) => {
const verdict = await registry.dispatch(update);
if (verdict === "consume") return;
await defaultHandle(update, ctx);
};
}
/**
* Register a handler that runs before pi-telegram routes a Telegram update
* through its built-in handlers.
*
* This is the low-level public surface for extensions that share the same bot
* and Pi process with pi-telegram.
*/
export function registerTelegramUpdateHandler(
handler: TelegramUpdateHandler,
): () => void {
return getOrCreateUpdateHandlerRegistry().add(handler);
}