/** * Owner-side debug feed for mode 2 (manager). Because the manager replies AS the * owner through the business connection, the bot's own account is otherwise idle * โ€” so we repurpose it as an observability channel: after each manager turn the * bot DMs the owner a rich card showing which chat was handled, the model's * thinking (folded into a `
` block), the tool calls it made, and the * decision that resulted. This module is the pure formatter; `index.ts` gathers * the thinking/tool-calls from the turn's messages and does the actual send. */ import { blockquote, bold, details, inlineCode, italic, paragraph, preformatted, RichHtml, } from "../../telegram/rich-builder"; import type { ManagerTurnLog, ManagerTurnOutcome } from "./controller"; /** * Which owner-DM topic a finished turn's card belongs to. * * A DELIVERED reply is the manager's work product and lands in the clean `manager` * topic โ€” that topic now holds only what the bot actually SAID to people. Everything * else โ€” a deliberate silence, a held draft, a plain-text correction โ€” is behaviour * you read only when something looks off, so it goes to the `log` topic alongside the * runtime notices. A held draft that is finally sent settles as `reply` in the * controller (not `held`), so a delivered answer always lands with the replies, never * with the diagnostics. */ export function feedTopicOf(outcome: ManagerTurnOutcome): "manager" | "log" { return outcome === "reply" ? "manager" : "log"; } /** Human badge for each turn outcome, shown as the card's headline. */ const OUTCOME_BADGE: Record = { reply: "๐Ÿ’ฌ Replied", silent: "๐Ÿคซ Stayed silent", held: "โœ๏ธ Draft held โ€” new messages arrived", corrected: "โš ๏ธ Wrote plain text โ€” re-prompted to use a tool", }; /** Keep a card comfortably under Telegram's per-message limit. */ const THINKING_LIMIT = 3500; const TOOL_ARGS_LIMIT = 400; /** One tool call the model made during the turn. */ export interface ManagerToolCall { name: string; /** JSON-ish rendering of the call arguments (already stringified). */ args: string; } export interface ManagerFeedEntry { log: ManagerTurnLog; /** The `[Now: โ€ฆ]` line, shown as a small footer. */ nowLine: string; /** The model's raw reasoning for the turn, if any (folded, collapsed). */ thinking?: string; tools: readonly ManagerToolCall[]; } /** Trim overlong text, marking how much was cut so nothing looks silently lost. */ function truncate(text: string, max: number): string { const trimmed = text.trim(); if (trimmed.length <= max) return trimmed; return `${trimmed.slice(0, max)} โ€ฆ[+${trimmed.length - max} chars]`; } /** * Build the rich HTML card mirroring one manager turn to the owner. Returns a * {@link RichHtml} ready for `OutboundSender.notify`. */ export function buildManagerFeed(entry: ManagerFeedEntry): RichHtml { const { log, nowLine, thinking, tools } = entry; const blocks: RichHtml[] = []; // Header: the interlocutor this turn is about โ€” full name, with @username and // phone (when shared) in parentheses. Ids and the rest live in a folded // "Contact" block below. const paren: string[] = []; if (log.username) paren.push(`@${log.username}`); if (log.phone) paren.push(log.phone); const parenSuffix = paren.length > 0 ? ` (${paren.join(", ")})` : ""; blocks.push( paragraph(RichHtml.join(["๐Ÿ’ฌ ", bold(log.contactName), parenSuffix])), ); // Outcome headline. For a reply, name who it went to (always the interlocutor // in a 1:1 business chat) rather than a bare message id. const meta: string[] = []; if (log.outcome === "reply" && log.replyToMessageId !== undefined) { meta.push(`โ†ฉ๏ธŽ to ${log.contactName}`); } if (log.category) meta.push(log.category); const metaSuffix = meta.length > 0 ? ` ยท ${meta.join(" ยท ")}` : ""; blocks.push( paragraph(RichHtml.join([bold(OUTCOME_BADGE[log.outcome]), metaSuffix])), ); // The decision's own text: a reply/draft as a quote, a silent reason in italics. if (log.text?.trim()) { if (log.outcome === "silent") { blocks.push(paragraph(italic(`โ€œ${log.text.trim()}โ€`))); } else { blocks.push(blockquote([log.text.trim()])); } } // The model's reasoning, folded so it never dominates the card. if (thinking?.trim()) { blocks.push( details("๐Ÿง  Model thinking", [ preformatted(truncate(thinking, THINKING_LIMIT)), ]), ); } // The tool calls made this turn, folded, one per line. if (tools.length > 0) { const rows = tools.map((tool) => paragraph( RichHtml.join([ bold(tool.name), tool.args ? ` ${truncate(tool.args, TOOL_ARGS_LIMIT)}` : "", ]), ), ); blocks.push(details(`๐Ÿ”ง Tools (${tools.length})`, rows)); } // Everything else about the contact, folded: the ids and flags you rarely need. // Plain identifiers, deliberately: Telegram has no link that opens a PRIVATE chat // at a given message (`t.me//` is channels/groups only, and tg:// with a // message_id is ignored by most clients), so a "jump" link promised what it could // not deliver and only landed you wherever the chat was last left. const detailRows: RichHtml[] = [ paragraph(RichHtml.join(["Chat: ", inlineCode(`#${log.chatId}`)])), ]; // The message this turn is about: the one we answered, else their latest. const turnMessageId = log.replyToMessageId ?? log.lastMessageId; if (turnMessageId !== undefined) { detailRows.push( paragraph(RichHtml.join(["Message: ", inlineCode(`#${turnMessageId}`)])), ); } if (log.userId) { detailRows.push( paragraph(RichHtml.join(["User ID: ", inlineCode(log.userId)])), ); } if (log.username) detailRows.push(paragraph(`Username: @${log.username}`)); if (log.phone) detailRows.push(paragraph(`Phone: ${log.phone}`)); if (log.languageCode) { detailRows.push(paragraph(`Language: ${log.languageCode}`)); } if (log.isPremium !== undefined) { detailRows.push(paragraph(`Premium: ${log.isPremium ? "yes" : "no"}`)); } if (log.isBot !== undefined) { detailRows.push(paragraph(`Bot: ${log.isBot ? "yes" : "no"}`)); } blocks.push(details("โ„น๏ธ Contact", detailRows)); blocks.push(paragraph(italic(nowLine))); return RichHtml.join(blocks); } /** * Whether a finished turn is not worth a feed card: a silent decision with no * reason carries nothing to show (the blank "Stayed silent" entries) and is just * noise. Anything with text โ€” a reply, a held draft, a correction, or a silent * WITH a reason โ€” still posts. */ export function isEmptyFeedTurn(log: ManagerTurnLog): boolean { return log.outcome === "silent" && !log.text?.trim(); } /** Severity of a relayed runtime notice. */ export type ManagerNoticeLevel = "info" | "warning" | "error"; const NOTICE_BADGE: Record = { info: "โ„น๏ธ Info", warning: "โš ๏ธ Warning", error: "โ›” Error", }; /** * A rich card relaying a runtime notice (warning/error/info) to the owner. Turn * aborts are our built-in turn-end mechanism, not failures โ€” callers relay those * as `info` ("Turn complete"), never as an error. */ export function buildManagerNotice( level: ManagerNoticeLevel, message: string, nowLine: string, ): RichHtml { return RichHtml.join([ paragraph(bold(NOTICE_BADGE[level])), blockquote([message.trim()]), paragraph(italic(nowLine)), ]); }