тестово 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 fastapi.routing import APIRouter
from pydantic import BaseModel from pydantic import BaseModel, Field
from src import deps, dto 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) return await deps.get_usecase().attach_channel_to_workspace(input=input_data)
@internal_router.post('/telegram/updates') class SubscriptionEventRequest(BaseModel):
async def handle_telegram_update(update: dict[str, Any]) -> None: type: Literal['subscription']
"""Обрабатывает системные события от Golang бота (echotron формат)""" user_telegram_id: int
import json invite_link: str
import logging username: str | None = None
first_name: str | None = None
log = logging.getLogger(__name__) last_name: str | None = None
# Логируем что пришло для отладки
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()))
async def _handle_chat_join_request(event: dict[str, Any]) -> None: class UnsubscriptionEventRequest(BaseModel):
"""Обработка запроса на вступление в канал через invite link""" type: Literal['unsubscription']
import logging user_telegram_id: int
channel_telegram_id: int
log = logging.getLogger(__name__)
chat = event.get('chat', {}) class BotAddedEventRequest(dto.ConnectProjectInput):
if chat.get('type') not in ['channel', 'supergroup']: type: Literal['bot_added']
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', {}) class BotRemovedEventRequest(dto.DisconnectProjectByTgIdInput):
invite_link = invite_link_obj.get('invite_link') type: Literal['bot_removed']
if not invite_link:
log.debug('No invite_link in chat_join_request event')
return
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( await deps.get_usecase().handle_subscription(
user_telegram_id=from_user['id'], user_telegram_id=input.user_telegram_id,
username=from_user.get('username'), username=input.username,
invite_link=invite_link, invite_link=input.invite_link,
first_name=from_user.get('first_name'), first_name=input.first_name,
last_name=from_user.get('last_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 return
member_user = event.get('new_chat_member', {}).get('user') if isinstance(input, UnsubscriptionEventRequest):
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:
await deps.get_usecase().handle_unsubscription( await deps.get_usecase().handle_unsubscription(
user_telegram_id=member_user['id'], user_telegram_id=input.user_telegram_id,
channel_telegram_id=chat['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 return
from_user = event.get('from') if isinstance(input, BotAddedEventRequest):
if not from_user: data = dto.ConnectProjectInput(**input.dict(exclude={'type'}))
log.error('No user in my_chat_member event') await deps.get_usecase().tg_add_project(input=data)
return return
old_member = event.get('old_chat_member', {}) if isinstance(input, BotRemovedEventRequest):
new_member = event.get('new_chat_member', {}) data = dto.DisconnectProjectByTgIdInput(**input.dict(exclude={'type'}))
await deps.get_usecase().disconnect_project_by_tg_id(input=data)
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)
return return
# Проверяем изменение прав администратора if isinstance(input, BotPermissionsEventRequest):
permissions_changed = old_status == 'administrator' and new_status == 'administrator' data = dto.UpdateProjectPermissionsInput(**input.dict(exclude={'type'}))
await deps.get_usecase().update_project_permissions(input=data)
# Извлекаем права бота
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)

View File

@@ -302,15 +302,12 @@ func (c *Client) UpdateProjectInviteLinkType(
return &project, err return &project, err
} }
func (c *Client) ForwardTelegramUpdate( func (c *Client) SendEvent(ctx context.Context, payload any) error {
ctx context.Context,
update any,
) error {
return c.do( return c.do(
ctx, ctx,
http.MethodPost, http.MethodPost,
"api/v1/internal/telegram/updates", "api/v1/internal/events",
update, payload,
nil, nil,
) )
} }

View File

@@ -2,7 +2,6 @@ package bot
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"strings" "strings"
"sync" "sync"
@@ -118,6 +117,204 @@ func (b *Bot) SetState(s State, mode RenderMode) {
s.Enter(b, mode) 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) { func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode RenderMode) {
if mode == EditMessage { if mode == EditMessage {
b.Edit(text, keyboard) b.Edit(text, keyboard)
@@ -238,21 +435,9 @@ func (b *Bot) Update(u *echotron.Update) {
Bool("has_edited_message", u.EditedMessage != nil). Bool("has_edited_message", u.EditedMessage != nil).
Msg("Update received") Msg("Update received")
// 1. Системные события (ChatJoinRequest, ChatMember, etc.) -> форвардим асинхронно // 1. Системные события -> отправляем доменные события в backend
if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil { if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil {
go func() { go b.forwardSystemEvent(u)
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")
}()
return return
} }