global fix and update bot Api

This commit is contained in:
lendry
2026-06-26 13:01:52 +03:00
parent d3ea470d02
commit aa228d84eb
29 changed files with 980 additions and 221 deletions

View File

@@ -13,6 +13,7 @@ import {
ForwardMessagesDto, ForwardMessagesDto,
MarkRoomReadDto, MarkRoomReadDto,
SendChatMessageDto, SendChatMessageDto,
SetMessagePinnedDto,
SetRoomNotificationsMutedDto, SetRoomNotificationsMutedDto,
ToggleMessageReactionDto, ToggleMessageReactionDto,
UpdateChatRoomDto, UpdateChatRoomDto,
@@ -198,6 +199,17 @@ export class ChatController {
return firstValueFrom(this.core.chat.DeleteMessage({ userId, messageId })); 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') @Post('messages/:messageId/reactions')
@ApiOperation({ summary: 'Поставить или снять реакцию' }) @ApiOperation({ summary: 'Поставить или снять реакцию' })
async toggleReaction( async toggleReaction(

View File

@@ -5,7 +5,7 @@ import { firstValueFrom } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { CoreGrpcService } from '../core-grpc.service'; import { CoreGrpcService } from '../core-grpc.service';
import { getAuthorizedUserId } from '../document-access'; 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('Семья') @ApiTags('Семья')
@ApiBearerAuth() @ApiBearerAuth()
@@ -152,23 +152,5 @@ export class FamilyController {
return firstValueFrom(this.core.family.GetFamilyPresence({ requesterId, groupId })); 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 }));
}
} }

View File

