repositories / will
will
owned by admin
src/shared/telegram.ts
Rawimport 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 <id>") 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
}