feat(setup): add reply-based household invites

This commit is contained in:
2026-03-11 15:21:18 +04:00
parent 0787847c19
commit 086e521ce7
8 changed files with 941 additions and 7 deletions

View File

@@ -21,8 +21,16 @@ import type {
import { createTelegramBot } from './bot'
import { buildJoinMiniAppUrl, registerHouseholdSetupCommands } from './household-setup'
function startUpdate(text: string, languageCode?: string) {
function startUpdate(
text: string,
options: {
userId?: number
firstName?: string
languageCode?: string
} = {}
) {
const commandToken = text.split(' ')[0] ?? text
const userId = options.userId ?? 123456
return {
update_id: 2001,
@@ -30,16 +38,16 @@ function startUpdate(text: string, languageCode?: string) {
message_id: 71,
date: Math.floor(Date.now() / 1000),
chat: {
id: 123456,
id: userId,
type: 'private'
},
from: {
id: 123456,
id: userId,
is_bot: false,
first_name: 'Stan',
...(languageCode
first_name: options.firstName ?? 'Stan',
...(options.languageCode
? {
language_code: languageCode
language_code: options.languageCode
}
: {})
},
@@ -125,6 +133,52 @@ function groupCommandUpdate(text: string) {
}
}
function groupReplyCommandUpdate(text: string, repliedUser: { id: number; firstName: string }) {
const commandToken = text.split(' ')[0] ?? text
return {
update_id: 3004,
message: {
message_id: 82,
date: Math.floor(Date.now() / 1000),
chat: {
id: -100123456,
type: 'supergroup',
title: 'Kojori House'
},
from: {
id: 123456,
is_bot: false,
first_name: 'Stan',
language_code: 'en'
},
reply_to_message: {
message_id: 80,
date: Math.floor(Date.now() / 1000),
chat: {
id: -100123456,
type: 'supergroup',
title: 'Kojori House'
},
from: {
id: repliedUser.id,
is_bot: false,
first_name: repliedUser.firstName
},
text: 'hello'
},
text,
entities: [
{
offset: 0,
length: commandToken.length,
type: 'bot_command'
}
]
}
}
}
function groupCallbackUpdate(data: string) {
return {
update_id: 3002,
@@ -685,7 +739,7 @@ describe('registerHouseholdSetupCommands', () => {
miniAppUrl: 'https://miniapp.example.app'
})
await bot.handleUpdate(startUpdate('/start join_join-token', 'ru') as never)
await bot.handleUpdate(startUpdate('/start join_join-token', { languageCode: 'ru' }) as never)
expect(calls[0]?.payload).toMatchObject({
chat_id: 123456,
@@ -836,6 +890,394 @@ describe('registerHouseholdSetupCommands', () => {
})
})
test('creates a targeted in-group invite from a replied user message', async () => {
const bot = createTelegramBot('000000:test-token')
const calls: Array<{ method: string; payload: unknown }> = []
const repository = createHouseholdConfigurationRepository()
const promptRepository = createPromptRepository()
bot.botInfo = {
id: 999000,
is_bot: true,
first_name: 'Household Test Bot',
username: 'household_test_bot',
can_join_groups: true,
can_read_all_group_messages: false,
supports_inline_queries: false,
can_connect_to_business: false,
has_main_web_app: false,
has_topics_enabled: true,
allows_users_to_create_topics: true
}
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
if (method === 'getChatMember') {
return {
ok: true,
result: {
status: 'administrator',
user: {
id: 123456,
is_bot: false,
first_name: 'Stan'
}
}
} as never
}
if (method === 'sendMessage') {
return {
ok: true,
result: {
message_id: 410,
date: Math.floor(Date.now() / 1000),
chat: {
id: -100123456,
type: 'supergroup'
},
text: (payload as { text?: string }).text ?? 'ok'
}
} as never
}
return {
ok: true,
result: true
} as never
})
const householdOnboardingService: HouseholdOnboardingService = {
async ensureHouseholdJoinToken() {
return {
householdId: 'household-1',
householdName: 'Kojori House',
token: 'join-token'
}
},
async getMiniAppAccess() {
return {
status: 'open_from_group'
}
},
async joinHousehold() {
return {
status: 'pending',
household: {
id: 'household-1',
name: 'Kojori House',
defaultLocale: 'en'
}
}
}
}
registerHouseholdSetupCommands({
bot,
householdSetupService: createHouseholdSetupService(repository),
householdOnboardingService,
householdAdminService: createHouseholdAdminService(),
householdConfigurationRepository: repository,
promptRepository
})
await bot.handleUpdate(groupCommandUpdate('/setup Kojori House') as never)
calls.length = 0
await bot.handleUpdate(
groupReplyCommandUpdate('/invite', { id: 654321, firstName: 'Chorbanaut' }) as never
)
expect(calls[1]).toMatchObject({
method: 'sendMessage',
payload: {
chat_id: -100123456,
text: 'Invitation prepared for Chorbanaut. Tap below to join Kojori House.',
reply_markup: {
inline_keyboard: [
[
{
text: 'Join household',
url: 'https://t.me/household_test_bot?start=invite_-100123456_654321'
}
]
]
}
}
})
expect(await promptRepository.getPendingAction('invite:-100123456', '654321')).toMatchObject({
action: 'household_group_invite',
payload: {
joinToken: 'join-token',
householdId: 'household-1',
householdName: 'Kojori House',
targetDisplayName: 'Chorbanaut',
inviteMessageId: 410
}
})
})
test('rejects household invite links for the wrong Telegram user', async () => {
const bot = createTelegramBot('000000:test-token')
const calls: Array<{ method: string; payload: unknown }> = []
bot.botInfo = {
id: 999000,
is_bot: true,
first_name: 'Household Test Bot',
username: 'household_test_bot',
can_join_groups: true,
can_read_all_group_messages: false,
supports_inline_queries: false,
can_connect_to_business: false,
has_main_web_app: false,
has_topics_enabled: true,
allows_users_to_create_topics: true
}
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: {
message_id: 1,
date: Math.floor(Date.now() / 1000),
chat: {
id: 111111,
type: 'private'
},
text: (payload as { text?: string }).text ?? 'ok'
}
} as never
})
registerHouseholdSetupCommands({
bot,
householdSetupService: createRejectedHouseholdSetupService(),
householdOnboardingService: {
async ensureHouseholdJoinToken() {
return {
householdId: 'household-1',
householdName: 'Kojori House',
token: 'join-token'
}
},
async getMiniAppAccess() {
return {
status: 'open_from_group'
}
},
async joinHousehold() {
return {
status: 'invalid_token'
}
}
},
householdAdminService: createHouseholdAdminService(),
promptRepository: createPromptRepository()
})
await bot.handleUpdate(
startUpdate('/start invite_-100123456_654321', {
userId: 111111,
firstName: 'Wrong user'
}) as never
)
expect(calls[0]).toMatchObject({
method: 'sendMessage',
payload: {
chat_id: 111111,
text: 'This invite is for a different Telegram user.'
}
})
})
test('consumes a targeted invite for the invited user and updates the group message', async () => {
const bot = createTelegramBot('000000:test-token')
const calls: Array<{ method: string; payload: unknown }> = []
const repository = createHouseholdConfigurationRepository()
const promptRepository = createPromptRepository()
const joinCalls: string[] = []
bot.botInfo = {
id: 999000,
is_bot: true,
first_name: 'Household Test Bot',
username: 'household_test_bot',
can_join_groups: true,
can_read_all_group_messages: false,
supports_inline_queries: false,
can_connect_to_business: false,
has_main_web_app: false,
has_topics_enabled: true,
allows_users_to_create_topics: true
}
const householdOnboardingService: HouseholdOnboardingService = {
async ensureHouseholdJoinToken() {
return {
householdId: 'household-1',
householdName: 'Kojori House',
token: 'join-token'
}
},
async getMiniAppAccess(input) {
if (joinCalls.includes(input.identity.telegramUserId)) {
return {
status: 'pending',
household: {
id: 'household-1',
name: 'Kojori House',
defaultLocale: 'en'
}
}
}
return {
status: 'open_from_group'
}
},
async joinHousehold(input) {
joinCalls.push(input.identity.telegramUserId)
return {
status: 'pending',
household: {
id: 'household-1',
name: 'Kojori House',
defaultLocale: 'en'
}
}
}
}
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
if (method === 'getChatMember') {
return {
ok: true,
result: {
status: 'administrator',
user: {
id: 123456,
is_bot: false,
first_name: 'Stan'
}
}
} as never
}
if (method === 'sendMessage') {
const chatId = (payload as { chat_id?: number }).chat_id ?? 0
return {
ok: true,
result: {
message_id: chatId === -100123456 ? 411 : 1,
date: Math.floor(Date.now() / 1000),
chat: {
id: chatId,
type: chatId > 0 ? 'private' : 'supergroup'
},
text: (payload as { text?: string }).text ?? 'ok'
}
} as never
}
if (method === 'editMessageText') {
return {
ok: true,
result: {
message_id: (payload as { message_id?: number }).message_id ?? 411,
date: Math.floor(Date.now() / 1000),
chat: {
id: (payload as { chat_id?: number }).chat_id ?? -100123456,
type: 'supergroup'
},
text: (payload as { text?: string }).text ?? 'ok'
}
} as never
}
return {
ok: true,
result: true
} as never
})
registerHouseholdSetupCommands({
bot,
householdSetupService: createHouseholdSetupService(repository),
householdOnboardingService,
householdAdminService: createHouseholdAdminService(),
householdConfigurationRepository: repository,
promptRepository
})
await bot.handleUpdate(groupCommandUpdate('/setup Kojori House') as never)
calls.length = 0
await bot.handleUpdate(
groupReplyCommandUpdate('/invite', { id: 654321, firstName: 'Chorbanaut' }) as never
)
calls.length = 0
await bot.handleUpdate(
startUpdate('/start invite_-100123456_654321', {
userId: 654321,
firstName: 'Chorbanaut'
}) as never
)
expect(calls[0]).toMatchObject({
method: 'editMessageText',
payload: {
chat_id: -100123456,
message_id: 411,
text: 'Chorbanaut sent a join request for Kojori House.'
}
})
expect(calls[1]).toMatchObject({
method: 'sendMessage',
payload: {
chat_id: 654321,
text: 'Join request sent for Kojori House. Wait for a household admin to confirm you.'
}
})
expect(await promptRepository.getPendingAction('invite:-100123456', '654321')).toMatchObject({
action: 'household_group_invite',
payload: {
completed: true
}
})
calls.length = 0
await bot.handleUpdate(
startUpdate('/start invite_-100123456_654321', {
userId: 654321,
firstName: 'Chorbanaut'
}) as never
)
expect(calls[0]).toMatchObject({
method: 'editMessageText',
payload: {
chat_id: -100123456,
message_id: 411,
text: 'Chorbanaut sent a join request for Kojori House.'
}
})
expect(calls[1]).toMatchObject({
method: 'sendMessage',
payload: {
chat_id: 654321,
text: 'Join request sent for Kojori House. Wait for a household admin to confirm you.'
}
})
})
test('creates and binds a missing setup topic from callback', async () => {
const bot = createTelegramBot('000000:test-token')
const calls: Array<{ method: string; payload: unknown }> = []

View File

@@ -21,6 +21,9 @@ import { resolveReplyLocale } from './bot-locale'
const APPROVE_MEMBER_CALLBACK_PREFIX = 'approve_member:'
const SETUP_CREATE_TOPIC_CALLBACK_PREFIX = 'setup_topic:create:'
const SETUP_BIND_TOPIC_CALLBACK_PREFIX = 'setup_topic:bind:'
const GROUP_INVITE_START_PREFIX = 'invite_'
const GROUP_INVITE_ACTION = 'household_group_invite' as const
const GROUP_INVITE_TTL_MS = 3 * 24 * 60 * 60 * 1000
const SETUP_BIND_TOPIC_ACTION = 'setup_topic_binding' as const
const SETUP_BIND_TOPIC_TTL_MS = 10 * 60 * 1000
const HOUSEHOLD_TOPIC_ROLE_ORDER: readonly HouseholdTopicRole[] = [
@@ -161,6 +164,45 @@ function actorDisplayName(ctx: Context): string | undefined {
return fullName || ctx.from?.username?.trim() || undefined
}
function telegramUserDisplayName(input: {
firstName: string | undefined
lastName: string | undefined
username: string | undefined
fallback: string
}): string {
const fullName = [input.firstName?.trim(), input.lastName?.trim()]
.filter(Boolean)
.join(' ')
.trim()
return fullName || input.username?.trim() || input.fallback
}
function repliedTelegramUser(ctx: Context): {
telegramUserId: string
displayName: string
username?: string
} | null {
const replied = ctx.msg?.reply_to_message
if (!replied?.from || replied.from.is_bot) {
return null
}
return {
telegramUserId: replied.from.id.toString(),
displayName: telegramUserDisplayName({
firstName: replied.from.first_name,
lastName: replied.from.last_name,
username: replied.from.username,
fallback: `Telegram ${replied.from.id}`
}),
...(replied.from.username
? {
username: replied.from.username
}
: {})
}
}
function buildPendingMemberLabel(displayName: string): string {
const normalized = displayName.trim().replaceAll(/\s+/g, ' ')
if (normalized.length <= 32) {
@@ -337,6 +379,77 @@ function parseSetupBindPayload(payload: Record<string, unknown>): {
}
}
function invitePendingChatId(telegramChatId: string): string {
return `invite:${telegramChatId}`
}
function parseGroupInvitePayload(payload: Record<string, unknown>): {
joinToken: string
householdId: string
householdName: string
targetDisplayName: string
inviteMessageId?: number
completed?: boolean
} | null {
if (
typeof payload.joinToken !== 'string' ||
payload.joinToken.trim().length === 0 ||
typeof payload.householdId !== 'string' ||
payload.householdId.trim().length === 0 ||
typeof payload.householdName !== 'string' ||
payload.householdName.trim().length === 0 ||
typeof payload.targetDisplayName !== 'string' ||
payload.targetDisplayName.trim().length === 0
) {
return null
}
return {
joinToken: payload.joinToken,
householdId: payload.householdId,
householdName: payload.householdName,
targetDisplayName: payload.targetDisplayName,
...(typeof payload.inviteMessageId === 'number' && Number.isInteger(payload.inviteMessageId)
? {
inviteMessageId: payload.inviteMessageId
}
: {}),
...(payload.completed === true
? {
completed: true
}
: {})
}
}
function parseInviteStartPayload(payload: string): {
telegramChatId: string
targetTelegramUserId: string
} | null {
const match = /^invite_(-?\d+)_(\d+)$/.exec(payload)
if (!match) {
return null
}
return {
telegramChatId: match[1]!,
targetTelegramUserId: match[2]!
}
}
function buildGroupInviteDeepLink(
botUsername: string | undefined,
telegramChatId: string,
targetTelegramUserId: string
): string | null {
const normalizedBotUsername = botUsername?.trim()
if (!normalizedBotUsername) {
return null
}
return `https://t.me/${normalizedBotUsername}?start=${GROUP_INVITE_START_PREFIX}${telegramChatId}_${targetTelegramUserId}`
}
export function buildJoinMiniAppUrl(
miniAppUrl: string | undefined,
botUsername: string | undefined,
@@ -394,6 +507,64 @@ export function registerHouseholdSetupCommands(options: {
miniAppUrl?: string
logger?: Logger
}): void {
async function editGroupInviteCompletion(input: {
locale: BotLocale
telegramChatId: string
payload: {
householdName: string
targetDisplayName: string
inviteMessageId?: number
}
status: 'active' | 'pending'
ctx: Context
}) {
if (!input.payload.inviteMessageId) {
return
}
const t = getBotTranslations(input.locale).setup
const text =
input.status === 'active'
? t.inviteJoinCompleted(input.payload.targetDisplayName, input.payload.householdName)
: t.inviteJoinRequestSent(input.payload.targetDisplayName, input.payload.householdName)
try {
await input.ctx.api.editMessageText(
Number(input.telegramChatId),
input.payload.inviteMessageId,
text
)
} catch (error) {
options.logger?.warn(
{
event: 'household_setup.invite_message_update_failed',
telegramChatId: input.telegramChatId,
inviteMessageId: input.payload.inviteMessageId,
error: error instanceof Error ? error.message : String(error)
},
'Failed to update household invite message'
)
}
}
async function isInviteAuthorized(ctx: Context, householdId: string): Promise<boolean> {
if (await isGroupAdmin(ctx)) {
return true
}
const actorTelegramUserId = ctx.from?.id?.toString()
if (!actorTelegramUserId || !options.householdConfigurationRepository) {
return false
}
const member = await options.householdConfigurationRepository.getHouseholdMember(
householdId,
actorTelegramUserId
)
return member?.isAdmin === true
}
async function buildSetupReplyForHousehold(input: {
ctx: Context
locale: BotLocale
@@ -580,6 +751,149 @@ export function registerHouseholdSetupCommands(options: {
}
const startPayload = commandArgText(ctx)
const inviteStart = parseInviteStartPayload(startPayload)
if (inviteStart) {
if (ctx.from.id.toString() !== inviteStart.targetTelegramUserId) {
await ctx.reply(t.setup.inviteJoinWrongUser)
return
}
if (!options.promptRepository) {
await ctx.reply(t.setup.inviteJoinExpired)
return
}
const invitePending = await options.promptRepository.getPendingAction(
invitePendingChatId(inviteStart.telegramChatId),
inviteStart.targetTelegramUserId
)
const invitePayload =
invitePending?.action === GROUP_INVITE_ACTION
? parseGroupInvitePayload(invitePending.payload)
: null
const inviteExpiresAt = invitePending?.expiresAt ?? null
if (!invitePayload) {
await ctx.reply(t.setup.inviteJoinExpired)
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
}
: {})
}
if (invitePayload.completed) {
const access = await options.householdOnboardingService.getMiniAppAccess({
identity,
joinToken: invitePayload.joinToken
})
locale = localeFromAccess(access, fallbackLocale)
t = getBotTranslations(locale)
if (access.status === 'active') {
await editGroupInviteCompletion({
locale,
telegramChatId: inviteStart.telegramChatId,
payload: invitePayload,
status: 'active',
ctx
})
await ctx.reply(
t.setup.alreadyActiveMember(access.member.displayName),
miniAppReplyMarkup(locale, options.miniAppUrl, ctx.me.username, invitePayload.joinToken)
)
return
}
if (access.status === 'pending') {
await editGroupInviteCompletion({
locale,
telegramChatId: inviteStart.telegramChatId,
payload: invitePayload,
status: 'pending',
ctx
})
await ctx.reply(
t.setup.joinRequestSent(access.household.name),
miniAppReplyMarkup(locale, options.miniAppUrl, ctx.me.username, invitePayload.joinToken)
)
return
}
await ctx.reply(t.setup.inviteJoinExpired)
return
}
const result = await options.householdOnboardingService.joinHousehold({
identity,
joinToken: invitePayload.joinToken
})
if (result.status === 'invalid_token') {
await ctx.reply(t.setup.inviteJoinExpired)
return
}
if (result.status === 'active') {
locale = result.member.preferredLocale ?? result.member.householdDefaultLocale
t = getBotTranslations(locale)
} else {
const access = await options.householdOnboardingService.getMiniAppAccess({
identity,
joinToken: invitePayload.joinToken
})
locale = localeFromAccess(access, fallbackLocale)
t = getBotTranslations(locale)
}
await options.promptRepository.upsertPendingAction({
telegramUserId: inviteStart.targetTelegramUserId,
telegramChatId: invitePendingChatId(inviteStart.telegramChatId),
action: GROUP_INVITE_ACTION,
payload: {
...invitePayload,
completed: true
},
expiresAt: inviteExpiresAt
})
await editGroupInviteCompletion({
locale,
telegramChatId: inviteStart.telegramChatId,
payload: invitePayload,
status: result.status,
ctx
})
if (result.status === 'active') {
await ctx.reply(
t.setup.alreadyActiveMember(result.member.displayName),
miniAppReplyMarkup(locale, options.miniAppUrl, ctx.me.username, invitePayload.joinToken)
)
return
}
await ctx.reply(
t.setup.joinRequestSent(result.household.name),
miniAppReplyMarkup(locale, options.miniAppUrl, ctx.me.username, invitePayload.joinToken)
)
return
}
if (!startPayload.startsWith('join_')) {
await ctx.reply(t.common.useHelp)
return
@@ -849,6 +1163,128 @@ export function registerHouseholdSetupCommands(options: {
await ctx.reply(t.setup.approvedMember(result.member.displayName, result.householdName))
})
options.bot.command('invite', async (ctx) => {
const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
const t = getBotTranslations(locale)
if (!isGroupChat(ctx)) {
await ctx.reply(t.setup.useInviteInGroup)
return
}
if (!options.promptRepository || !options.householdConfigurationRepository) {
await ctx.reply(t.setup.inviteJoinExpired)
return
}
const household = await options.householdConfigurationRepository.getTelegramHouseholdChat(
ctx.chat.id.toString()
)
if (!household) {
await ctx.reply(t.setup.householdNotConfigured)
return
}
if (!(await isInviteAuthorized(ctx, household.householdId))) {
await ctx.reply(t.setup.onlyInviteAdmins)
return
}
const target = repliedTelegramUser(ctx)
if (!target) {
await ctx.reply(t.setup.inviteUsage)
return
}
const existingMember = await options.householdConfigurationRepository.getHouseholdMember(
household.householdId,
target.telegramUserId
)
if (existingMember?.status === 'active') {
await ctx.reply(
t.setup.inviteAlreadyMember(existingMember.displayName, household.householdName)
)
return
}
const existingPending =
await options.householdConfigurationRepository.getPendingHouseholdMember(
household.householdId,
target.telegramUserId
)
if (existingPending) {
await ctx.reply(
t.setup.inviteAlreadyPending(existingPending.displayName, household.householdName)
)
return
}
const joinToken = await options.householdOnboardingService.ensureHouseholdJoinToken({
householdId: household.householdId,
...(ctx.from?.id
? {
actorTelegramUserId: ctx.from.id.toString()
}
: {})
})
await options.promptRepository.upsertPendingAction({
telegramUserId: target.telegramUserId,
telegramChatId: invitePendingChatId(ctx.chat.id.toString()),
action: GROUP_INVITE_ACTION,
payload: {
joinToken: joinToken.token,
householdId: household.householdId,
householdName: household.householdName,
targetDisplayName: target.displayName
},
expiresAt: nowInstant().add({ milliseconds: GROUP_INVITE_TTL_MS })
})
const deepLink = buildGroupInviteDeepLink(
ctx.me.username,
ctx.chat.id.toString(),
target.telegramUserId
)
if (!deepLink) {
await ctx.reply(t.setup.inviteJoinExpired)
return
}
const inviteMessage = await ctx.reply(
t.setup.invitePrepared(target.displayName, household.householdName),
{
reply_markup: {
inline_keyboard: [
[
{
text: t.setup.joinHouseholdButton,
url: deepLink
}
]
]
}
}
)
await options.promptRepository.upsertPendingAction({
telegramUserId: target.telegramUserId,
telegramChatId: invitePendingChatId(ctx.chat.id.toString()),
action: GROUP_INVITE_ACTION,
payload: {
joinToken: joinToken.token,
householdId: household.householdId,
householdName: household.householdName,
targetDisplayName: target.displayName,
inviteMessageId: inviteMessage.message_id
},
expiresAt: nowInstant().add({ milliseconds: GROUP_INVITE_TTL_MS })
})
})
options.bot.callbackQuery(
new RegExp(`^${APPROVE_MEMBER_CALLBACK_PREFIX}(\\d+)$`),
async (ctx) => {

View File

@@ -13,6 +13,7 @@ export const enBotTranslations: BotTranslationCatalog = {
bind_feedback_topic: 'Bind the current topic as feedback',
bind_reminders_topic: 'Bind the current topic as reminders',
bind_payments_topic: 'Bind the current topic as payments',
invite: 'Invite the replied user into this household',
payment_add: 'Record your rent or utilities payment',
pending_members: 'List pending household join requests',
approve_member: 'Approve a pending household member'
@@ -115,6 +116,23 @@ export const enBotTranslations: BotTranslationCatalog = {
usePendingMembersInGroup: 'Use /pending_members inside the household group.',
useApproveMemberInGroup: 'Use /approve_member inside the household group.',
approveMemberUsage: 'Usage: /approve_member <telegram_user_id>',
useInviteInGroup: 'Use /invite as a reply inside the household group.',
onlyInviteAdmins: 'Only Telegram group admins or household admins can invite members.',
inviteUsage: 'Reply to a real user message with /invite.',
inviteTargetInvalid: 'I can only prepare invites for real group members.',
inviteAlreadyMember: (displayName, householdName) =>
`${displayName} is already an active member of ${householdName}.`,
inviteAlreadyPending: (displayName, householdName) =>
`${displayName} already has a pending join request for ${householdName}.`,
invitePrepared: (displayName, householdName) =>
`Invitation prepared for ${displayName}. Tap below to join ${householdName}.`,
invitePreparedToast: (displayName) => `Invite prepared for ${displayName}.`,
inviteJoinWrongUser: 'This invite is for a different Telegram user.',
inviteJoinExpired: 'This invite is no longer available.',
inviteJoinCompleted: (displayName, householdName) =>
`${displayName} completed the join flow for ${householdName}.`,
inviteJoinRequestSent: (displayName, householdName) =>
`${displayName} sent a join request for ${householdName}.`,
approvedMember: (displayName, householdName) =>
`Approved ${displayName} as an active member of ${householdName}.`,
useButtonInGroup: 'Use this button in the household group.',

View File

@@ -13,6 +13,7 @@ export const ruBotTranslations: BotTranslationCatalog = {
bind_feedback_topic: 'Назначить текущий топик для анонимных сообщений',
bind_reminders_topic: 'Назначить текущий топик для напоминаний',
bind_payments_topic: 'Назначить текущий топик для оплат',
invite: 'Пригласить пользователя из сообщения в этот дом',
payment_add: 'Подтвердить оплату аренды или коммуналки',
pending_members: 'Показать ожидающие заявки на вступление',
approve_member: 'Подтвердить участника дома'
@@ -117,6 +118,23 @@ export const ruBotTranslations: BotTranslationCatalog = {
usePendingMembersInGroup: 'Используйте /pending_members внутри группы дома.',
useApproveMemberInGroup: 'Используйте /approve_member внутри группы дома.',
approveMemberUsage: 'Использование: /approve_member <telegram_user_id>',
useInviteInGroup: 'Используйте /invite как ответ внутри группы дома.',
onlyInviteAdmins: 'Приглашать участников могут только админы Telegram-группы или админы дома.',
inviteUsage: 'Ответьте командой /invite на сообщение реального участника.',
inviteTargetInvalid: 'Я могу подготовить приглашение только для реального участника группы.',
inviteAlreadyMember: (displayName, householdName) =>
`${displayName} уже является активным участником ${householdName}.`,
inviteAlreadyPending: (displayName, householdName) =>
`${displayName} уже отправил(а) заявку на вступление в ${householdName}.`,
invitePrepared: (displayName, householdName) =>
`Приглашение для ${displayName} готово. Нажмите кнопку ниже, чтобы вступить в ${householdName}.`,
invitePreparedToast: (displayName) => `Приглашение для ${displayName} подготовлено.`,
inviteJoinWrongUser: 'Это приглашение предназначено для другого пользователя Telegram.',
inviteJoinExpired: 'Это приглашение больше недоступно.',
inviteJoinCompleted: (displayName, householdName) =>
`${displayName} завершил(а) вступление в ${householdName}.`,
inviteJoinRequestSent: (displayName, householdName) =>
`${displayName} отправил(а) заявку на вступление в ${householdName}.`,
approvedMember: (displayName, householdName) =>
`Участник ${displayName} подтверждён как активный участник ${householdName}.`,
useButtonInGroup: 'Используйте эту кнопку в группе дома.',

View File

@@ -11,6 +11,7 @@ export type TelegramCommandName =
| 'bind_feedback_topic'
| 'bind_reminders_topic'
| 'bind_payments_topic'
| 'invite'
| 'payment_add'
| 'pending_members'
| 'approve_member'
@@ -26,6 +27,7 @@ export interface BotCommandDescriptions {
bind_feedback_topic: string
bind_reminders_topic: string
bind_payments_topic: string
invite: string
payment_add: string
pending_members: string
approve_member: string
@@ -103,6 +105,18 @@ export interface BotTranslationCatalog {
usePendingMembersInGroup: string
useApproveMemberInGroup: string
approveMemberUsage: string
useInviteInGroup: string
onlyInviteAdmins: string
inviteUsage: string
inviteTargetInvalid: string
inviteAlreadyMember: (displayName: string, householdName: string) => string
inviteAlreadyPending: (displayName: string, householdName: string) => string
invitePrepared: (displayName: string, householdName: string) => string
invitePreparedToast: (displayName: string) => string
inviteJoinWrongUser: string
inviteJoinExpired: string
inviteJoinCompleted: (displayName: string, householdName: string) => string
inviteJoinRequestSent: (displayName: string, householdName: string) => string
approvedMember: (displayName: string, householdName: string) => string
useButtonInGroup: string
unableToIdentifySelectedMember: string

View File

@@ -39,6 +39,7 @@ const GROUP_ADMIN_COMMAND_NAMES = [
'bind_feedback_topic',
'bind_reminders_topic',
'bind_payments_topic',
'invite',
'pending_members',
'approve_member'
] as const satisfies readonly TelegramCommandName[]