парсер
This commit is contained in:
@@ -123,9 +123,12 @@ async def _handle_chat_join_request(event: dict) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
from_user = event.get('from')
|
from_user = event.get('from')
|
||||||
if not from_user:
|
if not from_user or not from_user.get('id'):
|
||||||
log.error('No user in chat_join_request event')
|
user_chat_id = event.get('user_chat_id')
|
||||||
return
|
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_obj = event.get('invite_link', {})
|
||||||
invite_link = invite_link_obj.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
|
return
|
||||||
|
|
||||||
from_user = event.get('from')
|
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')
|
log.error('No user in chat_member_updated event')
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -181,16 +185,16 @@ async def _handle_chat_member_updated(event: dict) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
await deps.get_usecase().handle_subscription(
|
await deps.get_usecase().handle_subscription(
|
||||||
user_telegram_id=from_user['id'],
|
user_telegram_id=member_user['id'],
|
||||||
username=from_user.get('username'),
|
username=member_user.get('username'),
|
||||||
invite_link=invite_link,
|
invite_link=invite_link,
|
||||||
first_name=from_user.get('first_name'),
|
first_name=member_user.get('first_name'),
|
||||||
last_name=from_user.get('last_name'),
|
last_name=member_user.get('last_name'),
|
||||||
)
|
)
|
||||||
|
|
||||||
elif user_left:
|
elif user_left:
|
||||||
await deps.get_usecase().handle_unsubscription(
|
await deps.get_usecase().handle_unsubscription(
|
||||||
user_telegram_id=from_user['id'],
|
user_telegram_id=member_user['id'],
|
||||||
channel_telegram_id=chat['id'],
|
channel_telegram_id=chat['id'],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ async def create_placement(
|
|||||||
raise domain.ChannelNotFound(project.channel.id)
|
raise domain.ChannelNotFound(project.channel.id)
|
||||||
|
|
||||||
invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval)
|
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(
|
placement = domain.Placement(
|
||||||
project_id=input.project_id,
|
project_id=input.project_id,
|
||||||
|
|||||||
@@ -64,4 +64,4 @@ async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) ->
|
|||||||
post.id,
|
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,
|
title=channel.title,
|
||||||
username=channel.username,
|
username=channel.username,
|
||||||
status=project.status,
|
status=project.status,
|
||||||
|
purchase_invite_type_default=project.purchase_invite_type_default,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Если >1 workspace - создаем/обновляем канал и отправляем уведомление
|
# Если >1 workspace - создаем/обновляем канал и отправляем уведомление
|
||||||
|
|||||||
@@ -97,11 +97,6 @@ async def create_purchase(
|
|||||||
if not creative or creative.project_id != project.id:
|
if not creative or creative.project_id != project.id:
|
||||||
raise domain.CreativeNotFound(input.creative_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
|
details = input.details
|
||||||
purchase = domain.Purchase(
|
purchase = domain.Purchase(
|
||||||
workspace_id=workspace_id,
|
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)
|
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)
|
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
|
channel_details = channel_input.details
|
||||||
purchase_channel = await self.database.add_channel_to_purchase(
|
purchase_channel = await self.database.add_channel_to_purchase(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package bot
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -222,6 +223,11 @@ func (b *Bot) Update(u *echotron.Update) {
|
|||||||
if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil {
|
if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil {
|
||||||
go func() {
|
go func() {
|
||||||
ctx := context.Background()
|
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)
|
err := b.Backend.ForwardTelegramUpdate(ctx, u)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to forward system event to backend")
|
log.Error().Err(err).Msg("Failed to forward system event to backend")
|
||||||
|
|||||||
@@ -1433,6 +1433,7 @@ func (s *PurchaseOptionalDetails) sendCreativeWithInviteLink(
|
|||||||
creative *backend.Creative,
|
creative *backend.Creative,
|
||||||
inviteLink string,
|
inviteLink string,
|
||||||
) {
|
) {
|
||||||
|
log.Info().Str("invite_link", inviteLink).Msg("Sending creative with invite link")
|
||||||
linkedText := s.injectInviteLink(creative.Text, inviteLink)
|
linkedText := s.injectInviteLink(creative.Text, inviteLink)
|
||||||
editor := CreativeEditorFields{
|
editor := CreativeEditorFields{
|
||||||
Text: &linkedText,
|
Text: &linkedText,
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ func extractPosts(channel domain.Channel, msgs []tg.MessageClass) []domain.Post
|
|||||||
continue
|
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)
|
posts = append(posts, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ func (t *Telegram) GetChannelHistory(ctx context.Context, channel domain.Channel
|
|||||||
continue
|
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)
|
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