Fix topic message history persistence

This commit is contained in:
2026-03-12 19:21:37 +04:00
parent 23faeef738
commit b7fa489d24
7 changed files with 420 additions and 52 deletions

View File

@@ -1736,7 +1736,7 @@ Confirm or cancel below.`,
})
})
test('loads persisted thread and same-day chat history for memory-style prompts', async () => {
test('loads persisted thread and same-day chat history including bot replies', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
const topicMessageHistoryRepository = createTopicMessageHistoryRepository()
@@ -1800,9 +1800,12 @@ Confirm or cancel below.`,
await bot.handleUpdate(topicMessageUpdate('I think we need a TV in the house') as never)
await bot.handleUpdate(topicMessageUpdate('Bot, do you remember what we said today?') as never)
await bot.handleUpdate(topicMessageUpdate('Bot, do you remember what you answered?') as never)
expect(recentThreadTexts).toContain('I think we need a TV in the house')
expect(recentThreadTexts).toContain('Yes. You were discussing a TV for the house.')
expect(sameDayTexts).toContain('I think we need a TV in the house')
expect(sameDayTexts).toContain('Yes. You were discussing a TV for the house.')
expect(calls.at(-1)).toMatchObject({
method: 'sendMessage',
payload: {

View File

@@ -40,8 +40,11 @@ import {
} from './topic-message-router'
import {
historyRecordToTurn,
persistTopicHistoryMessage,
shouldLoadExpandedChatHistory,
startOfCurrentDayInTimezone
startOfCurrentDayInTimezone,
telegramMessageIdFromMessage,
telegramMessageSentAtFromMessage
} from './topic-history'
import { startTypingIndicator } from './telegram-chat-action'
import { stripExplicitBotMention } from './telegram-mentions'
@@ -392,12 +395,8 @@ async function persistIncomingTopicMessage(input: {
rawText: string
messageSentAt: ReturnType<typeof currentMessageSentAt>
}) {
const normalizedText = input.rawText.trim()
if (!input.repository || normalizedText.length === 0) {
return
}
await input.repository.saveMessage({
await persistTopicHistoryMessage({
repository: input.repository,
householdId: input.householdId,
telegramChatId: input.telegramChatId,
telegramThreadId: input.telegramThreadId,
@@ -406,11 +405,39 @@ async function persistIncomingTopicMessage(input: {
senderTelegramUserId: input.senderTelegramUserId,
senderDisplayName: input.senderDisplayName,
isBot: false,
rawText: normalizedText,
rawText: input.rawText,
messageSentAt: input.messageSentAt
})
}
async function replyAndPersistTopicMessage(input: {
ctx: Context
repository: TopicMessageHistoryRepository | undefined
householdId: string
telegramChatId: string
telegramThreadId: string | null
text: string
replyOptions?: Parameters<Context['reply']>[1]
}) {
const reply = await input.ctx.reply(input.text, input.replyOptions)
await persistTopicHistoryMessage({
repository: input.repository,
householdId: input.householdId,
telegramChatId: input.telegramChatId,
telegramThreadId: input.telegramThreadId,
telegramMessageId: telegramMessageIdFromMessage(reply),
telegramUpdateId: null,
senderTelegramUserId: input.ctx.me?.id?.toString() ?? null,
senderDisplayName: null,
isBot: true,
rawText: input.text,
messageSentAt: telegramMessageSentAtFromMessage(reply)
})
return reply
}
async function routeGroupAssistantMessage(input: {
router: TopicMessageRouter | undefined
locale: BotLocale
@@ -582,6 +609,8 @@ async function replyWithAssistant(input: {
memoryStore: AssistantConversationMemoryStore
usageTracker: AssistantUsageTracker
logger: Logger | undefined
topicMessageHistoryRepository?: TopicMessageHistoryRepository
telegramThreadId: string | null
recentThreadMessages: readonly {
role: 'user' | 'assistant'
speaker: string
@@ -598,6 +627,18 @@ async function replyWithAssistant(input: {
const t = getBotTranslations(input.locale).assistant
if (!input.assistant) {
if (input.topicMessageHistoryRepository) {
await replyAndPersistTopicMessage({
ctx: input.ctx,
repository: input.topicMessageHistoryRepository,
householdId: input.householdId,
telegramChatId: input.telegramChatId,
telegramThreadId: input.telegramThreadId,
text: t.unavailable
})
return
}
await input.ctx.reply(t.unavailable)
return
}
@@ -672,6 +713,18 @@ async function replyWithAssistant(input: {
'Assistant reply generated'
)
if (input.topicMessageHistoryRepository) {
await replyAndPersistTopicMessage({
ctx: input.ctx,
repository: input.topicMessageHistoryRepository,
householdId: input.householdId,
telegramChatId: input.telegramChatId,
telegramThreadId: input.telegramThreadId,
text: reply.text
})
return
}
await input.ctx.reply(reply.text)
} catch (error) {
input.logger?.error(
@@ -688,6 +741,18 @@ async function replyWithAssistant(input: {
},
'Assistant reply failed'
)
if (input.topicMessageHistoryRepository) {
await replyAndPersistTopicMessage({
ctx: input.ctx,
repository: input.topicMessageHistoryRepository,
householdId: input.householdId,
telegramChatId: input.telegramChatId,
telegramThreadId: input.telegramThreadId,
text: t.unavailable
})
return
}
await input.ctx.reply(t.unavailable)
} finally {
typingIndicator.stop()
@@ -1230,6 +1295,7 @@ export function registerDmAssistant(options: {
memoryStore: options.memoryStore,
usageTracker: options.usageTracker,
logger: options.logger,
telegramThreadId: null,
recentThreadMessages: [],
sameDayChatMessages: []
})
@@ -1389,7 +1455,14 @@ export function registerDmAssistant(options: {
role: 'assistant',
text: route.replyText
})
await ctx.reply(route.replyText)
await replyAndPersistTopicMessage({
ctx,
repository: options.topicMessageHistoryRepository,
householdId: household.householdId,
telegramChatId,
telegramThreadId,
text: route.replyText
})
}
return
}
@@ -1429,14 +1502,29 @@ export function registerDmAssistant(options: {
null
)
await ctx.reply(purchaseText, {
reply_markup: purchaseProposalReplyMarkup(locale, purchaseResult.purchaseMessageId)
await replyAndPersistTopicMessage({
ctx,
repository: options.topicMessageHistoryRepository,
householdId: household.householdId,
telegramChatId,
telegramThreadId,
text: purchaseText,
replyOptions: {
reply_markup: purchaseProposalReplyMarkup(locale, purchaseResult.purchaseMessageId)
}
})
return
}
if (purchaseResult.status === 'clarification_needed') {
await ctx.reply(buildPurchaseClarificationText(locale, purchaseResult))
await replyAndPersistTopicMessage({
ctx,
repository: options.topicMessageHistoryRepository,
householdId: household.householdId,
telegramChatId,
telegramThreadId,
text: buildPurchaseClarificationText(locale, purchaseResult)
})
return
}
}
@@ -1451,7 +1539,14 @@ export function registerDmAssistant(options: {
const t = getBotTranslations(locale).assistant
if (!rateLimit.allowed) {
await ctx.reply(t.rateLimited(formatRetryDelay(locale, rateLimit.retryAfterMs)))
await replyAndPersistTopicMessage({
ctx,
repository: options.topicMessageHistoryRepository,
householdId: household.householdId,
telegramChatId,
telegramThreadId,
text: t.rateLimited(formatRetryDelay(locale, rateLimit.retryAfterMs))
})
return
}
@@ -1464,7 +1559,14 @@ export function registerDmAssistant(options: {
})
if (paymentBalanceReply) {
await ctx.reply(formatPaymentBalanceReplyText(locale, paymentBalanceReply))
await replyAndPersistTopicMessage({
ctx,
repository: options.topicMessageHistoryRepository,
householdId: household.householdId,
telegramChatId,
telegramThreadId,
text: formatPaymentBalanceReplyText(locale, paymentBalanceReply)
})
return
}
@@ -1488,7 +1590,14 @@ export function registerDmAssistant(options: {
text: memberInsightReply
})
await ctx.reply(memberInsightReply)
await replyAndPersistTopicMessage({
ctx,
repository: options.topicMessageHistoryRepository,
householdId: household.householdId,
telegramChatId,
telegramThreadId,
text: memberInsightReply
})
return
}
@@ -1508,6 +1617,12 @@ export function registerDmAssistant(options: {
memoryStore: options.memoryStore,
usageTracker: options.usageTracker,
logger: options.logger,
telegramThreadId,
...(options.topicMessageHistoryRepository
? {
topicMessageHistoryRepository: options.topicMessageHistoryRepository
}
: {}),
recentThreadMessages,
sameDayChatMessages: await listExpandedChatMessages({
repository: options.topicMessageHistoryRepository,

View File

@@ -26,7 +26,12 @@ import {
looksLikeDirectBotAddress,
type TopicMessageRouter
} from './topic-message-router'
import { historyRecordToTurn } from './topic-history'
import {
historyRecordToTurn,
persistTopicHistoryMessage,
telegramMessageIdFromMessage,
telegramMessageSentAtFromMessage
} from './topic-history'
import { stripExplicitBotMention } from './telegram-mentions'
const PAYMENT_TOPIC_CONFIRM_CALLBACK_PREFIX = 'payment_topic:confirm:'
@@ -239,11 +244,8 @@ async function persistIncomingTopicMessage(
repository: TopicMessageHistoryRepository | undefined,
record: PaymentTopicRecord
) {
if (!repository || record.rawText.trim().length === 0) {
return
}
await repository.saveMessage({
await persistTopicHistoryMessage({
repository,
householdId: record.householdId,
telegramChatId: record.chatId,
telegramThreadId: record.threadId,
@@ -252,7 +254,7 @@ async function persistIncomingTopicMessage(
senderTelegramUserId: record.senderTelegramUserId,
senderDisplayName: null,
isBot: false,
rawText: record.rawText.trim(),
rawText: record.rawText,
messageSentAt: record.messageSentAt
})
}
@@ -397,14 +399,18 @@ function paymentProposalReplyMarkup(locale: BotLocale, proposalId: string) {
async function replyToPaymentMessage(
ctx: Context,
text: string,
replyMarkup?: { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> }
replyMarkup?: { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> },
history?: {
repository: TopicMessageHistoryRepository | undefined
record: PaymentTopicRecord
}
): Promise<void> {
const message = ctx.msg
if (!message) {
return
}
await ctx.reply(text, {
const reply = await ctx.reply(text, {
reply_parameters: {
message_id: message.message_id
},
@@ -414,6 +420,20 @@ async function replyToPaymentMessage(
}
: {})
})
await persistTopicHistoryMessage({
repository: history?.repository,
householdId: history?.record.householdId ?? '',
telegramChatId: history?.record.chatId ?? '',
telegramThreadId: history?.record.threadId ?? null,
telegramMessageId: telegramMessageIdFromMessage(reply),
telegramUpdateId: null,
senderTelegramUserId: ctx.me?.id?.toString() ?? null,
senderDisplayName: null,
isBot: true,
rawText: text,
messageSentAt: telegramMessageSentAtFromMessage(reply)
})
}
export function registerConfiguredPaymentTopicIngestion(
@@ -637,7 +657,10 @@ export function registerConfiguredPaymentTopicIngestion(
if (route.route === 'chat_reply' || route.route === 'dismiss_workflow') {
if (route.replyText) {
await replyToPaymentMessage(ctx, route.replyText)
await replyToPaymentMessage(ctx, route.replyText, undefined, {
repository: options.historyRepository,
record
})
appendConversation(options.memoryStore, record, record.rawText, route.replyText)
}
return
@@ -665,7 +688,10 @@ export function registerConfiguredPaymentTopicIngestion(
}
const helperText = formatPaymentBalanceReplyText(locale, balanceReply)
await replyToPaymentMessage(ctx, helperText)
await replyToPaymentMessage(ctx, helperText, undefined, {
repository: options.historyRepository,
record
})
appendConversation(options.memoryStore, record, record.rawText, helperText)
return
}
@@ -709,7 +735,10 @@ export function registerConfiguredPaymentTopicIngestion(
expiresAt: nowInstant().add({ milliseconds: PAYMENT_TOPIC_ACTION_TTL_MS })
})
await replyToPaymentMessage(ctx, t.clarification)
await replyToPaymentMessage(ctx, t.clarification, undefined, {
repository: options.historyRepository,
record
})
appendConversation(options.memoryStore, record, record.rawText, t.clarification)
return
}
@@ -717,13 +746,19 @@ export function registerConfiguredPaymentTopicIngestion(
await promptRepository.clearPendingAction(record.chatId, record.senderTelegramUserId)
if (proposal.status === 'unsupported_currency') {
await replyToPaymentMessage(ctx, t.unsupportedCurrency)
await replyToPaymentMessage(ctx, t.unsupportedCurrency, undefined, {
repository: options.historyRepository,
record
})
appendConversation(options.memoryStore, record, record.rawText, t.unsupportedCurrency)
return
}
if (proposal.status === 'no_balance') {
await replyToPaymentMessage(ctx, t.noBalance)
await replyToPaymentMessage(ctx, t.noBalance, undefined, {
repository: options.historyRepository,
record
})
appendConversation(options.memoryStore, record, record.rawText, t.noBalance)
return
}
@@ -754,7 +789,11 @@ export function registerConfiguredPaymentTopicIngestion(
await replyToPaymentMessage(
ctx,
proposalText,
paymentProposalReplyMarkup(locale, proposal.payload.proposalId)
paymentProposalReplyMarkup(locale, proposal.payload.proposalId),
{
repository: options.historyRepository,
record
}
)
appendConversation(options.memoryStore, record, record.rawText, proposalText)
}

View File

@@ -1,4 +1,10 @@
import { instantFromEpochSeconds, instantToDate, Money, type Instant } from '@household/domain'
import {
instantFromEpochSeconds,
instantToDate,
Money,
nowInstant,
type Instant
} from '@household/domain'
import { and, desc, eq } from 'drizzle-orm'
import type { Bot, Context } from 'grammy'
import type { Logger } from '@household/observability'
@@ -23,7 +29,12 @@ import {
type TopicMessageRouter,
type TopicMessageRoutingResult
} from './topic-message-router'
import { historyRecordToTurn } from './topic-history'
import {
historyRecordToTurn,
persistTopicHistoryMessage,
telegramMessageIdFromMessage,
telegramMessageSentAtFromMessage
} from './topic-history'
import { startTypingIndicator } from './telegram-chat-action'
import { stripExplicitBotMention } from './telegram-mentions'
@@ -435,6 +446,10 @@ async function replyToPurchaseMessage(
callback_data: string
}>
>
},
history?: {
repository: TopicMessageHistoryRepository | undefined
record: PurchaseTopicRecord
}
): Promise<void> {
const message = ctx.msg
@@ -442,7 +457,7 @@ async function replyToPurchaseMessage(
return
}
await ctx.reply(text, {
const reply = await ctx.reply(text, {
reply_parameters: {
message_id: message.message_id
},
@@ -452,6 +467,20 @@ async function replyToPurchaseMessage(
}
: {})
})
await persistTopicHistoryMessage({
repository: history?.repository,
householdId: history?.record.householdId ?? '',
telegramChatId: history?.record.chatId ?? '',
telegramThreadId: history?.record.threadId ?? null,
telegramMessageId: telegramMessageIdFromMessage(reply),
telegramUpdateId: null,
senderTelegramUserId: ctx.me?.id?.toString() ?? null,
senderDisplayName: null,
isBot: true,
rawText: text,
messageSentAt: telegramMessageSentAtFromMessage(reply)
})
}
interface PendingPurchaseReply {
@@ -511,6 +540,10 @@ async function finalizePurchaseReply(
callback_data: string
}>
>
},
history?: {
repository: TopicMessageHistoryRepository | undefined
record: PurchaseTopicRecord
}
): Promise<void> {
if (!text) {
@@ -524,7 +557,7 @@ async function finalizePurchaseReply(
}
if (!pendingReply) {
await replyToPurchaseMessage(ctx, text, replyMarkup)
await replyToPurchaseMessage(ctx, text, replyMarkup, history)
return
}
@@ -535,8 +568,22 @@ async function finalizePurchaseReply(
text,
replyMarkup ? { reply_markup: replyMarkup } : {}
)
await persistTopicHistoryMessage({
repository: history?.repository,
householdId: history?.record.householdId ?? '',
telegramChatId: history?.record.chatId ?? '',
telegramThreadId: history?.record.threadId ?? null,
telegramMessageId: pendingReply.messageId.toString(),
telegramUpdateId: null,
senderTelegramUserId: ctx.me?.id?.toString() ?? null,
senderDisplayName: null,
isBot: true,
rawText: text,
messageSentAt: nowInstant()
})
} catch {
await replyToPurchaseMessage(ctx, text, replyMarkup)
await replyToPurchaseMessage(ctx, text, replyMarkup, history)
}
}
@@ -1500,11 +1547,8 @@ async function persistIncomingTopicMessage(
repository: TopicMessageHistoryRepository | undefined,
record: PurchaseTopicRecord
) {
if (!repository || record.rawText.trim().length === 0) {
return
}
await repository.saveMessage({
await persistTopicHistoryMessage({
repository,
householdId: record.householdId,
telegramChatId: record.chatId,
telegramThreadId: record.threadId,
@@ -1513,7 +1557,7 @@ async function persistIncomingTopicMessage(
senderTelegramUserId: record.senderTelegramUserId,
senderDisplayName: record.senderDisplayName ?? null,
isBot: false,
rawText: record.rawText.trim(),
rawText: record.rawText,
messageSentAt: record.messageSentAt
})
}
@@ -1612,7 +1656,8 @@ async function handlePurchaseMessageResult(
result: PurchaseMessageIngestionResult,
locale: BotLocale,
logger: Logger | undefined,
pendingReply: PendingPurchaseReply | null = null
pendingReply: PendingPurchaseReply | null = null,
historyRepository?: TopicMessageHistoryRepository
): Promise<void> {
if (result.status !== 'duplicate') {
logger?.info(
@@ -1644,6 +1689,12 @@ async function handlePurchaseMessageResult(
result.purchaseMessageId,
result.participants
)
: undefined,
historyRepository
? {
repository: historyRepository,
record
}
: undefined
)
}
@@ -1983,7 +2034,10 @@ export function registerPurchaseTopicIngestion(
if (route.route === 'chat_reply' || route.route === 'dismiss_workflow') {
rememberUserTurn(options.memoryStore, record)
if (route.replyText) {
await replyToPurchaseMessage(ctx, route.replyText)
await replyToPurchaseMessage(ctx, route.replyText, undefined, {
repository: options.historyRepository,
record
})
rememberAssistantTurn(options.memoryStore, record, route.replyText)
}
return
@@ -2012,7 +2066,15 @@ export function registerPurchaseTopicIngestion(
if (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,
options.historyRepository
)
rememberAssistantTurn(options.memoryStore, record, buildPurchaseAcknowledgement(result, 'en'))
} catch (error) {
options.logger?.error(
@@ -2114,7 +2176,10 @@ export function registerConfiguredPurchaseTopicIngestion(
if (route.route === 'chat_reply' || route.route === 'dismiss_workflow') {
rememberUserTurn(options.memoryStore, record)
if (route.replyText) {
await replyToPurchaseMessage(ctx, route.replyText)
await replyToPurchaseMessage(ctx, route.replyText, undefined, {
repository: options.historyRepository,
record
})
rememberAssistantTurn(options.memoryStore, record, route.replyText)
}
return
@@ -2151,7 +2216,15 @@ export function registerConfiguredPurchaseTopicIngestion(
return await next()
}
await handlePurchaseMessageResult(ctx, record, result, locale, options.logger, pendingReply)
await handlePurchaseMessageResult(
ctx,
record,
result,
locale,
options.logger,
pendingReply,
options.historyRepository
)
rememberAssistantTurn(
options.memoryStore,
record,

View File

@@ -1,5 +1,5 @@
import { nowInstant, Temporal, type Instant } from '@household/domain'
import type { TopicMessageHistoryRecord } from '@household/ports'
import { instantFromEpochSeconds, nowInstant, Temporal, type Instant } from '@household/domain'
import type { TopicMessageHistoryRecord, TopicMessageHistoryRepository } from '@household/ports'
export interface TopicHistoryTurn {
role: 'user' | 'assistant'
@@ -45,6 +45,50 @@ export function historyRecordToTurn(record: TopicMessageHistoryRecord): TopicHis
}
}
export function telegramMessageIdFromMessage(
message: { message_id?: number } | null | undefined
): string | null {
return typeof message?.message_id === 'number' ? message.message_id.toString() : null
}
export function telegramMessageSentAtFromMessage(
message: { date?: number } | null | undefined
): Instant | null {
return typeof message?.date === 'number' ? instantFromEpochSeconds(message.date) : null
}
export async function persistTopicHistoryMessage(input: {
repository: TopicMessageHistoryRepository | undefined
householdId: string
telegramChatId: string
telegramThreadId: string | null
telegramMessageId: string | null
telegramUpdateId: string | null
senderTelegramUserId: string | null
senderDisplayName: string | null
isBot: boolean
rawText: string
messageSentAt: Instant | null
}) {
const normalizedText = input.rawText.trim()
if (!input.repository || normalizedText.length === 0) {
return
}
await input.repository.saveMessage({
householdId: input.householdId,
telegramChatId: input.telegramChatId,
telegramThreadId: input.telegramThreadId,
telegramMessageId: input.telegramMessageId,
telegramUpdateId: input.telegramUpdateId,
senderTelegramUserId: input.senderTelegramUserId,
senderDisplayName: input.senderDisplayName,
isBot: input.isBot,
rawText: normalizedText,
messageSentAt: input.messageSentAt
})
}
export function formatThreadHistory(turns: readonly TopicHistoryTurn[]): string | null {
const lines = turns
.map((turn) => `${turn.speaker} (${turn.role}): ${turn.text}`)