Initial commit
Build & Push / build (push) Canceled after 0s

This commit is contained in:
2026-08-05 19:31:35 +03:00
commit a33fa24e99
272 changed files with 40566 additions and 0 deletions
@@ -0,0 +1,37 @@
package telegram
import (
"context"
"fmt"
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
"github.com/gotd/td/tg"
)
func (t *Telegram) UpdatePostsViews(ctx context.Context, channel domain.Channel, posts []domain.Post) error {
ids := make([]int, len(posts))
for i, p := range posts {
ids[i] = p.MessageID
}
req := &tg.MessagesGetMessagesViewsRequest{
Peer: &tg.InputPeerChannel{
ChannelID: channel.ChannelID(),
AccessHash: channel.AccessHash,
},
ID: ids,
Increment: false,
}
resp, err := t.API().MessagesGetMessagesViews(ctx, req)
if err != nil {
return fmt.Errorf("get messages views: %w", err)
}
// Telegram гарантирует, что resp.Views соответствует порядку ids
for i, v := range resp.Views {
posts[i].Views = v.Views
}
return nil
}
@@ -0,0 +1,125 @@
package telegram
import (
"context"
"fmt"
"time"
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
"github.com/gotd/td/tg"
)
func (t *Telegram) GetChannelDiff(ctx context.Context, channel domain.Channel, limit int) (domain.ChannelDiff, error) {
req := &tg.UpdatesGetChannelDifferenceRequest{
Channel: &tg.InputChannel{
ChannelID: channel.ChannelID(),
AccessHash: channel.AccessHash,
},
Filter: &tg.ChannelMessagesFilterEmpty{},
Pts: channel.Pts,
Limit: limit,
}
rawDiff, err := t.API().UpdatesGetChannelDifference(ctx, req)
if err != nil {
return domain.ChannelDiff{}, fmt.Errorf("get difference: %w", err)
}
result := domain.ChannelDiff{}
switch d := rawDiff.(type) {
case *tg.UpdatesChannelDifferenceEmpty:
result.NewPts = d.Pts
case *tg.UpdatesChannelDifferenceTooLong:
if dialog, ok := d.Dialog.(*tg.Dialog); ok {
result.NewPts = dialog.Pts
}
result.NewPosts = extractPosts(channel, d.Messages)
result.UpdatedChannel = extractChannelMeta(channel, d.Chats)
case *tg.UpdatesChannelDifference:
result.NewPts = d.Pts
result.NewPosts = extractPosts(channel, d.NewMessages)
result.DeletedPosts = extractDeletedPosts(channel, d.OtherUpdates)
result.UpdatedChannel = extractChannelMeta(channel, d.Chats)
default:
return domain.ChannelDiff{}, fmt.Errorf("unexpected rawDiff type: %T", rawDiff)
}
return result, nil
}
func extractPosts(channel domain.Channel, msgs []tg.MessageClass) []domain.Post {
posts := make([]domain.Post, 0, len(msgs))
for _, raw := range msgs {
m, ok := raw.(*tg.Message)
if !ok {
continue
}
text := messageToHTML(m.Message, m.Entities)
publishedAt := time.Unix(int64(m.Date), 0).UTC()
p := domain.NewPost(channel, m.ID, text, m.Views, publishedAt)
posts = append(posts, p)
}
return posts
}
func extractDeletedPosts(channel domain.Channel, updates []tg.UpdateClass) []domain.Post {
var deleted []domain.Post
for _, upd := range updates {
if u, ok := upd.(*tg.UpdateDeleteChannelMessages); ok {
for _, msgID := range u.Messages {
p := domain.NewPost(channel, msgID, "", 0, time.Time{})
deleted = append(deleted, p)
}
}
}
return deleted
}
func extractChannelMeta(currentChannel domain.Channel, chats []tg.ChatClass) *domain.Channel {
for _, chat := range chats {
ch, ok := chat.(*tg.Channel)
if !ok {
continue
}
if ch.ID != currentChannel.ChannelID() {
continue
}
// Preserve username if Telegram doesn't provide a new one (private channels may not have username)
username := currentChannel.Username
if usernameVal, ok := ch.GetUsername(); ok && usernameVal != "" {
username = usernameVal
}
// Keep current access hash if new one is not provided or is zero
accessHash := currentChannel.AccessHash
if accessHashVal, ok := ch.GetAccessHash(); ok && accessHashVal != 0 {
accessHash = accessHashVal
}
updated := domain.Channel{
ID: currentChannel.ID,
TelegramID: domain.ChatIDFromChannelID(ch.ID),
Username: username,
Title: ch.Title,
AccessHash: accessHash,
InviteLink: currentChannel.InviteLink, // Preserve invite link (never returned in channel diff)
Pts: currentChannel.Pts,
IsAccessible: currentChannel.IsAccessible,
}
return &updated
}
return nil
}
@@ -0,0 +1,49 @@
package telegram
import (
"context"
"fmt"
"time"
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
"github.com/gotd/td/tg"
"github.com/rs/zerolog/log"
)
func (t *Telegram) GetChannelHistory(ctx context.Context, channel domain.Channel, limit int) ([]domain.Post, error) {
log.Info().Msg(channel.String())
req := &tg.MessagesGetHistoryRequest{
Peer: &tg.InputPeerChannel{
ChannelID: channel.ChannelID(),
AccessHash: channel.AccessHash,
},
Limit: limit,
}
resp, err := t.API().MessagesGetHistory(ctx, req)
if err != nil {
return nil, fmt.Errorf("resp: %w", err)
}
messages, ok := resp.(*tg.MessagesChannelMessages)
if !ok {
return nil, nil
}
posts := make([]domain.Post, 0, len(messages.Messages))
for _, raw := range messages.Messages {
m, ok := raw.(*tg.Message)
if !ok {
continue
}
text := messageToHTML(m.Message, m.Entities)
publishedAt := time.Unix(int64(m.Date), 0).UTC()
p := domain.NewPost(channel, m.ID, text, m.Views, publishedAt)
posts = append(posts, p)
}
return posts, nil
}
@@ -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
}
@@ -0,0 +1,33 @@
package telegram
import (
"context"
"fmt"
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
"github.com/gotd/td/tg"
)
func (t *Telegram) GetChannelPTS(ctx context.Context, channel domain.Channel) (int, error) {
req := []tg.InputDialogPeerClass{
&tg.InputDialogPeer{
Peer: &tg.InputPeerChannel{
ChannelID: channel.ChannelID(),
AccessHash: channel.AccessHash,
},
},
}
dialogs, err := t.API().MessagesGetPeerDialogs(ctx, req)
if err != nil {
return 0, fmt.Errorf("peer dialogs: %w", err)
}
if len(dialogs.Dialogs) > 0 {
if d, ok := dialogs.Dialogs[0].(*tg.Dialog); ok {
return d.Pts, nil
}
}
return 0, nil
}
@@ -0,0 +1,58 @@
package telegram
import (
"context"
"fmt"
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
"github.com/gotd/td/tg"
)
func (t *Telegram) ParseChannelMeta(ctx context.Context, username string) (domain.Channel, error) {
resolved, err := t.API().ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{
Username: username,
})
if err != nil {
return domain.Channel{}, fmt.Errorf("resolve username: %w", err)
}
if len(resolved.Chats) == 0 {
return domain.Channel{}, fmt.Errorf("no chats in resolve result")
}
ch, ok := resolved.Chats[0].(*tg.Channel)
if !ok {
return domain.Channel{}, fmt.Errorf("not a channel")
}
req := []tg.InputDialogPeerClass{
&tg.InputDialogPeer{
Peer: &tg.InputPeerChannel{
ChannelID: ch.ID,
AccessHash: ch.AccessHash,
},
},
}
dialogs, err := t.API().MessagesGetPeerDialogs(ctx, req)
if err != nil {
return domain.Channel{}, fmt.Errorf("peer dialogs: %w", err)
}
pts := 0
if len(dialogs.Dialogs) > 0 {
d, ok := dialogs.Dialogs[0].(*tg.Dialog)
if ok {
pts = d.Pts
}
}
return domain.Channel{
TelegramID: domain.ChatIDFromChannelID(ch.ID),
Username: username,
Title: ch.Title,
AccessHash: ch.AccessHash,
Pts: pts,
IsAccessible: true,
}, nil
}
@@ -0,0 +1,133 @@
package telegram
import (
"context"
"fmt"
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
"github.com/gotd/td/telegram/deeplink"
"github.com/gotd/td/tg"
)
func (t *Telegram) ParseChannelMetaByInvite(ctx context.Context, inviteLink string) (domain.Channel, error) {
link, err := deeplink.Parse(inviteLink)
if err != nil {
return domain.Channel{}, fmt.Errorf("parse invite link: %w", err)
}
if link.Type != deeplink.Join {
return domain.Channel{}, fmt.Errorf("invite link is not a join link")
}
hash := link.Args.Get("invite")
if hash == "" {
return domain.Channel{}, fmt.Errorf("invite link missing hash")
}
info, err := t.API().MessagesCheckChatInvite(ctx, hash)
if err != nil {
return domain.Channel{}, fmt.Errorf("check invite: %w", err)
}
var channel *tg.Channel
switch v := info.(type) {
case *tg.ChatInviteAlready:
channel = extractChannelFromChat(v.Chat)
case *tg.ChatInvite:
updates, err := t.API().MessagesImportChatInvite(ctx, hash)
if err != nil {
return domain.Channel{}, fmt.Errorf("import invite: %w", err)
}
channel = extractChannelFromUpdates(updates)
case *tg.ChatInvitePeek:
// ChatInvitePeek means we can preview the channel without joining (public channels)
channel = extractChannelFromChat(v.Chat)
default:
return domain.Channel{}, fmt.Errorf("unexpected invite response: %T", v)
}
if channel == nil {
return domain.Channel{}, fmt.Errorf("no channel in invite response")
}
if channel.AccessHash == 0 {
return domain.Channel{}, fmt.Errorf("channel access hash missing")
}
pts, err := getChannelPTS(ctx, t.API(), channel)
if err != nil {
return domain.Channel{}, fmt.Errorf("get peer dialogs: %w", err)
}
username := ""
if usernameVal, ok := channel.GetUsername(); ok && usernameVal != "" {
username = usernameVal
}
return domain.Channel{
TelegramID: domain.ChatIDFromChannelID(channel.ID),
Username: username,
Title: channel.Title,
AccessHash: channel.AccessHash,
Pts: pts,
InviteLink: inviteLink,
IsAccessible: true,
}, nil
}
func extractChannelFromChat(chat tg.ChatClass) *tg.Channel {
switch v := chat.(type) {
case *tg.Channel:
return v
case *tg.ChannelForbidden:
return &tg.Channel{
ID: v.ID,
AccessHash: v.AccessHash,
Title: v.Title,
}
default:
return nil
}
}
func extractChannelFromUpdates(updates tg.UpdatesClass) *tg.Channel {
var chats []tg.ChatClass
switch v := updates.(type) {
case *tg.Updates:
chats = v.Chats
case *tg.UpdatesCombined:
chats = v.Chats
default:
return nil
}
for _, chat := range chats {
if channel := extractChannelFromChat(chat); channel != nil {
return channel
}
}
return nil
}
func getChannelPTS(ctx context.Context, api *tg.Client, channel *tg.Channel) (int, error) {
req := []tg.InputDialogPeerClass{
&tg.InputDialogPeer{
Peer: &tg.InputPeerChannel{
ChannelID: channel.ID,
AccessHash: channel.AccessHash,
},
},
}
dialogs, err := api.MessagesGetPeerDialogs(ctx, req)
if err != nil {
return 0, err
}
if len(dialogs.Dialogs) > 0 {
if d, ok := dialogs.Dialogs[0].(*tg.Dialog); ok {
return d.Pts, nil
}
}
return 0, nil
}
@@ -0,0 +1,20 @@
package telegram
import (
"github.com/TelegramExchange/pkg/telegram"
"github.com/gotd/td/tg"
)
type Telegram struct {
client *telegram.Client
}
func New(client *telegram.Client) *Telegram {
return &Telegram{
client: client,
}
}
func (t *Telegram) API() *tg.Client {
return t.client.API()
}