global fix and update bot Api
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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()
|
||||
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<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')
|
||||
@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(
|
||||
@Req() request: HttpRequest,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
@Param('botPath') botPath: string,
|
||||
@Param('method') method: string,
|
||||
@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')) {
|
||||
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,
|
||||
|
||||
@@ -99,6 +99,11 @@ export class ToggleMessageReactionDto {
|
||||
emoji!: string;
|
||||
}
|
||||
|
||||
export class SetMessagePinnedDto {
|
||||
@IsBoolean()
|
||||
pinned!: boolean;
|
||||
}
|
||||
|
||||
export class ForwardMessagesDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
|
||||
@@ -154,9 +154,3 @@ export class RespondFamilyInviteDto {
|
||||
accept!: boolean;
|
||||
}
|
||||
|
||||
export class AddBotToFamilyDto {
|
||||
@ApiProperty({ description: 'ID бота для добавления в семью' })
|
||||
@IsString({ message: 'ID бота должен быть строкой' })
|
||||
@IsNotEmpty({ message: 'Укажите ID бота' })
|
||||
botId!: string;
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -213,6 +213,11 @@ export function BotManageMiniAppContent({
|
||||
showToast('Токен скопирован');
|
||||
}
|
||||
|
||||
async function copyBotId() {
|
||||
await navigator.clipboard.writeText(botId);
|
||||
showToast('bot_id скопирован');
|
||||
}
|
||||
|
||||
if (!botId) {
|
||||
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>
|
||||
</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">
|
||||
<SectionCard
|
||||
icon={TextQuote}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'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 {
|
||||
ContextMenu,
|
||||
@@ -24,6 +24,7 @@ interface ChatMessageContextMenuProps {
|
||||
onDelete?: () => 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({
|
||||
Переслать
|
||||
</ContextMenuItem>
|
||||
) : 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 ? (
|
||||
<ContextMenuItem
|
||||
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 {
|
||||
selected?: boolean;
|
||||
selectionMode?: boolean;
|
||||
|
||||
@@ -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({
|
||||
<div key={message.id} className={cn('flex', mine ? 'justify-end' : 'justify-start')}>
|
||||
<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')}>
|
||||
{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}
|
||||
<p className="whitespace-pre-wrap text-sm">{message.text || '…'}</p>
|
||||
{!mine && hasKeyboard && message.messageId > 0 ? (
|
||||
|
||||
@@ -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 ? (
|
||||
<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}
|
||||
{forwardedFrom ? (
|
||||
<p className="mb-1 text-xs font-medium text-[#3390ec]">↪️ Переслано от {forwardedFrom.senderName}</p>
|
||||
@@ -1648,14 +1689,26 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
/>
|
||||
) : null}
|
||||
{selectionMode ? (
|
||||
<SelectableMessageWrapper
|
||||
selected={isSelected}
|
||||
selectionMode
|
||||
align={mine ? 'right' : 'left'}
|
||||
onClick={() => toggleSelectedMessage(message.id)}
|
||||
<ChatSelectionContextMenu
|
||||
count={selectedMessageIds.length}
|
||||
canPin={selectedMessageIds.length === 1}
|
||||
pinned={getSelectedMessages()[0]?.isPinned}
|
||||
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
|
||||
message={message}
|
||||
@@ -1675,6 +1728,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
setSelectedMessageIds([message.id]);
|
||||
setForwardDialogOpen(true);
|
||||
}}
|
||||
onTogglePin={() => void handleTogglePin(message)}
|
||||
onSelect={() => enterSelectionMode(message.id)}
|
||||
onToggleReaction={(emoji) => void handleToggleReaction(message.id, emoji)}
|
||||
onCopy={() => showToast('Текст скопирован')}
|
||||
|
||||
@@ -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<ManagedBot[]>([]);
|
||||
const [myBotsLoading, setMyBotsLoading] = useState(false);
|
||||
const [addingBotId, setAddingBotId] = useState<string | null>(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({
|
||||
<button
|
||||
key={bot.id}
|
||||
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] disabled:opacity-60"
|
||||
onClick={() => void addBotById(bot.id, bot.name)}
|
||||
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]"
|
||||
onClick={() => setInviteQuery(`@${bot.username}`)}
|
||||
>
|
||||
<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 className="min-w-0">
|
||||
<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>
|
||||
</button>
|
||||
))
|
||||
@@ -211,7 +188,7 @@ export function FamilyInviteDialog({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[#667085]">Или найдите пользователя / бота другого владельца через поиск выше</p>
|
||||
<p className="mt-2 text-xs text-[#667085]">Выберите своего бота, затем подтвердите приглашение в результатах поиска</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 max-h-56 overflow-y-auto rounded-2xl border border-[#eceef4]">
|
||||
|
||||
@@ -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() {
|
||||
</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 className="rounded-xl bg-[#f4f5f8] px-2 py-3 text-[11px] text-[#667085]">
|
||||
|
||||
@@ -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 ? (
|
||||
<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 ? (
|
||||
<p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">{message.senderName}</p>
|
||||
) : null}
|
||||
@@ -1045,14 +1081,26 @@ export function MiniFamilyChat() {
|
||||
/>
|
||||
) : null}
|
||||
{selectionMode ? (
|
||||
<SelectableMessageWrapper
|
||||
selected={isSelected}
|
||||
selectionMode
|
||||
align={mine ? 'right' : 'left'}
|
||||
onClick={() => toggleSelectedMessage(message.id)}
|
||||
<ChatSelectionContextMenu
|
||||
count={selectedMessageIds.length}
|
||||
canPin={selectedMessageIds.length === 1}
|
||||
pinned={getSelectedMessages()[0]?.isPinned}
|
||||
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
|
||||
message={message}
|
||||
@@ -1065,6 +1113,7 @@ export function MiniFamilyChat() {
|
||||
}}
|
||||
onDelete={() => requestDeleteMessages([message.id])}
|
||||
onForward={() => showToast('Пересылка доступна в полном виде семьи')}
|
||||
onTogglePin={() => void handleTogglePin(message)}
|
||||
onSelect={() => enterSelectionMode(message.id)}
|
||||
onToggleReaction={async (emoji) => {
|
||||
if (!token) return;
|
||||
|
||||
@@ -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<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) {
|
||||
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<ChatMessage>(`/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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user