@@ -1,5 +1,5 @@
import { All, Body, Controller, HttpCode, Param, Req, Res } from '@nestjs/common'; import { All, Body, Controller, Get, HttpCode, Param, Post, Query, Req, Res } from '@nestjs/common';
import { ApiExcludeController } from '@nestjs/swagger'; import { ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import { firstValueFrom, timeout } from 'rxjs'; import { firstValueFrom, timeout } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service'; import { CoreGrpcService } from '../core-grpc.service';
@@ -13,19 +13,205 @@ type HttpResponse = {
setHeader(name: string, value: string): void; setHeader(name: string, value: string): void;
}; };
@ApiExcludeController() const botPathParam = {
name: 'botPath',
description: 'Путь токена в формате bot<token>, совместимо с 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() @Controller()
export class TelegramBotApiController { export class TelegramBotApiController {
constructor(private readonly core: CoreGrpcService) {} 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<string, unknown>
) {
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<string, unknown>
) {
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<string, unknown>
) {
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<string, unknown>
) {
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<string, unknown>
) {
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<string, unknown>
) {
return this.executeTelegramMethod(response, botPath, 'unpinChatMessage', body ?? {});
}
@All(':botPath/:method') @All(':botPath/:method')
@HttpCode(200) @HttpCode(200)
@ApiOperation({
summary: 'Bot API: универсальный метод',
description:
'Совместимый endpoint /bot<TOKEN>/<method>. Поддерживаются 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( async handleTelegramMethod(
@Req() request: HttpRequest, @Req() request: HttpRequest,
@Res({ passthrough: true }) response: HttpResponse, @Res({ passthrough: true }) response: HttpResponse,
@Param('botPath') botPath: string, @Param('botPath') botPath: string,
@Param('method') method: string, @Param('method') method: string,
@Body() body: Record<string, unknown> @Body() body: Record<string, unknown>
) {
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<string, unknown>
) { ) {
if (!botPath.startsWith('bot')) { if (!botPath.startsWith('bot')) {
response.status(404); response.status(404);
@@ -38,7 +224,6 @@ export class TelegramBotApiController {
return { ok: false, error_code: 401, description: 'Unauthorized' }; return { ok: false, error_code: 401, description: 'Unauthorized' };
} }
const payload = request.method === 'GET' ? { ...request.query } : body ?? {};
const grpcCall = this.core.bot.ExecuteBotMethod({ const grpcCall = this.core.bot.ExecuteBotMethod({
token, token,
method, method,

View File

@@ -99,6 +99,11 @@ export class ToggleMessageReactionDto {
emoji!: string; emoji!: string;
} }
export class SetMessagePinnedDto {
@IsBoolean()
pinned!: boolean;
}
export class ForwardMessagesDto { export class ForwardMessagesDto {
@IsArray() @IsArray()
@ArrayNotEmpty() @ArrayNotEmpty()

View File

@@ -154,9 +154,3 @@ export class RespondFamilyInviteDto {
accept!: boolean; accept!: boolean;
} }
export class AddBotToFamilyDto {
@ApiProperty({ description: 'ID бота для добавления в семью' })
@IsString({ message: 'ID бота должен быть строкой' })
@IsNotEmpty({ message: 'Укажите ID бота' })
botId!: string;
}

View File

@@ -170,18 +170,17 @@ export const apiReference: ApiTagGroup[] = [
description: 'Создатель может удалить участника; участник может удалить себя («Выйти»). Владельца семьи удалить нельзя.', description: 'Создатель может удалить участника; участник может удалить себя («Выйти»). Владельца семьи удалить нельзя.',
auth: true 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', method: 'POST',
path: '/family/groups/{groupId}/botfather', path: '/family/groups/{groupId}/invites',
summary: 'Добавить BotFather в семью', summary: 'Пригласить участника или бота',
description: 'Создаёт BOT-чат с системным ботом BotFather для управления Telegram-ботами.', description: 'Люди получают pending-приглашение. Системные боты и боты других пользователей добавляются автоматически через auto-accept приглашения.',
auth: true 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 }
] ]
}, },
{ {

View File

@@ -156,7 +156,8 @@ curl -s -X POST '${API_BASE}/bots' \\
# Ответ содержит token — сохраните его, повторно не показывается при GET # Ответ содержит token — сохраните его, повторно не показывается при GET
# Профиль бота через BotFather в чате (после POST /family/groups/{id}/botfather): # BotFather добавляется в семью через поиск и POST /family/groups/{id}/invites.
# Профиль бота через BotFather в чате:
# /setdescription my_service Описание перед /start # /setdescription my_service Описание перед /start
# /setabouttext my_service Краткое описание # /setabouttext my_service Краткое описание
# /setuserpic my_service https://cdn.example.com/avatar.png # /setuserpic my_service https://cdn.example.com/avatar.png

View File

@@ -1293,15 +1293,14 @@ $oidc->authenticate(); // client_id, redirect_uri, response_type=code — авт
blocks: [ blocks: [
{ {
type: 'paragraph', type: 'paragraph',
text: 'Системный бот BotFather можно добавить в семейную группу, чтобы создавать и управлять Telegram-ботами прямо из семейного чата (команды /newbot, /mybots, настройка профиля бота и inline-кнопки Mini App).' text: 'Системный бот BotFather можно пригласить в семейную группу, чтобы создавать и управлять Telegram-ботами прямо из семейного чата (команды /newbot, /mybots, настройка профиля бота и inline-кнопки Mini App). Боты не подтверждают приглашение вручную: система принимает его автоматически.'
}, },
{ {
type: 'list', type: 'list',
items: [ items: [
'POST /family/groups/{groupId}/botfather — добавить BotFather в семью (JWT участника)', 'GET /family/groups/{groupId}/invite-search?q=... — поиск пользователей, BotFather, GetMyIdBot и ботов других владельцев',
'POST /family/groups/{groupId}/bots — добавить своего или чужого бота в семью ({ "botId": "..." })', 'POST /family/groups/{groupId}/invites — пригласить найденного пользователя или бота ({ "inviteeUserId": "..." })',
'После добавления появится BOT-чат с BotFather / ботом для каждого участника', 'Для бота приглашение сразу получает статус ACCEPTED, после чего появляется BOT-чат для участников',
'GET /family/groups/{groupId}/invite-search?q=... — поиск пользователей и ботов для приглашения',
'Команды профиля в чате BotFather: /setdescription, /setabouttext, /setuserpic, /setmenubutton' 'Команды профиля в чате BotFather: /setdescription, /setabouttext, /setuserpic, /setmenubutton'
] ]
} }
@@ -1544,7 +1543,7 @@ curl -s -X POST http://localhost:3000/chat/groups/$GROUP_ID/e2e-rooms \\
blocks: [ blocks: [
{ {
type: 'paragraph', type: 'paragraph',
text: 'Системный бот BotFather доступен в семейном чате (POST /family/groups/{groupId}/botfather). Помимо /newbot и /mybots поддерживается настройка профиля каждого вашего бота прямо из переписки. Username указывайте без суффикса _bot.' text: 'Системный бот BotFather доступен в семейном чате после приглашения через поиск семьи. Помимо /newbot и /mybots поддерживается настройка профиля каждого вашего бота прямо из переписки. Username указывайте без суффикса _bot.'
}, },
{ {
type: 'table', type: 'table',

View File

@@ -213,6 +213,11 @@ export function BotManageMiniAppContent({
showToast('Токен скопирован'); showToast('Токен скопирован');
} }
async function copyBotId() {
await navigator.clipboard.writeText(botId);
showToast('bot_id скопирован');
}
if (!botId) { if (!botId) {
return <div className="flex min-h-screen items-center justify-center p-6 text-sm text-[#667085]">Не указан botId</div>; return <div className="flex min-h-screen items-center justify-center p-6 text-sm text-[#667085]">Не указан botId</div>;
} }
@@ -294,6 +299,29 @@ export function BotManageMiniAppContent({
</SectionCard> </SectionCard>
</div> </div>
<div className="space-y-4 rounded-[24px] bg-white p-5 shadow-sm">
<SectionCard
icon={Copy}
title="ID и chat_id"
description="Данные для Bot API-скриптов и интеграций"
>
<div className="space-y-2">
<label className="text-sm font-medium">bot_id</label>
<div className="flex items-center gap-2">
<Input value={botId} readOnly className="rounded-xl font-mono text-xs" />
<Button type="button" variant="secondary" className="rounded-xl" onClick={() => void copyBotId()}>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div className="rounded-2xl bg-[#f4f5f8] p-3 text-sm leading-relaxed text-[#667085]">
Чтобы получить <span className="font-mono text-[#1f2430]">chat_id</span> для отправки сообщений этому боту,
добавьте <span className="font-medium text-[#1f2430]">GetMyIdBot</span> в семью и отправьте ему
<span className="font-mono text-[#1f2430]"> /id</span>. Он покажет готовый chat_id для каждого вашего бота.
</div>
</SectionCard>
</div>
<div className="space-y-4 rounded-[24px] bg-white p-5 shadow-sm"> <div className="space-y-4 rounded-[24px] bg-white p-5 shadow-sm">
<SectionCard <SectionCard
icon={TextQuote} icon={TextQuote}

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { CheckCircle2, Copy, Forward, Pencil, Reply, Trash2 } from 'lucide-react'; import { CheckCircle2, Copy, Forward, Pencil, Pin, PinOff, Reply, Trash2, XCircle } from 'lucide-react';
import { ChatMessageReactions } from '@/components/chat/chat-message-reactions'; import { ChatMessageReactions } from '@/components/chat/chat-message-reactions';
import { import {
ContextMenu, ContextMenu,
@@ -24,6 +24,7 @@ interface ChatMessageContextMenuProps {
onDelete?: () => void; onDelete?: () => void;
onReply?: () => void; onReply?: () => void;
onForward?: () => void; onForward?: () => void;
onTogglePin?: () => void;
onSelect?: () => void; onSelect?: () => void;
onToggleReaction?: (emoji: string) => void; onToggleReaction?: (emoji: string) => void;
onCopy?: () => void; onCopy?: () => void;
@@ -39,6 +40,7 @@ export function ChatMessageContextMenu({
onDelete, onDelete,
onReply, onReply,
onForward, onForward,
onTogglePin,
onSelect, onSelect,
onToggleReaction, onToggleReaction,
onCopy onCopy
@@ -125,6 +127,17 @@ export function ChatMessageContextMenu({
Переслать Переслать
</ContextMenuItem> </ContextMenuItem>
) : null} ) : null}
{onTogglePin && !disabled ? (
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
runMenuAction(onTogglePin);
}}
>
{message.isPinned ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
{message.isPinned ? 'Открепить' : 'Закрепить'}
</ContextMenuItem>
) : null}
{deletable ? ( {deletable ? (
<ContextMenuItem <ContextMenuItem
className="text-red-600 data-[highlighted]:bg-red-50 data-[highlighted]:text-red-700" className="text-red-600 data-[highlighted]:bg-red-50 data-[highlighted]:text-red-700"
@@ -161,6 +174,108 @@ export function ChatMessageContextMenu({
); );
} }
interface ChatSelectionContextMenuProps {
count: number;
canPin?: boolean;
pinned?: boolean;
children: React.ReactNode;
onReply?: () => 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 (
<ContextMenu
onOpenChange={(open) => {
if (open) suppressChatDragSelection(800);
}}
>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="min-w-[260px]">
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
runMenuAction(onReply);
}}
>
<Reply className="h-4 w-4" />
Ответить
</ContextMenuItem>
{canPin && onTogglePin ? (
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
runMenuAction(onTogglePin);
}}
>
{pinned ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
{pinned ? 'Открепить' : 'Закрепить'}
</ContextMenuItem>
) : null}
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
runMenuAction(onCopy);
}}
>
<Copy className="h-4 w-4" />
Копировать выбранное как текст
</ContextMenuItem>
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
runMenuAction(onForward);
}}
>
<Forward className="h-4 w-4" />
Переслать выбранное
</ContextMenuItem>
<ContextMenuItem
className="text-red-600 data-[highlighted]:bg-red-50 data-[highlighted]:text-red-700"
onSelect={(event) => {
event.preventDefault();
runMenuAction(onDelete);
}}
>
<Trash2 className="h-4 w-4" />
Удалить выбранное
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
runMenuAction(onCancel);
}}
>
<XCircle className="h-4 w-4" />
Отменить выбор
</ContextMenuItem>
<span className="sr-only">Выбрано сообщений: {count}</span>
</ContextMenuContent>
</ContextMenu>
);
}
interface SelectableMessageWrapperProps { interface SelectableMessageWrapperProps {
selected?: boolean; selected?: boolean;
selectionMode?: boolean; selectionMode?: boolean;

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useCallback, useEffect, useRef, useState } from 'react'; 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 { BotMessageKeyboard } from '@/components/chat/bot-message-keyboard';
import { BotChatMessageContextMenu } from '@/components/chat/bot-chat-message-context-menu'; import { BotChatMessageContextMenu } from '@/components/chat/bot-chat-message-context-menu';
import { useRealtime } from '@/components/notifications/realtime-provider'; import { useRealtime } from '@/components/notifications/realtime-provider';
@@ -135,7 +135,9 @@ export function FamilyBotChat({
messageType: typeof payload.messageType === 'string' ? payload.messageType : 'text', messageType: typeof payload.messageType === 'string' ? payload.messageType : 'text',
messageId, messageId,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
replyMarkupJson replyMarkupJson,
isPinned: Boolean(payload.isPinned),
pinnedAt: typeof payload.pinnedAt === 'string' ? payload.pinnedAt : null
}; };
setMessages((current) => { setMessages((current) => {
@@ -183,6 +185,27 @@ export function FamilyBotChat({
: item : 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]); }, [botRef, loadMessages, subscribe]);
@@ -227,6 +250,12 @@ export function FamilyBotChat({
<div key={message.id} className={cn('flex', mine ? 'justify-end' : 'justify-start')}> <div key={message.id} className={cn('flex', mine ? 'justify-end' : 'justify-start')}>
<BotChatMessageContextMenu text={message.text} onCopy={() => showToast('Текст скопирован')}> <BotChatMessageContextMenu text={message.text} onCopy={() => showToast('Текст скопирован')}>
<div className={cn('max-w-[78%] rounded-[18px] px-3 py-2 shadow-sm', mine ? 'rounded-br-md bg-[#effdde]' : 'rounded-bl-md bg-white')}> <div className={cn('max-w-[78%] rounded-[18px] px-3 py-2 shadow-sm', mine ? 'rounded-br-md bg-[#effdde]' : 'rounded-bl-md bg-white')}>
{message.isPinned ? (
<div className={cn('mb-1 flex items-center gap-1 text-[11px] font-medium', mine ? 'text-[#6c8f56]' : 'text-[#3390ec]')}>
<Pin className="h-3 w-3" />
Закреплено
</div>
) : null}
{!mine ? <p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">{botName}</p> : null} {!mine ? <p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">{botName}</p> : null}
<p className="whitespace-pre-wrap text-sm">{message.text || '…'}</p> <p className="whitespace-pre-wrap text-sm">{message.text || '…'}</p>
{!mine && hasKeyboard && message.messageId > 0 ? ( {!mine && hasKeyboard && message.messageId > 0 ? (

View File

@@ -14,6 +14,7 @@ import {
Loader2, Loader2,
Mic, Mic,
Paperclip, Paperclip,
Pin,
Plus, Plus,
Search, Search,
Send, Send,
@@ -37,7 +38,7 @@ import { ChatDateSeparator } from '@/components/chat/chat-date-separator';
import { ChatToolsDialog, type ChatToolsTab } from '@/components/chat/chat-tools-dialog'; import { ChatToolsDialog, type ChatToolsTab } from '@/components/chat/chat-tools-dialog';
import { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog'; import { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog';
import { ChatMessageEditBanner } from '@/components/chat/chat-message-edit-banner'; 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 { ChatMessageReactions } from '@/components/chat/chat-message-reactions';
import { ChatSelectionToolbar } from '@/components/chat/chat-selection-toolbar'; import { ChatSelectionToolbar } from '@/components/chat/chat-selection-toolbar';
import { EmojiPicker } from '@/components/chat/emoji-picker'; import { EmojiPicker } from '@/components/chat/emoji-picker';
@@ -78,6 +79,7 @@ import {
removeFamilyMember, removeFamilyMember,
reportChatTyping, reportChatTyping,
sendChatMessage, sendChatMessage,
setChatMessagePinned,
setChatRoomMuted, setChatRoomMuted,
toggleChatMessageReaction, toggleChatMessageReaction,
updateFamilyGroup, 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() { function getSelectedMessages() {
const selected = new Set(selectedMessageIds); const selected = new Set(selectedMessageIds);
return visibleMessages.filter((message) => selected.has(message.id)); return visibleMessages.filter((message) => selected.has(message.id));
@@ -1483,6 +1518,12 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
) )
)} )}
> >
{message.isPinned && !isLargeEmoji ? (
<div className={cn('mb-1 flex items-center gap-1 text-[11px] font-medium', mine ? 'text-[#6c8f56]' : 'text-[#3390ec]')}>
<Pin className="h-3 w-3" />
Закреплено
</div>
) : null}
{!mine ? <p className="mb-1 text-xs font-semibold text-[#3390ec]">{message.senderName}</p> : null} {!mine ? <p className="mb-1 text-xs font-semibold text-[#3390ec]">{message.senderName}</p> : null}
{forwardedFrom ? ( {forwardedFrom ? (
<p className="mb-1 text-xs font-medium text-[#3390ec]"> Переслано от {forwardedFrom.senderName}</p> <p className="mb-1 text-xs font-medium text-[#3390ec]"> Переслано от {forwardedFrom.senderName}</p>
@@ -1648,14 +1689,26 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
/> />
) : null} ) : null}
{selectionMode ? ( {selectionMode ? (
<SelectableMessageWrapper <ChatSelectionContextMenu
selected={isSelected} count={selectedMessageIds.length}
selectionMode canPin={selectedMessageIds.length === 1}
align={mine ? 'right' : 'left'} pinned={getSelectedMessages()[0]?.isPinned}
onClick={() => toggleSelectedMessage(message.id)} onReply={handleSelectionReply}
onCopy={() => void handleBulkCopy()}
onForward={() => setForwardDialogOpen(true)}
onDelete={() => void handleBulkDelete()}
onTogglePin={handleSelectionPin}
onCancel={exitSelectionMode}
> >
{bubble} <SelectableMessageWrapper
</SelectableMessageWrapper> selected={isSelected}
selectionMode
align={mine ? 'right' : 'left'}
onClick={() => toggleSelectedMessage(message.id)}
>
{bubble}
</SelectableMessageWrapper>
</ChatSelectionContextMenu>
) : ( ) : (
<ChatMessageContextMenu <ChatMessageContextMenu
message={message} message={message}
@@ -1675,6 +1728,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
setSelectedMessageIds([message.id]); setSelectedMessageIds([message.id]);
setForwardDialogOpen(true); setForwardDialogOpen(true);
}} }}
onTogglePin={() => void handleTogglePin(message)}
onSelect={() => enterSelectionMode(message.id)} onSelect={() => enterSelectionMode(message.id)}
onToggleReaction={(emoji) => void handleToggleReaction(message.id, emoji)} onToggleReaction={(emoji) => void handleToggleReaction(message.id, emoji)}
onCopy={() => showToast('Текст скопирован')} onCopy={() => showToast('Текст скопирован')}

View File

@@ -12,8 +12,6 @@ import {
FamilyGroup, FamilyGroup,
FamilyInviteCandidate, FamilyInviteCandidate,
ManagedBot, ManagedBot,
addBotFatherToFamily,
addBotToFamily,
fetchMyBots, fetchMyBots,
getApiErrorMessage, getApiErrorMessage,
searchFamilyInviteUsers, searchFamilyInviteUsers,
@@ -42,7 +40,6 @@ export function FamilyInviteDialog({
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [myBots, setMyBots] = useState<ManagedBot[]>([]); const [myBots, setMyBots] = useState<ManagedBot[]>([]);
const [myBotsLoading, setMyBotsLoading] = useState(false); const [myBotsLoading, setMyBotsLoading] = useState(false);
const [addingBotId, setAddingBotId] = useState<string | null>(null);
const familyBotUsernames = useMemo( const familyBotUsernames = useMemo(
() => new Set((group?.members ?? []).map((member) => member.botUsername).filter(Boolean) as string[]), () => new Set((group?.members ?? []).map((member) => member.botUsername).filter(Boolean) as string[]),
@@ -89,35 +86,14 @@ export function FamilyInviteDialog({
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [groupId, inviteQuery, open, token]); }, [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() { async function submitInvite() {
if (!token || !selectedInviteUser) return; if (!token || !selectedInviteUser) return;
setSubmitting(true); setSubmitting(true);
try { try {
await sendFamilyInvite(groupId, { inviteeUserId: selectedInviteUser.id }, token);
if (selectedInviteUser.isBot) { if (selectedInviteUser.isBot) {
if (selectedInviteUser.botId) { showToast(`${selectedInviteUser.displayName} добавлен в семью`);
await addBotToFamily(groupId, selectedInviteUser.botId, token);
showToast(`${selectedInviteUser.displayName} добавлен в семью`);
} else {
await addBotFatherToFamily(groupId, token);
showToast('BotFather добавлен в семью');
}
} else { } else {
await sendFamilyInvite(groupId, { inviteeUserId: selectedInviteUser.id }, token);
showToast('Приглашение отправлено'); showToast('Приглашение отправлено');
} }
onOpenChange(false); onOpenChange(false);
@@ -145,13 +121,15 @@ export function FamilyInviteDialog({
if (candidate.botUsername === 'BotFather_bot') { if (candidate.botUsername === 'BotFather_bot') {
return `${handle} — создавайте ботов через чат`; return `${handle} — создавайте ботов через чат`;
} }
if (candidate.botUsername === 'GetMyIdBot_bot') {
return `${handle} — получите user_id и chat_id`;
}
return handle; return handle;
} }
function submitButtonLabel() { function submitButtonLabel() {
if (!selectedInviteUser?.isBot) return 'Отправить приглашение'; if (!selectedInviteUser?.isBot) return 'Отправить приглашение';
if (selectedInviteUser.botId) return 'Добавить бота'; return 'Пригласить бота';
return 'Добавить BotFather';
} }
return ( return (
@@ -192,16 +170,15 @@ export function FamilyInviteDialog({
<button <button
key={bot.id} key={bot.id}
type="button" type="button"
disabled={addingBotId === bot.id} className="flex w-full items-center gap-3 border-b border-[#eceef4] px-4 py-3 text-left last:border-b-0 hover:bg-[#fafbfd]"
className="flex w-full items-center gap-3 border-b border-[#eceef4] px-4 py-3 text-left last:border-b-0 hover:bg-[#fafbfd] disabled:opacity-60" onClick={() => setInviteQuery(`@${bot.username}`)}
onClick={() => void addBotById(bot.id, bot.name)}
> >
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[#eef4ff] text-[#3390ec]"> <div className="flex h-9 w-9 items-center justify-center rounded-full bg-[#eef4ff] text-[#3390ec]">
{addingBotId === bot.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Bot className="h-4 w-4" />} <Bot className="h-4 w-4" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-medium">{bot.name}</p> <p className="truncate font-medium">{bot.name}</p>
<p className="truncate text-sm text-[#667085]">@{bot.username}</p> <p className="truncate text-sm text-[#667085]">@{bot.username} · выбрать через приглашение</p>
</div> </div>
</button> </button>
)) ))
@@ -211,7 +188,7 @@ export function FamilyInviteDialog({
</p> </p>
)} )}
</div> </div>
<p className="mt-2 text-xs text-[#667085]">Или найдите пользователя / бота другого владельца через поиск выше</p> <p className="mt-2 text-xs text-[#667085]">Выберите своего бота, затем подтвердите приглашение в результатах поиска</p>
</div> </div>
) : null} ) : null}
<div className="mt-2 max-h-56 overflow-y-auto rounded-2xl border border-[#eceef4]"> <div className="mt-2 max-h-56 overflow-y-auto rounded-2xl border border-[#eceef4]">

View File

@@ -2,16 +2,14 @@
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; 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 { AvatarWithPresence } from '@/components/family/avatar-with-presence';
import { FamilyGroupSelector } from '@/components/family/family-group-selector'; import { FamilyGroupSelector } from '@/components/family/family-group-selector';
import { FamilyInviteDialog } from '@/components/family/family-invite-dialog'; import { FamilyInviteDialog } from '@/components/family/family-invite-dialog';
import { useFamilyOverlay } from '@/components/family/family-overlay-provider'; import { useFamilyOverlay } from '@/components/family/family-overlay-provider';
import { useAuth } from '@/components/id/auth-provider'; import { useAuth } from '@/components/id/auth-provider';
import { useSelectedFamily } from '@/hooks/use-primary-family'; import { useSelectedFamily } from '@/hooks/use-primary-family';
import { addBotFatherToFamily, getApiErrorMessage } from '@/lib/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useToast } from '@/components/id/toast-provider';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export function FamilySidebarPanel() { export function FamilySidebarPanel() {
@@ -27,9 +25,7 @@ export function FamilySidebarPanel() {
refresh refresh
} = useSelectedFamily(Boolean(user && token)); } = useSelectedFamily(Boolean(user && token));
const { openChatWithMember } = useFamilyOverlay(); const { openChatWithMember } = useFamilyOverlay();
const { showToast } = useToast();
const [inviteOpen, setInviteOpen] = useState(false); const [inviteOpen, setInviteOpen] = useState(false);
const [addingBotFather, setAddingBotFather] = useState(false);
if (!user || !token) return null; if (!user || !token) return null;
@@ -118,32 +114,6 @@ export function FamilySidebarPanel() {
</button> </button>
); );
})} })}
{group?.botFatherAvailable ? (
<button
type="button"
disabled={addingBotFather}
className="flex w-full items-center gap-2 rounded-xl border border-dashed border-[#d5dbe8] px-2 py-2 text-left transition hover:bg-[#f4f5f8] disabled:opacity-60"
onClick={() => {
if (!group || !token) return;
setAddingBotFather(true);
void addBotFatherToFamily(group.id, token)
.then(() => {
showToast('BotFather добавлен в семью');
void refresh();
})
.catch((error) => showToast(getApiErrorMessage(error, 'Не удалось добавить BotFather') ?? 'Ошибка'))
.finally(() => setAddingBotFather(false));
}}
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#eef4ff] text-[#3390ec]">
{addingBotFather ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-[12px] font-medium leading-tight">Добавить BotFather 🤖</p>
<p className="truncate text-[10px] text-[#667085]">Создавайте ботов через чат</p>
</div>
</button>
) : null}
</div> </div>
) : ( ) : (
<div className="rounded-xl bg-[#f4f5f8] px-2 py-3 text-[11px] text-[#667085]"> <div className="rounded-xl bg-[#f4f5f8] px-2 py-3 text-[11px] text-[#667085]">

View File

@@ -2,13 +2,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link'; 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 { AvatarWithPresence } from '@/components/family/avatar-with-presence';
import { FamilyBotChat, BotMiniAppSheet } from '@/components/family/family-bot-chat'; import { FamilyBotChat, BotMiniAppSheet } from '@/components/family/family-bot-chat';
import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display'; import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display';
import { ChatDateSeparator } from '@/components/chat/chat-date-separator'; import { ChatDateSeparator } from '@/components/chat/chat-date-separator';
import { ChatEmojiContent } from '@/components/chat/chat-emoji-content'; 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 { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog';
import { ChatMessageEditBanner } from '@/components/chat/chat-message-edit-banner'; import { ChatMessageEditBanner } from '@/components/chat/chat-message-edit-banner';
import { ChatMessageReactions } from '@/components/chat/chat-message-reactions'; import { ChatMessageReactions } from '@/components/chat/chat-message-reactions';
@@ -44,6 +44,7 @@ import {
markChatRoomRead, markChatRoomRead,
sendBotMessage, sendBotMessage,
sendChatMessage, sendChatMessage,
setChatMessagePinned,
toggleChatMessageReaction, toggleChatMessageReaction,
uploadMediaObject, uploadMediaObject,
voteChatPoll voteChatPoll
@@ -516,6 +517,35 @@ export function MiniFamilyChat() {
requestDeleteMessages(selected.map((message) => message.id)); 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) { async function uploadChatFile(file: File) {
if (!token || !activeRoom || sending) return; if (!token || !activeRoom || sending) return;
setSending(true); setSending(true);
@@ -948,6 +978,12 @@ export function MiniFamilyChat() {
) )
)} )}
> >
{message.isPinned && !isLargeEmoji ? (
<div className={cn('mb-1 flex items-center gap-1 text-[10px] font-medium', mine ? 'text-[#6c8f56]' : 'text-[#3390ec]')}>
<Pin className="h-3 w-3" />
Закреплено
</div>
) : null}
{!mine && !isLargeEmoji ? ( {!mine && !isLargeEmoji ? (
<p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">{message.senderName}</p> <p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">{message.senderName}</p>
) : null} ) : null}
@@ -1045,14 +1081,26 @@ export function MiniFamilyChat() {
/> />
) : null} ) : null}
{selectionMode ? ( {selectionMode ? (
<SelectableMessageWrapper <ChatSelectionContextMenu
selected={isSelected} count={selectedMessageIds.length}
selectionMode canPin={selectedMessageIds.length === 1}
align={mine ? 'right' : 'left'} pinned={getSelectedMessages()[0]?.isPinned}
onClick={() => toggleSelectedMessage(message.id)} onReply={handleSelectionReply}
onCopy={() => void handleBulkCopy()}
onForward={() => showToast('Пересылка доступна в полном виде семьи')}
onDelete={() => void handleBulkDelete()}
onTogglePin={handleSelectionPin}
onCancel={exitSelectionMode}
> >
{bubble} <SelectableMessageWrapper
</SelectableMessageWrapper> selected={isSelected}
selectionMode
align={mine ? 'right' : 'left'}
onClick={() => toggleSelectedMessage(message.id)}
>
{bubble}
</SelectableMessageWrapper>
</ChatSelectionContextMenu>
) : ( ) : (
<ChatMessageContextMenu <ChatMessageContextMenu
message={message} message={message}
@@ -1065,6 +1113,7 @@ export function MiniFamilyChat() {
}} }}
onDelete={() => requestDeleteMessages([message.id])} onDelete={() => requestDeleteMessages([message.id])}
onForward={() => showToast('Пересылка доступна в полном виде семьи')} onForward={() => showToast('Пересылка доступна в полном виде семьи')}
onTogglePin={() => void handleTogglePin(message)}
onSelect={() => enterSelectionMode(message.id)} onSelect={() => enterSelectionMode(message.id)}
onToggleReaction={async (emoji) => { onToggleReaction={async (emoji) => {
if (!token) return; if (!token) return;

View File

@@ -741,6 +741,7 @@ export interface FamilyGroup {
hasAvatar: boolean; hasAvatar: boolean;
members?: FamilyMember[]; members?: FamilyMember[];
botFatherAvailable?: boolean; botFatherAvailable?: boolean;
getMyIdBotAvailable?: boolean;
} }
export interface BotChatMessage { export interface BotChatMessage {
@@ -751,6 +752,8 @@ export interface BotChatMessage {
messageId: number; messageId: number;
createdAt: string; createdAt: string;
replyMarkupJson?: string | null; replyMarkupJson?: string | null;
isPinned?: boolean;
pinnedAt?: string | null;
} }
export interface BotChatMeta { export interface BotChatMeta {
@@ -817,6 +820,9 @@ export interface ChatMessage {
createdAt: string; createdAt: string;
editedAt?: string; editedAt?: string;
isDeleted?: boolean; isDeleted?: boolean;
isPinned?: boolean;
pinnedAt?: string;
pinnedById?: string;
readByAll?: boolean; readByAll?: boolean;
poll?: { poll?: {
id: string; id: string;
@@ -887,14 +893,6 @@ export async function sendFamilyInvite(groupId: string, payload: { inviteeUserId
return apiFetch<FamilyInvite>(`/family/groups/${groupId}/invites`, { method: 'POST', body: JSON.stringify(payload) }, token); return apiFetch<FamilyInvite>(`/family/groups/${groupId}/invites`, { method: 'POST', body: JSON.stringify(payload) }, token);
} }
export async function addBotFatherToFamily(groupId: string, token?: string | null) {
return apiFetch<FamilyMember>(`/family/groups/${groupId}/botfather`, { method: 'POST', body: '{}' }, token);
}
export async function addBotToFamily(groupId: string, botId: string, token?: string | null) {
return apiFetch<FamilyMember>(`/family/groups/${groupId}/bots`, { method: 'POST', body: JSON.stringify({ botId }) }, token);
}
export async function fetchBotChatMessages(botRef: string, token?: string | null) { export async function fetchBotChatMessages(botRef: string, token?: string | null) {
return apiFetch<{ return apiFetch<{
messages?: BotChatMessage[]; messages?: BotChatMessage[];
@@ -1137,6 +1135,13 @@ export async function toggleChatMessageReaction(messageId: string, emoji: string
}, token); }, token);
} }
export async function setChatMessagePinned(messageId: string, pinned: boolean, token?: string | null) {
return apiFetch<ChatMessage>(`/chat/messages/${messageId}/pin`, {
method: 'POST',
body: JSON.stringify({ pinned })
}, token);
}
export async function forwardChatMessages(targetRoomId: string, messageIds: string[], token?: string | null) { export async function forwardChatMessages(targetRoomId: string, messageIds: string[], token?: string | null) {
return apiFetch<{ messages?: ChatMessage[] }>(`/chat/rooms/${targetRoomId}/forward`, { return apiFetch<{ messages?: ChatMessage[] }>(`/chat/rooms/${targetRoomId}/forward`, {
method: 'POST', method: 'POST',

View File

@@ -438,6 +438,9 @@ model ChatMessage {
metadata Json? metadata Json?
storageKey String? storageKey String?
mimeType String? mimeType String?
isPinned Boolean @default(false)
pinnedAt DateTime?
pinnedById String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
editedAt DateTime? editedAt DateTime?
deletedAt DateTime? deletedAt DateTime?
@@ -446,6 +449,7 @@ model ChatMessage {
poll ChatPoll? poll ChatPoll?
@@index([roomId, createdAt]) @@index([roomId, createdAt])
@@index([roomId, isPinned, pinnedAt])
} }
model ChatPoll { model ChatPoll {
@@ -603,6 +607,8 @@ model BotMessage {
text String? text String?
mediaUrl String? mediaUrl String?
payload Json? payload Json?
isPinned Boolean @default(false)
pinnedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
editedAt DateTime? editedAt DateTime?
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade) bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
@@ -610,6 +616,7 @@ model BotMessage {
@@unique([botId, telegramMsgId]) @@unique([botId, telegramMsgId])
@@index([botChatId]) @@index([botChatId])
@@index([botChatId, isPinned, pinnedAt])
} }
model BotInboundMessage { model BotInboundMessage {

View File

@@ -908,11 +908,15 @@ export class AuthGrpcController {
} }
@GrpcMethod('FamilyService', 'SendFamilyInvite') @GrpcMethod('FamilyService', 'SendFamilyInvite')
sendFamilyInvite(command: { requesterId: string; groupId: string; target?: string; inviteeUserId?: string }) { async sendFamilyInvite(command: { requesterId: string; groupId: string; target?: string; inviteeUserId?: string }) {
return this.family.sendInvite(command.requesterId, command.groupId, { const invite = await this.family.sendInvite(command.requesterId, command.groupId, {
target: command.target, target: command.target,
inviteeUserId: command.inviteeUserId inviteeUserId: command.inviteeUserId
}); });
if (invite.status === 'ACCEPTED') {
await this.chat.syncFamilyChats(command.groupId);
}
return invite;
} }
@GrpcMethod('FamilyService', 'SearchFamilyInviteUsers') @GrpcMethod('FamilyService', 'SearchFamilyInviteUsers')
@@ -935,20 +939,6 @@ export class AuthGrpcController {
return this.presence.getFamilyPresence(command.requesterId, command.groupId); 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') @GrpcMethod('NotificationsService', 'ListNotifications')
listNotifications(command: { userId: string; limit?: number; unreadOnly?: boolean }) { listNotifications(command: { userId: string; limit?: number; unreadOnly?: boolean }) {
return this.notifications.list(command.userId, command.limit, command.unreadOnly); 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); 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') @GrpcMethod('ChatService', 'ToggleMessageReaction')
toggleMessageReaction(command: { userId: string; messageId: string; emoji: string }) { toggleMessageReaction(command: { userId: string; messageId: string; emoji: string }) {
return this.chat.toggleMessageReaction(command.userId, command.messageId, command.emoji); return this.chat.toggleMessageReaction(command.userId, command.messageId, command.emoji);

View File

@@ -53,6 +53,10 @@ type EditMessageReplyMarkupPayload = ChatPayload & {
reply_markup?: unknown; reply_markup?: unknown;
}; };
type PinChatMessagePayload = ChatPayload & {
disable_notification?: boolean;
};
type AnswerCallbackQueryPayload = { type AnswerCallbackQueryPayload = {
callback_query_id?: string; callback_query_id?: string;
text?: string; text?: string;
@@ -125,12 +129,18 @@ export class BotApiService {
switch (normalizedMethod) { switch (normalizedMethod) {
case 'getMe': case 'getMe':
return this.wrapOk(this.buildUserObject(bot)); return this.wrapOk(this.buildUserObject(bot));
case 'getChat':
return this.getChat(bot, payload as ChatPayload);
case 'sendMessage': case 'sendMessage':
return this.sendMessage(bot, payload as SendMessagePayload); return this.sendMessage(bot, payload as SendMessagePayload);
case 'editMessageText': case 'editMessageText':
return this.editMessageText(bot, payload as EditMessageTextPayload); return this.editMessageText(bot, payload as EditMessageTextPayload);
case 'editMessageReplyMarkup': case 'editMessageReplyMarkup':
return this.editMessageReplyMarkup(bot, payload as EditMessageReplyMarkupPayload); 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': case 'answerCallbackQuery':
return this.answerCallbackQuery(payload as AnswerCallbackQueryPayload); return this.answerCallbackQuery(payload as AnswerCallbackQueryPayload);
case 'sendPhoto': case 'sendPhoto':
@@ -277,7 +287,9 @@ export class BotApiService {
messageType: 'text', messageType: 'text',
messageId: item.telegramMsgId, messageId: item.telegramMsgId,
createdAt: item.createdAt.toISOString(), createdAt: item.createdAt.toISOString(),
replyMarkupJson: null as string | null replyMarkupJson: null as string | null,
isPinned: false,
pinnedAt: null as string | null
})), })),
...outbound.map((item) => { ...outbound.map((item) => {
const payload = this.serializer.readPayload(item.payload); const payload = this.serializer.readPayload(item.payload);
@@ -289,7 +301,9 @@ export class BotApiService {
messageType: item.messageType ?? 'text', messageType: item.messageType ?? 'text',
messageId: item.telegramMsgId, messageId: item.telegramMsgId,
createdAt: item.createdAt.toISOString(), 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)); ].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
@@ -564,6 +578,82 @@ export class BotApiService {
return this.wrapOk(this.buildTelegramMessage(bot, located, updated)); 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) { private async answerCallbackQuery(payload: AnswerCallbackQueryPayload) {
const callbackQueryId = payload.callback_query_id?.trim(); const callbackQueryId = payload.callback_query_id?.trim();
if (!callbackQueryId) { if (!callbackQueryId) {
@@ -743,6 +833,8 @@ export class BotApiService {
createdAt: Date; createdAt: Date;
editedAt?: Date | null; editedAt?: Date | null;
payload?: unknown; payload?: unknown;
isPinned?: boolean;
pinnedAt?: Date | null;
} }
) { ) {
return this.serializer.buildBotMessageObject({ 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( private async publishBotMessageEdited(
userId: string, userId: string,
bot: ValidatedBot, bot: ValidatedBot,

View File

@@ -1,8 +1,10 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { BotApiService } from './bot-api.service'; 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 { BotFatherSeedService } from './bot-father-seed.service';
import { BotFatherService } from './bot-father.service'; import { BotFatherService } from './bot-father.service';
import { deriveTelegramChatId } from './bot-token.util';
@Injectable() @Injectable()
export class BotFatherAssistantService { export class BotFatherAssistantService {
@@ -11,10 +13,16 @@ export class BotFatherAssistantService {
constructor( constructor(
private readonly seed: BotFatherSeedService, private readonly seed: BotFatherSeedService,
private readonly botFather: BotFatherService, 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 botId = await this.seed.getBotFatherBotId();
const trimmed = text.trim(); const trimmed = text.trim();
const lower = trimmed.toLowerCase(); 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(); const botId = await this.seed.getBotFatherBotId();
if (callbackData === 'newbot_help' || callbackData === 'newbot') { if (callbackData === 'newbot_help' || callbackData === 'newbot') {
await this.botApi.sendInternalBotMessage( 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): private parseProfileCommand(text: string):
| { kind: 'setdescription' | 'setabouttext' | 'setuserpic' | 'setmenubutton'; botUsername: string; value: string } | { kind: 'setdescription' | 'setabouttext' | 'setuserpic' | 'setmenubutton'; botUsername: string; value: string }
| null { | null {

View File

@@ -7,6 +7,11 @@ import {
BOTFATHER_SETTING_BOT_ID, BOTFATHER_SETTING_BOT_ID,
BOTFATHER_SETTING_USER_ID, BOTFATHER_SETTING_USER_ID,
BOTFATHER_USER_USERNAME, 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 SYSTEM_BOT_OWNER_EMAIL
} from './bot-father.constants'; } from './bot-father.constants';
import { generateTelegramStyleBotToken, hashBotToken } from './bot-token.util'; import { generateTelegramStyleBotToken, hashBotToken } from './bot-token.util';
@@ -16,6 +21,8 @@ export class BotFatherSeedService implements OnModuleInit {
private readonly logger = new Logger(BotFatherSeedService.name); private readonly logger = new Logger(BotFatherSeedService.name);
private cachedUserId: string | null = null; private cachedUserId: string | null = null;
private cachedBotId: string | null = null; private cachedBotId: string | null = null;
private cachedGetMyIdUserId: string | null = null;
private cachedGetMyIdBotId: string | null = null;
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
@@ -24,6 +31,7 @@ export class BotFatherSeedService implements OnModuleInit {
async onModuleInit() { async onModuleInit() {
await this.ensureBotFather(); await this.ensureBotFather();
await this.ensureGetMyIdBot();
} }
async getBotFatherUserId() { async getBotFatherUserId() {
@@ -48,6 +56,28 @@ export class BotFatherSeedService implements OnModuleInit {
return ensured.botId; 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() { async ensureBotFather() {
const existingBotId = await this.prisma.systemSetting.findUnique({ where: { key: BOTFATHER_SETTING_BOT_ID } }); 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 } }); 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 }; 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() { private async resolveBotOwnerId() {
const superAdmin = await this.prisma.user.findFirst({ const superAdmin = await this.prisma.user.findFirst({
where: { isSuperAdmin: true, deletedAt: null, status: 'ACTIVE', isSystemAccount: false }, where: { isSuperAdmin: true, deletedAt: null, status: 'ACTIVE', isSystemAccount: false },

View File

@@ -3,6 +3,11 @@ export const BOTFATHER_USER_USERNAME = 'BotFather';
export const BOTFATHER_DISPLAY_NAME = 'BotFather'; export const BOTFATHER_DISPLAY_NAME = 'BotFather';
export const BOTFATHER_SETTING_USER_ID = 'BOTFATHER_USER_ID'; export const BOTFATHER_SETTING_USER_ID = 'BOTFATHER_USER_ID';
export const BOTFATHER_SETTING_BOT_ID = 'BOTFATHER_BOT_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 const SYSTEM_BOT_OWNER_EMAIL = 'system-bot-owner@internal.lendry.id';
export function resolvePublicFrontendUrl() { export function resolvePublicFrontendUrl() {

View File

@@ -34,7 +34,7 @@ export class BotInboundService {
const event = await this.createMessageEvent(bot.id, chat.id, senderUserId, chat.telegramChatId, trimmedText); const event = await this.createMessageEvent(bot.id, chat.id, senderUserId, chat.telegramChatId, trimmedText);
await this.dispatchEvent(event); await this.dispatchEvent(event);
if (bot.isSystemBot) { if (bot.isSystemBot) {
void this.assistant.handleUserMessage(senderUserId, trimmedText).catch(() => undefined); void this.assistant.handleUserMessage(bot.id, senderUserId, trimmedText).catch(() => undefined);
} }
return { return {
@@ -89,7 +89,7 @@ export class BotInboundService {
await this.dispatchEvent(event); await this.dispatchEvent(event);
if (bot.isSystemBot) { if (bot.isSystemBot) {
void this.assistant.handleCallbackQuery(senderUserId, trimmedData).catch(() => undefined); void this.assistant.handleCallbackQuery(bot.id, senderUserId, trimmedData).catch(() => undefined);
} }
return { return {

View File

@@ -607,6 +607,33 @@ export class ChatService {
return response; 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) { async toggleMessageReaction(userId: string, messageId: string, emoji: string) {
const normalizedEmoji = emoji.trim(); const normalizedEmoji = emoji.trim();
if (!normalizedEmoji || normalizedEmoji.length > 16) { if (!normalizedEmoji || normalizedEmoji.length > 16) {
@@ -1016,6 +1043,9 @@ export class ChatService {
storageKey: string | null; storageKey: string | null;
mimeType: string | null; mimeType: string | null;
metadata: unknown; metadata: unknown;
isPinned?: boolean;
pinnedAt?: Date | null;
pinnedById?: string | null;
createdAt: Date; createdAt: Date;
editedAt?: Date | null; editedAt?: Date | null;
deletedAt?: Date | null; deletedAt?: Date | null;
@@ -1060,6 +1090,9 @@ export class ChatService {
isDeleted, isDeleted,
readByAll: message.senderId === viewerId ? readByAll : undefined, readByAll: message.senderId === viewerId ? readByAll : undefined,
reactions: isDeleted ? [] : this.buildReactionResponses(message.metadata, viewerId), 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 poll: !isDeleted && message.poll
? { ? {
id: message.poll.id, id: message.poll.id,
@@ -1202,6 +1235,9 @@ export class ChatService {
isDeleted: false, isDeleted: false,
readByAll: undefined, readByAll: undefined,
reactions: [], reactions: [],
isPinned: false,
pinnedAt: undefined,
pinnedById: undefined,
poll: undefined poll: undefined
}; };
} }

View File

@@ -9,7 +9,14 @@ import { NotificationsService } from './notifications.service';
import { MinioService } from '../infra/minio.service'; import { MinioService } from '../infra/minio.service';
import { BotFatherSeedService } from './bot/bot-father-seed.service'; import { BotFatherSeedService } from './bot/bot-father-seed.service';
import { BotFatherService } from './bot/bot-father.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 { normalizeBotUsername } from './bot/bot-token.util';
import { HUMAN_USER_WHERE, isProtectedSystemAccount } from './system-account.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) { async removeMember(requesterId: string, memberId: string) {
const member = await this.prisma.familyMember.findUnique({ const member = await this.prisma.familyMember.findUnique({
@@ -483,7 +447,7 @@ export class FamilyService {
const invitee = options.inviteeUserId const invitee = options.inviteeUserId
? await this.prisma.user.findFirst({ ? 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 : options.target
? await this.findUserByContact(options.target) ? await this.findUserByContact(options.target)
@@ -493,10 +457,6 @@ export class FamilyService {
throw new BadRequestException('Пользователь не найден. Выберите его из результатов поиска.'); throw new BadRequestException('Пользователь не найден. Выберите его из результатов поиска.');
} }
if (isProtectedSystemAccount(invitee)) {
throw new BadRequestException('BotFather добавляется через отдельное действие «Добавить BotFather»');
}
if (invitee.id === requesterId) { if (invitee.id === requesterId) {
throw new BadRequestException('Нельзя пригласить самого себя'); throw new BadRequestException('Нельзя пригласить самого себя');
} }
@@ -513,6 +473,35 @@ export class FamilyService {
} }
const inviter = await this.prisma.user.findUnique({ where: { id: requesterId } }); 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({ const invite = await this.prisma.familyInvite.create({
data: { data: {
groupId, groupId,
@@ -555,10 +544,17 @@ export class FamilyService {
.filter((username): username is string => Boolean(username)) .filter((username): username is string => Boolean(username))
); );
const botFatherUserId = await this.botFatherSeed.getBotFatherUserId(); 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 = const botFatherMatches =
/bot\s*father|бот\s*father|botfather/i.test(trimmed) && /bot\s*father|бот\s*father|botfather/i.test(trimmed) &&
!excludedIds.includes(botFatherUserId) && !excludedIds.includes(botFatherUserId) &&
!excludedBotUsernames.has(BOTFATHER_BOT_USERNAME); !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 digits = trimmed.replace(/\D/g, '');
const normalizedBotQuery = normalizeBotUsername(trimmed.replace(/^@/, '')); const normalizedBotQuery = normalizeBotUsername(trimmed.replace(/^@/, ''));
@@ -624,6 +620,8 @@ export class FamilyService {
for (const bot of matchedBots) { for (const bot of matchedBots) {
if (excludedBotUsernames.has(bot.username)) continue; 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 const { userId } = bot.linkedSystemUser
? { userId: bot.linkedSystemUser.id } ? { userId: bot.linkedSystemUser.id }
: await this.botFather.ensureBotSystemUser(bot.id); : await this.botFather.ensureBotSystemUser(bot.id);
@@ -672,7 +670,25 @@ export class FamilyService {
verificationIcon: resolveVerificationIcon('bot'), verificationIcon: resolveVerificationIcon('bot'),
isBot: true, isBot: true,
botUsername: BOTFATHER_BOT_USERNAME, 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 ownerDisplayName: undefined
} }
] ]
@@ -1004,6 +1020,10 @@ export class FamilyService {
botFatherAvailable: !group.members.some( botFatherAvailable: !group.members.some(
(member) => member.user?.linkedBot?.username === BOTFATHER_BOT_USERNAME (member) => member.user?.linkedBot?.username === BOTFATHER_BOT_USERNAME
),
getMyIdBotAvailable: !group.members.some(
(member) => member.user?.linkedBot?.username === GET_MY_ID_BOT_USERNAME
) )
}; };

View File

@@ -5,7 +5,7 @@ export type SystemAccountProbe = {
linkedBotId?: string | null; 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 { export function isProtectedSystemAccount(user: SystemAccountProbe | null | undefined): boolean {
if (!user) return false; if (!user) return false;

View File

@@ -220,6 +220,8 @@ message BotChatMessageItem {
int32 messageId = 5; int32 messageId = 5;
string createdAt = 6; string createdAt = 6;
optional string replyMarkupJson = 7; optional string replyMarkupJson = 7;
bool isPinned = 8;
optional string pinnedAt = 9;
} }
message ListBotChatMessagesResponse { message ListBotChatMessagesResponse {

View File

@@ -13,6 +13,7 @@ service ChatService {
rpc SendMessage (SendMessageRequest) returns (ChatMessageResponse); rpc SendMessage (SendMessageRequest) returns (ChatMessageResponse);
rpc EditMessage (EditMessageRequest) returns (ChatMessageResponse); rpc EditMessage (EditMessageRequest) returns (ChatMessageResponse);
rpc DeleteMessage (DeleteMessageRequest) returns (ChatMessageResponse); rpc DeleteMessage (DeleteMessageRequest) returns (ChatMessageResponse);
rpc SetMessagePinned (SetMessagePinnedRequest) returns (ChatMessageResponse);
rpc MarkRoomRead (MarkRoomReadRequest) returns (MutationResponse); rpc MarkRoomRead (MarkRoomReadRequest) returns (MutationResponse);
rpc VotePoll (VotePollRequest) returns (ChatMessageResponse); rpc VotePoll (VotePollRequest) returns (ChatMessageResponse);
rpc SetRoomNotificationsMuted (SetRoomNotificationsMutedRequest) returns (MutationResponse); rpc SetRoomNotificationsMuted (SetRoomNotificationsMutedRequest) returns (MutationResponse);
@@ -90,6 +91,12 @@ message DeleteMessageRequest {
string messageId = 2; string messageId = 2;
} }
message SetMessagePinnedRequest {
string userId = 1;
string messageId = 2;
bool pinned = 3;
}
message MarkRoomReadRequest { message MarkRoomReadRequest {
string userId = 1; string userId = 1;
string roomId = 2; string roomId = 2;
@@ -203,6 +210,9 @@ message ChatMessageResponse {
optional bool readByAll = 16; optional bool readByAll = 16;
optional bool isEncrypted = 19; optional bool isEncrypted = 19;
repeated MessageReactionResponse reactions = 20; repeated MessageReactionResponse reactions = 20;
bool isPinned = 21;
optional string pinnedAt = 22;
optional string pinnedById = 23;
} }
message MessageReactionResponse { message MessageReactionResponse {

View File

@@ -39,8 +39,6 @@ service FamilyService {
rpc RespondFamilyInvite (RespondFamilyInviteRequest) returns (FamilyInviteResponse); rpc RespondFamilyInvite (RespondFamilyInviteRequest) returns (FamilyInviteResponse);
rpc ListFamilyInvites (ListFamilyInvitesRequest) returns (ListFamilyInvitesResponse); rpc ListFamilyInvites (ListFamilyInvitesRequest) returns (ListFamilyInvitesResponse);
rpc GetFamilyPresence (GetFamilyGroupRequest) returns (FamilyPresenceResponse); rpc GetFamilyPresence (GetFamilyGroupRequest) returns (FamilyPresenceResponse);
rpc AddBotFatherToFamily (GetFamilyGroupRequest) returns (FamilyMemberResponse);
rpc AddBotToFamily (AddBotToFamilyRequest) returns (FamilyMemberResponse);
} }
message AuthorizeRequest { message AuthorizeRequest {
@@ -269,12 +267,6 @@ message FamilyInviteUserCandidate {
optional string ownerDisplayName = 10; optional string ownerDisplayName = 10;
} }
message AddBotToFamilyRequest {
string requesterId = 1;
string groupId = 2;
string botId = 3;
}
message SearchFamilyInviteUsersResponse { message SearchFamilyInviteUsersResponse {
repeated FamilyInviteUserCandidate users = 1; repeated FamilyInviteUserCandidate users = 1;
} }
@@ -345,6 +337,7 @@ message FamilyGroupResponse {
string updatedAt = 6; string updatedAt = 6;
repeated FamilyMemberResponse members = 7; repeated FamilyMemberResponse members = 7;
bool botFatherAvailable = 8; bool botFatherAvailable = 8;
bool getMyIdBotAvailable = 9;
} }
message ListFamilyGroupsResponse { message ListFamilyGroupsResponse {