mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Compare commits
5 Commits
phase-H-ha
...
link-thumb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97b8f4584f | ||
|
|
6e4928ca17 | ||
|
|
72f4ef3b27 | ||
|
|
6cda1cac38 | ||
|
|
1f6c05a565 |
@@ -7,6 +7,11 @@ BOT_DISPLAY_NAME=GogoBee
|
||||
# Which rooms the bot posts scheduled content to (comma-separated room IDs)
|
||||
BROADCAST_ROOMS=!roomid:example.com
|
||||
|
||||
# The daily 08:00 WOTD auto-post is disabled by default.
|
||||
# Set to true to enable it. The !wotd command and passive WOTD
|
||||
# usage tracking work regardless of this setting.
|
||||
ENABLE_WOTD_POST=false
|
||||
|
||||
# Admins who can run admin-only commands (comma-separated Matrix user IDs)
|
||||
ADMIN_USERS=@yourmxid:example.com
|
||||
|
||||
|
||||
@@ -333,6 +333,9 @@ func runMigrations(d *sql.DB) error {
|
||||
// engages when a fork / elite / boss / supply pinch actually
|
||||
// needs a decision. CAS-claim on this column gates re-entry.
|
||||
`ALTER TABLE dnd_expedition ADD COLUMN last_autorun_at DATETIME`,
|
||||
// URL link previews now post the page's og:image/twitter:image
|
||||
// thumbnail; cache it alongside the title/description.
|
||||
`ALTER TABLE url_cache ADD COLUMN image_url TEXT NOT NULL DEFAULT ''`,
|
||||
}
|
||||
for _, stmt := range columnMigrations {
|
||||
if _, err := d.Exec(stmt); err != nil {
|
||||
@@ -1124,6 +1127,7 @@ CREATE TABLE IF NOT EXISTS url_cache (
|
||||
url TEXT PRIMARY KEY,
|
||||
title TEXT DEFAULT '',
|
||||
description TEXT DEFAULT '',
|
||||
image_url TEXT NOT NULL DEFAULT '',
|
||||
cached_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
@@ -2041,15 +2045,15 @@ func SeedSchedulerDefaults(d *sql.DB) error {
|
||||
name string
|
||||
cron string
|
||||
}{
|
||||
{"prefetch", "5 0 * * *"}, // 00:05 daily
|
||||
{"maintenance", "0 3 * * *"}, // 03:00 daily
|
||||
{"wotd", "0 8 * * *"}, // 08:00 daily
|
||||
{"holidays", "0 7 * * *"}, // 07:00 daily
|
||||
{"releases", "0 9 * * 1"}, // 09:00 Monday
|
||||
{"birthday_check", "0 6 * * *"}, // 06:00 daily
|
||||
{"anime_releases", "0 10 * * *"},// 10:00 daily
|
||||
{"movie_releases", "0 11 * * *"},// 11:00 daily
|
||||
{"concert_digest", "0 12 * * 0"},// 12:00 Sunday
|
||||
{"prefetch", "5 0 * * *"}, // 00:05 daily
|
||||
{"maintenance", "0 3 * * *"}, // 03:00 daily
|
||||
{"wotd", "0 8 * * *"}, // 08:00 daily
|
||||
{"holidays", "0 7 * * *"}, // 07:00 daily
|
||||
{"releases", "0 9 * * 1"}, // 09:00 Monday
|
||||
{"birthday_check", "0 6 * * *"}, // 06:00 daily
|
||||
{"anime_releases", "0 10 * * *"}, // 10:00 daily
|
||||
{"movie_releases", "0 11 * * *"}, // 11:00 daily
|
||||
{"concert_digest", "0 12 * * 0"}, // 12:00 Sunday
|
||||
}
|
||||
|
||||
stmt, err := d.Prepare(`INSERT OR IGNORE INTO scheduler_config (job_name, cron_expr) VALUES (?, ?)`)
|
||||
|
||||
@@ -113,6 +113,7 @@ const fxHelpText = "**Forex Commands**\n\n" +
|
||||
"`!fx rate EUR/USD` — cross-pair rate from first currency's perspective\n" +
|
||||
"`!fx report [EUR|JPY|CAD]` — full analysis (averages, 52w range, buy score)\n" +
|
||||
"`!fx 1500 USD to EUR` — convert an amount (also: `!fx convert 1500 USD EUR`)\n" +
|
||||
"`!fx 100 USD to BTC` — convert to/from crypto (BTC, ETH, SOL, XRP, DOGE, ADA, LTC)\n" +
|
||||
"`!fx setalert <currency> <rate>` — alert when USD/currency reaches threshold\n" +
|
||||
"`!fx alerts` — list active alerts in this room\n" +
|
||||
"`!fx delalert <currency> <rate>` — remove an alert\n" +
|
||||
@@ -122,7 +123,7 @@ const fxHelpText = "**Forex Commands**\n\n" +
|
||||
|
||||
func (p *ForexPlugin) cmdRate(ctx MessageContext, args []string) error {
|
||||
// Check for cross-pair syntax like EUR/USD or USD/JPY
|
||||
if pair := fxParsePair(args); pair != nil {
|
||||
if pair := fxParsePair(args, false); pair != nil {
|
||||
return p.cmdPairRateOrReport(ctx, pair, false)
|
||||
}
|
||||
|
||||
@@ -156,8 +157,10 @@ type fxPair struct {
|
||||
}
|
||||
|
||||
// fxParsePair detects "XXX/YYY" syntax in args. Both sides must be USD or a
|
||||
// tracked currency. Returns nil if no pair is found.
|
||||
func fxParsePair(args []string) *fxPair {
|
||||
// tracked currency; when allowCrypto is set, supported crypto assets are
|
||||
// accepted too (used by the conversion path, not by analysis). Returns nil if
|
||||
// no pair is found.
|
||||
func fxParsePair(args []string, allowCrypto bool) *fxPair {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -174,7 +177,11 @@ func fxParsePair(args []string) *fxPair {
|
||||
return nil
|
||||
}
|
||||
base, quote := parts[0], parts[1]
|
||||
if !fxIsSupported(base) || !fxIsSupported(quote) {
|
||||
ok := fxIsSupported
|
||||
if allowCrypto {
|
||||
ok = fxIsConvertible
|
||||
}
|
||||
if !ok(base) || !ok(quote) {
|
||||
return nil
|
||||
}
|
||||
if base == quote {
|
||||
@@ -215,46 +222,62 @@ func (p *ForexPlugin) cmdPairRateOrReport(ctx MessageContext, pair *fxPair, full
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sig.FormatQuick())
|
||||
}
|
||||
|
||||
// fxLivePairRate computes the live cross-pair rate from USD-base rates.
|
||||
// fxLivePairRate computes the live cross-pair rate (how many Quote per 1 Base).
|
||||
// Each side is resolved to a "units per USD" figure, then crossed; this handles
|
||||
// USD, fiat (Frankfurter), and crypto (CoinGecko) sides uniformly.
|
||||
func (p *ForexPlugin) fxLivePairRate(pair *fxPair) (float64, error) {
|
||||
var currencies []string
|
||||
if pair.Base != "USD" {
|
||||
currencies = append(currencies, pair.Base)
|
||||
// Batch the fiat sides into a single Frankfurter call.
|
||||
var fiat []string
|
||||
for _, c := range []string{pair.Base, pair.Quote} {
|
||||
if c != "USD" && !fxIsCrypto(c) {
|
||||
fiat = append(fiat, c)
|
||||
}
|
||||
}
|
||||
if pair.Quote != "USD" {
|
||||
currencies = append(currencies, pair.Quote)
|
||||
var fiatRates map[string]float64
|
||||
if len(fiat) > 0 {
|
||||
var err error
|
||||
if fiatRates, err = p.fxLiveRates(fiat); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
rates, err := p.fxLiveRates(currencies)
|
||||
basePer, err := p.fxPerUSD(pair.Base, fiatRates)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
quotePer, err := p.fxPerUSD(pair.Quote, fiatRates)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if basePer == 0 {
|
||||
return 0, fmt.Errorf("missing rate data for cross-pair")
|
||||
}
|
||||
return quotePer / basePer, nil
|
||||
}
|
||||
|
||||
// fxPerUSD returns how many units of cur equal 1 USD. Fiat rates are taken from
|
||||
// the pre-fetched fiatRates map; crypto is fetched live from CoinGecko.
|
||||
func (p *ForexPlugin) fxPerUSD(cur string, fiatRates map[string]float64) (float64, error) {
|
||||
switch {
|
||||
case pair.Base == "USD":
|
||||
r, ok := rates[pair.Quote]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("no rate data for %s", pair.Quote)
|
||||
case cur == "USD":
|
||||
return 1.0, nil
|
||||
case fxIsCrypto(cur):
|
||||
price, err := p.cgSpotUSD(cur)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 1.0 / price, nil
|
||||
default:
|
||||
r, ok := fiatRates[cur]
|
||||
if !ok || r == 0 {
|
||||
return 0, fmt.Errorf("no rate data for %s", cur)
|
||||
}
|
||||
return r, nil
|
||||
case pair.Quote == "USD":
|
||||
r, ok := rates[pair.Base]
|
||||
if !ok || r == 0 {
|
||||
return 0, fmt.Errorf("no rate data for %s", pair.Base)
|
||||
}
|
||||
return 1.0 / r, nil
|
||||
default:
|
||||
br, ok1 := rates[pair.Base]
|
||||
qr, ok2 := rates[pair.Quote]
|
||||
if !ok1 || !ok2 || br == 0 {
|
||||
return 0, fmt.Errorf("missing rate data for cross-pair")
|
||||
}
|
||||
return qr / br, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ForexPlugin) cmdReport(ctx MessageContext, args []string) error {
|
||||
if pair := fxParsePair(args); pair != nil {
|
||||
if pair := fxParsePair(args, false); pair != nil {
|
||||
return p.cmdPairRateOrReport(ctx, pair, true)
|
||||
}
|
||||
currencies := fxParseCurrencies(args)
|
||||
|
||||
@@ -34,8 +34,12 @@ func fxIsTracked(cur string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// fxFormatRate formats a rate: JPY uses 2 decimal places, others use 4.
|
||||
// fxFormatRate formats a rate: crypto uses up to 8 decimals (trailing zeros
|
||||
// trimmed) for tiny per-USD figures, JPY uses 2, others use 4.
|
||||
func fxFormatRate(currency string, rate float64) string {
|
||||
if fxIsCrypto(currency) {
|
||||
return fxTrimZeros(fmt.Sprintf("%.8f", rate))
|
||||
}
|
||||
if currency == "JPY" {
|
||||
return fmt.Sprintf("%.2f", rate)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func fxParseConvert(args []string) (amount float64, base, quote string, err erro
|
||||
for _, tok := range tokens {
|
||||
// Pair form: USD/EUR
|
||||
if strings.ContainsAny(tok, "/|-") {
|
||||
pair := fxParsePair([]string{tok})
|
||||
pair := fxParsePair([]string{tok}, true)
|
||||
if pair != nil {
|
||||
if base != "" {
|
||||
return 0, "", "", fmt.Errorf("multiple currency pairs")
|
||||
@@ -76,9 +76,9 @@ func fxParseConvert(args []string) (amount float64, base, quote string, err erro
|
||||
amountSet = true
|
||||
continue
|
||||
}
|
||||
// Currency code?
|
||||
// Currency or crypto code?
|
||||
up := strings.ToUpper(tok)
|
||||
if fxIsSupported(up) {
|
||||
if fxIsConvertible(up) {
|
||||
currencies = append(currencies, up)
|
||||
continue
|
||||
}
|
||||
@@ -127,12 +127,15 @@ func (p *ForexPlugin) cmdConvert(ctx MessageContext, args []string) error {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
// fxEmoji returns the flag emoji for a supported currency, falling back
|
||||
// to USD's flag.
|
||||
// fxEmoji returns the flag/symbol emoji for a supported currency or crypto
|
||||
// asset, falling back to USD's flag.
|
||||
func fxEmoji(cur string) string {
|
||||
if m, ok := fxMeta[cur]; ok && m.Emoji != "" {
|
||||
return m.Emoji
|
||||
}
|
||||
if c, ok := fxCryptoMeta[strings.ToUpper(cur)]; ok && c.Emoji != "" {
|
||||
return c.Emoji
|
||||
}
|
||||
if cur == "USD" {
|
||||
return "🇺🇸"
|
||||
}
|
||||
@@ -140,9 +143,13 @@ func fxEmoji(cur string) string {
|
||||
}
|
||||
|
||||
// fxFormatAmount formats a converted amount with thousands separators.
|
||||
// JPY uses 0 decimal places (yen are not subdivided in practice for display),
|
||||
// other currencies use 2.
|
||||
// Crypto uses up to 8 decimals (trailing zeros trimmed) since amounts are
|
||||
// often fractional; JPY uses 0 (yen are not subdivided in practice for
|
||||
// display); other currencies use 2.
|
||||
func fxFormatAmount(currency string, amount float64) string {
|
||||
if fxIsCrypto(currency) {
|
||||
return fxTrimZeros(fxAddCommas(amount, 8))
|
||||
}
|
||||
decimals := 2
|
||||
if currency == "JPY" {
|
||||
decimals = 0
|
||||
@@ -150,6 +157,16 @@ func fxFormatAmount(currency string, amount float64) string {
|
||||
return fxAddCommas(amount, decimals)
|
||||
}
|
||||
|
||||
// fxTrimZeros drops trailing fractional zeros (and a bare trailing dot) from a
|
||||
// formatted decimal string, leaving integers untouched.
|
||||
func fxTrimZeros(s string) string {
|
||||
if !strings.Contains(s, ".") {
|
||||
return s
|
||||
}
|
||||
s = strings.TrimRight(s, "0")
|
||||
return strings.TrimRight(s, ".")
|
||||
}
|
||||
|
||||
// fxAddCommas formats a float with N decimal places and thousands separators.
|
||||
func fxAddCommas(v float64, decimals int) string {
|
||||
s := strconv.FormatFloat(v, 'f', decimals, 64)
|
||||
|
||||
119
internal/plugin/forex_crypto.go
Normal file
119
internal/plugin/forex_crypto.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── Crypto support (CoinGecko, keyless) ──────────────────────────────────────
|
||||
//
|
||||
// CoinGecko's public simple/price endpoint needs no API key and no usage tier,
|
||||
// matching Frankfurter's keyless model. Crypto is wired into the *conversion*
|
||||
// path only (`!fx 100 USD to BTC`). The rate/report/alert analysis stays
|
||||
// fiat-only on purpose: a daily-snapshot "buy signal" or 52-week range is
|
||||
// meaningless for 24/7 crypto markets.
|
||||
|
||||
type fxCryptoInfo struct {
|
||||
CoinGeckoID string
|
||||
Emoji string
|
||||
Label string
|
||||
}
|
||||
|
||||
// fxCryptoMeta maps a ticker to its CoinGecko coin ID and display metadata.
|
||||
// Add a row here to support another asset.
|
||||
var fxCryptoMeta = map[string]fxCryptoInfo{
|
||||
"BTC": {"bitcoin", "₿", "Bitcoin"},
|
||||
"ETH": {"ethereum", "Ξ", "Ethereum"},
|
||||
"SOL": {"solana", "◎", "Solana"},
|
||||
"XRP": {"ripple", "✕", "XRP"},
|
||||
"DOGE": {"dogecoin", "🐕", "Dogecoin"},
|
||||
"ADA": {"cardano", "₳", "Cardano"},
|
||||
"LTC": {"litecoin", "Ł", "Litecoin"},
|
||||
}
|
||||
|
||||
// fxIsCrypto reports whether sym is a supported crypto asset.
|
||||
func fxIsCrypto(sym string) bool {
|
||||
_, ok := fxCryptoMeta[strings.ToUpper(sym)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// fxIsConvertible reports whether a symbol can appear in a conversion —
|
||||
// USD, a tracked fiat currency, or a supported crypto asset.
|
||||
func fxIsConvertible(cur string) bool {
|
||||
return fxIsSupported(cur) || fxIsCrypto(cur)
|
||||
}
|
||||
|
||||
const coinGeckoBaseURL = "https://api.coingecko.com/api/v3"
|
||||
|
||||
// Short-TTL price cache to stay well under CoinGecko's public rate limit and
|
||||
// keep repeated conversions snappy.
|
||||
const cgCacheTTL = 60 * time.Second
|
||||
|
||||
var (
|
||||
cgMu sync.Mutex
|
||||
cgCache = map[string]cgCacheEntry{}
|
||||
)
|
||||
|
||||
type cgCacheEntry struct {
|
||||
priceUSD float64
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// cgSpotUSD returns the USD spot price for one unit of the given crypto symbol.
|
||||
func (p *ForexPlugin) cgSpotUSD(sym string) (float64, error) {
|
||||
sym = strings.ToUpper(sym)
|
||||
info, ok := fxCryptoMeta[sym]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unsupported crypto %s", sym)
|
||||
}
|
||||
|
||||
cgMu.Lock()
|
||||
if e, ok := cgCache[sym]; ok && time.Since(e.at) < cgCacheTTL {
|
||||
cgMu.Unlock()
|
||||
return e.priceUSD, nil
|
||||
}
|
||||
cgMu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
|
||||
url := fmt.Sprintf("%s/simple/price?ids=%s&vs_currencies=usd", coinGeckoBaseURL, info.CoinGeckoID)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("coingecko API error: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("coingecko API returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var data map[string]map[string]float64
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return 0, fmt.Errorf("coingecko decode error: %w", err)
|
||||
}
|
||||
entry, ok := data[info.CoinGeckoID]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("no price for %s", sym)
|
||||
}
|
||||
price, ok := entry["usd"]
|
||||
if !ok || price <= 0 {
|
||||
return 0, fmt.Errorf("invalid price for %s", sym)
|
||||
}
|
||||
|
||||
cgMu.Lock()
|
||||
cgCache[sym] = cgCacheEntry{priceUSD: price, at: time.Now()}
|
||||
cgMu.Unlock()
|
||||
|
||||
return price, nil
|
||||
}
|
||||
184
internal/plugin/link_thumbnail.go
Normal file
184
internal/plugin/link_thumbnail.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif" // register GIF decoder for DecodeConfig
|
||||
_ "image/jpeg" // register JPEG decoder for DecodeConfig
|
||||
_ "image/png" // register PNG decoder for DecodeConfig
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/safehttp"
|
||||
|
||||
_ "golang.org/x/image/webp" // register WebP decoder for DecodeConfig
|
||||
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// thumbnailUserAgent identifies the bot when probing and downloading the
|
||||
// preview image for a posted link.
|
||||
const thumbnailUserAgent = "GogoBee/1.0 (+link thumbnail fetch)"
|
||||
|
||||
// thumbnailClient validates and downloads link-supplied image URLs. It routes
|
||||
// through safehttp so a posted link can't steer fetches at loopback / RFC1918 /
|
||||
// cloud-metadata IPs — the dial-time guard re-checks every redirect target too.
|
||||
// The 10 MiB download ceiling below bounds memory regardless.
|
||||
var thumbnailClient = safehttp.NewClient(15 * time.Second)
|
||||
|
||||
// resolveURL turns a possibly-relative ref into an absolute URL against base.
|
||||
func resolveURL(base, ref string) string {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" {
|
||||
return ""
|
||||
}
|
||||
b, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return ref
|
||||
}
|
||||
r, err := url.Parse(ref)
|
||||
if err != nil {
|
||||
return ref
|
||||
}
|
||||
return b.ResolveReference(r).String()
|
||||
}
|
||||
|
||||
// normalizeImageURL rewrites known CDN thumbnail URLs to a higher-resolution
|
||||
// variant. Currently handles The Guardian's i.guim.co.uk, whose pages hand out
|
||||
// narrow thumbnails. Signed URLs are left alone (re-signing isn't possible),
|
||||
// as are unrecognized hosts.
|
||||
func normalizeImageURL(raw string) string {
|
||||
if raw == "" {
|
||||
return raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host != "i.guim.co.uk" {
|
||||
return raw
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("width") == "" || q.Get("s") != "" {
|
||||
return raw
|
||||
}
|
||||
q.Set("width", "1200")
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// validateImageURL HEAD-probes an image URL: it must be http(s), return 200,
|
||||
// have an image/* content type, and (if a length is declared) exceed 5 KiB so
|
||||
// tracking pixels are filtered. Returns false with no error on any failure.
|
||||
func validateImageURL(rawURL string) bool {
|
||||
if rawURL == "" || safehttp.ValidateURL(rawURL) != nil {
|
||||
return false
|
||||
}
|
||||
req, err := http.NewRequest("HEAD", rawURL, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
req.Header.Set("User-Agent", thumbnailUserAgent)
|
||||
|
||||
resp, err := thumbnailClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Debug("thumbnail: image HEAD failed", "url", rawURL, "err", err)
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
if !strings.HasPrefix(resp.Header.Get("Content-Type"), "image/") {
|
||||
return false
|
||||
}
|
||||
if cl := resp.Header.Get("Content-Length"); cl != "" {
|
||||
if size, err := strconv.ParseInt(cl, 10, 64); err == nil && size <= 5120 {
|
||||
return false // tracking pixel
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// SendImageFromURL downloads imageURL, uploads it to Matrix, and posts it as an
|
||||
// m.image event in roomID. Returns an error on any failure so callers can fall
|
||||
// back to a text-only preview. The fetch is SSRF-guarded and size-capped.
|
||||
func (b *Base) SendImageFromURL(roomID id.RoomID, imageURL string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create image request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", thumbnailUserAgent)
|
||||
|
||||
resp, err := thumbnailClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download image: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("image download status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read image: %w", err)
|
||||
}
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if i := strings.IndexByte(contentType, ';'); i >= 0 {
|
||||
contentType = strings.TrimSpace(contentType[:i])
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = "image/jpeg"
|
||||
}
|
||||
if !strings.HasPrefix(contentType, "image/") {
|
||||
return fmt.Errorf("not an image: %s", contentType)
|
||||
}
|
||||
|
||||
// Decode dimensions so clients render the image inline rather than as a
|
||||
// downloadable file attachment.
|
||||
var width, height int
|
||||
if cfg, _, decErr := image.DecodeConfig(bytes.NewReader(data)); decErr == nil {
|
||||
width, height = cfg.Width, cfg.Height
|
||||
}
|
||||
|
||||
ext := ".jpg"
|
||||
switch contentType {
|
||||
case "image/png":
|
||||
ext = ".png"
|
||||
case "image/gif":
|
||||
ext = ".gif"
|
||||
case "image/webp":
|
||||
ext = ".webp"
|
||||
}
|
||||
filename := "thumbnail" + ext
|
||||
|
||||
uri, err := b.UploadContent(data, contentType, filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upload image: %w", err)
|
||||
}
|
||||
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgImage,
|
||||
Body: filename,
|
||||
FileName: filename,
|
||||
URL: uri.CUString(),
|
||||
Info: &event.FileInfo{
|
||||
MimeType: contentType,
|
||||
Size: len(data),
|
||||
Width: width,
|
||||
Height: height,
|
||||
},
|
||||
}
|
||||
_, err = b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||
return err
|
||||
}
|
||||
40
internal/plugin/link_thumbnail_test.go
Normal file
40
internal/plugin/link_thumbnail_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
base, ref, want string
|
||||
}{
|
||||
{"https://e.com/news/story", "https://cdn.e.com/a.jpg", "https://cdn.e.com/a.jpg"},
|
||||
{"https://e.com/news/story", "//cdn.e.com/a.jpg", "https://cdn.e.com/a.jpg"},
|
||||
{"https://e.com/news/story", "/img/a.jpg", "https://e.com/img/a.jpg"},
|
||||
{"https://e.com/news/story", "a.jpg", "https://e.com/news/a.jpg"},
|
||||
{"https://e.com/news/story", "", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := resolveURL(c.base, c.ref); got != c.want {
|
||||
t.Errorf("resolveURL(%q, %q) = %q, want %q", c.base, c.ref, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeImageURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, in, want string
|
||||
}{
|
||||
{"non-guardian passthrough", "https://e.com/a.jpg?width=140", "https://e.com/a.jpg?width=140"},
|
||||
{"signed left alone", "https://i.guim.co.uk/x.jpg?width=140&s=abc", "https://i.guim.co.uk/x.jpg?width=140&s=abc"},
|
||||
{"no width passthrough", "https://i.guim.co.uk/x.jpg", "https://i.guim.co.uk/x.jpg"},
|
||||
{"empty", "", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := normalizeImageURL(c.in); got != c.want {
|
||||
t.Errorf("%s: normalizeImageURL(%q) = %q, want %q", c.name, c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
// Unsigned Guardian thumbnail gets bumped to width=1200.
|
||||
if got := normalizeImageURL("https://i.guim.co.uk/x.jpg?width=140"); got != "https://i.guim.co.uk/x.jpg?width=1200" {
|
||||
t.Errorf("normalizeImageURL unsigned = %q, want width=1200", got)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/safehttp"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"maunium.net/go/mautrix"
|
||||
@@ -40,9 +41,10 @@ func NewURLsPlugin(client *mautrix.Client) *URLsPlugin {
|
||||
Base: NewBase(client),
|
||||
enabled: enabled,
|
||||
ignoreUsers: ignore,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 3 * time.Second,
|
||||
},
|
||||
// Route page scrapes through safehttp so a posted link can't steer the
|
||||
// fetch at loopback / RFC1918 / cloud-metadata IPs (re-checked on every
|
||||
// redirect), and can't OOM the parser by streaming an unbounded body.
|
||||
httpClient: safehttp.NewClient(8 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,12 +92,23 @@ func (p *URLsPlugin) OnMessage(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
func (p *URLsPlugin) previewURL(ctx MessageContext, targetURL string) {
|
||||
title, desc, err := p.fetchPreview(targetURL)
|
||||
title, desc, image, err := p.fetchPreview(targetURL)
|
||||
if err != nil {
|
||||
slog.Debug("urls: fetch preview failed", "url", targetURL, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
if title == "" && desc == "" && image == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Post the thumbnail first (best-effort), so it renders above the text.
|
||||
if image != "" && validateImageURL(image) {
|
||||
if err := p.SendImageFromURL(ctx.RoomID, image); err != nil {
|
||||
slog.Debug("urls: thumbnail post failed", "url", targetURL, "image", image, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
if title == "" && desc == "" {
|
||||
return
|
||||
}
|
||||
@@ -120,63 +133,69 @@ func (p *URLsPlugin) previewURL(ctx MessageContext, targetURL string) {
|
||||
}
|
||||
}
|
||||
|
||||
// fetchPreview retrieves og:title and og:description, checking cache first.
|
||||
func (p *URLsPlugin) fetchPreview(rawURL string) (string, string, error) {
|
||||
// fetchPreview retrieves og:title, og:description and og:image, checking cache first.
|
||||
func (p *URLsPlugin) fetchPreview(rawURL string) (title, desc, image string, err 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)
|
||||
err = d.QueryRow(
|
||||
`SELECT title, description, image_url, cached_at FROM url_cache WHERE url = ?`, rawURL,
|
||||
).Scan(&title, &desc, &image, &cachedAt)
|
||||
if err == nil && now-cachedAt < cacheTTL {
|
||||
return title, desc, nil
|
||||
return title, desc, image, nil
|
||||
}
|
||||
|
||||
// Fetch from web
|
||||
title, desc, err = p.scrapeOG(rawURL)
|
||||
title, desc, image, err = p.scrapeOG(rawURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
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,
|
||||
`INSERT INTO url_cache (url, title, description, image_url, cached_at) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(url) DO UPDATE SET title = ?, description = ?, image_url = ?, cached_at = ?`,
|
||||
rawURL, title, desc, image, now, title, desc, image, now,
|
||||
)
|
||||
|
||||
return title, desc, nil
|
||||
return title, desc, image, nil
|
||||
}
|
||||
|
||||
// scrapeOG fetches a URL and extracts og:title and og:description.
|
||||
func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, error) {
|
||||
// scrapeOG fetches a URL and extracts og:title, og:description and og:image.
|
||||
func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, string, error) {
|
||||
if err := safehttp.ValidateURL(rawURL); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", rawURL, nil)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("create request: %w", err)
|
||||
return "", "", "", fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "GogoBee Bot/1.0")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("fetch: %w", err)
|
||||
return "", "", "", fmt.Errorf("fetch: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", fmt.Errorf("status %d", resp.StatusCode)
|
||||
return "", "", "", fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
// Cap the parsed body at 2 MiB — og: tags live in <head>, near the top.
|
||||
doc, err := goquery.NewDocumentFromReader(safehttp.LimitedBody(resp.Body, 2*1024*1024))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("parse HTML: %w", err)
|
||||
return "", "", "", fmt.Errorf("parse HTML: %w", err)
|
||||
}
|
||||
|
||||
title := ""
|
||||
desc := ""
|
||||
image := ""
|
||||
|
||||
doc.Find("meta").Each(func(_ int, s *goquery.Selection) {
|
||||
prop, _ := s.Attr("property")
|
||||
@@ -186,6 +205,10 @@ func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, error) {
|
||||
title = content
|
||||
case "og:description":
|
||||
desc = content
|
||||
case "og:image:secure_url", "og:image:url", "og:image":
|
||||
if image == "" && strings.TrimSpace(content) != "" {
|
||||
image = content
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -205,5 +228,9 @@ func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, error) {
|
||||
})
|
||||
}
|
||||
|
||||
return strings.TrimSpace(title), strings.TrimSpace(desc), nil
|
||||
if image != "" {
|
||||
image = normalizeImageURL(resolveURL(rawURL, strings.TrimSpace(image)))
|
||||
}
|
||||
|
||||
return strings.TrimSpace(title), strings.TrimSpace(desc), image, nil
|
||||
}
|
||||
|
||||
146
internal/safehttp/safehttp.go
Normal file
146
internal/safehttp/safehttp.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Package safehttp provides an http.Client hardened against SSRF and
|
||||
// memory-DoS via hostile upstreams. Every outbound fetch the bot makes
|
||||
// against feed-supplied URLs (RSS articles, image hosts) should go through
|
||||
// one of these clients so a malicious feed can't steer the bot at loopback,
|
||||
// link-local, RFC1918, or cloud metadata IPs, and can't OOM the process by
|
||||
// streaming an unbounded body.
|
||||
package safehttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrBlockedHost is returned when a URL resolves to a non-public IP.
|
||||
var ErrBlockedHost = errors.New("safehttp: blocked non-public host")
|
||||
|
||||
// AllowPrivate, when true, disables the loopback/RFC1918 dial guard. It
|
||||
// exists for tests that spin up httptest.NewServer on 127.0.0.1 — never
|
||||
// set this in production.
|
||||
var AllowPrivate bool
|
||||
|
||||
// safeDialContext refuses connections to non-public IPs. It runs after
|
||||
// DNS resolution, so a hostile DNS rebinding that returns 127.0.0.1
|
||||
// still gets blocked at dial time.
|
||||
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ips, err := (&net.Resolver{}).LookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var allowed net.IP
|
||||
for _, ip := range ips {
|
||||
if AllowPrivate || isPublicIP(ip) {
|
||||
allowed = ip
|
||||
break
|
||||
}
|
||||
}
|
||||
if allowed == nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrBlockedHost, host)
|
||||
}
|
||||
d := &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}
|
||||
return d.DialContext(ctx, network, net.JoinHostPort(allowed.String(), port))
|
||||
}
|
||||
|
||||
// isPublicIP reports whether ip is a globally routable unicast address.
|
||||
// Rejects loopback, link-local, multicast, RFC1918, CGNAT, and the
|
||||
// AWS/GCP/Azure metadata IPs 169.254.169.254 / fd00:ec2::254 (these
|
||||
// already fall under link-local but spell it out for clarity).
|
||||
func isPublicIP(ip net.IP) bool {
|
||||
if ip == nil || ip.IsUnspecified() || ip.IsLoopback() ||
|
||||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
||||
ip.IsMulticast() || ip.IsPrivate() {
|
||||
return false
|
||||
}
|
||||
// 100.64.0.0/10 (CGNAT) is not covered by IsPrivate on older Go.
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
if v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 {
|
||||
return false
|
||||
}
|
||||
// 0.0.0.0/8 and friends.
|
||||
if v4[0] == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ValidateURL returns nil if the URL is http(s) and parseable. It does
|
||||
// not resolve DNS — the dial step does that — but it does reject bare
|
||||
// schemes (file://, gopher://, etc.) before we even open a connection.
|
||||
func ValidateURL(raw string) error {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("safehttp: unsupported scheme %q", u.Scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return errors.New("safehttp: empty host")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewClient returns an http.Client whose transport blocks non-public
|
||||
// destinations at dial time, caps redirects at 5, and re-validates each
|
||||
// redirect target's scheme. timeout is the per-request overall budget.
|
||||
func NewClient(timeout time.Duration) *http.Client {
|
||||
tr := &http.Transport{
|
||||
DialContext: safeDialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 32,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return errors.New("safehttp: stopped after 5 redirects")
|
||||
}
|
||||
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
|
||||
return fmt.Errorf("safehttp: unsupported redirect scheme %q", req.URL.Scheme)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LimitedBody wraps r in a reader that errors once more than max bytes have
|
||||
// been read. Use to cap how much of a response body downstream parsers
|
||||
// (goquery, image.Decode) will ever see — a hostile origin streaming an
|
||||
// endless body otherwise OOMs the process.
|
||||
func LimitedBody(r io.Reader, max int64) io.Reader {
|
||||
return &limitedReader{R: r, N: max}
|
||||
}
|
||||
|
||||
type limitedReader struct {
|
||||
R io.Reader
|
||||
N int64
|
||||
}
|
||||
|
||||
func (l *limitedReader) Read(p []byte) (int, error) {
|
||||
if l.N <= 0 {
|
||||
return 0, fmt.Errorf("safehttp: response body exceeded cap")
|
||||
}
|
||||
if int64(len(p)) > l.N {
|
||||
p = p[:l.N]
|
||||
}
|
||||
n, err := l.R.Read(p)
|
||||
l.N -= int64(n)
|
||||
return n, err
|
||||
}
|
||||
6
main.go
6
main.go
@@ -406,8 +406,8 @@ func setupScheduledJobs(
|
||||
}
|
||||
})
|
||||
|
||||
// WOTD post at 08:00
|
||||
if strings.ToLower(os.Getenv("DISABLE_WOTD_POST")) != "true" {
|
||||
// WOTD post at 08:00 (disabled by default; opt in via ENABLE_WOTD_POST=true)
|
||||
if strings.ToLower(os.Getenv("ENABLE_WOTD_POST")) == "true" {
|
||||
c.AddFunc("0 8 * * *", func() {
|
||||
slog.Info("scheduler: posting WOTD")
|
||||
for _, r := range rooms {
|
||||
@@ -415,7 +415,7 @@ func setupScheduledJobs(
|
||||
}
|
||||
})
|
||||
} else {
|
||||
slog.Info("scheduler: WOTD daily post disabled via DISABLE_WOTD_POST")
|
||||
slog.Info("scheduler: WOTD daily post disabled (set ENABLE_WOTD_POST=true to enable)")
|
||||
}
|
||||
|
||||
// Game releases Monday 09:00
|
||||
|
||||
Reference in New Issue
Block a user