diff --git a/internal/karakeepbot/bookmark_type.go b/internal/karakeepbot/bookmark_type.go index 8517cd8..8c6c4f2 100644 --- a/internal/karakeepbot/bookmark_type.go +++ b/internal/karakeepbot/bookmark_type.go @@ -30,7 +30,9 @@ type Bookmark struct { // LinkBookmark represents a bookmark with a URL. type LinkBookmark struct { Bookmark - URL string `json:"url"` + URL string `json:"url"` + Title string `json:"title,omitempty"` + Note string `json:"note,omitempty"` } // NewLinkBookmark creates a new LinkBookmark with the given URL. @@ -50,6 +52,7 @@ func (lb LinkBookmark) String() string { type TextBookmark struct { Bookmark Text string `json:"text"` + Note string `json:"note,omitempty"` } // NewTextBookmark creates a new TextBookmark with the given text content. diff --git a/internal/karakeepbot/enricher.go b/internal/karakeepbot/enricher.go new file mode 100644 index 0000000..6fdce7e --- /dev/null +++ b/internal/karakeepbot/enricher.go @@ -0,0 +1,11 @@ +package karakeepbot + +import "context" + +// enrichBookmark adds Telegram origin metadata to a newly created bookmark. +// Currently attaches the #telegram tag. Non-fatal on failure. +func (kb *KarakeepBot) enrichBookmark(ctx context.Context, msg TelegramMessage, bookmark *KarakeepBookmark) { + if err := kb.karakeep.AddTag(ctx, bookmark.Id, "telegram"); err != nil { + kb.logger.Warn("Failed to add #telegram tag", "bookmark_id", bookmark.Id, "error", err) + } +} diff --git a/internal/karakeepbot/karakeep.go b/internal/karakeepbot/karakeep.go index 0688ac5..b78d4df 100644 --- a/internal/karakeepbot/karakeep.go +++ b/internal/karakeepbot/karakeep.go @@ -10,6 +10,7 @@ import ( "net/url" "os" "path/filepath" + "strings" "github.com/Madh93/go-karakeep" "github.com/Madh93/karakeepbot/internal/config" @@ -176,3 +177,19 @@ func (k Karakeep) CreateAsset(ctx context.Context, filePath string, mimeType str asset := KarakeepAsset(*response.JSON200) return &asset, nil } + +// AddTag attaches a human-attached tag to an existing bookmark. +func (k Karakeep) AddTag(ctx context.Context, bookmarkID string, tagName string) error { + payload := fmt.Sprintf(`{"tags":[{"tagName":"%s"}]}`, tagName) + body := strings.NewReader(payload) + + response, err := k.PostBookmarksBookmarkIdTagsWithBodyWithResponse(ctx, bookmarkID, "application/json", body) + if err != nil { + return fmt.Errorf("failed to add tag: %w", err) + } + if response.StatusCode() != http.StatusOK { + return fmt.Errorf("failed to add tag, received HTTP status: %s", response.Status()) + } + + return nil +} diff --git a/internal/karakeepbot/karakeepbot.go b/internal/karakeepbot/karakeepbot.go index 66de656..b6c10dd 100644 --- a/internal/karakeepbot/karakeepbot.go +++ b/internal/karakeepbot/karakeepbot.go @@ -22,6 +22,10 @@ import ( "github.com/Madh93/karakeepbot/internal/validation" ) +// maxTagRetries is the number of times to wait for Karakeep AI tagging to +// complete before proceeding. At 5s per retry, 6 retries = ~30s timeout. +const maxTagRetries = 6 + // KarakeepBot represents the bot with its dependencies, including the Karakeep // client, Telegram bot, logger and other options. type KarakeepBot struct { @@ -125,22 +129,17 @@ func (kb KarakeepBot) handler(ctx context.Context, _ *Bot, update *TelegramUpdat } kb.logger.Info("Created bookmark", bookmark.Attrs()...) - // Wait until bookmark tags are updated + // Enrich bookmark with Telegram origin metadata + kb.logger.Debug("Enriching bookmark with Telegram origin metadata", bookmark.Attrs()...) + kb.enrichBookmark(ctx, msg, bookmark) + + // Wait until bookmark tags are updated (with a timeout to avoid hanging on + // uncrawlable URLs) kb.logger.Debug("Waiting for bookmark tags to be updated", bookmark.Attrs()...) - for { - bookmark, err = kb.karakeep.RetrieveBookmarkById(ctx, bookmark.Id) - if err != nil { - kb.logger.Error("Failed to retrieve bookmark", "error", err) - return - } - if *bookmark.TaggingStatus == karakeep.BookmarkTaggingStatusSuccess { - break - } else if *bookmark.TaggingStatus == karakeep.BookmarkTaggingStatusFailure { - kb.logger.Error("Failed to update bookmark tags", bookmark.AttrsWithError(err)...) - return - } - kb.logger.Debug(fmt.Sprintf("Bookmark is still pending, waiting %d seconds before retrying", kb.waitInterval), bookmark.Attrs()...) - time.Sleep(time.Duration(kb.waitInterval) * time.Second) + bookmark, err = kb.waitForTagCompletion(ctx, bookmark) + if err != nil { + kb.logger.Error("Failed to wait for bookmark tagging", "error", err) + return } // Get hashtags @@ -200,18 +199,130 @@ func (kb KarakeepBot) isThreadIdAllowed(threadId int) bool { return len(kb.threads) == 0 || slices.Contains(kb.threads, threadId) } -// parseMessage parses the incoming Telegram message and returns the corresponding Bookmark type. +// waitForTagCompletion polls the bookmark tagging status until it succeeds, +// fails, or the retry timeout is reached. Returns the updated bookmark. +func (kb *KarakeepBot) waitForTagCompletion(ctx context.Context, bookmark *KarakeepBookmark) (*KarakeepBookmark, error) { + retries := 0 + for { + var err error + bookmark, err = kb.karakeep.RetrieveBookmarkById(ctx, bookmark.Id) + if err != nil { + return nil, err + } + if *bookmark.TaggingStatus == karakeep.BookmarkTaggingStatusSuccess { + return bookmark, nil + } + if *bookmark.TaggingStatus == karakeep.BookmarkTaggingStatusFailure { + return nil, fmt.Errorf("bookmark tagging failed") + } + retries++ + if retries >= maxTagRetries { + kb.logger.Warn("Bookmark tagging did not complete within timeout, proceeding anyway", bookmark.Attrs()...) + return bookmark, nil + } + kb.logger.Debug(fmt.Sprintf("Bookmark is still pending, waiting %d seconds before retrying", kb.waitInterval), bookmark.Attrs()...) + time.Sleep(time.Duration(kb.waitInterval) * time.Second) + } +} + +// parseMessage parses the incoming Telegram message and returns the +// corresponding Bookmark type. func (kb KarakeepBot) parseMessage(ctx context.Context, msg TelegramMessage) (BookmarkType, error) { - switch { - case msg.Photo != nil: + if msg.Photo != nil { return kb.handlePhotoMessage(ctx, msg) - case validation.ValidateURL(msg.Text) == nil: - return NewLinkBookmark(msg.Text), nil - case msg.Text != "": - return NewTextBookmark(msg.Text), nil - default: - return nil, errors.New("unsupported bookmark type") } + + if url := msg.ChannelPostLink(); url != "" { + lb := NewLinkBookmark(url) + lb.Title = extractTitle(msg.Text) + + var parts []string + if text := strings.TrimSpace(msg.Text); text != "" { + parts = append(parts, text) + } + if entityURLs := msg.EntityURLs(); len(entityURLs) > 0 { + parts = append(parts, "Links:\n"+strings.Join(entityURLs, "\n")) + } + if ctxNote := msg.ContextNote(); ctxNote != "" { + parts = append(parts, ctxNote) + } + lb.Note = strings.Join(parts, "\n\n") + + return lb, nil + } + + if url := msg.ExtractURL(); url != "" { + return newSimpleLinkBookmark(url, msg), nil + } + + if validation.ValidateURL(msg.Text) == nil { + return newSimpleLinkBookmark(msg.Text, msg), nil + } + + if url := extractEmbeddedURL(msg.Text); url != "" { + return newSimpleLinkBookmark(url, msg), nil + } + + if msg.Text != "" { + tb := NewTextBookmark(msg.Text) + if ctxNote := msg.ContextNote(); ctxNote != "" { + tb.Note = ctxNote + } + return tb, nil + } + + return nil, errors.New("unsupported bookmark type") +} + +// newSimpleLinkBookmark creates a LinkBookmark from a URL and message, with +// title extracted from the first line and a note combining the message text +// with Telegram origin context. +func newSimpleLinkBookmark(url string, msg TelegramMessage) *LinkBookmark { + lb := NewLinkBookmark(url) + lb.Title = extractTitle(msg.Text) + lb.Note = buildNote(msg.Text, msg.ContextNote()) + return lb +} + +// extractTitle returns the first line of text as a title, truncated to 150 +// characters to avoid Karakeep limits. +func extractTitle(text string) string { + text = strings.TrimSpace(text) + if text == "" { + return "" + } + lines := strings.SplitN(text, "\n", 2) + title := strings.TrimSpace(lines[0]) + runes := []rune(title) + if len(runes) > 150 { + title = string(runes[:150]) + "..." + } + return title +} + +// buildNote combines the original message text with a context note. +func buildNote(text, contextNote string) string { + text = strings.TrimSpace(text) + if text == "" { + return contextNote + } + if contextNote == "" { + return text + } + return text + "\n\n" + contextNote +} + +// extractEmbeddedURL finds the first http or https URL within arbitrary text. +// Returns empty string if no URL is found. +func extractEmbeddedURL(text string) string { + words := strings.Fields(text) + for _, word := range words { + word = strings.TrimRight(word, ",.!?:;)]}") + if err := validation.ValidateURL(word); err == nil { + return word + } + } + return "" } // handlePhotoMessage processes a message containing a photo. @@ -262,8 +373,15 @@ func (kb *KarakeepBot) handlePhotoMessage(ctx context.Context, msg TelegramMessa kb.logger.Debug("Asset uploaded successfully", "asset_id", asset.AssetId) - // Get note from caption + // Get note from caption and append Telegram origin context note := strings.TrimSpace(msg.Caption) - - return NewAssetBookmark(asset.AssetId, ImageAssetType, note), nil + ab := NewAssetBookmark(asset.AssetId, ImageAssetType, note) + if ctxNote := msg.ContextNote(); ctxNote != "" { + if ab.Note != "" { + ab.Note += "\n\n" + ctxNote + } else { + ab.Note = ctxNote + } + } + return ab, nil } diff --git a/internal/karakeepbot/telegram_message.go b/internal/karakeepbot/telegram_message.go index 4a0223f..763c754 100644 --- a/internal/karakeepbot/telegram_message.go +++ b/internal/karakeepbot/telegram_message.go @@ -2,6 +2,8 @@ package karakeepbot import ( "fmt" + "strings" + "time" "github.com/go-telegram/bot/models" ) @@ -44,6 +46,12 @@ func (tm TelegramMessage) Attrs() []any { } } + if tm.ForwardOrigin != nil { + if sourceType, displayName := tm.authorInfo(); sourceType != "" { + attrs = append(attrs, "forward_origin", fmt.Sprintf("%s %s", sourceType, displayName)) + } + } + return attrs } @@ -52,3 +60,105 @@ func (tm TelegramMessage) Attrs() []any { func (tm TelegramMessage) AttrsWithError(err error) []any { return append(tm.Attrs(), "error", err) } + +// ExtractURL returns the first URL found in message entities of type text_link. +// Returns empty string if no text_link entity exists. +func (tm TelegramMessage) ExtractURL() string { + for _, entity := range tm.Entities { + if entity.Type == models.MessageEntityTypeTextLink && entity.URL != "" { + return entity.URL + } + } + return "" +} + +// EntityURLs returns all unique URLs found in message entities of type text_link. +func (tm TelegramMessage) EntityURLs() []string { + seen := make(map[string]struct{}) + var urls []string + for _, entity := range tm.Entities { + if entity.Type == models.MessageEntityTypeTextLink && entity.URL != "" { + if _, ok := seen[entity.URL]; !ok { + seen[entity.URL] = struct{}{} + urls = append(urls, entity.URL) + } + } + } + return urls +} + +// authorInfo returns the origin type and display name for the message author. +// For forwarded messages it uses the forward origin; for direct messages it +// falls back to the sender. Returns empty strings when no author info exists. +func (tm TelegramMessage) authorInfo() (sourceType, displayName string) { + switch { + case tm.ForwardOrigin != nil && tm.ForwardOrigin.MessageOriginChannel != nil: + ch := tm.ForwardOrigin.MessageOriginChannel + if ch.Chat.Username != "" { + return "channel", "@" + ch.Chat.Username + } + return "channel", ch.Chat.Title + case tm.ForwardOrigin != nil && tm.ForwardOrigin.MessageOriginUser != nil: + u := tm.ForwardOrigin.MessageOriginUser.SenderUser + if u.Username != "" { + return "user", "@" + u.Username + } + return "user", strings.TrimSpace(u.FirstName + " " + u.LastName) + case tm.ForwardOrigin != nil && tm.ForwardOrigin.MessageOriginHiddenUser != nil: + return "hidden_user", tm.ForwardOrigin.MessageOriginHiddenUser.SenderUserName + case tm.ForwardOrigin != nil && tm.ForwardOrigin.MessageOriginChat != nil: + sc := tm.ForwardOrigin.MessageOriginChat.SenderChat + if sc.Username != "" { + return "chat", "@" + sc.Username + } + return "chat", sc.Title + case tm.From != nil: + if tm.From.Username != "" { + return "direct", "@" + tm.From.Username + } + return "direct", strings.TrimSpace(tm.From.FirstName + " " + tm.From.LastName) + } + return "", "" +} + +// ChannelPostLink constructs the t.me link for a forwarded channel post. +// Returns "https://t.me/{username}/{messageID}" or empty string if the +// message is not forwarded from a channel with a username. +func (tm TelegramMessage) ChannelPostLink() string { + if tm.ForwardOrigin == nil || tm.ForwardOrigin.MessageOriginChannel == nil { + return "" + } + ch := tm.ForwardOrigin.MessageOriginChannel + if ch.Chat.Username == "" { + return "" + } + return fmt.Sprintf("https://t.me/%s/%d", ch.Chat.Username, ch.MessageID) +} + +// ContextNote builds a short note describing the Telegram origin of the +// bookmark. +func (tm TelegramMessage) ContextNote() string { + var b strings.Builder + + b.WriteString("📎 From Telegram\n") + + if _, displayName := tm.authorInfo(); displayName != "" { + fmt.Fprintf(&b, "✍️ %s\n", displayName) + } + + if tm.Chat.Title != "" { + fmt.Fprintf(&b, "💬 %s\n", tm.Chat.Title) + } + + if tm.Date > 0 { + t := time.Unix(int64(tm.Date), 0) + fmt.Fprintf(&b, "📅 %s\n", t.Format("2006-01-02 15:04")) + } + + return strings.TrimRight(b.String(), "\n") +} + +// MessageTime returns the message timestamp as time.Time. +func (tm TelegramMessage) MessageTime() time.Time { + return time.Unix(int64(tm.Date), 0) +}