парсер
This commit is contained in:
@@ -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')
|
||||
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'],
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 - создаем/обновляем канал и отправляем уведомление
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
197
tg_parser/internal/adapter/telegram/message_html.go
Normal file
197
tg_parser/internal/adapter/telegram/message_html.go
Normal file
@@ -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, "<b>", "</b>", true
|
||||
case *tg.MessageEntityItalic:
|
||||
return e.Offset, e.Length, "<i>", "</i>", true
|
||||
case *tg.MessageEntityUnderline:
|
||||
return e.Offset, e.Length, "<u>", "</u>", true
|
||||
case *tg.MessageEntityStrike:
|
||||
return e.Offset, e.Length, "<s>", "</s>", true
|
||||
case *tg.MessageEntityCode:
|
||||
return e.Offset, e.Length, "<code>", "</code>", true
|
||||
case *tg.MessageEntityPre:
|
||||
if e.Language != "" {
|
||||
lang := html.EscapeString(e.Language)
|
||||
return e.Offset, e.Length, `<pre><code class="language-` + lang + `">`, "</code></pre>", true
|
||||
}
|
||||
return e.Offset, e.Length, "<pre>", "</pre>", true
|
||||
case *tg.MessageEntityTextURL:
|
||||
url := html.EscapeString(e.URL)
|
||||
return e.Offset, e.Length, `<a href="` + url + `">`, "</a>", true
|
||||
case *tg.MessageEntityMentionName:
|
||||
userID := html.EscapeString(strconv.FormatInt(e.UserID, 10))
|
||||
return e.Offset, e.Length, `<a href="tg://user?id=` + userID + `">`, "</a>", true
|
||||
case *tg.MessageEntitySpoiler:
|
||||
return e.Offset, e.Length, `<span class="tg-spoiler">`, "</span>", true
|
||||
case *tg.MessageEntityBlockquote:
|
||||
if e.Collapsed {
|
||||
return e.Offset, e.Length, `<blockquote expandable>`, "</blockquote>", true
|
||||
}
|
||||
return e.Offset, e.Length, "<blockquote>", "</blockquote>", true
|
||||
case *tg.MessageEntityCustomEmoji:
|
||||
id := html.EscapeString(strconv.FormatInt(e.DocumentID, 10))
|
||||
return e.Offset, e.Length, `<tg-emoji emoji-id="` + id + `">`, "</tg-emoji>", 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
|
||||
}
|
||||
Reference in New Issue
Block a user