package plugin import ( "fmt" "log/slog" "net/http" "os" "regexp" "strings" "time" "gogobee/internal/db" "github.com/PuerkitoBio/goquery" "maunium.net/go/mautrix" ) var urlRe = regexp.MustCompile(`https?://[^\s<>"]+`) // URLsPlugin detects URLs in messages and previews og:title/og:description. type URLsPlugin struct { Base enabled bool ignoreUsers map[string]struct{} httpClient *http.Client } // NewURLsPlugin creates a new URL preview plugin. func NewURLsPlugin(client *mautrix.Client) *URLsPlugin { enabled := os.Getenv("FEATURE_URL_PREVIEW") != "" ignore := make(map[string]struct{}) if raw := os.Getenv("URL_PREVIEW_IGNORE_USERS"); raw != "" { for _, u := range strings.Split(raw, ",") { if u = strings.TrimSpace(u); u != "" { ignore[u] = struct{}{} } } } return &URLsPlugin{ Base: NewBase(client), enabled: enabled, ignoreUsers: ignore, httpClient: &http.Client{ Timeout: 3 * time.Second, }, } } func (p *URLsPlugin) Name() string { return "urls" } func (p *URLsPlugin) Commands() []CommandDef { return nil // No commands — purely passive } func (p *URLsPlugin) Init() error { return nil } func (p *URLsPlugin) OnReaction(_ ReactionContext) error { return nil } func (p *URLsPlugin) OnMessage(ctx MessageContext) error { if !p.enabled { return nil } // Skip command messages if ctx.IsCommand { return nil } // Skip ignored users (e.g. news bots) if _, ok := p.ignoreUsers[string(ctx.Sender)]; ok { return nil } allURLs := urlRe.FindAllString(ctx.Body, -1) // Filter out Matrix internal links (user mentions, room links, etc.) var urls []string for _, u := range allURLs { if !strings.Contains(u, "matrix.to/") { urls = append(urls, u) } } // Only preview if there is exactly one URL; skip multi-URL messages if len(urls) != 1 { return nil } safeGo("url-preview", func() { p.previewURL(ctx, urls[0]) }) return nil } func (p *URLsPlugin) previewURL(ctx MessageContext, targetURL string) { title, desc, err := p.fetchPreview(targetURL) if err != nil { slog.Debug("urls: fetch preview failed", "url", targetURL, "err", err) return } if title == "" && desc == "" { return } var preview strings.Builder if title != "" { preview.WriteString(fmt.Sprintf("Title: %s", title)) } if desc != "" { if preview.Len() > 0 { preview.WriteString("\n") } // Truncate long descriptions if len(desc) > 200 { desc = desc[:200] + "..." } preview.WriteString(fmt.Sprintf("Description: %s", desc)) } if err := p.SendReply(ctx.RoomID, ctx.EventID, preview.String()); err != nil { slog.Error("urls: send preview", "err", err) } } // fetchPreview retrieves og:title and og:description, checking cache first. func (p *URLsPlugin) fetchPreview(rawURL string) (string, string, error) { d := db.Get() now := time.Now().UTC().Unix() cacheTTL := int64(24 * 60 * 60) // Check cache var title, desc string var cachedAt int64 err := d.QueryRow( `SELECT title, description, cached_at FROM url_cache WHERE url = ?`, rawURL, ).Scan(&title, &desc, &cachedAt) if err == nil && now-cachedAt < cacheTTL { return title, desc, nil } // Fetch from web title, desc, err = p.scrapeOG(rawURL) if err != nil { return "", "", err } // Cache the result db.Exec("urls: cache write", `INSERT INTO url_cache (url, title, description, cached_at) VALUES (?, ?, ?, ?) ON CONFLICT(url) DO UPDATE SET title = ?, description = ?, cached_at = ?`, rawURL, title, desc, now, title, desc, now, ) return title, desc, nil } // scrapeOG fetches a URL and extracts og:title and og:description. func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, error) { req, err := http.NewRequest("GET", rawURL, nil) if err != nil { return "", "", fmt.Errorf("create request: %w", err) } req.Header.Set("User-Agent", "GogoBee Bot/1.0") resp, err := p.httpClient.Do(req) if err != nil { return "", "", fmt.Errorf("fetch: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", "", fmt.Errorf("status %d", resp.StatusCode) } doc, err := goquery.NewDocumentFromReader(resp.Body) if err != nil { return "", "", fmt.Errorf("parse HTML: %w", err) } title := "" desc := "" doc.Find("meta").Each(func(_ int, s *goquery.Selection) { prop, _ := s.Attr("property") content, _ := s.Attr("content") switch prop { case "og:title": title = content case "og:description": desc = content } }) // Fallback to