From f7ad52f543de9d2d24d57fab3f9a258e32bbd6c0 Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Mon, 19 Jan 2026 19:39:34 +0300 Subject: [PATCH] =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=D0=BE=20ev?= =?UTF-8?q?ents=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D1=8F=20=D0=B2?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20telegram=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controller/http_v1/internal.py | 236 ++++++----------------------- tg_bot/backend/backend_client.go | 9 +- tg_bot/bot/bot.go | 215 ++++++++++++++++++++++++-- 3 files changed, 253 insertions(+), 207 deletions(-) diff --git a/src/controller/http_v1/internal.py b/src/controller/http_v1/internal.py index 6872a7c..458a9ab 100644 --- a/src/controller/http_v1/internal.py +++ b/src/controller/http_v1/internal.py @@ -1,7 +1,7 @@ -from typing import Any +from typing import Annotated, Literal, Union from fastapi.routing import APIRouter -from pydantic import BaseModel +from pydantic import BaseModel, Field from src import deps, dto @@ -58,210 +58,74 @@ async def attach_channel_to_workspace(input: AttachChannelToWorkspaceRequest) -> return await deps.get_usecase().attach_channel_to_workspace(input=input_data) -@internal_router.post('/telegram/updates') -async def handle_telegram_update(update: dict[str, Any]) -> None: - """Обрабатывает системные события от Golang бота (echotron формат)""" - import json - import logging - - log = logging.getLogger(__name__) - - # Логируем что пришло для отладки - log.info('Received update from Golang bot, keys: %s', list(update.keys())) - - # Обрабатываем ChatJoinRequest (запрос на вступление в канал) - if 'chat_join_request' in update: - log.info('ChatJoinRequest event: %s', json.dumps(update['chat_join_request'], ensure_ascii=False)) - await _handle_chat_join_request(update['chat_join_request']) - return - - # Обрабатываем ChatMember (пользователь вступил/вышел из канала) - if 'chat_member' in update: - event = update['chat_member'] - old_status = event.get('old_chat_member', {}).get('status') - new_status = event.get('new_chat_member', {}).get('status') - log.info('ChatMember event: status change %s -> %s', old_status, new_status) - await _handle_chat_member_updated(event) - return - - # Обрабатываем MyChatMember (бот добавлен/удален из канала) - if 'my_chat_member' in update: - event = update['my_chat_member'] - old_status = event.get('old_chat_member', {}).get('status') - new_status = event.get('new_chat_member', {}).get('status') - log.info('MyChatMember event: bot status changed %s -> %s in chat', old_status, new_status) - await _handle_my_chat_member(event) - return - - log.warning('Unknown update type from Golang bot: %s', list(update.keys())) +class SubscriptionEventRequest(BaseModel): + type: Literal['subscription'] + user_telegram_id: int + invite_link: str + username: str | None = None + first_name: str | None = None + last_name: str | None = None -async def _handle_chat_join_request(event: dict[str, Any]) -> None: - """Обработка запроса на вступление в канал через invite link""" - import logging - - log = logging.getLogger(__name__) - - chat = event.get('chat', {}) - if chat.get('type') not in ['channel', 'supergroup']: - return - - from_user = event.get('from') - if not from_user or not from_user.get('id'): - user_chat_id = event.get('user_chat_id') - if not user_chat_id: - log.error('No user or user_chat_id in chat_join_request event') - return - from_user = {'id': user_chat_id} - - invite_link_obj = event.get('invite_link', {}) - invite_link = invite_link_obj.get('invite_link') - if not invite_link: - log.debug('No invite_link in chat_join_request event') - return - - await deps.get_usecase().handle_subscription( - user_telegram_id=from_user['id'], - username=from_user.get('username'), - invite_link=invite_link, - first_name=from_user.get('first_name'), - last_name=from_user.get('last_name'), - ) +class UnsubscriptionEventRequest(BaseModel): + type: Literal['unsubscription'] + user_telegram_id: int + channel_telegram_id: int -async def _handle_chat_member_updated(event: dict[str, Any]) -> None: - """Обработка изменения статуса участника канала (вступление/выход)""" - import logging +class BotAddedEventRequest(dto.ConnectProjectInput): + type: Literal['bot_added'] - log = logging.getLogger(__name__) - chat = event.get('chat', {}) - if chat.get('type') not in ['channel', 'supergroup']: - return +class BotRemovedEventRequest(dto.DisconnectProjectByTgIdInput): + type: Literal['bot_removed'] - member_user = event.get('new_chat_member', {}).get('user') - if not member_user: - log.error('No user in chat_member_updated event') - return - old_member = event.get('old_chat_member', {}) - new_member = event.get('new_chat_member', {}) +class BotPermissionsEventRequest(dto.UpdateProjectPermissionsInput): + type: Literal['bot_permissions'] - old_status = old_member.get('status') - new_status = new_member.get('status') - # Проверяем что пользователь присоединился к каналу - was_not_member = old_status in ['left', 'kicked'] - is_now_member = new_status in ['member', 'administrator', 'creator'] - user_joined = was_not_member and is_now_member +EventRequest = Annotated[ + Union[ + SubscriptionEventRequest, + UnsubscriptionEventRequest, + BotAddedEventRequest, + BotRemovedEventRequest, + BotPermissionsEventRequest, + ], + Field(discriminator='type'), +] - # Проверяем что пользователь покинул канал - was_member = old_status in ['member', 'administrator', 'creator', 'restricted'] - is_now_not_member = new_status in ['left', 'kicked'] - user_left = was_member and is_now_not_member - - if user_joined: - invite_link_obj = event.get('invite_link', {}) - invite_link = invite_link_obj.get('invite_link') if invite_link_obj else None - if not invite_link: - log.debug('No invite_link in chat_member_updated event (user joined)') - return +@internal_router.post('/events') +async def handle_event(input: EventRequest) -> None: + if isinstance(input, SubscriptionEventRequest): await deps.get_usecase().handle_subscription( - user_telegram_id=member_user['id'], - username=member_user.get('username'), - invite_link=invite_link, - first_name=member_user.get('first_name'), - last_name=member_user.get('last_name'), + user_telegram_id=input.user_telegram_id, + username=input.username, + invite_link=input.invite_link, + first_name=input.first_name, + last_name=input.last_name, ) + return - elif user_left: + if isinstance(input, UnsubscriptionEventRequest): await deps.get_usecase().handle_unsubscription( - user_telegram_id=member_user['id'], - channel_telegram_id=chat['id'], + user_telegram_id=input.user_telegram_id, + channel_telegram_id=input.channel_telegram_id, ) - - -async def _handle_my_chat_member(event: dict[str, Any]) -> None: - """Обработка добавления/удаления бота из канала""" - import logging - - log = logging.getLogger(__name__) - - chat = event.get('chat', {}) - if chat.get('type') not in ['channel', 'supergroup']: return - from_user = event.get('from') - if not from_user: - log.error('No user in my_chat_member event') + if isinstance(input, BotAddedEventRequest): + data = dto.ConnectProjectInput(**input.dict(exclude={'type'})) + await deps.get_usecase().tg_add_project(input=data) return - old_member = event.get('old_chat_member', {}) - new_member = event.get('new_chat_member', {}) - - old_status = old_member.get('status') - new_status = new_member.get('status') - - # Проверяем что бот был удален из канала - was_member = old_status in ['administrator', 'member'] - is_now_not_member = new_status in ['left', 'kicked'] - bot_removed = was_member and is_now_not_member - - if bot_removed: - # Отключаем проект - disconnect_input = dto.DisconnectProjectByTgIdInput( - telegram_id=chat['id'], - user_telegram_id=from_user['id'], - ) - try: - await deps.get_usecase().disconnect_project_by_tg_id(input=disconnect_input) - log.info('Bot removed from channel %s by user %s', chat['id'], from_user['id']) - except Exception as e: - log.error('Failed to disconnect project: %s', e) + if isinstance(input, BotRemovedEventRequest): + data = dto.DisconnectProjectByTgIdInput(**input.dict(exclude={'type'})) + await deps.get_usecase().disconnect_project_by_tg_id(input=data) return - # Проверяем изменение прав администратора - permissions_changed = old_status == 'administrator' and new_status == 'administrator' - - # Извлекаем права бота - bot_permissions = dto.ChannelBotPermissions( - is_admin=(new_status == 'administrator'), - can_invite_users=new_member.get('can_invite_users', False), - can_restrict_members=new_member.get('can_restrict_members', False), - ) - - if permissions_changed: - # Обновляем права бота - permissions_input = dto.UpdateProjectPermissionsInput( - telegram_id=chat['id'], - permissions=bot_permissions, - chat_title=chat.get('title') or f'Channel {chat["id"]}', - user_telegram_id=from_user['id'], - ) - try: - await deps.get_usecase().update_project_permissions(input=permissions_input) - log.info('Bot permissions updated in channel %s', chat['id']) - except Exception as e: - log.error('Failed to update permissions: %s', e) - return - - # Проверяем что бот был добавлен в канал - was_not_member = old_status in ['left', 'kicked'] - is_now_member = new_status in ['administrator', 'member'] - bot_added = was_not_member and is_now_member - - if bot_added: - # Добавляем канал как проект - connect_input = dto.ConnectProjectInput( - telegram_id=chat['id'], - title=chat.get('title') or f'Channel {chat["id"]}', - username=chat.get('username'), - user_telegram_id=from_user['id'], - bot_permissions=bot_permissions, - ) - try: - await deps.get_usecase().tg_add_project(input=connect_input) - log.info('Bot added to channel %s by user %s', chat['id'], from_user['id']) - except Exception as e: - log.error('Failed to add project: %s', e) + if isinstance(input, BotPermissionsEventRequest): + data = dto.UpdateProjectPermissionsInput(**input.dict(exclude={'type'})) + await deps.get_usecase().update_project_permissions(input=data) diff --git a/tg_bot/backend/backend_client.go b/tg_bot/backend/backend_client.go index caa0f3c..6a79496 100644 --- a/tg_bot/backend/backend_client.go +++ b/tg_bot/backend/backend_client.go @@ -302,15 +302,12 @@ func (c *Client) UpdateProjectInviteLinkType( return &project, err } -func (c *Client) ForwardTelegramUpdate( - ctx context.Context, - update any, -) error { +func (c *Client) SendEvent(ctx context.Context, payload any) error { return c.do( ctx, http.MethodPost, - "api/v1/internal/telegram/updates", - update, + "api/v1/internal/events", + payload, nil, ) } diff --git a/tg_bot/bot/bot.go b/tg_bot/bot/bot.go index 3f447c1..6dff63e 100644 --- a/tg_bot/bot/bot.go +++ b/tg_bot/bot/bot.go @@ -2,7 +2,6 @@ package bot import ( "context" - "encoding/json" "fmt" "strings" "sync" @@ -118,6 +117,204 @@ func (b *Bot) SetState(s State, mode RenderMode) { s.Enter(b, mode) } +func (b *Bot) forwardSystemEvent(u *echotron.Update) { + ctx := context.Background() + + switch { + case u.ChatJoinRequest != nil: + req := u.ChatJoinRequest + if !isChannelChat(req.Chat.Type) { + return + } + + inviteLink := "" + if req.InviteLink != nil { + inviteLink = req.InviteLink.InviteLink + } + if inviteLink == "" { + log.Debug().Msg("No invite link in chat join request") + return + } + + userID := req.From.ID + username := stringPtr(req.From.Username) + firstName := stringPtr(req.From.FirstName) + lastName := stringPtr(req.From.LastName) + if userID == 0 && req.UserChatID != 0 { + userID = req.UserChatID + } + if userID == 0 { + log.Error().Msg("No user id in chat join request") + return + } + + payload := map[string]any{ + "type": "subscription", + "user_telegram_id": userID, + "invite_link": inviteLink, + "username": username, + "first_name": firstName, + "last_name": lastName, + } + if err := b.Backend.SendEvent(ctx, payload); err != nil { + log.Error().Err(err).Msg("Failed to send subscription event") + } + + case u.ChatMember != nil: + event := u.ChatMember + if !isChannelChat(event.Chat.Type) { + return + } + + memberUser := event.NewChatMember.User + if memberUser == nil { + log.Error().Msg("No user in chat member update") + return + } + + oldStatus := event.OldChatMember.Status + newStatus := event.NewChatMember.Status + + wasNotMember := oldStatus == "left" || oldStatus == "kicked" + isNowMember := newStatus == "member" || newStatus == "administrator" || newStatus == "creator" + userJoined := wasNotMember && isNowMember + + wasMember := oldStatus == "member" || oldStatus == "administrator" || oldStatus == "creator" || oldStatus == "restricted" + isNowNotMember := newStatus == "left" || newStatus == "kicked" + userLeft := wasMember && isNowNotMember + + if userJoined { + inviteLink := "" + if event.InviteLink != nil { + inviteLink = event.InviteLink.InviteLink + } + if inviteLink == "" { + log.Debug().Msg("No invite link in chat member update") + return + } + + username := stringPtr(memberUser.Username) + firstName := stringPtr(memberUser.FirstName) + lastName := stringPtr(memberUser.LastName) + payload := map[string]any{ + "type": "subscription", + "user_telegram_id": memberUser.ID, + "invite_link": inviteLink, + "username": username, + "first_name": firstName, + "last_name": lastName, + } + if err := b.Backend.SendEvent(ctx, payload); err != nil { + log.Error().Err(err).Msg("Failed to send subscription event") + } + return + } + + if userLeft { + payload := map[string]any{ + "type": "unsubscription", + "user_telegram_id": memberUser.ID, + "channel_telegram_id": event.Chat.ID, + } + if err := b.Backend.SendEvent(ctx, payload); err != nil { + log.Error().Err(err).Msg("Failed to send unsubscription event") + } + } + + case u.MyChatMember != nil: + event := u.MyChatMember + if !isChannelChat(event.Chat.Type) { + return + } + + actorID := event.From.ID + if actorID == 0 { + log.Error().Msg("No user in my chat member update") + return + } + + oldStatus := event.OldChatMember.Status + newStatus := event.NewChatMember.Status + + wasMember := oldStatus == "administrator" || oldStatus == "member" + isNowNotMember := newStatus == "left" || newStatus == "kicked" + botRemoved := wasMember && isNowNotMember + + permissionsChanged := oldStatus == "administrator" && newStatus == "administrator" + + if botRemoved { + payload := map[string]any{ + "type": "bot_removed", + "telegram_id": event.Chat.ID, + "user_telegram_id": actorID, + } + if err := b.Backend.SendEvent(ctx, payload); err != nil { + log.Error().Err(err).Msg("Failed to send bot removed event") + } + return + } + + if permissionsChanged { + title := event.Chat.Title + if title == "" { + title = fmt.Sprintf("Channel %d", event.Chat.ID) + } + payload := map[string]any{ + "type": "bot_permissions", + "telegram_id": event.Chat.ID, + "chat_title": title, + "user_telegram_id": actorID, + "permissions": map[string]any{ + "is_admin": newStatus == "administrator", + "can_invite_users": event.NewChatMember.CanInviteUsers, + "can_restrict_members": event.NewChatMember.CanRestrictMembers, + }, + } + if err := b.Backend.SendEvent(ctx, payload); err != nil { + log.Error().Err(err).Msg("Failed to send bot permissions event") + } + return + } + + wasNotMember := oldStatus == "left" || oldStatus == "kicked" + isNowMember := newStatus == "administrator" || newStatus == "member" + botAdded := wasNotMember && isNowMember + + if botAdded { + title := event.Chat.Title + if title == "" { + title = fmt.Sprintf("Channel %d", event.Chat.ID) + } + payload := map[string]any{ + "type": "bot_added", + "telegram_id": event.Chat.ID, + "title": title, + "username": stringPtr(event.Chat.Username), + "user_telegram_id": actorID, + "bot_permissions": map[string]any{ + "is_admin": newStatus == "administrator", + "can_invite_users": event.NewChatMember.CanInviteUsers, + "can_restrict_members": event.NewChatMember.CanRestrictMembers, + }, + } + if err := b.Backend.SendEvent(ctx, payload); err != nil { + log.Error().Err(err).Msg("Failed to send bot added event") + } + } + } +} + +func isChannelChat(chatType string) bool { + return chatType == "channel" || chatType == "supergroup" +} + +func stringPtr(value string) *string { + if value == "" { + return nil + } + return &value +} + func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode RenderMode) { if mode == EditMessage { b.Edit(text, keyboard) @@ -238,21 +435,9 @@ func (b *Bot) Update(u *echotron.Update) { Bool("has_edited_message", u.EditedMessage != nil). Msg("Update received") - // 1. Системные события (ChatJoinRequest, ChatMember, etc.) -> форвардим асинхронно + // 1. Системные события -> отправляем доменные события в backend if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil { - go func() { - ctx := context.Background() - raw, err := json.Marshal(u) - if err != nil { - log.Error().RawJSON("update", raw).Msg("Failed to marshal update") - } - err = b.Backend.ForwardTelegramUpdate(ctx, u) - if err != nil { - log.Error().Err(err).Msg("Failed to forward system event to backend") - return - } - log.Info().Msg("System event forwarded to backend") - }() + go b.forwardSystemEvent(u) return }