mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04:02 +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: {}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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: 'Зачитывать через коммуналку',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
2
packages/db/drizzle/0018_nimble_kojori.sql
Normal file
2
packages/db/drizzle/0018_nimble_kojori.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "households" ADD COLUMN "assistant_context" text;
|
||||
ALTER TABLE "households" ADD COLUMN "assistant_tone" text;
|
||||
3234
packages/db/drizzle/meta/0018_snapshot.json
Normal file
3234
packages/db/drizzle/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,13 @@
|
||||
"when": 1773226133315,
|
||||
"tag": "0017_gigantic_selene",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1773252000000,
|
||||
"tag": "0018_nimble_kojori",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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[]>
|
||||
|
||||
@@ -19,6 +19,7 @@ export {
|
||||
HOUSEHOLD_TOPIC_ROLES,
|
||||
type HouseholdMemberAbsencePolicy,
|
||||
type HouseholdMemberAbsencePolicyRecord,
|
||||
type HouseholdAssistantConfigRecord,
|
||||
type HouseholdPaymentBalanceAdjustmentPolicy,
|
||||
type HouseholdConfigurationRepository,
|
||||
type HouseholdBillingSettingsRecord,
|
||||
|
||||
Reference in New Issue
Block a user