mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Security & economy: - Credit()/Debit() reject non-positive amounts (closes infinite-money exploit) - Lottery ticket purchase uses in-transaction count check (closes TOCTOU race) - Arena bail channel ownership prevents double-close panic - Entry.ID validated before exec.Command in fetch-esteemed - Internal errors no longer leaked to users (forex, esteemed) Robustness: - safeGo() panic recovery added to 17 goroutine launch sites across 14 plugins - db.Close() added and called on shutdown - Miniflux mutex pattern fixed (snapshot-under-lock) - Silently ignored DB/JSON errors now logged (lottery_db) Performance: - Added indexes: idx_arena_runs_user(user_id, status), idx_euro_bal_user(user_id) UX: - Empty !buy args shows usage instead of "No item matching ''" - "Failed to load character" now suggests !adventure Test coverage: - internal/util/parser_test.go (19 tests: XP, levels, progress bar, commands, parsing) - internal/crypto/crypto_test.go (12 tests: encrypt/decrypt, HMAC, ParseKey) - internal/plugin/helpers_test.go (30 tests: formatNumber, calc, lottery, chat perks) - internal/plugin/audit_fixes_test.go (25 tests: safeGo, bail channels, error leaks, overflow) - internal/db/db_test.go (4 tests: Init, Close, schema indexes) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
210 lines
4.8 KiB
Go
210 lines
4.8 KiB
Go
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 <title> tag if no og:title
|
|
if title == "" {
|
|
title = doc.Find("title").First().Text()
|
|
}
|
|
|
|
// Fallback to meta description
|
|
if desc == "" {
|
|
doc.Find("meta").Each(func(_ int, s *goquery.Selection) {
|
|
name, _ := s.Attr("name")
|
|
if strings.EqualFold(name, "description") {
|
|
content, _ := s.Attr("content")
|
|
desc = content
|
|
}
|
|
})
|
|
}
|
|
|
|
return strings.TrimSpace(title), strings.TrimSpace(desc), nil
|
|
}
|