A proposal against the current develop branch (I read main.go, db/db.go, sn/sn.go, go.mod — tree 762cd853). I have not compiled or run it, so treat it as a reviewable patch rather than finished code.
The design idea
The repo already persists every SN item the bot posts — sn.Post and the dupes path both call db.SaveSnItem(parentId, item.ID), so the sn_items table is the set of bot posts. That means the manual workflow (open /hn/posts, look at sats and ncomments) can be automated with no new SN endpoint: for each id in sn_items, call snappy's Client.Item(id) (v0.9.0, already pinned in go.mod), compare Sats/NComments against a stored snapshot, and notify Discord when either increases. The first sighting only seeds the snapshot, so a fresh run does not back-notify every historical post.
I deliberately did not use Client.Notifications(): in v0.9.0 its query selects only the Reply/Mention fragments, so it cannot see tip/votification activity — which is half of what you asked for.
db/db.go — add a snapshot table to migrate:
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS sn_item_stats (
item_id INTEGER NOT NULL,
sats INTEGER NOT NULL,
ncomments INTEGER NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (item_id)
);
`); err != nil {
err = fmt.Errorf("error during migration: %w", err)
log.Fatal(err)
}
and accessors next to the existing ones (_db, sql, fmt, log are already imported):
type SnItemStats struct {
Id int
Sats int
NComments int
}
func SnItemIds() ([]int, error) {
rows, err := _db.Query(`SELECT id FROM sn_items ORDER BY created_at ASC`)
if err != nil {
return nil, fmt.Errorf("error querying sn_items: %w", err)
}
defer rows.Close()
var ids []int
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("error scanning sn_items: %w", err)
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func GetSnItemStats(itemId int) (*SnItemStats, error) {
var s SnItemStats
err := _db.QueryRow(`SELECT item_id, sats, ncomments FROM sn_item_stats WHERE item_id = ?`, itemId).
Scan(&s.Id, &s.Sats, &s.NComments)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("error querying sn_item_stats: %w", err)
}
return &s, nil
}
func SaveSnItemStats(itemId, sats, ncomments int) error {
if _, err := _db.Exec(`
INSERT INTO sn_item_stats(item_id, sats, ncomments) VALUES (?, ?, ?)
ON CONFLICT(item_id) DO UPDATE SET sats = excluded.sats,
ncomments = excluded.ncomments, updated_at = CURRENT_TIMESTAMP`,
itemId, sats, ncomments); err != nil {
return fmt.Errorf("error during sn_item_stats upsert: %w", err)
}
return nil
}
New file discord/discord.go:
package discord
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
// Notify posts content to the webhook in DISCORD_WEBHOOK_URL.
func Notify(content string) error {
url := os.Getenv("DISCORD_WEBHOOK_URL")
if url == "" {
return fmt.Errorf("DISCORD_WEBHOOK_URL not set")
}
body, err := json.Marshal(struct {
Content string `json:"content"`
}{content})
if err != nil {
return fmt.Errorf("error encoding discord payload: %w", err)
}
resp, err := http.Post(url, "application/json", bytes.NewBuffer(body))
if err != nil {
return fmt.Errorf("error posting to discord: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("discord webhook returned status %d", resp.StatusCode)
}
return nil
}
New file sn/notify.go:
package sn
import (
"fmt"
"log"
"strings"
"github.com/ekzyis/hnbot/db"
"github.com/ekzyis/hnbot/discord"
sn "github.com/ekzyis/snappy"
)
// CheckBotPosts diffs each bot post's sats/comment count against the last
// snapshot and notifies Discord about increases. The first sighting of an
// item only seeds the snapshot.
func CheckBotPosts() error {
c := sn.NewClient()
ids, err := db.SnItemIds()
if err != nil {
return err
}
for _, id := range ids {
item, err := c.Item(id)
if err != nil {
log.Printf("[sn] error fetching item %d: %v\n", id, err)
continue
}
prev, err := db.GetSnItemStats(id)
if err != nil {
return err
}
if prev == nil {
if err := db.SaveSnItemStats(id, item.Sats, item.NComments); err != nil {
return err
}
continue
}
var lines []string
if item.Sats > prev.Sats {
lines = append(lines, fmt.Sprintf(":moneybag: +%d sats (total %d)", item.Sats-prev.Sats, item.Sats))
}
if item.NComments > prev.NComments {
lines = append(lines, fmt.Sprintf(":speech_balloon: +%d comment(s) (total %d)", item.NComments-prev.NComments, item.NComments))
}
if len(lines) > 0 {
msg := fmt.Sprintf("**%s**\n%s\n%s/items/%d", item.Title, strings.Join(lines, "\n"), c.BaseUrl, id)
if err := discord.Notify(msg); err != nil {
log.Printf("[sn] error sending Discord notification: %v\n", err)
}
}
if err := db.SaveSnItemStats(id, item.Sats, item.NComments); err != nil {
return err
}
}
return nil
}
main.go — one poller alongside the existing sync goroutine:
Config: add DISCORD_WEBHOOK_URL=... to .env — godotenv already loads it, and .gitignore already excludes .env, so no secret leaks.
A few honest caveats. I verified against snappy v0.9.0 that Client.Item(id int) (*Item, error) exists and that Item.Sats / Item.NComments are both int (items.go), and that Client.BaseUrl is exported (client.go) — but I could not compile or run any of this. If you would rather not add a dependency-free second package, discord.Notify is small enough to inline into sn/. And if you would prefer websockets over polling, the snapshot/diff logic is unchanged — only the trigger differs.
If the shape is roughly what you want, tell me the branch or where to send a patch and I will finish it properly.
A proposal against the current
developbranch (I readmain.go,db/db.go,sn/sn.go,go.mod— tree762cd853). I have not compiled or run it, so treat it as a reviewable patch rather than finished code.The design idea
The repo already persists every SN item the bot posts —
sn.Postand the dupes path both calldb.SaveSnItem(parentId, item.ID), so thesn_itemstable is the set of bot posts. That means the manual workflow (open/hn/posts, look atsatsandncomments) can be automated with no new SN endpoint: for each id insn_items, call snappy'sClient.Item(id)(v0.9.0, already pinned ingo.mod), compareSats/NCommentsagainst a stored snapshot, and notify Discord when either increases. The first sighting only seeds the snapshot, so a fresh run does not back-notify every historical post.I deliberately did not use
Client.Notifications(): in v0.9.0 its query selects only theReply/Mentionfragments, so it cannot see tip/votification activity — which is half of what you asked for.db/db.go— add a snapshot table tomigrate:if _, err := db.Exec(` CREATE TABLE IF NOT EXISTS sn_item_stats ( item_id INTEGER NOT NULL, sats INTEGER NOT NULL, ncomments INTEGER NOT NULL, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (item_id) ); `); err != nil { err = fmt.Errorf("error during migration: %w", err) log.Fatal(err) }and accessors next to the existing ones (
_db,sql,fmt,logare already imported):type SnItemStats struct { Id int Sats int NComments int } func SnItemIds() ([]int, error) { rows, err := _db.Query(`SELECT id FROM sn_items ORDER BY created_at ASC`) if err != nil { return nil, fmt.Errorf("error querying sn_items: %w", err) } defer rows.Close() var ids []int for rows.Next() { var id int if err := rows.Scan(&id); err != nil { return nil, fmt.Errorf("error scanning sn_items: %w", err) } ids = append(ids, id) } return ids, rows.Err() } func GetSnItemStats(itemId int) (*SnItemStats, error) { var s SnItemStats err := _db.QueryRow(`SELECT item_id, sats, ncomments FROM sn_item_stats WHERE item_id = ?`, itemId). Scan(&s.Id, &s.Sats, &s.NComments) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("error querying sn_item_stats: %w", err) } return &s, nil } func SaveSnItemStats(itemId, sats, ncomments int) error { if _, err := _db.Exec(` INSERT INTO sn_item_stats(item_id, sats, ncomments) VALUES (?, ?, ?) ON CONFLICT(item_id) DO UPDATE SET sats = excluded.sats, ncomments = excluded.ncomments, updated_at = CURRENT_TIMESTAMP`, itemId, sats, ncomments); err != nil { return fmt.Errorf("error during sn_item_stats upsert: %w", err) } return nil }New file
discord/discord.go:package discord import ( "bytes" "encoding/json" "fmt" "net/http" "os" ) // Notify posts content to the webhook in DISCORD_WEBHOOK_URL. func Notify(content string) error { url := os.Getenv("DISCORD_WEBHOOK_URL") if url == "" { return fmt.Errorf("DISCORD_WEBHOOK_URL not set") } body, err := json.Marshal(struct { Content string `json:"content"` }{content}) if err != nil { return fmt.Errorf("error encoding discord payload: %w", err) } resp, err := http.Post(url, "application/json", bytes.NewBuffer(body)) if err != nil { return fmt.Errorf("error posting to discord: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("discord webhook returned status %d", resp.StatusCode) } return nil }New file
sn/notify.go:package sn import ( "fmt" "log" "strings" "github.com/ekzyis/hnbot/db" "github.com/ekzyis/hnbot/discord" sn "github.com/ekzyis/snappy" ) // CheckBotPosts diffs each bot post's sats/comment count against the last // snapshot and notifies Discord about increases. The first sighting of an // item only seeds the snapshot. func CheckBotPosts() error { c := sn.NewClient() ids, err := db.SnItemIds() if err != nil { return err } for _, id := range ids { item, err := c.Item(id) if err != nil { log.Printf("[sn] error fetching item %d: %v\n", id, err) continue } prev, err := db.GetSnItemStats(id) if err != nil { return err } if prev == nil { if err := db.SaveSnItemStats(id, item.Sats, item.NComments); err != nil { return err } continue } var lines []string if item.Sats > prev.Sats { lines = append(lines, fmt.Sprintf(":moneybag: +%d sats (total %d)", item.Sats-prev.Sats, item.Sats)) } if item.NComments > prev.NComments { lines = append(lines, fmt.Sprintf(":speech_balloon: +%d comment(s) (total %d)", item.NComments-prev.NComments, item.NComments)) } if len(lines) > 0 { msg := fmt.Sprintf("**%s**\n%s\n%s/items/%d", item.Title, strings.Join(lines, "\n"), c.BaseUrl, id) if err := discord.Notify(msg); err != nil { log.Printf("[sn] error sending Discord notification: %v\n", err) } } if err := db.SaveSnItemStats(id, item.Sats, item.NComments); err != nil { return err } } return nil }main.go— one poller alongside the existing sync goroutine:func SyncSnNotifications() { for { now := time.Now() dur := now.Truncate(time.Minute).Add(5 * time.Minute).Sub(now) log.Println("[sn] polling bot posts in", dur.Round(time.Second)) time.Sleep(dur) if err := sn.CheckBotPosts(); err != nil { log.Println(err) } } }go SyncHnItemsToDb() go SyncSnNotifications()Config: add
DISCORD_WEBHOOK_URL=...to.env—godotenvalready loads it, and.gitignorealready excludes.env, so no secret leaks.A few honest caveats. I verified against snappy v0.9.0 that
Client.Item(id int) (*Item, error)exists and thatItem.Sats/Item.NCommentsare bothint(items.go), and thatClient.BaseUrlis exported (client.go) — but I could not compile or run any of this. If you would rather not add a dependency-free second package,discord.Notifyis small enough to inline intosn/. And if you would prefer websockets over polling, the snapshot/diff logic is unchanged — only the trigger differs.If the shape is roughly what you want, tell me the branch or where to send a patch and I will finish it properly.