mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 21:04:03 +00:00
feat(bot): add multi-household setup flow
This commit is contained in:
193
packages/application/src/household-setup-service.test.ts
Normal file
193
packages/application/src/household-setup-service.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import type {
|
||||
HouseholdConfigurationRepository,
|
||||
HouseholdTelegramChatRecord,
|
||||
HouseholdTopicBindingRecord
|
||||
} from '@household/ports'
|
||||
|
||||
import { createHouseholdSetupService } from './household-setup-service'
|
||||
|
||||
function createRepositoryStub() {
|
||||
const households = new Map<string, HouseholdTelegramChatRecord>()
|
||||
const bindings = new Map<string, HouseholdTopicBindingRecord[]>()
|
||||
|
||||
const repository: HouseholdConfigurationRepository = {
|
||||
async registerTelegramHouseholdChat(input) {
|
||||
const existing = households.get(input.telegramChatId)
|
||||
if (existing) {
|
||||
const next = {
|
||||
...existing,
|
||||
telegramChatType: input.telegramChatType,
|
||||
title: input.title?.trim() || existing.title
|
||||
}
|
||||
households.set(input.telegramChatId, next)
|
||||
return {
|
||||
status: 'existing',
|
||||
household: next
|
||||
}
|
||||
}
|
||||
|
||||
const created: HouseholdTelegramChatRecord = {
|
||||
householdId: `household-${households.size + 1}`,
|
||||
householdName: input.householdName,
|
||||
telegramChatId: input.telegramChatId,
|
||||
telegramChatType: input.telegramChatType,
|
||||
title: input.title?.trim() || null
|
||||
}
|
||||
households.set(input.telegramChatId, created)
|
||||
|
||||
return {
|
||||
status: 'created',
|
||||
household: created
|
||||
}
|
||||
},
|
||||
|
||||
async getTelegramHouseholdChat(telegramChatId) {
|
||||
return households.get(telegramChatId) ?? null
|
||||
},
|
||||
|
||||
async bindHouseholdTopic(input) {
|
||||
const next: HouseholdTopicBindingRecord = {
|
||||
householdId: input.householdId,
|
||||
role: input.role,
|
||||
telegramThreadId: input.telegramThreadId,
|
||||
topicName: input.topicName?.trim() || null
|
||||
}
|
||||
const existing = bindings.get(input.householdId) ?? []
|
||||
const filtered = existing.filter((entry) => entry.role !== input.role)
|
||||
bindings.set(input.householdId, [...filtered, next])
|
||||
return next
|
||||
},
|
||||
|
||||
async getHouseholdTopicBinding(householdId, role) {
|
||||
return bindings.get(householdId)?.find((entry) => entry.role === role) ?? null
|
||||
},
|
||||
|
||||
async findHouseholdTopicByTelegramContext(input) {
|
||||
const household = households.get(input.telegramChatId)
|
||||
if (!household) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
bindings
|
||||
.get(household.householdId)
|
||||
?.find((entry) => entry.telegramThreadId === input.telegramThreadId) ?? null
|
||||
)
|
||||
},
|
||||
|
||||
async listHouseholdTopicBindings(householdId) {
|
||||
return bindings.get(householdId) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repository
|
||||
}
|
||||
}
|
||||
|
||||
describe('createHouseholdSetupService', () => {
|
||||
test('creates a new household chat binding for a group admin', async () => {
|
||||
const { repository } = createRepositoryStub()
|
||||
const service = createHouseholdSetupService(repository)
|
||||
|
||||
const result = await service.setupGroupChat({
|
||||
actorIsAdmin: true,
|
||||
telegramChatId: '-100123',
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Kojori House'
|
||||
})
|
||||
|
||||
expect(result.status).toBe('created')
|
||||
if (result.status !== 'created') {
|
||||
return
|
||||
}
|
||||
expect(result.household.householdName).toBe('Kojori House')
|
||||
expect(result.household.telegramChatId).toBe('-100123')
|
||||
})
|
||||
|
||||
test('rejects setup when the actor is not a group admin', async () => {
|
||||
const { repository } = createRepositoryStub()
|
||||
const service = createHouseholdSetupService(repository)
|
||||
|
||||
const result = await service.setupGroupChat({
|
||||
actorIsAdmin: false,
|
||||
telegramChatId: '-100123',
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Kojori House'
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'rejected',
|
||||
reason: 'not_admin'
|
||||
})
|
||||
})
|
||||
|
||||
test('binds a purchase topic for an existing household', async () => {
|
||||
const { repository } = createRepositoryStub()
|
||||
const service = createHouseholdSetupService(repository)
|
||||
const setup = await service.setupGroupChat({
|
||||
actorIsAdmin: true,
|
||||
telegramChatId: '-100123',
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Kojori House'
|
||||
})
|
||||
|
||||
expect(setup.status).toBe('created')
|
||||
|
||||
const result = await service.bindTopic({
|
||||
actorIsAdmin: true,
|
||||
telegramChatId: '-100123',
|
||||
role: 'purchase',
|
||||
telegramThreadId: '777',
|
||||
topicName: 'Общие покупки'
|
||||
})
|
||||
|
||||
expect(result.status).toBe('bound')
|
||||
if (result.status !== 'bound') {
|
||||
return
|
||||
}
|
||||
expect(result.binding.role).toBe('purchase')
|
||||
expect(result.binding.telegramThreadId).toBe('777')
|
||||
})
|
||||
|
||||
test('rejects topic binding when the household is not set up yet', async () => {
|
||||
const { repository } = createRepositoryStub()
|
||||
const service = createHouseholdSetupService(repository)
|
||||
|
||||
const result = await service.bindTopic({
|
||||
actorIsAdmin: true,
|
||||
telegramChatId: '-100123',
|
||||
role: 'feedback',
|
||||
telegramThreadId: '778'
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'rejected',
|
||||
reason: 'household_not_found'
|
||||
})
|
||||
})
|
||||
|
||||
test('rejects topic binding outside a topic thread', async () => {
|
||||
const { repository } = createRepositoryStub()
|
||||
const service = createHouseholdSetupService(repository)
|
||||
await service.setupGroupChat({
|
||||
actorIsAdmin: true,
|
||||
telegramChatId: '-100123',
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Kojori House'
|
||||
})
|
||||
|
||||
const result = await service.bindTopic({
|
||||
actorIsAdmin: true,
|
||||
telegramChatId: '-100123',
|
||||
role: 'feedback'
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'rejected',
|
||||
reason: 'not_topic_message'
|
||||
})
|
||||
})
|
||||
})
|
||||
133
packages/application/src/household-setup-service.ts
Normal file
133
packages/application/src/household-setup-service.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type {
|
||||
HouseholdConfigurationRepository,
|
||||
HouseholdTelegramChatRecord,
|
||||
HouseholdTopicBindingRecord,
|
||||
HouseholdTopicRole
|
||||
} from '@household/ports'
|
||||
|
||||
export interface HouseholdSetupService {
|
||||
setupGroupChat(input: {
|
||||
actorIsAdmin: boolean
|
||||
telegramChatId: string
|
||||
telegramChatType: string
|
||||
title?: string
|
||||
householdName?: string
|
||||
}): Promise<
|
||||
| {
|
||||
status: 'created' | 'existing'
|
||||
household: HouseholdTelegramChatRecord
|
||||
}
|
||||
| {
|
||||
status: 'rejected'
|
||||
reason: 'not_admin' | 'invalid_chat_type'
|
||||
}
|
||||
>
|
||||
bindTopic(input: {
|
||||
actorIsAdmin: boolean
|
||||
telegramChatId: string
|
||||
role: HouseholdTopicRole
|
||||
telegramThreadId?: string
|
||||
topicName?: string
|
||||
}): Promise<
|
||||
| {
|
||||
status: 'bound'
|
||||
household: HouseholdTelegramChatRecord
|
||||
binding: HouseholdTopicBindingRecord
|
||||
}
|
||||
| {
|
||||
status: 'rejected'
|
||||
reason: 'not_admin' | 'household_not_found' | 'not_topic_message'
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
function isSupportedGroupChat(chatType: string): boolean {
|
||||
return chatType === 'group' || chatType === 'supergroup'
|
||||
}
|
||||
|
||||
function defaultHouseholdName(title: string | undefined, telegramChatId: string): string {
|
||||
const normalizedTitle = title?.trim()
|
||||
return normalizedTitle && normalizedTitle.length > 0
|
||||
? normalizedTitle
|
||||
: `Household ${telegramChatId}`
|
||||
}
|
||||
|
||||
export function createHouseholdSetupService(
|
||||
repository: HouseholdConfigurationRepository
|
||||
): HouseholdSetupService {
|
||||
return {
|
||||
async setupGroupChat(input) {
|
||||
if (!input.actorIsAdmin) {
|
||||
return {
|
||||
status: 'rejected',
|
||||
reason: 'not_admin'
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSupportedGroupChat(input.telegramChatType)) {
|
||||
return {
|
||||
status: 'rejected',
|
||||
reason: 'invalid_chat_type'
|
||||
}
|
||||
}
|
||||
|
||||
const registered = await repository.registerTelegramHouseholdChat({
|
||||
householdName:
|
||||
input.householdName?.trim() || defaultHouseholdName(input.title, input.telegramChatId),
|
||||
telegramChatId: input.telegramChatId,
|
||||
telegramChatType: input.telegramChatType,
|
||||
...(input.title?.trim()
|
||||
? {
|
||||
title: input.title.trim()
|
||||
}
|
||||
: {})
|
||||
})
|
||||
|
||||
return {
|
||||
status: registered.status,
|
||||
household: registered.household
|
||||
}
|
||||
},
|
||||
|
||||
async bindTopic(input) {
|
||||
if (!input.actorIsAdmin) {
|
||||
return {
|
||||
status: 'rejected',
|
||||
reason: 'not_admin'
|
||||
}
|
||||
}
|
||||
|
||||
if (!input.telegramThreadId) {
|
||||
return {
|
||||
status: 'rejected',
|
||||
reason: 'not_topic_message'
|
||||
}
|
||||
}
|
||||
|
||||
const household = await repository.getTelegramHouseholdChat(input.telegramChatId)
|
||||
if (!household) {
|
||||
return {
|
||||
status: 'rejected',
|
||||
reason: 'household_not_found'
|
||||
}
|
||||
}
|
||||
|
||||
const binding = await repository.bindHouseholdTopic({
|
||||
householdId: household.householdId,
|
||||
role: input.role,
|
||||
telegramThreadId: input.telegramThreadId,
|
||||
...(input.topicName?.trim()
|
||||
? {
|
||||
topicName: input.topicName.trim()
|
||||
}
|
||||
: {})
|
||||
})
|
||||
|
||||
return {
|
||||
status: 'bound',
|
||||
household,
|
||||
binding
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
type AnonymousFeedbackSubmitResult
|
||||
} from './anonymous-feedback-service'
|
||||
export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service'
|
||||
export { createHouseholdSetupService, type HouseholdSetupService } from './household-setup-service'
|
||||
export {
|
||||
createReminderJobService,
|
||||
type ReminderJobResult,
|
||||
|
||||
Reference in New Issue
Block a user