diff --git a/src/controller/http_v1/internal.py b/src/controller/http_v1/internal.py index 4d66193..cece2f7 100644 --- a/src/controller/http_v1/internal.py +++ b/src/controller/http_v1/internal.py @@ -123,9 +123,12 @@ async def _handle_chat_join_request(event: dict) -> None: return from_user = event.get('from') - if not from_user: - log.error('No user in chat_join_request event') - return + 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') @@ -153,7 +156,8 @@ async def _handle_chat_member_updated(event: dict) -> None: return from_user = event.get('from') - if not from_user: + member_user = event.get('new_chat_member', {}).get('user') + if not member_user: log.error('No user in chat_member_updated event') return @@ -181,16 +185,16 @@ async def _handle_chat_member_updated(event: dict) -> None: return await deps.get_usecase().handle_subscription( - user_telegram_id=from_user['id'], - username=from_user.get('username'), + user_telegram_id=member_user['id'], + username=member_user.get('username'), invite_link=invite_link, - first_name=from_user.get('first_name'), - last_name=from_user.get('last_name'), + first_name=member_user.get('first_name'), + last_name=member_user.get('last_name'), ) elif user_left: await deps.get_usecase().handle_unsubscription( - user_telegram_id=from_user['id'], + user_telegram_id=member_user['id'], channel_telegram_id=chat['id'], ) diff --git a/src/usecase/placement/create_placement.py b/src/usecase/placement/create_placement.py index 74ef62f..69e36d2 100644 --- a/src/usecase/placement/create_placement.py +++ b/src/usecase/placement/create_placement.py @@ -42,6 +42,13 @@ async def create_placement( raise domain.ChannelNotFound(project.channel.id) invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval) + log.info( + 'Created invite link for project=%s channel_telegram_id=%s requires_approval=%s invite_link=%s', + project.id, + project.channel.telegram_id, + requires_approval, + invite_link, + ) placement = domain.Placement( project_id=input.project_id, diff --git a/src/usecase/placement/fetch_placement_post_cycle.py b/src/usecase/placement/fetch_placement_post_cycle.py index 0a3a4c8..1251642 100644 --- a/src/usecase/placement/fetch_placement_post_cycle.py +++ b/src/usecase/placement/fetch_placement_post_cycle.py @@ -64,4 +64,4 @@ async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) -> post.id, ) - log.info('Fetch placement post cycle completed. Created %s placements', created_count) + log.debug('Fetch placement post cycle completed. Created %s placements', created_count) diff --git a/src/usecase/project/tg_add_project.py b/src/usecase/project/tg_add_project.py index 917bd5d..d78453a 100644 --- a/src/usecase/project/tg_add_project.py +++ b/src/usecase/project/tg_add_project.py @@ -121,6 +121,7 @@ async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto title=channel.title, username=channel.username, status=project.status, + purchase_invite_type_default=project.purchase_invite_type_default, ) # Если >1 workspace - создаем/обновляем канал и отправляем уведомление diff --git a/src/usecase/purchase/create_purchase.py b/src/usecase/purchase/create_purchase.py index 11b991e..cdb275b 100644 --- a/src/usecase/purchase/create_purchase.py +++ b/src/usecase/purchase/create_purchase.py @@ -97,11 +97,6 @@ async def create_purchase( if not creative or creative.project_id != project.id: raise domain.CreativeNotFound(input.creative_id) - existing_purchase = await self.database.get_active_purchase(project.id, creative.id) - if existing_purchase: - channels = await self.database.get_purchase_channels(existing_purchase.id) - return _build_purchase_output(existing_purchase, channels) - details = input.details purchase = domain.Purchase( workspace_id=workspace_id, @@ -143,6 +138,13 @@ async def create_purchase( log.info('Created channel @%s with telegram_id=%s', channel.username, channel.telegram_id) invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval) + log.info( + 'Created invite link for purchase %s channel_telegram_id=%s requires_approval=%s invite_link=%s', + purchase.id, + project.channel.telegram_id, + requires_approval, + invite_link, + ) channel_details = channel_input.details purchase_channel = await self.database.add_channel_to_purchase( diff --git a/telegram_bot/bot/bot.go b/telegram_bot/bot/bot.go index 75c1309..0c49632 100644 --- a/telegram_bot/bot/bot.go +++ b/telegram_bot/bot/bot.go @@ -2,6 +2,7 @@ package bot import ( "context" + "encoding/json" "fmt" "strings" "sync" @@ -222,6 +223,11 @@ func (b *Bot) Update(u *echotron.Update) { if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil { go func() { ctx := context.Background() + if raw, err := json.Marshal(u); err == nil { + log.Info().RawJSON("update", raw).Msg("System event payload") + } else { + log.Error().Err(err).Msg("Failed to marshal system event payload") + } err := b.Backend.ForwardTelegramUpdate(ctx, u) if err != nil { log.Error().Err(err).Msg("Failed to forward system event to backend") diff --git a/telegram_bot/screens/purchase_optional_details.go b/telegram_bot/screens/purchase_optional_details.go index c67ab4c..236c125 100644 --- a/telegram_bot/screens/purchase_optional_details.go +++ b/telegram_bot/screens/purchase_optional_details.go @@ -1433,6 +1433,7 @@ func (s *PurchaseOptionalDetails) sendCreativeWithInviteLink( creative *backend.Creative, inviteLink string, ) { + log.Info().Str("invite_link", inviteLink).Msg("Sending creative with invite link") linkedText := s.injectInviteLink(creative.Text, inviteLink) editor := CreativeEditorFields{ Text: &linkedText, diff --git a/tg_parser/internal/adapter/telegram/get_channel_diff.go b/tg_parser/internal/adapter/telegram/get_channel_diff.go index 394e74f..3161498 100644 --- a/tg_parser/internal/adapter/telegram/get_channel_diff.go +++ b/tg_parser/internal/adapter/telegram/get_channel_diff.go @@ -59,7 +59,8 @@ func extractPosts(channel domain.Channel, msgs []tg.MessageClass) []domain.Post continue } - p := domain.NewPost(channel, m.ID, m.Message, m.Views) + text := messageToHTML(m.Message, m.Entities) + p := domain.NewPost(channel, m.ID, text, m.Views) posts = append(posts, p) } diff --git a/tg_parser/internal/adapter/telegram/history.go b/tg_parser/internal/adapter/telegram/history.go index 7ec1e62..cdfd68e 100644 --- a/tg_parser/internal/adapter/telegram/history.go +++ b/tg_parser/internal/adapter/telegram/history.go @@ -38,7 +38,8 @@ func (t *Telegram) GetChannelHistory(ctx context.Context, channel domain.Channel continue } - p := domain.NewPost(channel, m.ID, m.Message, m.Views) + text := messageToHTML(m.Message, m.Entities) + p := domain.NewPost(channel, m.ID, text, m.Views) placements = append(placements, p) } diff --git a/tg_parser/internal/adapter/telegram/message_html.go b/tg_parser/internal/adapter/telegram/message_html.go new file mode 100644 index 0000000..3ddc4e5 --- /dev/null +++ b/tg_parser/internal/adapter/telegram/message_html.go @@ -0,0 +1,197 @@ +package telegram + +import ( + "html" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "github.com/gotd/td/tg" +) + +type htmlEntity struct { + start int + end int + openTag string + closeTag string + length int +} + +type htmlEvent struct { + pos int + tag string + isStart bool + length int +} + +func messageToHTML(text string, entities []tg.MessageEntityClass) string { + if text == "" { + return "" + } + + if len(entities) == 0 { + return html.EscapeString(text) + } + + ranges := make([]htmlEntity, 0, len(entities)) + needed := make([]int, 0, len(entities)*2) + + for _, raw := range entities { + offset, length, openTag, closeTag, ok := htmlEntityMeta(raw) + if !ok { + continue + } + + start := offset + end := offset + length + if length <= 0 { + continue + } + + ranges = append(ranges, htmlEntity{ + start: start, + end: end, + openTag: openTag, + closeTag: closeTag, + length: length, + }) + needed = append(needed, start, end) + } + + if len(ranges) == 0 { + return html.EscapeString(text) + } + + positions := utf16PositionsToBytes(text, needed) + events := make([]htmlEvent, 0, len(ranges)*2) + + for _, r := range ranges { + startByte, okStart := positions[r.start] + endByte, okEnd := positions[r.end] + if !okStart || !okEnd || startByte > endByte { + continue + } + + events = append(events, htmlEvent{ + pos: startByte, + tag: r.openTag, + isStart: true, + length: r.length, + }) + events = append(events, htmlEvent{ + pos: endByte, + tag: r.closeTag, + isStart: false, + length: r.length, + }) + } + + sort.SliceStable(events, func(i, j int) bool { + if events[i].pos != events[j].pos { + return events[i].pos < events[j].pos + } + if events[i].isStart != events[j].isStart { + return !events[i].isStart + } + if events[i].isStart { + return events[i].length > events[j].length + } + return events[i].length < events[j].length + }) + + var b strings.Builder + last := 0 + for _, ev := range events { + if ev.pos > last { + b.WriteString(html.EscapeString(text[last:ev.pos])) + } + b.WriteString(ev.tag) + last = ev.pos + } + if last < len(text) { + b.WriteString(html.EscapeString(text[last:])) + } + + return b.String() +} + +func htmlEntityMeta(entity tg.MessageEntityClass) (offset int, length int, openTag string, closeTag string, ok bool) { + switch e := entity.(type) { + case *tg.MessageEntityBold: + return e.Offset, e.Length, "", "", true + case *tg.MessageEntityItalic: + return e.Offset, e.Length, "", "", true + case *tg.MessageEntityUnderline: + return e.Offset, e.Length, "", "", true + case *tg.MessageEntityStrike: + return e.Offset, e.Length, "", "", true + case *tg.MessageEntityCode: + return e.Offset, e.Length, "", "", true + case *tg.MessageEntityPre: + if e.Language != "" { + lang := html.EscapeString(e.Language) + return e.Offset, e.Length, `
`, "
", true + } + return e.Offset, e.Length, "
", "
", true + case *tg.MessageEntityTextURL: + url := html.EscapeString(e.URL) + return e.Offset, e.Length, ``, "", true + case *tg.MessageEntityMentionName: + userID := html.EscapeString(strconv.FormatInt(e.UserID, 10)) + return e.Offset, e.Length, ``, "", true + case *tg.MessageEntitySpoiler: + return e.Offset, e.Length, ``, "", true + case *tg.MessageEntityBlockquote: + if e.Collapsed { + return e.Offset, e.Length, `
`, "
", true + } + return e.Offset, e.Length, "
", "
", true + case *tg.MessageEntityCustomEmoji: + id := html.EscapeString(strconv.FormatInt(e.DocumentID, 10)) + return e.Offset, e.Length, ``, "", true + default: + return 0, 0, "", "", false + } +} + +func utf16PositionsToBytes(text string, needed []int) map[int]int { + result := make(map[int]int, len(needed)) + needSet := make(map[int]struct{}, len(needed)) + for _, n := range needed { + needSet[n] = struct{}{} + } + + utf16Pos := 0 + if _, ok := needSet[0]; ok { + result[0] = 0 + } + + for i, r := range text { + if _, ok := needSet[utf16Pos]; ok { + result[utf16Pos] = i + } + + step := utf16RuneLen(r) + if step == 2 { + if _, ok := needSet[utf16Pos+1]; ok { + result[utf16Pos+1] = i + } + } + utf16Pos += step + } + + if _, ok := needSet[utf16Pos]; ok { + result[utf16Pos] = len(text) + } + + return result +} + +func utf16RuneLen(r rune) int { + const surrSelf = 0x10000 + if r >= surrSelf && r <= utf8.MaxRune { + return 2 + } + return 1 +}