Compare commits
2 Commits
f29c056171
...
23fffdda3c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23fffdda3c | ||
|
|
689dc4ef92 |
@@ -11,6 +11,7 @@ A Matrix news bot that ingests RSS feeds from curated sources, classifies storie
|
|||||||
- **Round-robin mode** — opt-in pacing: one story per N hours (default 4), cycling through sources in config order, skip-and-advance over empty sources, state persists across restarts
|
- **Round-robin mode** — opt-in pacing: one story per N hours (default 4), cycling through sources in config order, skip-and-advance over empty sources, state persists across restarts
|
||||||
- **Reaction tracking** — records emoji reactions on posts for classifier tuning
|
- **Reaction tracking** — records emoji reactions on posts for classifier tuning
|
||||||
- **`!explain` via ❓ reaction** — react with `❓` (or `❔ ⁉ 🤔 ?`) on any post; Pete fetches the article body and replies in-thread with a 3-bullet Ollama-generated TL;DR
|
- **`!explain` via ❓ reaction** — react with `❓` (or `❔ ⁉ 🤔 ?`) on any post; Pete fetches the article body and replies in-thread with a 3-bullet Ollama-generated TL;DR
|
||||||
|
- **`!post` on demand** — type `!post` in any configured channel room and Pete force-publishes the next queued story for that channel, bypassing min-interval, burst cap, and daily cap (canonical-URL dedup still applies); replies in-thread if the queue is empty
|
||||||
- **Paywall detection** — if an article's visible body text is below threshold, Pete swaps in a Wayback Machine snapshot URL for both the lead image and the posted link
|
- **Paywall detection** — if an article's visible body text is below threshold, Pete swaps in a Wayback Machine snapshot URL for both the lead image and the posted link
|
||||||
- **FTS5 search** — full-text search across headlines and ledes
|
- **FTS5 search** — full-text search across headlines and ledes
|
||||||
- **Image validation** — HEAD-based checks filter tracking pixels, uploads valid images via MXC
|
- **Image validation** — HEAD-based checks filter tracking pixels, uploads valid images via MXC
|
||||||
@@ -108,7 +109,7 @@ Posted message ← ❓ reaction → Explainer → Article Fetch → Ollama summa
|
|||||||
| `internal/storage` | SQLite with WAL, FTS5, all queries |
|
| `internal/storage` | SQLite with WAL, FTS5, all queries |
|
||||||
| `internal/ingestion` | Per-source RSS polling, feed parsing, image validation |
|
| `internal/ingestion` | Per-source RSS polling, feed parsing, image validation |
|
||||||
| `internal/classifier` | Ollama client, Tier 1/2 prompts, JSON repair, keyword gating |
|
| `internal/classifier` | Ollama client, Tier 1/2 prompts, JSON repair, keyword gating |
|
||||||
| `internal/matrix` | Password auth with device persistence, posting, threaded replies, reaction listener |
|
| `internal/matrix` | Password auth with device persistence, posting, threaded replies, reaction + message listener |
|
||||||
| `internal/poster` | Per-channel metered release queue, reaction tracking, callback hook |
|
| `internal/poster` | Per-channel metered release queue, reaction tracking, callback hook |
|
||||||
| `internal/explainer` | ❓-reaction → article fetch → Ollama summary → threaded reply |
|
| `internal/explainer` | ❓-reaction → article fetch → Ollama summary → threaded reply |
|
||||||
| `internal/scheduler` | Round-robin posting scheduler: paced rotation across sources when enabled |
|
| `internal/scheduler` | Round-robin posting scheduler: paced rotation across sources when enabled |
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ import (
|
|||||||
// ReactionHandler is called when a reaction is received on a post.
|
// ReactionHandler is called when a reaction is received on a post.
|
||||||
type ReactionHandler func(roomID id.RoomID, eventID id.EventID, targetEventID id.EventID, emoji string, userID id.UserID)
|
type ReactionHandler func(roomID id.RoomID, eventID id.EventID, targetEventID id.EventID, emoji string, userID id.UserID)
|
||||||
|
|
||||||
|
// MessageHandler is called when a text message is received in a configured room.
|
||||||
|
// body is the plain-text content; channel is the configured channel name for the room.
|
||||||
|
type MessageHandler func(channel string, roomID id.RoomID, eventID id.EventID, sender id.UserID, body string)
|
||||||
|
|
||||||
// deviceInfo holds persisted device credentials.
|
// deviceInfo holds persisted device credentials.
|
||||||
type deviceInfo struct {
|
type deviceInfo struct {
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
@@ -47,6 +51,7 @@ type Client struct {
|
|||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
onReact ReactionHandler
|
onReact ReactionHandler
|
||||||
|
onMessage MessageHandler
|
||||||
cancelSync context.CancelFunc
|
cancelSync context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +222,23 @@ func (c *Client) SetReactionHandler(fn ReactionHandler) {
|
|||||||
c.onReact = fn
|
c.onReact = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMessageHandler registers a callback for text messages in configured channel rooms.
|
||||||
|
func (c *Client) SetMessageHandler(fn MessageHandler) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.onMessage = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelForRoom returns the channel name configured for the given room, if any.
|
||||||
|
func (c *Client) ChannelForRoom(roomID id.RoomID) (string, bool) {
|
||||||
|
for name, rid := range c.channels {
|
||||||
|
if rid == roomID {
|
||||||
|
return name, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
// Start begins the Matrix sync loop in the background.
|
// Start begins the Matrix sync loop in the background.
|
||||||
func (c *Client) Start(ctx context.Context) {
|
func (c *Client) Start(ctx context.Context) {
|
||||||
syncer := c.mx.Syncer.(*mautrix.DefaultSyncer)
|
syncer := c.mx.Syncer.(*mautrix.DefaultSyncer)
|
||||||
@@ -247,6 +269,27 @@ func (c *Client) Start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Listen for text messages in configured channel rooms
|
||||||
|
syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) {
|
||||||
|
if evt.Sender == c.userID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
channel, ok := c.ChannelForRoom(evt.RoomID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := evt.Content.AsMessage()
|
||||||
|
if msg == nil || msg.MsgType != event.MsgText {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.RLock()
|
||||||
|
handler := c.onMessage
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if handler != nil {
|
||||||
|
handler(channel, evt.RoomID, evt.ID, evt.Sender, msg.Body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Auto-join on invite
|
// Auto-join on invite
|
||||||
syncer.OnEventType(event.StateMember, func(ctx context.Context, evt *event.Event) {
|
syncer.OnEventType(event.StateMember, func(ctx context.Context, evt *event.Event) {
|
||||||
if evt.GetStateKey() == string(c.userID) && evt.Content.AsMember().Membership == event.MembershipInvite {
|
if evt.GetStateKey() == string(c.userID) && evt.Content.AsMember().Membership == event.MembershipInvite {
|
||||||
|
|||||||
@@ -90,6 +90,26 @@ func (q *Queue) Wait() {
|
|||||||
<-q.done
|
<-q.done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForcePost pops the next queued item for the given channel and posts it
|
||||||
|
// immediately, bypassing min-interval, burst cap, and daily cap. Dedup
|
||||||
|
// (canonical URL cooldown) still applies. Returns true if an item was sent.
|
||||||
|
func (q *Queue) ForcePost(channel string) bool {
|
||||||
|
q.mu.Lock()
|
||||||
|
items := q.queues[channel]
|
||||||
|
if len(items) == 0 {
|
||||||
|
q.mu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
item := items[0]
|
||||||
|
q.queues[channel] = items[1:]
|
||||||
|
q.mu.Unlock()
|
||||||
|
|
||||||
|
slog.Info("force-posting story on demand",
|
||||||
|
"guid", item.GUID, "channel", channel)
|
||||||
|
q.postItem(item)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (q *Queue) drain() {
|
func (q *Queue) drain() {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
channels := make([]string, 0, len(q.queues))
|
channels := make([]string, 0, len(q.queues))
|
||||||
|
|||||||
20
main.go
20
main.go
@@ -6,6 +6,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ import (
|
|||||||
"pete/internal/poster"
|
"pete/internal/poster"
|
||||||
"pete/internal/scheduler"
|
"pete/internal/scheduler"
|
||||||
"pete/internal/storage"
|
"pete/internal/storage"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -80,6 +83,23 @@ func main() {
|
|||||||
exp := explainer.New(mx, ollamaClient)
|
exp := explainer.New(mx, ollamaClient)
|
||||||
poster.SetReactionCallback(exp.Handle)
|
poster.SetReactionCallback(exp.Handle)
|
||||||
|
|
||||||
|
// Wire !post command: force-publish next queued story for the room's channel.
|
||||||
|
mx.SetMessageHandler(func(channel string, roomID id.RoomID, eventID id.EventID, sender id.UserID, body string) {
|
||||||
|
if strings.TrimSpace(body) != "!post" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("!post requested", "channel", channel, "sender", sender)
|
||||||
|
if queue.ForcePost(channel) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := mx.PostThreadedReply(channel, eventID,
|
||||||
|
"nothing queued for "+channel,
|
||||||
|
"nothing queued for <code>"+channel+"</code>",
|
||||||
|
); err != nil {
|
||||||
|
slog.Warn("!post: failed to send empty-queue reply", "err", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Set up graceful shutdown
|
// Set up graceful shutdown
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
Reference in New Issue
Block a user