import type { TriggerReason } from './events.ts' /** Subset of a Telegram message the runtime ingests; wire shape kept minimal. */ export interface TgMessage { updateId: number messageId: number chatId: number date: number senderId?: number senderName?: string text?: string /** Entities as delivered by Telegram: type + UTF-16 code unit offsets. */ entities?: { type: string; offset: number; length: number }[] | undefined replyToMessageId?: number | undefined replyToSenderId?: number | undefined /** Present on media messages. */ media?: { kind: 'photo' | 'animation' | 'video' | 'voice' | 'audio' | 'video_note' | 'document' fileId: string fileName?: string | undefined mimeType?: string | undefined fileSize?: number | undefined }[] caption?: string } export interface TgUpdateRaw { update_id: number message?: { message_id: number date: number chat: { id: number } from?: { id: number; first_name?: string; username?: string; title?: string } text?: string caption?: string entities?: { type: string; offset: number; length: number }[] reply_to_message?: { message_id: number from?: { id: number; is_bot?: boolean } } photo?: { file_id: string; file_size?: number; width: number; height: number }[] animation?: { file_id: string; file_name?: string; mime_type?: string; file_size?: number } video?: { file_id: string; file_name?: string; mime_type?: string; file_size?: number } voice?: { file_id: string; mime_type?: string; file_size?: number; duration: number } audio?: { file_id: string; file_name?: string; mime_type?: string; file_size?: number } video_note?: { file_id: string; file_size?: number; length: number; duration: number } document?: { file_id: string; file_name?: string; mime_type?: string; file_size?: number } } } /** Convert a raw Telegram update into the internal message shape. */ export function toTgMessage(update: TgUpdateRaw): TgMessage | undefined { const m = update.message if (!m) return undefined const media: TgMessage['media'] = [] if (m.photo) { const largest = m.photo[m.photo.length - 1] if (largest) media.push({ kind: 'photo', fileId: largest.file_id, fileSize: largest.file_size }) } const pushIf = ( v: { file_id: string; file_name?: string; mime_type?: string; file_size?: number } | undefined, kind: 'animation' | 'video' | 'voice' | 'audio' | 'video_note' | 'document', ) => { if (v) media.push({ kind, fileId: v.file_id, fileName: v.file_name, mimeType: v.mime_type, fileSize: v.file_size, }) } pushIf(m.animation, 'animation') pushIf(m.video, 'video') pushIf(m.voice, 'voice') pushIf(m.audio, 'audio') pushIf(m.video_note, 'video_note') pushIf(m.document, 'document') const msg: TgMessage = { updateId: update.update_id, messageId: m.message_id, chatId: m.chat.id, date: m.date, } if (m.from?.id !== undefined) msg.senderId = m.from.id const name = m.from?.username ?? m.from?.first_name ?? m.from?.title if (name !== undefined) msg.senderName = name if (m.text !== undefined) msg.text = m.text if (m.caption !== undefined) msg.caption = m.caption if (m.entities !== undefined) msg.entities = m.entities if (m.reply_to_message?.message_id !== undefined) msg.replyToMessageId = m.reply_to_message.message_id if (m.reply_to_message?.from?.id !== undefined) msg.replyToSenderId = m.reply_to_message.from.id if (media.length > 0) msg.media = media return msg } export interface InboundClassification { wake: boolean reason: TriggerReason /** Deterministic hide request: reply "/hide" (or "/hide ") targeting a message. */ hide?: { targetMessageId: number } } /** * Pure trigger classification (spec: only mention, reply-to-will, or command wakes; * deterministic commands like hide never reach the model). * `text` is the effective text (caption for media messages). */ export function classifyInbound( msg: TgMessage, botUserId: number, botUsername: string, ): InboundClassification { const text = (msg.text ?? msg.caption ?? '').trim() const mention = `@${botUsername}`.toLowerCase() const isMention = (msg.entities ?? []).some((e) => { if (e.type !== 'mention') return false const slice = text.slice(e.offset, e.offset + e.length) return slice.toLowerCase() === mention }) const isReplyToWill = msg.replyToSenderId === botUserId const isCommand = text.startsWith('/') if (isCommand) { const parts = text.split(/\s+/) const cmd = (parts[0] ?? '').toLowerCase() if (cmd === '/hide' || cmd.startsWith('/hide@')) { const explicit = parts[1] !== undefined ? Number.parseInt(parts[1], 10) : undefined const target = explicit !== undefined && !Number.isNaN(explicit) ? explicit : msg.replyToMessageId if (target !== undefined) return { wake: false, reason: 'command', hide: { targetMessageId: target } } } } if (isMention || isReplyToWill || isCommand) { return { wake: true, reason: isMention ? 'mention' : isReplyToWill ? 'reply' : 'command' } } return { wake: false, reason: 'none' } } export interface HideAuthorityInput { targetSenderId?: string requesterId: string oliverId: string } /** Sender of the target message or Oliver may hide (spec: deterministic command). */ export function mayHide(input: HideAuthorityInput): boolean { if (input.requesterId === input.oliverId) return true return input.targetSenderId === input.requesterId }