mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 13:54:02 +00:00
feat(bot): refactor /setup and add /bind command
- Simplify /setup message: remove chat ID, use emojis, compact layout - Remove 'Bind' buttons from /setup - Add /bind command for binding existing topics - Remove old binding mode with 10-min timeout - Update i18n translations for en and ru
This commit is contained in:
@@ -160,30 +160,6 @@ function groupCallbackUpdate(data: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function topicMessageUpdate(text: string, threadId: number) {
|
|
||||||
return {
|
|
||||||
update_id: 3003,
|
|
||||||
message: {
|
|
||||||
message_id: 92,
|
|
||||||
date: Math.floor(Date.now() / 1000),
|
|
||||||
is_topic_message: true,
|
|
||||||
message_thread_id: threadId,
|
|
||||||
chat: {
|
|
||||||
id: -100123456,
|
|
||||||
type: 'supergroup',
|
|
||||||
title: 'Kojori House'
|
|
||||||
},
|
|
||||||
from: {
|
|
||||||
id: 123456,
|
|
||||||
is_bot: false,
|
|
||||||
first_name: 'Stan',
|
|
||||||
language_code: 'en'
|
|
||||||
},
|
|
||||||
text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPromptRepository(): TelegramPendingActionRepository {
|
function createPromptRepository(): TelegramPendingActionRepository {
|
||||||
const store = new Map<string, TelegramPendingActionRecord>()
|
const store = new Map<string, TelegramPendingActionRecord>()
|
||||||
|
|
||||||
@@ -898,29 +874,14 @@ describe('registerHouseholdSetupCommands', () => {
|
|||||||
chat_id: -100123456
|
chat_id: -100123456
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
expect(sendPayload.text).toContain('Household created: Kojori House')
|
expect(sendPayload.text).toContain('Kojori House is ready!')
|
||||||
expect(sendPayload.text).toContain('- purchases: not configured')
|
expect(sendPayload.text).toContain('Topics: 0/5 configured')
|
||||||
expect(sendPayload.text).toContain('- payments: not configured')
|
expect(sendPayload.text).toContain('⚪ purchases')
|
||||||
expect(sendPayload.reply_markup).toMatchObject({
|
expect(sendPayload.text).toContain('⚪ payments')
|
||||||
inline_keyboard: expect.arrayContaining([
|
// Check that join household button exists
|
||||||
[
|
expect(JSON.stringify(sendPayload.reply_markup)).toContain('Join household')
|
||||||
{
|
expect(JSON.stringify(sendPayload.reply_markup)).toContain('+ purchases')
|
||||||
text: 'Join household',
|
expect(JSON.stringify(sendPayload.reply_markup)).toContain('setup_topic:create:purchase')
|
||||||
url: 'https://t.me/household_test_bot?start=join_join-token'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: 'Create purchases topic',
|
|
||||||
callback_data: 'setup_topic:create:purchase'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Bind purchases topic',
|
|
||||||
callback_data: 'setup_topic:bind:purchase'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
])
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('creates and binds a missing setup topic from callback', async () => {
|
test('creates and binds a missing setup topic from callback', async () => {
|
||||||
@@ -1045,7 +1006,7 @@ describe('registerHouseholdSetupCommands', () => {
|
|||||||
payload: {
|
payload: {
|
||||||
chat_id: -100123456,
|
chat_id: -100123456,
|
||||||
message_id: 91,
|
message_id: 91,
|
||||||
text: expect.stringContaining('- purchases: bound to Shared purchases')
|
text: expect.stringContaining('✅ purchases')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1055,143 +1016,6 @@ describe('registerHouseholdSetupCommands', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('arms manual setup topic binding and consumes the next topic message', async () => {
|
|
||||||
const bot = createTelegramBot('000000:test-token')
|
|
||||||
const calls: Array<{ method: string; payload: unknown }> = []
|
|
||||||
const repository = createHouseholdConfigurationRepository()
|
|
||||||
const promptRepository = createPromptRepository()
|
|
||||||
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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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: calls.length,
|
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
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(groupCallbackUpdate('setup_topic:bind:payments') as never)
|
|
||||||
|
|
||||||
expect(calls[1]).toMatchObject({
|
|
||||||
method: 'answerCallbackQuery',
|
|
||||||
payload: {
|
|
||||||
callback_query_id: 'callback-1',
|
|
||||||
text: 'Binding mode is on for payments. Open the target topic and send any message there within 10 minutes.'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
expect(await promptRepository.getPendingAction('-100123456', '123456')).toMatchObject({
|
|
||||||
action: 'setup_topic_binding',
|
|
||||||
payload: {
|
|
||||||
role: 'payments',
|
|
||||||
setupMessageId: 91
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
calls.length = 0
|
|
||||||
await bot.handleUpdate(topicMessageUpdate('hello from payments', 444) as never)
|
|
||||||
|
|
||||||
expect(calls).toHaveLength(3)
|
|
||||||
expect(calls[1]).toMatchObject({
|
|
||||||
method: 'editMessageText',
|
|
||||||
payload: {
|
|
||||||
chat_id: -100123456,
|
|
||||||
message_id: 91,
|
|
||||||
text: expect.stringContaining('- payments: bound to thread 444')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
expect(calls[2]).toMatchObject({
|
|
||||||
method: 'sendMessage',
|
|
||||||
payload: {
|
|
||||||
chat_id: -100123456,
|
|
||||||
message_thread_id: 444,
|
|
||||||
text: 'Payments topic saved for Kojori House (thread 444).'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
expect(await promptRepository.getPendingAction('-100123456', '123456')).toBeNull()
|
|
||||||
expect(await repository.getHouseholdTopicBinding('household-1', 'payments')).toMatchObject({
|
|
||||||
telegramThreadId: '444'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('resets setup state with /unsetup and clears pending setup bindings', async () => {
|
test('resets setup state with /unsetup and clears pending setup bindings', async () => {
|
||||||
const bot = createTelegramBot('000000:test-token')
|
const bot = createTelegramBot('000000:test-token')
|
||||||
const calls: Array<{ method: string; payload: unknown }> = []
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type {
|
|||||||
HouseholdSetupService,
|
HouseholdSetupService,
|
||||||
HouseholdMiniAppAccess
|
HouseholdMiniAppAccess
|
||||||
} from '@household/application'
|
} from '@household/application'
|
||||||
import { nowInstant } from '@household/domain'
|
|
||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
import type {
|
import type {
|
||||||
HouseholdConfigurationRepository,
|
HouseholdConfigurationRepository,
|
||||||
@@ -20,9 +20,7 @@ import { resolveReplyLocale } from './bot-locale'
|
|||||||
|
|
||||||
const APPROVE_MEMBER_CALLBACK_PREFIX = 'approve_member:'
|
const APPROVE_MEMBER_CALLBACK_PREFIX = 'approve_member:'
|
||||||
const SETUP_CREATE_TOPIC_CALLBACK_PREFIX = 'setup_topic:create:'
|
const SETUP_CREATE_TOPIC_CALLBACK_PREFIX = 'setup_topic:create:'
|
||||||
const SETUP_BIND_TOPIC_CALLBACK_PREFIX = 'setup_topic:bind:'
|
|
||||||
const SETUP_BIND_TOPIC_ACTION = 'setup_topic_binding' as const
|
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[] = [
|
const HOUSEHOLD_TOPIC_ROLE_ORDER: readonly HouseholdTopicRole[] = [
|
||||||
'chat',
|
'chat',
|
||||||
'purchase',
|
'purchase',
|
||||||
@@ -46,11 +44,6 @@ function isTopicMessage(ctx: Context): boolean {
|
|||||||
return !!message && 'is_topic_message' in message && message.is_topic_message === true
|
return !!message && 'is_topic_message' in message && message.is_topic_message === true
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCommandMessage(ctx: Context): boolean {
|
|
||||||
const text = ctx.msg?.text
|
|
||||||
return typeof text === 'string' && text.trimStart().startsWith('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isGroupAdmin(ctx: Context): Promise<boolean> {
|
async function isGroupAdmin(ctx: Context): Promise<boolean> {
|
||||||
if (!ctx.chat || !ctx.from) {
|
if (!ctx.chat || !ctx.from) {
|
||||||
return false
|
return false
|
||||||
@@ -204,10 +197,6 @@ function pendingMembersReply(
|
|||||||
} as const
|
} as const
|
||||||
}
|
}
|
||||||
|
|
||||||
function topicBindingDisplay(binding: HouseholdTopicBindingRecord): string {
|
|
||||||
return binding.topicName?.trim() || `thread ${binding.telegramThreadId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupTopicRoleLabel(locale: BotLocale, role: HouseholdTopicRole): string {
|
function setupTopicRoleLabel(locale: BotLocale, role: HouseholdTopicRole): string {
|
||||||
return getBotTranslations(locale).setup.setupTopicBindRoleName(role)
|
return getBotTranslations(locale).setup.setupTopicBindRoleName(role)
|
||||||
}
|
}
|
||||||
@@ -236,6 +225,23 @@ function setupKeyboard(input: {
|
|||||||
>
|
>
|
||||||
> = []
|
> = []
|
||||||
|
|
||||||
|
// Create buttons for unconfigured roles (3 per row)
|
||||||
|
const createButtons: Array<{ text: string; callback_data: string }> = []
|
||||||
|
for (const role of HOUSEHOLD_TOPIC_ROLE_ORDER) {
|
||||||
|
if (!configuredRoles.has(role)) {
|
||||||
|
createButtons.push({
|
||||||
|
text: t.setupTopicCreateButton(setupTopicRoleLabel(input.locale, role)),
|
||||||
|
callback_data: `${SETUP_CREATE_TOPIC_CALLBACK_PREFIX}${role}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunk create buttons into rows of 3
|
||||||
|
for (let i = 0; i < createButtons.length; i += 3) {
|
||||||
|
rows.push(createButtons.slice(i, i + 3))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add join link button
|
||||||
if (input.joinDeepLink) {
|
if (input.joinDeepLink) {
|
||||||
rows.push([
|
rows.push([
|
||||||
{
|
{
|
||||||
@@ -245,23 +251,6 @@ function setupKeyboard(input: {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const role of HOUSEHOLD_TOPIC_ROLE_ORDER) {
|
|
||||||
if (configuredRoles.has(role)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
rows.push([
|
|
||||||
{
|
|
||||||
text: t.setupTopicCreateButton(setupTopicRoleLabel(input.locale, role)),
|
|
||||||
callback_data: `${SETUP_CREATE_TOPIC_CALLBACK_PREFIX}${role}`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t.setupTopicBindButton(setupTopicRoleLabel(input.locale, role)),
|
|
||||||
callback_data: `${SETUP_BIND_TOPIC_CALLBACK_PREFIX}${role}`
|
|
||||||
}
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows.length > 0
|
return rows.length > 0
|
||||||
? {
|
? {
|
||||||
reply_markup: {
|
reply_markup: {
|
||||||
@@ -277,17 +266,28 @@ function setupTopicChecklist(input: {
|
|||||||
}): string {
|
}): string {
|
||||||
const t = getBotTranslations(input.locale).setup
|
const t = getBotTranslations(input.locale).setup
|
||||||
const bindingByRole = new Map(input.bindings.map((binding) => [binding.role, binding]))
|
const bindingByRole = new Map(input.bindings.map((binding) => [binding.role, binding]))
|
||||||
|
const configuredCount = input.bindings.length
|
||||||
|
const totalCount = HOUSEHOLD_TOPIC_ROLE_ORDER.length
|
||||||
|
|
||||||
return [
|
const lines = [t.setupTopicsHeading(configuredCount, totalCount)]
|
||||||
t.setupTopicsHeading,
|
|
||||||
...HOUSEHOLD_TOPIC_ROLE_ORDER.map((role) => {
|
// Group roles in pairs for compact display
|
||||||
const binding = bindingByRole.get(role)
|
for (let i = 0; i < HOUSEHOLD_TOPIC_ROLE_ORDER.length; i += 2) {
|
||||||
const roleLabel = setupTopicRoleLabel(input.locale, role)
|
const role1 = HOUSEHOLD_TOPIC_ROLE_ORDER[i]!
|
||||||
return binding
|
const role2 = HOUSEHOLD_TOPIC_ROLE_ORDER[i + 1]
|
||||||
? t.setupTopicBound(roleLabel, topicBindingDisplay(binding))
|
const binding1 = bindingByRole.get(role1)
|
||||||
: t.setupTopicMissing(roleLabel)
|
const binding2 = role2 ? bindingByRole.get(role2) : null
|
||||||
})
|
const label1 = setupTopicRoleLabel(input.locale, role1)
|
||||||
].join('\n')
|
const label2 = role2 ? setupTopicRoleLabel(input.locale, role2) : null
|
||||||
|
|
||||||
|
const status1 = binding1 ? t.setupTopicBound(label1) : t.setupTopicMissing(label1)
|
||||||
|
const status2 =
|
||||||
|
label2 && role2 ? (binding2 ? t.setupTopicBound(label2) : t.setupTopicMissing(label2)) : ''
|
||||||
|
|
||||||
|
lines.push(status2 ? `${status1} ${status2}` : status1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupReply(input: {
|
function setupReply(input: {
|
||||||
@@ -302,7 +302,6 @@ function setupReply(input: {
|
|||||||
text: [
|
text: [
|
||||||
t.setupSummary({
|
t.setupSummary({
|
||||||
householdName: input.household.householdName,
|
householdName: input.household.householdName,
|
||||||
telegramChatId: input.household.telegramChatId,
|
|
||||||
created: input.created
|
created: input.created
|
||||||
}),
|
}),
|
||||||
setupTopicChecklist({
|
setupTopicChecklist({
|
||||||
@@ -318,34 +317,6 @@ function setupReply(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isHouseholdTopicRole(value: string): value is HouseholdTopicRole {
|
|
||||||
return (
|
|
||||||
value === 'chat' ||
|
|
||||||
value === 'purchase' ||
|
|
||||||
value === 'feedback' ||
|
|
||||||
value === 'reminders' ||
|
|
||||||
value === 'payments'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSetupBindPayload(payload: Record<string, unknown>): {
|
|
||||||
role: HouseholdTopicRole
|
|
||||||
setupMessageId?: number
|
|
||||||
} | null {
|
|
||||||
if (typeof payload.role !== 'string' || !isHouseholdTopicRole(payload.role)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
role: payload.role,
|
|
||||||
...(typeof payload.setupMessageId === 'number' && Number.isInteger(payload.setupMessageId)
|
|
||||||
? {
|
|
||||||
setupMessageId: payload.setupMessageId
|
|
||||||
}
|
|
||||||
: {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMiniAppBaseUrl(
|
function buildMiniAppBaseUrl(
|
||||||
miniAppUrl: string | undefined,
|
miniAppUrl: string | undefined,
|
||||||
botUsername?: string | undefined
|
botUsername?: string | undefined
|
||||||
@@ -559,83 +530,6 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.promptRepository) {
|
|
||||||
const promptRepository = options.promptRepository
|
|
||||||
|
|
||||||
options.bot.on('message', async (ctx, next) => {
|
|
||||||
if (!isGroupChat(ctx) || !isTopicMessage(ctx) || isCommandMessage(ctx)) {
|
|
||||||
await next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const telegramUserId = ctx.from?.id?.toString()
|
|
||||||
const telegramChatId = ctx.chat?.id?.toString()
|
|
||||||
const telegramThreadId =
|
|
||||||
ctx.msg && 'message_thread_id' in ctx.msg ? ctx.msg.message_thread_id?.toString() : null
|
|
||||||
|
|
||||||
if (!telegramUserId || !telegramChatId || !telegramThreadId) {
|
|
||||||
await next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const pending = await promptRepository.getPendingAction(telegramChatId, telegramUserId)
|
|
||||||
if (pending?.action !== SETUP_BIND_TOPIC_ACTION) {
|
|
||||||
await next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = parseSetupBindPayload(pending.payload)
|
|
||||||
if (!payload) {
|
|
||||||
await promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
|
||||||
await next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const locale = await resolveReplyLocale({
|
|
||||||
ctx,
|
|
||||||
repository: options.householdConfigurationRepository
|
|
||||||
})
|
|
||||||
const result = await options.householdSetupService.bindTopic({
|
|
||||||
actorIsAdmin: await isGroupAdmin(ctx),
|
|
||||||
telegramChatId,
|
|
||||||
telegramThreadId,
|
|
||||||
role: payload.role
|
|
||||||
})
|
|
||||||
|
|
||||||
await promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
|
||||||
|
|
||||||
if (result.status === 'rejected') {
|
|
||||||
await ctx.reply(bindRejectionMessage(locale, result.reason))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.setupMessageId && options.householdConfigurationRepository) {
|
|
||||||
const reply = await buildSetupReplyForHousehold({
|
|
||||||
ctx,
|
|
||||||
locale,
|
|
||||||
household: result.household,
|
|
||||||
created: false
|
|
||||||
})
|
|
||||||
|
|
||||||
await ctx.api.editMessageText(
|
|
||||||
Number(telegramChatId),
|
|
||||||
payload.setupMessageId,
|
|
||||||
reply.text,
|
|
||||||
'reply_markup' in reply ? { reply_markup: reply.reply_markup } : {}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.reply(
|
|
||||||
bindTopicSuccessMessage(
|
|
||||||
locale,
|
|
||||||
payload.role,
|
|
||||||
result.household.householdName,
|
|
||||||
result.binding.telegramThreadId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
options.bot.command('start', async (ctx) => {
|
options.bot.command('start', async (ctx) => {
|
||||||
const fallbackLocale = await resolveReplyLocale({
|
const fallbackLocale = await resolveReplyLocale({
|
||||||
ctx,
|
ctx,
|
||||||
@@ -864,6 +758,83 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
await handleBindTopicCommand(ctx, 'payments')
|
await handleBindTopicCommand(ctx, 'payments')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
options.bot.command('bind', async (ctx) => {
|
||||||
|
const locale = await resolveReplyLocale({
|
||||||
|
ctx,
|
||||||
|
repository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
|
const t = getBotTranslations(locale)
|
||||||
|
|
||||||
|
if (!isGroupChat(ctx)) {
|
||||||
|
await ctx.reply(t.setup.useBindInTopic)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.householdConfigurationRepository) {
|
||||||
|
await ctx.reply(t.setup.householdNotConfigured)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const household = await options.householdConfigurationRepository.getTelegramHouseholdChat(
|
||||||
|
ctx.chat.id.toString()
|
||||||
|
)
|
||||||
|
if (!household) {
|
||||||
|
await ctx.reply(t.setup.householdNotConfigured)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await isGroupAdmin(ctx))) {
|
||||||
|
await ctx.reply(t.setup.onlyTelegramAdminsBindTopics)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const telegramThreadId =
|
||||||
|
ctx.msg && 'message_thread_id' in ctx.msg ? ctx.msg.message_thread_id?.toString() : null
|
||||||
|
|
||||||
|
// If not in a topic, show error
|
||||||
|
if (!telegramThreadId) {
|
||||||
|
await ctx.reply(t.setup.useBindInTopic)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this topic is already bound
|
||||||
|
const existingBinding =
|
||||||
|
await options.householdConfigurationRepository.findHouseholdTopicByTelegramContext({
|
||||||
|
telegramChatId: ctx.chat.id.toString(),
|
||||||
|
telegramThreadId
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingBinding) {
|
||||||
|
const roleLabel = setupTopicRoleLabel(locale, existingBinding.role)
|
||||||
|
await ctx.reply(t.setup.topicAlreadyBound(roleLabel))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all existing bindings to show which roles are available
|
||||||
|
const bindings = await options.householdConfigurationRepository.listHouseholdTopicBindings(
|
||||||
|
household.householdId
|
||||||
|
)
|
||||||
|
const boundRoles = new Set(bindings.map((b) => b.role))
|
||||||
|
const availableRoles = HOUSEHOLD_TOPIC_ROLE_ORDER.filter((role) => !boundRoles.has(role))
|
||||||
|
|
||||||
|
if (availableRoles.length === 0) {
|
||||||
|
await ctx.reply(t.setup.allRolesConfigured)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show role selection buttons
|
||||||
|
await ctx.reply(t.setup.bindSelectRole, {
|
||||||
|
reply_markup: {
|
||||||
|
inline_keyboard: availableRoles.map((role) => [
|
||||||
|
{
|
||||||
|
text: setupTopicRoleLabel(locale, role),
|
||||||
|
callback_data: `bind_topic:${role}:${telegramThreadId}`
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
options.bot.command('pending_members', async (ctx) => {
|
options.bot.command('pending_members', async (ctx) => {
|
||||||
const locale = await resolveReplyLocale({
|
const locale = await resolveReplyLocale({
|
||||||
ctx,
|
ctx,
|
||||||
@@ -1071,8 +1042,6 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (options.promptRepository) {
|
if (options.promptRepository) {
|
||||||
const promptRepository = options.promptRepository
|
|
||||||
|
|
||||||
options.bot.callbackQuery(
|
options.bot.callbackQuery(
|
||||||
new RegExp(`^${SETUP_CREATE_TOPIC_CALLBACK_PREFIX}(purchase|feedback|reminders|payments)$`),
|
new RegExp(`^${SETUP_CREATE_TOPIC_CALLBACK_PREFIX}(purchase|feedback|reminders|payments)$`),
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
@@ -1164,8 +1133,9 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Bind topic callback from /bind command
|
||||||
options.bot.callbackQuery(
|
options.bot.callbackQuery(
|
||||||
new RegExp(`^${SETUP_BIND_TOPIC_CALLBACK_PREFIX}(purchase|feedback|reminders|payments)$`),
|
new RegExp(`^bind_topic:(chat|purchase|feedback|reminders|payments):(\\d+)$`),
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const locale = await resolveReplyLocale({
|
const locale = await resolveReplyLocale({
|
||||||
ctx,
|
ctx,
|
||||||
@@ -1181,18 +1151,8 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const telegramUserId = ctx.from?.id?.toString()
|
const actorIsAdmin = await isGroupAdmin(ctx)
|
||||||
const telegramChatId = ctx.chat.id.toString()
|
if (!actorIsAdmin) {
|
||||||
const role = ctx.match[1] as HouseholdTopicRole
|
|
||||||
if (!telegramUserId) {
|
|
||||||
await ctx.answerCallbackQuery({
|
|
||||||
text: t.unableToIdentifySelectedMember,
|
|
||||||
show_alert: true
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await isGroupAdmin(ctx))) {
|
|
||||||
await ctx.answerCallbackQuery({
|
await ctx.answerCallbackQuery({
|
||||||
text: t.onlyTelegramAdminsBindTopics,
|
text: t.onlyTelegramAdminsBindTopics,
|
||||||
show_alert: true
|
show_alert: true
|
||||||
@@ -1200,24 +1160,35 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await promptRepository.upsertPendingAction({
|
const role = ctx.match[1] as HouseholdTopicRole
|
||||||
telegramUserId,
|
const telegramThreadId = ctx.match[2]!
|
||||||
telegramChatId,
|
|
||||||
action: SETUP_BIND_TOPIC_ACTION,
|
const result = await options.householdSetupService.bindTopic({
|
||||||
payload: {
|
actorIsAdmin,
|
||||||
role,
|
telegramChatId: ctx.chat.id.toString(),
|
||||||
...(ctx.msg
|
telegramThreadId,
|
||||||
? {
|
role
|
||||||
setupMessageId: ctx.msg.message_id
|
|
||||||
}
|
|
||||||
: {})
|
|
||||||
},
|
|
||||||
expiresAt: nowInstant().add({ milliseconds: SETUP_BIND_TOPIC_TTL_MS })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
await ctx.answerCallbackQuery({
|
||||||
|
text: bindRejectionMessage(locale, result.reason),
|
||||||
|
show_alert: true
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await ctx.answerCallbackQuery({
|
await ctx.answerCallbackQuery({
|
||||||
text: t.setupTopicBindPending(setupTopicRoleLabel(locale, role))
|
text: t.topicBoundSuccess(
|
||||||
|
setupTopicRoleLabel(locale, role),
|
||||||
|
result.household.householdName
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Edit the role selection message to show success
|
||||||
|
await ctx.editMessageText(
|
||||||
|
t.topicBoundSuccess(setupTopicRoleLabel(locale, role), result.household.householdName)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const enBotTranslations: BotTranslationCatalog = {
|
|||||||
bind_feedback_topic: 'Bind the current topic as feedback',
|
bind_feedback_topic: 'Bind the current topic as feedback',
|
||||||
bind_reminders_topic: 'Bind the current topic as reminders',
|
bind_reminders_topic: 'Bind the current topic as reminders',
|
||||||
bind_payments_topic: 'Bind the current topic as payments',
|
bind_payments_topic: 'Bind the current topic as payments',
|
||||||
|
bind: 'Bind current topic to a household role',
|
||||||
join_link: 'Get a shareable link for new members to join',
|
join_link: 'Get a shareable link for new members to join',
|
||||||
payment_add: 'Record your rent or utilities payment',
|
payment_add: 'Record your rent or utilities payment',
|
||||||
pending_members: 'List pending household join requests',
|
pending_members: 'List pending household join requests',
|
||||||
@@ -54,25 +55,19 @@ export const enBotTranslations: BotTranslationCatalog = {
|
|||||||
`You are already an active member. Open the mini app to view ${displayName}.`,
|
`You are already an active member. Open the mini app to view ${displayName}.`,
|
||||||
joinRequestSent: (householdName) =>
|
joinRequestSent: (householdName) =>
|
||||||
`Join request sent for ${householdName}. Wait for a household admin to confirm you.`,
|
`Join request sent for ${householdName}. Wait for a household admin to confirm you.`,
|
||||||
setupSummary: ({ householdName, telegramChatId, created }) =>
|
setupSummary: ({ householdName, created }) =>
|
||||||
[
|
`${created ? '✅' : 'ℹ️'} ${householdName} is ${created ? 'ready' : 'already registered'}!`,
|
||||||
`Household ${created ? 'created' : 'already registered'}: ${householdName}`,
|
setupTopicsHeading: (configured, total) => `Topics: ${configured}/${total} configured`,
|
||||||
`Chat ID: ${telegramChatId}`,
|
setupTopicBound: (role) => `✅ ${role}`,
|
||||||
'Use the buttons below to finish topic setup. For an existing topic, tap Bind and then send any message inside that topic.',
|
setupTopicMissing: (role) => `⚪ ${role}`,
|
||||||
'Members should open the bot chat from the button below and confirm the join request there.'
|
setupTopicCreateButton: (role) => `+ ${role}`,
|
||||||
].join('\n'),
|
setupTopicBindButton: (role) => `Bind ${role}`,
|
||||||
setupTopicsHeading: 'Topic setup:',
|
|
||||||
setupTopicBound: (role, topic) => `- ${role}: bound to ${topic}`,
|
|
||||||
setupTopicMissing: (role) => `- ${role}: not configured`,
|
|
||||||
setupTopicCreateButton: (role) => `Create ${role} topic`,
|
|
||||||
setupTopicBindButton: (role) => `Bind ${role} topic`,
|
|
||||||
setupTopicCreateFailed:
|
setupTopicCreateFailed:
|
||||||
'I could not create that topic. Check bot admin permissions and forum settings.',
|
'I could not create that topic. Check bot admin permissions and forum settings.',
|
||||||
setupTopicCreateForbidden:
|
setupTopicCreateForbidden:
|
||||||
'I need permission to manage topics in this group before I can create one automatically.',
|
'I need permission to manage topics in this group before I can create one automatically.',
|
||||||
setupTopicCreated: (role, topicName) => `${role} topic created and bound: ${topicName}.`,
|
setupTopicCreated: (role, topicName) => `${role} topic created and bound: ${topicName}.`,
|
||||||
setupTopicBindPending: (role) =>
|
setupTopicBindPending: '',
|
||||||
`Binding mode is on for ${role}. Open the target topic and send any message there within 10 minutes.`,
|
|
||||||
setupTopicBindCancelled: 'Topic binding mode cleared.',
|
setupTopicBindCancelled: 'Topic binding mode cleared.',
|
||||||
setupTopicBindNotAvailable: 'That topic-binding action is no longer available.',
|
setupTopicBindNotAvailable: 'That topic-binding action is no longer available.',
|
||||||
setupTopicBindRoleName: (role) => {
|
setupTopicBindRoleName: (role) => {
|
||||||
@@ -135,7 +130,12 @@ export const enBotTranslations: BotTranslationCatalog = {
|
|||||||
useJoinLinkInGroup: 'Use /join_link inside the household group.',
|
useJoinLinkInGroup: 'Use /join_link inside the household group.',
|
||||||
joinLinkUnavailable: 'Could not generate join link.',
|
joinLinkUnavailable: 'Could not generate join link.',
|
||||||
joinLinkReady: (link, householdName) =>
|
joinLinkReady: (link, householdName) =>
|
||||||
`Join link for ${householdName}:\n${link}\n\nAnyone with this link can join the household. Share it carefully.`
|
`Join link for ${householdName}:\n${link}\n\nAnyone with this link can join the household. Share it carefully.`,
|
||||||
|
useBindInTopic: 'Use /bind inside a topic to bind it to a role.',
|
||||||
|
topicAlreadyBound: (role) => `This topic is already bound as ${role}.`,
|
||||||
|
bindSelectRole: 'Bind this topic as:',
|
||||||
|
topicBoundSuccess: (role, householdName) => `Bound as ${role} for ${householdName}.`,
|
||||||
|
allRolesConfigured: 'All topic roles are already configured.'
|
||||||
},
|
},
|
||||||
anonymousFeedback: {
|
anonymousFeedback: {
|
||||||
title: 'Anonymous household note',
|
title: 'Anonymous household note',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
|||||||
bind_feedback_topic: 'Назначить текущий топик для анонимных сообщений',
|
bind_feedback_topic: 'Назначить текущий топик для анонимных сообщений',
|
||||||
bind_reminders_topic: 'Назначить текущий топик для напоминаний',
|
bind_reminders_topic: 'Назначить текущий топик для напоминаний',
|
||||||
bind_payments_topic: 'Назначить текущий топик для оплат',
|
bind_payments_topic: 'Назначить текущий топик для оплат',
|
||||||
|
bind: 'Привязать текущий топик к роли дома',
|
||||||
join_link: 'Получить ссылку для приглашения новых участников',
|
join_link: 'Получить ссылку для приглашения новых участников',
|
||||||
payment_add: 'Подтвердить оплату аренды или коммуналки',
|
payment_add: 'Подтвердить оплату аренды или коммуналки',
|
||||||
pending_members: 'Показать ожидающие заявки на вступление',
|
pending_members: 'Показать ожидающие заявки на вступление',
|
||||||
@@ -56,25 +57,19 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
|||||||
`Вы уже активный участник. Откройте мини-приложение, чтобы увидеть профиль ${displayName}.`,
|
`Вы уже активный участник. Откройте мини-приложение, чтобы увидеть профиль ${displayName}.`,
|
||||||
joinRequestSent: (householdName) =>
|
joinRequestSent: (householdName) =>
|
||||||
`Заявка на вступление в ${householdName} отправлена. Дождитесь подтверждения от админа дома.`,
|
`Заявка на вступление в ${householdName} отправлена. Дождитесь подтверждения от админа дома.`,
|
||||||
setupSummary: ({ householdName, telegramChatId, created }) =>
|
setupSummary: ({ householdName, created }) =>
|
||||||
[
|
`${created ? '✅' : 'ℹ️'} ${householdName} ${created ? 'готов' : 'уже подключён'}!`,
|
||||||
`${created ? 'Дом создан' : 'Дом уже подключён'}: ${householdName}`,
|
setupTopicsHeading: (configured, total) => `Топики: ${configured}/${total} настроено`,
|
||||||
`ID чата: ${telegramChatId}`,
|
setupTopicBound: (role) => `✅ ${role}`,
|
||||||
'Используйте кнопки ниже, чтобы завершить настройку топиков. Для уже существующего топика нажмите «Привязать», затем отправьте любое сообщение внутри этого топика.',
|
setupTopicMissing: (role) => `⚪ ${role}`,
|
||||||
'Участники должны открыть чат с ботом по кнопке ниже и подтвердить заявку на вступление.'
|
setupTopicCreateButton: (role) => `+ ${role}`,
|
||||||
].join('\n'),
|
setupTopicBindButton: (role) => `Привязать ${role}`,
|
||||||
setupTopicsHeading: 'Настройка топиков:',
|
|
||||||
setupTopicBound: (role, topic) => `- ${role}: привязан к ${topic}`,
|
|
||||||
setupTopicMissing: (role) => `- ${role}: не настроен`,
|
|
||||||
setupTopicCreateButton: (role) => `Создать топик для ${role}`,
|
|
||||||
setupTopicBindButton: (role) => `Привязать топик для ${role}`,
|
|
||||||
setupTopicCreateFailed:
|
setupTopicCreateFailed:
|
||||||
'Не удалось создать этот топик. Проверьте права бота и включённые форум-топики в группе.',
|
'Не удалось создать этот топик. Проверьте права бота и включённые форум-топики в группе.',
|
||||||
setupTopicCreateForbidden:
|
setupTopicCreateForbidden:
|
||||||
'Мне нужны права на управление топиками в этой группе, чтобы создать его автоматически.',
|
'Мне нужны права на управление топиками в этой группе, чтобы создать его автоматически.',
|
||||||
setupTopicCreated: (role, topicName) => `Топик ${role} создан и привязан: ${topicName}.`,
|
setupTopicCreated: (role, topicName) => `Топик ${role} создан и привязан: ${topicName}.`,
|
||||||
setupTopicBindPending: (role) =>
|
setupTopicBindPending: '',
|
||||||
`Режим привязки включён для ${role}. Откройте нужный топик и отправьте там любое сообщение в течение 10 минут.`,
|
|
||||||
setupTopicBindCancelled: 'Режим привязки топика очищен.',
|
setupTopicBindCancelled: 'Режим привязки топика очищен.',
|
||||||
setupTopicBindNotAvailable: 'Это действие привязки топика уже недоступно.',
|
setupTopicBindNotAvailable: 'Это действие привязки топика уже недоступно.',
|
||||||
setupTopicBindRoleName: (role) => {
|
setupTopicBindRoleName: (role) => {
|
||||||
@@ -137,7 +132,12 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
|||||||
useJoinLinkInGroup: 'Используйте /join_link внутри группы дома.',
|
useJoinLinkInGroup: 'Используйте /join_link внутри группы дома.',
|
||||||
joinLinkUnavailable: 'Не удалось сгенерировать ссылку для вступления.',
|
joinLinkUnavailable: 'Не удалось сгенерировать ссылку для вступления.',
|
||||||
joinLinkReady: (link, householdName) =>
|
joinLinkReady: (link, householdName) =>
|
||||||
`Поделитесь этой ссылкой, чтобы пригласить участников в ${householdName}:\n\n${link}\n\nЛюбой, у кого есть эта ссылка, может подать заявку на вступление.`
|
`Поделитесь этой ссылкой, чтобы пригласить участников в ${householdName}:\n\n${link}\n\nЛюбой, у кого есть эта ссылка, может подать заявку на вступление.`,
|
||||||
|
useBindInTopic: 'Используйте /bind внутри топика, чтобы привязать его к роли.',
|
||||||
|
topicAlreadyBound: (role) => `Этот топик уже привязан как ${role}.`,
|
||||||
|
bindSelectRole: 'Привязать этот топик как:',
|
||||||
|
topicBoundSuccess: (role, householdName) => `Привязан как ${role} для ${householdName}.`,
|
||||||
|
allRolesConfigured: 'Все роли топиков уже настроены.'
|
||||||
},
|
},
|
||||||
anonymousFeedback: {
|
anonymousFeedback: {
|
||||||
title: 'Анонимное сообщение по дому',
|
title: 'Анонимное сообщение по дому',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type TelegramCommandName =
|
|||||||
| 'cancel'
|
| 'cancel'
|
||||||
| 'setup'
|
| 'setup'
|
||||||
| 'unsetup'
|
| 'unsetup'
|
||||||
|
| 'bind'
|
||||||
| 'bind_chat_topic'
|
| 'bind_chat_topic'
|
||||||
| 'bind_purchase_topic'
|
| 'bind_purchase_topic'
|
||||||
| 'bind_feedback_topic'
|
| 'bind_feedback_topic'
|
||||||
@@ -29,6 +30,7 @@ export interface BotCommandDescriptions {
|
|||||||
bind_feedback_topic: string
|
bind_feedback_topic: string
|
||||||
bind_reminders_topic: string
|
bind_reminders_topic: string
|
||||||
bind_payments_topic: string
|
bind_payments_topic: string
|
||||||
|
bind: string
|
||||||
join_link: string
|
join_link: string
|
||||||
payment_add: string
|
payment_add: string
|
||||||
pending_members: string
|
pending_members: string
|
||||||
@@ -76,20 +78,16 @@ export interface BotTranslationCatalog {
|
|||||||
joinLinkInvalidOrExpired: string
|
joinLinkInvalidOrExpired: string
|
||||||
alreadyActiveMember: (displayName: string) => string
|
alreadyActiveMember: (displayName: string) => string
|
||||||
joinRequestSent: (householdName: string) => string
|
joinRequestSent: (householdName: string) => string
|
||||||
setupSummary: (params: {
|
setupSummary: (params: { householdName: string; created: boolean }) => string
|
||||||
householdName: string
|
setupTopicsHeading: (configured: number, total: number) => string
|
||||||
telegramChatId: string
|
setupTopicBound: (role: string) => string
|
||||||
created: boolean
|
|
||||||
}) => string
|
|
||||||
setupTopicsHeading: string
|
|
||||||
setupTopicBound: (role: string, topic: string) => string
|
|
||||||
setupTopicMissing: (role: string) => string
|
setupTopicMissing: (role: string) => string
|
||||||
setupTopicCreateButton: (role: string) => string
|
setupTopicCreateButton: (role: string) => string
|
||||||
setupTopicBindButton: (role: string) => string
|
setupTopicBindButton: (role: string) => string
|
||||||
setupTopicCreateFailed: string
|
setupTopicCreateFailed: string
|
||||||
setupTopicCreateForbidden: string
|
setupTopicCreateForbidden: string
|
||||||
setupTopicCreated: (role: string, topicName: string) => string
|
setupTopicCreated: (role: string, topicName: string) => string
|
||||||
setupTopicBindPending: (role: string) => string
|
setupTopicBindPending: string
|
||||||
setupTopicBindCancelled: string
|
setupTopicBindCancelled: string
|
||||||
setupTopicBindNotAvailable: string
|
setupTopicBindNotAvailable: string
|
||||||
setupTopicBindRoleName: (
|
setupTopicBindRoleName: (
|
||||||
@@ -123,6 +121,11 @@ export interface BotTranslationCatalog {
|
|||||||
useJoinLinkInGroup: string
|
useJoinLinkInGroup: string
|
||||||
joinLinkUnavailable: string
|
joinLinkUnavailable: string
|
||||||
joinLinkReady: (link: string, householdName: string) => string
|
joinLinkReady: (link: string, householdName: string) => string
|
||||||
|
useBindInTopic: string
|
||||||
|
topicAlreadyBound: (role: string) => string
|
||||||
|
bindSelectRole: string
|
||||||
|
topicBoundSuccess: (role: string, householdName: string) => string
|
||||||
|
allRolesConfigured: string
|
||||||
}
|
}
|
||||||
anonymousFeedback: {
|
anonymousFeedback: {
|
||||||
title: string
|
title: string
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const GROUP_ADMIN_COMMAND_NAMES = [
|
|||||||
...GROUP_MEMBER_COMMAND_NAMES,
|
...GROUP_MEMBER_COMMAND_NAMES,
|
||||||
'setup',
|
'setup',
|
||||||
'unsetup',
|
'unsetup',
|
||||||
|
'bind',
|
||||||
'bind_chat_topic',
|
'bind_chat_topic',
|
||||||
'bind_purchase_topic',
|
'bind_purchase_topic',
|
||||||
'bind_feedback_topic',
|
'bind_feedback_topic',
|
||||||
|
|||||||
Reference in New Issue
Block a user