pull down to refresh

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:

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 .envgodotenv 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.