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 {
|
return {
|
||||||
update_id: 3001,
|
update_id: 3001,
|
||||||
message: {
|
message: {
|
||||||
@@ -77,11 +82,34 @@ function topicMentionUpdate(text: string) {
|
|||||||
first_name: 'Stan',
|
first_name: 'Stan',
|
||||||
language_code: 'en'
|
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) {
|
function privateCallbackUpdate(data: string) {
|
||||||
return {
|
return {
|
||||||
update_id: 2002,
|
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 () => {
|
test('ignores duplicate deliveries of the same DM update', async () => {
|
||||||
const bot = createTestBot()
|
const bot = createTestBot()
|
||||||
const calls: Array<{ method: string; payload: unknown }> = []
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
|||||||
@@ -123,6 +123,15 @@ function isCommandMessage(ctx: Context): boolean {
|
|||||||
return typeof ctx.msg?.text === 'string' && ctx.msg.text.trim().startsWith('/')
|
return typeof ctx.msg?.text === 'string' && ctx.msg.text.trim().startsWith('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
function summarizeTurns(
|
||||||
summary: string | null,
|
summary: string | null,
|
||||||
turns: readonly AssistantConversationTurn[]
|
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 {
|
function looksLikePurchaseIntent(rawText: string): boolean {
|
||||||
const normalized = rawText.trim()
|
const normalized = rawText.trim()
|
||||||
if (normalized.length === 0) {
|
if (normalized.length === 0) {
|
||||||
@@ -416,6 +463,19 @@ function looksLikePurchaseIntent(rawText: string): boolean {
|
|||||||
return PURCHASE_MONEY_PATTERN.test(normalized) && /\p{L}/u.test(normalized)
|
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(
|
function formatAssistantLedger(
|
||||||
dashboard: NonNullable<Awaited<ReturnType<FinanceCommandService['generateDashboard']>>>
|
dashboard: NonNullable<Awaited<ReturnType<FinanceCommandService['generateDashboard']>>>
|
||||||
) {
|
) {
|
||||||
@@ -440,9 +500,10 @@ async function buildHouseholdContext(input: {
|
|||||||
householdConfigurationRepository: HouseholdConfigurationRepository
|
householdConfigurationRepository: HouseholdConfigurationRepository
|
||||||
financeService: FinanceCommandService
|
financeService: FinanceCommandService
|
||||||
}): Promise<string> {
|
}): 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.getHouseholdChatByHouseholdId(input.householdId),
|
||||||
input.householdConfigurationRepository.getHouseholdBillingSettings(input.householdId),
|
input.householdConfigurationRepository.getHouseholdBillingSettings(input.householdId),
|
||||||
|
resolveAssistantConfig(input.householdConfigurationRepository, input.householdId),
|
||||||
input.financeService.generateDashboard(),
|
input.financeService.generateDashboard(),
|
||||||
input.householdConfigurationRepository.listHouseholdMembers(input.householdId)
|
input.householdConfigurationRepository.listHouseholdMembers(input.householdId)
|
||||||
])
|
])
|
||||||
@@ -456,6 +517,14 @@ async function buildHouseholdContext(input: {
|
|||||||
`Current billing cycle: ${dashboard?.period ?? 'not available'}`
|
`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) {
|
if (!dashboard) {
|
||||||
lines.push('No current dashboard data is available yet.')
|
lines.push('No current dashboard data is available yet.')
|
||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
@@ -988,14 +1057,20 @@ export function registerDmAssistant(options: {
|
|||||||
const typingIndicator = startTypingIndicator(ctx)
|
const typingIndicator = startTypingIndicator(ctx)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const settings =
|
const [settings, assistantConfig] = await Promise.all([
|
||||||
await options.householdConfigurationRepository.getHouseholdBillingSettings(
|
options.householdConfigurationRepository.getHouseholdBillingSettings(
|
||||||
member.householdId
|
member.householdId
|
||||||
)
|
),
|
||||||
|
resolveAssistantConfig(options.householdConfigurationRepository, member.householdId)
|
||||||
|
])
|
||||||
const purchaseResult = await options.purchaseRepository.save(
|
const purchaseResult = await options.purchaseRepository.save(
|
||||||
purchaseRecord,
|
purchaseRecord,
|
||||||
options.purchaseInterpreter,
|
options.purchaseInterpreter,
|
||||||
settings.settlementCurrency
|
settings.settlementCurrency,
|
||||||
|
{
|
||||||
|
householdContext: assistantConfig.assistantContext,
|
||||||
|
assistantTone: assistantConfig.assistantTone
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (purchaseResult.status !== 'ignored_not_purchase') {
|
if (purchaseResult.status !== 'ignored_not_purchase') {
|
||||||
@@ -1174,10 +1249,9 @@ export function registerDmAssistant(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mention = stripExplicitBotMention(ctx)
|
const mention = stripExplicitBotMention(ctx)
|
||||||
if (!mention || mention.strippedText.length === 0) {
|
const isAddressed = Boolean(
|
||||||
await next()
|
(mention && mention.strippedText.length > 0) || isReplyToBotMessage(ctx)
|
||||||
return
|
)
|
||||||
}
|
|
||||||
|
|
||||||
const telegramUserId = ctx.from?.id?.toString()
|
const telegramUserId = ctx.from?.id?.toString()
|
||||||
const telegramChatId = ctx.chat?.id?.toString()
|
const telegramChatId = ctx.chat?.id?.toString()
|
||||||
@@ -1193,6 +1267,26 @@ export function registerDmAssistant(options: {
|
|||||||
return
|
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(
|
const member = await options.householdConfigurationRepository.getHouseholdMember(
|
||||||
household.householdId,
|
household.householdId,
|
||||||
telegramUserId
|
telegramUserId
|
||||||
@@ -1203,13 +1297,6 @@ export function registerDmAssistant(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const locale = member.preferredLocale ?? household.defaultLocale ?? 'en'
|
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 updateId = ctx.update.update_id?.toString()
|
||||||
const dedupeClaim =
|
const dedupeClaim =
|
||||||
@@ -1243,13 +1330,73 @@ export function registerDmAssistant(options: {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const financeService = options.financeServiceForHousehold(household.householdId)
|
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({
|
const memoryKey = conversationMemoryKey({
|
||||||
telegramUserId,
|
telegramUserId,
|
||||||
telegramChatId,
|
telegramChatId,
|
||||||
isPrivateChat: false
|
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({
|
const paymentBalanceReply = await maybeCreatePaymentBalanceReply({
|
||||||
rawText: mention.strippedText,
|
rawText: messageText,
|
||||||
householdId: household.householdId,
|
householdId: household.householdId,
|
||||||
memberId: member.id,
|
memberId: member.id,
|
||||||
financeService,
|
financeService,
|
||||||
@@ -1262,7 +1409,7 @@ export function registerDmAssistant(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const memberInsightReply = await maybeCreateMemberInsightReply({
|
const memberInsightReply = await maybeCreateMemberInsightReply({
|
||||||
rawText: mention.strippedText,
|
rawText: messageText,
|
||||||
locale,
|
locale,
|
||||||
householdId: household.householdId,
|
householdId: household.householdId,
|
||||||
currentMemberId: member.id,
|
currentMemberId: member.id,
|
||||||
@@ -1274,7 +1421,7 @@ export function registerDmAssistant(options: {
|
|||||||
if (memberInsightReply) {
|
if (memberInsightReply) {
|
||||||
options.memoryStore.appendTurn(memoryKey, {
|
options.memoryStore.appendTurn(memoryKey, {
|
||||||
role: 'user',
|
role: 'user',
|
||||||
text: mention.strippedText
|
text: messageText
|
||||||
})
|
})
|
||||||
options.memoryStore.appendTurn(memoryKey, {
|
options.memoryStore.appendTurn(memoryKey, {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
@@ -1294,7 +1441,7 @@ export function registerDmAssistant(options: {
|
|||||||
telegramUserId,
|
telegramUserId,
|
||||||
telegramChatId,
|
telegramChatId,
|
||||||
locale,
|
locale,
|
||||||
userMessage: mention.strippedText,
|
userMessage: messageText,
|
||||||
householdConfigurationRepository: options.householdConfigurationRepository,
|
householdConfigurationRepository: options.householdConfigurationRepository,
|
||||||
financeService,
|
financeService,
|
||||||
memoryStore: options.memoryStore,
|
memoryStore: options.memoryStore,
|
||||||
|
|||||||
@@ -185,6 +185,16 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
utilitiesReminderDay: input.utilitiesReminderDay ?? 3,
|
utilitiesReminderDay: input.utilitiesReminderDay ?? 3,
|
||||||
timezone: input.timezone ?? 'Asia/Tbilisi'
|
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 () => [],
|
listHouseholdUtilityCategories: async () => [],
|
||||||
upsertHouseholdUtilityCategory: async (input) => ({
|
upsertHouseholdUtilityCategory: async (input) => ({
|
||||||
id: input.slug ?? 'utility-category-1',
|
id: input.slug ?? 'utility-category-1',
|
||||||
@@ -471,6 +481,11 @@ describe('createMiniAppSettingsHandler', () => {
|
|||||||
timezone: 'Asia/Tbilisi',
|
timezone: 'Asia/Tbilisi',
|
||||||
paymentBalanceAdjustmentPolicy: 'utilities'
|
paymentBalanceAdjustmentPolicy: 'utilities'
|
||||||
},
|
},
|
||||||
|
assistantConfig: {
|
||||||
|
householdId: 'household-1',
|
||||||
|
assistantContext: 'House in Kojori',
|
||||||
|
assistantTone: 'Playful'
|
||||||
|
},
|
||||||
topics: [
|
topics: [
|
||||||
{
|
{
|
||||||
householdId: 'household-1',
|
householdId: 'household-1',
|
||||||
@@ -566,6 +581,11 @@ describe('createMiniAppUpdateSettingsHandler', () => {
|
|||||||
utilitiesReminderDay: 5,
|
utilitiesReminderDay: 5,
|
||||||
timezone: 'Asia/Tbilisi',
|
timezone: 'Asia/Tbilisi',
|
||||||
paymentBalanceAdjustmentPolicy: 'utilities'
|
paymentBalanceAdjustmentPolicy: 'utilities'
|
||||||
|
},
|
||||||
|
assistantConfig: {
|
||||||
|
householdId: 'household-1',
|
||||||
|
assistantContext: 'House in Kojori',
|
||||||
|
assistantTone: 'Playful'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ async function readSettingsUpdatePayload(request: Request): Promise<{
|
|||||||
utilitiesDueDay: number
|
utilitiesDueDay: number
|
||||||
utilitiesReminderDay: number
|
utilitiesReminderDay: number
|
||||||
timezone: string
|
timezone: string
|
||||||
|
assistantContext?: string
|
||||||
|
assistantTone?: string
|
||||||
}> {
|
}> {
|
||||||
const clonedRequest = request.clone()
|
const clonedRequest = request.clone()
|
||||||
const payload = await readMiniAppRequestPayload(request)
|
const payload = await readMiniAppRequestPayload(request)
|
||||||
@@ -76,6 +78,8 @@ async function readSettingsUpdatePayload(request: Request): Promise<{
|
|||||||
utilitiesDueDay?: number
|
utilitiesDueDay?: number
|
||||||
utilitiesReminderDay?: number
|
utilitiesReminderDay?: number
|
||||||
timezone?: string
|
timezone?: string
|
||||||
|
assistantContext?: string
|
||||||
|
assistantTone?: string
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(text)
|
parsed = JSON.parse(text)
|
||||||
@@ -115,6 +119,16 @@ async function readSettingsUpdatePayload(request: Request): Promise<{
|
|||||||
rentCurrency: parsed.rentCurrency
|
rentCurrency: parsed.rentCurrency
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(typeof parsed.assistantContext === 'string'
|
||||||
|
? {
|
||||||
|
assistantContext: parsed.assistantContext
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(typeof parsed.assistantTone === 'string'
|
||||||
|
? {
|
||||||
|
assistantTone: parsed.assistantTone
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
rentDueDay: parsed.rentDueDay,
|
rentDueDay: parsed.rentDueDay,
|
||||||
rentWarningDay: parsed.rentWarningDay,
|
rentWarningDay: parsed.rentWarningDay,
|
||||||
utilitiesDueDay: parsed.utilitiesDueDay,
|
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(
|
async function authenticateAdminSession(
|
||||||
request: Request,
|
request: Request,
|
||||||
sessionService: ReturnType<typeof createMiniAppSessionService>,
|
sessionService: ReturnType<typeof createMiniAppSessionService>,
|
||||||
@@ -520,6 +546,7 @@ export function createMiniAppSettingsHandler(options: {
|
|||||||
ok: true,
|
ok: true,
|
||||||
authorized: true,
|
authorized: true,
|
||||||
settings: serializeBillingSettings(result.settings),
|
settings: serializeBillingSettings(result.settings),
|
||||||
|
assistantConfig: serializeAssistantConfig(result.assistantConfig),
|
||||||
topics: result.topics,
|
topics: result.topics,
|
||||||
categories: result.categories,
|
categories: result.categories,
|
||||||
members: result.members,
|
members: result.members,
|
||||||
@@ -617,7 +644,17 @@ export function createMiniAppUpdateSettingsHandler(options: {
|
|||||||
rentWarningDay: payload.rentWarningDay,
|
rentWarningDay: payload.rentWarningDay,
|
||||||
utilitiesDueDay: payload.utilitiesDueDay,
|
utilitiesDueDay: payload.utilitiesDueDay,
|
||||||
utilitiesReminderDay: payload.utilitiesReminderDay,
|
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') {
|
if (result.status === 'rejected') {
|
||||||
@@ -638,7 +675,8 @@ export function createMiniAppUpdateSettingsHandler(options: {
|
|||||||
{
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
authorized: true,
|
authorized: true,
|
||||||
settings: serializeBillingSettings(result.settings)
|
settings: serializeBillingSettings(result.settings),
|
||||||
|
assistantConfig: serializeAssistantConfig(result.assistantConfig)
|
||||||
},
|
},
|
||||||
200,
|
200,
|
||||||
origin
|
origin
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export type PurchaseMessageInterpreter = (
|
|||||||
options: {
|
options: {
|
||||||
defaultCurrency: 'GEL' | 'USD'
|
defaultCurrency: 'GEL' | 'USD'
|
||||||
clarificationContext?: PurchaseClarificationContext
|
clarificationContext?: PurchaseClarificationContext
|
||||||
|
householdContext?: string | null
|
||||||
|
assistantTone?: string | null
|
||||||
}
|
}
|
||||||
) => Promise<PurchaseInterpretation | 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 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.',
|
'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.',
|
'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.'
|
'Return only JSON that matches the schema.'
|
||||||
].join(' ')
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
|
|||||||
@@ -157,7 +157,11 @@ export interface PurchaseMessageIngestionRepository {
|
|||||||
save(
|
save(
|
||||||
record: PurchaseTopicRecord,
|
record: PurchaseTopicRecord,
|
||||||
interpreter?: PurchaseMessageInterpreter,
|
interpreter?: PurchaseMessageInterpreter,
|
||||||
defaultCurrency?: 'GEL' | 'USD'
|
defaultCurrency?: 'GEL' | 'USD',
|
||||||
|
options?: {
|
||||||
|
householdContext?: string | null
|
||||||
|
assistantTone?: string | null
|
||||||
|
}
|
||||||
): Promise<PurchaseMessageIngestionResult>
|
): Promise<PurchaseMessageIngestionResult>
|
||||||
confirm(
|
confirm(
|
||||||
purchaseMessageId: string,
|
purchaseMessageId: string,
|
||||||
@@ -820,7 +824,7 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
return Boolean(clarificationContext && clarificationContext.length > 0)
|
return Boolean(clarificationContext && clarificationContext.length > 0)
|
||||||
},
|
},
|
||||||
|
|
||||||
async save(record, interpreter, defaultCurrency) {
|
async save(record, interpreter, defaultCurrency, options) {
|
||||||
const matchedMember = await db
|
const matchedMember = await db
|
||||||
.select({ id: schema.members.id })
|
.select({ id: schema.members.id })
|
||||||
.from(schema.members)
|
.from(schema.members)
|
||||||
@@ -839,6 +843,8 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
const interpretation = interpreter
|
const interpretation = interpreter
|
||||||
? await interpreter(record.rawText, {
|
? await interpreter(record.rawText, {
|
||||||
defaultCurrency: defaultCurrency ?? 'GEL',
|
defaultCurrency: defaultCurrency ?? 'GEL',
|
||||||
|
householdContext: options?.householdContext ?? null,
|
||||||
|
assistantTone: options?.assistantTone ?? null,
|
||||||
...(clarificationContext
|
...(clarificationContext
|
||||||
? {
|
? {
|
||||||
clarificationContext: {
|
clarificationContext: {
|
||||||
@@ -1190,6 +1196,23 @@ async function resolveHouseholdLocale(
|
|||||||
return householdChat?.defaultLocale ?? 'en'
|
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(
|
async function handlePurchaseMessageResult(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
record: PurchaseTopicRecord,
|
record: PurchaseTopicRecord,
|
||||||
@@ -1529,9 +1552,10 @@ export function registerConfiguredPurchaseTopicIngestion(
|
|||||||
const typingIndicator = options.interpreter ? startTypingIndicator(ctx) : null
|
const typingIndicator = options.interpreter ? startTypingIndicator(ctx) : null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const billingSettings = await householdConfigurationRepository.getHouseholdBillingSettings(
|
const [billingSettings, assistantConfig] = await Promise.all([
|
||||||
record.householdId
|
householdConfigurationRepository.getHouseholdBillingSettings(record.householdId),
|
||||||
)
|
resolveAssistantConfig(householdConfigurationRepository, record.householdId)
|
||||||
|
])
|
||||||
const locale = await resolveHouseholdLocale(
|
const locale = await resolveHouseholdLocale(
|
||||||
householdConfigurationRepository,
|
householdConfigurationRepository,
|
||||||
record.householdId
|
record.householdId
|
||||||
@@ -1542,7 +1566,11 @@ export function registerConfiguredPurchaseTopicIngestion(
|
|||||||
const result = await repository.save(
|
const result = await repository.save(
|
||||||
record,
|
record,
|
||||||
options.interpreter,
|
options.interpreter,
|
||||||
billingSettings.settlementCurrency
|
billingSettings.settlementCurrency,
|
||||||
|
{
|
||||||
|
householdContext: assistantConfig.assistantContext,
|
||||||
|
assistantTone: assistantConfig.assistantTone
|
||||||
|
}
|
||||||
)
|
)
|
||||||
if (stripExplicitBotMention(ctx) && result.status === 'ignored_not_purchase') {
|
if (stripExplicitBotMention(ctx) && result.status === 'ignored_not_purchase') {
|
||||||
return await next()
|
return await next()
|
||||||
|
|||||||
@@ -37,7 +37,14 @@ describe('createBotWebhookServer', () => {
|
|||||||
miniAppSettings: {
|
miniAppSettings: {
|
||||||
handler: async () =>
|
handler: async () =>
|
||||||
new Response(
|
new Response(
|
||||||
JSON.stringify({ ok: true, authorized: true, settings: {}, categories: [], members: [] }),
|
JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
authorized: true,
|
||||||
|
settings: {},
|
||||||
|
assistantConfig: {},
|
||||||
|
categories: [],
|
||||||
|
members: []
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -48,12 +55,15 @@ describe('createBotWebhookServer', () => {
|
|||||||
},
|
},
|
||||||
miniAppUpdateSettings: {
|
miniAppUpdateSettings: {
|
||||||
handler: async () =>
|
handler: async () =>
|
||||||
new Response(JSON.stringify({ ok: true, authorized: true, settings: {} }), {
|
new Response(
|
||||||
status: 200,
|
JSON.stringify({ ok: true, authorized: true, settings: {}, assistantConfig: {} }),
|
||||||
headers: {
|
{
|
||||||
'content-type': 'application/json; charset=utf-8'
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json; charset=utf-8'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
},
|
},
|
||||||
miniAppUpsertUtilityCategory: {
|
miniAppUpsertUtilityCategory: {
|
||||||
handler: async () =>
|
handler: async () =>
|
||||||
@@ -305,6 +315,7 @@ describe('createBotWebhookServer', () => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
authorized: true,
|
authorized: true,
|
||||||
settings: {},
|
settings: {},
|
||||||
|
assistantConfig: {},
|
||||||
categories: [],
|
categories: [],
|
||||||
members: []
|
members: []
|
||||||
})
|
})
|
||||||
@@ -322,7 +333,8 @@ describe('createBotWebhookServer', () => {
|
|||||||
expect(await response.json()).toEqual({
|
expect(await response.json()).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
authorized: true,
|
authorized: true,
|
||||||
settings: {}
|
settings: {},
|
||||||
|
assistantConfig: {}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -376,7 +376,9 @@ function App() {
|
|||||||
rentWarningDay: 17,
|
rentWarningDay: 17,
|
||||||
utilitiesDueDay: 4,
|
utilitiesDueDay: 4,
|
||||||
utilitiesReminderDay: 3,
|
utilitiesReminderDay: 3,
|
||||||
timezone: 'Asia/Tbilisi'
|
timezone: 'Asia/Tbilisi',
|
||||||
|
assistantContext: '',
|
||||||
|
assistantTone: ''
|
||||||
})
|
})
|
||||||
const [newCategoryName, setNewCategoryName] = createSignal('')
|
const [newCategoryName, setNewCategoryName] = createSignal('')
|
||||||
const [cycleForm, setCycleForm] = createSignal({
|
const [cycleForm, setCycleForm] = createSignal({
|
||||||
@@ -917,7 +919,9 @@ function App() {
|
|||||||
rentWarningDay: payload.settings.rentWarningDay,
|
rentWarningDay: payload.settings.rentWarningDay,
|
||||||
utilitiesDueDay: payload.settings.utilitiesDueDay,
|
utilitiesDueDay: payload.settings.utilitiesDueDay,
|
||||||
utilitiesReminderDay: payload.settings.utilitiesReminderDay,
|
utilitiesReminderDay: payload.settings.utilitiesReminderDay,
|
||||||
timezone: payload.settings.timezone
|
timezone: payload.settings.timezone,
|
||||||
|
assistantContext: payload.assistantConfig.assistantContext ?? '',
|
||||||
|
assistantTone: payload.assistantConfig.assistantTone ?? ''
|
||||||
})
|
})
|
||||||
setPaymentForm((current) => ({
|
setPaymentForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -1033,7 +1037,9 @@ function App() {
|
|||||||
rentWarningDay: demoAdminSettings.settings.rentWarningDay,
|
rentWarningDay: demoAdminSettings.settings.rentWarningDay,
|
||||||
utilitiesDueDay: demoAdminSettings.settings.utilitiesDueDay,
|
utilitiesDueDay: demoAdminSettings.settings.utilitiesDueDay,
|
||||||
utilitiesReminderDay: demoAdminSettings.settings.utilitiesReminderDay,
|
utilitiesReminderDay: demoAdminSettings.settings.utilitiesReminderDay,
|
||||||
timezone: demoAdminSettings.settings.timezone
|
timezone: demoAdminSettings.settings.timezone,
|
||||||
|
assistantContext: demoAdminSettings.assistantConfig.assistantContext ?? '',
|
||||||
|
assistantTone: demoAdminSettings.assistantConfig.assistantTone ?? ''
|
||||||
})
|
})
|
||||||
setCycleForm((current) => ({
|
setCycleForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -1338,12 +1344,16 @@ function App() {
|
|||||||
setSavingBillingSettings(true)
|
setSavingBillingSettings(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const settings = await updateMiniAppBillingSettings(initData, billingForm())
|
const { settings, assistantConfig } = await updateMiniAppBillingSettings(
|
||||||
|
initData,
|
||||||
|
billingForm()
|
||||||
|
)
|
||||||
setAdminSettings((current) =>
|
setAdminSettings((current) =>
|
||||||
current
|
current
|
||||||
? {
|
? {
|
||||||
...current,
|
...current,
|
||||||
settings
|
settings,
|
||||||
|
assistantConfig
|
||||||
}
|
}
|
||||||
: current
|
: current
|
||||||
)
|
)
|
||||||
@@ -2230,6 +2240,18 @@ function App() {
|
|||||||
timezone: value
|
timezone: value
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
onBillingAssistantContextChange={(value) =>
|
||||||
|
setBillingForm((current) => ({
|
||||||
|
...current,
|
||||||
|
assistantContext: value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onBillingAssistantToneChange={(value) =>
|
||||||
|
setBillingForm((current) => ({
|
||||||
|
...current,
|
||||||
|
assistantTone: value
|
||||||
|
}))
|
||||||
|
}
|
||||||
onOpenAddUtilityBill={() => setAddingUtilityBillOpen(true)}
|
onOpenAddUtilityBill={() => setAddingUtilityBillOpen(true)}
|
||||||
onCloseAddUtilityBill={() => setAddingUtilityBillOpen(false)}
|
onCloseAddUtilityBill={() => setAddingUtilityBillOpen(false)}
|
||||||
onAddUtilityBill={handleAddUtilityBill}
|
onAddUtilityBill={handleAddUtilityBill}
|
||||||
|
|||||||
@@ -203,6 +203,11 @@ export const demoAdminSettings: MiniAppAdminSettingsPayload = {
|
|||||||
utilitiesReminderDay: 3,
|
utilitiesReminderDay: 3,
|
||||||
timezone: 'Asia/Tbilisi'
|
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: [
|
topics: [
|
||||||
{ role: 'purchase', telegramThreadId: '101', topicName: 'Purchases' },
|
{ role: 'purchase', telegramThreadId: '101', topicName: 'Purchases' },
|
||||||
{ role: 'feedback', telegramThreadId: '102', topicName: 'Anonymous feedback' },
|
{ role: 'feedback', telegramThreadId: '102', topicName: 'Anonymous feedback' },
|
||||||
|
|||||||
@@ -147,6 +147,16 @@ export const dictionary = {
|
|||||||
topicBound: 'Bound',
|
topicBound: 'Bound',
|
||||||
topicUnbound: 'Unbound',
|
topicUnbound: 'Unbound',
|
||||||
billingSettingsTitle: 'Billing settings',
|
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',
|
settlementCurrency: 'Settlement currency',
|
||||||
paymentBalanceAdjustmentPolicy: 'Purchase balance adjustment',
|
paymentBalanceAdjustmentPolicy: 'Purchase balance adjustment',
|
||||||
paymentBalanceAdjustmentUtilities: 'Adjust through utilities',
|
paymentBalanceAdjustmentUtilities: 'Adjust through utilities',
|
||||||
@@ -392,6 +402,15 @@ export const dictionary = {
|
|||||||
topicBound: 'Привязан',
|
topicBound: 'Привязан',
|
||||||
topicUnbound: 'Не привязан',
|
topicUnbound: 'Не привязан',
|
||||||
billingSettingsTitle: 'Настройки биллинга',
|
billingSettingsTitle: 'Настройки биллинга',
|
||||||
|
assistantSettingsTitle: 'Характер бота',
|
||||||
|
assistantSettingsBody:
|
||||||
|
'Задай бытовой контекст и тон, чтобы ответы бота звучали уместно и не лезли в разговор без повода.',
|
||||||
|
assistantToneLabel: 'Настроение бота',
|
||||||
|
assistantTonePlaceholder: 'Игривый, сухой, короткий, слегка саркастичный',
|
||||||
|
assistantToneDefault: 'По умолчанию',
|
||||||
|
assistantContextLabel: 'Контекст дома',
|
||||||
|
assistantContextPlaceholder: 'Это дом в Коджори, рядом двор и сосновый лес.',
|
||||||
|
assistantContextEmpty: 'Контекст не задан',
|
||||||
settlementCurrency: 'Валюта расчёта',
|
settlementCurrency: 'Валюта расчёта',
|
||||||
paymentBalanceAdjustmentPolicy: 'Зачёт баланса по покупкам',
|
paymentBalanceAdjustmentPolicy: 'Зачёт баланса по покупкам',
|
||||||
paymentBalanceAdjustmentUtilities: 'Зачитывать через коммуналку',
|
paymentBalanceAdjustmentUtilities: 'Зачитывать через коммуналку',
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ export interface MiniAppBillingSettings {
|
|||||||
timezone: string
|
timezone: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MiniAppAssistantConfig {
|
||||||
|
householdId: string
|
||||||
|
assistantContext: string | null
|
||||||
|
assistantTone: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface MiniAppUtilityCategory {
|
export interface MiniAppUtilityCategory {
|
||||||
id: string
|
id: string
|
||||||
householdId: string
|
householdId: string
|
||||||
@@ -133,6 +139,7 @@ export interface MiniAppDashboard {
|
|||||||
|
|
||||||
export interface MiniAppAdminSettingsPayload {
|
export interface MiniAppAdminSettingsPayload {
|
||||||
settings: MiniAppBillingSettings
|
settings: MiniAppBillingSettings
|
||||||
|
assistantConfig: MiniAppAssistantConfig
|
||||||
topics: readonly MiniAppTopicBinding[]
|
topics: readonly MiniAppTopicBinding[]
|
||||||
categories: readonly MiniAppUtilityCategory[]
|
categories: readonly MiniAppUtilityCategory[]
|
||||||
members: readonly MiniAppMember[]
|
members: readonly MiniAppMember[]
|
||||||
@@ -380,6 +387,7 @@ export async function fetchMiniAppAdminSettings(
|
|||||||
ok: boolean
|
ok: boolean
|
||||||
authorized?: boolean
|
authorized?: boolean
|
||||||
settings?: MiniAppBillingSettings
|
settings?: MiniAppBillingSettings
|
||||||
|
assistantConfig?: MiniAppAssistantConfig
|
||||||
topics?: MiniAppTopicBinding[]
|
topics?: MiniAppTopicBinding[]
|
||||||
categories?: MiniAppUtilityCategory[]
|
categories?: MiniAppUtilityCategory[]
|
||||||
members?: MiniAppMember[]
|
members?: MiniAppMember[]
|
||||||
@@ -391,6 +399,7 @@ export async function fetchMiniAppAdminSettings(
|
|||||||
!response.ok ||
|
!response.ok ||
|
||||||
!payload.authorized ||
|
!payload.authorized ||
|
||||||
!payload.settings ||
|
!payload.settings ||
|
||||||
|
!payload.assistantConfig ||
|
||||||
!payload.topics ||
|
!payload.topics ||
|
||||||
!payload.categories ||
|
!payload.categories ||
|
||||||
!payload.members ||
|
!payload.members ||
|
||||||
@@ -401,6 +410,7 @@ export async function fetchMiniAppAdminSettings(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
settings: payload.settings,
|
settings: payload.settings,
|
||||||
|
assistantConfig: payload.assistantConfig,
|
||||||
topics: payload.topics,
|
topics: payload.topics,
|
||||||
categories: payload.categories,
|
categories: payload.categories,
|
||||||
members: payload.members,
|
members: payload.members,
|
||||||
@@ -420,8 +430,13 @@ export async function updateMiniAppBillingSettings(
|
|||||||
utilitiesDueDay: number
|
utilitiesDueDay: number
|
||||||
utilitiesReminderDay: number
|
utilitiesReminderDay: number
|
||||||
timezone: string
|
timezone: string
|
||||||
|
assistantContext?: string
|
||||||
|
assistantTone?: string
|
||||||
}
|
}
|
||||||
): Promise<MiniAppBillingSettings> {
|
): Promise<{
|
||||||
|
settings: MiniAppBillingSettings
|
||||||
|
assistantConfig: MiniAppAssistantConfig
|
||||||
|
}> {
|
||||||
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/settings/update`, {
|
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/settings/update`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -437,14 +452,18 @@ export async function updateMiniAppBillingSettings(
|
|||||||
ok: boolean
|
ok: boolean
|
||||||
authorized?: boolean
|
authorized?: boolean
|
||||||
settings?: MiniAppBillingSettings
|
settings?: MiniAppBillingSettings
|
||||||
|
assistantConfig?: MiniAppAssistantConfig
|
||||||
error?: string
|
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')
|
throw new Error(payload.error ?? 'Failed to update billing settings')
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload.settings
|
return {
|
||||||
|
settings: payload.settings,
|
||||||
|
assistantConfig: payload.assistantConfig
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertMiniAppUtilityCategory(
|
export async function upsertMiniAppUtilityCategory(
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ type BillingForm = {
|
|||||||
utilitiesDueDay: number
|
utilitiesDueDay: number
|
||||||
utilitiesReminderDay: number
|
utilitiesReminderDay: number
|
||||||
timezone: string
|
timezone: string
|
||||||
|
assistantContext: string
|
||||||
|
assistantTone: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type CycleForm = {
|
type CycleForm = {
|
||||||
@@ -121,6 +123,8 @@ type Props = {
|
|||||||
onBillingUtilitiesDueDayChange: (value: number | null) => void
|
onBillingUtilitiesDueDayChange: (value: number | null) => void
|
||||||
onBillingUtilitiesReminderDayChange: (value: number | null) => void
|
onBillingUtilitiesReminderDayChange: (value: number | null) => void
|
||||||
onBillingTimezoneChange: (value: string) => void
|
onBillingTimezoneChange: (value: string) => void
|
||||||
|
onBillingAssistantContextChange: (value: string) => void
|
||||||
|
onBillingAssistantToneChange: (value: string) => void
|
||||||
onOpenAddUtilityBill: () => void
|
onOpenAddUtilityBill: () => void
|
||||||
onCloseAddUtilityBill: () => void
|
onCloseAddUtilityBill: () => void
|
||||||
onAddUtilityBill: () => Promise<void>
|
onAddUtilityBill: () => Promise<void>
|
||||||
@@ -260,23 +264,22 @@ export function HouseScreen(props: Props) {
|
|||||||
|
|
||||||
<article class="balance-item">
|
<article class="balance-item">
|
||||||
<header>
|
<header>
|
||||||
<strong>{props.copy.billingSettingsTitle ?? ''}</strong>
|
<strong>{props.copy.assistantSettingsTitle ?? ''}</strong>
|
||||||
<span>{props.billingForm.settlementCurrency}</span>
|
<span>
|
||||||
|
{props.billingForm.assistantTone || (props.copy.assistantToneDefault ?? '')}
|
||||||
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<p>
|
<p>{props.copy.assistantSettingsBody ?? ''}</p>
|
||||||
{props.billingForm.paymentBalanceAdjustmentPolicy === 'utilities'
|
|
||||||
? props.copy.paymentBalanceAdjustmentUtilities
|
|
||||||
: props.billingForm.paymentBalanceAdjustmentPolicy === 'rent'
|
|
||||||
? props.copy.paymentBalanceAdjustmentRent
|
|
||||||
: props.copy.paymentBalanceAdjustmentSeparate}
|
|
||||||
</p>
|
|
||||||
<div class="ledger-compact-card__meta">
|
<div class="ledger-compact-card__meta">
|
||||||
<span class="mini-chip">
|
<span class="mini-chip">
|
||||||
{props.copy.rentAmount ?? ''}: {props.billingForm.rentAmountMajor || '—'}{' '}
|
{props.copy.assistantToneLabel ?? ''}:{' '}
|
||||||
{props.billingForm.rentCurrency}
|
{props.billingForm.assistantTone || props.copy.assistantToneDefault || '—'}
|
||||||
</span>
|
</span>
|
||||||
<span class="mini-chip mini-chip--muted">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-toolbar">
|
<div class="panel-toolbar">
|
||||||
@@ -514,6 +517,27 @@ export function HouseScreen(props: Props) {
|
|||||||
onInput={(event) => props.onBillingTimezoneChange(event.currentTarget.value)}
|
onInput={(event) => props.onBillingTimezoneChange(event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES,
|
HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES,
|
||||||
HOUSEHOLD_PAYMENT_BALANCE_ADJUSTMENT_POLICIES,
|
HOUSEHOLD_PAYMENT_BALANCE_ADJUSTMENT_POLICIES,
|
||||||
HOUSEHOLD_TOPIC_ROLES,
|
HOUSEHOLD_TOPIC_ROLES,
|
||||||
|
type HouseholdAssistantConfigRecord,
|
||||||
type HouseholdMemberAbsencePolicy,
|
type HouseholdMemberAbsencePolicy,
|
||||||
type HouseholdMemberAbsencePolicyRecord,
|
type HouseholdMemberAbsencePolicyRecord,
|
||||||
type HouseholdBillingSettingsRecord,
|
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: {
|
function toHouseholdUtilityCategoryRecord(row: {
|
||||||
id: string
|
id: string
|
||||||
householdId: string
|
householdId: string
|
||||||
@@ -957,6 +970,25 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
|||||||
return toHouseholdBillingSettingsRecord(row)
|
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) {
|
async updateHouseholdBillingSettings(input) {
|
||||||
await ensureBillingSettings(input.householdId)
|
await ensureBillingSettings(input.householdId)
|
||||||
|
|
||||||
@@ -1033,6 +1065,36 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
|||||||
return toHouseholdBillingSettingsRecord(row)
|
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) {
|
async listHouseholdUtilityCategories(householdId) {
|
||||||
await ensureUtilityCategories(householdId)
|
await ensureUtilityCategories(householdId)
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,16 @@ function repository(): HouseholdConfigurationRepository {
|
|||||||
utilitiesReminderDay: input.utilitiesReminderDay ?? 3,
|
utilitiesReminderDay: input.utilitiesReminderDay ?? 3,
|
||||||
timezone: input.timezone ?? 'Asia/Tbilisi'
|
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 () => [],
|
listHouseholdUtilityCategories: async () => [],
|
||||||
upsertHouseholdUtilityCategory: async (input) => ({
|
upsertHouseholdUtilityCategory: async (input) => ({
|
||||||
id: input.slug ?? 'utility-category-1',
|
id: input.slug ?? 'utility-category-1',
|
||||||
@@ -269,6 +279,11 @@ describe('createMiniAppAdminService', () => {
|
|||||||
utilitiesReminderDay: 3,
|
utilitiesReminderDay: 3,
|
||||||
timezone: 'Asia/Tbilisi'
|
timezone: 'Asia/Tbilisi'
|
||||||
},
|
},
|
||||||
|
assistantConfig: {
|
||||||
|
householdId: 'household-1',
|
||||||
|
assistantContext: 'House in Kojori',
|
||||||
|
assistantTone: 'Playful'
|
||||||
|
},
|
||||||
topics: [
|
topics: [
|
||||||
{
|
{
|
||||||
householdId: 'household-1',
|
householdId: 'household-1',
|
||||||
@@ -322,6 +337,11 @@ describe('createMiniAppAdminService', () => {
|
|||||||
utilitiesDueDay: 5,
|
utilitiesDueDay: 5,
|
||||||
utilitiesReminderDay: 4,
|
utilitiesReminderDay: 4,
|
||||||
timezone: 'Asia/Tbilisi'
|
timezone: 'Asia/Tbilisi'
|
||||||
|
},
|
||||||
|
assistantConfig: {
|
||||||
|
householdId: 'household-1',
|
||||||
|
assistantContext: 'House in Kojori',
|
||||||
|
assistantTone: 'Playful'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
HouseholdAssistantConfigRecord,
|
||||||
HouseholdBillingSettingsRecord,
|
HouseholdBillingSettingsRecord,
|
||||||
HouseholdConfigurationRepository,
|
HouseholdConfigurationRepository,
|
||||||
HouseholdMemberAbsencePolicy,
|
HouseholdMemberAbsencePolicy,
|
||||||
@@ -29,6 +30,7 @@ export interface MiniAppAdminService {
|
|||||||
| {
|
| {
|
||||||
status: 'ok'
|
status: 'ok'
|
||||||
settings: HouseholdBillingSettingsRecord
|
settings: HouseholdBillingSettingsRecord
|
||||||
|
assistantConfig: HouseholdAssistantConfigRecord
|
||||||
categories: readonly HouseholdUtilityCategoryRecord[]
|
categories: readonly HouseholdUtilityCategoryRecord[]
|
||||||
members: readonly HouseholdMemberRecord[]
|
members: readonly HouseholdMemberRecord[]
|
||||||
memberAbsencePolicies: readonly HouseholdMemberAbsencePolicyRecord[]
|
memberAbsencePolicies: readonly HouseholdMemberAbsencePolicyRecord[]
|
||||||
@@ -51,10 +53,13 @@ export interface MiniAppAdminService {
|
|||||||
utilitiesDueDay: number
|
utilitiesDueDay: number
|
||||||
utilitiesReminderDay: number
|
utilitiesReminderDay: number
|
||||||
timezone: string
|
timezone: string
|
||||||
|
assistantContext?: string
|
||||||
|
assistantTone?: string
|
||||||
}): Promise<
|
}): Promise<
|
||||||
| {
|
| {
|
||||||
status: 'ok'
|
status: 'ok'
|
||||||
settings: HouseholdBillingSettingsRecord
|
settings: HouseholdBillingSettingsRecord
|
||||||
|
assistantConfig: HouseholdAssistantConfigRecord
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
status: 'rejected'
|
status: 'rejected'
|
||||||
@@ -210,6 +215,34 @@ function normalizeDisplayName(raw: string): string | null {
|
|||||||
return trimmed.replace(/\s+/g, ' ')
|
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(
|
export function createMiniAppAdminService(
|
||||||
repository: HouseholdConfigurationRepository
|
repository: HouseholdConfigurationRepository
|
||||||
): MiniAppAdminService {
|
): MiniAppAdminService {
|
||||||
@@ -222,17 +255,22 @@ export function createMiniAppAdminService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [settings, categories, members, memberAbsencePolicies, topics] = await Promise.all([
|
const [settings, assistantConfig, categories, members, memberAbsencePolicies, topics] =
|
||||||
repository.getHouseholdBillingSettings(input.householdId),
|
await Promise.all([
|
||||||
repository.listHouseholdUtilityCategories(input.householdId),
|
repository.getHouseholdBillingSettings(input.householdId),
|
||||||
repository.listHouseholdMembers(input.householdId),
|
repository.getHouseholdAssistantConfig
|
||||||
repository.listHouseholdMemberAbsencePolicies(input.householdId),
|
? repository.getHouseholdAssistantConfig(input.householdId)
|
||||||
repository.listHouseholdTopicBindings(input.householdId)
|
: Promise.resolve(defaultAssistantConfig(input.householdId)),
|
||||||
])
|
repository.listHouseholdUtilityCategories(input.householdId),
|
||||||
|
repository.listHouseholdMembers(input.householdId),
|
||||||
|
repository.listHouseholdMemberAbsencePolicies(input.householdId),
|
||||||
|
repository.listHouseholdTopicBindings(input.householdId)
|
||||||
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
settings,
|
settings,
|
||||||
|
assistantConfig,
|
||||||
categories,
|
categories,
|
||||||
members,
|
members,
|
||||||
memberAbsencePolicies,
|
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 rentAmountMinor: bigint | null | undefined
|
||||||
let rentCurrency: CurrencyCode | undefined
|
let rentCurrency: CurrencyCode | undefined
|
||||||
const settlementCurrency = input.settlementCurrency
|
const settlementCurrency = input.settlementCurrency
|
||||||
@@ -291,38 +346,65 @@ export function createMiniAppAdminService(
|
|||||||
rentCurrency = parseCurrency(input.rentCurrency ?? 'USD')
|
rentCurrency = parseCurrency(input.rentCurrency ?? 'USD')
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = await repository.updateHouseholdBillingSettings({
|
const shouldUpdateAssistantConfig =
|
||||||
householdId: input.householdId,
|
assistantContext !== undefined || assistantTone !== undefined
|
||||||
...(settlementCurrency
|
|
||||||
? {
|
const [settings, nextAssistantConfig] = await Promise.all([
|
||||||
settlementCurrency
|
repository.updateHouseholdBillingSettings({
|
||||||
}
|
householdId: input.householdId,
|
||||||
: {}),
|
...(settlementCurrency
|
||||||
...(paymentBalanceAdjustmentPolicy
|
? {
|
||||||
? {
|
settlementCurrency
|
||||||
paymentBalanceAdjustmentPolicy
|
}
|
||||||
}
|
: {}),
|
||||||
: {}),
|
...(paymentBalanceAdjustmentPolicy
|
||||||
...(rentAmountMinor !== undefined
|
? {
|
||||||
? {
|
paymentBalanceAdjustmentPolicy
|
||||||
rentAmountMinor
|
}
|
||||||
}
|
: {}),
|
||||||
: {}),
|
...(rentAmountMinor !== undefined
|
||||||
...(rentCurrency
|
? {
|
||||||
? {
|
rentAmountMinor
|
||||||
rentCurrency
|
}
|
||||||
}
|
: {}),
|
||||||
: {}),
|
...(rentCurrency
|
||||||
rentDueDay: input.rentDueDay,
|
? {
|
||||||
rentWarningDay: input.rentWarningDay,
|
rentCurrency
|
||||||
utilitiesDueDay: input.utilitiesDueDay,
|
}
|
||||||
utilitiesReminderDay: input.utilitiesReminderDay,
|
: {}),
|
||||||
timezone: input.timezone.trim()
|
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 {
|
return {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
settings
|
settings,
|
||||||
|
assistantConfig: nextAssistantConfig
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"0014_empty_risque.sql": "6dd4aba0f84d43bc86afbd04cad3b1055ecac03bd80ad6fd510bef1550d10335",
|
"0014_empty_risque.sql": "6dd4aba0f84d43bc86afbd04cad3b1055ecac03bd80ad6fd510bef1550d10335",
|
||||||
"0015_white_owl.sql": "a9dec4c536c660d7eb0fcea42a3bedb1301408551977d098dff8324d7d5b26bd",
|
"0015_white_owl.sql": "a9dec4c536c660d7eb0fcea42a3bedb1301408551977d098dff8324d7d5b26bd",
|
||||||
"0016_equal_susan_delgado.sql": "1698bf0516d16d2d7929dcb1bd2bb76d5a629eaba3d0bb2533c1ae926408de7a",
|
"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,
|
"when": 1773226133315,
|
||||||
"tag": "0017_gigantic_selene",
|
"tag": "0017_gigantic_selene",
|
||||||
"breakpoints": true
|
"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(),
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
defaultLocale: text('default_locale').default('ru').notNull(),
|
defaultLocale: text('default_locale').default('ru').notNull(),
|
||||||
|
assistantContext: text('assistant_context'),
|
||||||
|
assistantTone: text('assistant_tone'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ export interface HouseholdBillingSettingsRecord {
|
|||||||
timezone: string
|
timezone: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HouseholdAssistantConfigRecord {
|
||||||
|
householdId: string
|
||||||
|
assistantContext: string | null
|
||||||
|
assistantTone: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface HouseholdUtilityCategoryRecord {
|
export interface HouseholdUtilityCategoryRecord {
|
||||||
id: string
|
id: string
|
||||||
householdId: string
|
householdId: string
|
||||||
@@ -166,6 +172,7 @@ export interface HouseholdConfigurationRepository {
|
|||||||
): Promise<HouseholdMemberRecord | null>
|
): Promise<HouseholdMemberRecord | null>
|
||||||
listHouseholdMembers(householdId: string): Promise<readonly HouseholdMemberRecord[]>
|
listHouseholdMembers(householdId: string): Promise<readonly HouseholdMemberRecord[]>
|
||||||
getHouseholdBillingSettings(householdId: string): Promise<HouseholdBillingSettingsRecord>
|
getHouseholdBillingSettings(householdId: string): Promise<HouseholdBillingSettingsRecord>
|
||||||
|
getHouseholdAssistantConfig?(householdId: string): Promise<HouseholdAssistantConfigRecord>
|
||||||
updateHouseholdBillingSettings(input: {
|
updateHouseholdBillingSettings(input: {
|
||||||
householdId: string
|
householdId: string
|
||||||
settlementCurrency?: CurrencyCode
|
settlementCurrency?: CurrencyCode
|
||||||
@@ -178,6 +185,11 @@ export interface HouseholdConfigurationRepository {
|
|||||||
utilitiesReminderDay?: number
|
utilitiesReminderDay?: number
|
||||||
timezone?: string
|
timezone?: string
|
||||||
}): Promise<HouseholdBillingSettingsRecord>
|
}): Promise<HouseholdBillingSettingsRecord>
|
||||||
|
updateHouseholdAssistantConfig?(input: {
|
||||||
|
householdId: string
|
||||||
|
assistantContext?: string | null
|
||||||
|
assistantTone?: string | null
|
||||||
|
}): Promise<HouseholdAssistantConfigRecord>
|
||||||
listHouseholdUtilityCategories(
|
listHouseholdUtilityCategories(
|
||||||
householdId: string
|
householdId: string
|
||||||
): Promise<readonly HouseholdUtilityCategoryRecord[]>
|
): Promise<readonly HouseholdUtilityCategoryRecord[]>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export {
|
|||||||
HOUSEHOLD_TOPIC_ROLES,
|
HOUSEHOLD_TOPIC_ROLES,
|
||||||
type HouseholdMemberAbsencePolicy,
|
type HouseholdMemberAbsencePolicy,
|
||||||
type HouseholdMemberAbsencePolicyRecord,
|
type HouseholdMemberAbsencePolicyRecord,
|
||||||
|
type HouseholdAssistantConfigRecord,
|
||||||
type HouseholdPaymentBalanceAdjustmentPolicy,
|
type HouseholdPaymentBalanceAdjustmentPolicy,
|
||||||
type HouseholdConfigurationRepository,
|
type HouseholdConfigurationRepository,
|
||||||
type HouseholdBillingSettingsRecord,
|
type HouseholdBillingSettingsRecord,
|
||||||
|
|||||||
Reference in New Issue
Block a user