From aa228d84ebdc10b3d3d2ad6b51391c3b9550952f Mon Sep 17 00:00:00 2001 From: lendry Date: Fri, 26 Jun 2026 13:01:52 +0300 Subject: [PATCH] global fix and update bot Api --- .../src/controllers/chat.controller.ts | 12 ++ .../src/controllers/family.controller.ts | 20 +- .../telegram-bot-api.controller.ts | 193 +++++++++++++++++- apps/api-gateway/src/dto/chat.dto.ts | 5 + apps/api-gateway/src/dto/identity.dto.ts | 6 - apps/docs/lib/api-endpoints.ts | 17 +- apps/docs/lib/bot-examples.ts | 3 +- apps/docs/lib/docs-pages.ts | 11 +- .../app/mini-apps/bot-manage/content.tsx | 28 +++ .../chat/chat-message-context-menu.tsx | 117 ++++++++++- .../components/family/family-bot-chat.tsx | 33 ++- .../components/family/family-group-view.tsx | 70 ++++++- .../family/family-invite-dialog.tsx | 45 +--- .../family/family-sidebar-panel.tsx | 32 +-- .../components/family/mini-family-chat.tsx | 67 +++++- apps/frontend/lib/api.ts | 21 +- apps/sso-core/prisma/schema.prisma | 7 + .../src/domain/auth-grpc.controller.ts | 27 +-- .../src/domain/bot/bot-api.service.ts | 116 ++++++++++- .../bot/bot-father-assistant.service.ts | 87 +++++++- .../src/domain/bot/bot-father-seed.service.ts | 96 +++++++++ .../src/domain/bot/bot-father.constants.ts | 5 + .../src/domain/bot/bot-inbound.service.ts | 4 +- apps/sso-core/src/domain/chat.service.ts | 36 ++++ apps/sso-core/src/domain/family.service.ts | 120 ++++++----- .../src/domain/system-account.util.ts | 2 +- shared/proto/bot.proto | 2 + shared/proto/chat.proto | 10 + shared/proto/identity.proto | 9 +- 29 files changed, 980 insertions(+), 221 deletions(-) diff --git a/apps/api-gateway/src/controllers/chat.controller.ts b/apps/api-gateway/src/controllers/chat.controller.ts index 99ea744..1846d0d 100644 --- a/apps/api-gateway/src/controllers/chat.controller.ts +++ b/apps/api-gateway/src/controllers/chat.controller.ts @@ -13,6 +13,7 @@ import { ForwardMessagesDto, MarkRoomReadDto, SendChatMessageDto, + SetMessagePinnedDto, SetRoomNotificationsMutedDto, ToggleMessageReactionDto, UpdateChatRoomDto, @@ -198,6 +199,17 @@ export class ChatController { return firstValueFrom(this.core.chat.DeleteMessage({ userId, messageId })); } + @Post('messages/:messageId/pin') + @ApiOperation({ summary: 'Закрепить или открепить сообщение' }) + async setMessagePinned( + @Headers('authorization') authorization: string | undefined, + @Param('messageId') messageId: string, + @Body() dto: SetMessagePinnedDto + ) { + const userId = await this.auth(authorization); + return firstValueFrom(this.core.chat.SetMessagePinned({ userId, messageId, pinned: dto.pinned })); + } + @Post('messages/:messageId/reactions') @ApiOperation({ summary: 'Поставить или снять реакцию' }) async toggleReaction( diff --git a/apps/api-gateway/src/controllers/family.controller.ts b/apps/api-gateway/src/controllers/family.controller.ts index bc9f4a0..dde513a 100644 --- a/apps/api-gateway/src/controllers/family.controller.ts +++ b/apps/api-gateway/src/controllers/family.controller.ts @@ -5,7 +5,7 @@ import { firstValueFrom } from 'rxjs'; import { map } from 'rxjs/operators'; import { CoreGrpcService } from '../core-grpc.service'; import { getAuthorizedUserId } from '../document-access'; -import { AddFamilyMemberDto, AddBotToFamilyDto, CreateFamilyGroupDto, RespondFamilyInviteDto, SendFamilyInviteDto, UpdateFamilyGroupDto } from '../dto/identity.dto'; +import { AddFamilyMemberDto, CreateFamilyGroupDto, RespondFamilyInviteDto, SendFamilyInviteDto, UpdateFamilyGroupDto } from '../dto/identity.dto'; @ApiTags('Семья') @ApiBearerAuth() @@ -152,23 +152,5 @@ export class FamilyController { return firstValueFrom(this.core.family.GetFamilyPresence({ requesterId, groupId })); } - @Post('groups/:groupId/botfather') - @ApiOperation({ summary: 'Добавить BotFather в семью', description: 'Добавляет системного бота BotFather для создания Telegram-ботов через чат.' }) - async addBotFather(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) { - const requesterId = await this.auth(authorization); - return firstValueFrom(this.core.family.AddBotFatherToFamily({ requesterId, groupId })); - } - - @Post('groups/:groupId/bots') - @ApiOperation({ summary: 'Добавить бота в семью', description: 'Добавляет Telegram-бота (своего или другого пользователя) в семейную группу.' }) - @ApiBody({ type: AddBotToFamilyDto }) - async addBot( - @Headers('authorization') authorization: string | undefined, - @Param('groupId') groupId: string, - @Body() dto: AddBotToFamilyDto - ) { - const requesterId = await this.auth(authorization); - return firstValueFrom(this.core.family.AddBotToFamily({ requesterId, groupId, botId: dto.botId })); - } } diff --git a/apps/api-gateway/src/controllers/telegram-bot-api.controller.ts b/apps/api-gateway/src/controllers/telegram-bot-api.controller.ts index 7349ec3..4633b70 100644 --- a/apps/api-gateway/src/controllers/telegram-bot-api.controller.ts +++ b/apps/api-gateway/src/controllers/telegram-bot-api.controller.ts @@ -1,5 +1,5 @@ -import { All, Body, Controller, HttpCode, Param, Req, Res } from '@nestjs/common'; -import { ApiExcludeController } from '@nestjs/swagger'; +import { All, Body, Controller, Get, HttpCode, Param, Post, Query, Req, Res } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; import { firstValueFrom, timeout } from 'rxjs'; import { CoreGrpcService } from '../core-grpc.service'; @@ -13,19 +13,205 @@ type HttpResponse = { setHeader(name: string, value: string): void; }; -@ApiExcludeController() +const botPathParam = { + name: 'botPath', + description: 'Путь токена в формате bot, совместимо с Telegram Bot API.', + example: 'bot123456:ABC-DEF' +}; + +const chatIdProperty = { + oneOf: [{ type: 'string' }, { type: 'number' }], + description: 'ID пользователя/чата в приложении или numeric chat_id из ответа Bot API.' +}; + +@ApiTags('Telegram Bot API') @Controller() export class TelegramBotApiController { constructor(private readonly core: CoreGrpcService) {} + @Get(':botPath/getMe') + @ApiOperation({ + summary: 'Bot API: getMe', + description: 'Возвращает профиль бота в формате Telegram Bot API.' + }) + @ApiParam(botPathParam) + getMe(@Res({ passthrough: true }) response: HttpResponse, @Param('botPath') botPath: string) { + return this.executeTelegramMethod(response, botPath, 'getMe', {}); + } + + @Post(':botPath/getChat') + @HttpCode(200) + @ApiOperation({ + summary: 'Bot API: getChat', + description: 'Возвращает приватный чат приложения и pinned_message, если сообщение закреплено.' + }) + @ApiParam(botPathParam) + @ApiBody({ + schema: { + type: 'object', + required: ['chat_id'], + properties: { + chat_id: chatIdProperty + } + } + }) + getChatPost( + @Res({ passthrough: true }) response: HttpResponse, + @Param('botPath') botPath: string, + @Body() body: Record + ) { + return this.executeTelegramMethod(response, botPath, 'getChat', body ?? {}); + } + + @Get(':botPath/getChat') + @ApiOperation({ + summary: 'Bot API: getChat (GET)', + description: 'GET-вариант метода getChat для клиентов, которые передают параметры в query.' + }) + @ApiParam(botPathParam) + getChatGet( + @Res({ passthrough: true }) response: HttpResponse, + @Param('botPath') botPath: string, + @Query() query: Record + ) { + return this.executeTelegramMethod(response, botPath, 'getChat', query ?? {}); + } + + @Post(':botPath/sendMessage') + @HttpCode(200) + @ApiOperation({ + summary: 'Bot API: sendMessage', + description: 'Отправляет сообщение пользователю внутри приложения. Поддерживаются text, parse_mode и reply_markup.' + }) + @ApiParam(botPathParam) + @ApiBody({ + schema: { + type: 'object', + required: ['chat_id', 'text'], + properties: { + chat_id: chatIdProperty, + text: { type: 'string' }, + parse_mode: { type: 'string', example: 'HTML' }, + reply_markup: { type: 'object', additionalProperties: true } + } + } + }) + sendMessage( + @Res({ passthrough: true }) response: HttpResponse, + @Param('botPath') botPath: string, + @Body() body: Record + ) { + return this.executeTelegramMethod(response, botPath, 'sendMessage', body ?? {}); + } + + @Post(':botPath/editMessageText') + @HttpCode(200) + @ApiOperation({ + summary: 'Bot API: editMessageText', + description: 'Редактирует текст ранее отправленного ботом сообщения.' + }) + @ApiParam(botPathParam) + @ApiBody({ + schema: { + type: 'object', + required: ['chat_id', 'message_id', 'text'], + properties: { + chat_id: chatIdProperty, + message_id: { type: 'integer' }, + text: { type: 'string' }, + parse_mode: { type: 'string', example: 'HTML' }, + reply_markup: { type: 'object', additionalProperties: true } + } + } + }) + editMessageText( + @Res({ passthrough: true }) response: HttpResponse, + @Param('botPath') botPath: string, + @Body() body: Record + ) { + return this.executeTelegramMethod(response, botPath, 'editMessageText', body ?? {}); + } + + @Post(':botPath/pinChatMessage') + @HttpCode(200) + @ApiOperation({ + summary: 'Bot API: pinChatMessage', + description: 'Закрепляет сообщение бота в чате приложения.' + }) + @ApiParam(botPathParam) + @ApiBody({ + schema: { + type: 'object', + required: ['chat_id', 'message_id'], + properties: { + chat_id: chatIdProperty, + message_id: { type: 'integer' }, + disable_notification: { type: 'boolean', default: false } + } + } + }) + pinChatMessage( + @Res({ passthrough: true }) response: HttpResponse, + @Param('botPath') botPath: string, + @Body() body: Record + ) { + return this.executeTelegramMethod(response, botPath, 'pinChatMessage', body ?? {}); + } + + @Post(':botPath/unpinChatMessage') + @HttpCode(200) + @ApiOperation({ + summary: 'Bot API: unpinChatMessage', + description: 'Открепляет указанное сообщение. Если message_id не передан, открепляет последнее закреплённое сообщение.' + }) + @ApiParam(botPathParam) + @ApiBody({ + schema: { + type: 'object', + required: ['chat_id'], + properties: { + chat_id: chatIdProperty, + message_id: { type: 'integer' } + } + } + }) + unpinChatMessage( + @Res({ passthrough: true }) response: HttpResponse, + @Param('botPath') botPath: string, + @Body() body: Record + ) { + return this.executeTelegramMethod(response, botPath, 'unpinChatMessage', body ?? {}); + } + @All(':botPath/:method') @HttpCode(200) + @ApiOperation({ + summary: 'Bot API: универсальный метод', + description: + 'Совместимый endpoint /bot/. Поддерживаются getMe, getChat, sendMessage, editMessageText, editMessageReplyMarkup, sendPhoto, sendDocument, pinChatMessage, unpinChatMessage, answerCallbackQuery, setWebhook, deleteWebhook, getWebhookInfo, getUpdates и setChatMenuButton.' + }) + @ApiParam(botPathParam) + @ApiParam({ + name: 'method', + description: 'Название метода Telegram Bot API.', + example: 'sendMessage' + }) async handleTelegramMethod( @Req() request: HttpRequest, @Res({ passthrough: true }) response: HttpResponse, @Param('botPath') botPath: string, @Param('method') method: string, @Body() body: Record + ) { + const payload = request.method === 'GET' ? { ...request.query } : body ?? {}; + return this.executeTelegramMethod(response, botPath, method, payload); + } + + private async executeTelegramMethod( + response: HttpResponse, + botPath: string, + method: string, + payload: Record ) { if (!botPath.startsWith('bot')) { response.status(404); @@ -38,7 +224,6 @@ export class TelegramBotApiController { return { ok: false, error_code: 401, description: 'Unauthorized' }; } - const payload = request.method === 'GET' ? { ...request.query } : body ?? {}; const grpcCall = this.core.bot.ExecuteBotMethod({ token, method, diff --git a/apps/api-gateway/src/dto/chat.dto.ts b/apps/api-gateway/src/dto/chat.dto.ts index 4d84c66..e96023b 100644 --- a/apps/api-gateway/src/dto/chat.dto.ts +++ b/apps/api-gateway/src/dto/chat.dto.ts @@ -99,6 +99,11 @@ export class ToggleMessageReactionDto { emoji!: string; } +export class SetMessagePinnedDto { + @IsBoolean() + pinned!: boolean; +} + export class ForwardMessagesDto { @IsArray() @ArrayNotEmpty() diff --git a/apps/api-gateway/src/dto/identity.dto.ts b/apps/api-gateway/src/dto/identity.dto.ts index cd69c36..be4a589 100644 --- a/apps/api-gateway/src/dto/identity.dto.ts +++ b/apps/api-gateway/src/dto/identity.dto.ts @@ -154,9 +154,3 @@ export class RespondFamilyInviteDto { accept!: boolean; } -export class AddBotToFamilyDto { - @ApiProperty({ description: 'ID бота для добавления в семью' }) - @IsString({ message: 'ID бота должен быть строкой' }) - @IsNotEmpty({ message: 'Укажите ID бота' }) - botId!: string; -} diff --git a/apps/docs/lib/api-endpoints.ts b/apps/docs/lib/api-endpoints.ts index e8bbf94..9ca41d4 100644 --- a/apps/docs/lib/api-endpoints.ts +++ b/apps/docs/lib/api-endpoints.ts @@ -170,18 +170,17 @@ export const apiReference: ApiTagGroup[] = [ description: 'Создатель может удалить участника; участник может удалить себя («Выйти»). Владельца семьи удалить нельзя.', auth: true }, - { method: 'POST', path: '/family/groups/{groupId}/invites', summary: 'Пригласить участника', auth: true }, - { method: 'GET', path: '/family/groups/{groupId}/invite-search', summary: 'Поиск пользователей для приглашения', auth: true }, - { method: 'GET', path: '/family/invites', summary: 'Входящие приглашения', auth: true }, - { method: 'POST', path: '/family/invites/{inviteId}/respond', summary: 'Принять или отклонить приглашение', auth: true }, - { method: 'GET', path: '/family/groups/{groupId}/presence', summary: 'Онлайн-статус участников семьи', auth: true }, { method: 'POST', - path: '/family/groups/{groupId}/botfather', - summary: 'Добавить BotFather в семью', - description: 'Создаёт BOT-чат с системным ботом BotFather для управления Telegram-ботами.', + path: '/family/groups/{groupId}/invites', + summary: 'Пригласить участника или бота', + description: 'Люди получают pending-приглашение. Системные боты и боты других пользователей добавляются автоматически через auto-accept приглашения.', auth: true - } + }, + { method: 'GET', path: '/family/groups/{groupId}/invite-search', summary: 'Поиск пользователей и ботов для приглашения', auth: true }, + { method: 'GET', path: '/family/invites', summary: 'Входящие приглашения', auth: true }, + { method: 'POST', path: '/family/invites/{inviteId}/respond', summary: 'Принять или отклонить приглашение', auth: true }, + { method: 'GET', path: '/family/groups/{groupId}/presence', summary: 'Онлайн-статус участников семьи', auth: true } ] }, { diff --git a/apps/docs/lib/bot-examples.ts b/apps/docs/lib/bot-examples.ts index d040370..94f97a5 100644 --- a/apps/docs/lib/bot-examples.ts +++ b/apps/docs/lib/bot-examples.ts @@ -156,7 +156,8 @@ curl -s -X POST '${API_BASE}/bots' \\ # Ответ содержит token — сохраните его, повторно не показывается при GET -# Профиль бота через BotFather в чате (после POST /family/groups/{id}/botfather): +# BotFather добавляется в семью через поиск и POST /family/groups/{id}/invites. +# Профиль бота через BotFather в чате: # /setdescription my_service Описание перед /start # /setabouttext my_service Краткое описание # /setuserpic my_service https://cdn.example.com/avatar.png diff --git a/apps/docs/lib/docs-pages.ts b/apps/docs/lib/docs-pages.ts index 2e841d7..7d25079 100644 --- a/apps/docs/lib/docs-pages.ts +++ b/apps/docs/lib/docs-pages.ts @@ -1293,15 +1293,14 @@ $oidc->authenticate(); // client_id, redirect_uri, response_type=code — авт blocks: [ { type: 'paragraph', - text: 'Системный бот BotFather можно добавить в семейную группу, чтобы создавать и управлять Telegram-ботами прямо из семейного чата (команды /newbot, /mybots, настройка профиля бота и inline-кнопки Mini App).' + text: 'Системный бот BotFather можно пригласить в семейную группу, чтобы создавать и управлять Telegram-ботами прямо из семейного чата (команды /newbot, /mybots, настройка профиля бота и inline-кнопки Mini App). Боты не подтверждают приглашение вручную: система принимает его автоматически.' }, { type: 'list', items: [ - 'POST /family/groups/{groupId}/botfather — добавить BotFather в семью (JWT участника)', - 'POST /family/groups/{groupId}/bots — добавить своего или чужого бота в семью ({ "botId": "..." })', - 'После добавления появится BOT-чат с BotFather / ботом для каждого участника', - 'GET /family/groups/{groupId}/invite-search?q=... — поиск пользователей и ботов для приглашения', + 'GET /family/groups/{groupId}/invite-search?q=... — поиск пользователей, BotFather, GetMyIdBot и ботов других владельцев', + 'POST /family/groups/{groupId}/invites — пригласить найденного пользователя или бота ({ "inviteeUserId": "..." })', + 'Для бота приглашение сразу получает статус ACCEPTED, после чего появляется BOT-чат для участников', 'Команды профиля в чате BotFather: /setdescription, /setabouttext, /setuserpic, /setmenubutton' ] } @@ -1544,7 +1543,7 @@ curl -s -X POST http://localhost:3000/chat/groups/$GROUP_ID/e2e-rooms \\ blocks: [ { type: 'paragraph', - text: 'Системный бот BotFather доступен в семейном чате (POST /family/groups/{groupId}/botfather). Помимо /newbot и /mybots поддерживается настройка профиля каждого вашего бота прямо из переписки. Username указывайте без суффикса _bot.' + text: 'Системный бот BotFather доступен в семейном чате после приглашения через поиск семьи. Помимо /newbot и /mybots поддерживается настройка профиля каждого вашего бота прямо из переписки. Username указывайте без суффикса _bot.' }, { type: 'table', diff --git a/apps/frontend/app/mini-apps/bot-manage/content.tsx b/apps/frontend/app/mini-apps/bot-manage/content.tsx index ac4f927..0a85e0b 100644 --- a/apps/frontend/app/mini-apps/bot-manage/content.tsx +++ b/apps/frontend/app/mini-apps/bot-manage/content.tsx @@ -213,6 +213,11 @@ export function BotManageMiniAppContent({ showToast('Токен скопирован'); } + async function copyBotId() { + await navigator.clipboard.writeText(botId); + showToast('bot_id скопирован'); + } + if (!botId) { return
Не указан botId
; } @@ -294,6 +299,29 @@ export function BotManageMiniAppContent({ +
+ +
+ +
+ + +
+
+
+ Чтобы получить chat_id для отправки сообщений этому боту, + добавьте GetMyIdBot в семью и отправьте ему + /id. Он покажет готовый chat_id для каждого вашего бота. +
+
+
+
void; onReply?: () => void; onForward?: () => void; + onTogglePin?: () => void; onSelect?: () => void; onToggleReaction?: (emoji: string) => void; onCopy?: () => void; @@ -39,6 +40,7 @@ export function ChatMessageContextMenu({ onDelete, onReply, onForward, + onTogglePin, onSelect, onToggleReaction, onCopy @@ -125,6 +127,17 @@ export function ChatMessageContextMenu({ Переслать ) : null} + {onTogglePin && !disabled ? ( + { + event.preventDefault(); + runMenuAction(onTogglePin); + }} + > + {message.isPinned ? : } + {message.isPinned ? 'Открепить' : 'Закрепить'} + + ) : null} {deletable ? ( void; + onCopy?: () => void; + onForward?: () => void; + onDelete?: () => void; + onTogglePin?: () => void; + onCancel?: () => void; +} + +export function ChatSelectionContextMenu({ + count, + canPin, + pinned, + children, + onReply, + onCopy, + onForward, + onDelete, + onTogglePin, + onCancel +}: ChatSelectionContextMenuProps) { + function runMenuAction(action?: () => void) { + suppressChatDragSelection(); + action?.(); + } + + return ( + { + if (open) suppressChatDragSelection(800); + }} + > + {children} + + { + event.preventDefault(); + runMenuAction(onReply); + }} + > + + Ответить + + {canPin && onTogglePin ? ( + { + event.preventDefault(); + runMenuAction(onTogglePin); + }} + > + {pinned ? : } + {pinned ? 'Открепить' : 'Закрепить'} + + ) : null} + { + event.preventDefault(); + runMenuAction(onCopy); + }} + > + + Копировать выбранное как текст + + { + event.preventDefault(); + runMenuAction(onForward); + }} + > + + Переслать выбранное + + { + event.preventDefault(); + runMenuAction(onDelete); + }} + > + + Удалить выбранное + + + { + event.preventDefault(); + runMenuAction(onCancel); + }} + > + + Отменить выбор + + Выбрано сообщений: {count} + + + ); +} + interface SelectableMessageWrapperProps { selected?: boolean; selectionMode?: boolean; diff --git a/apps/frontend/components/family/family-bot-chat.tsx b/apps/frontend/components/family/family-bot-chat.tsx index b844e35..827e1e3 100644 --- a/apps/frontend/components/family/family-bot-chat.tsx +++ b/apps/frontend/components/family/family-bot-chat.tsx @@ -1,7 +1,7 @@ 'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { LayoutGrid, Loader2, Send, X } from 'lucide-react'; +import { LayoutGrid, Loader2, Pin, Send, X } from 'lucide-react'; import { BotMessageKeyboard } from '@/components/chat/bot-message-keyboard'; import { BotChatMessageContextMenu } from '@/components/chat/bot-chat-message-context-menu'; import { useRealtime } from '@/components/notifications/realtime-provider'; @@ -135,7 +135,9 @@ export function FamilyBotChat({ messageType: typeof payload.messageType === 'string' ? payload.messageType : 'text', messageId, createdAt: new Date().toISOString(), - replyMarkupJson + replyMarkupJson, + isPinned: Boolean(payload.isPinned), + pinnedAt: typeof payload.pinnedAt === 'string' ? payload.pinnedAt : null }; setMessages((current) => { @@ -183,6 +185,27 @@ export function FamilyBotChat({ : item ) ); + return; + } + + if (event.type === 'bot_message_pinned') { + const messageId = Number(payload.messageId); + if (!Number.isFinite(messageId)) { + void loadMessages(); + return; + } + + setMessages((current) => + current.map((item) => + item.messageId === messageId && item.direction === 'out' + ? { + ...item, + isPinned: Boolean(payload.isPinned), + pinnedAt: typeof payload.pinnedAt === 'string' ? payload.pinnedAt : null + } + : item + ) + ); } }); }, [botRef, loadMessages, subscribe]); @@ -227,6 +250,12 @@ export function FamilyBotChat({
showToast('Текст скопирован')}>
+ {message.isPinned ? ( +
+ + Закреплено +
+ ) : null} {!mine ?

{botName}

: null}

{message.text || '…'}

{!mine && hasKeyboard && message.messageId > 0 ? ( diff --git a/apps/frontend/components/family/family-group-view.tsx b/apps/frontend/components/family/family-group-view.tsx index 8e54d1c..0bf73c8 100644 --- a/apps/frontend/components/family/family-group-view.tsx +++ b/apps/frontend/components/family/family-group-view.tsx @@ -14,6 +14,7 @@ import { Loader2, Mic, Paperclip, + Pin, Plus, Search, Send, @@ -37,7 +38,7 @@ import { ChatDateSeparator } from '@/components/chat/chat-date-separator'; import { ChatToolsDialog, type ChatToolsTab } from '@/components/chat/chat-tools-dialog'; import { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog'; import { ChatMessageEditBanner } from '@/components/chat/chat-message-edit-banner'; -import { ChatMessageContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu'; +import { ChatMessageContextMenu, ChatSelectionContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu'; import { ChatMessageReactions } from '@/components/chat/chat-message-reactions'; import { ChatSelectionToolbar } from '@/components/chat/chat-selection-toolbar'; import { EmojiPicker } from '@/components/chat/emoji-picker'; @@ -78,6 +79,7 @@ import { removeFamilyMember, reportChatTyping, sendChatMessage, + setChatMessagePinned, setChatRoomMuted, toggleChatMessageReaction, updateFamilyGroup, @@ -922,6 +924,39 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { } } + async function handleTogglePin(message: ChatMessage) { + if (!token) return; + try { + const updated = await setChatMessagePinned(message.id, !message.isPinned, token); + setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item))); + showToast(updated.isPinned ? 'Сообщение закреплено' : 'Сообщение откреплено'); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось изменить закрепление') ?? 'Ошибка'); + } + } + + function handleSelectionReply() { + const selected = getSelectedMessages(); + if (selected.length !== 1) { + showToast('Ответить можно только на одно сообщение'); + return; + } + exitSelectionMode(); + setReplyToMessage(selected[0]!); + setEditingMessageId(null); + setDraft(''); + setEmojiOpen(false); + } + + function handleSelectionPin() { + const selected = getSelectedMessages(); + if (selected.length !== 1) { + showToast('Закрепить можно только одно сообщение'); + return; + } + void handleTogglePin(selected[0]!); + } + function getSelectedMessages() { const selected = new Set(selectedMessageIds); return visibleMessages.filter((message) => selected.has(message.id)); @@ -1483,6 +1518,12 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { ) )} > + {message.isPinned && !isLargeEmoji ? ( +
+ + Закреплено +
+ ) : null} {!mine ?

{message.senderName}

: null} {forwardedFrom ? (

↪️ Переслано от {forwardedFrom.senderName}

@@ -1648,14 +1689,26 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { /> ) : null} {selectionMode ? ( - toggleSelectedMessage(message.id)} + void handleBulkCopy()} + onForward={() => setForwardDialogOpen(true)} + onDelete={() => void handleBulkDelete()} + onTogglePin={handleSelectionPin} + onCancel={exitSelectionMode} > - {bubble} - + toggleSelectedMessage(message.id)} + > + {bubble} + + ) : ( void handleTogglePin(message)} onSelect={() => enterSelectionMode(message.id)} onToggleReaction={(emoji) => void handleToggleReaction(message.id, emoji)} onCopy={() => showToast('Текст скопирован')} diff --git a/apps/frontend/components/family/family-invite-dialog.tsx b/apps/frontend/components/family/family-invite-dialog.tsx index 6495f33..938618f 100644 --- a/apps/frontend/components/family/family-invite-dialog.tsx +++ b/apps/frontend/components/family/family-invite-dialog.tsx @@ -12,8 +12,6 @@ import { FamilyGroup, FamilyInviteCandidate, ManagedBot, - addBotFatherToFamily, - addBotToFamily, fetchMyBots, getApiErrorMessage, searchFamilyInviteUsers, @@ -42,7 +40,6 @@ export function FamilyInviteDialog({ const [submitting, setSubmitting] = useState(false); const [myBots, setMyBots] = useState([]); const [myBotsLoading, setMyBotsLoading] = useState(false); - const [addingBotId, setAddingBotId] = useState(null); const familyBotUsernames = useMemo( () => new Set((group?.members ?? []).map((member) => member.botUsername).filter(Boolean) as string[]), @@ -89,35 +86,14 @@ export function FamilyInviteDialog({ return () => window.clearTimeout(timer); }, [groupId, inviteQuery, open, token]); - async function addBotById(botId: string, displayName: string) { - if (!token) return; - setAddingBotId(botId); - try { - await addBotToFamily(groupId, botId, token); - showToast(`${displayName} добавлен в семью`); - onOpenChange(false); - onInvited?.(); - } catch (error) { - showToast(getApiErrorMessage(error, 'Не удалось добавить бота') ?? 'Ошибка'); - } finally { - setAddingBotId(null); - } - } - async function submitInvite() { if (!token || !selectedInviteUser) return; setSubmitting(true); try { + await sendFamilyInvite(groupId, { inviteeUserId: selectedInviteUser.id }, token); if (selectedInviteUser.isBot) { - if (selectedInviteUser.botId) { - await addBotToFamily(groupId, selectedInviteUser.botId, token); - showToast(`${selectedInviteUser.displayName} добавлен в семью`); - } else { - await addBotFatherToFamily(groupId, token); - showToast('BotFather добавлен в семью'); - } + showToast(`${selectedInviteUser.displayName} добавлен в семью`); } else { - await sendFamilyInvite(groupId, { inviteeUserId: selectedInviteUser.id }, token); showToast('Приглашение отправлено'); } onOpenChange(false); @@ -145,13 +121,15 @@ export function FamilyInviteDialog({ if (candidate.botUsername === 'BotFather_bot') { return `${handle} — создавайте ботов через чат`; } + if (candidate.botUsername === 'GetMyIdBot_bot') { + return `${handle} — получите user_id и chat_id`; + } return handle; } function submitButtonLabel() { if (!selectedInviteUser?.isBot) return 'Отправить приглашение'; - if (selectedInviteUser.botId) return 'Добавить бота'; - return 'Добавить BotFather'; + return 'Пригласить бота'; } return ( @@ -192,16 +170,15 @@ export function FamilyInviteDialog({ )) @@ -211,7 +188,7 @@ export function FamilyInviteDialog({

)}
-

Или найдите пользователя / бота другого владельца через поиск выше

+

Выберите своего бота, затем подтвердите приглашение в результатах поиска

) : null}
diff --git a/apps/frontend/components/family/family-sidebar-panel.tsx b/apps/frontend/components/family/family-sidebar-panel.tsx index 61d3eb1..6ee11d5 100644 --- a/apps/frontend/components/family/family-sidebar-panel.tsx +++ b/apps/frontend/components/family/family-sidebar-panel.tsx @@ -2,16 +2,14 @@ import Link from 'next/link'; import { useState } from 'react'; -import { Loader2, Plus, Search, UsersRound } from 'lucide-react'; +import { Loader2, Search, UsersRound } from 'lucide-react'; import { AvatarWithPresence } from '@/components/family/avatar-with-presence'; import { FamilyGroupSelector } from '@/components/family/family-group-selector'; import { FamilyInviteDialog } from '@/components/family/family-invite-dialog'; import { useFamilyOverlay } from '@/components/family/family-overlay-provider'; import { useAuth } from '@/components/id/auth-provider'; import { useSelectedFamily } from '@/hooks/use-primary-family'; -import { addBotFatherToFamily, getApiErrorMessage } from '@/lib/api'; import { Button } from '@/components/ui/button'; -import { useToast } from '@/components/id/toast-provider'; import { cn } from '@/lib/utils'; export function FamilySidebarPanel() { @@ -27,9 +25,7 @@ export function FamilySidebarPanel() { refresh } = useSelectedFamily(Boolean(user && token)); const { openChatWithMember } = useFamilyOverlay(); - const { showToast } = useToast(); const [inviteOpen, setInviteOpen] = useState(false); - const [addingBotFather, setAddingBotFather] = useState(false); if (!user || !token) return null; @@ -118,32 +114,6 @@ export function FamilySidebarPanel() { ); })} - {group?.botFatherAvailable ? ( - - ) : null}
) : (
diff --git a/apps/frontend/components/family/mini-family-chat.tsx b/apps/frontend/components/family/mini-family-chat.tsx index f1ec8a4..3b300be 100644 --- a/apps/frontend/components/family/mini-family-chat.tsx +++ b/apps/frontend/components/family/mini-family-chat.tsx @@ -2,13 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; -import { ArrowLeft, BarChart3, CalendarDays, ImageIcon, Loader2, MessageCircle, Paperclip, Search, Send, Settings, Smile, Trash2, X } from 'lucide-react'; +import { ArrowLeft, BarChart3, CalendarDays, ImageIcon, Loader2, MessageCircle, Paperclip, Pin, Search, Send, Settings, Smile, Trash2, X } from 'lucide-react'; import { AvatarWithPresence } from '@/components/family/avatar-with-presence'; import { FamilyBotChat, BotMiniAppSheet } from '@/components/family/family-bot-chat'; import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display'; import { ChatDateSeparator } from '@/components/chat/chat-date-separator'; import { ChatEmojiContent } from '@/components/chat/chat-emoji-content'; -import { ChatMessageContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu'; +import { ChatMessageContextMenu, ChatSelectionContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu'; import { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog'; import { ChatMessageEditBanner } from '@/components/chat/chat-message-edit-banner'; import { ChatMessageReactions } from '@/components/chat/chat-message-reactions'; @@ -44,6 +44,7 @@ import { markChatRoomRead, sendBotMessage, sendChatMessage, + setChatMessagePinned, toggleChatMessageReaction, uploadMediaObject, voteChatPoll @@ -516,6 +517,35 @@ export function MiniFamilyChat() { requestDeleteMessages(selected.map((message) => message.id)); } + async function handleTogglePin(message: ChatMessage) { + if (!token) return; + try { + const updated = await setChatMessagePinned(message.id, !message.isPinned, token); + setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item))); + showToast(updated.isPinned ? 'Сообщение закреплено' : 'Сообщение откреплено'); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось изменить закрепление') ?? 'Ошибка'); + } + } + + function handleSelectionReply() { + const selected = getSelectedMessages(); + if (selected.length !== 1) { + showToast('Ответить можно только на одно сообщение'); + return; + } + showToast('Ответ доступен в полном виде семьи'); + } + + function handleSelectionPin() { + const selected = getSelectedMessages(); + if (selected.length !== 1) { + showToast('Закрепить можно только одно сообщение'); + return; + } + void handleTogglePin(selected[0]!); + } + async function uploadChatFile(file: File) { if (!token || !activeRoom || sending) return; setSending(true); @@ -948,6 +978,12 @@ export function MiniFamilyChat() { ) )} > + {message.isPinned && !isLargeEmoji ? ( +
+ + Закреплено +
+ ) : null} {!mine && !isLargeEmoji ? (

{message.senderName}

) : null} @@ -1045,14 +1081,26 @@ export function MiniFamilyChat() { /> ) : null} {selectionMode ? ( - toggleSelectedMessage(message.id)} + void handleBulkCopy()} + onForward={() => showToast('Пересылка доступна в полном виде семьи')} + onDelete={() => void handleBulkDelete()} + onTogglePin={handleSelectionPin} + onCancel={exitSelectionMode} > - {bubble} - + toggleSelectedMessage(message.id)} + > + {bubble} + + ) : ( requestDeleteMessages([message.id])} onForward={() => showToast('Пересылка доступна в полном виде семьи')} + onTogglePin={() => void handleTogglePin(message)} onSelect={() => enterSelectionMode(message.id)} onToggleReaction={async (emoji) => { if (!token) return; diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index e91bc46..493e0b2 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -741,6 +741,7 @@ export interface FamilyGroup { hasAvatar: boolean; members?: FamilyMember[]; botFatherAvailable?: boolean; + getMyIdBotAvailable?: boolean; } export interface BotChatMessage { @@ -751,6 +752,8 @@ export interface BotChatMessage { messageId: number; createdAt: string; replyMarkupJson?: string | null; + isPinned?: boolean; + pinnedAt?: string | null; } export interface BotChatMeta { @@ -817,6 +820,9 @@ export interface ChatMessage { createdAt: string; editedAt?: string; isDeleted?: boolean; + isPinned?: boolean; + pinnedAt?: string; + pinnedById?: string; readByAll?: boolean; poll?: { id: string; @@ -887,14 +893,6 @@ export async function sendFamilyInvite(groupId: string, payload: { inviteeUserId return apiFetch(`/family/groups/${groupId}/invites`, { method: 'POST', body: JSON.stringify(payload) }, token); } -export async function addBotFatherToFamily(groupId: string, token?: string | null) { - return apiFetch(`/family/groups/${groupId}/botfather`, { method: 'POST', body: '{}' }, token); -} - -export async function addBotToFamily(groupId: string, botId: string, token?: string | null) { - return apiFetch(`/family/groups/${groupId}/bots`, { method: 'POST', body: JSON.stringify({ botId }) }, token); -} - export async function fetchBotChatMessages(botRef: string, token?: string | null) { return apiFetch<{ messages?: BotChatMessage[]; @@ -1137,6 +1135,13 @@ export async function toggleChatMessageReaction(messageId: string, emoji: string }, token); } +export async function setChatMessagePinned(messageId: string, pinned: boolean, token?: string | null) { + return apiFetch(`/chat/messages/${messageId}/pin`, { + method: 'POST', + body: JSON.stringify({ pinned }) + }, token); +} + export async function forwardChatMessages(targetRoomId: string, messageIds: string[], token?: string | null) { return apiFetch<{ messages?: ChatMessage[] }>(`/chat/rooms/${targetRoomId}/forward`, { method: 'POST', diff --git a/apps/sso-core/prisma/schema.prisma b/apps/sso-core/prisma/schema.prisma index 98d77b3..f85bf85 100644 --- a/apps/sso-core/prisma/schema.prisma +++ b/apps/sso-core/prisma/schema.prisma @@ -438,6 +438,9 @@ model ChatMessage { metadata Json? storageKey String? mimeType String? + isPinned Boolean @default(false) + pinnedAt DateTime? + pinnedById String? createdAt DateTime @default(now()) editedAt DateTime? deletedAt DateTime? @@ -446,6 +449,7 @@ model ChatMessage { poll ChatPoll? @@index([roomId, createdAt]) + @@index([roomId, isPinned, pinnedAt]) } model ChatPoll { @@ -603,6 +607,8 @@ model BotMessage { text String? mediaUrl String? payload Json? + isPinned Boolean @default(false) + pinnedAt DateTime? createdAt DateTime @default(now()) editedAt DateTime? bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade) @@ -610,6 +616,7 @@ model BotMessage { @@unique([botId, telegramMsgId]) @@index([botChatId]) + @@index([botChatId, isPinned, pinnedAt]) } model BotInboundMessage { diff --git a/apps/sso-core/src/domain/auth-grpc.controller.ts b/apps/sso-core/src/domain/auth-grpc.controller.ts index 125a523..de423df 100644 --- a/apps/sso-core/src/domain/auth-grpc.controller.ts +++ b/apps/sso-core/src/domain/auth-grpc.controller.ts @@ -908,11 +908,15 @@ export class AuthGrpcController { } @GrpcMethod('FamilyService', 'SendFamilyInvite') - sendFamilyInvite(command: { requesterId: string; groupId: string; target?: string; inviteeUserId?: string }) { - return this.family.sendInvite(command.requesterId, command.groupId, { + async sendFamilyInvite(command: { requesterId: string; groupId: string; target?: string; inviteeUserId?: string }) { + const invite = await this.family.sendInvite(command.requesterId, command.groupId, { target: command.target, inviteeUserId: command.inviteeUserId }); + if (invite.status === 'ACCEPTED') { + await this.chat.syncFamilyChats(command.groupId); + } + return invite; } @GrpcMethod('FamilyService', 'SearchFamilyInviteUsers') @@ -935,20 +939,6 @@ export class AuthGrpcController { return this.presence.getFamilyPresence(command.requesterId, command.groupId); } - @GrpcMethod('FamilyService', 'AddBotFatherToFamily') - async addBotFatherToFamily(command: { requesterId: string; groupId: string }) { - const member = await this.family.addBotFatherToFamily(command.requesterId, command.groupId); - await this.chat.syncFamilyChats(command.groupId); - return member; - } - - @GrpcMethod('FamilyService', 'AddBotToFamily') - async addBotToFamily(command: { requesterId: string; groupId: string; botId: string }) { - const member = await this.family.addBotToFamily(command.requesterId, command.groupId, command.botId); - await this.chat.syncFamilyChats(command.groupId); - return member; - } - @GrpcMethod('NotificationsService', 'ListNotifications') listNotifications(command: { userId: string; limit?: number; unreadOnly?: boolean }) { return this.notifications.list(command.userId, command.limit, command.unreadOnly); @@ -1066,6 +1056,11 @@ export class AuthGrpcController { return this.chat.deleteMessage(command.userId, command.messageId); } + @GrpcMethod('ChatService', 'SetMessagePinned') + setChatMessagePinned(command: { userId: string; messageId: string; pinned: boolean }) { + return this.chat.setMessagePinned(command.userId, command.messageId, Boolean(command.pinned)); + } + @GrpcMethod('ChatService', 'ToggleMessageReaction') toggleMessageReaction(command: { userId: string; messageId: string; emoji: string }) { return this.chat.toggleMessageReaction(command.userId, command.messageId, command.emoji); diff --git a/apps/sso-core/src/domain/bot/bot-api.service.ts b/apps/sso-core/src/domain/bot/bot-api.service.ts index 10c2e98..398aacd 100644 --- a/apps/sso-core/src/domain/bot/bot-api.service.ts +++ b/apps/sso-core/src/domain/bot/bot-api.service.ts @@ -53,6 +53,10 @@ type EditMessageReplyMarkupPayload = ChatPayload & { reply_markup?: unknown; }; +type PinChatMessagePayload = ChatPayload & { + disable_notification?: boolean; +}; + type AnswerCallbackQueryPayload = { callback_query_id?: string; text?: string; @@ -125,12 +129,18 @@ export class BotApiService { switch (normalizedMethod) { case 'getMe': return this.wrapOk(this.buildUserObject(bot)); + case 'getChat': + return this.getChat(bot, payload as ChatPayload); case 'sendMessage': return this.sendMessage(bot, payload as SendMessagePayload); case 'editMessageText': return this.editMessageText(bot, payload as EditMessageTextPayload); case 'editMessageReplyMarkup': return this.editMessageReplyMarkup(bot, payload as EditMessageReplyMarkupPayload); + case 'pinChatMessage': + return this.setMessagePinned(bot, payload as PinChatMessagePayload, true); + case 'unpinChatMessage': + return this.setMessagePinned(bot, payload as ChatPayload, false); case 'answerCallbackQuery': return this.answerCallbackQuery(payload as AnswerCallbackQueryPayload); case 'sendPhoto': @@ -277,7 +287,9 @@ export class BotApiService { messageType: 'text', messageId: item.telegramMsgId, createdAt: item.createdAt.toISOString(), - replyMarkupJson: null as string | null + replyMarkupJson: null as string | null, + isPinned: false, + pinnedAt: null as string | null })), ...outbound.map((item) => { const payload = this.serializer.readPayload(item.payload); @@ -289,7 +301,9 @@ export class BotApiService { messageType: item.messageType ?? 'text', messageId: item.telegramMsgId, createdAt: item.createdAt.toISOString(), - replyMarkupJson: replyMarkup ? JSON.stringify(replyMarkup) : null + replyMarkupJson: replyMarkup ? JSON.stringify(replyMarkup) : null, + isPinned: item.isPinned, + pinnedAt: item.pinnedAt?.toISOString() ?? null }; }) ].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); @@ -564,6 +578,82 @@ export class BotApiService { return this.wrapOk(this.buildTelegramMessage(bot, located, updated)); } + private async getChat(bot: ValidatedBot, payload: ChatPayload) { + if (!payload.chat_id) { + return this.wrapBadRequest('Bad Request: chat_id is required'); + } + + const context = await this.resolveChatContext(bot.id, String(payload.chat_id)); + if (!context) { + return this.wrapBadRequest('Bad Request: chat not found'); + } + + const pinned = await this.prisma.botMessage.findFirst({ + where: { + botId: bot.id, + botChatId: context.chat.id, + isPinned: true + }, + orderBy: { pinnedAt: 'desc' } + }); + + return this.wrapOk({ + id: Number(context.chat.telegramChatId), + type: context.chat.chatType, + first_name: context.recipient.displayName, + ...(context.recipient.username ? { username: context.recipient.username } : {}), + ...(pinned ? { pinned_message: this.buildTelegramMessage(bot, context, pinned) } : {}) + }); + } + + private async setMessagePinned(bot: ValidatedBot, payload: ChatPayload, pinned: boolean) { + if (!payload.chat_id) { + return this.wrapBadRequest('Bad Request: chat_id is required'); + } + if (pinned && payload.message_id === undefined) { + return this.wrapBadRequest('Bad Request: message_id is required'); + } + + const messageId = payload.message_id === undefined ? undefined : Number(payload.message_id); + if (messageId !== undefined && !Number.isFinite(messageId)) { + return this.wrapBadRequest('Bad Request: message_id is invalid'); + } + + const context = await this.resolveChatContext(bot.id, String(payload.chat_id)); + if (!context) { + return this.wrapBadRequest('Bad Request: chat not found'); + } + + const message = messageId === undefined + ? await this.prisma.botMessage.findFirst({ + where: { + botId: bot.id, + botChatId: context.chat.id, + isPinned: true + }, + orderBy: { pinnedAt: 'desc' } + }) + : await this.prisma.botMessage.findFirst({ + where: { + botId: bot.id, + botChatId: context.chat.id, + telegramMsgId: messageId + } + }); + + if (!message) { + return this.wrapBadRequest(pinned ? 'Bad Request: message to pin not found' : 'Bad Request: message to unpin not found'); + } + + const updated = await this.prisma.botMessage.update({ + where: { id: message.id }, + data: pinned ? { isPinned: true, pinnedAt: new Date() } : { isPinned: false, pinnedAt: null } + }); + + await this.publishBotMessagePinned(context.recipient.id, bot, context.chat, updated); + return this.wrapOk(true); + } + private async answerCallbackQuery(payload: AnswerCallbackQueryPayload) { const callbackQueryId = payload.callback_query_id?.trim(); if (!callbackQueryId) { @@ -743,6 +833,8 @@ export class BotApiService { createdAt: Date; editedAt?: Date | null; payload?: unknown; + isPinned?: boolean; + pinnedAt?: Date | null; } ) { return this.serializer.buildBotMessageObject({ @@ -786,6 +878,26 @@ export class BotApiService { }); } + private async publishBotMessagePinned( + userId: string, + bot: ValidatedBot, + chat: { telegramChatId: bigint; chatType: string }, + message: { + telegramMsgId: number; + isPinned?: boolean; + pinnedAt?: Date | null; + } + ) { + await this.notifications.publishRealtime(userId, 'bot_message_pinned', bot.name, '', { + botId: bot.id, + botUsername: bot.username, + chatId: chat.telegramChatId.toString(), + messageId: message.telegramMsgId, + isPinned: Boolean(message.isPinned), + pinnedAt: message.pinnedAt?.toISOString() ?? null + }); + } + private async publishBotMessageEdited( userId: string, bot: ValidatedBot, diff --git a/apps/sso-core/src/domain/bot/bot-father-assistant.service.ts b/apps/sso-core/src/domain/bot/bot-father-assistant.service.ts index 45c3abe..7b0c59a 100644 --- a/apps/sso-core/src/domain/bot/bot-father-assistant.service.ts +++ b/apps/sso-core/src/domain/bot/bot-father-assistant.service.ts @@ -1,8 +1,10 @@ import { Injectable, Logger } from '@nestjs/common'; import { BotApiService } from './bot-api.service'; -import { BOTFATHER_BOT_USERNAME, buildBotCreateWebAppUrl, buildBotManageWebAppUrl } from './bot-father.constants'; +import { PrismaService } from '../../infra/prisma.service'; +import { BOTFATHER_BOT_USERNAME, GET_MY_ID_DISPLAY_NAME, buildBotCreateWebAppUrl, buildBotManageWebAppUrl } from './bot-father.constants'; import { BotFatherSeedService } from './bot-father-seed.service'; import { BotFatherService } from './bot-father.service'; +import { deriveTelegramChatId } from './bot-token.util'; @Injectable() export class BotFatherAssistantService { @@ -11,10 +13,16 @@ export class BotFatherAssistantService { constructor( private readonly seed: BotFatherSeedService, private readonly botFather: BotFatherService, - private readonly botApi: BotApiService + private readonly botApi: BotApiService, + private readonly prisma: PrismaService ) {} - async handleUserMessage(senderUserId: string, text: string) { + async handleUserMessage(systemBotId: string, senderUserId: string, text: string) { + if (systemBotId === await this.seed.getGetMyIdBotId()) { + await this.handleGetMyIdMessage(senderUserId, text, systemBotId); + return; + } + const botId = await this.seed.getBotFatherBotId(); const trimmed = text.trim(); const lower = trimmed.toLowerCase(); @@ -52,7 +60,12 @@ export class BotFatherAssistantService { ); } - async handleCallbackQuery(senderUserId: string, callbackData: string) { + async handleCallbackQuery(systemBotId: string, senderUserId: string, callbackData: string) { + if (systemBotId === await this.seed.getGetMyIdBotId()) { + await this.handleGetMyIdMessage(senderUserId, '/id', systemBotId); + return; + } + const botId = await this.seed.getBotFatherBotId(); if (callbackData === 'newbot_help' || callbackData === 'newbot') { await this.botApi.sendInternalBotMessage( @@ -84,6 +97,72 @@ export class BotFatherAssistantService { ); } + private async handleGetMyIdMessage(senderUserId: string, text: string, botId: string) { + const lower = text.trim().toLowerCase(); + if (lower === '/start' || lower === '/help') { + await this.botApi.sendInternalBotMessage( + botId, + senderUserId, + [ + `Привет! Я ${GET_MY_ID_DISPLAY_NAME}.`, + '', + 'Отправьте /id, чтобы получить:', + '• UUID пользователя', + '• username', + '• chat_id для этого бота', + '• chat_id для ваших ботов, которые можно вставлять в Bot API-скрипты' + ].join('\n'), + { inline_keyboard: [[{ text: 'Показать мои ID', callback_data: 'get_my_id' }]] } + ); + return; + } + + await this.sendMyIdReport(senderUserId, botId); + } + + private async sendMyIdReport(senderUserId: string, botId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: senderUserId }, + select: { id: true, displayName: true, username: true, email: true, phone: true } + }); + if (!user) { + await this.botApi.sendInternalBotMessage(botId, senderUserId, 'Пользователь не найден'); + return; + } + + const ownBots = await this.prisma.bot.findMany({ + where: { ownerId: senderUserId, isActive: true }, + orderBy: { name: 'asc' }, + select: { id: true, name: true, username: true } + }); + + const currentChatId = deriveTelegramChatId(`${botId}:${senderUserId}`).toString(); + const botLines = ownBots.length + ? ownBots.map((bot) => `• ${bot.name} (@${bot.username})\n bot_id: ${bot.id}\n chat_id: ${deriveTelegramChatId(`${bot.id}:${senderUserId}`).toString()}`) + : ['У вас пока нет созданных ботов.']; + + await this.botApi.sendInternalBotMessage( + botId, + senderUserId, + [ + 'Ваши ID для интеграций:', + '', + `user_id: ${user.id}`, + `name: ${user.displayName}`, + `username: ${user.username ? `@${user.username}` : 'не указан'}`, + `email: ${user.email ?? 'не указан'}`, + `phone: ${user.phone ?? 'не указан'}`, + '', + `chat_id с ${GET_MY_ID_DISPLAY_NAME}: ${currentChatId}`, + '', + 'chat_id для ваших ботов:', + ...botLines, + '', + 'Используйте нужный chat_id в методах sendMessage, editMessageText, pinChatMessage и getChat.' + ].join('\n') + ); + } + private parseProfileCommand(text: string): | { kind: 'setdescription' | 'setabouttext' | 'setuserpic' | 'setmenubutton'; botUsername: string; value: string } | null { diff --git a/apps/sso-core/src/domain/bot/bot-father-seed.service.ts b/apps/sso-core/src/domain/bot/bot-father-seed.service.ts index 5ab0533..516522b 100644 --- a/apps/sso-core/src/domain/bot/bot-father-seed.service.ts +++ b/apps/sso-core/src/domain/bot/bot-father-seed.service.ts @@ -7,6 +7,11 @@ import { BOTFATHER_SETTING_BOT_ID, BOTFATHER_SETTING_USER_ID, BOTFATHER_USER_USERNAME, + GET_MY_ID_BOT_USERNAME, + GET_MY_ID_DISPLAY_NAME, + GET_MY_ID_SETTING_BOT_ID, + GET_MY_ID_SETTING_USER_ID, + GET_MY_ID_USER_USERNAME, SYSTEM_BOT_OWNER_EMAIL } from './bot-father.constants'; import { generateTelegramStyleBotToken, hashBotToken } from './bot-token.util'; @@ -16,6 +21,8 @@ export class BotFatherSeedService implements OnModuleInit { private readonly logger = new Logger(BotFatherSeedService.name); private cachedUserId: string | null = null; private cachedBotId: string | null = null; + private cachedGetMyIdUserId: string | null = null; + private cachedGetMyIdBotId: string | null = null; constructor( private readonly prisma: PrismaService, @@ -24,6 +31,7 @@ export class BotFatherSeedService implements OnModuleInit { async onModuleInit() { await this.ensureBotFather(); + await this.ensureGetMyIdBot(); } async getBotFatherUserId() { @@ -48,6 +56,28 @@ export class BotFatherSeedService implements OnModuleInit { return ensured.botId; } + async getGetMyIdBotUserId() { + if (this.cachedGetMyIdUserId) return this.cachedGetMyIdUserId; + const setting = await this.prisma.systemSetting.findUnique({ where: { key: GET_MY_ID_SETTING_USER_ID } }); + if (setting?.value) { + this.cachedGetMyIdUserId = setting.value; + return setting.value; + } + const ensured = await this.ensureGetMyIdBot(); + return ensured.userId; + } + + async getGetMyIdBotId() { + if (this.cachedGetMyIdBotId) return this.cachedGetMyIdBotId; + const setting = await this.prisma.systemSetting.findUnique({ where: { key: GET_MY_ID_SETTING_BOT_ID } }); + if (setting?.value) { + this.cachedGetMyIdBotId = setting.value; + return setting.value; + } + const ensured = await this.ensureGetMyIdBot(); + return ensured.botId; + } + async ensureBotFather() { const existingBotId = await this.prisma.systemSetting.findUnique({ where: { key: BOTFATHER_SETTING_BOT_ID } }); const existingUserId = await this.prisma.systemSetting.findUnique({ where: { key: BOTFATHER_SETTING_USER_ID } }); @@ -112,6 +142,72 @@ export class BotFatherSeedService implements OnModuleInit { return { userId: botFatherUser.id, botId: bot.id }; } + async ensureGetMyIdBot() { + const existingBotId = await this.prisma.systemSetting.findUnique({ where: { key: GET_MY_ID_SETTING_BOT_ID } }); + const existingUserId = await this.prisma.systemSetting.findUnique({ where: { key: GET_MY_ID_SETTING_USER_ID } }); + if (existingBotId?.value && existingUserId?.value) { + this.cachedGetMyIdBotId = existingBotId.value; + this.cachedGetMyIdUserId = existingUserId.value; + return { userId: existingUserId.value, botId: existingBotId.value }; + } + + const ownerId = await this.resolveBotOwnerId(); + let systemUser = await this.prisma.user.findFirst({ + where: { username: GET_MY_ID_USER_USERNAME, isSystemAccount: true } + }); + + if (!systemUser) { + systemUser = await this.prisma.user.create({ + data: { + displayName: GET_MY_ID_DISPLAY_NAME, + username: GET_MY_ID_USER_USERNAME, + isSystemAccount: true, + isVerified: true, + verificationIcon: 'bot' + } + }); + this.logger.log(`Создан системный пользователь ${GET_MY_ID_DISPLAY_NAME}`); + } + + let bot = await this.prisma.bot.findUnique({ where: { username: GET_MY_ID_BOT_USERNAME } }); + if (!bot) { + const { token, tokenPrefix } = generateTelegramStyleBotToken(); + bot = await this.prisma.bot.create({ + data: { + name: GET_MY_ID_DISPLAY_NAME, + username: GET_MY_ID_BOT_USERNAME, + tokenHash: hashBotToken(token), + tokenPrefix, + ownerId, + description: 'Показывает ваш user_id и chat_id для Bot API.', + aboutText: 'Получите ID пользователя и chat_id для интеграций.', + isSystemBot: true + } + }); + this.logger.log(`Создан системный бот @${GET_MY_ID_BOT_USERNAME}`); + } else if (!bot.isSystemBot) { + bot = await this.prisma.bot.update({ + where: { id: bot.id }, + data: { isSystemBot: true, isActive: true } + }); + } + + if (systemUser.linkedBotId !== bot.id) { + await this.prisma.user.update({ + where: { id: systemUser.id }, + data: { linkedBotId: bot.id } + }); + } + + await this.upsertSetting(GET_MY_ID_SETTING_USER_ID, systemUser.id, 'UUID системного пользователя GetMyIdBot'); + await this.upsertSetting(GET_MY_ID_SETTING_BOT_ID, bot.id, 'UUID системного бота GetMyIdBot'); + await this.rbac.ensureBotUserRole(systemUser.id); + + this.cachedGetMyIdUserId = systemUser.id; + this.cachedGetMyIdBotId = bot.id; + return { userId: systemUser.id, botId: bot.id }; + } + private async resolveBotOwnerId() { const superAdmin = await this.prisma.user.findFirst({ where: { isSuperAdmin: true, deletedAt: null, status: 'ACTIVE', isSystemAccount: false }, diff --git a/apps/sso-core/src/domain/bot/bot-father.constants.ts b/apps/sso-core/src/domain/bot/bot-father.constants.ts index 7ab9100..3a8fa23 100644 --- a/apps/sso-core/src/domain/bot/bot-father.constants.ts +++ b/apps/sso-core/src/domain/bot/bot-father.constants.ts @@ -3,6 +3,11 @@ export const BOTFATHER_USER_USERNAME = 'BotFather'; export const BOTFATHER_DISPLAY_NAME = 'BotFather'; export const BOTFATHER_SETTING_USER_ID = 'BOTFATHER_USER_ID'; export const BOTFATHER_SETTING_BOT_ID = 'BOTFATHER_BOT_ID'; +export const GET_MY_ID_BOT_USERNAME = 'GetMyIdBot_bot'; +export const GET_MY_ID_USER_USERNAME = 'GetMyIdBot'; +export const GET_MY_ID_DISPLAY_NAME = 'GetMyIdBot'; +export const GET_MY_ID_SETTING_USER_ID = 'GET_MY_ID_BOT_USER_ID'; +export const GET_MY_ID_SETTING_BOT_ID = 'GET_MY_ID_BOT_ID'; export const SYSTEM_BOT_OWNER_EMAIL = 'system-bot-owner@internal.lendry.id'; export function resolvePublicFrontendUrl() { diff --git a/apps/sso-core/src/domain/bot/bot-inbound.service.ts b/apps/sso-core/src/domain/bot/bot-inbound.service.ts index d1424a7..84f88a2 100644 --- a/apps/sso-core/src/domain/bot/bot-inbound.service.ts +++ b/apps/sso-core/src/domain/bot/bot-inbound.service.ts @@ -34,7 +34,7 @@ export class BotInboundService { const event = await this.createMessageEvent(bot.id, chat.id, senderUserId, chat.telegramChatId, trimmedText); await this.dispatchEvent(event); if (bot.isSystemBot) { - void this.assistant.handleUserMessage(senderUserId, trimmedText).catch(() => undefined); + void this.assistant.handleUserMessage(bot.id, senderUserId, trimmedText).catch(() => undefined); } return { @@ -89,7 +89,7 @@ export class BotInboundService { await this.dispatchEvent(event); if (bot.isSystemBot) { - void this.assistant.handleCallbackQuery(senderUserId, trimmedData).catch(() => undefined); + void this.assistant.handleCallbackQuery(bot.id, senderUserId, trimmedData).catch(() => undefined); } return { diff --git a/apps/sso-core/src/domain/chat.service.ts b/apps/sso-core/src/domain/chat.service.ts index 6bce199..2717910 100644 --- a/apps/sso-core/src/domain/chat.service.ts +++ b/apps/sso-core/src/domain/chat.service.ts @@ -607,6 +607,33 @@ export class ChatService { return response; } + async setMessagePinned(userId: string, messageId: string, pinned: boolean) { + const message = await this.prisma.chatMessage.findUnique({ + where: { id: messageId }, + include: { room: { include: { members: true } } } + }); + if (!message || message.deletedAt) { + throw new NotFoundException('Сообщение не найдено'); + } + if (message.room.type === 'BOT') { + throw new BadRequestException('Сообщения бота закрепляются через Bot API'); + } + if (!message.room.members.some((member) => member.userId === userId)) { + throw new ForbiddenException('Вы не состоите в этом чате'); + } + + await this.prisma.chatMessage.update({ + where: { id: messageId }, + data: pinned + ? { isPinned: true, pinnedAt: new Date(), pinnedById: userId } + : { isPinned: false, pinnedAt: null, pinnedById: null } + }); + + const response = await this.loadMessageResponse(messageId, userId); + await this.publishRoomEvent(message.room, 'chat_message_updated', response); + return response; + } + async toggleMessageReaction(userId: string, messageId: string, emoji: string) { const normalizedEmoji = emoji.trim(); if (!normalizedEmoji || normalizedEmoji.length > 16) { @@ -1016,6 +1043,9 @@ export class ChatService { storageKey: string | null; mimeType: string | null; metadata: unknown; + isPinned?: boolean; + pinnedAt?: Date | null; + pinnedById?: string | null; createdAt: Date; editedAt?: Date | null; deletedAt?: Date | null; @@ -1060,6 +1090,9 @@ export class ChatService { isDeleted, readByAll: message.senderId === viewerId ? readByAll : undefined, reactions: isDeleted ? [] : this.buildReactionResponses(message.metadata, viewerId), + isPinned: !isDeleted && Boolean(message.isPinned), + pinnedAt: !isDeleted ? message.pinnedAt?.toISOString() : undefined, + pinnedById: !isDeleted ? message.pinnedById ?? undefined : undefined, poll: !isDeleted && message.poll ? { id: message.poll.id, @@ -1202,6 +1235,9 @@ export class ChatService { isDeleted: false, readByAll: undefined, reactions: [], + isPinned: false, + pinnedAt: undefined, + pinnedById: undefined, poll: undefined }; } diff --git a/apps/sso-core/src/domain/family.service.ts b/apps/sso-core/src/domain/family.service.ts index 6f6ce41..2b146e8 100644 --- a/apps/sso-core/src/domain/family.service.ts +++ b/apps/sso-core/src/domain/family.service.ts @@ -9,7 +9,14 @@ import { NotificationsService } from './notifications.service'; import { MinioService } from '../infra/minio.service'; import { BotFatherSeedService } from './bot/bot-father-seed.service'; import { BotFatherService } from './bot/bot-father.service'; -import { BOTFATHER_BOT_USERNAME, BOTFATHER_DISPLAY_NAME, BOTFATHER_USER_USERNAME } from './bot/bot-father.constants'; +import { + BOTFATHER_BOT_USERNAME, + BOTFATHER_DISPLAY_NAME, + BOTFATHER_USER_USERNAME, + GET_MY_ID_BOT_USERNAME, + GET_MY_ID_DISPLAY_NAME, + GET_MY_ID_USER_USERNAME +} from './bot/bot-father.constants'; import { normalizeBotUsername } from './bot/bot-token.util'; import { HUMAN_USER_WHERE, isProtectedSystemAccount } from './system-account.util'; @@ -386,49 +393,6 @@ export class FamilyService { - async addBotFatherToFamily(requesterId: string, groupId: string) { - - const group = await this.getGroupWithMembers(groupId); - - this.assertMember(group, requesterId); - - const botFatherUserId = await this.botFatherSeed.getBotFatherUserId(); - - if (group.members.some((member) => member.userId === botFatherUserId)) { - - throw new BadRequestException('BotFather уже добавлен в эту семью'); - - } - - return this.addMember(groupId, botFatherUserId, 'bot'); - - } - - async addBotToFamily(requesterId: string, groupId: string, botId: string) { - const group = await this.getGroupWithMembers(groupId); - this.assertMember(group, requesterId); - - const bot = await this.prisma.bot.findUnique({ - where: { id: botId }, - select: { id: true, isActive: true, username: true, name: true } - }); - if (!bot) { - throw new NotFoundException('Бот не найден'); - } - if (!bot.isActive) { - throw new BadRequestException('Бот деактивирован и не может быть добавлен в семью'); - } - - const { userId: botSystemUserId } = await this.botFather.ensureBotSystemUser(bot.id); - if (group.members.some((member) => member.userId === botSystemUserId)) { - throw new BadRequestException('Этот бот уже добавлен в семью'); - } - - return this.addMember(groupId, botSystemUserId, 'bot'); - } - - - async removeMember(requesterId: string, memberId: string) { const member = await this.prisma.familyMember.findUnique({ @@ -483,7 +447,7 @@ export class FamilyService { const invitee = options.inviteeUserId ? await this.prisma.user.findFirst({ - where: { id: options.inviteeUserId, status: 'ACTIVE', deletedAt: null, ...HUMAN_USER_WHERE } + where: { id: options.inviteeUserId, status: 'ACTIVE', deletedAt: null } }) : options.target ? await this.findUserByContact(options.target) @@ -493,10 +457,6 @@ export class FamilyService { throw new BadRequestException('Пользователь не найден. Выберите его из результатов поиска.'); } - if (isProtectedSystemAccount(invitee)) { - throw new BadRequestException('BotFather добавляется через отдельное действие «Добавить BotFather»'); - } - if (invitee.id === requesterId) { throw new BadRequestException('Нельзя пригласить самого себя'); } @@ -513,6 +473,35 @@ export class FamilyService { } const inviter = await this.prisma.user.findUnique({ where: { id: requesterId } }); + const isBotInvite = isProtectedSystemAccount(invitee); + if (isBotInvite) { + const invite = await this.prisma.familyInvite.create({ + data: { + groupId, + inviterId: requesterId, + inviteeUserId: invitee.id, + targetEmail: invitee.email ?? undefined, + targetPhone: invitee.phone ?? undefined, + status: 'ACCEPTED', + expiresAt: new Date(Date.now() + INVITE_TTL_DAYS * 24 * 60 * 60 * 1000) + }, + include: { + group: true, + inviter: true + } + }); + await this.addMember(groupId, invitee.id, 'bot'); + await this.notifications.create( + requesterId, + 'family_bot_added', + 'Бот добавлен в семью', + `${invitee.displayName} добавлен(а) в «${group.name}» по приглашению`, + { groupId, inviteId: invite.id, botUserId: invitee.id }, + { skipPublish: true } + ); + return this.toInvite(invite); + } + const invite = await this.prisma.familyInvite.create({ data: { groupId, @@ -555,10 +544,17 @@ export class FamilyService { .filter((username): username is string => Boolean(username)) ); const botFatherUserId = await this.botFatherSeed.getBotFatherUserId(); + const botFatherBotId = await this.botFatherSeed.getBotFatherBotId(); + const getMyIdUserId = await this.botFatherSeed.getGetMyIdBotUserId(); + const getMyIdBotId = await this.botFatherSeed.getGetMyIdBotId(); const botFatherMatches = /bot\s*father|бот\s*father|botfather/i.test(trimmed) && !excludedIds.includes(botFatherUserId) && !excludedBotUsernames.has(BOTFATHER_BOT_USERNAME); + const getMyIdMatches = + /get\s*my\s*id|getmyid|мой\s*id|айди|chat[_\s-]*id/i.test(trimmed) && + !excludedIds.includes(getMyIdUserId) && + !excludedBotUsernames.has(GET_MY_ID_BOT_USERNAME); const digits = trimmed.replace(/\D/g, ''); const normalizedBotQuery = normalizeBotUsername(trimmed.replace(/^@/, '')); @@ -624,6 +620,8 @@ export class FamilyService { for (const bot of matchedBots) { if (excludedBotUsernames.has(bot.username)) continue; + if (botFatherMatches && bot.username === BOTFATHER_BOT_USERNAME) continue; + if (getMyIdMatches && bot.username === GET_MY_ID_BOT_USERNAME) continue; const { userId } = bot.linkedSystemUser ? { userId: bot.linkedSystemUser.id } : await this.botFather.ensureBotSystemUser(bot.id); @@ -672,7 +670,25 @@ export class FamilyService { verificationIcon: resolveVerificationIcon('bot'), isBot: true, botUsername: BOTFATHER_BOT_USERNAME, - botId: undefined, + botId: botFatherBotId, + ownerDisplayName: undefined + } + ] + : []), + ...(getMyIdMatches + ? [ + { + id: getMyIdUserId, + displayName: GET_MY_ID_DISPLAY_NAME, + email: undefined, + phone: undefined, + username: GET_MY_ID_USER_USERNAME, + hasAvatar: false, + isVerified: true, + verificationIcon: resolveVerificationIcon('bot'), + isBot: true, + botUsername: GET_MY_ID_BOT_USERNAME, + botId: getMyIdBotId, ownerDisplayName: undefined } ] @@ -1004,6 +1020,10 @@ export class FamilyService { botFatherAvailable: !group.members.some( (member) => member.user?.linkedBot?.username === BOTFATHER_BOT_USERNAME + ), + + getMyIdBotAvailable: !group.members.some( + (member) => member.user?.linkedBot?.username === GET_MY_ID_BOT_USERNAME ) }; diff --git a/apps/sso-core/src/domain/system-account.util.ts b/apps/sso-core/src/domain/system-account.util.ts index eaa5290..601f614 100644 --- a/apps/sso-core/src/domain/system-account.util.ts +++ b/apps/sso-core/src/domain/system-account.util.ts @@ -5,7 +5,7 @@ export type SystemAccountProbe = { linkedBotId?: string | null; }; -export const RESERVED_SYSTEM_USERNAMES = new Set(['botfather', 'system-bot-owner']); +export const RESERVED_SYSTEM_USERNAMES = new Set(['botfather', 'getmyidbot', 'system-bot-owner']); export function isProtectedSystemAccount(user: SystemAccountProbe | null | undefined): boolean { if (!user) return false; diff --git a/shared/proto/bot.proto b/shared/proto/bot.proto index 923b679..8a1b275 100644 --- a/shared/proto/bot.proto +++ b/shared/proto/bot.proto @@ -220,6 +220,8 @@ message BotChatMessageItem { int32 messageId = 5; string createdAt = 6; optional string replyMarkupJson = 7; + bool isPinned = 8; + optional string pinnedAt = 9; } message ListBotChatMessagesResponse { diff --git a/shared/proto/chat.proto b/shared/proto/chat.proto index 2ad052c..8ca5690 100644 --- a/shared/proto/chat.proto +++ b/shared/proto/chat.proto @@ -13,6 +13,7 @@ service ChatService { rpc SendMessage (SendMessageRequest) returns (ChatMessageResponse); rpc EditMessage (EditMessageRequest) returns (ChatMessageResponse); rpc DeleteMessage (DeleteMessageRequest) returns (ChatMessageResponse); + rpc SetMessagePinned (SetMessagePinnedRequest) returns (ChatMessageResponse); rpc MarkRoomRead (MarkRoomReadRequest) returns (MutationResponse); rpc VotePoll (VotePollRequest) returns (ChatMessageResponse); rpc SetRoomNotificationsMuted (SetRoomNotificationsMutedRequest) returns (MutationResponse); @@ -90,6 +91,12 @@ message DeleteMessageRequest { string messageId = 2; } +message SetMessagePinnedRequest { + string userId = 1; + string messageId = 2; + bool pinned = 3; +} + message MarkRoomReadRequest { string userId = 1; string roomId = 2; @@ -203,6 +210,9 @@ message ChatMessageResponse { optional bool readByAll = 16; optional bool isEncrypted = 19; repeated MessageReactionResponse reactions = 20; + bool isPinned = 21; + optional string pinnedAt = 22; + optional string pinnedById = 23; } message MessageReactionResponse { diff --git a/shared/proto/identity.proto b/shared/proto/identity.proto index 70e1f27..287f9d4 100644 --- a/shared/proto/identity.proto +++ b/shared/proto/identity.proto @@ -39,8 +39,6 @@ service FamilyService { rpc RespondFamilyInvite (RespondFamilyInviteRequest) returns (FamilyInviteResponse); rpc ListFamilyInvites (ListFamilyInvitesRequest) returns (ListFamilyInvitesResponse); rpc GetFamilyPresence (GetFamilyGroupRequest) returns (FamilyPresenceResponse); - rpc AddBotFatherToFamily (GetFamilyGroupRequest) returns (FamilyMemberResponse); - rpc AddBotToFamily (AddBotToFamilyRequest) returns (FamilyMemberResponse); } message AuthorizeRequest { @@ -269,12 +267,6 @@ message FamilyInviteUserCandidate { optional string ownerDisplayName = 10; } -message AddBotToFamilyRequest { - string requesterId = 1; - string groupId = 2; - string botId = 3; -} - message SearchFamilyInviteUsersResponse { repeated FamilyInviteUserCandidate users = 1; } @@ -345,6 +337,7 @@ message FamilyGroupResponse { string updatedAt = 6; repeated FamilyMemberResponse members = 7; bool botFatherAvailable = 8; + bool getMyIdBotAvailable = 9; } message ListFamilyGroupsResponse {