mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 14:24:03 +00:00
feat(bot): add configurable household assistant behavior
This commit is contained in:
@@ -59,7 +59,12 @@ function privateMessageUpdate(text: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function topicMentionUpdate(text: string) {
|
||||
function topicMessageUpdate(
|
||||
text: string,
|
||||
options?: {
|
||||
replyToBot?: boolean
|
||||
}
|
||||
) {
|
||||
return {
|
||||
update_id: 3001,
|
||||
message: {
|
||||
@@ -77,11 +82,34 @@ function topicMentionUpdate(text: string) {
|
||||
first_name: 'Stan',
|
||||
language_code: 'en'
|
||||
},
|
||||
text
|
||||
text,
|
||||
...(options?.replyToBot
|
||||
? {
|
||||
reply_to_message: {
|
||||
message_id: 87,
|
||||
date: Math.floor(Date.now() / 1000),
|
||||
chat: {
|
||||
id: -100123,
|
||||
type: 'supergroup'
|
||||
},
|
||||
from: {
|
||||
id: 999000,
|
||||
is_bot: true,
|
||||
first_name: 'Household Test Bot',
|
||||
username: 'household_test_bot'
|
||||
},
|
||||
text: 'previous bot reply'
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function topicMentionUpdate(text: string) {
|
||||
return topicMessageUpdate(text)
|
||||
}
|
||||
|
||||
function privateCallbackUpdate(data: string) {
|
||||
return {
|
||||
update_id: 2002,
|
||||
@@ -1212,6 +1240,241 @@ Confirm or cancel below.`,
|
||||
})
|
||||
})
|
||||
|
||||
test('stays silent for regular group chatter when the bot is not addressed', 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() {
|
||||
assistantCalls += 1
|
||||
return {
|
||||
text: 'I should not speak here.',
|
||||
usage: {
|
||||
inputTokens: 12,
|
||||
outputTokens: 5,
|
||||
totalTokens: 17
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
purchaseRepository: createPurchaseRepository(),
|
||||
purchaseInterpreter: async () => null,
|
||||
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(topicMessageUpdate('Dima is joking with Stas again') as never)
|
||||
|
||||
expect(assistantCalls).toBe(0)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('creates a purchase proposal in a household topic without an explicit mention', async () => {
|
||||
const bot = createTestBot()
|
||||
const calls: Array<{ method: string; payload: unknown }> = []
|
||||
|
||||
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() {
|
||||
return {
|
||||
text: 'fallback',
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 2,
|
||||
totalTokens: 12
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
purchaseRepository: createPurchaseRepository(),
|
||||
purchaseInterpreter: async () => null,
|
||||
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(topicMessageUpdate('I bought a door handle for 30 lari') as never)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
method: 'sendMessage',
|
||||
payload: {
|
||||
chat_id: -100123,
|
||||
message_thread_id: 777,
|
||||
text: expect.stringContaining('door handle - 30.00 GEL'),
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{
|
||||
text: 'Confirm',
|
||||
callback_data: 'assistant_purchase:confirm:purchase-1'
|
||||
},
|
||||
{
|
||||
text: 'Cancel',
|
||||
callback_data: 'assistant_purchase:cancel:purchase-1'
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('replies when a household member answers the bot message in a 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('tell me a joke')
|
||||
return {
|
||||
text: 'Rent is still due on the 20th.',
|
||||
usage: {
|
||||
inputTokens: 17,
|
||||
outputTokens: 8,
|
||||
totalTokens: 25
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
purchaseRepository: createPurchaseRepository(),
|
||||
purchaseInterpreter: async () => null,
|
||||
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(
|
||||
topicMessageUpdate('tell me a joke', {
|
||||
replyToBot: true
|
||||
}) 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: 'Rent is still due on the 20th.'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('ignores duplicate deliveries of the same DM update', async () => {
|
||||
const bot = createTestBot()
|
||||
const calls: Array<{ method: string; payload: unknown }> = []
|
||||
|
||||
@@ -123,6 +123,15 @@ function isCommandMessage(ctx: Context): boolean {
|
||||
return typeof ctx.msg?.text === 'string' && ctx.msg.text.trim().startsWith('/')
|
||||
}
|
||||
|
||||
function isReplyToBotMessage(ctx: Context): boolean {
|
||||
const replyAuthor = ctx.msg?.reply_to_message?.from
|
||||
if (!replyAuthor) {
|
||||
return false
|
||||
}
|
||||
|
||||
return replyAuthor.id === ctx.me.id
|
||||
}
|
||||
|
||||
function summarizeTurns(
|
||||
summary: string | null,
|
||||
turns: readonly AssistantConversationTurn[]
|
||||
@@ -403,6 +412,44 @@ function createDmPurchaseRecord(ctx: Context, householdId: string): PurchaseTopi
|
||||
}
|
||||
}
|
||||
|
||||
function createGroupPurchaseRecord(
|
||||
ctx: Context,
|
||||
householdId: string,
|
||||
rawText: string
|
||||
): PurchaseTopicRecord | null {
|
||||
if (!isGroupChat(ctx) || !ctx.msg || !ctx.from) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalized = rawText.trim()
|
||||
if (normalized.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const senderDisplayName = [ctx.from.first_name, ctx.from.last_name]
|
||||
.filter((part) => !!part && part.trim().length > 0)
|
||||
.join(' ')
|
||||
|
||||
return {
|
||||
updateId: ctx.update.update_id,
|
||||
householdId,
|
||||
chatId: ctx.chat!.id.toString(),
|
||||
messageId: ctx.msg.message_id.toString(),
|
||||
threadId:
|
||||
'message_thread_id' in ctx.msg && ctx.msg.message_thread_id !== undefined
|
||||
? ctx.msg.message_thread_id.toString()
|
||||
: ctx.chat!.id.toString(),
|
||||
senderTelegramUserId: ctx.from.id.toString(),
|
||||
rawText: normalized,
|
||||
messageSentAt: instantFromEpochSeconds(ctx.msg.date),
|
||||
...(senderDisplayName.length > 0
|
||||
? {
|
||||
senderDisplayName
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikePurchaseIntent(rawText: string): boolean {
|
||||
const normalized = rawText.trim()
|
||||
if (normalized.length === 0) {
|
||||
@@ -416,6 +463,19 @@ function looksLikePurchaseIntent(rawText: string): boolean {
|
||||
return PURCHASE_MONEY_PATTERN.test(normalized) && /\p{L}/u.test(normalized)
|
||||
}
|
||||
|
||||
async function resolveAssistantConfig(
|
||||
householdConfigurationRepository: HouseholdConfigurationRepository,
|
||||
householdId: string
|
||||
) {
|
||||
return householdConfigurationRepository.getHouseholdAssistantConfig
|
||||
? await householdConfigurationRepository.getHouseholdAssistantConfig(householdId)
|
||||
: {
|
||||
householdId,
|
||||
assistantContext: null,
|
||||
assistantTone: null
|
||||
}
|
||||
}
|
||||
|
||||
function formatAssistantLedger(
|
||||
dashboard: NonNullable<Awaited<ReturnType<FinanceCommandService['generateDashboard']>>>
|
||||
) {
|
||||
@@ -440,9 +500,10 @@ async function buildHouseholdContext(input: {
|
||||
householdConfigurationRepository: HouseholdConfigurationRepository
|
||||
financeService: FinanceCommandService
|
||||
}): Promise<string> {
|
||||
const [household, settings, dashboard, members] = await Promise.all([
|
||||
const [household, settings, assistantConfig, dashboard, members] = await Promise.all([
|
||||
input.householdConfigurationRepository.getHouseholdChatByHouseholdId(input.householdId),
|
||||
input.householdConfigurationRepository.getHouseholdBillingSettings(input.householdId),
|
||||
resolveAssistantConfig(input.householdConfigurationRepository, input.householdId),
|
||||
input.financeService.generateDashboard(),
|
||||
input.householdConfigurationRepository.listHouseholdMembers(input.householdId)
|
||||
])
|
||||
@@ -456,6 +517,14 @@ async function buildHouseholdContext(input: {
|
||||
`Current billing cycle: ${dashboard?.period ?? 'not available'}`
|
||||
]
|
||||
|
||||
if (assistantConfig.assistantTone) {
|
||||
lines.push(`Preferred assistant tone: ${assistantConfig.assistantTone}`)
|
||||
}
|
||||
|
||||
if (assistantConfig.assistantContext) {
|
||||
lines.push(`Household narrative context: ${assistantConfig.assistantContext}`)
|
||||
}
|
||||
|
||||
if (!dashboard) {
|
||||
lines.push('No current dashboard data is available yet.')
|
||||
return lines.join('\n')
|
||||
@@ -988,14 +1057,20 @@ export function registerDmAssistant(options: {
|
||||
const typingIndicator = startTypingIndicator(ctx)
|
||||
|
||||
try {
|
||||
const settings =
|
||||
await options.householdConfigurationRepository.getHouseholdBillingSettings(
|
||||
const [settings, assistantConfig] = await Promise.all([
|
||||
options.householdConfigurationRepository.getHouseholdBillingSettings(
|
||||
member.householdId
|
||||
)
|
||||
),
|
||||
resolveAssistantConfig(options.householdConfigurationRepository, member.householdId)
|
||||
])
|
||||
const purchaseResult = await options.purchaseRepository.save(
|
||||
purchaseRecord,
|
||||
options.purchaseInterpreter,
|
||||
settings.settlementCurrency
|
||||
settings.settlementCurrency,
|
||||
{
|
||||
householdContext: assistantConfig.assistantContext,
|
||||
assistantTone: assistantConfig.assistantTone
|
||||
}
|
||||
)
|
||||
|
||||
if (purchaseResult.status !== 'ignored_not_purchase') {
|
||||
@@ -1174,10 +1249,9 @@ export function registerDmAssistant(options: {
|
||||
}
|
||||
|
||||
const mention = stripExplicitBotMention(ctx)
|
||||
if (!mention || mention.strippedText.length === 0) {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
const isAddressed = Boolean(
|
||||
(mention && mention.strippedText.length > 0) || isReplyToBotMessage(ctx)
|
||||
)
|
||||
|
||||
const telegramUserId = ctx.from?.id?.toString()
|
||||
const telegramChatId = ctx.chat?.id?.toString()
|
||||
@@ -1193,6 +1267,26 @@ export function registerDmAssistant(options: {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!isAddressed &&
|
||||
ctx.msg &&
|
||||
'is_topic_message' in ctx.msg &&
|
||||
ctx.msg.is_topic_message === true &&
|
||||
'message_thread_id' in ctx.msg &&
|
||||
ctx.msg.message_thread_id !== undefined
|
||||
) {
|
||||
const binding =
|
||||
await options.householdConfigurationRepository.findHouseholdTopicByTelegramContext({
|
||||
telegramChatId,
|
||||
telegramThreadId: ctx.msg.message_thread_id.toString()
|
||||
})
|
||||
|
||||
if (binding) {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const member = await options.householdConfigurationRepository.getHouseholdMember(
|
||||
household.householdId,
|
||||
telegramUserId
|
||||
@@ -1203,13 +1297,6 @@ export function registerDmAssistant(options: {
|
||||
}
|
||||
|
||||
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 =
|
||||
@@ -1243,13 +1330,73 @@ export function registerDmAssistant(options: {
|
||||
|
||||
try {
|
||||
const financeService = options.financeServiceForHousehold(household.householdId)
|
||||
const [settings, assistantConfig] = await Promise.all([
|
||||
options.householdConfigurationRepository.getHouseholdBillingSettings(household.householdId),
|
||||
resolveAssistantConfig(options.householdConfigurationRepository, household.householdId)
|
||||
])
|
||||
const memoryKey = conversationMemoryKey({
|
||||
telegramUserId,
|
||||
telegramChatId,
|
||||
isPrivateChat: false
|
||||
})
|
||||
const messageText = mention?.strippedText ?? ctx.msg.text.trim()
|
||||
|
||||
if (options.purchaseRepository && options.purchaseInterpreter) {
|
||||
const purchaseRecord = createGroupPurchaseRecord(ctx, household.householdId, messageText)
|
||||
|
||||
if (purchaseRecord) {
|
||||
const purchaseResult = await options.purchaseRepository.save(
|
||||
purchaseRecord,
|
||||
options.purchaseInterpreter,
|
||||
settings.settlementCurrency,
|
||||
{
|
||||
householdContext: assistantConfig.assistantContext,
|
||||
assistantTone: assistantConfig.assistantTone
|
||||
}
|
||||
)
|
||||
|
||||
if (purchaseResult.status === 'pending_confirmation') {
|
||||
const purchaseText = getBotTranslations(locale).purchase.proposal(
|
||||
formatPurchaseSummary(locale, purchaseResult),
|
||||
null
|
||||
)
|
||||
|
||||
await ctx.reply(purchaseText, {
|
||||
reply_markup: purchaseProposalReplyMarkup(locale, purchaseResult.purchaseMessageId)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (purchaseResult.status === 'clarification_needed') {
|
||||
await ctx.reply(buildPurchaseClarificationText(locale, purchaseResult))
|
||||
return
|
||||
}
|
||||
|
||||
if (!isAddressed) {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
}
|
||||
} else if (!isAddressed) {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
if (!isAddressed || messageText.length === 0) {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
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 paymentBalanceReply = await maybeCreatePaymentBalanceReply({
|
||||
rawText: mention.strippedText,
|
||||
rawText: messageText,
|
||||
householdId: household.householdId,
|
||||
memberId: member.id,
|
||||
financeService,
|
||||
@@ -1262,7 +1409,7 @@ export function registerDmAssistant(options: {
|
||||
}
|
||||
|
||||
const memberInsightReply = await maybeCreateMemberInsightReply({
|
||||
rawText: mention.strippedText,
|
||||
rawText: messageText,
|
||||
locale,
|
||||
householdId: household.householdId,
|
||||
currentMemberId: member.id,
|
||||
@@ -1274,7 +1421,7 @@ export function registerDmAssistant(options: {
|
||||
if (memberInsightReply) {
|
||||
options.memoryStore.appendTurn(memoryKey, {
|
||||
role: 'user',
|
||||
text: mention.strippedText
|
||||
text: messageText
|
||||
})
|
||||
options.memoryStore.appendTurn(memoryKey, {
|
||||
role: 'assistant',
|
||||
@@ -1294,7 +1441,7 @@ export function registerDmAssistant(options: {
|
||||
telegramUserId,
|
||||
telegramChatId,
|
||||
locale,
|
||||
userMessage: mention.strippedText,
|
||||
userMessage: messageText,
|
||||
householdConfigurationRepository: options.householdConfigurationRepository,
|
||||
financeService,
|
||||
memoryStore: options.memoryStore,
|
||||
|
||||
@@ -185,6 +185,16 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
utilitiesReminderDay: input.utilitiesReminderDay ?? 3,
|
||||
timezone: input.timezone ?? 'Asia/Tbilisi'
|
||||
}),
|
||||
getHouseholdAssistantConfig: async (householdId) => ({
|
||||
householdId,
|
||||
assistantContext: 'House in Kojori',
|
||||
assistantTone: 'Playful'
|
||||
}),
|
||||
updateHouseholdAssistantConfig: async (input) => ({
|
||||
householdId: input.householdId,
|
||||
assistantContext: input.assistantContext ?? 'House in Kojori',
|
||||
assistantTone: input.assistantTone ?? 'Playful'
|
||||
}),
|
||||
listHouseholdUtilityCategories: async () => [],
|
||||
upsertHouseholdUtilityCategory: async (input) => ({
|
||||
id: input.slug ?? 'utility-category-1',
|
||||
@@ -471,6 +481,11 @@ describe('createMiniAppSettingsHandler', () => {
|
||||
timezone: 'Asia/Tbilisi',
|
||||
paymentBalanceAdjustmentPolicy: 'utilities'
|
||||
},
|
||||
assistantConfig: {
|
||||
householdId: 'household-1',
|
||||
assistantContext: 'House in Kojori',
|
||||
assistantTone: 'Playful'
|
||||
},
|
||||
topics: [
|
||||
{
|
||||
householdId: 'household-1',
|
||||
@@ -566,6 +581,11 @@ describe('createMiniAppUpdateSettingsHandler', () => {
|
||||
utilitiesReminderDay: 5,
|
||||
timezone: 'Asia/Tbilisi',
|
||||
paymentBalanceAdjustmentPolicy: 'utilities'
|
||||
},
|
||||
assistantConfig: {
|
||||
householdId: 'household-1',
|
||||
assistantContext: 'House in Kojori',
|
||||
assistantTone: 'Playful'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,6 +58,8 @@ async function readSettingsUpdatePayload(request: Request): Promise<{
|
||||
utilitiesDueDay: number
|
||||
utilitiesReminderDay: number
|
||||
timezone: string
|
||||
assistantContext?: string
|
||||
assistantTone?: string
|
||||
}> {
|
||||
const clonedRequest = request.clone()
|
||||
const payload = await readMiniAppRequestPayload(request)
|
||||
@@ -76,6 +78,8 @@ async function readSettingsUpdatePayload(request: Request): Promise<{
|
||||
utilitiesDueDay?: number
|
||||
utilitiesReminderDay?: number
|
||||
timezone?: string
|
||||
assistantContext?: string
|
||||
assistantTone?: string
|
||||
}
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
@@ -115,6 +119,16 @@ async function readSettingsUpdatePayload(request: Request): Promise<{
|
||||
rentCurrency: parsed.rentCurrency
|
||||
}
|
||||
: {}),
|
||||
...(typeof parsed.assistantContext === 'string'
|
||||
? {
|
||||
assistantContext: parsed.assistantContext
|
||||
}
|
||||
: {}),
|
||||
...(typeof parsed.assistantTone === 'string'
|
||||
? {
|
||||
assistantTone: parsed.assistantTone
|
||||
}
|
||||
: {}),
|
||||
rentDueDay: parsed.rentDueDay,
|
||||
rentWarningDay: parsed.rentWarningDay,
|
||||
utilitiesDueDay: parsed.utilitiesDueDay,
|
||||
@@ -352,6 +366,18 @@ function serializeBillingSettings(settings: HouseholdBillingSettingsRecord) {
|
||||
}
|
||||
}
|
||||
|
||||
function serializeAssistantConfig(config: {
|
||||
householdId: string
|
||||
assistantContext: string | null
|
||||
assistantTone: string | null
|
||||
}) {
|
||||
return {
|
||||
householdId: config.householdId,
|
||||
assistantContext: config.assistantContext,
|
||||
assistantTone: config.assistantTone
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticateAdminSession(
|
||||
request: Request,
|
||||
sessionService: ReturnType<typeof createMiniAppSessionService>,
|
||||
@@ -520,6 +546,7 @@ export function createMiniAppSettingsHandler(options: {
|
||||
ok: true,
|
||||
authorized: true,
|
||||
settings: serializeBillingSettings(result.settings),
|
||||
assistantConfig: serializeAssistantConfig(result.assistantConfig),
|
||||
topics: result.topics,
|
||||
categories: result.categories,
|
||||
members: result.members,
|
||||
@@ -617,7 +644,17 @@ export function createMiniAppUpdateSettingsHandler(options: {
|
||||
rentWarningDay: payload.rentWarningDay,
|
||||
utilitiesDueDay: payload.utilitiesDueDay,
|
||||
utilitiesReminderDay: payload.utilitiesReminderDay,
|
||||
timezone: payload.timezone
|
||||
timezone: payload.timezone,
|
||||
...(payload.assistantContext !== undefined
|
||||
? {
|
||||
assistantContext: payload.assistantContext
|
||||
}
|
||||
: {}),
|
||||
...(payload.assistantTone !== undefined
|
||||
? {
|
||||
assistantTone: payload.assistantTone
|
||||
}
|
||||
: {})
|
||||
})
|
||||
|
||||
if (result.status === 'rejected') {
|
||||
@@ -638,7 +675,8 @@ export function createMiniAppUpdateSettingsHandler(options: {
|
||||
{
|
||||
ok: true,
|
||||
authorized: true,
|
||||
settings: serializeBillingSettings(result.settings)
|
||||
settings: serializeBillingSettings(result.settings),
|
||||
assistantConfig: serializeAssistantConfig(result.assistantConfig)
|
||||
},
|
||||
200,
|
||||
origin
|
||||
|
||||
@@ -21,6 +21,8 @@ export type PurchaseMessageInterpreter = (
|
||||
options: {
|
||||
defaultCurrency: 'GEL' | 'USD'
|
||||
clarificationContext?: PurchaseClarificationContext
|
||||
householdContext?: string | null
|
||||
assistantTone?: string | null
|
||||
}
|
||||
) => Promise<PurchaseInterpretation | null>
|
||||
|
||||
@@ -186,9 +188,18 @@ export function createOpenAiPurchaseInterpreter(
|
||||
'If the latest message is a complete standalone purchase on its own, ignore the earlier clarification context.',
|
||||
'If the latest message answers a previous clarification, combine it with the earlier messages to resolve the purchase.',
|
||||
'Use clarification when the amount, currency, item, or overall intent is missing or uncertain.',
|
||||
'Return a clarification question in the same language as the user message when clarification is needed.',
|
||||
'Return a short, natural clarification question in the same language as the user message when clarification is needed.',
|
||||
'The clarification should sound like a conversational household bot, not a form validator.',
|
||||
options.assistantTone
|
||||
? `Use this tone lightly when asking clarification questions: ${options.assistantTone}.`
|
||||
: null,
|
||||
options.householdContext
|
||||
? `Household flavor context: ${options.householdContext}`
|
||||
: null,
|
||||
'Return only JSON that matches the schema.'
|
||||
].join(' ')
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
|
||||
@@ -157,7 +157,11 @@ export interface PurchaseMessageIngestionRepository {
|
||||
save(
|
||||
record: PurchaseTopicRecord,
|
||||
interpreter?: PurchaseMessageInterpreter,
|
||||
defaultCurrency?: 'GEL' | 'USD'
|
||||
defaultCurrency?: 'GEL' | 'USD',
|
||||
options?: {
|
||||
householdContext?: string | null
|
||||
assistantTone?: string | null
|
||||
}
|
||||
): Promise<PurchaseMessageIngestionResult>
|
||||
confirm(
|
||||
purchaseMessageId: string,
|
||||
@@ -820,7 +824,7 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
||||
return Boolean(clarificationContext && clarificationContext.length > 0)
|
||||
},
|
||||
|
||||
async save(record, interpreter, defaultCurrency) {
|
||||
async save(record, interpreter, defaultCurrency, options) {
|
||||
const matchedMember = await db
|
||||
.select({ id: schema.members.id })
|
||||
.from(schema.members)
|
||||
@@ -839,6 +843,8 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
||||
const interpretation = interpreter
|
||||
? await interpreter(record.rawText, {
|
||||
defaultCurrency: defaultCurrency ?? 'GEL',
|
||||
householdContext: options?.householdContext ?? null,
|
||||
assistantTone: options?.assistantTone ?? null,
|
||||
...(clarificationContext
|
||||
? {
|
||||
clarificationContext: {
|
||||
@@ -1190,6 +1196,23 @@ async function resolveHouseholdLocale(
|
||||
return householdChat?.defaultLocale ?? 'en'
|
||||
}
|
||||
|
||||
async function resolveAssistantConfig(
|
||||
householdConfigurationRepository: HouseholdConfigurationRepository,
|
||||
householdId: string
|
||||
): Promise<{
|
||||
householdId: string
|
||||
assistantContext: string | null
|
||||
assistantTone: string | null
|
||||
}> {
|
||||
return householdConfigurationRepository.getHouseholdAssistantConfig
|
||||
? await householdConfigurationRepository.getHouseholdAssistantConfig(householdId)
|
||||
: {
|
||||
householdId,
|
||||
assistantContext: null,
|
||||
assistantTone: null
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePurchaseMessageResult(
|
||||
ctx: Context,
|
||||
record: PurchaseTopicRecord,
|
||||
@@ -1529,9 +1552,10 @@ export function registerConfiguredPurchaseTopicIngestion(
|
||||
const typingIndicator = options.interpreter ? startTypingIndicator(ctx) : null
|
||||
|
||||
try {
|
||||
const billingSettings = await householdConfigurationRepository.getHouseholdBillingSettings(
|
||||
record.householdId
|
||||
)
|
||||
const [billingSettings, assistantConfig] = await Promise.all([
|
||||
householdConfigurationRepository.getHouseholdBillingSettings(record.householdId),
|
||||
resolveAssistantConfig(householdConfigurationRepository, record.householdId)
|
||||
])
|
||||
const locale = await resolveHouseholdLocale(
|
||||
householdConfigurationRepository,
|
||||
record.householdId
|
||||
@@ -1542,7 +1566,11 @@ export function registerConfiguredPurchaseTopicIngestion(
|
||||
const result = await repository.save(
|
||||
record,
|
||||
options.interpreter,
|
||||
billingSettings.settlementCurrency
|
||||
billingSettings.settlementCurrency,
|
||||
{
|
||||
householdContext: assistantConfig.assistantContext,
|
||||
assistantTone: assistantConfig.assistantTone
|
||||
}
|
||||
)
|
||||
if (stripExplicitBotMention(ctx) && result.status === 'ignored_not_purchase') {
|
||||
return await next()
|
||||
|
||||
@@ -37,7 +37,14 @@ describe('createBotWebhookServer', () => {
|
||||
miniAppSettings: {
|
||||
handler: async () =>
|
||||
new Response(
|
||||
JSON.stringify({ ok: true, authorized: true, settings: {}, categories: [], members: [] }),
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
authorized: true,
|
||||
settings: {},
|
||||
assistantConfig: {},
|
||||
categories: [],
|
||||
members: []
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
@@ -48,12 +55,15 @@ describe('createBotWebhookServer', () => {
|
||||
},
|
||||
miniAppUpdateSettings: {
|
||||
handler: async () =>
|
||||
new Response(JSON.stringify({ ok: true, authorized: true, settings: {} }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json; charset=utf-8'
|
||||
new Response(
|
||||
JSON.stringify({ ok: true, authorized: true, settings: {}, assistantConfig: {} }),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json; charset=utf-8'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
},
|
||||
miniAppUpsertUtilityCategory: {
|
||||
handler: async () =>
|
||||
@@ -305,6 +315,7 @@ describe('createBotWebhookServer', () => {
|
||||
ok: true,
|
||||
authorized: true,
|
||||
settings: {},
|
||||
assistantConfig: {},
|
||||
categories: [],
|
||||
members: []
|
||||
})
|
||||
@@ -322,7 +333,8 @@ describe('createBotWebhookServer', () => {
|
||||
expect(await response.json()).toEqual({
|
||||
ok: true,
|
||||
authorized: true,
|
||||
settings: {}
|
||||
settings: {},
|
||||
assistantConfig: {}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user