mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
- Add forex plugin (Frankfurter v2 API) with rate lookups, analysis, DM-based alerts, and daily cron poll. Backfills 1 year of history on startup for moving averages and buy signal scoring. - Fix bot hang caused by SQLite lock contention in reminder polling: rows cursor was held open while writing to the same DB. Collect results first, close cursor, then process. Same fix in milkcarton. - Add sync retry loop so the bot reconnects after network drops instead of silently exiting. StopSync() for clean Ctrl+C shutdown. - Add panic recovery to all dispatch, syncer, and cron paths. - Make all HTTP-calling plugin commands async (goroutines) so a slow or dead external API cannot block the message dispatch pipeline. Affects: lookup, stocks, forex, anime, movies, concerts, gaming, retro, wotd, urls, howami. - Extract DisplayName to Base, add db.Exec helper, convert silent error discards across the codebase. - Fix UNO mercy-kill bug (eliminated bot continues playing), adventure DM nag spam, stats column mismatch, per-call regex/replacer allocs. - Update README: forex commands, Finance section, 47 plugins. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
347 lines
9.5 KiB
Go
347 lines
9.5 KiB
Go
package plugin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"gogobee/internal/db"
|
|
|
|
"maunium.net/go/mautrix"
|
|
)
|
|
|
|
// finnhubQuote is the response from the Finnhub quote endpoint.
|
|
type finnhubQuote struct {
|
|
Current float64 `json:"c"` // Current price
|
|
Change float64 `json:"d"` // Change
|
|
ChangePct float64 `json:"dp"` // Percent change
|
|
High float64 `json:"h"` // High price of the day
|
|
Low float64 `json:"l"` // Low price of the day
|
|
Open float64 `json:"o"` // Open price of the day
|
|
PrevClose float64 `json:"pc"` // Previous close price
|
|
Timestamp int64 `json:"t"` // Timestamp
|
|
}
|
|
|
|
// finnhubProfile is the response from the Finnhub company profile endpoint.
|
|
type finnhubProfile struct {
|
|
Name string `json:"name"`
|
|
Ticker string `json:"ticker"`
|
|
Exchange string `json:"exchange"`
|
|
Industry string `json:"finnhubIndustry"`
|
|
MarketCap float64 `json:"marketCapitalization"` // in millions
|
|
Currency string `json:"currency"`
|
|
}
|
|
|
|
// stockCacheEntry holds cached stock data.
|
|
type stockCacheEntry struct {
|
|
Quote finnhubQuote `json:"quote"`
|
|
Profile finnhubProfile `json:"profile"`
|
|
}
|
|
|
|
// StocksPlugin provides stock market quotes via Finnhub.
|
|
type StocksPlugin struct {
|
|
Base
|
|
apiKey string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewStocksPlugin creates a new StocksPlugin.
|
|
func NewStocksPlugin(client *mautrix.Client) *StocksPlugin {
|
|
return &StocksPlugin{
|
|
Base: NewBase(client),
|
|
apiKey: os.Getenv("FINNHUB_API_KEY"),
|
|
httpClient: &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (p *StocksPlugin) Name() string { return "stocks" }
|
|
|
|
func (p *StocksPlugin) Commands() []CommandDef {
|
|
return []CommandDef{
|
|
{Name: "stock", Description: "Get stock quote(s)", Usage: "!stock <ticker> [ticker2...]", Category: "Entertainment"},
|
|
{Name: "stockwatch", Description: "Manage your stock watchlist", Usage: "!stockwatch add|list|remove <ticker>", Category: "Entertainment"},
|
|
}
|
|
}
|
|
|
|
func (p *StocksPlugin) Init() error { return nil }
|
|
|
|
func (p *StocksPlugin) OnReaction(_ ReactionContext) error { return nil }
|
|
|
|
func (p *StocksPlugin) OnMessage(ctx MessageContext) error {
|
|
if p.IsCommand(ctx.Body, "stock") {
|
|
go func() {
|
|
if err := p.handleStock(ctx); err != nil {
|
|
slog.Error("stocks: handler error", "err", err)
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
if p.IsCommand(ctx.Body, "stockwatch") {
|
|
return p.handleStockwatch(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *StocksPlugin) handleStock(ctx MessageContext) error {
|
|
args := p.GetArgs(ctx.Body, "stock")
|
|
if args == "" {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !stock <ticker> [ticker2...]")
|
|
}
|
|
|
|
if p.apiKey == "" {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Stock lookups are not configured (missing API key).")
|
|
}
|
|
|
|
tickers := strings.Fields(strings.ToUpper(args))
|
|
if len(tickers) > 5 {
|
|
tickers = tickers[:5]
|
|
}
|
|
|
|
var sb strings.Builder
|
|
for i, ticker := range tickers {
|
|
if i > 0 {
|
|
sb.WriteString("\n\n")
|
|
}
|
|
entry, err := p.fetchStock(ticker)
|
|
if err != nil {
|
|
slog.Error("stocks: fetch failed", "ticker", ticker, "err", err)
|
|
sb.WriteString(fmt.Sprintf("%s: Failed to fetch data", ticker))
|
|
continue
|
|
}
|
|
sb.WriteString(p.formatStock(ticker, entry))
|
|
}
|
|
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
|
}
|
|
|
|
func (p *StocksPlugin) fetchStock(ticker string) (*stockCacheEntry, error) {
|
|
d := db.Get()
|
|
|
|
// Check cache (60s TTL)
|
|
var cached string
|
|
var cachedAt int64
|
|
err := d.QueryRow(
|
|
`SELECT data, cached_at FROM stocks_cache WHERE ticker = ?`, ticker,
|
|
).Scan(&cached, &cachedAt)
|
|
if err == nil && time.Now().Unix()-cachedAt < 60 {
|
|
var entry stockCacheEntry
|
|
if json.Unmarshal([]byte(cached), &entry) == nil {
|
|
return &entry, nil
|
|
}
|
|
}
|
|
|
|
// Fetch quote
|
|
quote, err := p.fetchFinnhubQuote(ticker)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch quote: %w", err)
|
|
}
|
|
|
|
// Fetch profile
|
|
profile, err := p.fetchFinnhubProfile(ticker)
|
|
if err != nil {
|
|
slog.Warn("stocks: profile fetch failed, continuing without it", "ticker", ticker, "err", err)
|
|
profile = &finnhubProfile{}
|
|
}
|
|
|
|
entry := &stockCacheEntry{
|
|
Quote: *quote,
|
|
Profile: *profile,
|
|
}
|
|
|
|
// Update cache
|
|
data, _ := json.Marshal(entry)
|
|
db.Exec("stocks: cache write",
|
|
`INSERT INTO stocks_cache (ticker, data, cached_at) VALUES (?, ?, ?)
|
|
ON CONFLICT(ticker) DO UPDATE SET data = ?, cached_at = ?`,
|
|
ticker, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
|
)
|
|
|
|
return entry, nil
|
|
}
|
|
|
|
func (p *StocksPlugin) fetchFinnhubQuote(ticker string) (*finnhubQuote, error) {
|
|
url := fmt.Sprintf("https://finnhub.io/api/v1/quote?symbol=%s&token=%s", ticker, p.apiKey)
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("finnhub quote returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var quote finnhubQuote
|
|
if err := json.NewDecoder(resp.Body).Decode("e); err != nil {
|
|
return nil, err
|
|
}
|
|
return "e, nil
|
|
}
|
|
|
|
func (p *StocksPlugin) fetchFinnhubProfile(ticker string) (*finnhubProfile, error) {
|
|
url := fmt.Sprintf("https://finnhub.io/api/v1/stock/profile2?symbol=%s&token=%s", ticker, p.apiKey)
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("finnhub profile returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var profile finnhubProfile
|
|
if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
|
|
return nil, err
|
|
}
|
|
return &profile, nil
|
|
}
|
|
|
|
func (p *StocksPlugin) formatStock(ticker string, entry *stockCacheEntry) string {
|
|
q := entry.Quote
|
|
pr := entry.Profile
|
|
|
|
changeSign := ""
|
|
if q.Change > 0 {
|
|
changeSign = "+"
|
|
}
|
|
|
|
var sb strings.Builder
|
|
if pr.Name != "" {
|
|
sb.WriteString(fmt.Sprintf("%s (%s)", pr.Name, ticker))
|
|
} else {
|
|
sb.WriteString(ticker)
|
|
}
|
|
|
|
sb.WriteString(fmt.Sprintf("\nPrice: $%.2f", q.Current))
|
|
sb.WriteString(fmt.Sprintf("\nChange: %s%.2f (%s%.2f%%)", changeSign, q.Change, changeSign, q.ChangePct))
|
|
sb.WriteString(fmt.Sprintf("\nDay Range: $%.2f - $%.2f", q.Low, q.High))
|
|
|
|
if pr.MarketCap > 0 {
|
|
sb.WriteString(fmt.Sprintf("\nMarket Cap: %s", formatMarketCap(pr.MarketCap)))
|
|
}
|
|
if pr.Exchange != "" {
|
|
sb.WriteString(fmt.Sprintf("\nExchange: %s", pr.Exchange))
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func formatMarketCap(capMillions float64) string {
|
|
switch {
|
|
case capMillions >= 1_000_000:
|
|
return fmt.Sprintf("$%.2fT", capMillions/1_000_000)
|
|
case capMillions >= 1_000:
|
|
return fmt.Sprintf("$%.2fB", capMillions/1_000)
|
|
default:
|
|
return fmt.Sprintf("$%.2fM", capMillions)
|
|
}
|
|
}
|
|
|
|
func (p *StocksPlugin) handleStockwatch(ctx MessageContext) error {
|
|
args := p.GetArgs(ctx.Body, "stockwatch")
|
|
parts := strings.Fields(args)
|
|
if len(parts) == 0 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !stockwatch add|list|remove <ticker>")
|
|
}
|
|
|
|
sub := strings.ToLower(parts[0])
|
|
switch sub {
|
|
case "add":
|
|
if len(parts) < 2 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !stockwatch add <ticker>")
|
|
}
|
|
return p.watchlistAdd(ctx, strings.ToUpper(parts[1]))
|
|
case "remove":
|
|
if len(parts) < 2 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !stockwatch remove <ticker>")
|
|
}
|
|
return p.watchlistRemove(ctx, strings.ToUpper(parts[1]))
|
|
case "list":
|
|
return p.watchlistList(ctx)
|
|
default:
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !stockwatch add|list|remove <ticker>")
|
|
}
|
|
}
|
|
|
|
func (p *StocksPlugin) watchlistAdd(ctx MessageContext, ticker string) error {
|
|
d := db.Get()
|
|
_, err := d.Exec(
|
|
`INSERT INTO stock_watchlist (user_id, ticker, room_id) VALUES (?, ?, ?)
|
|
ON CONFLICT(user_id, ticker) DO NOTHING`,
|
|
string(ctx.Sender), ticker, string(ctx.RoomID),
|
|
)
|
|
if err != nil {
|
|
slog.Error("stocks: watchlist add", "err", err)
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to add to watchlist.")
|
|
}
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Added %s to your watchlist.", ticker))
|
|
}
|
|
|
|
func (p *StocksPlugin) watchlistRemove(ctx MessageContext, ticker string) error {
|
|
d := db.Get()
|
|
res, err := d.Exec(
|
|
`DELETE FROM stock_watchlist WHERE user_id = ? AND ticker = ?`,
|
|
string(ctx.Sender), ticker,
|
|
)
|
|
if err != nil {
|
|
slog.Error("stocks: watchlist remove", "err", err)
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove from watchlist.")
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s is not in your watchlist.", ticker))
|
|
}
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Removed %s from your watchlist.", ticker))
|
|
}
|
|
|
|
func (p *StocksPlugin) watchlistList(ctx MessageContext) error {
|
|
d := db.Get()
|
|
rows, err := d.Query(
|
|
`SELECT ticker FROM stock_watchlist WHERE user_id = ? ORDER BY ticker`,
|
|
string(ctx.Sender),
|
|
)
|
|
if err != nil {
|
|
slog.Error("stocks: watchlist list", "err", err)
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load watchlist.")
|
|
}
|
|
defer rows.Close()
|
|
|
|
var tickers []string
|
|
for rows.Next() {
|
|
var t string
|
|
if err := rows.Scan(&t); err != nil {
|
|
continue
|
|
}
|
|
tickers = append(tickers, t)
|
|
}
|
|
|
|
if len(tickers) == 0 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Your stock watchlist is empty. Use !stockwatch add <ticker> to add stocks.")
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("Your Stock Watchlist:\n")
|
|
for _, t := range tickers {
|
|
sb.WriteString(fmt.Sprintf(" - %s\n", t))
|
|
}
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
|
}
|