mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 19:14:03 +00:00
feat(onboarding): add mini app household join flow
This commit is contained in:
@@ -114,7 +114,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
||||
householdId !== undefined &&
|
||||
telegramHouseholdChatId !== undefined &&
|
||||
telegramFeedbackTopicId !== undefined
|
||||
const miniAppAuthEnabled = databaseUrl !== undefined && householdId !== undefined
|
||||
const miniAppAuthEnabled = databaseUrl !== undefined
|
||||
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
|
||||
const reminderJobsEnabled =
|
||||
databaseUrl !== undefined &&
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HouseholdSetupService } from '@household/application'
|
||||
import type { HouseholdOnboardingService, HouseholdSetupService } from '@household/application'
|
||||
import type { Logger } from '@household/observability'
|
||||
import type { Bot, Context } from 'grammy'
|
||||
|
||||
@@ -51,8 +51,72 @@ function bindRejectionMessage(
|
||||
export function registerHouseholdSetupCommands(options: {
|
||||
bot: Bot
|
||||
householdSetupService: HouseholdSetupService
|
||||
householdOnboardingService: HouseholdOnboardingService
|
||||
miniAppBaseUrl?: string
|
||||
logger?: Logger
|
||||
}): void {
|
||||
options.bot.command('start', async (ctx) => {
|
||||
if (ctx.chat?.type !== 'private') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!ctx.from) {
|
||||
await ctx.reply('Telegram user identity is required to join a household.')
|
||||
return
|
||||
}
|
||||
|
||||
const startPayload = commandArgText(ctx)
|
||||
if (!startPayload.startsWith('join_')) {
|
||||
await ctx.reply('Send /help to see available commands.')
|
||||
return
|
||||
}
|
||||
|
||||
const joinToken = startPayload.slice('join_'.length).trim()
|
||||
if (!joinToken) {
|
||||
await ctx.reply('Invalid household invite link.')
|
||||
return
|
||||
}
|
||||
|
||||
const identity = {
|
||||
telegramUserId: ctx.from.id.toString(),
|
||||
displayName:
|
||||
[ctx.from.first_name, ctx.from.last_name].filter(Boolean).join(' ').trim() ||
|
||||
ctx.from.username ||
|
||||
`Telegram ${ctx.from.id}`,
|
||||
...(ctx.from.username
|
||||
? {
|
||||
username: ctx.from.username
|
||||
}
|
||||
: {}),
|
||||
...(ctx.from.language_code
|
||||
? {
|
||||
languageCode: ctx.from.language_code
|
||||
}
|
||||
: {})
|
||||
}
|
||||
|
||||
const result = await options.householdOnboardingService.joinHousehold({
|
||||
identity,
|
||||
joinToken
|
||||
})
|
||||
|
||||
if (result.status === 'invalid_token') {
|
||||
await ctx.reply('This household invite link is invalid or expired.')
|
||||
return
|
||||
}
|
||||
|
||||
if (result.status === 'active') {
|
||||
await ctx.reply(
|
||||
`You are already an active member. Open the mini app to view ${result.member.displayName}.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.reply(
|
||||
`Join request sent for ${result.household.name}. Wait for a household admin to confirm you.`
|
||||
)
|
||||
})
|
||||
|
||||
options.bot.command('setup', async (ctx) => {
|
||||
if (!isGroupChat(ctx)) {
|
||||
await ctx.reply('Use /setup inside the household group.')
|
||||
@@ -85,12 +149,64 @@ export function registerHouseholdSetupCommands(options: {
|
||||
)
|
||||
|
||||
const action = result.status === 'created' ? 'created' : 'already registered'
|
||||
const joinToken = await options.householdOnboardingService.ensureHouseholdJoinToken({
|
||||
householdId: result.household.householdId,
|
||||
...(ctx.from?.id
|
||||
? {
|
||||
actorTelegramUserId: ctx.from.id.toString()
|
||||
}
|
||||
: {})
|
||||
})
|
||||
|
||||
const joinDeepLink = ctx.me.username
|
||||
? `https://t.me/${ctx.me.username}?start=join_${encodeURIComponent(joinToken.token)}`
|
||||
: null
|
||||
const joinMiniAppUrl = options.miniAppBaseUrl
|
||||
? (() => {
|
||||
const url = new URL(options.miniAppBaseUrl)
|
||||
url.searchParams.set('join', joinToken.token)
|
||||
if (ctx.me.username) {
|
||||
url.searchParams.set('bot', ctx.me.username)
|
||||
}
|
||||
return url.toString()
|
||||
})()
|
||||
: null
|
||||
|
||||
await ctx.reply(
|
||||
[
|
||||
`Household ${action}: ${result.household.householdName}`,
|
||||
`Chat ID: ${result.household.telegramChatId}`,
|
||||
'Next: open the purchase topic and run /bind_purchase_topic, then open the feedback topic and run /bind_feedback_topic.'
|
||||
].join('\n')
|
||||
'Next: open the purchase topic and run /bind_purchase_topic, then open the feedback topic and run /bind_feedback_topic.',
|
||||
'Members can join from the button below or from the bot link.'
|
||||
].join('\n'),
|
||||
joinMiniAppUrl || joinDeepLink
|
||||
? {
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
...(joinMiniAppUrl
|
||||
? [
|
||||
{
|
||||
text: 'Join household',
|
||||
web_app: {
|
||||
url: joinMiniAppUrl
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(joinDeepLink
|
||||
? [
|
||||
{
|
||||
text: 'Open bot chat',
|
||||
url: joinDeepLink
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
: {}
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { webhookCallback } from 'grammy'
|
||||
import {
|
||||
createAnonymousFeedbackService,
|
||||
createFinanceCommandService,
|
||||
createHouseholdOnboardingService,
|
||||
createHouseholdSetupService,
|
||||
createReminderJobService
|
||||
} from '@household/application'
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
import { createReminderJobsHandler } from './reminder-jobs'
|
||||
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
|
||||
import { createBotWebhookServer } from './server'
|
||||
import { createMiniAppAuthHandler } from './miniapp-auth'
|
||||
import { createMiniAppAuthHandler, createMiniAppJoinHandler } from './miniapp-auth'
|
||||
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
|
||||
|
||||
const runtime = getBotRuntimeConfig()
|
||||
@@ -51,6 +52,16 @@ const financeRepositoryClient =
|
||||
const financeService = financeRepositoryClient
|
||||
? createFinanceCommandService(financeRepositoryClient.repository)
|
||||
: null
|
||||
const householdOnboardingService = householdConfigurationRepositoryClient
|
||||
? createHouseholdOnboardingService({
|
||||
repository: householdConfigurationRepositoryClient.repository,
|
||||
...(financeRepositoryClient
|
||||
? {
|
||||
getMemberByTelegramUserId: financeRepositoryClient.repository.getMemberByTelegramUserId
|
||||
}
|
||||
: {})
|
||||
})
|
||||
: null
|
||||
const anonymousFeedbackRepositoryClient = runtime.anonymousFeedbackEnabled
|
||||
? createDbAnonymousFeedbackRepository(runtime.databaseUrl!, runtime.householdId!)
|
||||
: null
|
||||
@@ -118,6 +129,12 @@ if (householdConfigurationRepositoryClient) {
|
||||
householdSetupService: createHouseholdSetupService(
|
||||
householdConfigurationRepositoryClient.repository
|
||||
),
|
||||
householdOnboardingService: householdOnboardingService!,
|
||||
...(runtime.miniAppAllowedOrigins[0]
|
||||
? {
|
||||
miniAppBaseUrl: runtime.miniAppAllowedOrigins[0]
|
||||
}
|
||||
: {}),
|
||||
logger: getLogger('household-setup')
|
||||
})
|
||||
} else {
|
||||
@@ -177,11 +194,19 @@ const server = createBotWebhookServer({
|
||||
webhookPath: runtime.telegramWebhookPath,
|
||||
webhookSecret: runtime.telegramWebhookSecret,
|
||||
webhookHandler,
|
||||
miniAppAuth: financeRepositoryClient
|
||||
miniAppAuth: householdOnboardingService
|
||||
? createMiniAppAuthHandler({
|
||||
allowedOrigins: runtime.miniAppAllowedOrigins,
|
||||
botToken: runtime.telegramBotToken,
|
||||
repository: financeRepositoryClient.repository,
|
||||
onboardingService: householdOnboardingService,
|
||||
logger: getLogger('miniapp-auth')
|
||||
})
|
||||
: undefined,
|
||||
miniAppJoin: householdOnboardingService
|
||||
? createMiniAppJoinHandler({
|
||||
allowedOrigins: runtime.miniAppAllowedOrigins,
|
||||
botToken: runtime.telegramBotToken,
|
||||
onboardingService: householdOnboardingService,
|
||||
logger: getLogger('miniapp-auth')
|
||||
})
|
||||
: undefined,
|
||||
@@ -190,6 +215,7 @@ const server = createBotWebhookServer({
|
||||
allowedOrigins: runtime.miniAppAllowedOrigins,
|
||||
botToken: runtime.telegramBotToken,
|
||||
financeService,
|
||||
onboardingService: householdOnboardingService!,
|
||||
logger: getLogger('miniapp-dashboard')
|
||||
})
|
||||
: undefined,
|
||||
|
||||
@@ -1,28 +1,77 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import type { FinanceRepository } from '@household/ports'
|
||||
import { createHouseholdOnboardingService } from '@household/application'
|
||||
import type {
|
||||
HouseholdConfigurationRepository,
|
||||
HouseholdTopicBindingRecord
|
||||
} from '@household/ports'
|
||||
|
||||
import { createMiniAppAuthHandler } from './miniapp-auth'
|
||||
import { createMiniAppAuthHandler, createMiniAppJoinHandler } from './miniapp-auth'
|
||||
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
||||
|
||||
function repository(
|
||||
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
|
||||
): FinanceRepository {
|
||||
function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
const household = {
|
||||
householdId: 'household-1',
|
||||
householdName: 'Kojori House',
|
||||
telegramChatId: '-100123',
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Kojori House'
|
||||
}
|
||||
let joinToken: string | null = 'join-token'
|
||||
let pending: {
|
||||
householdId: string
|
||||
householdName: string
|
||||
telegramUserId: string
|
||||
displayName: string
|
||||
username: string | null
|
||||
languageCode: string | null
|
||||
} | null = null
|
||||
|
||||
return {
|
||||
getMemberByTelegramUserId: async () => member,
|
||||
listMembers: async () => [],
|
||||
getOpenCycle: async () => null,
|
||||
getCycleByPeriod: async () => null,
|
||||
getLatestCycle: async () => null,
|
||||
openCycle: async () => {},
|
||||
closeCycle: async () => {},
|
||||
saveRentRule: async () => {},
|
||||
addUtilityBill: async () => {},
|
||||
getRentRuleForPeriod: async () => null,
|
||||
getUtilityTotalForCycle: async () => 0n,
|
||||
listUtilityBillsForCycle: async () => [],
|
||||
listParsedPurchasesForRange: async () => [],
|
||||
replaceSettlementSnapshot: async () => {}
|
||||
registerTelegramHouseholdChat: async () => ({
|
||||
status: 'existing',
|
||||
household
|
||||
}),
|
||||
getTelegramHouseholdChat: async () => household,
|
||||
bindHouseholdTopic: async (input) =>
|
||||
({
|
||||
householdId: input.householdId,
|
||||
role: input.role,
|
||||
telegramThreadId: input.telegramThreadId,
|
||||
topicName: input.topicName?.trim() || null
|
||||
}) satisfies HouseholdTopicBindingRecord,
|
||||
getHouseholdTopicBinding: async () => null,
|
||||
findHouseholdTopicByTelegramContext: async () => null,
|
||||
listHouseholdTopicBindings: async () => [],
|
||||
upsertHouseholdJoinToken: async (input) => ({
|
||||
householdId: household.householdId,
|
||||
householdName: household.householdName,
|
||||
token: input.token,
|
||||
createdByTelegramUserId: input.createdByTelegramUserId ?? null
|
||||
}),
|
||||
getHouseholdJoinToken: async () =>
|
||||
joinToken
|
||||
? {
|
||||
householdId: household.householdId,
|
||||
householdName: household.householdName,
|
||||
token: joinToken,
|
||||
createdByTelegramUserId: null
|
||||
}
|
||||
: null,
|
||||
getHouseholdByJoinToken: async (token) => (token === joinToken ? household : null),
|
||||
upsertPendingHouseholdMember: async (input) => {
|
||||
pending = {
|
||||
householdId: household.householdId,
|
||||
householdName: household.householdName,
|
||||
telegramUserId: input.telegramUserId,
|
||||
displayName: input.displayName,
|
||||
username: input.username?.trim() || null,
|
||||
languageCode: input.languageCode?.trim() || null
|
||||
}
|
||||
return pending
|
||||
},
|
||||
getPendingHouseholdMember: async () => pending,
|
||||
findPendingHouseholdMemberByTelegramUserId: async () => pending
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,11 +81,14 @@ describe('createMiniAppAuthHandler', () => {
|
||||
const auth = createMiniAppAuthHandler({
|
||||
allowedOrigins: ['http://localhost:5173'],
|
||||
botToken: 'test-bot-token',
|
||||
repository: repository({
|
||||
id: 'member-1',
|
||||
telegramUserId: '123456',
|
||||
displayName: 'Stan',
|
||||
isAdmin: true
|
||||
onboardingService: createHouseholdOnboardingService({
|
||||
repository: onboardingRepository(),
|
||||
getMemberByTelegramUserId: async () => ({
|
||||
id: 'member-1',
|
||||
telegramUserId: '123456',
|
||||
displayName: 'Stan',
|
||||
isAdmin: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,10 +119,6 @@ describe('createMiniAppAuthHandler', () => {
|
||||
displayName: 'Stan',
|
||||
isAdmin: true
|
||||
},
|
||||
features: {
|
||||
balances: true,
|
||||
ledger: true
|
||||
},
|
||||
telegramUser: {
|
||||
id: '123456',
|
||||
firstName: 'Stan',
|
||||
@@ -80,12 +128,14 @@ describe('createMiniAppAuthHandler', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('returns membership gate failure for a non-member', async () => {
|
||||
test('returns onboarding state for a non-member with a valid household token', async () => {
|
||||
const authDate = Math.floor(Date.now() / 1000)
|
||||
const auth = createMiniAppAuthHandler({
|
||||
allowedOrigins: ['http://localhost:5173'],
|
||||
botToken: 'test-bot-token',
|
||||
repository: repository(null)
|
||||
onboardingService: createHouseholdOnboardingService({
|
||||
repository: onboardingRepository()
|
||||
})
|
||||
})
|
||||
|
||||
const response = await auth.handler(
|
||||
@@ -99,16 +149,58 @@ describe('createMiniAppAuthHandler', () => {
|
||||
initData: buildMiniAppInitData('test-bot-token', authDate, {
|
||||
id: 123456,
|
||||
first_name: 'Stan'
|
||||
})
|
||||
}),
|
||||
joinToken: 'join-token'
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(await response.json()).toEqual({
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({
|
||||
ok: true,
|
||||
authorized: false,
|
||||
reason: 'not_member'
|
||||
onboarding: {
|
||||
status: 'join_required',
|
||||
householdName: 'Kojori House'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('creates a pending join request from the mini app', async () => {
|
||||
const authDate = Math.floor(Date.now() / 1000)
|
||||
const join = createMiniAppJoinHandler({
|
||||
allowedOrigins: ['http://localhost:5173'],
|
||||
botToken: 'test-bot-token',
|
||||
onboardingService: createHouseholdOnboardingService({
|
||||
repository: onboardingRepository()
|
||||
})
|
||||
})
|
||||
|
||||
const response = await join.handler(
|
||||
new Request('http://localhost/api/miniapp/join', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
origin: 'http://localhost:5173',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
initData: buildMiniAppInitData('test-bot-token', authDate, {
|
||||
id: 123456,
|
||||
first_name: 'Stan'
|
||||
}),
|
||||
joinToken: 'join-token'
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({
|
||||
ok: true,
|
||||
authorized: false,
|
||||
onboarding: {
|
||||
status: 'pending',
|
||||
householdName: 'Kojori House'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -116,7 +208,9 @@ describe('createMiniAppAuthHandler', () => {
|
||||
const auth = createMiniAppAuthHandler({
|
||||
allowedOrigins: ['http://localhost:5173'],
|
||||
botToken: 'test-bot-token',
|
||||
repository: repository(null)
|
||||
onboardingService: createHouseholdOnboardingService({
|
||||
repository: onboardingRepository()
|
||||
})
|
||||
})
|
||||
|
||||
const response = await auth.handler(
|
||||
@@ -136,48 +230,4 @@ describe('createMiniAppAuthHandler', () => {
|
||||
error: 'Invalid JSON body'
|
||||
})
|
||||
})
|
||||
|
||||
test('does not reflect arbitrary origins in production without an allow-list', async () => {
|
||||
const previousNodeEnv = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
try {
|
||||
const authDate = Math.floor(Date.now() / 1000)
|
||||
const auth = createMiniAppAuthHandler({
|
||||
allowedOrigins: [],
|
||||
botToken: 'test-bot-token',
|
||||
repository: repository({
|
||||
id: 'member-1',
|
||||
telegramUserId: '123456',
|
||||
displayName: 'Stan',
|
||||
isAdmin: true
|
||||
})
|
||||
})
|
||||
|
||||
const response = await auth.handler(
|
||||
new Request('http://localhost/api/miniapp/session', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
origin: 'https://unknown.example',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
initData: buildMiniAppInitData('test-bot-token', authDate, {
|
||||
id: 123456,
|
||||
first_name: 'Stan'
|
||||
})
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('access-control-allow-origin')).toBeNull()
|
||||
} finally {
|
||||
if (previousNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV
|
||||
} else {
|
||||
process.env.NODE_ENV = previousNodeEnv
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { FinanceMemberRecord, FinanceRepository } from '@household/ports'
|
||||
import type { HouseholdOnboardingService } from '@household/application'
|
||||
import type { Logger } from '@household/observability'
|
||||
|
||||
import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth'
|
||||
|
||||
export interface MiniAppRequestPayload {
|
||||
initData: string | null
|
||||
joinToken?: string
|
||||
}
|
||||
|
||||
export function miniAppJsonResponse(body: object, status = 200, origin?: string): Response {
|
||||
const headers = new Headers({
|
||||
'content-type': 'application/json; charset=utf-8'
|
||||
@@ -42,23 +47,33 @@ export function allowedMiniAppOrigin(
|
||||
return allowedOrigins.includes(origin) ? origin : undefined
|
||||
}
|
||||
|
||||
export async function readMiniAppInitData(request: Request): Promise<string | null> {
|
||||
export async function readMiniAppRequestPayload(request: Request): Promise<MiniAppRequestPayload> {
|
||||
const text = await request.text()
|
||||
|
||||
if (text.trim().length === 0) {
|
||||
return null
|
||||
return {
|
||||
initData: null
|
||||
}
|
||||
}
|
||||
|
||||
let parsed: { initData?: string }
|
||||
let parsed: { initData?: string; joinToken?: string }
|
||||
try {
|
||||
parsed = JSON.parse(text) as { initData?: string }
|
||||
parsed = JSON.parse(text) as { initData?: string; joinToken?: string }
|
||||
} catch {
|
||||
throw new Error('Invalid JSON body')
|
||||
}
|
||||
|
||||
const initData = parsed.initData?.trim()
|
||||
const joinToken = parsed.joinToken?.trim()
|
||||
|
||||
return initData && initData.length > 0 ? initData : null
|
||||
return {
|
||||
initData: initData && initData.length > 0 ? initData : null,
|
||||
...(joinToken && joinToken.length > 0
|
||||
? {
|
||||
joinToken
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
export function miniAppErrorResponse(error: unknown, origin?: string, logger?: Logger): Response {
|
||||
@@ -81,46 +96,83 @@ export function miniAppErrorResponse(error: unknown, origin?: string, logger?: L
|
||||
|
||||
export interface MiniAppSessionResult {
|
||||
authorized: boolean
|
||||
reason?: 'not_member'
|
||||
member?: {
|
||||
id: string
|
||||
displayName: string
|
||||
isAdmin: boolean
|
||||
}
|
||||
telegramUser?: ReturnType<typeof verifyTelegramMiniAppInitData>
|
||||
onboarding?: {
|
||||
status: 'join_required' | 'pending' | 'open_from_group'
|
||||
householdName?: string
|
||||
}
|
||||
}
|
||||
|
||||
type MiniAppMemberLookup = (telegramUserId: string) => Promise<FinanceMemberRecord | null>
|
||||
|
||||
export function createMiniAppSessionService(options: {
|
||||
botToken: string
|
||||
getMemberByTelegramUserId: MiniAppMemberLookup
|
||||
onboardingService: HouseholdOnboardingService
|
||||
}): {
|
||||
authenticate: (initData: string) => Promise<MiniAppSessionResult | null>
|
||||
authenticate: (payload: MiniAppRequestPayload) => Promise<MiniAppSessionResult | null>
|
||||
} {
|
||||
return {
|
||||
authenticate: async (initData) => {
|
||||
const telegramUser = verifyTelegramMiniAppInitData(initData, options.botToken)
|
||||
authenticate: async (payload) => {
|
||||
if (!payload.initData) {
|
||||
return null
|
||||
}
|
||||
|
||||
const telegramUser = verifyTelegramMiniAppInitData(payload.initData, options.botToken)
|
||||
if (!telegramUser) {
|
||||
return null
|
||||
}
|
||||
|
||||
const member = await options.getMemberByTelegramUserId(telegramUser.id)
|
||||
if (!member) {
|
||||
return {
|
||||
authorized: false,
|
||||
reason: 'not_member'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authorized: true,
|
||||
member: {
|
||||
id: member.id,
|
||||
displayName: member.displayName,
|
||||
isAdmin: member.isAdmin
|
||||
const access = await options.onboardingService.getMiniAppAccess({
|
||||
identity: {
|
||||
telegramUserId: telegramUser.id,
|
||||
displayName:
|
||||
telegramUser.firstName ?? telegramUser.username ?? `Telegram ${telegramUser.id}`,
|
||||
username: telegramUser.username,
|
||||
languageCode: telegramUser.languageCode
|
||||
},
|
||||
telegramUser
|
||||
...(payload.joinToken
|
||||
? {
|
||||
joinToken: payload.joinToken
|
||||
}
|
||||
: {})
|
||||
})
|
||||
|
||||
switch (access.status) {
|
||||
case 'active':
|
||||
return {
|
||||
authorized: true,
|
||||
member: access.member,
|
||||
telegramUser
|
||||
}
|
||||
case 'pending':
|
||||
return {
|
||||
authorized: false,
|
||||
telegramUser,
|
||||
onboarding: {
|
||||
status: 'pending',
|
||||
householdName: access.household.name
|
||||
}
|
||||
}
|
||||
case 'join_required':
|
||||
return {
|
||||
authorized: false,
|
||||
telegramUser,
|
||||
onboarding: {
|
||||
status: 'join_required',
|
||||
householdName: access.household.name
|
||||
}
|
||||
}
|
||||
case 'open_from_group':
|
||||
return {
|
||||
authorized: false,
|
||||
telegramUser,
|
||||
onboarding: {
|
||||
status: 'open_from_group'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,14 +181,14 @@ export function createMiniAppSessionService(options: {
|
||||
export function createMiniAppAuthHandler(options: {
|
||||
allowedOrigins: readonly string[]
|
||||
botToken: string
|
||||
repository: FinanceRepository
|
||||
onboardingService: HouseholdOnboardingService
|
||||
logger?: Logger
|
||||
}): {
|
||||
handler: (request: Request) => Promise<Response>
|
||||
} {
|
||||
const sessionService = createMiniAppSessionService({
|
||||
botToken: options.botToken,
|
||||
getMemberByTelegramUserId: options.repository.getMemberByTelegramUserId
|
||||
onboardingService: options.onboardingService
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -152,12 +204,12 @@ export function createMiniAppAuthHandler(options: {
|
||||
}
|
||||
|
||||
try {
|
||||
const initData = await readMiniAppInitData(request)
|
||||
if (!initData) {
|
||||
const payload = await readMiniAppRequestPayload(request)
|
||||
if (!payload.initData) {
|
||||
return miniAppJsonResponse({ ok: false, error: 'Missing initData' }, 400, origin)
|
||||
}
|
||||
|
||||
const session = await sessionService.authenticate(initData)
|
||||
const session = await sessionService.authenticate(payload)
|
||||
if (!session) {
|
||||
return miniAppJsonResponse(
|
||||
{ ok: false, error: 'Invalid Telegram init data' },
|
||||
@@ -171,9 +223,10 @@ export function createMiniAppAuthHandler(options: {
|
||||
{
|
||||
ok: true,
|
||||
authorized: false,
|
||||
reason: 'not_member'
|
||||
onboarding: session.onboarding,
|
||||
telegramUser: session.telegramUser
|
||||
},
|
||||
403,
|
||||
200,
|
||||
origin
|
||||
)
|
||||
}
|
||||
@@ -198,3 +251,98 @@ export function createMiniAppAuthHandler(options: {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createMiniAppJoinHandler(options: {
|
||||
allowedOrigins: readonly string[]
|
||||
botToken: string
|
||||
onboardingService: HouseholdOnboardingService
|
||||
logger?: Logger
|
||||
}): {
|
||||
handler: (request: Request) => Promise<Response>
|
||||
} {
|
||||
return {
|
||||
handler: async (request) => {
|
||||
const origin = allowedMiniAppOrigin(request, options.allowedOrigins)
|
||||
|
||||
if (request.method === 'OPTIONS') {
|
||||
return miniAppJsonResponse({ ok: true }, 204, origin)
|
||||
}
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin)
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await readMiniAppRequestPayload(request)
|
||||
if (!payload.initData) {
|
||||
return miniAppJsonResponse({ ok: false, error: 'Missing initData' }, 400, origin)
|
||||
}
|
||||
|
||||
if (!payload.joinToken) {
|
||||
return miniAppJsonResponse(
|
||||
{ ok: false, error: 'Missing household join token' },
|
||||
400,
|
||||
origin
|
||||
)
|
||||
}
|
||||
|
||||
const telegramUser = verifyTelegramMiniAppInitData(payload.initData, options.botToken)
|
||||
if (!telegramUser) {
|
||||
return miniAppJsonResponse(
|
||||
{ ok: false, error: 'Invalid Telegram init data' },
|
||||
401,
|
||||
origin
|
||||
)
|
||||
}
|
||||
|
||||
const result = await options.onboardingService.joinHousehold({
|
||||
identity: {
|
||||
telegramUserId: telegramUser.id,
|
||||
displayName:
|
||||
telegramUser.firstName ?? telegramUser.username ?? `Telegram ${telegramUser.id}`,
|
||||
username: telegramUser.username,
|
||||
languageCode: telegramUser.languageCode
|
||||
},
|
||||
joinToken: payload.joinToken
|
||||
})
|
||||
|
||||
if (result.status === 'invalid_token') {
|
||||
return miniAppJsonResponse(
|
||||
{ ok: false, error: 'Invalid household join token' },
|
||||
404,
|
||||
origin
|
||||
)
|
||||
}
|
||||
|
||||
if (result.status === 'active') {
|
||||
return miniAppJsonResponse(
|
||||
{
|
||||
ok: true,
|
||||
authorized: true,
|
||||
member: result.member,
|
||||
telegramUser
|
||||
},
|
||||
200,
|
||||
origin
|
||||
)
|
||||
}
|
||||
|
||||
return miniAppJsonResponse(
|
||||
{
|
||||
ok: true,
|
||||
authorized: false,
|
||||
onboarding: {
|
||||
status: 'pending',
|
||||
householdName: result.household.name
|
||||
},
|
||||
telegramUser
|
||||
},
|
||||
200,
|
||||
origin
|
||||
)
|
||||
} catch (error) {
|
||||
return miniAppErrorResponse(error, origin, options.logger)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { createFinanceCommandService } from '@household/application'
|
||||
import type { FinanceRepository } from '@household/ports'
|
||||
import {
|
||||
createFinanceCommandService,
|
||||
createHouseholdOnboardingService
|
||||
} from '@household/application'
|
||||
import type {
|
||||
FinanceRepository,
|
||||
HouseholdConfigurationRepository,
|
||||
HouseholdTopicBindingRecord
|
||||
} from '@household/ports'
|
||||
|
||||
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
|
||||
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
||||
@@ -62,6 +69,52 @@ function repository(
|
||||
}
|
||||
}
|
||||
|
||||
function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
const household = {
|
||||
householdId: 'household-1',
|
||||
householdName: 'Kojori House',
|
||||
telegramChatId: '-100123',
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Kojori House'
|
||||
}
|
||||
|
||||
return {
|
||||
registerTelegramHouseholdChat: async () => ({
|
||||
status: 'existing',
|
||||
household
|
||||
}),
|
||||
getTelegramHouseholdChat: async () => household,
|
||||
bindHouseholdTopic: async (input) =>
|
||||
({
|
||||
householdId: input.householdId,
|
||||
role: input.role,
|
||||
telegramThreadId: input.telegramThreadId,
|
||||
topicName: input.topicName?.trim() || null
|
||||
}) satisfies HouseholdTopicBindingRecord,
|
||||
getHouseholdTopicBinding: async () => null,
|
||||
findHouseholdTopicByTelegramContext: async () => null,
|
||||
listHouseholdTopicBindings: async () => [],
|
||||
upsertHouseholdJoinToken: async (input) => ({
|
||||
householdId: household.householdId,
|
||||
householdName: household.householdName,
|
||||
token: input.token,
|
||||
createdByTelegramUserId: input.createdByTelegramUserId ?? null
|
||||
}),
|
||||
getHouseholdJoinToken: async () => null,
|
||||
getHouseholdByJoinToken: async () => null,
|
||||
upsertPendingHouseholdMember: async (input) => ({
|
||||
householdId: household.householdId,
|
||||
householdName: household.householdName,
|
||||
telegramUserId: input.telegramUserId,
|
||||
displayName: input.displayName,
|
||||
username: input.username?.trim() || null,
|
||||
languageCode: input.languageCode?.trim() || null
|
||||
}),
|
||||
getPendingHouseholdMember: async () => null,
|
||||
findPendingHouseholdMemberByTelegramUserId: async () => null
|
||||
}
|
||||
}
|
||||
|
||||
describe('createMiniAppDashboardHandler', () => {
|
||||
test('returns a dashboard for an authenticated household member', async () => {
|
||||
const authDate = Math.floor(Date.now() / 1000)
|
||||
@@ -77,7 +130,11 @@ describe('createMiniAppDashboardHandler', () => {
|
||||
const dashboard = createMiniAppDashboardHandler({
|
||||
allowedOrigins: ['http://localhost:5173'],
|
||||
botToken: 'test-bot-token',
|
||||
financeService
|
||||
financeService,
|
||||
onboardingService: createHouseholdOnboardingService({
|
||||
repository: onboardingRepository(),
|
||||
getMemberByTelegramUserId: financeService.getMemberByTelegramUserId
|
||||
})
|
||||
})
|
||||
|
||||
const response = await dashboard.handler(
|
||||
@@ -140,7 +197,11 @@ describe('createMiniAppDashboardHandler', () => {
|
||||
const dashboard = createMiniAppDashboardHandler({
|
||||
allowedOrigins: ['http://localhost:5173'],
|
||||
botToken: 'test-bot-token',
|
||||
financeService
|
||||
financeService,
|
||||
onboardingService: createHouseholdOnboardingService({
|
||||
repository: onboardingRepository(),
|
||||
getMemberByTelegramUserId: financeService.getMemberByTelegramUserId
|
||||
})
|
||||
})
|
||||
|
||||
const response = await dashboard.handler(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FinanceCommandService } from '@household/application'
|
||||
import type { FinanceCommandService, HouseholdOnboardingService } from '@household/application'
|
||||
import type { Logger } from '@household/observability'
|
||||
|
||||
import {
|
||||
@@ -6,20 +6,21 @@ import {
|
||||
createMiniAppSessionService,
|
||||
miniAppErrorResponse,
|
||||
miniAppJsonResponse,
|
||||
readMiniAppInitData
|
||||
readMiniAppRequestPayload
|
||||
} from './miniapp-auth'
|
||||
|
||||
export function createMiniAppDashboardHandler(options: {
|
||||
allowedOrigins: readonly string[]
|
||||
botToken: string
|
||||
financeService: FinanceCommandService
|
||||
onboardingService: HouseholdOnboardingService
|
||||
logger?: Logger
|
||||
}): {
|
||||
handler: (request: Request) => Promise<Response>
|
||||
} {
|
||||
const sessionService = createMiniAppSessionService({
|
||||
botToken: options.botToken,
|
||||
getMemberByTelegramUserId: options.financeService.getMemberByTelegramUserId
|
||||
onboardingService: options.onboardingService
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -35,12 +36,12 @@ export function createMiniAppDashboardHandler(options: {
|
||||
}
|
||||
|
||||
try {
|
||||
const initData = await readMiniAppInitData(request)
|
||||
if (!initData) {
|
||||
const payload = await readMiniAppRequestPayload(request)
|
||||
if (!payload.initData) {
|
||||
return miniAppJsonResponse({ ok: false, error: 'Missing initData' }, 400, origin)
|
||||
}
|
||||
|
||||
const session = await sessionService.authenticate(initData)
|
||||
const session = await sessionService.authenticate(payload)
|
||||
if (!session) {
|
||||
return miniAppJsonResponse(
|
||||
{ ok: false, error: 'Invalid Telegram init data' },
|
||||
@@ -54,7 +55,7 @@ export function createMiniAppDashboardHandler(options: {
|
||||
{
|
||||
ok: true,
|
||||
authorized: false,
|
||||
reason: 'not_member'
|
||||
onboarding: session.onboarding
|
||||
},
|
||||
403,
|
||||
origin
|
||||
|
||||
@@ -14,6 +14,12 @@ export interface BotWebhookServerOptions {
|
||||
handler: (request: Request) => Promise<Response>
|
||||
}
|
||||
| undefined
|
||||
miniAppJoin?:
|
||||
| {
|
||||
path?: string
|
||||
handler: (request: Request) => Promise<Response>
|
||||
}
|
||||
| undefined
|
||||
scheduler?:
|
||||
| {
|
||||
pathPrefix?: string
|
||||
@@ -46,6 +52,7 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
||||
: `/${options.webhookPath}`
|
||||
const miniAppAuthPath = options.miniAppAuth?.path ?? '/api/miniapp/session'
|
||||
const miniAppDashboardPath = options.miniAppDashboard?.path ?? '/api/miniapp/dashboard'
|
||||
const miniAppJoinPath = options.miniAppJoin?.path ?? '/api/miniapp/join'
|
||||
const schedulerPathPrefix = options.scheduler
|
||||
? (options.scheduler.pathPrefix ?? '/jobs/reminder')
|
||||
: null
|
||||
@@ -66,6 +73,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
||||
return await options.miniAppDashboard.handler(request)
|
||||
}
|
||||
|
||||
if (options.miniAppJoin && url.pathname === miniAppJoinPath) {
|
||||
return await options.miniAppJoin.handler(request)
|
||||
}
|
||||
|
||||
if (url.pathname !== normalizedWebhookPath) {
|
||||
if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) {
|
||||
if (request.method !== 'POST') {
|
||||
|
||||
Reference in New Issue
Block a user