mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 17:44:03 +00:00
feat(bot): add guided private prompts
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, mock, test } from 'bun:test'
|
import { describe, expect, mock, test } from 'bun:test'
|
||||||
|
|
||||||
import type { AnonymousFeedbackService } from '@household/application'
|
import type { AnonymousFeedbackService } from '@household/application'
|
||||||
|
import type { TelegramPendingActionRepository } from '@household/ports'
|
||||||
|
|
||||||
import { createTelegramBot } from './bot'
|
import { createTelegramBot } from './bot'
|
||||||
import { registerAnonymousFeedback } from './anonymous-feedback'
|
import { registerAnonymousFeedback } from './anonymous-feedback'
|
||||||
@@ -38,6 +39,43 @@ function anonUpdate(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createPromptRepository(): TelegramPendingActionRepository {
|
||||||
|
const store = new Map<string, { action: 'anonymous_feedback'; expiresAt: Date | null }>()
|
||||||
|
|
||||||
|
return {
|
||||||
|
async upsertPendingAction(input) {
|
||||||
|
store.set(`${input.telegramChatId}:${input.telegramUserId}`, {
|
||||||
|
action: input.action,
|
||||||
|
expiresAt: input.expiresAt
|
||||||
|
})
|
||||||
|
return input
|
||||||
|
},
|
||||||
|
async getPendingAction(telegramChatId, telegramUserId) {
|
||||||
|
const key = `${telegramChatId}:${telegramUserId}`
|
||||||
|
const record = store.get(key)
|
||||||
|
if (!record) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.expiresAt && record.expiresAt.getTime() <= Date.now()) {
|
||||||
|
store.delete(key)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
telegramChatId,
|
||||||
|
telegramUserId,
|
||||||
|
action: record.action,
|
||||||
|
payload: {},
|
||||||
|
expiresAt: record.expiresAt
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async clearPendingAction(telegramChatId, telegramUserId) {
|
||||||
|
store.delete(`${telegramChatId}:${telegramUserId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('registerAnonymousFeedback', () => {
|
describe('registerAnonymousFeedback', () => {
|
||||||
test('posts accepted feedback into the configured topic', async () => {
|
test('posts accepted feedback into the configured topic', async () => {
|
||||||
const bot = createTelegramBot('000000:test-token')
|
const bot = createTelegramBot('000000:test-token')
|
||||||
@@ -87,6 +125,7 @@ describe('registerAnonymousFeedback', () => {
|
|||||||
registerAnonymousFeedback({
|
registerAnonymousFeedback({
|
||||||
bot,
|
bot,
|
||||||
anonymousFeedbackService,
|
anonymousFeedbackService,
|
||||||
|
promptRepository: createPromptRepository(),
|
||||||
householdChatId: '-100222333',
|
householdChatId: '-100222333',
|
||||||
feedbackTopicId: 77
|
feedbackTopicId: 77
|
||||||
})
|
})
|
||||||
@@ -157,6 +196,7 @@ describe('registerAnonymousFeedback', () => {
|
|||||||
markPosted: mock(async () => {}),
|
markPosted: mock(async () => {}),
|
||||||
markFailed: mock(async () => {})
|
markFailed: mock(async () => {})
|
||||||
},
|
},
|
||||||
|
promptRepository: createPromptRepository(),
|
||||||
householdChatId: '-100222333',
|
householdChatId: '-100222333',
|
||||||
feedbackTopicId: 77
|
feedbackTopicId: 77
|
||||||
})
|
})
|
||||||
@@ -174,4 +214,184 @@ describe('registerAnonymousFeedback', () => {
|
|||||||
text: 'Use /anon in a private chat with the bot.'
|
text: 'Use /anon in a private chat with the bot.'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('prompts for the next DM message when /anon has no body', async () => {
|
||||||
|
const bot = createTelegramBot('000000:test-token')
|
||||||
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
|
||||||
|
bot.botInfo = {
|
||||||
|
id: 999000,
|
||||||
|
is_bot: true,
|
||||||
|
first_name: 'Household Test Bot',
|
||||||
|
username: 'household_test_bot',
|
||||||
|
can_join_groups: true,
|
||||||
|
can_read_all_group_messages: false,
|
||||||
|
supports_inline_queries: false,
|
||||||
|
can_connect_to_business: false,
|
||||||
|
has_main_web_app: false,
|
||||||
|
has_topics_enabled: true,
|
||||||
|
allows_users_to_create_topics: false
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.api.config.use(async (_prev, method, payload) => {
|
||||||
|
calls.push({ method, payload })
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
message_id: calls.length,
|
||||||
|
date: Math.floor(Date.now() / 1000),
|
||||||
|
chat: {
|
||||||
|
id: 1,
|
||||||
|
type: 'private'
|
||||||
|
},
|
||||||
|
text: 'ok'
|
||||||
|
}
|
||||||
|
} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
const submit = mock(async () => ({
|
||||||
|
status: 'accepted' as const,
|
||||||
|
submissionId: 'submission-1',
|
||||||
|
sanitizedText: 'Please clean the kitchen tonight.'
|
||||||
|
}))
|
||||||
|
|
||||||
|
registerAnonymousFeedback({
|
||||||
|
bot,
|
||||||
|
anonymousFeedbackService: {
|
||||||
|
submit,
|
||||||
|
markPosted: mock(async () => {}),
|
||||||
|
markFailed: mock(async () => {})
|
||||||
|
},
|
||||||
|
promptRepository: createPromptRepository(),
|
||||||
|
householdChatId: '-100222333',
|
||||||
|
feedbackTopicId: 77
|
||||||
|
})
|
||||||
|
|
||||||
|
await bot.handleUpdate(
|
||||||
|
anonUpdate({
|
||||||
|
updateId: 1003,
|
||||||
|
chatType: 'private',
|
||||||
|
text: '/anon'
|
||||||
|
}) as never
|
||||||
|
)
|
||||||
|
|
||||||
|
await bot.handleUpdate(
|
||||||
|
anonUpdate({
|
||||||
|
updateId: 1004,
|
||||||
|
chatType: 'private',
|
||||||
|
text: 'Please clean the kitchen tonight.'
|
||||||
|
}) as never
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(submit).toHaveBeenCalledTimes(1)
|
||||||
|
expect(calls[0]?.payload).toMatchObject({
|
||||||
|
text: 'Send me the anonymous message in your next reply, or tap Cancel.'
|
||||||
|
})
|
||||||
|
expect(calls[1]?.payload).toMatchObject({
|
||||||
|
chat_id: '-100222333',
|
||||||
|
message_thread_id: 77,
|
||||||
|
text: 'Anonymous household note\n\nPlease clean the kitchen tonight.'
|
||||||
|
})
|
||||||
|
expect(calls[2]?.payload).toMatchObject({
|
||||||
|
text: 'Anonymous feedback delivered.'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cancels the pending anonymous feedback prompt', async () => {
|
||||||
|
const bot = createTelegramBot('000000:test-token')
|
||||||
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
|
||||||
|
bot.botInfo = {
|
||||||
|
id: 999000,
|
||||||
|
is_bot: true,
|
||||||
|
first_name: 'Household Test Bot',
|
||||||
|
username: 'household_test_bot',
|
||||||
|
can_join_groups: true,
|
||||||
|
can_read_all_group_messages: false,
|
||||||
|
supports_inline_queries: false,
|
||||||
|
can_connect_to_business: false,
|
||||||
|
has_main_web_app: false,
|
||||||
|
has_topics_enabled: true,
|
||||||
|
allows_users_to_create_topics: false
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.api.config.use(async (_prev, method, payload) => {
|
||||||
|
calls.push({ method, payload })
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
message_id: calls.length,
|
||||||
|
date: Math.floor(Date.now() / 1000),
|
||||||
|
chat: {
|
||||||
|
id: 1,
|
||||||
|
type: 'private'
|
||||||
|
},
|
||||||
|
text: 'ok'
|
||||||
|
}
|
||||||
|
} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
const submit = mock(async () => ({
|
||||||
|
status: 'accepted' as const,
|
||||||
|
submissionId: 'submission-1',
|
||||||
|
sanitizedText: 'Please clean the kitchen tonight.'
|
||||||
|
}))
|
||||||
|
|
||||||
|
registerAnonymousFeedback({
|
||||||
|
bot,
|
||||||
|
anonymousFeedbackService: {
|
||||||
|
submit,
|
||||||
|
markPosted: mock(async () => {}),
|
||||||
|
markFailed: mock(async () => {})
|
||||||
|
},
|
||||||
|
promptRepository: createPromptRepository(),
|
||||||
|
householdChatId: '-100222333',
|
||||||
|
feedbackTopicId: 77
|
||||||
|
})
|
||||||
|
|
||||||
|
await bot.handleUpdate(
|
||||||
|
anonUpdate({
|
||||||
|
updateId: 1005,
|
||||||
|
chatType: 'private',
|
||||||
|
text: '/anon'
|
||||||
|
}) as never
|
||||||
|
)
|
||||||
|
|
||||||
|
await bot.handleUpdate({
|
||||||
|
update_id: 1006,
|
||||||
|
callback_query: {
|
||||||
|
id: 'callback-1',
|
||||||
|
from: {
|
||||||
|
id: 123456,
|
||||||
|
is_bot: false,
|
||||||
|
first_name: 'Stan'
|
||||||
|
},
|
||||||
|
chat_instance: 'chat-instance',
|
||||||
|
message: {
|
||||||
|
message_id: 1005,
|
||||||
|
date: Math.floor(Date.now() / 1000),
|
||||||
|
chat: {
|
||||||
|
id: 123456,
|
||||||
|
type: 'private'
|
||||||
|
},
|
||||||
|
text: 'Send me the anonymous message in your next reply, or tap Cancel.'
|
||||||
|
},
|
||||||
|
data: 'cancel_prompt:anonymous_feedback'
|
||||||
|
}
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
await bot.handleUpdate(
|
||||||
|
anonUpdate({
|
||||||
|
updateId: 1007,
|
||||||
|
chatType: 'private',
|
||||||
|
text: 'Please clean the kitchen tonight.'
|
||||||
|
}) as never
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(submit).toHaveBeenCalledTimes(0)
|
||||||
|
expect(calls[1]?.method).toBe('answerCallbackQuery')
|
||||||
|
expect(calls[2]?.method).toBe('editMessageText')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,15 +1,45 @@
|
|||||||
import type { AnonymousFeedbackService } from '@household/application'
|
import type { AnonymousFeedbackService } from '@household/application'
|
||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
|
import type { TelegramPendingActionRepository } from '@household/ports'
|
||||||
import type { Bot, Context } from 'grammy'
|
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 {
|
function isPrivateChat(ctx: Context): boolean {
|
||||||
return ctx.chat?.type === 'private'
|
return ctx.chat?.type === 'private'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function commandArgText(ctx: Context): string {
|
||||||
|
return typeof ctx.match === 'string' ? ctx.match.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
function feedbackText(sanitizedText: string): string {
|
function feedbackText(sanitizedText: string): string {
|
||||||
return ['Anonymous household note', '', sanitizedText].join('\n')
|
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 {
|
function rejectionMessage(reason: string): string {
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
case 'not_member':
|
case 'not_member':
|
||||||
@@ -29,56 +59,98 @@ function rejectionMessage(reason: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerAnonymousFeedback(options: {
|
async function clearPendingAnonymousFeedbackPrompt(
|
||||||
bot: Bot
|
repository: TelegramPendingActionRepository,
|
||||||
anonymousFeedbackService: AnonymousFeedbackService
|
ctx: Context
|
||||||
householdChatId: string
|
): Promise<void> {
|
||||||
feedbackTopicId: number
|
|
||||||
logger?: Logger
|
|
||||||
}): void {
|
|
||||||
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() : ''
|
|
||||||
if (rawText.length === 0) {
|
|
||||||
await ctx.reply('Usage: /anon <message>')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const telegramUserId = ctx.from?.id?.toString()
|
const telegramUserId = ctx.from?.id?.toString()
|
||||||
const telegramChatId = ctx.chat?.id?.toString()
|
const telegramChatId = ctx.chat?.id?.toString()
|
||||||
const telegramMessageId = ctx.msg?.message_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 =
|
const telegramUpdateId =
|
||||||
'update_id' in ctx.update ? ctx.update.update_id?.toString() : undefined
|
'update_id' in options.ctx.update ? options.ctx.update.update_id?.toString() : undefined
|
||||||
|
|
||||||
if (!telegramUserId || !telegramChatId || !telegramMessageId || !telegramUpdateId) {
|
if (!telegramUserId || !telegramChatId || !telegramMessageId || !telegramUpdateId) {
|
||||||
await ctx.reply('Unable to identify this message for anonymous feedback.')
|
await options.ctx.reply('Unable to identify this message for anonymous feedback.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await options.anonymousFeedbackService.submit({
|
const result = await options.anonymousFeedbackService.submit({
|
||||||
telegramUserId,
|
telegramUserId,
|
||||||
rawText,
|
rawText: options.rawText,
|
||||||
telegramChatId,
|
telegramChatId,
|
||||||
telegramMessageId,
|
telegramMessageId,
|
||||||
telegramUpdateId
|
telegramUpdateId
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.status === 'duplicate') {
|
if (result.status === 'duplicate') {
|
||||||
await ctx.reply('This anonymous feedback message was already processed.')
|
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
||||||
|
await options.ctx.reply('This anonymous feedback message was already processed.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.status === 'rejected') {
|
if (result.status === 'rejected') {
|
||||||
await ctx.reply(rejectionMessage(result.reason))
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const posted = await ctx.api.sendMessage(
|
const posted = await options.ctx.api.sendMessage(
|
||||||
options.householdChatId,
|
options.householdChatId,
|
||||||
feedbackText(result.sanitizedText),
|
feedbackText(result.sanitizedText),
|
||||||
{
|
{
|
||||||
@@ -93,7 +165,8 @@ export function registerAnonymousFeedback(options: {
|
|||||||
postedMessageId: posted.message_id.toString()
|
postedMessageId: posted.message_id.toString()
|
||||||
})
|
})
|
||||||
|
|
||||||
await ctx.reply('Anonymous feedback delivered.')
|
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
||||||
|
await options.ctx.reply('Anonymous feedback delivered.')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown Telegram send failure'
|
const message = error instanceof Error ? error.message : 'Unknown Telegram send failure'
|
||||||
options.logger?.error(
|
options.logger?.error(
|
||||||
@@ -107,7 +180,110 @@ export function registerAnonymousFeedback(options: {
|
|||||||
'Anonymous feedback posting failed'
|
'Anonymous feedback posting failed'
|
||||||
)
|
)
|
||||||
await options.anonymousFeedbackService.markFailed(result.submissionId, message)
|
await options.anonymousFeedbackService.markFailed(result.submissionId, message)
|
||||||
await ctx.reply('Anonymous feedback was saved, but posting failed. Try again later.')
|
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 = commandArgText(ctx)
|
||||||
|
if (rawText.length === 0) {
|
||||||
|
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()
|
||||||
|
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,
|
||||||
|
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 (ctx.msg) {
|
||||||
|
await ctx.editMessageText('Anonymous feedback cancelled.')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type {
|
|||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
import type { Bot, Context } from 'grammy'
|
import type { Bot, Context } from 'grammy'
|
||||||
|
|
||||||
|
const APPROVE_MEMBER_CALLBACK_PREFIX = 'approve_member:'
|
||||||
|
|
||||||
function commandArgText(ctx: Context): string {
|
function commandArgText(ctx: Context): string {
|
||||||
return typeof ctx.match === 'string' ? ctx.match.trim() : ''
|
return typeof ctx.match === 'string' ? ctx.match.trim() : ''
|
||||||
}
|
}
|
||||||
@@ -73,6 +75,43 @@ function actorDisplayName(ctx: Context): string | undefined {
|
|||||||
return fullName || ctx.from?.username?.trim() || undefined
|
return fullName || ctx.from?.username?.trim() || undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPendingMemberLabel(displayName: string): string {
|
||||||
|
const normalized = displayName.trim().replaceAll(/\s+/g, ' ')
|
||||||
|
if (normalized.length <= 32) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${normalized.slice(0, 29)}...`
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingMembersReply(result: {
|
||||||
|
householdName: string
|
||||||
|
members: readonly {
|
||||||
|
telegramUserId: string
|
||||||
|
displayName: string
|
||||||
|
username?: string | null
|
||||||
|
}[]
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
text: [
|
||||||
|
`Pending members for ${result.householdName}:`,
|
||||||
|
...result.members.map(
|
||||||
|
(member, index) =>
|
||||||
|
`${index + 1}. ${member.displayName} (${member.telegramUserId})${member.username ? ` @${member.username}` : ''}`
|
||||||
|
),
|
||||||
|
'Tap a button below to approve, or use /approve_member <telegram_user_id>.'
|
||||||
|
].join('\n'),
|
||||||
|
reply_markup: {
|
||||||
|
inline_keyboard: result.members.map((member) => [
|
||||||
|
{
|
||||||
|
text: `Approve ${buildPendingMemberLabel(member.displayName)}`,
|
||||||
|
callback_data: `${APPROVE_MEMBER_CALLBACK_PREFIX}${member.telegramUserId}`
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
} as const
|
||||||
|
}
|
||||||
|
|
||||||
export function registerHouseholdSetupCommands(options: {
|
export function registerHouseholdSetupCommands(options: {
|
||||||
bot: Bot
|
bot: Bot
|
||||||
householdSetupService: HouseholdSetupService
|
householdSetupService: HouseholdSetupService
|
||||||
@@ -335,16 +374,10 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.reply(
|
const reply = pendingMembersReply(result)
|
||||||
[
|
await ctx.reply(reply.text, {
|
||||||
`Pending members for ${result.householdName}:`,
|
reply_markup: reply.reply_markup
|
||||||
...result.members.map(
|
})
|
||||||
(member, index) =>
|
|
||||||
`${index + 1}. ${member.displayName} (${member.telegramUserId})${member.username ? ` @${member.username}` : ''}`
|
|
||||||
),
|
|
||||||
'Approve with /approve_member <telegram_user_id>.'
|
|
||||||
].join('\n')
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
options.bot.command('approve_member', async (ctx) => {
|
options.bot.command('approve_member', async (ctx) => {
|
||||||
@@ -380,4 +413,67 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
`Approved ${result.member.displayName} as an active member of ${result.householdName}.`
|
`Approved ${result.member.displayName} as an active member of ${result.householdName}.`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
options.bot.callbackQuery(
|
||||||
|
new RegExp(`^${APPROVE_MEMBER_CALLBACK_PREFIX}(\\d+)$`),
|
||||||
|
async (ctx) => {
|
||||||
|
if (!isGroupChat(ctx)) {
|
||||||
|
await ctx.answerCallbackQuery({
|
||||||
|
text: 'Use this button in the household group.',
|
||||||
|
show_alert: true
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorTelegramUserId = ctx.from?.id?.toString()
|
||||||
|
const pendingTelegramUserId = ctx.match[1]
|
||||||
|
if (!actorTelegramUserId || !pendingTelegramUserId) {
|
||||||
|
await ctx.answerCallbackQuery({
|
||||||
|
text: 'Unable to identify the selected member.',
|
||||||
|
show_alert: true
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await options.householdAdminService.approvePendingMember({
|
||||||
|
actorTelegramUserId,
|
||||||
|
telegramChatId: ctx.chat.id.toString(),
|
||||||
|
pendingTelegramUserId
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
await ctx.answerCallbackQuery({
|
||||||
|
text: adminRejectionMessage(result.reason),
|
||||||
|
show_alert: true
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.answerCallbackQuery({
|
||||||
|
text: `Approved ${result.member.displayName}.`
|
||||||
|
})
|
||||||
|
|
||||||
|
if (ctx.msg) {
|
||||||
|
const refreshed = await options.householdAdminService.listPendingMembers({
|
||||||
|
actorTelegramUserId,
|
||||||
|
telegramChatId: ctx.chat.id.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (refreshed.status === 'ok') {
|
||||||
|
if (refreshed.members.length === 0) {
|
||||||
|
await ctx.editMessageText(`No pending members for ${refreshed.householdName}.`)
|
||||||
|
} else {
|
||||||
|
const reply = pendingMembersReply(refreshed)
|
||||||
|
await ctx.editMessageText(reply.text, {
|
||||||
|
reply_markup: reply.reply_markup
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.reply(
|
||||||
|
`Approved ${result.member.displayName} as an active member of ${result.householdName}.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
createDbAnonymousFeedbackRepository,
|
createDbAnonymousFeedbackRepository,
|
||||||
createDbFinanceRepository,
|
createDbFinanceRepository,
|
||||||
createDbHouseholdConfigurationRepository,
|
createDbHouseholdConfigurationRepository,
|
||||||
createDbReminderDispatchRepository
|
createDbReminderDispatchRepository,
|
||||||
|
createDbTelegramPendingActionRepository
|
||||||
} from '@household/adapters-db'
|
} from '@household/adapters-db'
|
||||||
import { configureLogger, getLogger } from '@household/observability'
|
import { configureLogger, getLogger } from '@household/observability'
|
||||||
|
|
||||||
@@ -66,6 +67,10 @@ const householdOnboardingService = householdConfigurationRepositoryClient
|
|||||||
const anonymousFeedbackRepositoryClient = runtime.anonymousFeedbackEnabled
|
const anonymousFeedbackRepositoryClient = runtime.anonymousFeedbackEnabled
|
||||||
? createDbAnonymousFeedbackRepository(runtime.databaseUrl!, runtime.householdId!)
|
? createDbAnonymousFeedbackRepository(runtime.databaseUrl!, runtime.householdId!)
|
||||||
: null
|
: null
|
||||||
|
const telegramPendingActionRepositoryClient =
|
||||||
|
runtime.databaseUrl && runtime.anonymousFeedbackEnabled
|
||||||
|
? createDbTelegramPendingActionRepository(runtime.databaseUrl!)
|
||||||
|
: null
|
||||||
const anonymousFeedbackService = anonymousFeedbackRepositoryClient
|
const anonymousFeedbackService = anonymousFeedbackRepositoryClient
|
||||||
? createAnonymousFeedbackService(anonymousFeedbackRepositoryClient.repository)
|
? createAnonymousFeedbackService(anonymousFeedbackRepositoryClient.repository)
|
||||||
: null
|
: null
|
||||||
@@ -82,6 +87,10 @@ if (anonymousFeedbackRepositoryClient) {
|
|||||||
shutdownTasks.push(anonymousFeedbackRepositoryClient.close)
|
shutdownTasks.push(anonymousFeedbackRepositoryClient.close)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (telegramPendingActionRepositoryClient) {
|
||||||
|
shutdownTasks.push(telegramPendingActionRepositoryClient.close)
|
||||||
|
}
|
||||||
|
|
||||||
if (runtime.databaseUrl && householdConfigurationRepositoryClient) {
|
if (runtime.databaseUrl && householdConfigurationRepositoryClient) {
|
||||||
const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!)
|
const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!)
|
||||||
shutdownTasks.push(purchaseRepositoryClient.close)
|
shutdownTasks.push(purchaseRepositoryClient.close)
|
||||||
@@ -175,6 +184,7 @@ if (anonymousFeedbackService) {
|
|||||||
registerAnonymousFeedback({
|
registerAnonymousFeedback({
|
||||||
bot,
|
bot,
|
||||||
anonymousFeedbackService,
|
anonymousFeedbackService,
|
||||||
|
promptRepository: telegramPendingActionRepositoryClient!.repository,
|
||||||
householdChatId: runtime.telegramHouseholdChatId!,
|
householdChatId: runtime.telegramHouseholdChatId!,
|
||||||
feedbackTopicId: runtime.telegramFeedbackTopicId!,
|
feedbackTopicId: runtime.telegramFeedbackTopicId!,
|
||||||
logger: getLogger('anonymous-feedback')
|
logger: getLogger('anonymous-feedback')
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export { createDbAnonymousFeedbackRepository } from './anonymous-feedback-reposi
|
|||||||
export { createDbFinanceRepository } from './finance-repository'
|
export { createDbFinanceRepository } from './finance-repository'
|
||||||
export { createDbHouseholdConfigurationRepository } from './household-config-repository'
|
export { createDbHouseholdConfigurationRepository } from './household-config-repository'
|
||||||
export { createDbReminderDispatchRepository } from './reminder-dispatch-repository'
|
export { createDbReminderDispatchRepository } from './reminder-dispatch-repository'
|
||||||
|
export { createDbTelegramPendingActionRepository } from './telegram-pending-action-repository'
|
||||||
|
|||||||
144
packages/adapters-db/src/telegram-pending-action-repository.ts
Normal file
144
packages/adapters-db/src/telegram-pending-action-repository.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { and, eq } from 'drizzle-orm'
|
||||||
|
|
||||||
|
import { createDbClient, schema } from '@household/db'
|
||||||
|
import type {
|
||||||
|
TelegramPendingActionRecord,
|
||||||
|
TelegramPendingActionRepository,
|
||||||
|
TelegramPendingActionType
|
||||||
|
} from '@household/ports'
|
||||||
|
|
||||||
|
function parsePendingActionType(raw: string): TelegramPendingActionType {
|
||||||
|
if (raw === 'anonymous_feedback') {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected telegram pending action type: ${raw}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPendingAction(row: {
|
||||||
|
telegramUserId: string
|
||||||
|
telegramChatId: string
|
||||||
|
action: string
|
||||||
|
payload: unknown
|
||||||
|
expiresAt: Date | null
|
||||||
|
}): TelegramPendingActionRecord {
|
||||||
|
return {
|
||||||
|
telegramUserId: row.telegramUserId,
|
||||||
|
telegramChatId: row.telegramChatId,
|
||||||
|
action: parsePendingActionType(row.action),
|
||||||
|
payload:
|
||||||
|
row.payload && typeof row.payload === 'object' && !Array.isArray(row.payload)
|
||||||
|
? (row.payload as Record<string, unknown>)
|
||||||
|
: {},
|
||||||
|
expiresAt: row.expiresAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDbTelegramPendingActionRepository(databaseUrl: string): {
|
||||||
|
repository: TelegramPendingActionRepository
|
||||||
|
close: () => Promise<void>
|
||||||
|
} {
|
||||||
|
const { db, queryClient } = createDbClient(databaseUrl, {
|
||||||
|
max: 5,
|
||||||
|
prepare: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const repository: TelegramPendingActionRepository = {
|
||||||
|
async upsertPendingAction(input) {
|
||||||
|
const rows = await db
|
||||||
|
.insert(schema.telegramPendingActions)
|
||||||
|
.values({
|
||||||
|
telegramUserId: input.telegramUserId,
|
||||||
|
telegramChatId: input.telegramChatId,
|
||||||
|
action: input.action,
|
||||||
|
payload: input.payload,
|
||||||
|
expiresAt: input.expiresAt,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
schema.telegramPendingActions.telegramChatId,
|
||||||
|
schema.telegramPendingActions.telegramUserId
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
action: input.action,
|
||||||
|
payload: input.payload,
|
||||||
|
expiresAt: input.expiresAt,
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
telegramUserId: schema.telegramPendingActions.telegramUserId,
|
||||||
|
telegramChatId: schema.telegramPendingActions.telegramChatId,
|
||||||
|
action: schema.telegramPendingActions.action,
|
||||||
|
payload: schema.telegramPendingActions.payload,
|
||||||
|
expiresAt: schema.telegramPendingActions.expiresAt
|
||||||
|
})
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
if (!row) {
|
||||||
|
throw new Error('Pending action upsert did not return a row')
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapPendingAction(row)
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPendingAction(telegramChatId, telegramUserId) {
|
||||||
|
const now = new Date()
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
telegramUserId: schema.telegramPendingActions.telegramUserId,
|
||||||
|
telegramChatId: schema.telegramPendingActions.telegramChatId,
|
||||||
|
action: schema.telegramPendingActions.action,
|
||||||
|
payload: schema.telegramPendingActions.payload,
|
||||||
|
expiresAt: schema.telegramPendingActions.expiresAt
|
||||||
|
})
|
||||||
|
.from(schema.telegramPendingActions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.telegramPendingActions.telegramChatId, telegramChatId),
|
||||||
|
eq(schema.telegramPendingActions.telegramUserId, telegramUserId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
if (!row) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.expiresAt && row.expiresAt.getTime() <= now.getTime()) {
|
||||||
|
await db
|
||||||
|
.delete(schema.telegramPendingActions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.telegramPendingActions.telegramChatId, telegramChatId),
|
||||||
|
eq(schema.telegramPendingActions.telegramUserId, telegramUserId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapPendingAction(row)
|
||||||
|
},
|
||||||
|
|
||||||
|
async clearPendingAction(telegramChatId, telegramUserId) {
|
||||||
|
await db
|
||||||
|
.delete(schema.telegramPendingActions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.telegramPendingActions.telegramChatId, telegramChatId),
|
||||||
|
eq(schema.telegramPendingActions.telegramUserId, telegramUserId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
repository,
|
||||||
|
close: async () => {
|
||||||
|
await queryClient.end({ timeout: 5 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
packages/db/drizzle/0007_sudden_murmur.sql
Normal file
13
packages/db/drizzle/0007_sudden_murmur.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE "telegram_pending_actions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"telegram_user_id" text NOT NULL,
|
||||||
|
"telegram_chat_id" text NOT NULL,
|
||||||
|
"action" text NOT NULL,
|
||||||
|
"payload" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "telegram_pending_actions_chat_user_unique" ON "telegram_pending_actions" USING btree ("telegram_chat_id","telegram_user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "telegram_pending_actions_user_action_idx" ON "telegram_pending_actions" USING btree ("telegram_user_id","action");
|
||||||
@@ -50,6 +50,13 @@
|
|||||||
"when": 1773015092441,
|
"when": 1773015092441,
|
||||||
"tag": "0006_marvelous_nehzno",
|
"tag": "0006_marvelous_nehzno",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773051000000,
|
||||||
|
"tag": "0007_sudden_murmur",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,32 @@ export const householdPendingMembers = pgTable(
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const telegramPendingActions = pgTable(
|
||||||
|
'telegram_pending_actions',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
telegramUserId: text('telegram_user_id').notNull(),
|
||||||
|
telegramChatId: text('telegram_chat_id').notNull(),
|
||||||
|
action: text('action').notNull(),
|
||||||
|
payload: jsonb('payload')
|
||||||
|
.default(sql`'{}'::jsonb`)
|
||||||
|
.notNull(),
|
||||||
|
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
chatUserUnique: uniqueIndex('telegram_pending_actions_chat_user_unique').on(
|
||||||
|
table.telegramChatId,
|
||||||
|
table.telegramUserId
|
||||||
|
),
|
||||||
|
userActionIdx: index('telegram_pending_actions_user_action_idx').on(
|
||||||
|
table.telegramUserId,
|
||||||
|
table.action
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
export const members = pgTable(
|
export const members = pgTable(
|
||||||
'members',
|
'members',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -35,3 +35,9 @@ export type {
|
|||||||
SettlementSnapshotLineRecord,
|
SettlementSnapshotLineRecord,
|
||||||
SettlementSnapshotRecord
|
SettlementSnapshotRecord
|
||||||
} from './finance'
|
} from './finance'
|
||||||
|
export {
|
||||||
|
TELEGRAM_PENDING_ACTION_TYPES,
|
||||||
|
type TelegramPendingActionRecord,
|
||||||
|
type TelegramPendingActionRepository,
|
||||||
|
type TelegramPendingActionType
|
||||||
|
} from './telegram-pending-actions'
|
||||||
|
|||||||
20
packages/ports/src/telegram-pending-actions.ts
Normal file
20
packages/ports/src/telegram-pending-actions.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export const TELEGRAM_PENDING_ACTION_TYPES = ['anonymous_feedback'] as const
|
||||||
|
|
||||||
|
export type TelegramPendingActionType = (typeof TELEGRAM_PENDING_ACTION_TYPES)[number]
|
||||||
|
|
||||||
|
export interface TelegramPendingActionRecord {
|
||||||
|
telegramUserId: string
|
||||||
|
telegramChatId: string
|
||||||
|
action: TelegramPendingActionType
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
expiresAt: Date | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelegramPendingActionRepository {
|
||||||
|
upsertPendingAction(input: TelegramPendingActionRecord): Promise<TelegramPendingActionRecord>
|
||||||
|
getPendingAction(
|
||||||
|
telegramChatId: string,
|
||||||
|
telegramUserId: string
|
||||||
|
): Promise<TelegramPendingActionRecord | null>
|
||||||
|
clearPendingAction(telegramChatId: string, telegramUserId: string): Promise<void>
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user