- 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
2381 lines
74 KiB
TypeScript
2381 lines
74 KiB
TypeScript
/**
|
|
* Telegram queue core contracts and pure planning helpers
|
|
* Zones: telegram queue, pi agent lifecycle, scheduling
|
|
* Owns queue item contracts, lane admission, pure queue mutations, and dispatch planning
|
|
*/
|
|
|
|
import { isVoiceTurn } from "./voice.ts";
|
|
|
|
// --- Queue Items ---
|
|
|
|
export interface QueuedAttachment {
|
|
path: string;
|
|
fileName: string;
|
|
}
|
|
|
|
export interface TelegramPromptTextContent {
|
|
type: "text";
|
|
text: string;
|
|
}
|
|
|
|
export interface TelegramPromptImageContent {
|
|
type: "image";
|
|
data: string;
|
|
mimeType: string;
|
|
}
|
|
|
|
export type TelegramPromptContent =
|
|
TelegramPromptTextContent | TelegramPromptImageContent;
|
|
|
|
export type TelegramQueueItemKind = "prompt" | "control";
|
|
export type TelegramQueueLane = "control" | "priority" | "default";
|
|
export type TelegramQueueAdmissionMode =
|
|
"control-queue" | "priority-queue" | "default-queue";
|
|
|
|
export interface TelegramQueueLaneContract {
|
|
lane: TelegramQueueLane;
|
|
admissionMode: TelegramQueueAdmissionMode;
|
|
dispatchRank: number;
|
|
allowedKinds: readonly TelegramQueueItemKind[];
|
|
}
|
|
|
|
export const TELEGRAM_QUEUE_LANE_CONTRACTS: readonly TelegramQueueLaneContract[] =
|
|
[
|
|
// Control lane intentionally accepts both direct controls and resume prompts.
|
|
// Model-switch continuations need prompt semantics but must run before queued user work.
|
|
// Do not admit ordinary user prompts here without an explicit control-flow reason.
|
|
{
|
|
lane: "control",
|
|
admissionMode: "control-queue",
|
|
dispatchRank: 0,
|
|
allowedKinds: ["control", "prompt"],
|
|
},
|
|
{
|
|
lane: "priority",
|
|
admissionMode: "priority-queue",
|
|
dispatchRank: 1,
|
|
allowedKinds: ["prompt"],
|
|
},
|
|
{
|
|
lane: "default",
|
|
admissionMode: "default-queue",
|
|
dispatchRank: 2,
|
|
allowedKinds: ["prompt"],
|
|
},
|
|
] as const;
|
|
|
|
export interface TelegramQueueTarget {
|
|
chatId: number;
|
|
threadId?: number;
|
|
}
|
|
|
|
export interface TelegramTransportStamp {
|
|
profile: string;
|
|
generation: string;
|
|
}
|
|
|
|
export interface TelegramTransportStampRuntime {
|
|
getStamp(): TelegramTransportStamp;
|
|
isActive(stamp: TelegramTransportStamp | undefined): boolean;
|
|
}
|
|
|
|
export interface TelegramQueueItemBase {
|
|
kind: TelegramQueueItemKind;
|
|
chatId: number;
|
|
target?: TelegramQueueTarget;
|
|
transportStamp?: TelegramTransportStamp;
|
|
replyToMessageId: number;
|
|
guestQueryId?: string;
|
|
queueOrder: number;
|
|
queueLane: TelegramQueueLane;
|
|
laneOrder: number;
|
|
statusSummary: string;
|
|
}
|
|
|
|
export interface PendingTelegramTurn extends TelegramQueueItemBase {
|
|
kind: "prompt";
|
|
sourceMessageIds: number[];
|
|
queuedAttachments: QueuedAttachment[];
|
|
content: TelegramPromptContent[];
|
|
historyText: string;
|
|
priorityEmoji?: string;
|
|
|
|
/** Turn should preferably be delivered as voice (mirror mode + user sent voice) */
|
|
voiceReplyPreferred?: boolean;
|
|
/** Turn must be delivered as voice (voice mode) */
|
|
voiceReplyRequired?: boolean;
|
|
}
|
|
|
|
export interface PendingTelegramControlItem<
|
|
TContext = unknown,
|
|
> extends TelegramQueueItemBase {
|
|
kind: "control";
|
|
controlType: "status" | "model";
|
|
execute: (ctx: TContext) => Promise<void>;
|
|
}
|
|
|
|
export type TelegramQueueItem<TContext = unknown> =
|
|
PendingTelegramTurn | PendingTelegramControlItem<TContext>;
|
|
|
|
export interface TelegramQueueStore<TContext = unknown> {
|
|
getQueuedItems: () => TelegramQueueItem<TContext>[];
|
|
setQueuedItems: (items: TelegramQueueItem<TContext>[]) => void;
|
|
}
|
|
|
|
export interface TelegramQueueStateStore<
|
|
TContext = unknown,
|
|
> extends TelegramQueueStore<TContext> {
|
|
hasQueuedItems: () => boolean;
|
|
}
|
|
|
|
export interface TelegramActiveTurnStore<
|
|
TTurn extends PendingTelegramTurn = PendingTelegramTurn,
|
|
> {
|
|
get: () => TTurn | undefined;
|
|
has: () => boolean;
|
|
set: (turn: TTurn) => void;
|
|
clear: () => void;
|
|
getChatId: () => number | undefined;
|
|
getTarget: () => TelegramQueueTarget | undefined;
|
|
getReplyToMessageId: () => number | undefined;
|
|
getGuestQueryId: () => string | undefined;
|
|
getSourceMessageIds: () => number[] | undefined;
|
|
}
|
|
|
|
export interface TelegramDispatchGuardState {
|
|
compactionInProgress: boolean;
|
|
hasActiveTelegramTurn: boolean;
|
|
hasPendingTelegramDispatch: boolean;
|
|
isIdle: boolean;
|
|
hasPendingMessages: boolean;
|
|
}
|
|
|
|
export function getTelegramQueueLaneContract(
|
|
lane: TelegramQueueLane,
|
|
): TelegramQueueLaneContract {
|
|
const contract = TELEGRAM_QUEUE_LANE_CONTRACTS.find(
|
|
(entry) => entry.lane === lane,
|
|
);
|
|
if (!contract) throw new Error(`Unknown Telegram queue lane: ${lane}`);
|
|
return contract;
|
|
}
|
|
|
|
export function getTelegramQueueItemAdmissionMode(
|
|
item: Pick<TelegramQueueItem, "queueLane">,
|
|
): TelegramQueueAdmissionMode {
|
|
return getTelegramQueueLaneContract(item.queueLane).admissionMode;
|
|
}
|
|
|
|
export function isTelegramQueueItemAdmissionValid(
|
|
item: Pick<TelegramQueueItem, "kind" | "queueLane">,
|
|
): boolean {
|
|
return getTelegramQueueLaneContract(item.queueLane).allowedKinds.includes(
|
|
item.kind,
|
|
);
|
|
}
|
|
|
|
export function assertTelegramQueueItemAdmissionValid(
|
|
item: Pick<TelegramQueueItem, "kind" | "queueLane">,
|
|
): void {
|
|
if (isTelegramQueueItemAdmissionValid(item)) return;
|
|
throw new Error(
|
|
`Invalid Telegram queue admission: ${item.kind} item cannot use ${item.queueLane} lane`,
|
|
);
|
|
}
|
|
|
|
function getTelegramQueueLaneRank(lane: TelegramQueueLane): number {
|
|
return getTelegramQueueLaneContract(lane).dispatchRank;
|
|
}
|
|
|
|
export function isPendingTelegramTurn<TContext = unknown>(
|
|
item: TelegramQueueItem<TContext>,
|
|
): item is PendingTelegramTurn {
|
|
return item.kind === "prompt";
|
|
}
|
|
|
|
export function createTelegramQueueStore<TContext = unknown>(
|
|
initialItems: TelegramQueueItem<TContext>[] = [],
|
|
): TelegramQueueStateStore<TContext> {
|
|
let queuedItems = initialItems;
|
|
return {
|
|
getQueuedItems: () => queuedItems,
|
|
setQueuedItems: (items) => {
|
|
queuedItems = items;
|
|
},
|
|
hasQueuedItems: () => queuedItems.length > 0,
|
|
};
|
|
}
|
|
|
|
export function createTelegramTransportStampRuntime(deps: {
|
|
getProfileName(): string | undefined;
|
|
getBotToken(): string | undefined;
|
|
}): TelegramTransportStampRuntime {
|
|
let profile: string | undefined;
|
|
let botToken: string | undefined;
|
|
let generation = 0;
|
|
const getStamp = function (): TelegramTransportStamp {
|
|
const nextProfile = deps.getProfileName() ?? "default";
|
|
const nextBotToken = deps.getBotToken();
|
|
if (nextProfile !== profile || nextBotToken !== botToken) {
|
|
profile = nextProfile;
|
|
botToken = nextBotToken;
|
|
generation += 1;
|
|
}
|
|
return { profile: nextProfile, generation: String(generation) };
|
|
};
|
|
return {
|
|
getStamp,
|
|
isActive(stamp) {
|
|
if (!stamp) return false;
|
|
const current = getStamp();
|
|
return (
|
|
stamp.profile === current.profile &&
|
|
stamp.generation === current.generation
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createTelegramTransportStampedQueueStore<TContext>(
|
|
store: TelegramQueueStateStore<TContext>,
|
|
getTransportStamp: () => TelegramTransportStamp,
|
|
): TelegramQueueStateStore<TContext> {
|
|
return {
|
|
getQueuedItems: store.getQueuedItems,
|
|
hasQueuedItems: store.hasQueuedItems,
|
|
setQueuedItems(items) {
|
|
const stamp = getTransportStamp();
|
|
store.setQueuedItems(
|
|
items.map((item) =>
|
|
item.transportStamp ? item : { ...item, transportStamp: stamp },
|
|
),
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createTelegramQueueItemCountGetter<TContext = unknown>(
|
|
store: Pick<TelegramQueueStore<TContext>, "getQueuedItems">,
|
|
): () => number {
|
|
return () => {
|
|
return store.getQueuedItems().length;
|
|
};
|
|
}
|
|
|
|
export function createTelegramActiveTurnStore<
|
|
TTurn extends PendingTelegramTurn = PendingTelegramTurn,
|
|
>(): TelegramActiveTurnStore<TTurn> {
|
|
let activeTurn: TTurn | undefined;
|
|
return {
|
|
get: () => activeTurn,
|
|
has: () => !!activeTurn,
|
|
set: (turn) => {
|
|
activeTurn = { ...turn };
|
|
},
|
|
clear: () => {
|
|
activeTurn = undefined;
|
|
},
|
|
getChatId: () => activeTurn?.chatId,
|
|
getTarget: () =>
|
|
activeTurn?.target ? { ...activeTurn.target } : undefined,
|
|
getReplyToMessageId: () => activeTurn?.replyToMessageId,
|
|
getGuestQueryId: () => activeTurn?.guestQueryId,
|
|
getSourceMessageIds: () => activeTurn?.sourceMessageIds,
|
|
};
|
|
}
|
|
|
|
// --- Queue Mutations ---
|
|
|
|
export function partitionTelegramQueueItemsForHistory<TContext = unknown>(
|
|
items: TelegramQueueItem<TContext>[],
|
|
): {
|
|
historyTurns: PendingTelegramTurn[];
|
|
remainingItems: TelegramQueueItem<TContext>[];
|
|
} {
|
|
const historyTurns: PendingTelegramTurn[] = [];
|
|
const remainingItems: TelegramQueueItem<TContext>[] = [];
|
|
for (const item of items) {
|
|
if (isPendingTelegramTurn(item)) {
|
|
historyTurns.push(item);
|
|
continue;
|
|
}
|
|
remainingItems.push(item);
|
|
}
|
|
return { historyTurns, remainingItems };
|
|
}
|
|
|
|
export function planTelegramPromptEnqueue<TContext = unknown>(
|
|
items: TelegramQueueItem<TContext>[],
|
|
foldQueuedPromptsIntoHistory: boolean,
|
|
): {
|
|
historyTurns: PendingTelegramTurn[];
|
|
remainingItems: TelegramQueueItem<TContext>[];
|
|
} {
|
|
if (!foldQueuedPromptsIntoHistory) {
|
|
return { historyTurns: [], remainingItems: items };
|
|
}
|
|
return partitionTelegramQueueItemsForHistory(items);
|
|
}
|
|
|
|
export function appendTelegramQueueItem<
|
|
TContext = unknown,
|
|
TItem extends TelegramQueueItem<TContext> = TelegramQueueItem<TContext>,
|
|
>(
|
|
items: TelegramQueueItem<TContext>[],
|
|
item: TItem,
|
|
): TelegramQueueItem<TContext>[] {
|
|
assertTelegramQueueItemAdmissionValid(item);
|
|
return [...items, item];
|
|
}
|
|
|
|
function getTelegramPromptTextSignature(item: PendingTelegramTurn): string {
|
|
return item.content
|
|
.filter(
|
|
(entry): entry is TelegramPromptTextContent => entry.type === "text",
|
|
)
|
|
.map((entry) => entry.text)
|
|
.join("\n");
|
|
}
|
|
|
|
function isDuplicateTelegramPromptTurn(
|
|
left: PendingTelegramTurn,
|
|
right: PendingTelegramTurn,
|
|
): boolean {
|
|
return (
|
|
left.chatId === right.chatId &&
|
|
left.target?.threadId === right.target?.threadId &&
|
|
left.replyToMessageId === right.replyToMessageId &&
|
|
getTelegramPromptTextSignature(left) ===
|
|
getTelegramPromptTextSignature(right)
|
|
);
|
|
}
|
|
|
|
export function appendTelegramPromptTurnOnce<TContext = unknown>(
|
|
items: TelegramQueueItem<TContext>[],
|
|
turn: PendingTelegramTurn,
|
|
): { items: TelegramQueueItem<TContext>[]; appended: boolean } {
|
|
assertTelegramQueueItemAdmissionValid(turn);
|
|
const duplicate = items.some(
|
|
(item) =>
|
|
isPendingTelegramTurn(item) && isDuplicateTelegramPromptTurn(item, turn),
|
|
);
|
|
if (duplicate) return { items, appended: false };
|
|
return { items: [...items, turn], appended: true };
|
|
}
|
|
|
|
export function compareTelegramQueueItems<TContext = unknown>(
|
|
left: TelegramQueueItem<TContext>,
|
|
right: TelegramQueueItem<TContext>,
|
|
): number {
|
|
assertTelegramQueueItemAdmissionValid(left);
|
|
assertTelegramQueueItemAdmissionValid(right);
|
|
const laneRankDelta =
|
|
getTelegramQueueLaneRank(left.queueLane) -
|
|
getTelegramQueueLaneRank(right.queueLane);
|
|
if (laneRankDelta !== 0) return laneRankDelta;
|
|
if (left.laneOrder !== right.laneOrder) {
|
|
return left.laneOrder - right.laneOrder;
|
|
}
|
|
return left.queueOrder - right.queueOrder;
|
|
}
|
|
|
|
export interface TelegramQueueMessageScope {
|
|
chatId?: number;
|
|
threadId?: number;
|
|
}
|
|
|
|
function isTelegramQueueItemInMessageScope<TContext = unknown>(
|
|
item: TelegramQueueItem<TContext>,
|
|
scope: TelegramQueueMessageScope | undefined,
|
|
): boolean {
|
|
if (!scope) return true;
|
|
if (typeof scope.chatId === "number" && item.chatId !== scope.chatId) {
|
|
return false;
|
|
}
|
|
if (typeof scope.threadId === "number") {
|
|
return item.target?.threadId === scope.threadId;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function removeTelegramQueueItemsByMessageIds<TContext = unknown>(
|
|
items: TelegramQueueItem<TContext>[],
|
|
messageIds: number[],
|
|
scope?: TelegramQueueMessageScope,
|
|
): { items: TelegramQueueItem<TContext>[]; removedCount: number } {
|
|
if (messageIds.length === 0 || items.length === 0) {
|
|
return { items, removedCount: 0 };
|
|
}
|
|
const deletedMessageIds = new Set(messageIds);
|
|
const nextItems = items.filter((item) => {
|
|
if (
|
|
!isPendingTelegramTurn(item) ||
|
|
!isTelegramQueueItemInMessageScope(item, scope)
|
|
)
|
|
return true;
|
|
return !item.sourceMessageIds.some((messageId) =>
|
|
deletedMessageIds.has(messageId),
|
|
);
|
|
});
|
|
return {
|
|
items: nextItems,
|
|
removedCount: items.length - nextItems.length,
|
|
};
|
|
}
|
|
|
|
export function clearTelegramQueuePromptPriority<TContext = unknown>(
|
|
items: TelegramQueueItem<TContext>[],
|
|
messageId: number,
|
|
scope?: TelegramQueueMessageScope,
|
|
): { items: TelegramQueueItem<TContext>[]; changed: boolean } {
|
|
let changed = false;
|
|
const nextItems = items.map((item) => {
|
|
if (
|
|
!isPendingTelegramTurn(item) ||
|
|
!isTelegramQueueItemInMessageScope(item, scope) ||
|
|
!item.sourceMessageIds.includes(messageId) ||
|
|
item.queueLane !== "priority"
|
|
) {
|
|
return item;
|
|
}
|
|
changed = true;
|
|
return {
|
|
...item,
|
|
queueLane: "default" as const,
|
|
laneOrder: item.queueOrder,
|
|
priorityEmoji: undefined,
|
|
};
|
|
});
|
|
return { items: nextItems, changed };
|
|
}
|
|
|
|
export function prioritizeTelegramQueuePrompt<TContext = unknown>(
|
|
items: TelegramQueueItem<TContext>[],
|
|
messageId: number,
|
|
laneOrder: number,
|
|
priorityEmoji = "⚡",
|
|
scope?: TelegramQueueMessageScope,
|
|
): { items: TelegramQueueItem<TContext>[]; changed: boolean } {
|
|
let changed = false;
|
|
const nextItems = items.map((item) => {
|
|
if (
|
|
!isPendingTelegramTurn(item) ||
|
|
!isTelegramQueueItemInMessageScope(item, scope) ||
|
|
!item.sourceMessageIds.includes(messageId)
|
|
) {
|
|
return item;
|
|
}
|
|
changed = true;
|
|
return {
|
|
...item,
|
|
queueLane: "priority" as const,
|
|
laneOrder,
|
|
priorityEmoji,
|
|
};
|
|
});
|
|
return { items: nextItems, changed };
|
|
}
|
|
|
|
export function consumeDispatchedTelegramPrompt<TContext = unknown>(
|
|
items: TelegramQueueItem<TContext>[],
|
|
hasPendingDispatch: boolean,
|
|
): {
|
|
activeTurn?: PendingTelegramTurn;
|
|
remainingItems: TelegramQueueItem<TContext>[];
|
|
} {
|
|
if (!hasPendingDispatch) {
|
|
return { activeTurn: undefined, remainingItems: items };
|
|
}
|
|
const nextItem = items[0];
|
|
if (!nextItem || !isPendingTelegramTurn(nextItem)) {
|
|
return { activeTurn: undefined, remainingItems: items };
|
|
}
|
|
return { activeTurn: nextItem, remainingItems: items.slice(1) };
|
|
}
|
|
|
|
export function formatQueuedTelegramItemsStatus<TContext = unknown>(
|
|
items: TelegramQueueItem<TContext>[],
|
|
): string {
|
|
return items.length === 0 ? "" : ` +${items.length}`;
|
|
}
|
|
|
|
/**
|
|
* Summaries of the waiting items in dispatch order, for the status preview.
|
|
* Display must never break the status update, so a malformed item only
|
|
* costs the ordering.
|
|
*/
|
|
export function formatQueuedTelegramItemsPreview<TContext = unknown>(
|
|
items: TelegramQueueItem<TContext>[],
|
|
): string[] {
|
|
let ordered = items;
|
|
try {
|
|
ordered = [...items].sort(compareTelegramQueueItems);
|
|
} catch {
|
|
// Keep the store order.
|
|
}
|
|
return ordered
|
|
.map((item) => item.statusSummary.trim())
|
|
.filter((summary) => summary.length > 0);
|
|
}
|
|
|
|
export function truncateTelegramQueueSummary(
|
|
text: string,
|
|
maxWords = 5,
|
|
maxLength = 40,
|
|
): string {
|
|
const normalized = text.replace(/\s+/g, " ").trim();
|
|
if (!normalized) return "";
|
|
const words = normalized.split(" ");
|
|
let summary = words.slice(0, maxWords).join(" ");
|
|
if (summary.length === 0) summary = normalized;
|
|
if (summary.length > maxLength) {
|
|
summary = summary.slice(0, maxLength).trimEnd();
|
|
}
|
|
return summary.length < normalized.length || words.length > maxWords
|
|
? `${summary}…`
|
|
: summary;
|
|
}
|
|
|
|
export function canDispatchTelegramTurnState(
|
|
state: TelegramDispatchGuardState,
|
|
): boolean {
|
|
return (
|
|
!state.compactionInProgress &&
|
|
!state.hasActiveTelegramTurn &&
|
|
!state.hasPendingTelegramDispatch &&
|
|
state.isIdle &&
|
|
!state.hasPendingMessages
|
|
);
|
|
}
|
|
|
|
export interface TelegramDispatchReadinessDeps<TContext> {
|
|
isCompactionInProgress: () => boolean;
|
|
hasActiveTurn: () => boolean;
|
|
hasDispatchPending: () => boolean;
|
|
isIdle: (ctx: TContext) => boolean;
|
|
hasPendingMessages: (ctx: TContext) => boolean;
|
|
}
|
|
|
|
export function createTelegramDispatchReadinessChecker<TContext>(
|
|
deps: TelegramDispatchReadinessDeps<TContext>,
|
|
): (ctx: TContext) => boolean {
|
|
return (ctx) =>
|
|
canDispatchTelegramTurnState({
|
|
compactionInProgress: deps.isCompactionInProgress(),
|
|
hasActiveTelegramTurn: deps.hasActiveTurn(),
|
|
hasPendingTelegramDispatch: deps.hasDispatchPending(),
|
|
isIdle: deps.isIdle(ctx),
|
|
hasPendingMessages: deps.hasPendingMessages(ctx),
|
|
});
|
|
}
|
|
|
|
export function buildPendingTelegramControlItem<TContext = unknown>(options: {
|
|
chatId: number;
|
|
target?: TelegramQueueTarget;
|
|
replyToMessageId: number;
|
|
controlType: PendingTelegramControlItem<TContext>["controlType"];
|
|
queueOrder: number;
|
|
laneOrder: number;
|
|
statusSummary: string;
|
|
execute: PendingTelegramControlItem<TContext>["execute"];
|
|
}): PendingTelegramControlItem<TContext> {
|
|
return {
|
|
kind: "control",
|
|
controlType: options.controlType,
|
|
chatId: options.chatId,
|
|
...(options.target ? { target: options.target } : {}),
|
|
replyToMessageId: options.replyToMessageId,
|
|
queueOrder: options.queueOrder,
|
|
queueLane: "control",
|
|
laneOrder: options.laneOrder,
|
|
statusSummary: options.statusSummary,
|
|
execute: options.execute,
|
|
};
|
|
}
|
|
|
|
export interface TelegramControlItemBuilderDeps {
|
|
allocateItemOrder: () => number;
|
|
allocateControlOrder: () => number;
|
|
}
|
|
|
|
export function createTelegramControlItemBuilder<TContext = unknown>(
|
|
deps: TelegramControlItemBuilderDeps,
|
|
): (options: {
|
|
chatId: number;
|
|
target?: TelegramQueueTarget;
|
|
replyToMessageId: number;
|
|
controlType: PendingTelegramControlItem<TContext>["controlType"];
|
|
statusSummary: string;
|
|
execute: PendingTelegramControlItem<TContext>["execute"];
|
|
}) => PendingTelegramControlItem<TContext> {
|
|
return (options) =>
|
|
buildPendingTelegramControlItem<TContext>({
|
|
...options,
|
|
queueOrder: deps.allocateItemOrder(),
|
|
laneOrder: deps.allocateControlOrder(),
|
|
});
|
|
}
|
|
|
|
// --- Dispatch Planning ---
|
|
|
|
export type TelegramQueueDispatchAction<TContext = unknown> =
|
|
| { kind: "none"; remainingItems: TelegramQueueItem<TContext>[] }
|
|
| {
|
|
kind: "control";
|
|
item: PendingTelegramControlItem<TContext>;
|
|
remainingItems: TelegramQueueItem<TContext>[];
|
|
}
|
|
| {
|
|
kind: "prompt";
|
|
item: PendingTelegramTurn;
|
|
remainingItems: TelegramQueueItem<TContext>[];
|
|
};
|
|
|
|
export function planNextTelegramQueueAction<TContext = unknown>(
|
|
items: TelegramQueueItem<TContext>[],
|
|
canDispatch: boolean,
|
|
): TelegramQueueDispatchAction<TContext> {
|
|
if (!canDispatch || items.length === 0) {
|
|
return { kind: "none", remainingItems: items };
|
|
}
|
|
const [firstItem, ...remainingItems] = items;
|
|
if (!firstItem) {
|
|
return { kind: "none", remainingItems: items };
|
|
}
|
|
assertTelegramQueueItemAdmissionValid(firstItem);
|
|
if (isPendingTelegramTurn(firstItem)) {
|
|
return { kind: "prompt", item: firstItem, remainingItems: items };
|
|
}
|
|
return { kind: "control", item: firstItem, remainingItems };
|
|
}
|
|
|
|
export function shouldDispatchAfterTelegramAgentEnd(options: {
|
|
hasTurn: boolean;
|
|
stopReason?: string;
|
|
foldQueuedPromptsIntoHistory: boolean;
|
|
}): boolean {
|
|
if (!options.hasTurn) return true;
|
|
if (options.stopReason === "aborted") {
|
|
return !options.foldQueuedPromptsIntoHistory;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// --- Agent Runtime ---
|
|
|
|
export interface TelegramAgentStartPlan<TContext = unknown> {
|
|
activeTurn?: PendingTelegramTurn;
|
|
remainingItems: TelegramQueueItem<TContext>[];
|
|
shouldResetPendingModelSwitch: boolean;
|
|
shouldResetToolExecutions: boolean;
|
|
shouldClearDispatchPending: boolean;
|
|
shouldClearAbortHistory: boolean;
|
|
}
|
|
|
|
export interface TelegramAgentStartRuntimeDeps<
|
|
TTurn extends PendingTelegramTurn,
|
|
TContext = unknown,
|
|
> {
|
|
queuedItems: TelegramQueueItem<TContext>[];
|
|
hasPendingDispatch: boolean;
|
|
hasActiveTurn: boolean;
|
|
resetToolExecutions: () => void;
|
|
resetPendingModelSwitch: () => void;
|
|
setQueuedItems: (items: TelegramQueueItem<TContext>[]) => void;
|
|
clearDispatchPending: () => void;
|
|
setFoldQueuedPromptsIntoHistory: (fold: boolean) => void;
|
|
setActiveTurn: (turn: TTurn) => void;
|
|
createPreviewState: () => void;
|
|
startTypingLoop: () => void;
|
|
updateStatus: () => void;
|
|
}
|
|
|
|
export interface TelegramAgentStartHookRuntimeDeps<
|
|
TTurn extends PendingTelegramTurn,
|
|
TContext = unknown,
|
|
> {
|
|
setAbortHandler: (ctx: TContext) => void;
|
|
getQueuedItems: () => TelegramQueueItem<TContext>[];
|
|
hasPendingDispatch: () => boolean;
|
|
hasActiveTurn: () => boolean;
|
|
resetToolExecutions: () => void;
|
|
resetPendingModelSwitch: () => void;
|
|
setQueuedItems: (items: TelegramQueueItem<TContext>[]) => void;
|
|
clearDispatchPending: () => void;
|
|
setFoldQueuedPromptsIntoHistory: (fold: boolean) => void;
|
|
setActiveTurn: (turn: TTurn) => void;
|
|
createPreviewState: () => void;
|
|
startTypingLoop: (ctx: TContext) => void;
|
|
updateStatus: (ctx: TContext) => void;
|
|
}
|
|
|
|
export type TelegramAgentStartHookEvent = unknown;
|
|
|
|
export interface TelegramToolExecutionRuntimeDeps {
|
|
hasActiveTurn: () => boolean;
|
|
getActiveToolExecutions: () => number;
|
|
setActiveToolExecutions: (count: number) => void;
|
|
}
|
|
|
|
export interface TelegramToolExecutionEndRuntimeDeps extends TelegramToolExecutionRuntimeDeps {
|
|
triggerPendingModelSwitchAbort: () => void;
|
|
}
|
|
|
|
export interface TelegramToolExecutionHookRuntimeDeps<
|
|
TContext,
|
|
> extends TelegramToolExecutionRuntimeDeps {
|
|
triggerPendingModelSwitchAbort: (ctx: TContext) => unknown;
|
|
}
|
|
|
|
export type TelegramToolExecutionHookEvent = unknown;
|
|
|
|
export function buildTelegramAgentStartPlan<TContext = unknown>(options: {
|
|
queuedItems: TelegramQueueItem<TContext>[];
|
|
hasPendingDispatch: boolean;
|
|
hasActiveTurn: boolean;
|
|
}): TelegramAgentStartPlan<TContext> {
|
|
if (options.hasActiveTurn || !options.hasPendingDispatch) {
|
|
return {
|
|
activeTurn: undefined,
|
|
remainingItems: options.queuedItems,
|
|
shouldResetPendingModelSwitch: true,
|
|
shouldResetToolExecutions: true,
|
|
shouldClearDispatchPending: options.hasPendingDispatch,
|
|
shouldClearAbortHistory:
|
|
!options.hasActiveTurn && !options.hasPendingDispatch,
|
|
};
|
|
}
|
|
const nextDispatch = consumeDispatchedTelegramPrompt(
|
|
options.queuedItems,
|
|
options.hasPendingDispatch,
|
|
);
|
|
return {
|
|
activeTurn: nextDispatch.activeTurn,
|
|
remainingItems: nextDispatch.remainingItems,
|
|
shouldResetPendingModelSwitch: true,
|
|
shouldResetToolExecutions: true,
|
|
shouldClearDispatchPending: options.hasPendingDispatch,
|
|
shouldClearAbortHistory: false,
|
|
};
|
|
}
|
|
|
|
export function handleTelegramAgentStartRuntime<
|
|
TTurn extends PendingTelegramTurn,
|
|
TContext = unknown,
|
|
>(deps: TelegramAgentStartRuntimeDeps<TTurn, TContext>): void {
|
|
const startPlan = buildTelegramAgentStartPlan({
|
|
queuedItems: deps.queuedItems,
|
|
hasPendingDispatch: deps.hasPendingDispatch,
|
|
hasActiveTurn: deps.hasActiveTurn,
|
|
});
|
|
if (startPlan.shouldResetToolExecutions) deps.resetToolExecutions();
|
|
if (startPlan.shouldResetPendingModelSwitch) deps.resetPendingModelSwitch();
|
|
if (startPlan.shouldClearAbortHistory) {
|
|
deps.setFoldQueuedPromptsIntoHistory(false);
|
|
}
|
|
deps.setQueuedItems(startPlan.remainingItems);
|
|
if (startPlan.shouldClearDispatchPending) deps.clearDispatchPending();
|
|
if (startPlan.activeTurn) {
|
|
deps.setActiveTurn(startPlan.activeTurn as TTurn);
|
|
deps.createPreviewState();
|
|
deps.startTypingLoop();
|
|
}
|
|
deps.updateStatus();
|
|
}
|
|
|
|
export function createTelegramAgentStartHook<
|
|
TTurn extends PendingTelegramTurn,
|
|
TContext = unknown,
|
|
>(deps: TelegramAgentStartHookRuntimeDeps<TTurn, TContext>) {
|
|
return async (
|
|
_event: TelegramAgentStartHookEvent,
|
|
ctx: TContext,
|
|
): Promise<void> => {
|
|
deps.setAbortHandler(ctx);
|
|
handleTelegramAgentStartRuntime<TTurn, TContext>({
|
|
queuedItems: deps.getQueuedItems(),
|
|
hasPendingDispatch: deps.hasPendingDispatch(),
|
|
hasActiveTurn: deps.hasActiveTurn(),
|
|
resetToolExecutions: deps.resetToolExecutions,
|
|
resetPendingModelSwitch: deps.resetPendingModelSwitch,
|
|
setQueuedItems: deps.setQueuedItems,
|
|
clearDispatchPending: deps.clearDispatchPending,
|
|
setFoldQueuedPromptsIntoHistory: deps.setFoldQueuedPromptsIntoHistory,
|
|
setActiveTurn: deps.setActiveTurn,
|
|
createPreviewState: deps.createPreviewState,
|
|
startTypingLoop: () => deps.startTypingLoop(ctx),
|
|
updateStatus: () => deps.updateStatus(ctx),
|
|
});
|
|
};
|
|
}
|
|
|
|
export function getNextTelegramToolExecutionCount(options: {
|
|
hasActiveTurn: boolean;
|
|
currentCount: number;
|
|
event: "start" | "end";
|
|
}): number {
|
|
if (!options.hasActiveTurn) return options.currentCount;
|
|
if (options.event === "start") {
|
|
return options.currentCount + 1;
|
|
}
|
|
return Math.max(0, options.currentCount - 1);
|
|
}
|
|
|
|
export function handleTelegramToolExecutionStartRuntime(
|
|
deps: TelegramToolExecutionRuntimeDeps,
|
|
): void {
|
|
deps.setActiveToolExecutions(
|
|
getNextTelegramToolExecutionCount({
|
|
hasActiveTurn: deps.hasActiveTurn(),
|
|
currentCount: deps.getActiveToolExecutions(),
|
|
event: "start",
|
|
}),
|
|
);
|
|
}
|
|
|
|
export function handleTelegramToolExecutionEndRuntime(
|
|
deps: TelegramToolExecutionEndRuntimeDeps,
|
|
): void {
|
|
const hasActiveTurn = deps.hasActiveTurn();
|
|
deps.setActiveToolExecutions(
|
|
getNextTelegramToolExecutionCount({
|
|
hasActiveTurn,
|
|
currentCount: deps.getActiveToolExecutions(),
|
|
event: "end",
|
|
}),
|
|
);
|
|
if (hasActiveTurn) deps.triggerPendingModelSwitchAbort();
|
|
}
|
|
|
|
export type TelegramAgentLifecycleHooksRuntimeDeps<
|
|
TTurn extends PendingTelegramTurn,
|
|
TContext,
|
|
TMessage,
|
|
TReplyMarkup = unknown,
|
|
> = TelegramAgentStartHookRuntimeDeps<TTurn, TContext> &
|
|
TelegramAgentEndHookRuntimeDeps<TTurn, TContext, TMessage, TReplyMarkup> &
|
|
TelegramToolExecutionHookRuntimeDeps<TContext>;
|
|
|
|
export function createTelegramAgentLifecycleHooks<
|
|
TTurn extends PendingTelegramTurn,
|
|
TContext,
|
|
TMessage,
|
|
TReplyMarkup = unknown,
|
|
>(
|
|
deps: TelegramAgentLifecycleHooksRuntimeDeps<
|
|
TTurn,
|
|
TContext,
|
|
TMessage,
|
|
TReplyMarkup
|
|
>,
|
|
) {
|
|
return {
|
|
onAgentStart: createTelegramAgentStartHook<TTurn, TContext>(deps),
|
|
onAgentEnd: createTelegramAgentEndHook<
|
|
TTurn,
|
|
TContext,
|
|
TMessage,
|
|
TReplyMarkup
|
|
>(deps),
|
|
...createTelegramToolExecutionHooks<TContext>(deps),
|
|
};
|
|
}
|
|
|
|
export function createTelegramToolExecutionHooks<TContext>(
|
|
deps: TelegramToolExecutionHookRuntimeDeps<TContext>,
|
|
) {
|
|
return {
|
|
onToolExecutionStart: (): void => {
|
|
handleTelegramToolExecutionStartRuntime(deps);
|
|
},
|
|
onToolExecutionEnd: (
|
|
_event: TelegramToolExecutionHookEvent,
|
|
ctx: TContext,
|
|
): void => {
|
|
handleTelegramToolExecutionEndRuntime({
|
|
hasActiveTurn: deps.hasActiveTurn,
|
|
getActiveToolExecutions: deps.getActiveToolExecutions,
|
|
setActiveToolExecutions: deps.setActiveToolExecutions,
|
|
triggerPendingModelSwitchAbort: () => {
|
|
deps.triggerPendingModelSwitchAbort(ctx);
|
|
},
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
// --- Agent End Lifecycle ---
|
|
|
|
export interface TelegramAgentEndPlan {
|
|
kind: "no-turn" | "aborted" | "error" | "text" | "attachments-only" | "empty";
|
|
shouldClearPreview: boolean;
|
|
shouldDispatchNext: boolean;
|
|
shouldSendErrorMessage: boolean;
|
|
shouldSendAttachmentNotice: boolean;
|
|
}
|
|
|
|
export interface TelegramAgentEndAssistantResult {
|
|
text?: string;
|
|
stopReason?: string;
|
|
errorMessage?: string;
|
|
}
|
|
|
|
export interface TelegramAgentEndOutboundVoiceReply {
|
|
text: string;
|
|
lang?: string;
|
|
rate?: string;
|
|
}
|
|
|
|
export interface TelegramAgentEndOutboundReplyPlan<TReplyMarkup = unknown> {
|
|
markdown: string;
|
|
replyMarkup?: TReplyMarkup;
|
|
voiceText?: string;
|
|
voiceReplies?: TelegramAgentEndOutboundVoiceReply[];
|
|
lang?: string;
|
|
rate?: string;
|
|
}
|
|
|
|
export interface TelegramAgentEndRuntimeDeps<
|
|
TTurn extends PendingTelegramTurn,
|
|
TReplyMarkup = unknown,
|
|
> {
|
|
turn: TTurn | undefined;
|
|
assistant: TelegramAgentEndAssistantResult;
|
|
foldQueuedPromptsIntoHistory: boolean;
|
|
resetRuntimeState: () => void;
|
|
isSessionActive?: () => boolean;
|
|
isTurnTransportActive?: (turn: TTurn) => boolean;
|
|
waitForTypingIdle?: () => Promise<void>;
|
|
updateStatus: () => void;
|
|
dispatchNextQueuedTelegramTurn: () => void;
|
|
scheduleActiveTurnDelivery?: (task: () => Promise<void>) => void;
|
|
clearPreview: (
|
|
chatId: number,
|
|
options?: { target?: TelegramQueueTarget },
|
|
) => Promise<void>;
|
|
setPreviewPendingText: (text: string) => void;
|
|
finalizeMarkdownPreview: (
|
|
chatId: number,
|
|
markdown: string,
|
|
replyToMessageId: number,
|
|
options?: { replyMarkup?: TReplyMarkup; target?: TelegramQueueTarget },
|
|
) => Promise<boolean>;
|
|
sendMarkdownReply: (
|
|
chatId: number,
|
|
replyToMessageId: number | undefined,
|
|
markdown: string,
|
|
options?: { replyMarkup?: TReplyMarkup; target?: TelegramQueueTarget },
|
|
) => Promise<unknown>;
|
|
sendTextReply: (
|
|
chatId: number,
|
|
replyToMessageId: number,
|
|
text: string,
|
|
options?: { target?: TelegramQueueTarget },
|
|
) => Promise<unknown>;
|
|
sendQueuedAttachments: (turn: TTurn) => Promise<void>;
|
|
answerGuestQuery?: (
|
|
guestQueryId: string,
|
|
text?: string,
|
|
options?: { parseMode?: string },
|
|
) => Promise<void>;
|
|
sendGuestReply?: (guestQueryId: string, markdown: string) => Promise<void>;
|
|
sendGuestAttachment?: (
|
|
turn: TTurn,
|
|
attachment: QueuedAttachment,
|
|
caption?: string,
|
|
) => Promise<void>;
|
|
sendGuestVoiceReply?: (
|
|
turn: TTurn,
|
|
plan: TelegramAgentEndOutboundReplyPlan<TReplyMarkup>,
|
|
caption?: string,
|
|
) => Promise<void>;
|
|
planOutboundReply?: (
|
|
markdown: string,
|
|
) => TelegramAgentEndOutboundReplyPlan<TReplyMarkup>;
|
|
sendOutboundReplyArtifacts?: (
|
|
turn: TTurn,
|
|
plan: TelegramAgentEndOutboundReplyPlan,
|
|
options?: { replyToPrompt?: boolean },
|
|
) => Promise<void>;
|
|
getDefaultChatId?: () => number | undefined;
|
|
getDefaultTarget?: () => TelegramQueueTarget | undefined;
|
|
isProactivePushEnabled?: () => boolean;
|
|
canSendProactivePush?: () => boolean;
|
|
recordRuntimeEvent?: (
|
|
category: string,
|
|
error: unknown,
|
|
details?: Record<string, unknown>,
|
|
) => void;
|
|
}
|
|
|
|
export interface TelegramAgentEndHookRuntimeDeps<
|
|
TTurn extends PendingTelegramTurn,
|
|
TContext,
|
|
TMessage,
|
|
TReplyMarkup = unknown,
|
|
> {
|
|
getActiveTurn: () => TTurn | undefined;
|
|
loadConfig?: () => Promise<void>;
|
|
extractAssistant: (
|
|
messages: readonly TMessage[],
|
|
) => TelegramAgentEndAssistantResult;
|
|
getFoldQueuedPromptsIntoHistory: () => boolean;
|
|
resetRuntimeState: () => void;
|
|
isSessionActive?: (ctx: TContext) => boolean;
|
|
isTurnTransportActive?: (turn: TTurn) => boolean;
|
|
waitForTypingIdle?: () => Promise<void>;
|
|
updateStatus: (ctx: TContext) => void;
|
|
dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
|
|
requestDeferredDispatchNextQueuedTelegramTurn: (
|
|
dispatch: (ctx: TContext) => void,
|
|
) => void;
|
|
scheduleActiveTurnDelivery?: TelegramAgentEndRuntimeDeps<
|
|
TTurn,
|
|
TReplyMarkup
|
|
>["scheduleActiveTurnDelivery"];
|
|
clearPreview: TelegramAgentEndRuntimeDeps<
|
|
TTurn,
|
|
TReplyMarkup
|
|
>["clearPreview"];
|
|
setPreviewPendingText: (text: string) => void;
|
|
finalizeMarkdownPreview: TelegramAgentEndRuntimeDeps<
|
|
TTurn,
|
|
TReplyMarkup
|
|
>["finalizeMarkdownPreview"];
|
|
sendMarkdownReply: TelegramAgentEndRuntimeDeps<
|
|
TTurn,
|
|
TReplyMarkup
|
|
>["sendMarkdownReply"];
|
|
sendTextReply: TelegramAgentEndRuntimeDeps<TTurn>["sendTextReply"];
|
|
sendQueuedAttachments: (turn: TTurn) => Promise<void>;
|
|
answerGuestQuery?: TelegramAgentEndRuntimeDeps<TTurn>["answerGuestQuery"];
|
|
sendGuestReply?: TelegramAgentEndRuntimeDeps<TTurn>["sendGuestReply"];
|
|
sendGuestAttachment?: TelegramAgentEndRuntimeDeps<TTurn>["sendGuestAttachment"];
|
|
sendGuestVoiceReply?: TelegramAgentEndRuntimeDeps<TTurn>["sendGuestVoiceReply"];
|
|
planOutboundReply?: TelegramAgentEndRuntimeDeps<
|
|
TTurn,
|
|
TReplyMarkup
|
|
>["planOutboundReply"];
|
|
sendOutboundReplyArtifacts?: TelegramAgentEndRuntimeDeps<TTurn>["sendOutboundReplyArtifacts"];
|
|
getDefaultChatId?: TelegramAgentEndRuntimeDeps<TTurn>["getDefaultChatId"];
|
|
getDefaultTarget?: TelegramAgentEndRuntimeDeps<TTurn>["getDefaultTarget"];
|
|
isProactivePushEnabled?: TelegramAgentEndRuntimeDeps<TTurn>["isProactivePushEnabled"];
|
|
canSendProactivePush?: (ctx: TContext) => boolean;
|
|
recordRuntimeEvent?: TelegramAgentEndRuntimeDeps<TTurn>["recordRuntimeEvent"];
|
|
}
|
|
|
|
export interface TelegramAgentEndHookEvent<TMessage> {
|
|
messages: readonly TMessage[];
|
|
}
|
|
|
|
export function buildTelegramAgentEndPlan(options: {
|
|
hasTurn: boolean;
|
|
stopReason?: string;
|
|
hasFinalText: boolean;
|
|
hasQueuedAttachments: boolean;
|
|
foldQueuedPromptsIntoHistory: boolean;
|
|
}): TelegramAgentEndPlan {
|
|
const shouldDispatchNext = shouldDispatchAfterTelegramAgentEnd({
|
|
hasTurn: options.hasTurn,
|
|
stopReason: options.stopReason,
|
|
foldQueuedPromptsIntoHistory: options.foldQueuedPromptsIntoHistory,
|
|
});
|
|
if (!options.hasTurn) {
|
|
return {
|
|
kind: "no-turn",
|
|
shouldClearPreview: false,
|
|
shouldDispatchNext,
|
|
shouldSendErrorMessage: false,
|
|
shouldSendAttachmentNotice: false,
|
|
};
|
|
}
|
|
if (options.stopReason === "aborted") {
|
|
return {
|
|
kind: "aborted",
|
|
shouldClearPreview: true,
|
|
shouldDispatchNext,
|
|
shouldSendErrorMessage: false,
|
|
shouldSendAttachmentNotice: false,
|
|
};
|
|
}
|
|
if (options.stopReason === "error") {
|
|
return {
|
|
kind: "error",
|
|
shouldClearPreview: true,
|
|
shouldDispatchNext,
|
|
shouldSendErrorMessage: true,
|
|
shouldSendAttachmentNotice: false,
|
|
};
|
|
}
|
|
if (options.hasFinalText) {
|
|
return {
|
|
kind: "text",
|
|
shouldClearPreview: false,
|
|
shouldDispatchNext,
|
|
shouldSendErrorMessage: false,
|
|
shouldSendAttachmentNotice: false,
|
|
};
|
|
}
|
|
if (options.hasQueuedAttachments) {
|
|
return {
|
|
kind: "attachments-only",
|
|
shouldClearPreview: true,
|
|
shouldDispatchNext,
|
|
shouldSendErrorMessage: false,
|
|
shouldSendAttachmentNotice: true,
|
|
};
|
|
}
|
|
return {
|
|
kind: "empty",
|
|
shouldClearPreview: true,
|
|
shouldDispatchNext,
|
|
shouldSendErrorMessage: false,
|
|
shouldSendAttachmentNotice: false,
|
|
};
|
|
}
|
|
|
|
export function createTelegramAgentEndHook<
|
|
TTurn extends PendingTelegramTurn,
|
|
TContext,
|
|
TMessage,
|
|
TReplyMarkup = unknown,
|
|
>(
|
|
deps: TelegramAgentEndHookRuntimeDeps<
|
|
TTurn,
|
|
TContext,
|
|
TMessage,
|
|
TReplyMarkup
|
|
>,
|
|
) {
|
|
return async (
|
|
event: TelegramAgentEndHookEvent<TMessage>,
|
|
ctx: TContext,
|
|
): Promise<void> => {
|
|
await deps.loadConfig?.();
|
|
if (deps.isSessionActive && !deps.isSessionActive(ctx)) return;
|
|
const turn = deps.getActiveTurn();
|
|
const proactiveEnabled = deps.isProactivePushEnabled?.() ?? false;
|
|
const canProactivePush = deps.canSendProactivePush?.(ctx) ?? false;
|
|
await handleTelegramAgentEndRuntime({
|
|
turn,
|
|
assistant:
|
|
turn || proactiveEnabled ? deps.extractAssistant(event.messages) : {},
|
|
foldQueuedPromptsIntoHistory: deps.getFoldQueuedPromptsIntoHistory(),
|
|
resetRuntimeState: deps.resetRuntimeState,
|
|
isSessionActive: () => deps.isSessionActive?.(ctx) ?? true,
|
|
isTurnTransportActive: deps.isTurnTransportActive,
|
|
waitForTypingIdle: deps.waitForTypingIdle,
|
|
updateStatus: () => deps.updateStatus(ctx),
|
|
dispatchNextQueuedTelegramTurn: () => {
|
|
deps.requestDeferredDispatchNextQueuedTelegramTurn(
|
|
deps.dispatchNextQueuedTelegramTurn,
|
|
);
|
|
},
|
|
scheduleActiveTurnDelivery: deps.scheduleActiveTurnDelivery
|
|
? (task) =>
|
|
deps.scheduleActiveTurnDelivery?.(async () => {
|
|
if (deps.isSessionActive?.(ctx) === false) return;
|
|
await task();
|
|
})
|
|
: undefined,
|
|
clearPreview: deps.clearPreview,
|
|
setPreviewPendingText: deps.setPreviewPendingText,
|
|
finalizeMarkdownPreview: deps.finalizeMarkdownPreview,
|
|
sendMarkdownReply: deps.sendMarkdownReply,
|
|
sendTextReply: deps.sendTextReply,
|
|
sendQueuedAttachments: deps.sendQueuedAttachments,
|
|
answerGuestQuery: deps.answerGuestQuery,
|
|
sendGuestReply: deps.sendGuestReply,
|
|
sendGuestAttachment: deps.sendGuestAttachment,
|
|
sendGuestVoiceReply: deps.sendGuestVoiceReply,
|
|
planOutboundReply: deps.planOutboundReply,
|
|
sendOutboundReplyArtifacts: deps.sendOutboundReplyArtifacts,
|
|
getDefaultChatId: deps.getDefaultChatId,
|
|
getDefaultTarget: deps.getDefaultTarget,
|
|
isProactivePushEnabled: deps.isProactivePushEnabled,
|
|
canSendProactivePush: () => canProactivePush,
|
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
});
|
|
};
|
|
}
|
|
|
|
export async function handleTelegramAgentEndRuntime<
|
|
TTurn extends PendingTelegramTurn,
|
|
TReplyMarkup = unknown,
|
|
>(deps: TelegramAgentEndRuntimeDeps<TTurn, TReplyMarkup>): Promise<void> {
|
|
const { turn, assistant } = deps;
|
|
const rawFinalText = assistant.text;
|
|
let outboundReply = rawFinalText
|
|
? deps.planOutboundReply?.(rawFinalText)
|
|
: undefined;
|
|
// Preserve the planned reply so voice-fallback can use stripped markdown + replyMarkup
|
|
const plannedReply = outboundReply;
|
|
|
|
// Transparent voice interception: when the turn is voice-tagged and the agent
|
|
// did not explicitly use <!-- telegram_voice --> markup, we automatically
|
|
// convert the whole response to voice.
|
|
const voiceInterceptionGuard =
|
|
turn &&
|
|
isVoiceTurn(turn) &&
|
|
rawFinalText?.trim() &&
|
|
deps.planOutboundReply &&
|
|
(!outboundReply ||
|
|
(!outboundReply.voiceText && !outboundReply.voiceReplies?.length));
|
|
if (voiceInterceptionGuard) {
|
|
const voiceText =
|
|
plannedReply !== undefined
|
|
? plannedReply.markdown?.trim() || ""
|
|
: (rawFinalText ?? "");
|
|
outboundReply = outboundReply
|
|
? { ...outboundReply, voiceText, markdown: "" }
|
|
: { markdown: "", voiceText };
|
|
}
|
|
|
|
const finalText = outboundReply ? outboundReply.markdown : rawFinalText;
|
|
const hasOutboundArtifacts =
|
|
!!outboundReply?.voiceText || !!outboundReply?.voiceReplies?.length;
|
|
const replyMarkup = outboundReply?.replyMarkup;
|
|
const isDeliveryActive = (): boolean =>
|
|
deps.isSessionActive?.() !== false &&
|
|
(!turn || deps.isTurnTransportActive?.(turn) !== false);
|
|
if (!isDeliveryActive()) {
|
|
deps.resetRuntimeState();
|
|
deps.updateStatus();
|
|
deps.dispatchNextQueuedTelegramTurn();
|
|
return;
|
|
}
|
|
deps.resetRuntimeState();
|
|
await deps.waitForTypingIdle?.();
|
|
if (!isDeliveryActive()) {
|
|
deps.updateStatus();
|
|
deps.dispatchNextQueuedTelegramTurn();
|
|
return;
|
|
}
|
|
deps.updateStatus();
|
|
const endPlan = buildTelegramAgentEndPlan({
|
|
hasTurn: !!turn,
|
|
stopReason: assistant.stopReason,
|
|
hasFinalText: !!finalText || hasOutboundArtifacts,
|
|
hasQueuedAttachments: (turn?.queuedAttachments.length ?? 0) > 0,
|
|
foldQueuedPromptsIntoHistory: deps.foldQueuedPromptsIntoHistory,
|
|
});
|
|
if (!turn) {
|
|
const proactiveEnabled = deps.isProactivePushEnabled?.() ?? false;
|
|
const canProactivePush = deps.canSendProactivePush?.() ?? false;
|
|
if (proactiveEnabled && finalText && !assistant.errorMessage) {
|
|
if (canProactivePush) {
|
|
const defaultTarget = deps.getDefaultTarget?.();
|
|
const defaultChatId =
|
|
defaultTarget?.chatId ?? deps.getDefaultChatId?.();
|
|
if (defaultChatId !== undefined) {
|
|
try {
|
|
await deps.sendMarkdownReply(defaultChatId, undefined, finalText, {
|
|
target: defaultTarget,
|
|
});
|
|
} catch (error) {
|
|
deps.recordRuntimeEvent?.("proactive-push", error, {
|
|
chatId: defaultChatId,
|
|
threadId: defaultTarget?.threadId,
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
deps.recordRuntimeEvent?.(
|
|
"proactive-push",
|
|
new Error(
|
|
"Proactive push skipped because this instance does not own Telegram polling.",
|
|
),
|
|
{ phase: "ownership" },
|
|
);
|
|
}
|
|
}
|
|
if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
|
|
return;
|
|
}
|
|
if (turn.guestQueryId) {
|
|
if (assistant.errorMessage) {
|
|
await deps.answerGuestQuery?.(
|
|
turn.guestQueryId,
|
|
"Telegram bridge: Pi failed while processing the request.",
|
|
);
|
|
if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
|
|
return;
|
|
}
|
|
const [guestAttachment] = turn.queuedAttachments;
|
|
if (guestAttachment && deps.sendGuestAttachment) {
|
|
try {
|
|
await deps.sendGuestAttachment(
|
|
turn,
|
|
guestAttachment,
|
|
finalText || undefined,
|
|
);
|
|
} catch (error) {
|
|
deps.recordRuntimeEvent?.("delivery", error, {
|
|
phase: "guest-attachment",
|
|
guestQueryId: turn.guestQueryId,
|
|
});
|
|
}
|
|
} else if (
|
|
outboundReply &&
|
|
(outboundReply.voiceText || outboundReply.voiceReplies?.length) &&
|
|
deps.sendGuestVoiceReply
|
|
) {
|
|
try {
|
|
await deps.sendGuestVoiceReply(
|
|
turn,
|
|
outboundReply,
|
|
finalText || undefined,
|
|
);
|
|
} catch (error) {
|
|
deps.recordRuntimeEvent?.("delivery", error, {
|
|
phase: "guest-voice",
|
|
guestQueryId: turn.guestQueryId,
|
|
});
|
|
}
|
|
} else if (finalText) {
|
|
if (deps.sendGuestReply) {
|
|
await deps.sendGuestReply(turn.guestQueryId, finalText);
|
|
} else {
|
|
await deps.answerGuestQuery?.(turn.guestQueryId, finalText);
|
|
}
|
|
}
|
|
if (!isDeliveryActive()) return;
|
|
if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
|
|
return;
|
|
}
|
|
if (endPlan.shouldClearPreview) {
|
|
await deps.clearPreview(turn.chatId, { target: turn.target });
|
|
}
|
|
if (!isDeliveryActive()) return;
|
|
if (endPlan.shouldSendErrorMessage) {
|
|
await deps.sendTextReply(
|
|
turn.chatId,
|
|
turn.replyToMessageId,
|
|
assistant.errorMessage ||
|
|
"Telegram bridge: Pi failed while processing the request.",
|
|
{ target: turn.target },
|
|
);
|
|
if (!isDeliveryActive()) return;
|
|
if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
|
|
return;
|
|
}
|
|
const deliverActiveTurn = async () => {
|
|
if (!isDeliveryActive()) return;
|
|
if (finalText) deps.setPreviewPendingText(finalText);
|
|
if (!finalText && hasOutboundArtifacts)
|
|
await deps.clearPreview(turn.chatId, { target: turn.target });
|
|
if (!isDeliveryActive()) return;
|
|
if (endPlan.kind === "text" && finalText) {
|
|
try {
|
|
const finalized = await deps.finalizeMarkdownPreview(
|
|
turn.chatId,
|
|
finalText,
|
|
turn.replyToMessageId,
|
|
{ replyMarkup, target: turn.target },
|
|
);
|
|
if (!isDeliveryActive()) return;
|
|
if (!finalized) {
|
|
await deps.clearPreview(turn.chatId, { target: turn.target });
|
|
if (!isDeliveryActive()) return;
|
|
await deps.sendMarkdownReply(
|
|
turn.chatId,
|
|
turn.replyToMessageId,
|
|
finalText,
|
|
{ replyMarkup, target: turn.target },
|
|
);
|
|
}
|
|
} catch (error) {
|
|
deps.recordRuntimeEvent?.("delivery", error, {
|
|
phase: "final-text",
|
|
chatId: turn.chatId,
|
|
replyToMessageId: turn.replyToMessageId,
|
|
});
|
|
}
|
|
}
|
|
if (!isDeliveryActive()) return;
|
|
if (outboundReply && deps.sendOutboundReplyArtifacts) {
|
|
try {
|
|
await deps.sendOutboundReplyArtifacts(turn, outboundReply, {
|
|
replyToPrompt: !finalText,
|
|
});
|
|
if (!isDeliveryActive()) return;
|
|
} catch (error) {
|
|
deps.recordRuntimeEvent?.("delivery", error, {
|
|
phase: "voice-artifacts",
|
|
chatId: turn.chatId,
|
|
});
|
|
// Fallback to planned text when voice delivery fails and text wasn't already delivered
|
|
if (!isDeliveryActive()) return;
|
|
if (rawFinalText?.trim() && !finalText && hasOutboundArtifacts) {
|
|
try {
|
|
const fallbackMarkdown =
|
|
plannedReply?.markdown ||
|
|
outboundReply?.voiceText ||
|
|
rawFinalText;
|
|
await deps.sendMarkdownReply(
|
|
turn.chatId,
|
|
turn.replyToMessageId,
|
|
fallbackMarkdown,
|
|
plannedReply?.replyMarkup || turn.target
|
|
? {
|
|
replyMarkup: plannedReply?.replyMarkup,
|
|
target: turn.target,
|
|
}
|
|
: undefined,
|
|
);
|
|
} catch (fallbackError) {
|
|
deps.recordRuntimeEvent?.("delivery", fallbackError, {
|
|
phase: "voice-fallback-text",
|
|
chatId: turn.chatId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!isDeliveryActive()) return;
|
|
if (endPlan.shouldSendAttachmentNotice) {
|
|
await deps.sendTextReply(
|
|
turn.chatId,
|
|
turn.replyToMessageId,
|
|
"Attached requested file(s).",
|
|
{ target: turn.target },
|
|
);
|
|
}
|
|
if (!isDeliveryActive()) return;
|
|
await deps.sendQueuedAttachments(turn);
|
|
if (!isDeliveryActive()) return;
|
|
if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
|
|
};
|
|
if (
|
|
deps.scheduleActiveTurnDelivery &&
|
|
(endPlan.kind === "text" || endPlan.kind === "attachments-only")
|
|
) {
|
|
deps.scheduleActiveTurnDelivery(deliverActiveTurn);
|
|
return;
|
|
}
|
|
await deliverActiveTurn();
|
|
}
|
|
|
|
// --- Session Runtime ---
|
|
|
|
export interface TelegramSessionStartState<TModel = unknown> {
|
|
currentTelegramModel: TModel | undefined;
|
|
activeTelegramToolExecutions: number;
|
|
pendingTelegramModelSwitch: undefined;
|
|
nextQueuedTelegramItemOrder: number;
|
|
nextQueuedTelegramControlOrder: number;
|
|
telegramTurnDispatchPending: boolean;
|
|
compactionInProgress: boolean;
|
|
}
|
|
|
|
export interface TelegramSessionShutdownState<TQueueItem> {
|
|
queuedTelegramItems: TQueueItem[];
|
|
nextQueuedTelegramItemOrder: number;
|
|
nextQueuedTelegramControlOrder: number;
|
|
nextPriorityReactionOrder: number;
|
|
currentTelegramModel: undefined;
|
|
activeTelegramToolExecutions: number;
|
|
pendingTelegramModelSwitch: undefined;
|
|
telegramTurnDispatchPending: boolean;
|
|
compactionInProgress: boolean;
|
|
foldQueuedPromptsIntoHistory: boolean;
|
|
}
|
|
|
|
export interface TelegramSessionRuntimeCounterState {
|
|
nextQueuedTelegramItemOrder?: number;
|
|
nextQueuedTelegramControlOrder?: number;
|
|
nextPriorityReactionOrder?: number;
|
|
}
|
|
|
|
export interface TelegramSessionRuntimeFlagState {
|
|
activeTelegramToolExecutions?: number;
|
|
telegramTurnDispatchPending?: boolean;
|
|
compactionInProgress?: boolean;
|
|
foldQueuedPromptsIntoHistory?: boolean;
|
|
}
|
|
|
|
export interface TelegramSessionStateApplierDeps<TQueueItem, TModel> {
|
|
setQueuedItems: (items: TQueueItem[]) => void;
|
|
setCurrentModel: (model: TModel | undefined) => void;
|
|
setPendingModelSwitch: (selection: undefined) => void;
|
|
syncCounters: (state: TelegramSessionRuntimeCounterState) => void;
|
|
syncFlags: (state: TelegramSessionRuntimeFlagState) => void;
|
|
}
|
|
|
|
export interface TelegramSessionStateApplier<TQueueItem, TModel> {
|
|
applyStartState: (state: TelegramSessionStartState<TModel>) => void;
|
|
applyShutdownState: (state: TelegramSessionShutdownState<TQueueItem>) => void;
|
|
}
|
|
|
|
export interface TelegramSessionStartRuntimeDeps<TContext, TModel = unknown> {
|
|
ctx: TContext;
|
|
currentModel: TModel | undefined;
|
|
loadConfig: () => Promise<void>;
|
|
isSessionActive?: () => boolean;
|
|
applyState: (state: TelegramSessionStartState<TModel>) => void;
|
|
bindDeferredDispatchContext?: (ctx: TContext) => void;
|
|
prepareTempDir: () => Promise<unknown>;
|
|
updateStatus: () => void;
|
|
}
|
|
|
|
export interface TelegramSessionShutdownRuntimeDeps<TQueueItem> {
|
|
isSessionActive?: () => boolean;
|
|
unbindDeferredDispatchContext?: () => void;
|
|
applyState: (state: TelegramSessionShutdownState<TQueueItem>) => void;
|
|
clearPendingMediaGroups: () => void;
|
|
clearModelMenuState: () => void;
|
|
getActiveTurnChatId: () => number | undefined;
|
|
getActiveTurnTarget?: () => TelegramQueueTarget | undefined;
|
|
clearPreview: (
|
|
chatId: number,
|
|
options?: { target?: TelegramQueueTarget },
|
|
) => Promise<void>;
|
|
previewShutdownTimeoutMs?: number;
|
|
clearActiveTurn: () => void;
|
|
clearAbort: () => void;
|
|
stopPolling: () => Promise<void>;
|
|
}
|
|
|
|
export interface TelegramSessionLifecycleHookRuntimeDeps<
|
|
TContext,
|
|
TQueueItem,
|
|
TModel = unknown,
|
|
> extends TelegramRuntimeEventRecorderPort {
|
|
getCurrentModel: (ctx: TContext) => TModel | undefined;
|
|
loadConfig: () => Promise<void>;
|
|
applySessionStartState: (state: TelegramSessionStartState<TModel>) => void;
|
|
bindDeferredDispatchContext?: (ctx: TContext) => void;
|
|
prepareTempDir: () => Promise<unknown>;
|
|
updateStatus: (ctx: TContext) => void;
|
|
isSessionActive?: (ctx: TContext) => boolean;
|
|
unbindDeferredDispatchContext?: () => void;
|
|
applySessionShutdownState: (
|
|
state: TelegramSessionShutdownState<TQueueItem>,
|
|
) => void;
|
|
clearPendingMediaGroups: () => void;
|
|
clearModelMenuState: () => void;
|
|
getActiveTurnChatId: () => number | undefined;
|
|
getActiveTurnTarget?: () => TelegramQueueTarget | undefined;
|
|
clearPreview: (
|
|
chatId: number,
|
|
options?: { target?: TelegramQueueTarget },
|
|
) => Promise<void>;
|
|
previewShutdownTimeoutMs?: number;
|
|
clearActiveTurn: () => void;
|
|
clearAbort: () => void;
|
|
stopPolling: () => Promise<void>;
|
|
}
|
|
|
|
export type TelegramSessionLifecycleHookEvent = unknown;
|
|
|
|
export function createTelegramSessionStateApplier<TQueueItem, TModel>(
|
|
deps: TelegramSessionStateApplierDeps<TQueueItem, TModel>,
|
|
): TelegramSessionStateApplier<TQueueItem, TModel> {
|
|
return {
|
|
applyStartState: (state) => {
|
|
deps.setCurrentModel(state.currentTelegramModel);
|
|
deps.setPendingModelSwitch(state.pendingTelegramModelSwitch);
|
|
deps.syncCounters(state);
|
|
deps.syncFlags(state);
|
|
},
|
|
applyShutdownState: (state) => {
|
|
deps.setQueuedItems(state.queuedTelegramItems);
|
|
deps.syncCounters(state);
|
|
deps.syncFlags(state);
|
|
deps.setCurrentModel(state.currentTelegramModel);
|
|
deps.setPendingModelSwitch(state.pendingTelegramModelSwitch);
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface TelegramQueueMutationRuntimeDeps<
|
|
TContext,
|
|
> extends TelegramQueueStore<TContext> {
|
|
ctx: TContext;
|
|
getNextPriorityReactionOrder?: () => number;
|
|
incrementNextPriorityReactionOrder?: () => void;
|
|
updateStatus: (ctx: TContext) => void;
|
|
}
|
|
|
|
export interface TelegramQueueMutationControllerDeps<
|
|
TContext,
|
|
> extends TelegramQueueStore<TContext> {
|
|
getNextPriorityReactionOrder?: () => number;
|
|
incrementNextPriorityReactionOrder?: () => void;
|
|
updateStatus: (ctx: TContext) => void;
|
|
}
|
|
|
|
export interface TelegramQueueMutationController<TContext> {
|
|
append: (item: TelegramQueueItem<TContext>, ctx: TContext) => void;
|
|
reorder: (ctx: TContext) => void;
|
|
clear: (ctx: TContext) => number;
|
|
removeByMessageIds: (
|
|
messageIds: number[],
|
|
ctx: TContext,
|
|
scope?: TelegramQueueMessageScope,
|
|
) => number;
|
|
clearPriorityByMessageId: (
|
|
messageId: number,
|
|
ctx: TContext,
|
|
scope?: TelegramQueueMessageScope,
|
|
) => boolean;
|
|
prioritizeByMessageId: (
|
|
messageId: number,
|
|
ctx: TContext,
|
|
priorityEmoji?: string,
|
|
scope?: TelegramQueueMessageScope,
|
|
) => boolean;
|
|
}
|
|
|
|
export interface TelegramControlQueueControllerDeps<TContext> {
|
|
appendControlItem: (
|
|
item: PendingTelegramControlItem<TContext>,
|
|
ctx: TContext,
|
|
) => void;
|
|
dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
|
|
}
|
|
|
|
export interface TelegramControlQueueController<TContext> {
|
|
enqueue: (item: PendingTelegramControlItem<TContext>, ctx: TContext) => void;
|
|
}
|
|
|
|
export interface TelegramPromptEnqueueRuntimeDeps<
|
|
TMessage,
|
|
TContext = unknown,
|
|
> extends TelegramQueueStore<TContext> {
|
|
getFoldQueuedPromptsIntoHistory: () => boolean;
|
|
setFoldQueuedPromptsIntoHistory: (fold: boolean) => void;
|
|
createTurn: (
|
|
messages: TMessage[],
|
|
historyTurns: PendingTelegramTurn[],
|
|
) => Promise<PendingTelegramTurn>;
|
|
updateStatus: () => void;
|
|
dispatchNextQueuedTelegramTurn: () => void;
|
|
}
|
|
|
|
export interface TelegramPromptEnqueueControllerDeps<
|
|
TMessage,
|
|
TContext = unknown,
|
|
> extends TelegramQueueStore<TContext> {
|
|
getFoldQueuedPromptsIntoHistory: () => boolean;
|
|
setFoldQueuedPromptsIntoHistory: (fold: boolean) => void;
|
|
createTurn: (
|
|
messages: TMessage[],
|
|
historyTurns: PendingTelegramTurn[],
|
|
ctx: TContext,
|
|
) => Promise<PendingTelegramTurn>;
|
|
updateStatus: (ctx: TContext) => void;
|
|
dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
|
|
}
|
|
|
|
export interface TelegramPromptEnqueueController<TMessage, TContext = unknown> {
|
|
enqueue: (messages: TMessage[], ctx: TContext) => Promise<void>;
|
|
}
|
|
|
|
function isTelegramStaleContextError(error: unknown): boolean {
|
|
return (
|
|
error instanceof Error &&
|
|
(error.message.includes("stale after session") ||
|
|
error.message.includes("stale ctx"))
|
|
);
|
|
}
|
|
|
|
export function buildTelegramSessionStartState<TModel = unknown>(
|
|
currentModel: TModel | undefined,
|
|
): TelegramSessionStartState<TModel> {
|
|
return {
|
|
currentTelegramModel: currentModel,
|
|
activeTelegramToolExecutions: 0,
|
|
pendingTelegramModelSwitch: undefined,
|
|
nextQueuedTelegramItemOrder: 0,
|
|
nextQueuedTelegramControlOrder: 0,
|
|
telegramTurnDispatchPending: false,
|
|
compactionInProgress: false,
|
|
};
|
|
}
|
|
|
|
export function buildTelegramSessionShutdownState<
|
|
TQueueItem,
|
|
>(): TelegramSessionShutdownState<TQueueItem> {
|
|
return {
|
|
queuedTelegramItems: [],
|
|
nextQueuedTelegramItemOrder: 0,
|
|
nextQueuedTelegramControlOrder: 0,
|
|
nextPriorityReactionOrder: 0,
|
|
currentTelegramModel: undefined,
|
|
activeTelegramToolExecutions: 0,
|
|
pendingTelegramModelSwitch: undefined,
|
|
telegramTurnDispatchPending: false,
|
|
compactionInProgress: false,
|
|
foldQueuedPromptsIntoHistory: false,
|
|
};
|
|
}
|
|
|
|
export async function startTelegramSessionRuntime<TContext, TModel = unknown>(
|
|
deps: TelegramSessionStartRuntimeDeps<TContext, TModel>,
|
|
): Promise<void> {
|
|
await deps.loadConfig();
|
|
if (deps.isSessionActive?.() === false) return;
|
|
deps.applyState(buildTelegramSessionStartState(deps.currentModel));
|
|
await deps.prepareTempDir();
|
|
if (deps.isSessionActive?.() === false) return;
|
|
try {
|
|
deps.bindDeferredDispatchContext?.(deps.ctx);
|
|
} catch (error) {
|
|
if (!isTelegramStaleContextError(error)) throw error;
|
|
}
|
|
deps.updateStatus();
|
|
}
|
|
|
|
export async function shutdownTelegramSessionRuntime<TQueueItem>(
|
|
deps: TelegramSessionShutdownRuntimeDeps<TQueueItem>,
|
|
): Promise<void> {
|
|
if (deps.isSessionActive?.() === false) return;
|
|
deps.unbindDeferredDispatchContext?.();
|
|
await deps.stopPolling();
|
|
if (deps.isSessionActive?.() === false) return;
|
|
deps.applyState(buildTelegramSessionShutdownState<TQueueItem>());
|
|
deps.clearPendingMediaGroups();
|
|
deps.clearModelMenuState();
|
|
const activeTurnChatId = deps.getActiveTurnChatId();
|
|
if (activeTurnChatId !== undefined) {
|
|
const target = deps.getActiveTurnTarget?.();
|
|
const previewTimeoutMs = deps.previewShutdownTimeoutMs ?? 1000;
|
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
await Promise.race([
|
|
deps.clearPreview(activeTurnChatId, target ? { target } : undefined),
|
|
new Promise<void>((resolve) => {
|
|
timeout = setTimeout(resolve, previewTimeoutMs);
|
|
timeout.unref?.();
|
|
}),
|
|
]).finally(() => {
|
|
if (timeout) clearTimeout(timeout);
|
|
});
|
|
if (deps.isSessionActive?.() === false) return;
|
|
}
|
|
deps.clearActiveTurn();
|
|
deps.clearAbort();
|
|
}
|
|
|
|
export type TelegramSessionLifecycleRuntimeDeps<
|
|
TContext,
|
|
TQueueItem,
|
|
TModel = unknown,
|
|
> = Omit<
|
|
TelegramSessionLifecycleHookRuntimeDeps<TContext, TQueueItem, TModel>,
|
|
"applySessionStartState" | "applySessionShutdownState"
|
|
> &
|
|
TelegramSessionStateApplierDeps<TQueueItem, TModel>;
|
|
|
|
export function createTelegramSessionLifecycleRuntime<
|
|
TContext,
|
|
TQueueItem,
|
|
TModel = unknown,
|
|
>(deps: TelegramSessionLifecycleRuntimeDeps<TContext, TQueueItem, TModel>) {
|
|
const stateApplier = createTelegramSessionStateApplier({
|
|
setQueuedItems: deps.setQueuedItems,
|
|
setCurrentModel: deps.setCurrentModel,
|
|
setPendingModelSwitch: deps.setPendingModelSwitch,
|
|
syncCounters: deps.syncCounters,
|
|
syncFlags: deps.syncFlags,
|
|
});
|
|
return createTelegramSessionLifecycleHooks({
|
|
getCurrentModel: deps.getCurrentModel,
|
|
loadConfig: deps.loadConfig,
|
|
applySessionStartState: stateApplier.applyStartState,
|
|
bindDeferredDispatchContext: deps.bindDeferredDispatchContext,
|
|
prepareTempDir: deps.prepareTempDir,
|
|
updateStatus: deps.updateStatus,
|
|
isSessionActive: deps.isSessionActive,
|
|
unbindDeferredDispatchContext: deps.unbindDeferredDispatchContext,
|
|
applySessionShutdownState: stateApplier.applyShutdownState,
|
|
clearPendingMediaGroups: deps.clearPendingMediaGroups,
|
|
clearModelMenuState: deps.clearModelMenuState,
|
|
getActiveTurnChatId: deps.getActiveTurnChatId,
|
|
getActiveTurnTarget: deps.getActiveTurnTarget,
|
|
clearPreview: deps.clearPreview,
|
|
clearActiveTurn: deps.clearActiveTurn,
|
|
clearAbort: deps.clearAbort,
|
|
stopPolling: deps.stopPolling,
|
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
});
|
|
}
|
|
|
|
export function createTelegramSessionLifecycleHooks<
|
|
TContext,
|
|
TQueueItem,
|
|
TModel = unknown,
|
|
>(deps: TelegramSessionLifecycleHookRuntimeDeps<TContext, TQueueItem, TModel>) {
|
|
return {
|
|
onSessionStart: async (
|
|
_event: TelegramSessionLifecycleHookEvent,
|
|
ctx: TContext,
|
|
): Promise<void> => {
|
|
try {
|
|
await startTelegramSessionRuntime({
|
|
ctx,
|
|
currentModel: deps.getCurrentModel(ctx),
|
|
loadConfig: deps.loadConfig,
|
|
isSessionActive: () => deps.isSessionActive?.(ctx) ?? true,
|
|
applyState: deps.applySessionStartState,
|
|
bindDeferredDispatchContext: deps.bindDeferredDispatchContext,
|
|
prepareTempDir: deps.prepareTempDir,
|
|
updateStatus: () => deps.updateStatus(ctx),
|
|
});
|
|
} catch (error) {
|
|
deps.recordRuntimeEvent?.("session", error, { phase: "start" });
|
|
throw error;
|
|
}
|
|
},
|
|
onSessionShutdown: async (
|
|
_event?: TelegramSessionLifecycleHookEvent,
|
|
ctx?: TContext,
|
|
): Promise<void> => {
|
|
try {
|
|
await shutdownTelegramSessionRuntime<TQueueItem>({
|
|
isSessionActive: () =>
|
|
ctx === undefined ? true : (deps.isSessionActive?.(ctx) ?? true),
|
|
unbindDeferredDispatchContext: deps.unbindDeferredDispatchContext,
|
|
applyState: deps.applySessionShutdownState,
|
|
clearPendingMediaGroups: deps.clearPendingMediaGroups,
|
|
clearModelMenuState: deps.clearModelMenuState,
|
|
getActiveTurnChatId: deps.getActiveTurnChatId,
|
|
getActiveTurnTarget: deps.getActiveTurnTarget,
|
|
clearPreview: deps.clearPreview,
|
|
previewShutdownTimeoutMs: deps.previewShutdownTimeoutMs,
|
|
clearActiveTurn: deps.clearActiveTurn,
|
|
clearAbort: deps.clearAbort,
|
|
stopPolling: deps.stopPolling,
|
|
});
|
|
} catch (error) {
|
|
deps.recordRuntimeEvent?.("session", error, { phase: "shutdown" });
|
|
throw error;
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createTelegramQueueMutationController<TContext>(
|
|
deps: TelegramQueueMutationControllerDeps<TContext>,
|
|
): TelegramQueueMutationController<TContext> {
|
|
const buildRuntimeDeps = (
|
|
ctx: TContext,
|
|
): TelegramQueueMutationRuntimeDeps<TContext> => ({
|
|
...deps,
|
|
ctx,
|
|
});
|
|
return {
|
|
append: (item, ctx) =>
|
|
appendTelegramQueueItemRuntime(item, buildRuntimeDeps(ctx)),
|
|
reorder: (ctx) => reorderTelegramQueueItemsRuntime(buildRuntimeDeps(ctx)),
|
|
clear: (ctx) => clearTelegramQueueItemsRuntime(buildRuntimeDeps(ctx)),
|
|
removeByMessageIds: (messageIds, ctx, scope) =>
|
|
removeTelegramQueueItemsByMessageIdsRuntime(
|
|
messageIds,
|
|
buildRuntimeDeps(ctx),
|
|
scope,
|
|
),
|
|
clearPriorityByMessageId: (messageId, ctx, scope) =>
|
|
clearTelegramQueuePromptPriorityRuntime(
|
|
messageId,
|
|
buildRuntimeDeps(ctx),
|
|
scope,
|
|
),
|
|
prioritizeByMessageId: (messageId, ctx, priorityEmoji, scope) =>
|
|
prioritizeTelegramQueuePromptRuntime(
|
|
messageId,
|
|
buildRuntimeDeps(ctx),
|
|
priorityEmoji,
|
|
scope,
|
|
),
|
|
};
|
|
}
|
|
|
|
function appendTelegramQueueItemRuntime<TContext>(
|
|
item: TelegramQueueItem<TContext>,
|
|
deps: TelegramQueueMutationRuntimeDeps<TContext>,
|
|
): void {
|
|
deps.setQueuedItems(appendTelegramQueueItem(deps.getQueuedItems(), item));
|
|
reorderTelegramQueueItemsRuntime(deps);
|
|
}
|
|
|
|
export function reorderTelegramQueueItemsRuntime<TContext>(
|
|
deps: TelegramQueueMutationRuntimeDeps<TContext>,
|
|
): void {
|
|
deps.setQueuedItems(
|
|
[...deps.getQueuedItems()].sort(compareTelegramQueueItems),
|
|
);
|
|
try {
|
|
deps.updateStatus(deps.ctx);
|
|
} catch (error) {
|
|
if (!isTelegramStaleContextError(error)) throw error;
|
|
}
|
|
}
|
|
|
|
export function clearTelegramQueueItemsRuntime<TContext>(
|
|
deps: TelegramQueueMutationRuntimeDeps<TContext>,
|
|
): number {
|
|
const removedCount = deps.getQueuedItems().length;
|
|
if (removedCount === 0) return 0;
|
|
deps.setQueuedItems([]);
|
|
try {
|
|
deps.updateStatus(deps.ctx);
|
|
} catch (error) {
|
|
if (!isTelegramStaleContextError(error)) throw error;
|
|
}
|
|
return removedCount;
|
|
}
|
|
|
|
export function removeTelegramQueueItemsByMessageIdsRuntime<TContext>(
|
|
messageIds: number[],
|
|
deps: TelegramQueueMutationRuntimeDeps<TContext>,
|
|
scope?: TelegramQueueMessageScope,
|
|
): number {
|
|
const { items, removedCount } = removeTelegramQueueItemsByMessageIds(
|
|
deps.getQueuedItems(),
|
|
messageIds,
|
|
scope,
|
|
);
|
|
if (removedCount === 0) return 0;
|
|
deps.setQueuedItems(items);
|
|
try {
|
|
deps.updateStatus(deps.ctx);
|
|
} catch (error) {
|
|
if (!isTelegramStaleContextError(error)) throw error;
|
|
}
|
|
return removedCount;
|
|
}
|
|
|
|
export function clearTelegramQueuePromptPriorityRuntime<TContext>(
|
|
messageId: number,
|
|
deps: TelegramQueueMutationRuntimeDeps<TContext>,
|
|
scope?: TelegramQueueMessageScope,
|
|
): boolean {
|
|
const { changed, items } = clearTelegramQueuePromptPriority(
|
|
deps.getQueuedItems(),
|
|
messageId,
|
|
scope,
|
|
);
|
|
if (!changed) return false;
|
|
deps.setQueuedItems(items);
|
|
reorderTelegramQueueItemsRuntime(deps);
|
|
return true;
|
|
}
|
|
|
|
export function prioritizeTelegramQueuePromptRuntime<TContext>(
|
|
messageId: number,
|
|
deps: TelegramQueueMutationRuntimeDeps<TContext>,
|
|
priorityEmoji?: string,
|
|
scope?: TelegramQueueMessageScope,
|
|
): boolean {
|
|
const nextPriorityReactionOrder = deps.getNextPriorityReactionOrder?.();
|
|
if (nextPriorityReactionOrder === undefined) return false;
|
|
const { changed, items } = prioritizeTelegramQueuePrompt(
|
|
deps.getQueuedItems(),
|
|
messageId,
|
|
nextPriorityReactionOrder,
|
|
priorityEmoji,
|
|
scope,
|
|
);
|
|
if (!changed) return false;
|
|
deps.setQueuedItems(items);
|
|
deps.incrementNextPriorityReactionOrder?.();
|
|
reorderTelegramQueueItemsRuntime(deps);
|
|
return true;
|
|
}
|
|
|
|
export async function enqueueTelegramPromptTurnRuntime<
|
|
TMessage,
|
|
TContext = unknown,
|
|
>(
|
|
messages: TMessage[],
|
|
deps: TelegramPromptEnqueueRuntimeDeps<TMessage, TContext>,
|
|
): Promise<void> {
|
|
const enqueuePlan = planTelegramPromptEnqueue(
|
|
deps.getQueuedItems(),
|
|
deps.getFoldQueuedPromptsIntoHistory(),
|
|
);
|
|
deps.setFoldQueuedPromptsIntoHistory(false);
|
|
const turn = await deps.createTurn(messages, enqueuePlan.historyTurns);
|
|
deps.setQueuedItems(
|
|
appendTelegramQueueItem(enqueuePlan.remainingItems, turn),
|
|
);
|
|
deps.updateStatus();
|
|
deps.dispatchNextQueuedTelegramTurn();
|
|
}
|
|
|
|
export function createTelegramPromptEnqueueController<
|
|
TMessage,
|
|
TContext = unknown,
|
|
>(
|
|
deps: TelegramPromptEnqueueControllerDeps<TMessage, TContext>,
|
|
): TelegramPromptEnqueueController<TMessage, TContext> {
|
|
return {
|
|
enqueue: (messages, ctx) =>
|
|
enqueueTelegramPromptTurnRuntime(messages, {
|
|
...deps,
|
|
createTurn: (nextMessages, historyTurns) =>
|
|
deps.createTurn(nextMessages, historyTurns, ctx),
|
|
updateStatus: () => deps.updateStatus(ctx),
|
|
dispatchNextQueuedTelegramTurn: () =>
|
|
deps.dispatchNextQueuedTelegramTurn(ctx),
|
|
}),
|
|
};
|
|
}
|
|
|
|
export function createTelegramControlQueueController<TContext>(
|
|
deps: TelegramControlQueueControllerDeps<TContext>,
|
|
): TelegramControlQueueController<TContext> {
|
|
return {
|
|
enqueue: (item, ctx) => {
|
|
deps.appendControlItem(item, ctx);
|
|
deps.dispatchNextQueuedTelegramTurn(ctx);
|
|
},
|
|
};
|
|
}
|
|
|
|
// --- Control Runtime ---
|
|
|
|
function getTelegramQueueErrorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
export interface TelegramRuntimeEventRecorderPort {
|
|
recordRuntimeEvent?: (
|
|
category: string,
|
|
error: unknown,
|
|
details?: Record<string, unknown>,
|
|
) => void;
|
|
}
|
|
|
|
export interface TelegramControlRuntimeDeps<
|
|
TContext,
|
|
> extends TelegramRuntimeEventRecorderPort {
|
|
ctx: TContext;
|
|
sendTextReply: (
|
|
chatId: number,
|
|
replyToMessageId: number,
|
|
text: string,
|
|
options?: { target?: TelegramQueueTarget },
|
|
) => Promise<number | undefined>;
|
|
onSettled: () => void;
|
|
}
|
|
|
|
export async function executeTelegramControlItemRuntime<TContext>(
|
|
item: PendingTelegramControlItem<TContext>,
|
|
deps: TelegramControlRuntimeDeps<TContext>,
|
|
): Promise<void> {
|
|
try {
|
|
await item.execute(deps.ctx);
|
|
} catch (error) {
|
|
const message = getTelegramQueueErrorMessage(error);
|
|
deps.recordRuntimeEvent?.("control", error, {
|
|
controlType: item.controlType,
|
|
chatId: item.chatId,
|
|
replyToMessageId: item.replyToMessageId,
|
|
});
|
|
await deps.sendTextReply(
|
|
item.chatId,
|
|
item.replyToMessageId,
|
|
`Telegram control action failed: ${message}`,
|
|
{ target: item.target },
|
|
);
|
|
} finally {
|
|
deps.onSettled();
|
|
}
|
|
}
|
|
|
|
// --- Deferred Dispatch Runtime ---
|
|
|
|
export interface TelegramDeferredQueueDispatchRuntimeDeps extends TelegramRuntimeEventRecorderPort {
|
|
delayMs?: number;
|
|
setTimer?: (
|
|
callback: () => void,
|
|
ms: number,
|
|
) => ReturnType<typeof setTimeout>;
|
|
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
|
|
}
|
|
|
|
export interface TelegramDeferredQueueDispatchRuntime<TContext = unknown> {
|
|
bind: (ctx: TContext) => void;
|
|
unbind: () => void;
|
|
isBound: () => boolean;
|
|
request: (dispatchNextQueuedTelegramTurn: (ctx: TContext) => void) => void;
|
|
}
|
|
|
|
export function createTelegramDeferredQueueDispatchRuntime<TContext = unknown>(
|
|
deps: TelegramDeferredQueueDispatchRuntimeDeps = {},
|
|
): TelegramDeferredQueueDispatchRuntime<TContext> {
|
|
let boundContext: TContext | undefined;
|
|
let generation = 0;
|
|
const timers = new Set<ReturnType<typeof setTimeout>>();
|
|
const delayMs = deps.delayMs ?? 0;
|
|
const setTimer =
|
|
deps.setTimer ??
|
|
((callback: () => void, ms: number): ReturnType<typeof setTimeout> =>
|
|
setTimeout(callback, ms));
|
|
const clearTimer =
|
|
deps.clearTimer ??
|
|
((timer: ReturnType<typeof setTimeout>): void => clearTimeout(timer));
|
|
const clearTimers = (): void => {
|
|
for (const timer of timers) clearTimer(timer);
|
|
timers.clear();
|
|
};
|
|
return {
|
|
bind: (ctx) => {
|
|
boundContext = ctx;
|
|
generation += 1;
|
|
},
|
|
unbind: () => {
|
|
boundContext = undefined;
|
|
generation += 1;
|
|
clearTimers();
|
|
},
|
|
isBound: () => boundContext !== undefined,
|
|
request: (dispatchNextQueuedTelegramTurn) => {
|
|
if (boundContext === undefined) return;
|
|
const scheduledGeneration = generation;
|
|
let timer: ReturnType<typeof setTimeout>;
|
|
timer = setTimer(() => {
|
|
timers.delete(timer);
|
|
if (generation !== scheduledGeneration || boundContext === undefined)
|
|
return;
|
|
dispatchNextQueuedTelegramTurn(boundContext);
|
|
}, delayMs);
|
|
timer.unref?.();
|
|
timers.add(timer);
|
|
},
|
|
};
|
|
}
|
|
|
|
// --- Dispatch Watchdog Runtime ---
|
|
|
|
export interface TelegramQueueDispatchWatchdogRuntime<TContext = unknown> {
|
|
start: (ctx: TContext) => void;
|
|
stop: () => void;
|
|
poke: () => void;
|
|
}
|
|
|
|
export interface TelegramQueueDispatchWatchdogRuntimeDeps<
|
|
TContext = unknown,
|
|
> extends TelegramRuntimeEventRecorderPort {
|
|
hasQueuedItems: () => boolean;
|
|
dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
|
|
intervalMs?: number;
|
|
setInterval?: (
|
|
callback: () => void,
|
|
ms: number,
|
|
) => ReturnType<typeof setInterval>;
|
|
clearInterval?: (timer: ReturnType<typeof setInterval>) => void;
|
|
}
|
|
|
|
export function createTelegramQueueDispatchWatchdogRuntime<TContext = unknown>(
|
|
deps: TelegramQueueDispatchWatchdogRuntimeDeps<TContext>,
|
|
): TelegramQueueDispatchWatchdogRuntime<TContext> {
|
|
const intervalMs = deps.intervalMs ?? 1000;
|
|
const setIntervalFn: NonNullable<
|
|
TelegramQueueDispatchWatchdogRuntimeDeps<TContext>["setInterval"]
|
|
> = deps.setInterval ?? ((callback, ms) => setInterval(callback, ms));
|
|
const clearIntervalFn: NonNullable<
|
|
TelegramQueueDispatchWatchdogRuntimeDeps<TContext>["clearInterval"]
|
|
> = deps.clearInterval ?? ((timer) => clearInterval(timer));
|
|
let ctx: TContext | undefined;
|
|
let interval: ReturnType<typeof setInterval> | undefined;
|
|
let dispatchInFlight = false;
|
|
const tick = (): void => {
|
|
if (ctx === undefined || dispatchInFlight || !deps.hasQueuedItems()) return;
|
|
dispatchInFlight = true;
|
|
try {
|
|
deps.dispatchNextQueuedTelegramTurn(ctx);
|
|
} catch (error) {
|
|
deps.recordRuntimeEvent?.("dispatch", error, {
|
|
phase: "queue-watchdog",
|
|
});
|
|
} finally {
|
|
dispatchInFlight = false;
|
|
}
|
|
};
|
|
const stop = (): void => {
|
|
ctx = undefined;
|
|
if (!interval) return;
|
|
clearIntervalFn(interval);
|
|
interval = undefined;
|
|
};
|
|
return {
|
|
start: (nextCtx) => {
|
|
ctx = nextCtx;
|
|
if (!interval) {
|
|
const nextInterval = setIntervalFn(tick, intervalMs);
|
|
interval = nextInterval;
|
|
nextInterval.unref?.();
|
|
}
|
|
tick();
|
|
},
|
|
stop,
|
|
poke: tick,
|
|
};
|
|
}
|
|
|
|
// --- Dispatch Runtime ---
|
|
|
|
export interface TelegramPromptDeliveryOptions {
|
|
deliverAs: "followUp";
|
|
}
|
|
|
|
export const TELEGRAM_PROMPT_FOLLOW_UP_DELIVERY = {
|
|
deliverAs: "followUp",
|
|
} as const satisfies TelegramPromptDeliveryOptions;
|
|
|
|
export interface TelegramDispatchRuntimeDeps<TContext = unknown> {
|
|
executeControlItem: (
|
|
item: Extract<
|
|
TelegramQueueDispatchAction<TContext>,
|
|
{ kind: "control" }
|
|
>["item"],
|
|
) => void;
|
|
onPromptDispatchStart: (chatId: number) => void;
|
|
sendUserMessage: (
|
|
content: Extract<
|
|
TelegramQueueDispatchAction,
|
|
{ kind: "prompt" }
|
|
>["item"]["content"],
|
|
options?: TelegramPromptDeliveryOptions,
|
|
) => void;
|
|
onPromptDispatchFailure: (message: string) => void;
|
|
onIdle: () => void;
|
|
}
|
|
|
|
export interface TelegramQueueDispatchControllerDeps<
|
|
TContext = unknown,
|
|
> extends TelegramRuntimeEventRecorderPort {
|
|
getQueuedItems: () => TelegramQueueItem<TContext>[];
|
|
setQueuedItems: (items: TelegramQueueItem<TContext>[]) => void;
|
|
canDispatch: (ctx: TContext) => boolean;
|
|
hasDispatchContext?: () => boolean;
|
|
updateStatus: (ctx: TContext, error?: string) => void;
|
|
sendTextReply: TelegramControlRuntimeDeps<TContext>["sendTextReply"];
|
|
onPromptDispatchStart: (ctx: TContext, chatId: number) => void;
|
|
sendUserMessage: TelegramDispatchRuntimeDeps<TContext>["sendUserMessage"];
|
|
onPromptDispatchFailure: (ctx: TContext, message: string) => void;
|
|
isQueueItemTransportActive?: (item: TelegramQueueItem<TContext>) => boolean;
|
|
}
|
|
|
|
export interface TelegramQueueDispatchController<TContext = unknown> {
|
|
dispatchNext: (ctx: TContext) => void;
|
|
}
|
|
|
|
export function executeTelegramQueueDispatchPlan<TContext = unknown>(
|
|
plan: TelegramQueueDispatchAction<TContext>,
|
|
deps: TelegramDispatchRuntimeDeps<TContext>,
|
|
): void {
|
|
if (plan.kind === "none") {
|
|
deps.onIdle();
|
|
return;
|
|
}
|
|
if (plan.kind === "control") {
|
|
deps.executeControlItem(plan.item);
|
|
return;
|
|
}
|
|
deps.onPromptDispatchStart(plan.item.chatId);
|
|
try {
|
|
deps.sendUserMessage(plan.item.content);
|
|
} catch (error) {
|
|
const message = getTelegramQueueErrorMessage(error);
|
|
deps.onPromptDispatchFailure(message);
|
|
}
|
|
}
|
|
|
|
export type TelegramQueueDispatchRuntimeDeps<TContext = unknown> = Omit<
|
|
TelegramQueueDispatchControllerDeps<TContext>,
|
|
"canDispatch"
|
|
> &
|
|
TelegramDispatchReadinessDeps<TContext>;
|
|
|
|
export function createTelegramQueueDispatchRuntime<TContext = unknown>(
|
|
deps: TelegramQueueDispatchRuntimeDeps<TContext>,
|
|
): TelegramQueueDispatchController<TContext> {
|
|
return createTelegramQueueDispatchController({
|
|
getQueuedItems: deps.getQueuedItems,
|
|
setQueuedItems: deps.setQueuedItems,
|
|
canDispatch: createTelegramDispatchReadinessChecker({
|
|
isCompactionInProgress: deps.isCompactionInProgress,
|
|
hasActiveTurn: deps.hasActiveTurn,
|
|
hasDispatchPending: deps.hasDispatchPending,
|
|
isIdle: deps.isIdle,
|
|
hasPendingMessages: deps.hasPendingMessages,
|
|
}),
|
|
hasDispatchContext: deps.hasDispatchContext,
|
|
updateStatus: deps.updateStatus,
|
|
sendTextReply: deps.sendTextReply,
|
|
onPromptDispatchStart: deps.onPromptDispatchStart,
|
|
sendUserMessage: deps.sendUserMessage,
|
|
onPromptDispatchFailure: deps.onPromptDispatchFailure,
|
|
isQueueItemTransportActive: deps.isQueueItemTransportActive,
|
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
});
|
|
}
|
|
|
|
export function createTelegramQueueDispatchController<TContext = unknown>(
|
|
deps: TelegramQueueDispatchControllerDeps<TContext>,
|
|
): TelegramQueueDispatchController<TContext> {
|
|
let controlDispatchPending = false;
|
|
const controller: TelegramQueueDispatchController<TContext> = {
|
|
dispatchNext: (ctx) => {
|
|
if (deps.hasDispatchContext && !deps.hasDispatchContext()) return;
|
|
if (controlDispatchPending) {
|
|
deps.updateStatus(ctx);
|
|
return;
|
|
}
|
|
const queuedItems = deps.getQueuedItems();
|
|
const activeItems = deps.isQueueItemTransportActive
|
|
? queuedItems.filter(deps.isQueueItemTransportActive)
|
|
: queuedItems;
|
|
if (activeItems.length !== queuedItems.length) {
|
|
deps.setQueuedItems(activeItems);
|
|
deps.recordRuntimeEvent?.(
|
|
"dispatch",
|
|
new Error(
|
|
"Dropped queue work from an inactive Telegram transport generation.",
|
|
),
|
|
{ phase: "transport-generation" },
|
|
);
|
|
}
|
|
const dispatchPlan = planNextTelegramQueueAction(
|
|
activeItems,
|
|
deps.canDispatch(ctx),
|
|
);
|
|
if (dispatchPlan.kind !== "none") {
|
|
deps.setQueuedItems(dispatchPlan.remainingItems);
|
|
}
|
|
executeTelegramQueueDispatchPlan(dispatchPlan, {
|
|
executeControlItem: (item) => {
|
|
controlDispatchPending = true;
|
|
deps.updateStatus(ctx);
|
|
void executeTelegramControlItemRuntime(item, {
|
|
ctx,
|
|
sendTextReply: deps.sendTextReply,
|
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
onSettled: () => {
|
|
controlDispatchPending = false;
|
|
if (deps.hasDispatchContext && !deps.hasDispatchContext()) return;
|
|
deps.updateStatus(ctx);
|
|
controller.dispatchNext(ctx);
|
|
},
|
|
});
|
|
},
|
|
onPromptDispatchStart: (chatId) => {
|
|
deps.onPromptDispatchStart(ctx, chatId);
|
|
},
|
|
sendUserMessage: deps.sendUserMessage,
|
|
onPromptDispatchFailure: (message) => {
|
|
deps.onPromptDispatchFailure(ctx, message);
|
|
},
|
|
onIdle: () => {
|
|
deps.updateStatus(ctx);
|
|
},
|
|
});
|
|
},
|
|
};
|
|
return controller;
|
|
}
|