rss email digests over ssh because you're a cool kid
herald.dunkirk.sh
go
rss
rss-reader
ssh
charm
1package ssh
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "strings"
8 "sync/atomic"
9 "time"
10
11 "github.com/charmbracelet/lipgloss"
12 "github.com/charmbracelet/log"
13 "github.com/charmbracelet/ssh"
14 "github.com/kierank/herald/scheduler"
15 "github.com/kierank/herald/store"
16)
17
18var (
19 titleStyle = lipgloss.NewStyle().
20 Bold(true).
21 Foreground(lipgloss.Color("12"))
22
23 dimStyle = lipgloss.NewStyle().
24 Foreground(lipgloss.Color("8"))
25
26 successStyle = lipgloss.NewStyle().
27 Foreground(lipgloss.Color("10"))
28
29 errorStyle = lipgloss.NewStyle().
30 Foreground(lipgloss.Color("9"))
31)
32
33// print writes to the session, ignoring errors (connection drops are expected)
34func print(w io.Writer, args ...interface{}) {
35 _, _ = fmt.Fprint(w, args...)
36}
37
38// printf writes formatted output to the session, ignoring errors
39func printf(w io.Writer, format string, args ...interface{}) {
40 _, _ = fmt.Fprintf(w, format, args...)
41}
42
43// println writes a line to the session, ignoring errors
44func println(w io.Writer, args ...interface{}) {
45 _, _ = fmt.Fprintln(w, args...)
46}
47
48func HandleCommand(sess ssh.Session, user *store.User, st *store.DB, sched *scheduler.Scheduler, logger *log.Logger) {
49 cmd := sess.Command()
50 if len(cmd) == 0 {
51 return
52 }
53
54 ctx := context.Background()
55
56 switch cmd[0] {
57 case "ls":
58 handleLs(ctx, sess, user, st)
59 case "cat":
60 if len(cmd) < 2 {
61 println(sess, errorStyle.Render("Usage: cat <filename>"))
62 return
63 }
64 handleCat(ctx, sess, user, st, cmd[1])
65 case "rm":
66 if len(cmd) < 2 {
67 println(sess, errorStyle.Render("Usage: rm <filename>"))
68 return
69 }
70 handleRm(ctx, sess, user, st, cmd[1])
71 case "activate":
72 if len(cmd) < 2 {
73 println(sess, errorStyle.Render("Usage: activate <filename>"))
74 return
75 }
76 handleActivate(ctx, sess, user, st, cmd[1])
77 case "deactivate":
78 if len(cmd) < 2 {
79 println(sess, errorStyle.Render("Usage: deactivate <filename>"))
80 return
81 }
82 handleDeactivate(ctx, sess, user, st, cmd[1])
83 case "run":
84 if len(cmd) < 2 {
85 println(sess, errorStyle.Render("Usage: run <filename>"))
86 return
87 }
88 handleRun(ctx, sess, user, st, sched, cmd[1])
89 case "logs":
90 handleLogs(ctx, sess, user, st)
91 default:
92 printf(sess, errorStyle.Render("Unknown command: %s\n"), cmd[0])
93 println(sess, "Available commands: ls, cat, rm, activate, deactivate, run, logs")
94 }
95}
96
97func handleLs(ctx context.Context, sess ssh.Session, user *store.User, st *store.DB) {
98 configs, err := st.ListConfigs(ctx, user.ID)
99 if err != nil {
100 println(sess, errorStyle.Render("Error: "+err.Error()))
101 return
102 }
103
104 if len(configs) == 0 {
105 println(sess, dimStyle.Render("No configs found. Upload one with: scp feeds.txt <host>:"))
106 return
107 }
108
109 println(sess, titleStyle.Render("Your configs:"))
110
111 for _, cfg := range configs {
112 feeds, err := st.GetFeedsByConfig(ctx, cfg.ID)
113 feedCount := 0
114 if err == nil {
115 feedCount = len(feeds)
116 }
117
118 nextRunStr := "never"
119 if cfg.NextRun.Valid {
120 nextRunStr = formatRelativeTime(cfg.NextRun.Time)
121 }
122
123 printf(sess, " %-20s %s next: %s\n",
124 cfg.Filename,
125 dimStyle.Render(fmt.Sprintf("%d feed(s)", feedCount)),
126 nextRunStr,
127 )
128 }
129}
130
131func handleCat(ctx context.Context, sess ssh.Session, user *store.User, st *store.DB, filename string) {
132 cfg, err := st.GetConfig(ctx, user.ID, filename)
133 if err != nil {
134 println(sess, errorStyle.Render("Config not found: "+filename))
135 return
136 }
137
138 println(sess, titleStyle.Render("# "+filename))
139 println(sess, cfg.RawText)
140}
141
142func handleRm(ctx context.Context, sess ssh.Session, user *store.User, st *store.DB, filename string) {
143 err := st.DeleteConfig(ctx, user.ID, filename)
144 if err != nil {
145 println(sess, errorStyle.Render("Error: "+err.Error()))
146 return
147 }
148
149 println(sess, successStyle.Render("Deleted: "+filename))
150}
151
152func handleActivate(ctx context.Context, sess ssh.Session, user *store.User, st *store.DB, filename string) {
153 err := st.ActivateConfig(ctx, user.ID, filename)
154 if err != nil {
155 println(sess, errorStyle.Render("Error: "+err.Error()))
156 return
157 }
158
159 println(sess, successStyle.Render("Activated: "+filename))
160}
161
162func handleDeactivate(ctx context.Context, sess ssh.Session, user *store.User, st *store.DB, filename string) {
163 err := st.DeactivateConfigByFilename(ctx, user.ID, filename)
164 if err != nil {
165 println(sess, errorStyle.Render("Error: "+err.Error()))
166 return
167 }
168
169 println(sess, successStyle.Render("Deactivated: "+filename))
170}
171
172func handleRun(ctx context.Context, sess ssh.Session, user *store.User, st *store.DB, sched *scheduler.Scheduler, filename string) {
173 cfg, err := st.GetConfig(ctx, user.ID, filename)
174 if err != nil {
175 println(sess, errorStyle.Render("Config not found: "+filename))
176 return
177 }
178
179 // Get feed count for progress display
180 feeds, err := st.GetFeedsByConfig(ctx, cfg.ID)
181 if err != nil {
182 println(sess, errorStyle.Render("Error: "+err.Error()))
183 return
184 }
185 totalFeeds := len(feeds)
186
187 // Progress tracking
188 var progress atomic.Int32
189 spinChars := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
190 done := make(chan struct{})
191 result := make(chan struct {
192 stats *scheduler.RunStats
193 err error
194 })
195
196 // Spinner goroutine with real-time progress
197 go func() {
198 i := 0
199 for {
200 select {
201 case <-done:
202 return
203 default:
204 completed := progress.Load()
205 printf(sess, "\r%s Fetching feeds... %d/%d", spinChars[i%len(spinChars)], completed, totalFeeds)
206 i++
207 time.Sleep(80 * time.Millisecond)
208 }
209 }
210 }()
211
212 // Work goroutine
213 go func() {
214 stats, err := sched.RunNow(ctx, cfg.ID, &progress)
215 result <- struct {
216 stats *scheduler.RunStats
217 err error
218 }{stats: stats, err: err}
219 }()
220
221 // Wait for result
222 res := <-result
223 close(done)
224 print(sess, "\r\033[K") // Clear the spinner line
225
226 if res.err != nil {
227 println(sess, errorStyle.Render("Error: "+res.err.Error()))
228 return
229 }
230
231 // Display detailed stats
232 if res.stats != nil {
233 if res.stats.FailedFeeds > 0 {
234 printf(sess, "%s Fetched %d/%d feeds (%d failed)\n",
235 dimStyle.Render("⚠"),
236 res.stats.FetchedFeeds,
237 res.stats.TotalFeeds,
238 res.stats.FailedFeeds)
239 } else {
240 printf(sess, "%s Fetched %d/%d feeds\n",
241 successStyle.Render("✓"),
242 res.stats.FetchedFeeds,
243 res.stats.TotalFeeds)
244 }
245
246 if res.stats.NewItems == 0 {
247 println(sess, dimStyle.Render("No new items found."))
248 } else {
249 if res.stats.EmailSent {
250 println(sess, successStyle.Render(fmt.Sprintf("Sent %d new item(s) to %s", res.stats.NewItems, cfg.Email)))
251 } else {
252 println(sess, dimStyle.Render(fmt.Sprintf("Found %d new item(s) but did not send email", res.stats.NewItems)))
253 }
254 }
255 }
256}
257
258func handleLogs(ctx context.Context, sess ssh.Session, user *store.User, st *store.DB) {
259 logs, err := st.GetRecentLogs(ctx, user.ID, 20)
260 if err != nil {
261 println(sess, errorStyle.Render("Error: "+err.Error()))
262 return
263 }
264
265 if len(logs) == 0 {
266 println(sess, dimStyle.Render("No logs yet."))
267 return
268 }
269
270 println(sess, titleStyle.Render("Recent activity:"))
271
272 for _, l := range logs {
273 levelStyle := dimStyle
274 switch strings.ToLower(l.Level) {
275 case "error":
276 levelStyle = errorStyle
277 case "info":
278 levelStyle = successStyle
279 }
280
281 timestamp := l.CreatedAt.Format("Jan 02 15:04")
282 printf(sess, " %s %s %s\n",
283 dimStyle.Render(timestamp),
284 levelStyle.Render(fmt.Sprintf("[%s]", l.Level)),
285 l.Message,
286 )
287 }
288}
289
290func formatRelativeTime(t time.Time) string {
291 now := time.Now()
292 diff := t.Sub(now)
293
294 if diff < 0 {
295 return "overdue"
296 }
297
298 if diff < time.Minute {
299 return "< 1 min"
300 }
301 if diff < time.Hour {
302 mins := int(diff.Minutes())
303 return fmt.Sprintf("%d min", mins)
304 }
305 if diff < 24*time.Hour {
306 hours := int(diff.Hours())
307 return fmt.Sprintf("%d hr", hours)
308 }
309
310 days := int(diff.Hours() / 24)
311 return fmt.Sprintf("%d day(s)", days)
312}