package scraper
import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
"time"
"github.com/firebase/genkit/go/ai"
"github.com/firebase/genkit/go/genkit"
"github.com/gotd/td/telegram/updates"
"github.com/gotd/td/tg"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"mimi/internal/persist"
"mimi/internal/provider/telegram"
)
const (
telegramTopicDescriptionPrompt = "telegram-topic-description"
)
func Run(ctx context.Context, pool *pgxpool.Pool, g *genkit.Genkit) error {
return telegram.StartClient(ctx, func(s telegram.ClientState) error {
api := s.Client.API()
err := Setup(ctx, api, pool, g)
if err != nil {
return fmt.Errorf("failed to setup Telegram scraper with %w", err)
}
setupDispatcher(ctx, &s.Dispatcher, s.Client.API(), g, pool, newSession())
return s.Gaps.Run(ctx, s.Client.API(), s.CurrentUser.ID, updates.AuthOptions{
OnStart: func(ctx context.Context) {
slog.Info("listening for events")
},
})
})
}
func setupDispatcher(ctx context.Context, d *tg.UpdateDispatcher, api *tg.Client, g *genkit.Genkit, pool *pgxpool.Pool, s *session) error {
q := persist.New(pool)
subscribeTo, err := q.FindTelegramPeers(ctx)
if err != nil {
slog.Error("failed to find peers to subscribe", "error", err)
return err
}
if len(subscribeTo) < 1 {
err = errors.New("got empty peers to subscribe")
slog.Error("error", "error", err)
return err
}
d.OnNewChannelMessage(func(ctx context.Context, e tg.Entities, u *tg.UpdateNewChannelMessage) error {
msg, ok := u.Message.(*tg.Message)
if !ok {
return nil
}
channel, ok := msg.PeerID.(*tg.PeerChannel)
if !ok {
slog.Warn("failed to extract channel from", "peer_id", msg.PeerID)
}
if slices.IndexFunc(subscribeTo, func(s persist.FindTelegramPeersRow) bool {
return s.ID == channel.ChannelID
}) == -1 {
return nil
}
replyTo, ok := msg.ReplyTo.(*tg.MessageReplyHeader)
if !ok {
slog.Warn("failed to extract reply to from", "reply_to", msg.ReplyTo)
return nil
}
var topic *tg.ForumTopic
if replyTo.ForumTopic {
t, err := s.resolveTopic(ctx, api, channel.ChannelID, replyTo.ReplyToMsgID)
if err != nil {
slog.Error("failed to resolve topic", "error", err)
return err
}
topic = t
}
tx, err := pool.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction with %w", err)
}
defer tx.Rollback(ctx)
qtx := q.WithTx(tx)
if topic != nil {
_, err := q.TelegramTopicExists(ctx, persist.TelegramTopicExistsParams{
ID: int32(topic.ID),
PeerID: channel.ChannelID,
})
if err == pgx.ErrNoRows {
inputChannel := &tg.InputChannel{
ChannelID: channel.ChannelID,
AccessHash: 0,
}
channels, err := api.ChannelsGetChannels(ctx, []tg.InputChannelClass{inputChannel})
if err != nil {
return fmt.Errorf("failed to resolve channel %d with %w", channel.ChannelID, err)
}
if len(channels.GetChats()) == 0 {
return fmt.Errorf("no channels found")
}
channel, ok := channels.GetChats()[0].(*tg.Channel)
if !ok {
return fmt.Errorf("unexpected resolved channel type %#v", channels)
}
_, err = processNewTopic(ctx, g, qtx, api, channel, topic)
if err != nil {
return fmt.Errorf("failed to save telegram topic with %w", err)
}
}
}
var topicID pgtype.Int4
if topic != nil {
topicID.Int32 = int32(topic.ID)
topicID.Valid = true
}
err = qtx.SaveTelegramMessage(ctx, persist.SaveTelegramMessageParams{
ID: int32(msg.ID),
PeerID: channel.ChannelID,
TopicID: topicID,
Message: msg.Message,
CreatedAt: pgtype.Timestamptz{Time: time.Unix(int64(msg.Date), 0), Valid: true},
})
if err != nil {
return fmt.Errorf("failed to save telegram message with %w", err)
}
return tx.Commit(ctx)
})
d.OnNewMessage(func(ctx context.Context, e tg.Entities, u *tg.UpdateNewMessage) error {
msg, ok := u.Message.(*tg.Message)
if !ok {
return nil
}
chat, ok := msg.PeerID.(*tg.PeerChat)
if !ok {
return fmt.Errorf("unexpected chat message peer %#v", msg)
}
if slices.IndexFunc(subscribeTo, func(s persist.FindTelegramPeersRow) bool {
return s.ID == chat.ChatID
}) == -1 {
return nil
}
err = q.SaveTelegramMessage(ctx, persist.SaveTelegramMessageParams{
ID: int32(msg.ID),
PeerID: chat.ChatID,
Message: msg.Message,
CreatedAt: pgtype.Timestamptz{Time: time.Unix(int64(msg.Date), 0), Valid: true},
})
if err != nil {
return fmt.Errorf("failed to save telegram message with %w", err)
}
return nil
})
return nil
}
func Setup(ctx context.Context, api *tg.Client, db *pgxpool.Pool, g *genkit.Genkit) error {
q := persist.New(db)
chats, err := q.FindTelegramPeers(ctx)
if err != nil {
return fmt.Errorf("failed to get peers to follow with %w", err)
}
dialogs, err := api.MessagesGetDialogs(ctx, &tg.MessagesGetDialogsRequest{
OffsetPeer: &tg.InputPeerEmpty{},
Limit: 100,
})
if err != nil {
return fmt.Errorf("failed to get dialogs with %w", err)
}
modifiedDialogs, ok := dialogs.AsModified()
if !ok {
return fmt.Errorf("got unexpected dialogs value %#v", dialogs)
}
foundChats := make([]bool, len(chats))
for _, chat := range modifiedDialogs.GetChats() {
chat, ok := chat.AsNotEmpty()
if !ok {
slog.Error("chat is empty, skipping", "value", chat)
continue
}
chatIdx := slices.IndexFunc(chats, func(c persist.FindTelegramPeersRow) bool {
return c.ID == chat.GetID()
})
if chatIdx < 0 {
slog.Debug("skipping unknown chat", "title", chat.GetTitle(), "id", chat.GetID())
continue
}
channel, ok := chat.(*tg.Channel)
if ok {
topics, err := getForumTopics(ctx, api, channel.ID, channel.AccessHash)
if err != nil {
slog.Error("chat", "value", fmt.Sprintf("%#v", chat))
return err
}
tx, err := db.Begin(ctx)
if err != nil {
return fmt.Errorf("faield to begin transaction with %w", err)
}
defer tx.Rollback(ctx)
qtx := q.WithTx(tx)
for _, topic := range topics {
_, err := q.TelegramTopicExists(ctx, persist.TelegramTopicExistsParams{
PeerID: channel.ID,
ID: int32(topic.ID),
})
switch err {
default:
return fmt.Errorf("failed to find telegram topic description with %w", err)
case nil:
case pgx.ErrNoRows:
messages, err := processNewTopic(ctx, g, qtx, api, channel, topic)
if err != nil {
return fmt.Errorf("failed to process new topic with %w", err)
}
for _, msg := range messages {
err = qtx.SaveTelegramMessage(ctx, persist.SaveTelegramMessageParams{
ID: int32(msg.ID),
TopicID: pgtype.Int4{Int32: int32(topic.ID), Valid: true},
PeerID: channel.ID,
Message: msg.Message, CreatedAt: pgtype.Timestamptz{Time: time.Unix(int64(msg.Date), 0), Valid: true},
})
if err != nil {
return fmt.Errorf("failed to save topic message %#v with %w", msg, err)
}
}
time.Sleep(7 * time.Second)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("failed to commit new topic transaction with %w", err)
}
}
foundChats[chatIdx] = true
}
var errs []error
for i, found := range foundChats {
if found {
continue
}
errs = append(errs, fmt.Errorf("chat was not found in the current account: %#v", chats[i]))
}
if len(errs) > 0 {
return errors.Join(errs...)
}
slog.Info("all required chats were found")
return nil
}
func getForumTopics(ctx context.Context, api *tg.Client, chatID, accessHash int64) ([]*tg.ForumTopic, error) {
resp, err := api.ChannelsGetForumTopics(ctx, &tg.ChannelsGetForumTopicsRequest{
Channel: &tg.InputChannel{
ChannelID: chatID,
AccessHash: accessHash,
},
})
if err != nil {
return nil, fmt.Errorf("failed to list channel topics with %w", err)
}
slog.Info("resolved topics", "length", len(resp.Topics))
topics := make([]*tg.ForumTopic, len(resp.Topics))
for i, topic := range resp.Topics {
topic, ok := topic.(*tg.ForumTopic)
if !ok {
return nil, fmt.Errorf("unexpected type of forum topic: %#v", topic)
}
topics[i] = topic
}
return topics, nil
}
func processNewTopic(ctx context.Context, g *genkit.Genkit, q *persist.Queries, api *tg.Client, channel *tg.Channel, topic *tg.ForumTopic) ([]*tg.Message, error) {
msgReplies, err := api.MessagesGetReplies(ctx, &tg.MessagesGetRepliesRequest{
Peer: &tg.InputPeerChannel{
ChannelID: channel.ID,
AccessHash: channel.AccessHash,
},
MsgID: topic.ID,
Limit: 50,
})
time.Sleep(1 * time.Second)
if err != nil {
return nil, fmt.Errorf("failed to get forum topic's messages. topic '%s', chat '%s', with %w", topic.Title, channel.Title, err)
}
topicMessages, ok := msgReplies.(*tg.MessagesChannelMessages)
if !ok {
return nil, fmt.Errorf("unexpected topic messages response type %#v", msgReplies)
}
summary, err := extractMessagesSummary(ctx, g, topicMessages.Messages)
if err != nil {
return nil, fmt.Errorf("failed to extract messages summary with %w", err)
}
err = q.SaveTelegramTopic(ctx, persist.SaveTelegramTopicParams{
PeerID: channel.ID,
ID: int32(topic.ID),
Title: topic.Title,
Description: summary.Description,
})
if err != nil {
return nil, fmt.Errorf("failed to save telegram topic description with %w", err)
}
return summary.Messages, nil
}
type messagesSummary struct {
Description string
Messages []*tg.Message
}
func extractMessagesSummary(ctx context.Context, g *genkit.Genkit, messages []tg.MessageClass) (summary messagesSummary, _ error) {
prompt := genkit.LookupPrompt(g, telegramTopicDescriptionPrompt)
if prompt == nil {
return summary, fmt.Errorf("no prompt named '%s' found", telegramTopicDescriptionPrompt)
}
var input telegramTopicDescriptionInput
loop:
for _, msg := range messages {
switch msg := msg.(type) {
case *tg.Message:
if msg.Message == "" {
continue loop
}
summary.Messages = append(summary.Messages, msg)
input.Messages = append(input.Messages, topicMessage{From: msg.FromID.String(), Text: msg.Message})
case *tg.MessageService:
default:
slog.Warn("got unexpected message type", "value", fmt.Sprintf("%#v", msg))
}
}
slog.Info("extracting summary from non empty messages", "amount", len(input.Messages))
resp, err := prompt.Execute(ctx, ai.WithInput(input))
if err != nil {
return summary, fmt.Errorf("failed to describe messages '%#v' with %w", input, err)
}
slog.Info("got described topic", "text", resp.Text())
var output telegramTopicDescriptionOutput
if err := resp.Output(&output); err != nil {
return summary, fmt.Errorf("failed to deserialize LLM output with %w", err)
}
summary.Description = output.Description
return summary, nil
}
type telegramTopicDescriptionInput struct {
Messages []topicMessage `json:"messages"`
}
type topicMessage struct {
From string `json:"from"`
Text string `json:"text"`
}
type telegramTopicDescriptionOutput struct {
Description string `json:"description"`
}