Files
household-bot/apps/bot/src/anonymous-feedback.ts

398 lines
12 KiB
TypeScript

import type { AnonymousFeedbackService } from '@household/application'
import { Temporal, nowInstant, type Instant } from '@household/domain'
import type { Logger } from '@household/observability'
import type {
HouseholdConfigurationRepository,
TelegramPendingActionRepository
} from '@household/ports'
import type { Bot, Context } from 'grammy'
import { getBotTranslations, type BotLocale } from './i18n'
import { resolveReplyLocale } from './bot-locale'
const ANONYMOUS_FEEDBACK_ACTION = 'anonymous_feedback' as const
const CANCEL_ANONYMOUS_FEEDBACK_CALLBACK = 'cancel_prompt:anonymous_feedback'
const PENDING_ACTION_TTL_MS = 24 * 60 * 60 * 1000
function isPrivateChat(ctx: Context): boolean {
return ctx.chat?.type === 'private'
}
function commandArgText(ctx: Context): string {
return typeof ctx.match === 'string' ? ctx.match.trim() : ''
}
function feedbackText(locale: BotLocale, sanitizedText: string): string {
return [getBotTranslations(locale).anonymousFeedback.title, '', sanitizedText].join('\n')
}
function cancelReplyMarkup(locale: BotLocale) {
return {
inline_keyboard: [
[
{
text: getBotTranslations(locale).anonymousFeedback.cancelButton,
callback_data: CANCEL_ANONYMOUS_FEEDBACK_CALLBACK
}
]
]
}
}
function isCommandMessage(ctx: Context): boolean {
return typeof ctx.msg?.text === 'string' && ctx.msg.text.trim().startsWith('/')
}
function shouldKeepPrompt(reason: string): boolean {
return reason === 'too_short' || reason === 'too_long' || reason === 'blocklisted'
}
function formatRetryDelay(locale: BotLocale, now: Instant, nextAllowedAt: Instant): string {
const t = getBotTranslations(locale).anonymousFeedback
if (Temporal.Instant.compare(nextAllowedAt, now) <= 0) {
return t.retryNow
}
const duration = now.until(nextAllowedAt, {
largestUnit: 'hour',
smallestUnit: 'minute',
roundingMode: 'ceil'
})
const days = Math.floor(duration.hours / 24)
const hours = duration.hours % 24
const parts = [
days > 0 ? t.day(days) : null,
hours > 0 ? t.hour(hours) : null,
duration.minutes > 0 ? t.minute(duration.minutes) : null
].filter(Boolean)
return parts.length > 0 ? t.retryIn(parts.join(' ')) : t.retryInLessThanMinute
}
function rejectionMessage(
locale: BotLocale,
reason: string,
nextAllowedAt?: Instant,
now = nowInstant()
): string {
const t = getBotTranslations(locale).anonymousFeedback
switch (reason) {
case 'not_member':
return t.notMember
case 'too_short':
return t.tooShort
case 'too_long':
return t.tooLong
case 'cooldown':
return nextAllowedAt
? t.cooldown(formatRetryDelay(locale, now, nextAllowedAt))
: t.cooldown(t.retryInLessThanMinute)
case 'daily_cap':
return nextAllowedAt
? t.dailyCap(formatRetryDelay(locale, now, nextAllowedAt))
: t.dailyCap(t.retryInLessThanMinute)
case 'blocklisted':
return t.blocklisted
default:
return t.submitFailed
}
}
async function clearPendingAnonymousFeedbackPrompt(
repository: TelegramPendingActionRepository,
ctx: Context
): Promise<void> {
const telegramUserId = ctx.from?.id?.toString()
const telegramChatId = ctx.chat?.id?.toString()
if (!telegramUserId || !telegramChatId) {
return
}
await repository.clearPendingAction(telegramChatId, telegramUserId)
}
async function startPendingAnonymousFeedbackPrompt(
repository: TelegramPendingActionRepository,
householdConfigurationRepository: HouseholdConfigurationRepository,
ctx: Context
): Promise<void> {
const locale = await resolveReplyLocale({
ctx,
repository: householdConfigurationRepository
})
const t = getBotTranslations(locale).anonymousFeedback
const telegramUserId = ctx.from?.id?.toString()
const telegramChatId = ctx.chat?.id?.toString()
if (!telegramUserId || !telegramChatId) {
await ctx.reply(t.unableToStart)
return
}
await repository.upsertPendingAction({
telegramUserId,
telegramChatId,
action: ANONYMOUS_FEEDBACK_ACTION,
payload: {},
expiresAt: nowInstant().add({ milliseconds: PENDING_ACTION_TTL_MS })
})
await ctx.reply(t.prompt, {
reply_markup: cancelReplyMarkup(locale)
})
}
async function submitAnonymousFeedback(options: {
ctx: Context
anonymousFeedbackServiceForHousehold: (householdId: string) => AnonymousFeedbackService
householdConfigurationRepository: HouseholdConfigurationRepository
promptRepository: TelegramPendingActionRepository
logger?: Logger | undefined
rawText: string
keepPromptOnValidationFailure?: boolean
}): Promise<void> {
const telegramUserId = options.ctx.from?.id?.toString()
const telegramChatId = options.ctx.chat?.id?.toString()
const telegramMessageId = options.ctx.msg?.message_id?.toString()
const telegramUpdateId =
'update_id' in options.ctx.update ? options.ctx.update.update_id?.toString() : undefined
const fallbackLocale = await resolveReplyLocale({
ctx: options.ctx,
repository: options.householdConfigurationRepository
})
if (!telegramUserId || !telegramChatId || !telegramMessageId || !telegramUpdateId) {
await options.ctx.reply(
getBotTranslations(fallbackLocale).anonymousFeedback.unableToIdentifyMessage
)
return
}
const memberships =
await options.householdConfigurationRepository.listHouseholdMembersByTelegramUserId(
telegramUserId
)
if (memberships.length === 0) {
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
await options.ctx.reply(getBotTranslations(fallbackLocale).anonymousFeedback.notMember)
return
}
if (memberships.length > 1) {
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
await options.ctx.reply(getBotTranslations(fallbackLocale).anonymousFeedback.multipleHouseholds)
return
}
const member = memberships[0]!
const locale = member.preferredLocale ?? member.householdDefaultLocale
const t = getBotTranslations(locale).anonymousFeedback
const householdChat =
await options.householdConfigurationRepository.getHouseholdChatByHouseholdId(member.householdId)
const feedbackTopic = await options.householdConfigurationRepository.getHouseholdTopicBinding(
member.householdId,
'feedback'
)
if (!householdChat || !feedbackTopic) {
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
await options.ctx.reply(t.feedbackTopicMissing)
return
}
const anonymousFeedbackService = options.anonymousFeedbackServiceForHousehold(member.householdId)
const result = await anonymousFeedbackService.submit({
telegramUserId,
rawText: options.rawText,
telegramChatId,
telegramMessageId,
telegramUpdateId
})
if (result.status === 'duplicate') {
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
await options.ctx.reply(t.duplicate)
return
}
if (result.status === 'rejected') {
if (!options.keepPromptOnValidationFailure || !shouldKeepPrompt(result.reason)) {
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
}
const rejectionText = rejectionMessage(
locale,
result.reason,
result.nextAllowedAt,
nowInstant()
)
await options.ctx.reply(
shouldKeepPrompt(result.reason) ? `${rejectionText} ${t.keepPromptSuffix}` : rejectionText,
shouldKeepPrompt(result.reason)
? {
reply_markup: cancelReplyMarkup(locale)
}
: {}
)
return
}
try {
const posted = await options.ctx.api.sendMessage(
householdChat.telegramChatId,
feedbackText(householdChat.defaultLocale, result.sanitizedText),
{
message_thread_id: Number(feedbackTopic.telegramThreadId)
}
)
await anonymousFeedbackService.markPosted({
submissionId: result.submissionId,
postedChatId: householdChat.telegramChatId,
postedThreadId: feedbackTopic.telegramThreadId,
postedMessageId: posted.message_id.toString()
})
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
await options.ctx.reply(t.delivered)
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown Telegram send failure'
options.logger?.error(
{
event: 'anonymous_feedback.post_failed',
submissionId: result.submissionId,
householdChatId: householdChat.telegramChatId,
feedbackTopicId: feedbackTopic.telegramThreadId,
error: message
},
'Anonymous feedback posting failed'
)
await anonymousFeedbackService.markFailed(result.submissionId, message)
await options.ctx.reply(t.savedButPostFailed)
}
}
export function registerAnonymousFeedback(options: {
bot: Bot
anonymousFeedbackServiceForHousehold: (householdId: string) => AnonymousFeedbackService
householdConfigurationRepository: HouseholdConfigurationRepository
promptRepository: TelegramPendingActionRepository
logger?: Logger
}): void {
options.bot.command('cancel', async (ctx) => {
const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
const t = getBotTranslations(locale).anonymousFeedback
if (!isPrivateChat(ctx)) {
return
}
const telegramUserId = ctx.from?.id?.toString()
const telegramChatId = ctx.chat?.id?.toString()
if (!telegramUserId || !telegramChatId) {
await ctx.reply(t.nothingToCancel)
return
}
const pending = await options.promptRepository.getPendingAction(telegramChatId, telegramUserId)
if (!pending) {
await ctx.reply(t.nothingToCancel)
return
}
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
await ctx.reply(t.cancelled)
})
options.bot.command('anon', async (ctx) => {
const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
const t = getBotTranslations(locale).anonymousFeedback
if (!isPrivateChat(ctx)) {
await ctx.reply(t.useInPrivateChat)
return
}
const rawText = commandArgText(ctx)
if (rawText.length === 0) {
await startPendingAnonymousFeedbackPrompt(
options.promptRepository,
options.householdConfigurationRepository,
ctx
)
return
}
await submitAnonymousFeedback({
ctx,
anonymousFeedbackServiceForHousehold: options.anonymousFeedbackServiceForHousehold,
householdConfigurationRepository: options.householdConfigurationRepository,
promptRepository: options.promptRepository,
logger: options.logger,
rawText
})
})
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 pending = await options.promptRepository.getPendingAction(telegramChatId, telegramUserId)
if (!pending || pending.action !== ANONYMOUS_FEEDBACK_ACTION) {
await next()
return
}
await submitAnonymousFeedback({
ctx,
anonymousFeedbackServiceForHousehold: options.anonymousFeedbackServiceForHousehold,
householdConfigurationRepository: options.householdConfigurationRepository,
promptRepository: options.promptRepository,
logger: options.logger,
rawText: ctx.msg.text,
keepPromptOnValidationFailure: true
})
})
options.bot.callbackQuery(CANCEL_ANONYMOUS_FEEDBACK_CALLBACK, async (ctx) => {
const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
const t = getBotTranslations(locale).anonymousFeedback
if (!isPrivateChat(ctx)) {
await ctx.answerCallbackQuery({
text: t.useThisInPrivateChat,
show_alert: true
})
return
}
await clearPendingAnonymousFeedbackPrompt(options.promptRepository, ctx)
await ctx.answerCallbackQuery({
text: t.cancelled
})
if (ctx.msg) {
await ctx.editMessageText(t.cancelledMessage)
}
})
}