приватные каналы
This commit is contained in:
@@ -17,7 +17,8 @@ func (d *Database) GetChannels(ctx context.Context) []domain.Channel {
|
||||
username,
|
||||
title,
|
||||
access_hash,
|
||||
pts
|
||||
pts,
|
||||
invite_link
|
||||
FROM channel
|
||||
WHERE deleted_at IS NULL;`
|
||||
|
||||
@@ -54,6 +55,7 @@ type getChannelsDTO struct {
|
||||
Title pgtype.Text
|
||||
AccessHash pgtype.Int8
|
||||
Pts pgtype.Int4
|
||||
InviteLink pgtype.Text
|
||||
}
|
||||
|
||||
func (dto *getChannelsDTO) destination() []any {
|
||||
@@ -64,6 +66,7 @@ func (dto *getChannelsDTO) destination() []any {
|
||||
&dto.Title,
|
||||
&dto.AccessHash,
|
||||
&dto.Pts,
|
||||
&dto.InviteLink,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,5 +78,6 @@ func (dto *getChannelsDTO) toDomain() domain.Channel {
|
||||
Title: dto.Title.String,
|
||||
AccessHash: dto.AccessHash.Int64,
|
||||
Pts: int(dto.Pts.Int32),
|
||||
InviteLink: dto.InviteLink.String,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ func (d *Database) GetChannelsWithTrackedPosts(ctx context.Context) ([]domain.Ch
|
||||
c.username,
|
||||
c.title,
|
||||
c.access_hash,
|
||||
c.pts
|
||||
c.pts,
|
||||
c.invite_link
|
||||
FROM channel c
|
||||
INNER JOIN placement p ON p.channel_id = c.id
|
||||
INNER JOIN placement_post pp ON pp.placement_id = p.id
|
||||
@@ -55,6 +56,7 @@ type getChannelsWithTrackedPostsDTO struct {
|
||||
Title pgtype.Text
|
||||
AccessHash pgtype.Int8
|
||||
Pts pgtype.Int4
|
||||
InviteLink pgtype.Text
|
||||
}
|
||||
|
||||
func (dto *getChannelsWithTrackedPostsDTO) destination() []any {
|
||||
@@ -65,6 +67,7 @@ func (dto *getChannelsWithTrackedPostsDTO) destination() []any {
|
||||
&dto.Title,
|
||||
&dto.AccessHash,
|
||||
&dto.Pts,
|
||||
&dto.InviteLink,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,5 +79,6 @@ func (dto *getChannelsWithTrackedPostsDTO) toDomain() domain.Channel {
|
||||
Title: dto.Title.String,
|
||||
AccessHash: dto.AccessHash.Int64,
|
||||
Pts: int(dto.Pts.Int32),
|
||||
InviteLink: dto.InviteLink.String,
|
||||
}
|
||||
}
|
||||
|
||||
33
tg_parser/internal/adapter/telegram/pts.go
Normal file
33
tg_parser/internal/adapter/telegram/pts.go
Normal file
@@ -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
|
||||
}
|
||||
130
tg_parser/internal/adapter/telegram/resolve_invite.go
Normal file
130
tg_parser/internal/adapter/telegram/resolve_invite.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
|
||||
"github.com/google/uuid"
|
||||
"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)
|
||||
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 {
|
||||
username = usernameVal
|
||||
}
|
||||
|
||||
return domain.Channel{
|
||||
ID: uuid.New(),
|
||||
TelegramID: domain.ChatIDFromChannelID(channel.ID),
|
||||
Username: username,
|
||||
Title: channel.Title,
|
||||
AccessHash: channel.AccessHash,
|
||||
Pts: pts,
|
||||
}, 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
|
||||
}
|
||||
@@ -62,6 +62,58 @@ func New(uc *usecase.UseCase, addr string) *Server {
|
||||
log.Error().Err(err).Msg("encode channel response")
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/resolve-channel-by-invite", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
InviteLink string `json:"invite_link"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
inviteLink := strings.TrimSpace(payload.InviteLink)
|
||||
if inviteLink == "" {
|
||||
http.Error(w, "missing invite_link", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
channel, err := uc.FetchChannelMetaByInvite(r.Context(), inviteLink)
|
||||
if err != nil {
|
||||
if isNotFoundError(err) {
|
||||
http.Error(w, "channel not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Error().Err(err).Msg("resolve channel by invite failed")
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp := struct {
|
||||
ID string `json:"id"`
|
||||
TelegramID int64 `json:"telegram_id"`
|
||||
Username string `json:"username"`
|
||||
Title string `json:"title"`
|
||||
AccessHash int64 `json:"access_hash"`
|
||||
Pts int `json:"pts"`
|
||||
}{
|
||||
ID: channel.ID.String(),
|
||||
TelegramID: channel.TelegramID,
|
||||
Username: channel.Username,
|
||||
Title: channel.Title,
|
||||
AccessHash: channel.AccessHash,
|
||||
Pts: channel.Pts,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
log.Error().Err(err).Msg("encode channel response")
|
||||
}
|
||||
})
|
||||
|
||||
return &Server{
|
||||
httpServer: &http.Server{
|
||||
@@ -80,11 +132,21 @@ func (s *Server) Shutdown(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func isNotFoundError(err error) bool {
|
||||
if tgerr.Is(err, "USERNAME_NOT_OCCUPIED", "USERNAME_INVALID", "CHANNEL_INVALID", "CHANNEL_PRIVATE") {
|
||||
if tgerr.Is(
|
||||
err,
|
||||
"USERNAME_NOT_OCCUPIED",
|
||||
"USERNAME_INVALID",
|
||||
"CHANNEL_INVALID",
|
||||
"CHANNEL_PRIVATE",
|
||||
"INVITE_HASH_INVALID",
|
||||
"INVITE_HASH_EXPIRED",
|
||||
"INVITE_HASH_EMPTY",
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "no chats in resolve result") ||
|
||||
strings.Contains(msg, "not a channel")
|
||||
strings.Contains(msg, "not a channel") ||
|
||||
strings.Contains(msg, "invite link")
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ type Channel struct {
|
||||
Title string
|
||||
AccessHash int64
|
||||
Pts int
|
||||
InviteLink string
|
||||
}
|
||||
|
||||
const channelIDOffset int64 = 1000000000000
|
||||
@@ -47,13 +48,14 @@ func NormalizeChatID(id int64) int64 {
|
||||
|
||||
func (c Channel) String() string {
|
||||
return fmt.Sprintf(
|
||||
"Channel{id=%s telegram_id=%d username=%q title=%q access_hash=%d pts=%d}",
|
||||
"Channel{id=%s telegram_id=%d username=%q title=%q access_hash=%d pts=%d invite_link=%t}",
|
||||
c.ID,
|
||||
c.TelegramID,
|
||||
c.Username,
|
||||
c.Title,
|
||||
c.AccessHash,
|
||||
c.Pts,
|
||||
c.InviteLink != "",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,12 +16,16 @@ type Post struct {
|
||||
}
|
||||
|
||||
func NewPost(channel Channel, messageID int, text string, views int) Post {
|
||||
link := ""
|
||||
if channel.Username != "" {
|
||||
link = fmt.Sprintf("https://t.me/%s/%d", channel.Username, messageID)
|
||||
}
|
||||
return Post{
|
||||
ID: uuid.New(),
|
||||
ChannelID: channel.ID,
|
||||
MessageID: messageID,
|
||||
Text: text,
|
||||
Link: fmt.Sprintf("https://t.me/%s/%d", channel.Username, messageID),
|
||||
Link: link,
|
||||
Views: views,
|
||||
}
|
||||
}
|
||||
|
||||
11
tg_parser/internal/usecase/fetch_channel_meta_by_invite.go
Normal file
11
tg_parser/internal/usecase/fetch_channel_meta_by_invite.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
|
||||
)
|
||||
|
||||
func (uc *UseCase) FetchChannelMetaByInvite(ctx context.Context, inviteLink string) (domain.Channel, error) {
|
||||
return uc.telegram.ParseChannelMetaByInvite(ctx, inviteLink)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/TelegramExchange/pkg/transaction"
|
||||
"github.com/gotd/td/tgerr"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
|
||||
@@ -23,6 +24,11 @@ func (uc *UseCase) FetchChannels(ctx context.Context) error {
|
||||
c = updatedChannel
|
||||
}
|
||||
|
||||
if c.Pts == 0 {
|
||||
log.Warn().Int64("telegram_id", c.TelegramID).Msg("Channel pts still empty, skipping diff")
|
||||
continue
|
||||
}
|
||||
|
||||
err := uc.processChannel(ctx, c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("uc.processChannel: %w", err)
|
||||
@@ -35,6 +41,25 @@ func (uc *UseCase) FetchChannels(ctx context.Context) error {
|
||||
func (uc *UseCase) processChannel(ctx context.Context, channel domain.Channel) error {
|
||||
diff, err := uc.telegram.GetChannelDiff(ctx, channel, 20)
|
||||
if err != nil {
|
||||
if isPrivateChannelError(err) {
|
||||
log.Warn().Err(err).Int64("telegram_id", channel.TelegramID).Msg("Private channel, attempting rejoin")
|
||||
if channel.InviteLink == "" {
|
||||
log.Warn().Int64("telegram_id", channel.TelegramID).Msg("No invite link stored, skipping channel")
|
||||
return nil
|
||||
}
|
||||
updatedChannel, joinErr := uc.telegram.ParseChannelMetaByInvite(ctx, channel.InviteLink)
|
||||
if joinErr != nil {
|
||||
log.Warn().Err(joinErr).Int64("telegram_id", channel.TelegramID).Msg("Invite rejoin failed")
|
||||
return nil
|
||||
}
|
||||
updatedChannel.ID = channel.ID
|
||||
updatedChannel.InviteLink = channel.InviteLink
|
||||
updatedChannel.Pts = 0
|
||||
if err := uc.database.UpdateChannel(ctx, updatedChannel); err != nil {
|
||||
return fmt.Errorf("database.UpdateChannel: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("telegram.GetChannelDiff: %w", err)
|
||||
}
|
||||
|
||||
@@ -74,10 +99,26 @@ func (uc *UseCase) processChannel(ctx context.Context, channel domain.Channel) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func isPrivateChannelError(err error) bool {
|
||||
return tgerr.Is(err, "CHANNEL_PRIVATE", "CHANNEL_INVALID", "CHANNEL_FORBIDDEN")
|
||||
}
|
||||
|
||||
func (uc *UseCase) initializeChannel(ctx context.Context, channel domain.Channel) (domain.Channel, error) {
|
||||
c, err := uc.telegram.ParseChannelMeta(ctx, channel.Username)
|
||||
if err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("telegram.ParseChannelMeta: %w", err)
|
||||
c := channel
|
||||
if channel.Username != "" {
|
||||
updated, err := uc.telegram.ParseChannelMeta(ctx, channel.Username)
|
||||
if err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("telegram.ParseChannelMeta: %w", err)
|
||||
}
|
||||
c = updated
|
||||
} else if channel.AccessHash == 0 {
|
||||
return domain.Channel{}, fmt.Errorf("channel %s missing access hash", channel.ID)
|
||||
} else {
|
||||
pts, err := uc.telegram.GetChannelPTS(ctx, channel)
|
||||
if err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("telegram.GetChannelPTS: %w", err)
|
||||
}
|
||||
c.Pts = pts
|
||||
}
|
||||
|
||||
// Preserve persistent channel ID from the database so foreign keys remain valid.
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
|
||||
type Telegram interface {
|
||||
ParseChannelMeta(ctx context.Context, username string) (domain.Channel, error)
|
||||
ParseChannelMetaByInvite(ctx context.Context, inviteLink string) (domain.Channel, error)
|
||||
GetChannelPTS(ctx context.Context, channel domain.Channel) (int, error)
|
||||
GetChannelHistory(ctx context.Context, channel domain.Channel, limit int) ([]domain.Post, error)
|
||||
GetChannelDiff(ctx context.Context, channel domain.Channel, limit int) (domain.ChannelDiff, error)
|
||||
UpdatePostsViews(ctx context.Context, channel domain.Channel, posts []domain.Post) error
|
||||
|
||||
Reference in New Issue
Block a user