mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 20:54:03 +00:00
feat(bot): add guided private prompts
This commit is contained in:
@@ -1,15 +1,45 @@
|
||||
import type { AnonymousFeedbackService } from '@household/application'
|
||||
import type { Logger } from '@household/observability'
|
||||
import type { TelegramPendingActionRepository } from '@household/ports'
|
||||
import type { Bot, Context } from 'grammy'
|
||||
|
||||
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(sanitizedText: string): string {
|
||||
return ['Anonymous household note', '', sanitizedText].join('\n')
|
||||
}
|
||||
|
||||
function cancelReplyMarkup() {
|
||||
return {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{
|
||||
text: 'Cancel',
|
||||
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 rejectionMessage(reason: string): string {
|
||||
switch (reason) {
|
||||
case 'not_member':
|
||||
@@ -29,85 +59,231 @@ function rejectionMessage(reason: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
ctx: Context
|
||||
): Promise<void> {
|
||||
const telegramUserId = ctx.from?.id?.toString()
|
||||
const telegramChatId = ctx.chat?.id?.toString()
|
||||
if (!telegramUserId || !telegramChatId) {
|
||||
await ctx.reply('Unable to start anonymous feedback right now.')
|
||||
return
|
||||
}
|
||||
|
||||
await repository.upsertPendingAction({
|
||||
telegramUserId,
|
||||
telegramChatId,
|
||||
action: ANONYMOUS_FEEDBACK_ACTION,
|
||||
payload: {},
|
||||
expiresAt: new Date(Date.now() + PENDING_ACTION_TTL_MS)
|
||||
})
|
||||
|
||||
await ctx.reply('Send me the anonymous message in your next reply, or tap Cancel.', {
|
||||
reply_markup: cancelReplyMarkup()
|
||||
})
|
||||
}
|
||||
|
||||
async function submitAnonymousFeedback(options: {
|
||||
ctx: Context
|
||||
anonymousFeedbackService: AnonymousFeedbackService
|
||||
promptRepository: TelegramPendingActionRepository
|
||||
householdChatId: string
|
||||
feedbackTopicId: number
|
||||
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
|
||||
|
||||
if (!telegramUserId || !telegramChatId || !telegramMessageId || !telegramUpdateId) {
|
||||
await options.ctx.reply('Unable to identify this message for anonymous feedback.')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await options.anonymousFeedbackService.submit({
|
||||
telegramUserId,
|
||||
rawText: options.rawText,
|
||||
telegramChatId,
|
||||
telegramMessageId,
|
||||
telegramUpdateId
|
||||
})
|
||||
|
||||
if (result.status === 'duplicate') {
|
||||
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
||||
await options.ctx.reply('This anonymous feedback message was already processed.')
|
||||
return
|
||||
}
|
||||
|
||||
if (result.status === 'rejected') {
|
||||
if (!options.keepPromptOnValidationFailure || !shouldKeepPrompt(result.reason)) {
|
||||
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
||||
}
|
||||
|
||||
await options.ctx.reply(
|
||||
shouldKeepPrompt(result.reason)
|
||||
? `${rejectionMessage(result.reason)} Send a revised message, or tap Cancel.`
|
||||
: rejectionMessage(result.reason),
|
||||
shouldKeepPrompt(result.reason)
|
||||
? {
|
||||
reply_markup: cancelReplyMarkup()
|
||||
}
|
||||
: {}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const posted = await options.ctx.api.sendMessage(
|
||||
options.householdChatId,
|
||||
feedbackText(result.sanitizedText),
|
||||
{
|
||||
message_thread_id: options.feedbackTopicId
|
||||
}
|
||||
)
|
||||
|
||||
await options.anonymousFeedbackService.markPosted({
|
||||
submissionId: result.submissionId,
|
||||
postedChatId: options.householdChatId,
|
||||
postedThreadId: options.feedbackTopicId.toString(),
|
||||
postedMessageId: posted.message_id.toString()
|
||||
})
|
||||
|
||||
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
||||
await options.ctx.reply('Anonymous feedback 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: options.householdChatId,
|
||||
feedbackTopicId: options.feedbackTopicId,
|
||||
error: message
|
||||
},
|
||||
'Anonymous feedback posting failed'
|
||||
)
|
||||
await options.anonymousFeedbackService.markFailed(result.submissionId, message)
|
||||
await options.ctx.reply('Anonymous feedback was saved, but posting failed. Try again later.')
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAnonymousFeedback(options: {
|
||||
bot: Bot
|
||||
anonymousFeedbackService: AnonymousFeedbackService
|
||||
promptRepository: TelegramPendingActionRepository
|
||||
householdChatId: string
|
||||
feedbackTopicId: number
|
||||
logger?: Logger
|
||||
}): void {
|
||||
options.bot.command('cancel', async (ctx) => {
|
||||
if (!isPrivateChat(ctx)) {
|
||||
return
|
||||
}
|
||||
|
||||
const telegramUserId = ctx.from?.id?.toString()
|
||||
const telegramChatId = ctx.chat?.id?.toString()
|
||||
if (!telegramUserId || !telegramChatId) {
|
||||
await ctx.reply('Nothing to cancel right now.')
|
||||
return
|
||||
}
|
||||
|
||||
const pending = await options.promptRepository.getPendingAction(telegramChatId, telegramUserId)
|
||||
if (!pending) {
|
||||
await ctx.reply('Nothing to cancel right now.')
|
||||
return
|
||||
}
|
||||
|
||||
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
||||
await ctx.reply('Cancelled.')
|
||||
})
|
||||
|
||||
options.bot.command('anon', async (ctx) => {
|
||||
if (!isPrivateChat(ctx)) {
|
||||
await ctx.reply('Use /anon in a private chat with the bot.')
|
||||
return
|
||||
}
|
||||
|
||||
const rawText = typeof ctx.match === 'string' ? ctx.match.trim() : ''
|
||||
const rawText = commandArgText(ctx)
|
||||
if (rawText.length === 0) {
|
||||
await ctx.reply('Usage: /anon <message>')
|
||||
await startPendingAnonymousFeedbackPrompt(options.promptRepository, ctx)
|
||||
return
|
||||
}
|
||||
|
||||
await submitAnonymousFeedback({
|
||||
ctx,
|
||||
anonymousFeedbackService: options.anonymousFeedbackService,
|
||||
promptRepository: options.promptRepository,
|
||||
householdChatId: options.householdChatId,
|
||||
feedbackTopicId: options.feedbackTopicId,
|
||||
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()
|
||||
const telegramMessageId = ctx.msg?.message_id?.toString()
|
||||
const telegramUpdateId =
|
||||
'update_id' in ctx.update ? ctx.update.update_id?.toString() : undefined
|
||||
|
||||
if (!telegramUserId || !telegramChatId || !telegramMessageId || !telegramUpdateId) {
|
||||
await ctx.reply('Unable to identify this message for anonymous feedback.')
|
||||
if (!telegramUserId || !telegramChatId) {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
const result = await options.anonymousFeedbackService.submit({
|
||||
telegramUserId,
|
||||
rawText,
|
||||
telegramChatId,
|
||||
telegramMessageId,
|
||||
telegramUpdateId
|
||||
const pending = await options.promptRepository.getPendingAction(telegramChatId, telegramUserId)
|
||||
if (!pending || pending.action !== ANONYMOUS_FEEDBACK_ACTION) {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
await submitAnonymousFeedback({
|
||||
ctx,
|
||||
anonymousFeedbackService: options.anonymousFeedbackService,
|
||||
promptRepository: options.promptRepository,
|
||||
householdChatId: options.householdChatId,
|
||||
feedbackTopicId: options.feedbackTopicId,
|
||||
logger: options.logger,
|
||||
rawText: ctx.msg.text,
|
||||
keepPromptOnValidationFailure: true
|
||||
})
|
||||
})
|
||||
|
||||
options.bot.callbackQuery(CANCEL_ANONYMOUS_FEEDBACK_CALLBACK, async (ctx) => {
|
||||
if (!isPrivateChat(ctx)) {
|
||||
await ctx.answerCallbackQuery({
|
||||
text: 'Use this in a private chat with the bot.',
|
||||
show_alert: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await clearPendingAnonymousFeedbackPrompt(options.promptRepository, ctx)
|
||||
await ctx.answerCallbackQuery({
|
||||
text: 'Cancelled.'
|
||||
})
|
||||
|
||||
if (result.status === 'duplicate') {
|
||||
await ctx.reply('This anonymous feedback message was already processed.')
|
||||
return
|
||||
}
|
||||
|
||||
if (result.status === 'rejected') {
|
||||
await ctx.reply(rejectionMessage(result.reason))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const posted = await ctx.api.sendMessage(
|
||||
options.householdChatId,
|
||||
feedbackText(result.sanitizedText),
|
||||
{
|
||||
message_thread_id: options.feedbackTopicId
|
||||
}
|
||||
)
|
||||
|
||||
await options.anonymousFeedbackService.markPosted({
|
||||
submissionId: result.submissionId,
|
||||
postedChatId: options.householdChatId,
|
||||
postedThreadId: options.feedbackTopicId.toString(),
|
||||
postedMessageId: posted.message_id.toString()
|
||||
})
|
||||
|
||||
await ctx.reply('Anonymous feedback 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: options.householdChatId,
|
||||
feedbackTopicId: options.feedbackTopicId,
|
||||
error: message
|
||||
},
|
||||
'Anonymous feedback posting failed'
|
||||
)
|
||||
await options.anonymousFeedbackService.markFailed(result.submissionId, message)
|
||||
await ctx.reply('Anonymous feedback was saved, but posting failed. Try again later.')
|
||||
if (ctx.msg) {
|
||||
await ctx.editMessageText('Anonymous feedback cancelled.')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user