This is a bounty to improve @hn:
I like to see if someone tipped or replied to posts of the bot. At the moment, I manually go through the posts of the bot using https://stacker.news/hn/posts and check for tips > 0 and comments > 1.
So it would be nice if notifications are constantly polled (or use websockets in the future: stackernews#284 👀) and are posted into the monitoring channel on discord.
See https://gitlab.com/ekzyis/hnbot/-/issues/5
If you have any questions, let me know here or DM me on nostr: npub16x07c4qz05yhqe2gy2q2u9ax359d2lc0tsh6wn3y70dmk8nv2j2s96s89d
50,000 sats bounty
Why not just get rid of that bot and let people to post themselves nice meta, not just throwing a bunch of links.
I just ignore ALL hn posts. Most of them are just clickbait and clickbait doesn't work on me.
Quite right hate clickbait links
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.