rss email digests over ssh because you're a cool kid herald.dunkirk.sh
go rss rss-reader ssh charm
at main 7.8 kB view raw
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}