mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 15:54:03 +00:00
226 lines
5.5 KiB
TypeScript
226 lines
5.5 KiB
TypeScript
import type { PaymentConfirmationService } from '@household/application'
|
|
import { instantFromEpochSeconds, type Instant } from '@household/domain'
|
|
import type { Bot, Context } from 'grammy'
|
|
import type { Logger } from '@household/observability'
|
|
import type {
|
|
HouseholdConfigurationRepository,
|
|
HouseholdTopicBindingRecord
|
|
} from '@household/ports'
|
|
|
|
import { getBotTranslations, type BotLocale } from './i18n'
|
|
|
|
export interface PaymentTopicCandidate {
|
|
updateId: number
|
|
chatId: string
|
|
messageId: string
|
|
threadId: string
|
|
senderTelegramUserId: string
|
|
rawText: string
|
|
attachmentCount: number
|
|
messageSentAt: Instant
|
|
}
|
|
|
|
export interface PaymentTopicRecord extends PaymentTopicCandidate {
|
|
householdId: string
|
|
}
|
|
|
|
function readMessageText(ctx: Context): string | null {
|
|
const message = ctx.message
|
|
if (!message) {
|
|
return null
|
|
}
|
|
|
|
if ('text' in message && typeof message.text === 'string') {
|
|
return message.text
|
|
}
|
|
|
|
if ('caption' in message && typeof message.caption === 'string') {
|
|
return message.caption
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function attachmentCount(ctx: Context): number {
|
|
const message = ctx.message
|
|
if (!message) {
|
|
return 0
|
|
}
|
|
|
|
if ('photo' in message && Array.isArray(message.photo)) {
|
|
return message.photo.length
|
|
}
|
|
|
|
if ('document' in message && message.document) {
|
|
return 1
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
function toCandidateFromContext(ctx: Context): PaymentTopicCandidate | null {
|
|
const message = ctx.message
|
|
const rawText = readMessageText(ctx)
|
|
if (!message || !rawText) {
|
|
return null
|
|
}
|
|
|
|
if (!('is_topic_message' in message) || message.is_topic_message !== true) {
|
|
return null
|
|
}
|
|
|
|
if (!('message_thread_id' in message) || message.message_thread_id === undefined) {
|
|
return null
|
|
}
|
|
|
|
const senderTelegramUserId = ctx.from?.id?.toString()
|
|
if (!senderTelegramUserId) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
updateId: ctx.update.update_id,
|
|
chatId: message.chat.id.toString(),
|
|
messageId: message.message_id.toString(),
|
|
threadId: message.message_thread_id.toString(),
|
|
senderTelegramUserId,
|
|
rawText,
|
|
attachmentCount: attachmentCount(ctx),
|
|
messageSentAt: instantFromEpochSeconds(message.date)
|
|
}
|
|
}
|
|
|
|
export function resolveConfiguredPaymentTopicRecord(
|
|
value: PaymentTopicCandidate,
|
|
binding: HouseholdTopicBindingRecord
|
|
): PaymentTopicRecord | null {
|
|
const normalizedText = value.rawText.trim()
|
|
if (normalizedText.length === 0) {
|
|
return null
|
|
}
|
|
|
|
if (binding.role !== 'payments') {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
...value,
|
|
rawText: normalizedText,
|
|
householdId: binding.householdId
|
|
}
|
|
}
|
|
|
|
export function buildPaymentAcknowledgement(
|
|
locale: BotLocale,
|
|
result:
|
|
| { status: 'duplicate' }
|
|
| {
|
|
status: 'recorded'
|
|
kind: 'rent' | 'utilities'
|
|
amountMajor: string
|
|
currency: 'USD' | 'GEL'
|
|
}
|
|
| { status: 'needs_review' }
|
|
): string | null {
|
|
const t = getBotTranslations(locale).payments
|
|
|
|
switch (result.status) {
|
|
case 'duplicate':
|
|
return null
|
|
case 'recorded':
|
|
return t.recorded(result.kind, result.amountMajor, result.currency)
|
|
case 'needs_review':
|
|
return t.savedForReview
|
|
}
|
|
}
|
|
|
|
async function replyToPaymentMessage(ctx: Context, text: string): Promise<void> {
|
|
const message = ctx.msg
|
|
if (!message) {
|
|
return
|
|
}
|
|
|
|
await ctx.reply(text, {
|
|
reply_parameters: {
|
|
message_id: message.message_id
|
|
}
|
|
})
|
|
}
|
|
|
|
export function registerConfiguredPaymentTopicIngestion(
|
|
bot: Bot,
|
|
householdConfigurationRepository: HouseholdConfigurationRepository,
|
|
paymentServiceForHousehold: (householdId: string) => PaymentConfirmationService,
|
|
options: {
|
|
logger?: Logger
|
|
} = {}
|
|
): void {
|
|
bot.on('message', async (ctx, next) => {
|
|
const candidate = toCandidateFromContext(ctx)
|
|
if (!candidate) {
|
|
await next()
|
|
return
|
|
}
|
|
|
|
const binding = await householdConfigurationRepository.findHouseholdTopicByTelegramContext({
|
|
telegramChatId: candidate.chatId,
|
|
telegramThreadId: candidate.threadId
|
|
})
|
|
|
|
if (!binding) {
|
|
await next()
|
|
return
|
|
}
|
|
|
|
const record = resolveConfiguredPaymentTopicRecord(candidate, binding)
|
|
if (!record) {
|
|
await next()
|
|
return
|
|
}
|
|
|
|
try {
|
|
const result = await paymentServiceForHousehold(record.householdId).submit({
|
|
senderTelegramUserId: record.senderTelegramUserId,
|
|
rawText: record.rawText,
|
|
telegramChatId: record.chatId,
|
|
telegramMessageId: record.messageId,
|
|
telegramThreadId: record.threadId,
|
|
telegramUpdateId: String(record.updateId),
|
|
attachmentCount: record.attachmentCount,
|
|
messageSentAt: record.messageSentAt
|
|
})
|
|
const householdChat = await householdConfigurationRepository.getHouseholdChatByHouseholdId(
|
|
record.householdId
|
|
)
|
|
const locale = householdChat?.defaultLocale ?? 'en'
|
|
const acknowledgement = buildPaymentAcknowledgement(
|
|
locale,
|
|
result.status === 'recorded'
|
|
? {
|
|
status: 'recorded',
|
|
kind: result.kind,
|
|
amountMajor: result.amount.toMajorString(),
|
|
currency: result.amount.currency
|
|
}
|
|
: result
|
|
)
|
|
|
|
if (acknowledgement) {
|
|
await replyToPaymentMessage(ctx, acknowledgement)
|
|
}
|
|
} catch (error) {
|
|
options.logger?.error(
|
|
{
|
|
event: 'payment.ingest_failed',
|
|
chatId: record.chatId,
|
|
threadId: record.threadId,
|
|
messageId: record.messageId,
|
|
updateId: record.updateId,
|
|
error
|
|
},
|
|
'Failed to ingest payment confirmation'
|
|
)
|
|
}
|
|
})
|
|
}
|