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,
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(

View File

@@ -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 }));
}
}

View File

@@ -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,

View File

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

View File

@@ -154,9 +154,3 @@ export class RespondFamilyInviteDto {
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: 'Создатель может удалить участника; участник может удалить себя («Выйти»). Владельца семьи удалить нельзя.',
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 }
]
},
{

View File

@@ -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

View File

@@ -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',

View File

@@ -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}

View File

@@ -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;

View File

@@ -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 ? (

View File

@@ -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,6 +1689,17 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
/>
) : null}
{selectionMode ? (
<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}
>
<SelectableMessageWrapper
selected={isSelected}
selectionMode
@@ -1656,6 +1708,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
>
{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('Текст скопирован')}

View File

@@ -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 добавлен в семью');
}
} 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]">

View File

@@ -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]">

View File

@@ -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,6 +1081,17 @@ export function MiniFamilyChat() {
/>
) : null}
{selectionMode ? (
<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}
>
<SelectableMessageWrapper
selected={isSelected}
selectionMode
@@ -1053,6 +1100,7 @@ export function MiniFamilyChat() {
>
{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;

View File

@@ -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',

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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 },

View File

@@ -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() {

View File

@@ -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 {

View File

@@ -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
};
}

View File

@@ -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
)
};

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {