mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 13:54:02 +00:00
feat(bot): support tagged assistant replies in topics
This commit is contained in:
@@ -58,6 +58,29 @@ function privateMessageUpdate(text: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function topicMentionUpdate(text: string) {
|
||||||
|
return {
|
||||||
|
update_id: 3001,
|
||||||
|
message: {
|
||||||
|
message_id: 88,
|
||||||
|
date: Math.floor(Date.now() / 1000),
|
||||||
|
message_thread_id: 777,
|
||||||
|
is_topic_message: true,
|
||||||
|
chat: {
|
||||||
|
id: -100123,
|
||||||
|
type: 'supergroup'
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
id: 123456,
|
||||||
|
is_bot: false,
|
||||||
|
first_name: 'Stan',
|
||||||
|
language_code: 'en'
|
||||||
|
},
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function privateCallbackUpdate(data: string) {
|
function privateCallbackUpdate(data: string) {
|
||||||
return {
|
return {
|
||||||
update_id: 2002,
|
update_id: 2002,
|
||||||
@@ -943,6 +966,86 @@ describe('registerDmAssistant', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('replies as the general assistant when explicitly mentioned in a household topic', async () => {
|
||||||
|
const bot = createTestBot()
|
||||||
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
let assistantCalls = 0
|
||||||
|
|
||||||
|
bot.api.config.use(async (_prev, method, payload) => {
|
||||||
|
calls.push({ method, payload })
|
||||||
|
|
||||||
|
if (method === 'sendMessage') {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
message_id: calls.length,
|
||||||
|
date: Math.floor(Date.now() / 1000),
|
||||||
|
chat: {
|
||||||
|
id: -100123,
|
||||||
|
type: 'supergroup'
|
||||||
|
},
|
||||||
|
text: (payload as { text?: string }).text ?? 'ok'
|
||||||
|
}
|
||||||
|
} as never
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
result: true
|
||||||
|
} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
registerDmAssistant({
|
||||||
|
bot,
|
||||||
|
assistant: {
|
||||||
|
async respond(input) {
|
||||||
|
assistantCalls += 1
|
||||||
|
expect(input.userMessage).toBe('how is life?')
|
||||||
|
return {
|
||||||
|
text: 'Still standing.',
|
||||||
|
usage: {
|
||||||
|
inputTokens: 15,
|
||||||
|
outputTokens: 4,
|
||||||
|
totalTokens: 19
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
householdConfigurationRepository: createHouseholdRepository(),
|
||||||
|
promptRepository: createPromptRepository(),
|
||||||
|
financeServiceForHousehold: () => createFinanceService(),
|
||||||
|
memoryStore: createInMemoryAssistantConversationMemoryStore(12),
|
||||||
|
rateLimiter: createInMemoryAssistantRateLimiter({
|
||||||
|
burstLimit: 5,
|
||||||
|
burstWindowMs: 60_000,
|
||||||
|
rollingLimit: 50,
|
||||||
|
rollingWindowMs: 86_400_000
|
||||||
|
}),
|
||||||
|
usageTracker: createInMemoryAssistantUsageTracker()
|
||||||
|
})
|
||||||
|
|
||||||
|
await bot.handleUpdate(topicMentionUpdate('@household_test_bot how is life?') as never)
|
||||||
|
|
||||||
|
expect(assistantCalls).toBe(1)
|
||||||
|
expect(calls).toHaveLength(2)
|
||||||
|
expect(calls[0]).toMatchObject({
|
||||||
|
method: 'sendChatAction',
|
||||||
|
payload: {
|
||||||
|
chat_id: -100123,
|
||||||
|
action: 'typing',
|
||||||
|
message_thread_id: 777
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(calls[1]).toMatchObject({
|
||||||
|
method: 'sendMessage',
|
||||||
|
payload: {
|
||||||
|
chat_id: -100123,
|
||||||
|
message_thread_id: 777,
|
||||||
|
text: 'Still standing.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test('ignores duplicate deliveries of the same DM update', async () => {
|
test('ignores duplicate deliveries of the same DM update', async () => {
|
||||||
const bot = createTestBot()
|
const bot = createTestBot()
|
||||||
const calls: Array<{ method: string; payload: unknown }> = []
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
PurchaseTopicRecord
|
PurchaseTopicRecord
|
||||||
} from './purchase-topic-ingestion'
|
} from './purchase-topic-ingestion'
|
||||||
import { startTypingIndicator } from './telegram-chat-action'
|
import { startTypingIndicator } from './telegram-chat-action'
|
||||||
|
import { stripExplicitBotMention } from './telegram-mentions'
|
||||||
|
|
||||||
const ASSISTANT_PAYMENT_ACTION = 'assistant_payment_confirmation' as const
|
const ASSISTANT_PAYMENT_ACTION = 'assistant_payment_confirmation' as const
|
||||||
const ASSISTANT_PAYMENT_CONFIRM_CALLBACK_PREFIX = 'assistant_payment:confirm:'
|
const ASSISTANT_PAYMENT_CONFIRM_CALLBACK_PREFIX = 'assistant_payment:confirm:'
|
||||||
@@ -26,6 +27,7 @@ const ASSISTANT_PAYMENT_CANCEL_CALLBACK_PREFIX = 'assistant_payment:cancel:'
|
|||||||
const ASSISTANT_PURCHASE_CONFIRM_CALLBACK_PREFIX = 'assistant_purchase:confirm:'
|
const ASSISTANT_PURCHASE_CONFIRM_CALLBACK_PREFIX = 'assistant_purchase:confirm:'
|
||||||
const ASSISTANT_PURCHASE_CANCEL_CALLBACK_PREFIX = 'assistant_purchase:cancel:'
|
const ASSISTANT_PURCHASE_CANCEL_CALLBACK_PREFIX = 'assistant_purchase:cancel:'
|
||||||
const DM_ASSISTANT_MESSAGE_SOURCE = 'telegram-dm-assistant'
|
const DM_ASSISTANT_MESSAGE_SOURCE = 'telegram-dm-assistant'
|
||||||
|
const GROUP_ASSISTANT_MESSAGE_SOURCE = 'telegram-group-assistant'
|
||||||
const MEMORY_SUMMARY_MAX_CHARS = 1200
|
const MEMORY_SUMMARY_MAX_CHARS = 1200
|
||||||
const PURCHASE_VERB_PATTERN =
|
const PURCHASE_VERB_PATTERN =
|
||||||
/\b(?:bought|buy|got|picked up|spent|купил(?:а|и)?|взял(?:а|и)?|выложил(?:а|и)?|отдал(?:а|и)?|потратил(?:а|и)?)\b/iu
|
/\b(?:bought|buy|got|picked up|spent|купил(?:а|и)?|взял(?:а|и)?|выложил(?:а|и)?|отдал(?:а|и)?|потратил(?:а|и)?)\b/iu
|
||||||
@@ -106,6 +108,10 @@ function isPrivateChat(ctx: Context): boolean {
|
|||||||
return ctx.chat?.type === 'private'
|
return ctx.chat?.type === 'private'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGroupChat(ctx: Context): boolean {
|
||||||
|
return ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup'
|
||||||
|
}
|
||||||
|
|
||||||
function isCommandMessage(ctx: Context): boolean {
|
function isCommandMessage(ctx: Context): boolean {
|
||||||
return typeof ctx.msg?.text === 'string' && ctx.msg.text.trim().startsWith('/')
|
return typeof ctx.msg?.text === 'string' && ctx.msg.text.trim().startsWith('/')
|
||||||
}
|
}
|
||||||
@@ -123,6 +129,16 @@ function summarizeTurns(
|
|||||||
: next.slice(next.length - MEMORY_SUMMARY_MAX_CHARS)
|
: 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(
|
export function createInMemoryAssistantConversationMemoryStore(
|
||||||
maxTurns: number
|
maxTurns: number
|
||||||
): AssistantConversationMemoryStore {
|
): AssistantConversationMemoryStore {
|
||||||
@@ -455,6 +471,118 @@ async function buildHouseholdContext(input: {
|
|||||||
return lines.join('\n')
|
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: {
|
export function registerDmAssistant(options: {
|
||||||
bot: Bot
|
bot: Bot
|
||||||
assistant?: ConversationalAssistant
|
assistant?: ConversationalAssistant
|
||||||
@@ -741,6 +869,11 @@ export function registerDmAssistant(options: {
|
|||||||
await next()
|
await next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const memoryKey = conversationMemoryKey({
|
||||||
|
telegramUserId,
|
||||||
|
telegramChatId,
|
||||||
|
isPrivateChat: true
|
||||||
|
})
|
||||||
|
|
||||||
const memberships =
|
const memberships =
|
||||||
await options.householdConfigurationRepository.listHouseholdMembersByTelegramUserId(
|
await options.householdConfigurationRepository.listHouseholdMembersByTelegramUserId(
|
||||||
@@ -831,11 +964,11 @@ export function registerDmAssistant(options: {
|
|||||||
? buildPurchaseClarificationText(locale, purchaseResult)
|
? buildPurchaseClarificationText(locale, purchaseResult)
|
||||||
: getBotTranslations(locale).purchase.parseFailed
|
: getBotTranslations(locale).purchase.parseFailed
|
||||||
|
|
||||||
options.memoryStore.appendTurn(telegramUserId, {
|
options.memoryStore.appendTurn(memoryKey, {
|
||||||
role: 'user',
|
role: 'user',
|
||||||
text: ctx.msg.text
|
text: ctx.msg.text
|
||||||
})
|
})
|
||||||
options.memoryStore.appendTurn(telegramUserId, {
|
options.memoryStore.appendTurn(memoryKey, {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
text: purchaseText
|
text: purchaseText
|
||||||
})
|
})
|
||||||
@@ -902,11 +1035,11 @@ export function registerDmAssistant(options: {
|
|||||||
amount.toMajorString(),
|
amount.toMajorString(),
|
||||||
amount.currency
|
amount.currency
|
||||||
)
|
)
|
||||||
options.memoryStore.appendTurn(telegramUserId, {
|
options.memoryStore.appendTurn(memoryKey, {
|
||||||
role: 'user',
|
role: 'user',
|
||||||
text: ctx.msg.text
|
text: ctx.msg.text
|
||||||
})
|
})
|
||||||
options.memoryStore.appendTurn(telegramUserId, {
|
options.memoryStore.appendTurn(memoryKey, {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
text: proposalText
|
text: proposalText
|
||||||
})
|
})
|
||||||
@@ -917,93 +1050,22 @@ export function registerDmAssistant(options: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!options.assistant) {
|
await replyWithAssistant({
|
||||||
await ctx.reply(t.unavailable)
|
ctx,
|
||||||
return
|
assistant: options.assistant,
|
||||||
}
|
householdId: member.householdId,
|
||||||
|
memberId: member.id,
|
||||||
const memory = options.memoryStore.get(telegramUserId)
|
memberDisplayName: member.displayName,
|
||||||
const typingIndicator = startTypingIndicator(ctx)
|
telegramUserId,
|
||||||
const assistantStartedAt = Date.now()
|
telegramChatId,
|
||||||
let stage: 'household_context' | 'assistant_response' = 'household_context'
|
locale,
|
||||||
let contextBuildMs: number | null = null
|
userMessage: ctx.msg.text,
|
||||||
let assistantResponseMs: number | null = null
|
householdConfigurationRepository: options.householdConfigurationRepository,
|
||||||
|
financeService,
|
||||||
try {
|
memoryStore: options.memoryStore,
|
||||||
const contextStartedAt = Date.now()
|
usageTracker: options.usageTracker,
|
||||||
const householdContext = await buildHouseholdContext({
|
logger: options.logger
|
||||||
householdId: member.householdId,
|
})
|
||||||
memberId: member.id,
|
|
||||||
memberDisplayName: member.displayName,
|
|
||||||
locale,
|
|
||||||
householdConfigurationRepository: options.householdConfigurationRepository,
|
|
||||||
financeService
|
|
||||||
})
|
|
||||||
contextBuildMs = Date.now() - contextStartedAt
|
|
||||||
stage = 'assistant_response'
|
|
||||||
const assistantResponseStartedAt = Date.now()
|
|
||||||
const reply = await options.assistant.respond({
|
|
||||||
locale,
|
|
||||||
householdContext,
|
|
||||||
memorySummary: memory.summary,
|
|
||||||
recentTurns: memory.turns,
|
|
||||||
userMessage: ctx.msg.text
|
|
||||||
})
|
|
||||||
assistantResponseMs = Date.now() - assistantResponseStartedAt
|
|
||||||
|
|
||||||
options.usageTracker.record({
|
|
||||||
householdId: member.householdId,
|
|
||||||
telegramUserId,
|
|
||||||
displayName: member.displayName,
|
|
||||||
usage: reply.usage
|
|
||||||
})
|
|
||||||
options.memoryStore.appendTurn(telegramUserId, {
|
|
||||||
role: 'user',
|
|
||||||
text: ctx.msg.text
|
|
||||||
})
|
|
||||||
options.memoryStore.appendTurn(telegramUserId, {
|
|
||||||
role: 'assistant',
|
|
||||||
text: reply.text
|
|
||||||
})
|
|
||||||
|
|
||||||
options.logger?.info(
|
|
||||||
{
|
|
||||||
event: 'assistant.reply',
|
|
||||||
householdId: member.householdId,
|
|
||||||
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
|
|
||||||
},
|
|
||||||
'DM assistant reply generated'
|
|
||||||
)
|
|
||||||
|
|
||||||
await ctx.reply(reply.text)
|
|
||||||
} catch (error) {
|
|
||||||
options.logger?.error(
|
|
||||||
{
|
|
||||||
event: 'assistant.reply_failed',
|
|
||||||
householdId: member.householdId,
|
|
||||||
telegramUserId,
|
|
||||||
stage,
|
|
||||||
contextBuildMs,
|
|
||||||
assistantResponseMs,
|
|
||||||
totalDurationMs: Date.now() - assistantStartedAt,
|
|
||||||
...describeError(error),
|
|
||||||
error
|
|
||||||
},
|
|
||||||
'DM assistant reply failed'
|
|
||||||
)
|
|
||||||
await ctx.reply(t.unavailable)
|
|
||||||
} finally {
|
|
||||||
typingIndicator.stop()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (dedupeClaim) {
|
if (dedupeClaim) {
|
||||||
await dedupeClaim.repository.releaseMessage({
|
await dedupeClaim.repository.releaseMessage({
|
||||||
@@ -1016,4 +1078,108 @@ export function registerDmAssistant(options: {
|
|||||||
throw error
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -556,4 +556,90 @@ describe('registerConfiguredPaymentTopicIngestion', () => {
|
|||||||
|
|
||||||
expect(paymentConfirmationService.submitted).toHaveLength(0)
|
expect(paymentConfirmationService.submitted).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('skips explicitly tagged bot messages in the payments topic', async () => {
|
||||||
|
const bot = createTelegramBot('000000:test-token')
|
||||||
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
const promptRepository = createPromptRepository()
|
||||||
|
const paymentConfirmationService = createPaymentConfirmationService()
|
||||||
|
|
||||||
|
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: true
|
||||||
|
} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
registerConfiguredPaymentTopicIngestion(
|
||||||
|
bot,
|
||||||
|
createHouseholdRepository() as never,
|
||||||
|
promptRepository,
|
||||||
|
() => createFinanceService(),
|
||||||
|
() => paymentConfirmationService
|
||||||
|
)
|
||||||
|
|
||||||
|
await bot.handleUpdate(paymentUpdate('@household_test_bot как жизнь?') as never)
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(0)
|
||||||
|
expect(await promptRepository.getPendingAction('-10012345', '10002')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('still handles tagged payment-like messages in the payments topic', async () => {
|
||||||
|
const bot = createTelegramBot('000000:test-token')
|
||||||
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
const promptRepository = createPromptRepository()
|
||||||
|
const paymentConfirmationService = createPaymentConfirmationService()
|
||||||
|
|
||||||
|
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: true
|
||||||
|
} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
registerConfiguredPaymentTopicIngestion(
|
||||||
|
bot,
|
||||||
|
createHouseholdRepository() as never,
|
||||||
|
promptRepository,
|
||||||
|
() => createFinanceService(),
|
||||||
|
() => paymentConfirmationService
|
||||||
|
)
|
||||||
|
|
||||||
|
await bot.handleUpdate(paymentUpdate('@household_test_bot за жилье закинул') as never)
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(1)
|
||||||
|
expect(calls[0]?.payload).toMatchObject({
|
||||||
|
text: 'Я могу записать эту оплату аренды: 472.50 GEL. Подтвердите или отмените ниже.'
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
parsePaymentProposalPayload,
|
parsePaymentProposalPayload,
|
||||||
synthesizePaymentConfirmationText
|
synthesizePaymentConfirmationText
|
||||||
} from './payment-proposals'
|
} from './payment-proposals'
|
||||||
|
import { stripExplicitBotMention } from './telegram-mentions'
|
||||||
|
|
||||||
const PAYMENT_TOPIC_CONFIRM_CALLBACK_PREFIX = 'payment_topic:confirm:'
|
const PAYMENT_TOPIC_CONFIRM_CALLBACK_PREFIX = 'payment_topic:confirm:'
|
||||||
const PAYMENT_TOPIC_CANCEL_CALLBACK_PREFIX = 'payment_topic:cancel:'
|
const PAYMENT_TOPIC_CANCEL_CALLBACK_PREFIX = 'payment_topic:cancel:'
|
||||||
@@ -95,7 +96,7 @@ function attachmentCount(ctx: Context): number {
|
|||||||
|
|
||||||
function toCandidateFromContext(ctx: Context): PaymentTopicCandidate | null {
|
function toCandidateFromContext(ctx: Context): PaymentTopicCandidate | null {
|
||||||
const message = ctx.message
|
const message = ctx.message
|
||||||
const rawText = readMessageText(ctx)
|
const rawText = stripExplicitBotMention(ctx)?.strippedText ?? readMessageText(ctx)
|
||||||
if (!message || !rawText) {
|
if (!message || !rawText) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -542,6 +542,95 @@ describe('registerPurchaseTopicIngestion', () => {
|
|||||||
expect(calls).toHaveLength(0)
|
expect(calls).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('skips explicitly tagged bot messages in the purchase topic', async () => {
|
||||||
|
const bot = createTestBot()
|
||||||
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
let saveCalls = 0
|
||||||
|
|
||||||
|
bot.api.config.use(async (_prev, method, payload) => {
|
||||||
|
calls.push({ method, payload })
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
result: true
|
||||||
|
} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
const repository: PurchaseMessageIngestionRepository = {
|
||||||
|
async hasClarificationContext() {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
saveCalls += 1
|
||||||
|
return {
|
||||||
|
status: 'ignored_not_purchase' as const,
|
||||||
|
purchaseMessageId: 'ignored-1'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async confirm() {
|
||||||
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async cancel() {
|
||||||
|
throw new Error('not used')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPurchaseTopicIngestion(bot, config, repository)
|
||||||
|
await bot.handleUpdate(purchaseUpdate('@household_test_bot how is life?') as never)
|
||||||
|
|
||||||
|
expect(saveCalls).toBe(1)
|
||||||
|
expect(calls).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('still handles tagged purchase-like messages in the purchase topic', async () => {
|
||||||
|
const bot = createTestBot()
|
||||||
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
|
||||||
|
bot.api.config.use(async (_prev, method, payload) => {
|
||||||
|
calls.push({ method, payload })
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
result: true
|
||||||
|
} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
const repository: PurchaseMessageIngestionRepository = {
|
||||||
|
async hasClarificationContext() {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
async save(record) {
|
||||||
|
expect(record.rawText).toBe('Bought toilet paper 30 gel')
|
||||||
|
return {
|
||||||
|
status: 'pending_confirmation',
|
||||||
|
purchaseMessageId: 'proposal-1',
|
||||||
|
parsedAmountMinor: 3000n,
|
||||||
|
parsedCurrency: 'GEL',
|
||||||
|
parsedItemDescription: 'toilet paper',
|
||||||
|
parserConfidence: 92,
|
||||||
|
parserMode: 'llm'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async confirm() {
|
||||||
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async cancel() {
|
||||||
|
throw new Error('not used')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPurchaseTopicIngestion(bot, config, repository)
|
||||||
|
await bot.handleUpdate(
|
||||||
|
purchaseUpdate('@household_test_bot Bought toilet paper 30 gel') as never
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(1)
|
||||||
|
expect(calls[0]).toMatchObject({
|
||||||
|
method: 'sendMessage',
|
||||||
|
payload: {
|
||||||
|
text: 'I think this shared purchase was: toilet paper - 30.00 GEL. Confirm or cancel below.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test('confirms a pending proposal and edits the bot message', async () => {
|
test('confirms a pending proposal and edits the bot message', async () => {
|
||||||
const bot = createTestBot()
|
const bot = createTestBot()
|
||||||
const calls: Array<{ method: string; payload: unknown }> = []
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
PurchaseMessageInterpreter
|
PurchaseMessageInterpreter
|
||||||
} from './openai-purchase-interpreter'
|
} from './openai-purchase-interpreter'
|
||||||
import { startTypingIndicator } from './telegram-chat-action'
|
import { startTypingIndicator } from './telegram-chat-action'
|
||||||
|
import { stripExplicitBotMention } from './telegram-mentions'
|
||||||
|
|
||||||
const PURCHASE_CONFIRM_CALLBACK_PREFIX = 'purchase:confirm:'
|
const PURCHASE_CONFIRM_CALLBACK_PREFIX = 'purchase:confirm:'
|
||||||
const PURCHASE_CANCEL_CALLBACK_PREFIX = 'purchase:cancel:'
|
const PURCHASE_CANCEL_CALLBACK_PREFIX = 'purchase:cancel:'
|
||||||
@@ -392,7 +393,7 @@ function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null {
|
|||||||
messageId: message.message_id.toString(),
|
messageId: message.message_id.toString(),
|
||||||
threadId: message.message_thread_id.toString(),
|
threadId: message.message_thread_id.toString(),
|
||||||
senderTelegramUserId,
|
senderTelegramUserId,
|
||||||
rawText: message.text,
|
rawText: stripExplicitBotMention(ctx)?.strippedText ?? message.text,
|
||||||
messageSentAt: instantFromEpochSeconds(message.date)
|
messageSentAt: instantFromEpochSeconds(message.date)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1054,6 +1055,9 @@ export function registerPurchaseTopicIngestion(
|
|||||||
? await sendPurchaseProcessingReply(ctx, getBotTranslations('en').purchase.processing)
|
? await sendPurchaseProcessingReply(ctx, getBotTranslations('en').purchase.processing)
|
||||||
: null
|
: null
|
||||||
const result = await repository.save(record, options.interpreter, 'GEL')
|
const result = await repository.save(record, options.interpreter, 'GEL')
|
||||||
|
if (stripExplicitBotMention(ctx) && result.status === 'ignored_not_purchase') {
|
||||||
|
return await next()
|
||||||
|
}
|
||||||
await handlePurchaseMessageResult(ctx, record, result, 'en', options.logger, pendingReply)
|
await handlePurchaseMessageResult(ctx, record, result, 'en', options.logger, pendingReply)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
options.logger?.error(
|
options.logger?.error(
|
||||||
@@ -1130,6 +1134,9 @@ export function registerConfiguredPurchaseTopicIngestion(
|
|||||||
options.interpreter,
|
options.interpreter,
|
||||||
billingSettings.settlementCurrency
|
billingSettings.settlementCurrency
|
||||||
)
|
)
|
||||||
|
if (stripExplicitBotMention(ctx) && result.status === 'ignored_not_purchase') {
|
||||||
|
return await next()
|
||||||
|
}
|
||||||
|
|
||||||
await handlePurchaseMessageResult(ctx, record, result, locale, options.logger, pendingReply)
|
await handlePurchaseMessageResult(ctx, record, result, locale, options.logger, pendingReply)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
42
apps/bot/src/telegram-mentions.ts
Normal file
42
apps/bot/src/telegram-mentions.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Context } from 'grammy'
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageText(ctx: Pick<Context, 'msg'>): string | null {
|
||||||
|
const message = ctx.msg
|
||||||
|
if (!message || !('text' in message) || typeof message.text !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return message.text
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripExplicitBotMention(ctx: Pick<Context, 'msg' | 'me'>): {
|
||||||
|
originalText: string
|
||||||
|
strippedText: string
|
||||||
|
} | null {
|
||||||
|
const text = getMessageText(ctx)
|
||||||
|
const username = ctx.me.username
|
||||||
|
|
||||||
|
if (!text || !username) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionPattern = new RegExp(`(^|\\s)@${escapeRegExp(username)}\\b`, 'giu')
|
||||||
|
if (!mentionPattern.test(text)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
mentionPattern.lastIndex = 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
originalText: text,
|
||||||
|
strippedText: text.replace(mentionPattern, '$1').replace(/\s+/gu, ' ').trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasExplicitBotMention(ctx: Pick<Context, 'msg' | 'me'>): boolean {
|
||||||
|
return stripExplicitBotMention(ctx) !== null
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user