Luigit
repositories / will

will

owned by admin

src/shared/telegram.ts

Raw
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 <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
}