тестово events события вместо telegram updates

This commit is contained in:
Artem Tsyrulnikov
2026-01-19 19:39:34 +03:00
parent 7f6b2ac336
commit f7ad52f543
3 changed files with 253 additions and 207 deletions

View File

@@ -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
class UnsubscriptionEventRequest(BaseModel):
type: Literal['unsubscription']
user_telegram_id: int
channel_telegram_id: int
log = logging.getLogger(__name__)
chat = event.get('chat', {})
if chat.get('type') not in ['channel', 'supergroup']:
return
class BotAddedEventRequest(dto.ConnectProjectInput):
type: Literal['bot_added']
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
class BotRemovedEventRequest(dto.DisconnectProjectByTgIdInput):
type: Literal['bot_removed']
class BotPermissionsEventRequest(dto.UpdateProjectPermissionsInput):
type: Literal['bot_permissions']
EventRequest = Annotated[
Union[
SubscriptionEventRequest,
UnsubscriptionEventRequest,
BotAddedEventRequest,
BotRemovedEventRequest,
BotPermissionsEventRequest,
],
Field(discriminator='type'),
]
@internal_router.post('/events')
async def handle_event(input: EventRequest) -> None:
if isinstance(input, SubscriptionEventRequest):
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'),
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,
)
async def _handle_chat_member_updated(event: dict[str, Any]) -> None:
"""Обработка изменения статуса участника канала (вступление/выход)"""
import logging
log = logging.getLogger(__name__)
chat = event.get('chat', {})
if chat.get('type') not in ['channel', 'supergroup']:
return
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', {})
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
# Проверяем что пользователь покинул канал
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
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'),
)
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)

View File

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

View File

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