feat(bot): add configurable household assistant behavior

This commit is contained in:
2026-03-12 03:22:43 +04:00
parent 146f5294f4
commit 4e7400e908
22 changed files with 4127 additions and 96 deletions

View File

@@ -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 }> = []

View File

@@ -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,

View File

@@ -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'
}
})
})

View File

@@ -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

View File

@@ -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',

View File

@@ -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()

View File

@@ -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: {}
})
})

View File

@@ -376,7 +376,9 @@ function App() {
rentWarningDay: 17,
utilitiesDueDay: 4,
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi'
timezone: 'Asia/Tbilisi',
assistantContext: '',
assistantTone: ''
})
const [newCategoryName, setNewCategoryName] = createSignal('')
const [cycleForm, setCycleForm] = createSignal({
@@ -917,7 +919,9 @@ function App() {
rentWarningDay: payload.settings.rentWarningDay,
utilitiesDueDay: payload.settings.utilitiesDueDay,
utilitiesReminderDay: payload.settings.utilitiesReminderDay,
timezone: payload.settings.timezone
timezone: payload.settings.timezone,
assistantContext: payload.assistantConfig.assistantContext ?? '',
assistantTone: payload.assistantConfig.assistantTone ?? ''
})
setPaymentForm((current) => ({
...current,
@@ -1033,7 +1037,9 @@ function App() {
rentWarningDay: demoAdminSettings.settings.rentWarningDay,
utilitiesDueDay: demoAdminSettings.settings.utilitiesDueDay,
utilitiesReminderDay: demoAdminSettings.settings.utilitiesReminderDay,
timezone: demoAdminSettings.settings.timezone
timezone: demoAdminSettings.settings.timezone,
assistantContext: demoAdminSettings.assistantConfig.assistantContext ?? '',
assistantTone: demoAdminSettings.assistantConfig.assistantTone ?? ''
})
setCycleForm((current) => ({
...current,
@@ -1338,12 +1344,16 @@ function App() {
setSavingBillingSettings(true)
try {
const settings = await updateMiniAppBillingSettings(initData, billingForm())
const { settings, assistantConfig } = await updateMiniAppBillingSettings(
initData,
billingForm()
)
setAdminSettings((current) =>
current
? {
...current,
settings
settings,
assistantConfig
}
: current
)
@@ -2230,6 +2240,18 @@ function App() {
timezone: value
}))
}
onBillingAssistantContextChange={(value) =>
setBillingForm((current) => ({
...current,
assistantContext: value
}))
}
onBillingAssistantToneChange={(value) =>
setBillingForm((current) => ({
...current,
assistantTone: value
}))
}
onOpenAddUtilityBill={() => setAddingUtilityBillOpen(true)}
onCloseAddUtilityBill={() => setAddingUtilityBillOpen(false)}
onAddUtilityBill={handleAddUtilityBill}

View File

@@ -203,6 +203,11 @@ export const demoAdminSettings: MiniAppAdminSettingsPayload = {
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi'
},
assistantConfig: {
householdId: 'demo-household',
assistantContext: 'The household is a house in Kojori with a backyard and pine forest nearby.',
assistantTone: 'Playful but concise'
},
topics: [
{ role: 'purchase', telegramThreadId: '101', topicName: 'Purchases' },
{ role: 'feedback', telegramThreadId: '102', topicName: 'Anonymous feedback' },

View File

@@ -147,6 +147,16 @@ export const dictionary = {
topicBound: 'Bound',
topicUnbound: 'Unbound',
billingSettingsTitle: 'Billing settings',
assistantSettingsTitle: 'Bot personality',
assistantSettingsBody:
'Give the bot household context and a tone so replies feel grounded without getting intrusive.',
assistantToneLabel: 'Bot mood',
assistantTonePlaceholder: 'Playful, dry, concise, slightly sarcastic',
assistantToneDefault: 'Default',
assistantContextLabel: 'Household context',
assistantContextPlaceholder:
'The household is a house in Kojori with a backyard and pine forest nearby.',
assistantContextEmpty: 'No custom context',
settlementCurrency: 'Settlement currency',
paymentBalanceAdjustmentPolicy: 'Purchase balance adjustment',
paymentBalanceAdjustmentUtilities: 'Adjust through utilities',
@@ -392,6 +402,15 @@ export const dictionary = {
topicBound: 'Привязан',
topicUnbound: 'Не привязан',
billingSettingsTitle: 'Настройки биллинга',
assistantSettingsTitle: 'Характер бота',
assistantSettingsBody:
'Задай бытовой контекст и тон, чтобы ответы бота звучали уместно и не лезли в разговор без повода.',
assistantToneLabel: 'Настроение бота',
assistantTonePlaceholder: 'Игривый, сухой, короткий, слегка саркастичный',
assistantToneDefault: 'По умолчанию',
assistantContextLabel: 'Контекст дома',
assistantContextPlaceholder: 'Это дом в Коджори, рядом двор и сосновый лес.',
assistantContextEmpty: 'Контекст не задан',
settlementCurrency: 'Валюта расчёта',
paymentBalanceAdjustmentPolicy: 'Зачёт баланса по покупкам',
paymentBalanceAdjustmentUtilities: 'Зачитывать через коммуналку',

View File

@@ -70,6 +70,12 @@ export interface MiniAppBillingSettings {
timezone: string
}
export interface MiniAppAssistantConfig {
householdId: string
assistantContext: string | null
assistantTone: string | null
}
export interface MiniAppUtilityCategory {
id: string
householdId: string
@@ -133,6 +139,7 @@ export interface MiniAppDashboard {
export interface MiniAppAdminSettingsPayload {
settings: MiniAppBillingSettings
assistantConfig: MiniAppAssistantConfig
topics: readonly MiniAppTopicBinding[]
categories: readonly MiniAppUtilityCategory[]
members: readonly MiniAppMember[]
@@ -380,6 +387,7 @@ export async function fetchMiniAppAdminSettings(
ok: boolean
authorized?: boolean
settings?: MiniAppBillingSettings
assistantConfig?: MiniAppAssistantConfig
topics?: MiniAppTopicBinding[]
categories?: MiniAppUtilityCategory[]
members?: MiniAppMember[]
@@ -391,6 +399,7 @@ export async function fetchMiniAppAdminSettings(
!response.ok ||
!payload.authorized ||
!payload.settings ||
!payload.assistantConfig ||
!payload.topics ||
!payload.categories ||
!payload.members ||
@@ -401,6 +410,7 @@ export async function fetchMiniAppAdminSettings(
return {
settings: payload.settings,
assistantConfig: payload.assistantConfig,
topics: payload.topics,
categories: payload.categories,
members: payload.members,
@@ -420,8 +430,13 @@ export async function updateMiniAppBillingSettings(
utilitiesDueDay: number
utilitiesReminderDay: number
timezone: string
assistantContext?: string
assistantTone?: string
}
): Promise<MiniAppBillingSettings> {
): Promise<{
settings: MiniAppBillingSettings
assistantConfig: MiniAppAssistantConfig
}> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/settings/update`, {
method: 'POST',
headers: {
@@ -437,14 +452,18 @@ export async function updateMiniAppBillingSettings(
ok: boolean
authorized?: boolean
settings?: MiniAppBillingSettings
assistantConfig?: MiniAppAssistantConfig
error?: string
}
if (!response.ok || !payload.authorized || !payload.settings) {
if (!response.ok || !payload.authorized || !payload.settings || !payload.assistantConfig) {
throw new Error(payload.error ?? 'Failed to update billing settings')
}
return payload.settings
return {
settings: payload.settings,
assistantConfig: payload.assistantConfig
}
}
export async function upsertMiniAppUtilityCategory(

View File

@@ -37,6 +37,8 @@ type BillingForm = {
utilitiesDueDay: number
utilitiesReminderDay: number
timezone: string
assistantContext: string
assistantTone: string
}
type CycleForm = {
@@ -121,6 +123,8 @@ type Props = {
onBillingUtilitiesDueDayChange: (value: number | null) => void
onBillingUtilitiesReminderDayChange: (value: number | null) => void
onBillingTimezoneChange: (value: string) => void
onBillingAssistantContextChange: (value: string) => void
onBillingAssistantToneChange: (value: string) => void
onOpenAddUtilityBill: () => void
onCloseAddUtilityBill: () => void
onAddUtilityBill: () => Promise<void>
@@ -260,23 +264,22 @@ export function HouseScreen(props: Props) {
<article class="balance-item">
<header>
<strong>{props.copy.billingSettingsTitle ?? ''}</strong>
<span>{props.billingForm.settlementCurrency}</span>
<strong>{props.copy.assistantSettingsTitle ?? ''}</strong>
<span>
{props.billingForm.assistantTone || (props.copy.assistantToneDefault ?? '')}
</span>
</header>
<p>
{props.billingForm.paymentBalanceAdjustmentPolicy === 'utilities'
? props.copy.paymentBalanceAdjustmentUtilities
: props.billingForm.paymentBalanceAdjustmentPolicy === 'rent'
? props.copy.paymentBalanceAdjustmentRent
: props.copy.paymentBalanceAdjustmentSeparate}
</p>
<p>{props.copy.assistantSettingsBody ?? ''}</p>
<div class="ledger-compact-card__meta">
<span class="mini-chip">
{props.copy.rentAmount ?? ''}: {props.billingForm.rentAmountMajor || '—'}{' '}
{props.billingForm.rentCurrency}
{props.copy.assistantToneLabel ?? ''}:{' '}
{props.billingForm.assistantTone || props.copy.assistantToneDefault || '—'}
</span>
<span class="mini-chip mini-chip--muted">
{props.copy.timezone ?? ''}: {props.billingForm.timezone}
{props.copy.assistantContextLabel ?? ''}:{' '}
{props.billingForm.assistantContext.trim().length > 0
? props.billingForm.assistantContext.trim().slice(0, 80)
: (props.copy.assistantContextEmpty ?? '')}
</span>
</div>
<div class="panel-toolbar">
@@ -514,6 +517,27 @@ export function HouseScreen(props: Props) {
onInput={(event) => props.onBillingTimezoneChange(event.currentTarget.value)}
/>
</Field>
<Field label={props.copy.assistantToneLabel ?? ''} wide>
<input
value={props.billingForm.assistantTone}
maxlength="160"
placeholder={props.copy.assistantTonePlaceholder ?? ''}
onInput={(event) =>
props.onBillingAssistantToneChange(event.currentTarget.value)
}
/>
</Field>
<Field label={props.copy.assistantContextLabel ?? ''} wide>
<textarea
rows="6"
maxlength="1200"
placeholder={props.copy.assistantContextPlaceholder ?? ''}
value={props.billingForm.assistantContext}
onInput={(event) =>
props.onBillingAssistantContextChange(event.currentTarget.value)
}
/>
</Field>
</div>
</Modal>
</section>

View File

@@ -12,6 +12,7 @@ import {
HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES,
HOUSEHOLD_PAYMENT_BALANCE_ADJUSTMENT_POLICIES,
HOUSEHOLD_TOPIC_ROLES,
type HouseholdAssistantConfigRecord,
type HouseholdMemberAbsencePolicy,
type HouseholdMemberAbsencePolicyRecord,
type HouseholdBillingSettingsRecord,
@@ -245,6 +246,18 @@ function toHouseholdBillingSettingsRecord(row: {
}
}
function toHouseholdAssistantConfigRecord(row: {
householdId: string
assistantContext: string | null
assistantTone: string | null
}): HouseholdAssistantConfigRecord {
return {
householdId: row.householdId,
assistantContext: row.assistantContext,
assistantTone: row.assistantTone
}
}
function toHouseholdUtilityCategoryRecord(row: {
id: string
householdId: string
@@ -957,6 +970,25 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
return toHouseholdBillingSettingsRecord(row)
},
async getHouseholdAssistantConfig(householdId) {
const rows = await db
.select({
householdId: schema.households.id,
assistantContext: schema.households.assistantContext,
assistantTone: schema.households.assistantTone
})
.from(schema.households)
.where(eq(schema.households.id, householdId))
.limit(1)
const row = rows[0]
if (!row) {
throw new Error('Failed to load household assistant config')
}
return toHouseholdAssistantConfigRecord(row)
},
async updateHouseholdBillingSettings(input) {
await ensureBillingSettings(input.householdId)
@@ -1033,6 +1065,36 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
return toHouseholdBillingSettingsRecord(row)
},
async updateHouseholdAssistantConfig(input) {
const rows = await db
.update(schema.households)
.set({
...(input.assistantContext !== undefined
? {
assistantContext: input.assistantContext
}
: {}),
...(input.assistantTone !== undefined
? {
assistantTone: input.assistantTone
}
: {})
})
.where(eq(schema.households.id, input.householdId))
.returning({
householdId: schema.households.id,
assistantContext: schema.households.assistantContext,
assistantTone: schema.households.assistantTone
})
const row = rows[0]
if (!row) {
throw new Error('Failed to update household assistant config')
}
return toHouseholdAssistantConfigRecord(row)
},
async listHouseholdUtilityCategories(householdId) {
await ensureUtilityCategories(householdId)

View File

@@ -172,6 +172,16 @@ function repository(): 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',
@@ -269,6 +279,11 @@ describe('createMiniAppAdminService', () => {
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi'
},
assistantConfig: {
householdId: 'household-1',
assistantContext: 'House in Kojori',
assistantTone: 'Playful'
},
topics: [
{
householdId: 'household-1',
@@ -322,6 +337,11 @@ describe('createMiniAppAdminService', () => {
utilitiesDueDay: 5,
utilitiesReminderDay: 4,
timezone: 'Asia/Tbilisi'
},
assistantConfig: {
householdId: 'household-1',
assistantContext: 'House in Kojori',
assistantTone: 'Playful'
}
})
})

View File

@@ -1,4 +1,5 @@
import type {
HouseholdAssistantConfigRecord,
HouseholdBillingSettingsRecord,
HouseholdConfigurationRepository,
HouseholdMemberAbsencePolicy,
@@ -29,6 +30,7 @@ export interface MiniAppAdminService {
| {
status: 'ok'
settings: HouseholdBillingSettingsRecord
assistantConfig: HouseholdAssistantConfigRecord
categories: readonly HouseholdUtilityCategoryRecord[]
members: readonly HouseholdMemberRecord[]
memberAbsencePolicies: readonly HouseholdMemberAbsencePolicyRecord[]
@@ -51,10 +53,13 @@ export interface MiniAppAdminService {
utilitiesDueDay: number
utilitiesReminderDay: number
timezone: string
assistantContext?: string
assistantTone?: string
}): Promise<
| {
status: 'ok'
settings: HouseholdBillingSettingsRecord
assistantConfig: HouseholdAssistantConfigRecord
}
| {
status: 'rejected'
@@ -210,6 +215,34 @@ function normalizeDisplayName(raw: string): string | null {
return trimmed.replace(/\s+/g, ' ')
}
function defaultAssistantConfig(householdId: string): HouseholdAssistantConfigRecord {
return {
householdId,
assistantContext: null,
assistantTone: null
}
}
function normalizeAssistantText(
raw: string | undefined,
maxLength: number
): string | null | undefined {
if (raw === undefined) {
return undefined
}
const trimmed = raw.trim()
if (trimmed.length === 0) {
return null
}
if (trimmed.length > maxLength) {
return null
}
return trimmed
}
export function createMiniAppAdminService(
repository: HouseholdConfigurationRepository
): MiniAppAdminService {
@@ -222,17 +255,22 @@ export function createMiniAppAdminService(
}
}
const [settings, categories, members, memberAbsencePolicies, topics] = await Promise.all([
repository.getHouseholdBillingSettings(input.householdId),
repository.listHouseholdUtilityCategories(input.householdId),
repository.listHouseholdMembers(input.householdId),
repository.listHouseholdMemberAbsencePolicies(input.householdId),
repository.listHouseholdTopicBindings(input.householdId)
])
const [settings, assistantConfig, categories, members, memberAbsencePolicies, topics] =
await Promise.all([
repository.getHouseholdBillingSettings(input.householdId),
repository.getHouseholdAssistantConfig
? repository.getHouseholdAssistantConfig(input.householdId)
: Promise.resolve(defaultAssistantConfig(input.householdId)),
repository.listHouseholdUtilityCategories(input.householdId),
repository.listHouseholdMembers(input.householdId),
repository.listHouseholdMemberAbsencePolicies(input.householdId),
repository.listHouseholdTopicBindings(input.householdId)
])
return {
status: 'ok',
settings,
assistantConfig,
categories,
members,
memberAbsencePolicies,
@@ -263,6 +301,23 @@ export function createMiniAppAdminService(
}
}
const assistantContext = normalizeAssistantText(input.assistantContext, 1200)
const assistantTone = normalizeAssistantText(input.assistantTone, 160)
if (
(input.assistantContext !== undefined &&
assistantContext === null &&
input.assistantContext.trim().length > 0) ||
(input.assistantTone !== undefined &&
assistantTone === null &&
input.assistantTone.trim().length > 0)
) {
return {
status: 'rejected',
reason: 'invalid_settings'
}
}
let rentAmountMinor: bigint | null | undefined
let rentCurrency: CurrencyCode | undefined
const settlementCurrency = input.settlementCurrency
@@ -291,38 +346,65 @@ export function createMiniAppAdminService(
rentCurrency = parseCurrency(input.rentCurrency ?? 'USD')
}
const settings = await repository.updateHouseholdBillingSettings({
householdId: input.householdId,
...(settlementCurrency
? {
settlementCurrency
}
: {}),
...(paymentBalanceAdjustmentPolicy
? {
paymentBalanceAdjustmentPolicy
}
: {}),
...(rentAmountMinor !== undefined
? {
rentAmountMinor
}
: {}),
...(rentCurrency
? {
rentCurrency
}
: {}),
rentDueDay: input.rentDueDay,
rentWarningDay: input.rentWarningDay,
utilitiesDueDay: input.utilitiesDueDay,
utilitiesReminderDay: input.utilitiesReminderDay,
timezone: input.timezone.trim()
})
const shouldUpdateAssistantConfig =
assistantContext !== undefined || assistantTone !== undefined
const [settings, nextAssistantConfig] = await Promise.all([
repository.updateHouseholdBillingSettings({
householdId: input.householdId,
...(settlementCurrency
? {
settlementCurrency
}
: {}),
...(paymentBalanceAdjustmentPolicy
? {
paymentBalanceAdjustmentPolicy
}
: {}),
...(rentAmountMinor !== undefined
? {
rentAmountMinor
}
: {}),
...(rentCurrency
? {
rentCurrency
}
: {}),
rentDueDay: input.rentDueDay,
rentWarningDay: input.rentWarningDay,
utilitiesDueDay: input.utilitiesDueDay,
utilitiesReminderDay: input.utilitiesReminderDay,
timezone: input.timezone.trim()
}),
repository.updateHouseholdAssistantConfig && shouldUpdateAssistantConfig
? repository.updateHouseholdAssistantConfig({
householdId: input.householdId,
...(assistantContext !== undefined
? {
assistantContext
}
: {}),
...(assistantTone !== undefined
? {
assistantTone
}
: {})
})
: repository.getHouseholdAssistantConfig
? repository.getHouseholdAssistantConfig(input.householdId)
: Promise.resolve({
householdId: input.householdId,
assistantContext: assistantContext ?? null,
assistantTone: assistantTone ?? null
})
])
return {
status: 'ok',
settings
settings,
assistantConfig: nextAssistantConfig
}
},

View File

@@ -18,6 +18,7 @@
"0014_empty_risque.sql": "6dd4aba0f84d43bc86afbd04cad3b1055ecac03bd80ad6fd510bef1550d10335",
"0015_white_owl.sql": "a9dec4c536c660d7eb0fcea42a3bedb1301408551977d098dff8324d7d5b26bd",
"0016_equal_susan_delgado.sql": "1698bf0516d16d2d7929dcb1bd2bb76d5a629eaba3d0bb2533c1ae926408de7a",
"0017_gigantic_selene.sql": "232d61b979675ddb97c9d69d14406dc15dd095ee6a332d3fa71d10416204fade"
"0017_gigantic_selene.sql": "232d61b979675ddb97c9d69d14406dc15dd095ee6a332d3fa71d10416204fade",
"0018_nimble_kojori.sql": "818738e729119c6de8049dcfca562926a5dc6e321ecbbf9cf38e02bc70b5a0dc"
}
}

View File

@@ -0,0 +1,2 @@
ALTER TABLE "households" ADD COLUMN "assistant_context" text;
ALTER TABLE "households" ADD COLUMN "assistant_tone" text;

File diff suppressed because it is too large Load Diff

View File

@@ -127,6 +127,13 @@
"when": 1773226133315,
"tag": "0017_gigantic_selene",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1773252000000,
"tag": "0018_nimble_kojori",
"breakpoints": true
}
]
}

View File

@@ -16,6 +16,8 @@ export const households = pgTable('households', {
id: uuid('id').defaultRandom().primaryKey(),
name: text('name').notNull(),
defaultLocale: text('default_locale').default('ru').notNull(),
assistantContext: text('assistant_context'),
assistantTone: text('assistant_tone'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
})

View File

@@ -86,6 +86,12 @@ export interface HouseholdBillingSettingsRecord {
timezone: string
}
export interface HouseholdAssistantConfigRecord {
householdId: string
assistantContext: string | null
assistantTone: string | null
}
export interface HouseholdUtilityCategoryRecord {
id: string
householdId: string
@@ -166,6 +172,7 @@ export interface HouseholdConfigurationRepository {
): Promise<HouseholdMemberRecord | null>
listHouseholdMembers(householdId: string): Promise<readonly HouseholdMemberRecord[]>
getHouseholdBillingSettings(householdId: string): Promise<HouseholdBillingSettingsRecord>
getHouseholdAssistantConfig?(householdId: string): Promise<HouseholdAssistantConfigRecord>
updateHouseholdBillingSettings(input: {
householdId: string
settlementCurrency?: CurrencyCode
@@ -178,6 +185,11 @@ export interface HouseholdConfigurationRepository {
utilitiesReminderDay?: number
timezone?: string
}): Promise<HouseholdBillingSettingsRecord>
updateHouseholdAssistantConfig?(input: {
householdId: string
assistantContext?: string | null
assistantTone?: string | null
}): Promise<HouseholdAssistantConfigRecord>
listHouseholdUtilityCategories(
householdId: string
): Promise<readonly HouseholdUtilityCategoryRecord[]>

View File

@@ -19,6 +19,7 @@ export {
HOUSEHOLD_TOPIC_ROLES,
type HouseholdMemberAbsencePolicy,
type HouseholdMemberAbsencePolicyRecord,
type HouseholdAssistantConfigRecord,
type HouseholdPaymentBalanceAdjustmentPolicy,
type HouseholdConfigurationRepository,
type HouseholdBillingSettingsRecord,