Files
gogobee/internal/plugin/stocks.go
prosolis 3369d7d8fe gofmt: bring internal/ and cmd/ back to gofmt -l clean
Mechanical `gofmt -w ./internal ./cmd`. Mostly struct-field realignment that
had drifted, plus a few trailing-newline fixes. No behaviour change — gofmt is
semantics-preserving, and build/vet/test are green either side.

Split out from the code-review fixes that follow so those stay reviewable
instead of hiding inside a wall of realignment.
2026-07-10 07:18:07 -07:00

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") {
safeGo("stocks-handler", 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(&quote); err != nil {
return nil, err
}
return &quote, 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())
}