global update and global fix

This commit is contained in:
lendry
2026-06-25 23:48:57 +03:00
parent 3880c68d59
commit b0ea87e898
121 changed files with 13759 additions and 663 deletions

View File

@@ -0,0 +1,68 @@
import { Body, Controller, ForbiddenException, Get, Param, Patch, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
import { firstValueFrom } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
import { CurrentAdmin } from '../decorators/current-admin.decorator';
import { ListAdminBotsQueryDto, SetBotActiveDto } from '../dto/bot.dto';
import { AdminGuard, AdminRequestUser } from '../guards/admin.guard';
function assertManageAllBots(admin: AdminRequestUser) {
if (admin.isSuperAdmin || admin.permissions.includes('bots.manage.all')) {
return;
}
throw new ForbiddenException('Недостаточно прав для управления всеми ботами');
}
@ApiTags('Администрирование ботов')
@ApiBearerAuth()
@UseGuards(AdminGuard)
@Controller('admin/bots')
export class AdminBotController {
constructor(private readonly core: CoreGrpcService) {}
@Get()
@ApiOperation({ summary: 'Список всех ботов', description: 'Административный список Telegram-ботов с поиском и пагинацией.' })
listAllBots(@Query() query: ListAdminBotsQueryDto, @CurrentAdmin() admin: AdminRequestUser) {
assertManageAllBots(admin);
return firstValueFrom(
this.core.bot.ListAllBots({
requesterId: admin.id,
isSuperAdmin: admin.isSuperAdmin,
search: query.search,
page: query.page,
limit: query.limit
})
);
}
@Get('metrics')
@ApiOperation({ summary: 'Метрики ботов', description: 'Сводная статистика по ботам системы.' })
getMetrics(@CurrentAdmin() admin: AdminRequestUser) {
assertManageAllBots(admin);
return firstValueFrom(this.core.bot.GetBotMetrics({ requesterId: admin.id, isSuperAdmin: admin.isSuperAdmin }));
}
@Get(':botId')
@ApiOperation({ summary: 'Получить бота (админ)', description: 'Возвращает любого бота по ID.' })
getBot(@Param('botId') botId: string, @CurrentAdmin() admin: AdminRequestUser) {
assertManageAllBots(admin);
return firstValueFrom(
this.core.bot.GetBot({ requesterId: admin.id, botId, isSuperAdmin: admin.isSuperAdmin })
);
}
@Patch(':botId/active')
@ApiOperation({ summary: 'Заблокировать или разблокировать бота', description: 'Анти-abuse: отключает Bot API токен.' })
@ApiBody({ type: SetBotActiveDto })
setActive(@Param('botId') botId: string, @Body() dto: SetBotActiveDto, @CurrentAdmin() admin: AdminRequestUser) {
assertManageAllBots(admin);
return firstValueFrom(
this.core.bot.SetBotActive({
requesterId: admin.id,
botId,
isActive: dto.isActive,
isSuperAdmin: admin.isSuperAdmin
})
);
}
}

View File

@@ -0,0 +1,159 @@
import { Body, Controller, Delete, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtService } from '@nestjs/jwt';
import { firstValueFrom } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
import { CreateBotDto, SetBotWebAppDto, SubmitBotCallbackDto, SubmitBotMessageDto, UpdateBotDto, UpdateBotProfileDto, ValidateWebAppInitDataDto } from '../dto/bot.dto';
import { getAuthorizedUserId } from '../document-access';
@ApiTags('BotFather')
@ApiBearerAuth()
@Controller('bots')
export class BotController {
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
private async auth(authorization?: string) {
return getAuthorizedUserId(this.jwt, this.core, authorization);
}
@Get()
@ApiOperation({ summary: 'Список моих ботов', description: 'Возвращает Telegram-ботов текущего пользователя.' })
listMyBots(@Headers('authorization') authorization?: string) {
return this.auth(authorization).then((userId) => firstValueFrom(this.core.bot.ListMyBots({ ownerId: userId })));
}
@Post()
@ApiOperation({ summary: 'Создать бота', description: 'Регистрирует нового бота и возвращает токен Bot API.' })
@ApiBody({ type: CreateBotDto })
createBot(@Headers('authorization') authorization: string | undefined, @Body() dto: CreateBotDto) {
return this.auth(authorization).then((ownerId) =>
firstValueFrom(this.core.bot.CreateBot({ ownerId, name: dto.name, username: dto.username }))
);
}
@Get('by-username/:botRef/messages')
@ApiOperation({ summary: 'История чата с ботом', description: 'Возвращает сообщения пользователя с ботом в хронологическом порядке.' })
listBotMessages(@Headers('authorization') authorization: string | undefined, @Param('botRef') botRef: string) {
return this.auth(authorization).then((userId) =>
firstValueFrom(this.core.bot.ListBotChatMessages({ userId, botRef }))
);
}
@Post('by-username/:botRef/messages')
@ApiOperation({
summary: 'Написать боту',
description: 'Публикует inbound-событие в RabbitMQ для доставки боту через webhook или getUpdates.'
})
@ApiBody({ type: SubmitBotMessageDto })
submitMessage(
@Headers('authorization') authorization: string | undefined,
@Param('botRef') botRef: string,
@Body() dto: SubmitBotMessageDto
) {
return this.auth(authorization).then((senderUserId) =>
firstValueFrom(this.core.bot.SubmitBotInboundMessage({ senderUserId, botRef, text: dto.text }))
);
}
@Post('by-username/:botRef/callback')
@ApiOperation({
summary: 'Нажатие inline-кнопки',
description: 'Публикует callback_query Update для webhook или getUpdates.'
})
@ApiBody({ type: SubmitBotCallbackDto })
submitCallback(
@Headers('authorization') authorization: string | undefined,
@Param('botRef') botRef: string,
@Body() dto: SubmitBotCallbackDto
) {
return this.auth(authorization).then((senderUserId) =>
firstValueFrom(
this.core.bot.SubmitBotCallbackQuery({
senderUserId,
botRef,
messageId: dto.messageId,
callbackData: dto.callbackData
})
)
);
}
@Post('web-app/validate')
@ApiOperation({
summary: 'Проверить initData Mini App',
description: 'Валидирует Telegram Web App initData по HMAC-SHA256, как в официальном Bot API.'
})
@ApiBody({ type: ValidateWebAppInitDataDto })
validateWebApp(@Body() dto: ValidateWebAppInitDataDto) {
return firstValueFrom(this.core.bot.ValidateWebAppInitData(dto));
}
@Get(':botId')
@ApiOperation({ summary: 'Получить бота', description: 'Возвращает настройки бота, принадлежащего пользователю.' })
getBot(@Headers('authorization') authorization: string | undefined, @Param('botId') botId: string) {
return this.auth(authorization).then((requesterId) =>
firstValueFrom(this.core.bot.GetBot({ requesterId, botId, isSuperAdmin: false }))
);
}
@Patch(':botId')
@ApiOperation({ summary: 'Обновить бота', description: 'Изменяет имя или username бота.' })
@ApiBody({ type: UpdateBotDto })
updateBot(
@Headers('authorization') authorization: string | undefined,
@Param('botId') botId: string,
@Body() dto: UpdateBotDto
) {
return this.auth(authorization).then((requesterId) =>
firstValueFrom(this.core.bot.UpdateBot({ requesterId, botId, ...dto, isSuperAdmin: false }))
);
}
@Delete(':botId')
@ApiOperation({ summary: 'Удалить бота', description: 'Удаляет бота и все связанные чаты/сообщения.' })
deleteBot(@Headers('authorization') authorization: string | undefined, @Param('botId') botId: string) {
return this.auth(authorization).then((requesterId) =>
firstValueFrom(this.core.bot.DeleteBot({ requesterId, botId, isSuperAdmin: false }))
);
}
@Post(':botId/revoke-token')
@ApiOperation({ summary: 'Перевыпустить токен', description: 'Инвалидирует старый токен и возвращает новый.' })
revokeToken(@Headers('authorization') authorization: string | undefined, @Param('botId') botId: string) {
return this.auth(authorization).then((requesterId) =>
firstValueFrom(this.core.bot.RevokeBotToken({ requesterId, botId, isSuperAdmin: false }))
);
}
@Patch(':botId/profile')
@ApiOperation({
summary: 'Профиль бота',
description: 'Обновляет description, aboutText, botPicUrl и глобальную кнопку меню (Web App).'
})
@ApiBody({ type: UpdateBotProfileDto })
updateBotProfile(
@Headers('authorization') authorization: string | undefined,
@Param('botId') botId: string,
@Body() dto: UpdateBotProfileDto
) {
return this.auth(authorization).then((requesterId) =>
firstValueFrom(this.core.bot.UpdateBotProfile({ requesterId, botId, ...dto, isSuperAdmin: false }))
);
}
@Patch(':botId/web-app')
@ApiOperation({ summary: 'Настроить Mini App', description: 'Привязывает URL Web App к боту.' })
@ApiBody({ type: SetBotWebAppDto })
setWebApp(
@Headers('authorization') authorization: string | undefined,
@Param('botId') botId: string,
@Body() dto: SetBotWebAppDto
) {
return this.auth(authorization).then((requesterId) =>
firstValueFrom(this.core.bot.SetBotWebApp({ requesterId, botId, webAppUrl: dto.webAppUrl, isSuperAdmin: false }))
);
}
}

View File

@@ -8,10 +8,13 @@ import { getAuthorizedUserId } from '../document-access';
import {
AddChatRoomMemberDto,
CreateChatRoomDto,
CreateE2EChatRoomDto,
EditChatMessageDto,
ForwardMessagesDto,
MarkRoomReadDto,
SendChatMessageDto,
SetRoomNotificationsMutedDto,
ToggleMessageReactionDto,
UpdateChatRoomDto,
VotePollDto
} from '../dto/chat.dto';
@@ -56,6 +59,17 @@ export class ChatController {
);
}
@Post('groups/:groupId/e2e-rooms')
@ApiOperation({ summary: 'Создать секретный E2E чат' })
async createE2ERoom(
@Headers('authorization') authorization: string | undefined,
@Param('groupId') groupId: string,
@Body() dto: CreateE2EChatRoomDto
) {
const userId = await this.auth(authorization);
return firstValueFrom(this.core.chat.CreateE2ERoom({ userId, groupId, peerUserId: dto.peerUserId }));
}
@Patch('rooms/:roomId')
@ApiOperation({ summary: 'Настройки чата' })
async updateRoom(
@@ -138,7 +152,8 @@ export class ChatController {
storageKey: dto.storageKey,
mimeType: dto.mimeType,
metadataJson: dto.metadataJson,
poll: dto.poll
poll: dto.poll,
isEncrypted: dto.isEncrypted
})
);
}
@@ -183,6 +198,35 @@ export class ChatController {
return firstValueFrom(this.core.chat.DeleteMessage({ userId, messageId }));
}
@Post('messages/:messageId/reactions')
@ApiOperation({ summary: 'Поставить или снять реакцию' })
async toggleReaction(
@Headers('authorization') authorization: string | undefined,
@Param('messageId') messageId: string,
@Body() dto: ToggleMessageReactionDto
) {
const userId = await this.auth(authorization);
return firstValueFrom(this.core.chat.ToggleMessageReaction({ userId, messageId, emoji: dto.emoji }));
}
@Post('rooms/:roomId/forward')
@ApiOperation({ summary: 'Переслать сообщения в другой чат' })
async forwardMessages(
@Headers('authorization') authorization: string | undefined,
@Param('roomId') roomId: string,
@Body() dto: ForwardMessagesDto
) {
const userId = await this.auth(authorization);
return firstValueFrom(
this.core.chat.ForwardMessages({ userId, targetRoomId: roomId, messageIds: dto.messageIds }).pipe(
map((response) => {
const payload = response as { messages?: unknown[] };
return { messages: payload.messages ?? [] };
})
)
);
}
@Post('rooms/:roomId/read')
@ApiOperation({ summary: 'Отметить сообщения чата прочитанными' })
async markRoomRead(
@@ -195,4 +239,21 @@ export class ChatController {
this.core.chat.MarkRoomRead({ userId, roomId, lastMessageId: dto.lastMessageId })
);
}
@Post('rooms/:roomId/typing')
@ApiOperation({ summary: 'Сообщить о наборе текста в чате' })
async reportTyping(
@Headers('authorization') authorization: string | undefined,
@Param('roomId') roomId: string
) {
const userId = await this.auth(authorization);
return firstValueFrom(this.core.chat.ReportTyping({ userId, roomId }));
}
@Delete('rooms/:roomId')
@ApiOperation({ summary: 'Удалить чат' })
async deleteRoom(@Headers('authorization') authorization: string | undefined, @Param('roomId') roomId: string) {
const userId = await this.auth(authorization);
return firstValueFrom(this.core.chat.DeleteRoom({ userId, roomId }));
}
}

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, CreateFamilyGroupDto, RespondFamilyInviteDto, SendFamilyInviteDto, UpdateFamilyGroupDto } from '../dto/identity.dto';
import { AddFamilyMemberDto, AddBotToFamilyDto, CreateFamilyGroupDto, RespondFamilyInviteDto, SendFamilyInviteDto, UpdateFamilyGroupDto } from '../dto/identity.dto';
@ApiTags('Семья')
@ApiBearerAuth()
@@ -151,5 +151,24 @@ export class FamilyController {
const requesterId = await this.auth(authorization);
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,31 +1,129 @@
import { Body, Controller, Get, Headers, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, Headers, Post, Query, Res, UnauthorizedException, UsePipes, ValidationPipe } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { firstValueFrom } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
import { OAuthAuthorizeQueryDto, OAuthTokenDto } from '../dto/identity.dto';
import { OAuthAuthorizeIncomingDto, OAuthTokenIncomingDto } from '../dto/oauth.dto';
import { extractBearerToken } from '../auth-token';
import { appendQueryParams, mergeTokenCredentials, mapTokenResponseToStandard, normalizeAuthorizeQuery, normalizeTokenBody } from '../lib/oauth-params';
import { resolveFrontendUrl } from '../lib/oauth-issuer';
import { verifyAccessToken } from '../session-auth';
type HttpResponse = {
redirect(status: number, url: string): void;
};
const oauthValidationPipe = new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: false
});
@ApiTags('OAuth 2.0')
@Controller('oauth')
export class OAuthController {
constructor(private readonly core: CoreGrpcService) {}
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
@Get('authorize')
@ApiOperation({ summary: 'OAuth авторизация', description: 'Создает authorization_code и возвращает redirectUrl для OAuth клиента. Consent считается подтвержденным для переданного userId.' })
@ApiQuery({ name: 'userId', description: 'ID пользователя' })
@ApiQuery({ name: 'clientId', description: 'OAuth client_id' })
@ApiQuery({ name: 'redirectUri', description: 'redirect_uri' })
@ApiQuery({ name: 'scope', description: 'Scopes через пробел' })
@ApiResponse({ status: 200, description: 'Authorization code создан' })
authorize(@Query() query: OAuthAuthorizeQueryDto) {
return this.core.oauth.Authorize(query);
@UsePipes(oauthValidationPipe)
@ApiOperation({
summary: 'OAuth / OIDC авторизация',
description:
'Поддерживает стандартные query-параметры RFC 6749 / OIDC (client_id, redirect_uri, response_type, code_challenge) и legacy camelCase. ' +
'Без userId и без Bearer-токена перенаправляет на страницу подтверждения доступа.'
})
@ApiQuery({ name: 'client_id', required: false, description: 'OAuth client_id (RFC 6749)' })
@ApiQuery({ name: 'clientId', required: false, description: 'OAuth client_id (legacy)' })
@ApiQuery({ name: 'redirect_uri', required: false })
@ApiQuery({ name: 'redirectUri', required: false })
@ApiQuery({ name: 'scope', required: false })
@ApiQuery({ name: 'state', required: false })
@ApiQuery({ name: 'response_type', required: false, example: 'code' })
@ApiQuery({ name: 'code_challenge', required: false })
@ApiQuery({ name: 'code_challenge_method', required: false })
@ApiQuery({ name: 'userId', required: false, description: 'Legacy: ID пользователя после входа' })
@ApiResponse({ status: 302, description: 'Redirect на redirect_uri с authorization code' })
async authorize(
@Query() query: OAuthAuthorizeIncomingDto,
@Headers('authorization') authorization: string | undefined,
@Headers('accept') acceptHeader: string | undefined,
@Res({ passthrough: true }) res: HttpResponse
) {
const normalized = normalizeAuthorizeQuery(query as Record<string, unknown>);
let userId = normalized.userId;
if (!userId && authorization) {
try {
const payload = await verifyAccessToken(this.jwt, authorization);
userId = payload.sub;
} catch {
// handled below
}
}
if (!userId) {
const frontendUrl = await resolveFrontendUrl(this.core);
const consentUrl = appendQueryParams(`${frontendUrl}/auth/oauth/authorize`, query as Record<string, unknown>);
res.redirect(302, consentUrl);
return;
}
const result = (await firstValueFrom(
this.core.oauth.Authorize({
userId,
clientId: normalized.clientId,
redirectUri: normalized.redirectUri,
scope: normalized.scope,
state: normalized.state
})
)) as { redirectUrl?: string; code?: string; state?: string };
const accept = acceptHeader ?? '';
const wantsJson = authorization?.startsWith('Bearer') || accept.includes('application/json');
if (wantsJson) {
return result;
}
if (!result.redirectUrl) {
throw new UnauthorizedException('Не удалось создать authorization code');
}
res.redirect(302, result.redirectUrl);
}
@Post('token')
@ApiOperation({ summary: 'Выдать OAuth токены', description: 'Поддерживает grant_type=authorization_code и grant_type=refresh_token.' })
@ApiBody({ type: OAuthTokenDto })
@UsePipes(oauthValidationPipe)
@ApiOperation({
summary: 'Выдать OAuth токены',
description:
'Поддерживает grant_type=authorization_code и grant_type=refresh_token. ' +
'Принимает application/x-www-form-urlencoded (RFC 6749) и JSON. ' +
'Ответ — snake_case: access_token, token_type, expires_in, refresh_token, id_token.'
})
@ApiBody({ type: OAuthTokenIncomingDto })
@ApiResponse({ status: 201, description: 'OAuth токены выданы' })
token(@Body() dto: OAuthTokenDto) {
return this.core.oauth.Token(dto);
async token(@Body() body: OAuthTokenIncomingDto, @Headers('authorization') authorization?: string) {
const normalized = mergeTokenCredentials(normalizeTokenBody(body as Record<string, unknown>), authorization);
const result = (await firstValueFrom(
this.core.oauth.Token({
grantType: normalized.grantType,
code: normalized.code,
refreshToken: normalized.refreshToken,
clientId: normalized.clientId,
clientSecret: normalized.clientSecret,
redirectUri: normalized.redirectUri
})
)) as {
accessToken?: string;
tokenType?: string;
expiresIn?: number;
refreshToken?: string;
idToken?: string;
};
return mapTokenResponseToStandard(result);
}
@Get('userinfo')

View File

@@ -139,5 +139,22 @@ export class ProfileController {
await this.assertSelfAccess(authorization, userId);
return this.core.profile.GetAccountDeletionStatus({ userId });
}
@Get('e2e-public-key')
@ApiOperation({ summary: 'Получить публичный E2E-ключ пользователя' })
async getE2EPublicKey(@Param('userId') userId: string) {
return this.core.profile.GetE2EPublicKey({ userId });
}
@Patch('e2e-public-key')
@ApiOperation({ summary: 'Сохранить свой публичный E2E-ключ' })
async setE2EPublicKey(
@Headers('authorization') authorization: string | undefined,
@Param('userId') userId: string,
@Body() dto: { publicKey: string }
) {
await this.assertSelfAccess(authorization, userId);
return this.core.profile.SetE2EPublicKey({ userId, publicKey: dto.publicKey });
}
}

View File

@@ -0,0 +1,59 @@
import { All, Body, Controller, HttpCode, Param, Req, Res } from '@nestjs/common';
import { ApiExcludeController } from '@nestjs/swagger';
import { firstValueFrom, timeout } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
type HttpRequest = {
method: string;
query: Record<string, unknown>;
};
type HttpResponse = {
status(code: number): HttpResponse;
setHeader(name: string, value: string): void;
};
@ApiExcludeController()
@Controller()
export class TelegramBotApiController {
constructor(private readonly core: CoreGrpcService) {}
@All(':botPath/:method')
@HttpCode(200)
async handleTelegramMethod(
@Req() request: HttpRequest,
@Res({ passthrough: true }) response: HttpResponse,
@Param('botPath') botPath: string,
@Param('method') method: string,
@Body() body: Record<string, unknown>
) {
if (!botPath.startsWith('bot')) {
response.status(404);
return { ok: false, error_code: 404, description: 'Not Found' };
}
const token = decodeURIComponent(botPath.slice(3));
if (!token) {
response.status(401);
return { ok: false, error_code: 401, description: 'Unauthorized' };
}
const payload = request.method === 'GET' ? { ...request.query } : body ?? {};
const grpcCall = this.core.bot.ExecuteBotMethod({
token,
method,
payloadJson: JSON.stringify(payload)
});
const waitSeconds = method === 'getUpdates' ? Number(payload.timeout ?? 0) : 0;
const grpcTimeoutMs = method === 'getUpdates' ? Math.min(Math.max(waitSeconds, 0), 50) * 1000 + 10_000 : 30_000;
const result = (await firstValueFrom(
grpcCall.pipe(timeout(grpcTimeoutMs)) as typeof grpcCall
)) as { responseJson: string; httpStatus: number };
response.status(result.httpStatus ?? 200);
response.setHeader('Content-Type', 'application/json');
return JSON.parse(result.responseJson);
}
}