Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion internal/karakeepbot/bookmark_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions internal/karakeepbot/enricher.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
17 changes: 17 additions & 0 deletions internal/karakeepbot/karakeep.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"net/url"
"os"
"path/filepath"
"strings"

"github.com/Madh93/go-karakeep"
"github.com/Madh93/karakeepbot/internal/config"
Expand Down Expand Up @@ -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
}
172 changes: 145 additions & 27 deletions internal/karakeepbot/karakeepbot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
110 changes: 110 additions & 0 deletions internal/karakeepbot/telegram_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package karakeepbot

import (
"fmt"
"strings"
"time"

"github.com/go-telegram/bot/models"
)
Expand Down Expand Up @@ -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
}

Expand All @@ -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)
}