Files
household-bot/apps/bot/src/dm-assistant.ts

1187 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { FinanceCommandService } from '@household/application'
import { instantFromEpochSeconds, Money } from '@household/domain'
import type { Logger } from '@household/observability'
import type {
HouseholdConfigurationRepository,
ProcessedBotMessageRepository,
TelegramPendingActionRepository
} from '@household/ports'
import type { Bot, Context } from 'grammy'
import { resolveReplyLocale } from './bot-locale'
import { getBotTranslations, type BotLocale } from './i18n'
import type { AssistantReply, ConversationalAssistant } from './openai-chat-assistant'
import type { PurchaseMessageInterpreter } from './openai-purchase-interpreter'
import { maybeCreatePaymentProposal, parsePaymentProposalPayload } from './payment-proposals'
import type {
PurchaseMessageIngestionRepository,
PurchaseProposalActionResult,
PurchaseTopicRecord
} from './purchase-topic-ingestion'
import { startTypingIndicator } from './telegram-chat-action'
import { stripExplicitBotMention } from './telegram-mentions'
const ASSISTANT_PAYMENT_ACTION = 'assistant_payment_confirmation' as const
const ASSISTANT_PAYMENT_CONFIRM_CALLBACK_PREFIX = 'assistant_payment:confirm:'
const ASSISTANT_PAYMENT_CANCEL_CALLBACK_PREFIX = 'assistant_payment:cancel:'
const ASSISTANT_PURCHASE_CONFIRM_CALLBACK_PREFIX = 'assistant_purchase:confirm:'
const ASSISTANT_PURCHASE_CANCEL_CALLBACK_PREFIX = 'assistant_purchase:cancel:'
const DM_ASSISTANT_MESSAGE_SOURCE = 'telegram-dm-assistant'
const GROUP_ASSISTANT_MESSAGE_SOURCE = 'telegram-group-assistant'
const MEMORY_SUMMARY_MAX_CHARS = 1200
const PURCHASE_VERB_PATTERN =
/\b(?:bought|buy|got|picked up|spent|купил(?:а|и)?|взял(?:а|и)?|выложил(?:а|и)?|отдал(?:а|и)?|потратил(?:а|и)?)\b/iu
const PURCHASE_MONEY_PATTERN =
/(?:\d+(?:[.,]\d{1,2})?\s*(?:|gel|lari|лари|usd|\$|доллар(?:а|ов)?|кровн\p{L}*)|\b\d+(?:[.,]\d{1,2})\b)/iu
interface AssistantConversationTurn {
role: 'user' | 'assistant'
text: string
}
interface AssistantConversationState {
summary: string | null
turns: AssistantConversationTurn[]
}
export interface AssistantConversationMemoryStore {
get(key: string): AssistantConversationState
appendTurn(key: string, turn: AssistantConversationTurn): AssistantConversationState
}
export interface AssistantRateLimitResult {
allowed: boolean
retryAfterMs: number
}
export interface AssistantRateLimiter {
consume(key: string): AssistantRateLimitResult
}
export interface AssistantUsageSnapshot {
householdId: string
telegramUserId: string
displayName: string
requestCount: number
inputTokens: number
outputTokens: number
totalTokens: number
updatedAt: string
}
export interface AssistantUsageTracker {
record(input: {
householdId: string
telegramUserId: string
displayName: string
usage: AssistantReply['usage']
}): void
listHouseholdUsage(householdId: string): readonly AssistantUsageSnapshot[]
}
type PurchaseActionResult = Extract<
PurchaseProposalActionResult,
{ status: 'confirmed' | 'already_confirmed' | 'cancelled' | 'already_cancelled' }
>
function describeError(error: unknown): {
errorMessage?: string
errorName?: string
} {
if (error instanceof Error) {
return {
errorMessage: error.message,
errorName: error.name
}
}
if (typeof error === 'string') {
return {
errorMessage: error
}
}
return {}
}
function isPrivateChat(ctx: Context): boolean {
return ctx.chat?.type === 'private'
}
function isGroupChat(ctx: Context): boolean {
return ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup'
}
function isCommandMessage(ctx: Context): boolean {
return typeof ctx.msg?.text === 'string' && ctx.msg.text.trim().startsWith('/')
}
function summarizeTurns(
summary: string | null,
turns: readonly AssistantConversationTurn[]
): string {
const next = [summary, ...turns.map((turn) => `${turn.role}: ${turn.text}`)]
.filter(Boolean)
.join('\n')
return next.length <= MEMORY_SUMMARY_MAX_CHARS
? next
: next.slice(next.length - MEMORY_SUMMARY_MAX_CHARS)
}
function conversationMemoryKey(input: {
telegramUserId: string
telegramChatId: string
isPrivateChat: boolean
}): string {
return input.isPrivateChat
? input.telegramUserId
: `group:${input.telegramChatId}:${input.telegramUserId}`
}
export function createInMemoryAssistantConversationMemoryStore(
maxTurns: number
): AssistantConversationMemoryStore {
const memory = new Map<string, AssistantConversationState>()
return {
get(key) {
return memory.get(key) ?? { summary: null, turns: [] }
},
appendTurn(key, turn) {
const current = memory.get(key) ?? { summary: null, turns: [] }
const nextTurns = [...current.turns, turn]
if (nextTurns.length <= maxTurns) {
const nextState = {
summary: current.summary,
turns: nextTurns
}
memory.set(key, nextState)
return nextState
}
const overflowCount = nextTurns.length - maxTurns
const overflow = nextTurns.slice(0, overflowCount)
const retained = nextTurns.slice(overflowCount)
const nextState = {
summary: summarizeTurns(current.summary, overflow),
turns: retained
}
memory.set(key, nextState)
return nextState
}
}
}
export function createInMemoryAssistantRateLimiter(config: {
burstLimit: number
burstWindowMs: number
rollingLimit: number
rollingWindowMs: number
}): AssistantRateLimiter {
const timestamps = new Map<string, number[]>()
return {
consume(key) {
const now = Date.now()
const events = (timestamps.get(key) ?? []).filter(
(timestamp) => now - timestamp < config.rollingWindowMs
)
const burstEvents = events.filter((timestamp) => now - timestamp < config.burstWindowMs)
if (burstEvents.length >= config.burstLimit) {
const oldestBurstEvent = burstEvents[0] ?? now
return {
allowed: false,
retryAfterMs: Math.max(1, config.burstWindowMs - (now - oldestBurstEvent))
}
}
if (events.length >= config.rollingLimit) {
const oldestEvent = events[0] ?? now
return {
allowed: false,
retryAfterMs: Math.max(1, config.rollingWindowMs - (now - oldestEvent))
}
}
events.push(now)
timestamps.set(key, events)
return {
allowed: true,
retryAfterMs: 0
}
}
}
}
export function createInMemoryAssistantUsageTracker(): AssistantUsageTracker {
const usage = new Map<string, AssistantUsageSnapshot>()
return {
record(input) {
const key = `${input.householdId}:${input.telegramUserId}`
const current = usage.get(key)
usage.set(key, {
householdId: input.householdId,
telegramUserId: input.telegramUserId,
displayName: input.displayName,
requestCount: (current?.requestCount ?? 0) + 1,
inputTokens: (current?.inputTokens ?? 0) + input.usage.inputTokens,
outputTokens: (current?.outputTokens ?? 0) + input.usage.outputTokens,
totalTokens: (current?.totalTokens ?? 0) + input.usage.totalTokens,
updatedAt: new Date().toISOString()
})
},
listHouseholdUsage(householdId) {
return [...usage.values()]
.filter((entry) => entry.householdId === householdId)
.sort((left, right) => right.totalTokens - left.totalTokens)
}
}
}
function formatRetryDelay(locale: BotLocale, retryAfterMs: number): string {
const t = getBotTranslations(locale).assistant
const roundedMinutes = Math.ceil(retryAfterMs / 60_000)
if (roundedMinutes <= 1) {
return t.retryInLessThanMinute
}
const hours = Math.floor(roundedMinutes / 60)
const minutes = roundedMinutes % 60
const parts = [hours > 0 ? t.hour(hours) : null, minutes > 0 ? t.minute(minutes) : null].filter(
Boolean
)
return t.retryIn(parts.join(' '))
}
function paymentProposalReplyMarkup(locale: BotLocale, proposalId: string) {
const t = getBotTranslations(locale).assistant
return {
inline_keyboard: [
[
{
text: t.paymentConfirmButton,
callback_data: `${ASSISTANT_PAYMENT_CONFIRM_CALLBACK_PREFIX}${proposalId}`
},
{
text: t.paymentCancelButton,
callback_data: `${ASSISTANT_PAYMENT_CANCEL_CALLBACK_PREFIX}${proposalId}`
}
]
]
}
}
function purchaseProposalReplyMarkup(locale: BotLocale, purchaseMessageId: string) {
const t = getBotTranslations(locale).purchase
return {
inline_keyboard: [
[
{
text: t.confirmButton,
callback_data: `${ASSISTANT_PURCHASE_CONFIRM_CALLBACK_PREFIX}${purchaseMessageId}`
},
{
text: t.cancelButton,
callback_data: `${ASSISTANT_PURCHASE_CANCEL_CALLBACK_PREFIX}${purchaseMessageId}`
}
]
]
}
}
function formatPurchaseSummary(
locale: BotLocale,
result: {
parsedAmountMinor: bigint | null
parsedCurrency: 'GEL' | 'USD' | null
parsedItemDescription: string | null
}
): string {
if (
result.parsedAmountMinor === null ||
result.parsedCurrency === null ||
result.parsedItemDescription === null
) {
return getBotTranslations(locale).purchase.sharedPurchaseFallback
}
const amount = Money.fromMinor(result.parsedAmountMinor, result.parsedCurrency)
return `${result.parsedItemDescription} - ${amount.toMajorString()} ${result.parsedCurrency}`
}
function buildPurchaseActionMessage(locale: BotLocale, result: PurchaseActionResult): string {
const t = getBotTranslations(locale).purchase
const summary = formatPurchaseSummary(locale, result)
if (result.status === 'confirmed' || result.status === 'already_confirmed') {
return t.confirmed(summary)
}
return t.cancelled(summary)
}
function buildPurchaseClarificationText(
locale: BotLocale,
result: {
clarificationQuestion: string | null
parsedAmountMinor: bigint | null
parsedCurrency: 'GEL' | 'USD' | null
parsedItemDescription: string | null
}
): string {
const t = getBotTranslations(locale).purchase
if (result.clarificationQuestion) {
return t.clarification(result.clarificationQuestion)
}
if (result.parsedAmountMinor === null && result.parsedCurrency === null) {
return t.clarificationMissingAmountAndCurrency
}
if (result.parsedAmountMinor === null) {
return t.clarificationMissingAmount
}
if (result.parsedCurrency === null) {
return t.clarificationMissingCurrency
}
if (result.parsedItemDescription === null) {
return t.clarificationMissingItem
}
return t.clarificationLowConfidence
}
function createDmPurchaseRecord(ctx: Context, householdId: string): PurchaseTopicRecord | null {
if (!isPrivateChat(ctx) || !ctx.msg || !('text' in ctx.msg) || !ctx.from) {
return null
}
const chat = ctx.chat
if (!chat) {
return null
}
const senderDisplayName = [ctx.from.first_name, ctx.from.last_name]
.filter((part) => !!part && part.trim().length > 0)
.join(' ')
return {
updateId: ctx.update.update_id,
householdId,
chatId: chat.id.toString(),
messageId: ctx.msg.message_id.toString(),
threadId: chat.id.toString(),
senderTelegramUserId: ctx.from.id.toString(),
rawText: ctx.msg.text.trim(),
messageSentAt: instantFromEpochSeconds(ctx.msg.date),
...(senderDisplayName.length > 0
? {
senderDisplayName
}
: {})
}
}
function looksLikePurchaseIntent(rawText: string): boolean {
const normalized = rawText.trim()
if (normalized.length === 0) {
return false
}
if (PURCHASE_VERB_PATTERN.test(normalized)) {
return true
}
return PURCHASE_MONEY_PATTERN.test(normalized) && /\p{L}/u.test(normalized)
}
function formatAssistantLedger(
dashboard: NonNullable<Awaited<ReturnType<FinanceCommandService['generateDashboard']>>>
) {
const recentLedger = dashboard.ledger.slice(-5)
if (recentLedger.length === 0) {
return 'No recent ledger activity.'
}
return recentLedger
.map(
(entry) =>
`- ${entry.kind}: ${entry.title} ${entry.displayAmount.toMajorString()} ${entry.displayCurrency} by ${entry.actorDisplayName ?? 'unknown'} on ${entry.occurredAt ?? 'unknown date'}`
)
.join('\n')
}
async function buildHouseholdContext(input: {
householdId: string
memberId: string
memberDisplayName: string
locale: BotLocale
householdConfigurationRepository: HouseholdConfigurationRepository
financeService: FinanceCommandService
}): Promise<string> {
const [household, settings, dashboard] = await Promise.all([
input.householdConfigurationRepository.getHouseholdChatByHouseholdId(input.householdId),
input.householdConfigurationRepository.getHouseholdBillingSettings(input.householdId),
input.financeService.generateDashboard()
])
const lines = [
`Household: ${household?.householdName ?? input.householdId}`,
`User display name: ${input.memberDisplayName}`,
`Locale: ${input.locale}`,
`Settlement currency: ${settings.settlementCurrency}`,
`Timezone: ${settings.timezone}`,
`Current billing cycle: ${dashboard?.period ?? 'not available'}`
]
if (!dashboard) {
lines.push('No current dashboard data is available yet.')
return lines.join('\n')
}
const memberLine = dashboard.members.find((line) => line.memberId === input.memberId)
if (memberLine) {
lines.push(
`Member balance: due ${memberLine.netDue.toMajorString()} ${dashboard.currency}, paid ${memberLine.paid.toMajorString()} ${dashboard.currency}, remaining ${memberLine.remaining.toMajorString()} ${dashboard.currency}`
)
lines.push(
`Rent share: ${memberLine.rentShare.toMajorString()} ${dashboard.currency}; utility share: ${memberLine.utilityShare.toMajorString()} ${dashboard.currency}; purchase offset: ${memberLine.purchaseOffset.toMajorString()} ${dashboard.currency}`
)
}
lines.push(
`Household total remaining: ${dashboard.totalRemaining.toMajorString()} ${dashboard.currency}`
)
lines.push(`Recent ledger activity:\n${formatAssistantLedger(dashboard)}`)
return lines.join('\n')
}
async function replyWithAssistant(input: {
ctx: Context
assistant: ConversationalAssistant | undefined
householdId: string
memberId: string
memberDisplayName: string
telegramUserId: string
telegramChatId: string
locale: BotLocale
userMessage: string
householdConfigurationRepository: HouseholdConfigurationRepository
financeService: FinanceCommandService
memoryStore: AssistantConversationMemoryStore
usageTracker: AssistantUsageTracker
logger: Logger | undefined
}): Promise<void> {
const t = getBotTranslations(input.locale).assistant
if (!input.assistant) {
await input.ctx.reply(t.unavailable)
return
}
const memoryKey = conversationMemoryKey({
telegramUserId: input.telegramUserId,
telegramChatId: input.telegramChatId,
isPrivateChat: isPrivateChat(input.ctx)
})
const memory = input.memoryStore.get(memoryKey)
const typingIndicator = startTypingIndicator(input.ctx)
const assistantStartedAt = Date.now()
let stage: 'household_context' | 'assistant_response' = 'household_context'
let contextBuildMs: number | null = null
let assistantResponseMs: number | null = null
try {
const contextStartedAt = Date.now()
const householdContext = await buildHouseholdContext({
householdId: input.householdId,
memberId: input.memberId,
memberDisplayName: input.memberDisplayName,
locale: input.locale,
householdConfigurationRepository: input.householdConfigurationRepository,
financeService: input.financeService
})
contextBuildMs = Date.now() - contextStartedAt
stage = 'assistant_response'
const assistantResponseStartedAt = Date.now()
const reply = await input.assistant.respond({
locale: input.locale,
householdContext,
memorySummary: memory.summary,
recentTurns: memory.turns,
userMessage: input.userMessage
})
assistantResponseMs = Date.now() - assistantResponseStartedAt
input.usageTracker.record({
householdId: input.householdId,
telegramUserId: input.telegramUserId,
displayName: input.memberDisplayName,
usage: reply.usage
})
input.memoryStore.appendTurn(memoryKey, {
role: 'user',
text: input.userMessage
})
input.memoryStore.appendTurn(memoryKey, {
role: 'assistant',
text: reply.text
})
input.logger?.info(
{
event: 'assistant.reply',
householdId: input.householdId,
telegramUserId: input.telegramUserId,
contextBuildMs,
assistantResponseMs,
totalDurationMs: Date.now() - assistantStartedAt,
householdContextChars: householdContext.length,
recentTurnsCount: memory.turns.length,
memorySummaryChars: memory.summary?.length ?? 0,
inputTokens: reply.usage.inputTokens,
outputTokens: reply.usage.outputTokens,
totalTokens: reply.usage.totalTokens
},
'Assistant reply generated'
)
await input.ctx.reply(reply.text)
} catch (error) {
input.logger?.error(
{
event: 'assistant.reply_failed',
householdId: input.householdId,
telegramUserId: input.telegramUserId,
stage,
contextBuildMs,
assistantResponseMs,
totalDurationMs: Date.now() - assistantStartedAt,
...describeError(error),
error
},
'Assistant reply failed'
)
await input.ctx.reply(t.unavailable)
} finally {
typingIndicator.stop()
}
}
export function registerDmAssistant(options: {
bot: Bot
assistant?: ConversationalAssistant
purchaseRepository?: PurchaseMessageIngestionRepository
purchaseInterpreter?: PurchaseMessageInterpreter
householdConfigurationRepository: HouseholdConfigurationRepository
messageProcessingRepository?: ProcessedBotMessageRepository
promptRepository: TelegramPendingActionRepository
financeServiceForHousehold: (householdId: string) => FinanceCommandService
memoryStore: AssistantConversationMemoryStore
rateLimiter: AssistantRateLimiter
usageTracker: AssistantUsageTracker
logger?: Logger
}): void {
options.bot.callbackQuery(
new RegExp(`^${ASSISTANT_PAYMENT_CONFIRM_CALLBACK_PREFIX}([^:]+)$`),
async (ctx) => {
if (!isPrivateChat(ctx)) {
await ctx.answerCallbackQuery({
text: getBotTranslations('en').assistant.paymentUnavailable,
show_alert: true
})
return
}
const telegramUserId = ctx.from?.id?.toString()
const telegramChatId = ctx.chat?.id?.toString()
const proposalId = ctx.match[1]
if (!telegramUserId || !telegramChatId || !proposalId) {
await ctx.answerCallbackQuery({
text: getBotTranslations('en').assistant.paymentUnavailable,
show_alert: true
})
return
}
const pending = await options.promptRepository.getPendingAction(
telegramChatId,
telegramUserId
)
const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
const t = getBotTranslations(locale).assistant
const payload =
pending?.action === ASSISTANT_PAYMENT_ACTION
? parsePaymentProposalPayload(pending.payload)
: null
if (!payload || payload.proposalId !== proposalId) {
await ctx.answerCallbackQuery({
text: t.paymentUnavailable,
show_alert: true
})
return
}
const amount = Money.fromMinor(BigInt(payload.amountMinor), payload.currency)
const result = await options
.financeServiceForHousehold(payload.householdId)
.addPayment(payload.memberId, payload.kind, amount.toMajorString(), amount.currency)
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
if (!result) {
await ctx.answerCallbackQuery({
text: t.paymentNoBalance,
show_alert: true
})
return
}
await ctx.answerCallbackQuery({
text: t.paymentConfirmed(payload.kind, result.amount.toMajorString(), result.currency)
})
if (ctx.msg) {
await ctx.editMessageText(
t.paymentConfirmed(payload.kind, result.amount.toMajorString(), result.currency),
{
reply_markup: {
inline_keyboard: []
}
}
)
}
}
)
options.bot.callbackQuery(
new RegExp(`^${ASSISTANT_PAYMENT_CANCEL_CALLBACK_PREFIX}([^:]+)$`),
async (ctx) => {
if (!isPrivateChat(ctx)) {
await ctx.answerCallbackQuery({
text: getBotTranslations('en').assistant.paymentUnavailable,
show_alert: true
})
return
}
const telegramUserId = ctx.from?.id?.toString()
const telegramChatId = ctx.chat?.id?.toString()
const proposalId = ctx.match[1]
if (!telegramUserId || !telegramChatId || !proposalId) {
await ctx.answerCallbackQuery({
text: getBotTranslations('en').assistant.paymentUnavailable,
show_alert: true
})
return
}
const pending = await options.promptRepository.getPendingAction(
telegramChatId,
telegramUserId
)
const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
const t = getBotTranslations(locale).assistant
const payload =
pending?.action === ASSISTANT_PAYMENT_ACTION
? parsePaymentProposalPayload(pending.payload)
: null
if (!payload || payload.proposalId !== proposalId) {
await ctx.answerCallbackQuery({
text: t.paymentAlreadyHandled,
show_alert: true
})
return
}
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
await ctx.answerCallbackQuery({
text: t.paymentCancelled
})
if (ctx.msg) {
await ctx.editMessageText(t.paymentCancelled, {
reply_markup: {
inline_keyboard: []
}
})
}
}
)
options.bot.callbackQuery(
new RegExp(`^${ASSISTANT_PURCHASE_CONFIRM_CALLBACK_PREFIX}([^:]+)$`),
async (ctx) => {
if (!isPrivateChat(ctx) || !options.purchaseRepository) {
await ctx.answerCallbackQuery({
text: getBotTranslations('en').purchase.proposalUnavailable,
show_alert: true
})
return
}
const purchaseMessageId = ctx.match[1]
const actorTelegramUserId = ctx.from?.id?.toString()
if (!actorTelegramUserId || !purchaseMessageId) {
await ctx.answerCallbackQuery({
text: getBotTranslations('en').purchase.proposalUnavailable,
show_alert: true
})
return
}
const result = await options.purchaseRepository.confirm(
purchaseMessageId,
actorTelegramUserId
)
const locale =
'householdId' in result
? await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
: 'en'
const t = getBotTranslations(locale).purchase
if (result.status === 'not_found' || result.status === 'not_pending') {
await ctx.answerCallbackQuery({
text: t.proposalUnavailable,
show_alert: true
})
return
}
if (result.status === 'forbidden') {
await ctx.answerCallbackQuery({
text: t.notYourProposal,
show_alert: true
})
return
}
await ctx.answerCallbackQuery({
text: result.status === 'confirmed' ? t.confirmedToast : t.alreadyConfirmed
})
if (ctx.msg) {
await ctx.editMessageText(buildPurchaseActionMessage(locale, result), {
reply_markup: {
inline_keyboard: []
}
})
}
}
)
options.bot.callbackQuery(
new RegExp(`^${ASSISTANT_PURCHASE_CANCEL_CALLBACK_PREFIX}([^:]+)$`),
async (ctx) => {
if (!isPrivateChat(ctx) || !options.purchaseRepository) {
await ctx.answerCallbackQuery({
text: getBotTranslations('en').purchase.proposalUnavailable,
show_alert: true
})
return
}
const purchaseMessageId = ctx.match[1]
const actorTelegramUserId = ctx.from?.id?.toString()
if (!actorTelegramUserId || !purchaseMessageId) {
await ctx.answerCallbackQuery({
text: getBotTranslations('en').purchase.proposalUnavailable,
show_alert: true
})
return
}
const result = await options.purchaseRepository.cancel(purchaseMessageId, actorTelegramUserId)
const locale =
'householdId' in result
? await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
: 'en'
const t = getBotTranslations(locale).purchase
if (result.status === 'not_found' || result.status === 'not_pending') {
await ctx.answerCallbackQuery({
text: t.proposalUnavailable,
show_alert: true
})
return
}
if (result.status === 'forbidden') {
await ctx.answerCallbackQuery({
text: t.notYourProposal,
show_alert: true
})
return
}
await ctx.answerCallbackQuery({
text: result.status === 'cancelled' ? t.cancelledToast : t.alreadyCancelled
})
if (ctx.msg) {
await ctx.editMessageText(buildPurchaseActionMessage(locale, result), {
reply_markup: {
inline_keyboard: []
}
})
}
}
)
options.bot.on('message:text', async (ctx, next) => {
if (!isPrivateChat(ctx) || isCommandMessage(ctx)) {
await next()
return
}
const telegramUserId = ctx.from?.id?.toString()
const telegramChatId = ctx.chat?.id?.toString()
if (!telegramUserId || !telegramChatId) {
await next()
return
}
const memoryKey = conversationMemoryKey({
telegramUserId,
telegramChatId,
isPrivateChat: true
})
const memberships =
await options.householdConfigurationRepository.listHouseholdMembersByTelegramUserId(
telegramUserId
)
const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
const t = getBotTranslations(locale).assistant
if (memberships.length === 0) {
await ctx.reply(t.noHousehold)
return
}
if (memberships.length > 1) {
await ctx.reply(t.multipleHouseholds)
return
}
const member = memberships[0]!
const updateId = ctx.update.update_id?.toString()
const dedupeClaim =
options.messageProcessingRepository && typeof updateId === 'string'
? {
repository: options.messageProcessingRepository,
updateId
}
: null
if (dedupeClaim) {
const claim = await dedupeClaim.repository.claimMessage({
householdId: member.householdId,
source: DM_ASSISTANT_MESSAGE_SOURCE,
sourceMessageKey: dedupeClaim.updateId
})
if (!claim.claimed) {
options.logger?.info(
{
event: 'assistant.duplicate_update',
householdId: member.householdId,
telegramUserId,
updateId: dedupeClaim.updateId
},
'Duplicate DM assistant update ignored'
)
return
}
}
try {
const rateLimit = options.rateLimiter.consume(`${member.householdId}:${telegramUserId}`)
if (!rateLimit.allowed) {
await ctx.reply(t.rateLimited(formatRetryDelay(locale, rateLimit.retryAfterMs)))
return
}
const purchaseRecord = createDmPurchaseRecord(ctx, member.householdId)
const shouldAttemptPurchase =
purchaseRecord &&
options.purchaseRepository &&
(looksLikePurchaseIntent(purchaseRecord.rawText) ||
(await options.purchaseRepository.hasClarificationContext(purchaseRecord)))
if (purchaseRecord && options.purchaseRepository && shouldAttemptPurchase) {
const typingIndicator = startTypingIndicator(ctx)
try {
const settings =
await options.householdConfigurationRepository.getHouseholdBillingSettings(
member.householdId
)
const purchaseResult = await options.purchaseRepository.save(
purchaseRecord,
options.purchaseInterpreter,
settings.settlementCurrency
)
if (purchaseResult.status !== 'ignored_not_purchase') {
const purchaseText =
purchaseResult.status === 'pending_confirmation'
? getBotTranslations(locale).purchase.proposal(
formatPurchaseSummary(locale, purchaseResult),
null
)
: purchaseResult.status === 'clarification_needed'
? buildPurchaseClarificationText(locale, purchaseResult)
: getBotTranslations(locale).purchase.parseFailed
options.memoryStore.appendTurn(memoryKey, {
role: 'user',
text: ctx.msg.text
})
options.memoryStore.appendTurn(memoryKey, {
role: 'assistant',
text: purchaseText
})
const replyOptions =
purchaseResult.status === 'pending_confirmation'
? {
reply_markup: purchaseProposalReplyMarkup(
locale,
purchaseResult.purchaseMessageId
)
}
: undefined
await ctx.reply(purchaseText, replyOptions)
return
}
} finally {
typingIndicator.stop()
}
}
const financeService = options.financeServiceForHousehold(member.householdId)
const paymentProposal = await maybeCreatePaymentProposal({
rawText: ctx.msg.text,
householdId: member.householdId,
memberId: member.id,
financeService,
householdConfigurationRepository: options.householdConfigurationRepository
})
if (paymentProposal.status === 'clarification') {
await ctx.reply(t.paymentClarification)
return
}
if (paymentProposal.status === 'unsupported_currency') {
await ctx.reply(t.paymentUnsupportedCurrency)
return
}
if (paymentProposal.status === 'no_balance') {
await ctx.reply(t.paymentNoBalance)
return
}
if (paymentProposal.status === 'proposal') {
await options.promptRepository.upsertPendingAction({
telegramUserId,
telegramChatId,
action: ASSISTANT_PAYMENT_ACTION,
payload: {
...paymentProposal.payload
},
expiresAt: null
})
const amount = Money.fromMinor(
BigInt(paymentProposal.payload.amountMinor),
paymentProposal.payload.currency
)
const proposalText = t.paymentProposal(
paymentProposal.payload.kind,
amount.toMajorString(),
amount.currency
)
options.memoryStore.appendTurn(memoryKey, {
role: 'user',
text: ctx.msg.text
})
options.memoryStore.appendTurn(memoryKey, {
role: 'assistant',
text: proposalText
})
await ctx.reply(proposalText, {
reply_markup: paymentProposalReplyMarkup(locale, paymentProposal.payload.proposalId)
})
return
}
await replyWithAssistant({
ctx,
assistant: options.assistant,
householdId: member.householdId,
memberId: member.id,
memberDisplayName: member.displayName,
telegramUserId,
telegramChatId,
locale,
userMessage: ctx.msg.text,
householdConfigurationRepository: options.householdConfigurationRepository,
financeService,
memoryStore: options.memoryStore,
usageTracker: options.usageTracker,
logger: options.logger
})
} catch (error) {
if (dedupeClaim) {
await dedupeClaim.repository.releaseMessage({
householdId: member.householdId,
source: DM_ASSISTANT_MESSAGE_SOURCE,
sourceMessageKey: dedupeClaim.updateId
})
}
throw error
}
})
options.bot.on('message:text', async (ctx, next) => {
if (!isGroupChat(ctx) || isCommandMessage(ctx)) {
await next()
return
}
const mention = stripExplicitBotMention(ctx)
if (!mention || mention.strippedText.length === 0) {
await next()
return
}
const telegramUserId = ctx.from?.id?.toString()
const telegramChatId = ctx.chat?.id?.toString()
if (!telegramUserId || !telegramChatId) {
await next()
return
}
const household =
await options.householdConfigurationRepository.getTelegramHouseholdChat(telegramChatId)
if (!household) {
await next()
return
}
const member = await options.householdConfigurationRepository.getHouseholdMember(
household.householdId,
telegramUserId
)
if (!member) {
await next()
return
}
const locale = member.preferredLocale ?? household.defaultLocale ?? 'en'
const rateLimit = options.rateLimiter.consume(`${household.householdId}:${telegramUserId}`)
const t = getBotTranslations(locale).assistant
if (!rateLimit.allowed) {
await ctx.reply(t.rateLimited(formatRetryDelay(locale, rateLimit.retryAfterMs)))
return
}
const updateId = ctx.update.update_id?.toString()
const dedupeClaim =
options.messageProcessingRepository && typeof updateId === 'string'
? {
repository: options.messageProcessingRepository,
updateId
}
: null
if (dedupeClaim) {
const claim = await dedupeClaim.repository.claimMessage({
householdId: household.householdId,
source: GROUP_ASSISTANT_MESSAGE_SOURCE,
sourceMessageKey: dedupeClaim.updateId
})
if (!claim.claimed) {
options.logger?.info(
{
event: 'assistant.duplicate_update',
householdId: household.householdId,
telegramUserId,
updateId: dedupeClaim.updateId
},
'Duplicate group assistant mention ignored'
)
return
}
}
try {
await replyWithAssistant({
ctx,
assistant: options.assistant,
householdId: household.householdId,
memberId: member.id,
memberDisplayName: member.displayName,
telegramUserId,
telegramChatId,
locale,
userMessage: mention.strippedText,
householdConfigurationRepository: options.householdConfigurationRepository,
financeService: options.financeServiceForHousehold(household.householdId),
memoryStore: options.memoryStore,
usageTracker: options.usageTracker,
logger: options.logger
})
} catch (error) {
if (dedupeClaim) {
await dedupeClaim.repository.releaseMessage({
householdId: household.householdId,
source: GROUP_ASSISTANT_MESSAGE_SOURCE,
sourceMessageKey: dedupeClaim.updateId
})
}
throw error
}
})
}