Skip to content

Commit fe60414

Browse files
committed
feat: dispatch webhook and ntfy deliveries
1 parent 533183c commit fe60414

16 files changed

Lines changed: 709 additions & 17 deletions

internal/cli/cli_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"flag"
88
"os"
99
"path/filepath"
10+
"reflect"
1011
"regexp"
1112
"strings"
1213
"testing"
@@ -152,6 +153,27 @@ func TestTelegramDeliveryTarget(t *testing.T) {
152153
}
153154
}
154155

156+
func TestDeliveryRuntimeUsesStableTargetsAndEnvironmentSecrets(t *testing.T) {
157+
t.Setenv("SCRIBA_WEBHOOK_TEST_SECRET", "webhook-secret")
158+
t.Setenv("SCRIBA_NTFY_TEST_TOKEN", "ntfy-token")
159+
cfg := config.Default()
160+
cfg.Telegram.Enabled = true
161+
cfg.Telegram.ChatID = "123"
162+
cfg.Deliveries = config.DeliveryConfig{
163+
Webhooks: []config.WebhookConfig{{ID: "deploy", Enabled: true, URL: "https://example.com/hook", SecretEnv: "SCRIBA_WEBHOOK_TEST_SECRET"}},
164+
Ntfy: []config.NtfyConfig{{ID: "phone", Enabled: true, URL: "https://ntfy.sh", Topic: "scriba", TokenEnv: "SCRIBA_NTFY_TEST_TOKEN"}},
165+
}
166+
chatID, targets, adapters, err := deliveryRuntime(cfg)
167+
if err != nil || chatID != 123 || !reflect.DeepEqual(targets, []string{"telegram:123", "webhook:deploy", "ntfy:phone"}) || len(adapters) != 2 || adapters[0].Target() != "webhook:deploy" || adapters[1].Target() != "ntfy:phone" {
168+
t.Fatalf("chat=%d targets=%v adapters=%v err=%v", chatID, targets, adapters, err)
169+
}
170+
171+
t.Setenv("SCRIBA_WEBHOOK_TEST_SECRET", "")
172+
if _, _, _, err := deliveryRuntime(cfg); err == nil || strings.Contains(err.Error(), "webhook-secret") {
173+
t.Fatalf("missing or unsafe webhook secret error: %v", err)
174+
}
175+
}
176+
155177
func TestCodexLimitsFromSnapshotRequiresCodexProvider(t *testing.T) {
156178
_, err := codexLimitsFromSnapshot(model.StatusSnapshot{
157179
SchemaVersion: model.SchemaVersion,

internal/cli/server.go

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616

1717
"github.com/agensfield/scriba/internal/buildinfo"
1818
"github.com/agensfield/scriba/internal/config"
19+
"github.com/agensfield/scriba/internal/delivery"
1920
"github.com/agensfield/scriba/internal/localapi"
2021
"github.com/agensfield/scriba/internal/radar"
2122
"github.com/agensfield/scriba/internal/resetwatch"
@@ -113,20 +114,24 @@ func runServerRun(cfg config.Config, opts options) error {
113114
if err != nil {
114115
return err
115116
}
116-
chatID, notificationTarget, err := telegramDeliveryTarget(cfg)
117+
chatID, notificationTargets, deliveryAdapters, err := deliveryRuntime(cfg)
117118
if err != nil {
118119
return err
119120
}
120121
srv := servercore.New(st, nil, nil, servercore.Config{
121122
Profiles: profiles,
122-
NotificationTarget: notificationTarget,
123+
NotificationTargets: notificationTargets,
123124
AccountLabel: cfg.Server.AccountLabel,
124125
JokeTone: cfg.Telegram.ResetJokeTone,
125126
StartupHeartbeat: heartbeat,
126127
ObservationRetentionDays: cfg.Server.ObservationRetentionDays,
127128
})
128129
srv.SetRadarFetcher(radar.Client{})
129130
children := []func(context.Context) error{srv.Run}
131+
for _, adapter := range deliveryAdapters {
132+
dispatcher := delivery.Dispatcher{Store: st, Adapter: adapter}
133+
children = append(children, dispatcher.Run)
134+
}
130135
if cfg.Server.ContextAPI.Enabled {
131136
socketPath := resolveContextAPISocketPath(st.Path(), cfg.Server.ContextAPI.SocketPath)
132137
listener, err := localapi.Listen(ctx, socketPath)
@@ -323,13 +328,13 @@ func runServerRefresh(cfg config.Config, opts options) error {
323328
if err := st.SyncProfiles(context.Background(), specs); err != nil {
324329
return fmt.Errorf("sync server profiles: %w", err)
325330
}
326-
_, notificationTarget, err := telegramDeliveryTarget(cfg)
331+
_, notificationTargets, _, err := deliveryRuntime(cfg)
327332
if err != nil {
328333
return err
329334
}
330335
srv := servercore.New(st, nil, nil, servercore.Config{
331336
Profiles: profiles,
332-
NotificationTarget: notificationTarget,
337+
NotificationTargets: notificationTargets,
333338
AccountLabel: cfg.Server.AccountLabel,
334339
JokeTone: cfg.Telegram.ResetJokeTone,
335340
ObservationRetentionDays: cfg.Server.ObservationRetentionDays,
@@ -352,6 +357,44 @@ func telegramDeliveryTarget(cfg config.Config) (int64, string, error) {
352357
return chatID, fmt.Sprintf("telegram:%d", chatID), nil
353358
}
354359

360+
func deliveryRuntime(cfg config.Config) (int64, []string, []delivery.Adapter, error) {
361+
chatID, telegramTarget, err := telegramDeliveryTarget(cfg)
362+
if err != nil {
363+
return 0, nil, nil, err
364+
}
365+
targets := make([]string, 0, 1+len(cfg.Deliveries.Webhooks)+len(cfg.Deliveries.Ntfy))
366+
if telegramTarget != "" {
367+
targets = append(targets, telegramTarget)
368+
}
369+
adapters := make([]delivery.Adapter, 0, len(cfg.Deliveries.Webhooks)+len(cfg.Deliveries.Ntfy))
370+
for _, webhook := range cfg.Deliveries.Webhooks {
371+
if !webhook.Enabled {
372+
continue
373+
}
374+
secret := os.Getenv(webhook.SecretEnv)
375+
if secret == "" {
376+
return 0, nil, nil, fmt.Errorf("missing webhook secret; set env %s", webhook.SecretEnv)
377+
}
378+
adapter := delivery.Webhook{ID: webhook.ID, URL: webhook.URL, Secret: []byte(secret)}
379+
targets, adapters = append(targets, adapter.Target()), append(adapters, adapter)
380+
}
381+
for _, ntfy := range cfg.Deliveries.Ntfy {
382+
if !ntfy.Enabled {
383+
continue
384+
}
385+
token := ""
386+
if ntfy.TokenEnv != "" {
387+
token = os.Getenv(ntfy.TokenEnv)
388+
if token == "" {
389+
return 0, nil, nil, fmt.Errorf("missing ntfy token; set env %s", ntfy.TokenEnv)
390+
}
391+
}
392+
adapter := delivery.Ntfy{ID: ntfy.ID, URL: ntfy.URL, Topic: ntfy.Topic, Token: token}
393+
targets, adapters = append(targets, adapter.Target()), append(adapters, adapter)
394+
}
395+
return chatID, targets, adapters, nil
396+
}
397+
355398
func runServerPrune(cfg config.Config, opts options) error {
356399
st, err := store.Open(resolveServerStatePath(cfg.Server.StatePath))
357400
if err != nil {

internal/config/config.go

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"errors"
66
"fmt"
7+
"net/url"
78
"os"
89
"path/filepath"
910
"regexp"
@@ -13,6 +14,8 @@ import (
1314
)
1415

1516
var profileIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
17+
var envNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
18+
var ntfyTopicPattern = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`)
1619

1720
type ProviderConfig struct {
1821
Enabled bool `json:"enabled"`
@@ -51,6 +54,26 @@ type ContextAPIConfig struct {
5154
SocketPath string `json:"socketPath,omitempty"`
5255
}
5356

57+
type WebhookConfig struct {
58+
ID string `json:"id"`
59+
Enabled bool `json:"enabled"`
60+
URL string `json:"url"`
61+
SecretEnv string `json:"secretEnv"`
62+
}
63+
64+
type NtfyConfig struct {
65+
ID string `json:"id"`
66+
Enabled bool `json:"enabled"`
67+
URL string `json:"url"`
68+
Topic string `json:"topic"`
69+
TokenEnv string `json:"tokenEnv,omitempty"`
70+
}
71+
72+
type DeliveryConfig struct {
73+
Webhooks []WebhookConfig `json:"webhooks,omitempty"`
74+
Ntfy []NtfyConfig `json:"ntfy,omitempty"`
75+
}
76+
5477
type Config struct {
5578
SchemaVersion int `json:"schemaVersion"`
5679
DefaultProfileID string `json:"defaultProfileId"`
@@ -62,8 +85,9 @@ type Config struct {
6285
Claude ProviderConfig `json:"claude"`
6386
Codex ProviderConfig `json:"codex"`
6487
} `json:"providers"`
65-
Server ServerConfig `json:"server"`
66-
Telegram TelegramConfig `json:"telegram"`
88+
Server ServerConfig `json:"server"`
89+
Telegram TelegramConfig `json:"telegram"`
90+
Deliveries DeliveryConfig `json:"deliveries,omitempty"`
6791
}
6892

6993
type Profile struct {
@@ -266,5 +290,61 @@ func Validate(cfg Config) error {
266290
if !defaultEnabled {
267291
return errors.New("defaultProfileId must identify an enabled profile")
268292
}
293+
if err := validateDeliveries(cfg.Deliveries); err != nil {
294+
return err
295+
}
296+
return nil
297+
}
298+
299+
func validateDeliveries(cfg DeliveryConfig) error {
300+
seen := make(map[string]struct{}, len(cfg.Webhooks)+len(cfg.Ntfy))
301+
validateID := func(kind, id string) error {
302+
if len(id) > 32 || !profileIDPattern.MatchString(id) {
303+
return fmt.Errorf("%s id must be a lowercase slug of at most 32 characters", kind)
304+
}
305+
target := kind + ":" + id
306+
if _, ok := seen[target]; ok {
307+
return fmt.Errorf("duplicate delivery target %q", target)
308+
}
309+
seen[target] = struct{}{}
310+
return nil
311+
}
312+
validateURL := func(kind, value string) error {
313+
u, err := url.Parse(value)
314+
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.Fragment != "" {
315+
return fmt.Errorf("%s url must be an absolute HTTP(S) URL", kind)
316+
}
317+
return nil
318+
}
319+
for i, webhook := range cfg.Webhooks {
320+
kind := fmt.Sprintf("deliveries.webhooks[%d]", i)
321+
if err := validateID("webhook", webhook.ID); err != nil {
322+
return fmt.Errorf("%s: %w", kind, err)
323+
}
324+
if err := validateURL(kind, webhook.URL); err != nil {
325+
return err
326+
}
327+
if webhook.Enabled && !envNamePattern.MatchString(webhook.SecretEnv) {
328+
return fmt.Errorf("%s.secretEnv is required and must name an environment variable", kind)
329+
}
330+
}
331+
for i, ntfy := range cfg.Ntfy {
332+
kind := fmt.Sprintf("deliveries.ntfy[%d]", i)
333+
if err := validateID("ntfy", ntfy.ID); err != nil {
334+
return fmt.Errorf("%s: %w", kind, err)
335+
}
336+
if err := validateURL(kind, ntfy.URL); err != nil {
337+
return err
338+
}
339+
if u, _ := url.Parse(ntfy.URL); u.RawQuery != "" {
340+
return fmt.Errorf("%s.url must not contain a query string", kind)
341+
}
342+
if !ntfyTopicPattern.MatchString(ntfy.Topic) {
343+
return fmt.Errorf("%s.topic must contain 1-64 letters, numbers, underscores, or dashes", kind)
344+
}
345+
if ntfy.TokenEnv != "" && !envNamePattern.MatchString(ntfy.TokenEnv) {
346+
return fmt.Errorf("%s.tokenEnv must name an environment variable", kind)
347+
}
348+
}
269349
return nil
270350
}

internal/config/config_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,43 @@ func TestProfileValidation(t *testing.T) {
184184
})
185185
}
186186
}
187+
188+
func TestDeliveryConfigRoundTripAndValidation(t *testing.T) {
189+
path := filepath.Join(t.TempDir(), "config.json")
190+
cfg := Default()
191+
cfg.Deliveries = DeliveryConfig{
192+
Webhooks: []WebhookConfig{{ID: "deploy", Enabled: true, URL: "https://example.com/scriba", SecretEnv: "SCRIBA_WEBHOOK_DEPLOY_SECRET"}},
193+
Ntfy: []NtfyConfig{{ID: "phone", Enabled: true, URL: "https://ntfy.sh", Topic: "scriba-private", TokenEnv: "SCRIBA_NTFY_TOKEN"}},
194+
}
195+
if err := Save(path, cfg); err != nil {
196+
t.Fatal(err)
197+
}
198+
got, err := Load(path)
199+
if err != nil {
200+
t.Fatal(err)
201+
}
202+
if len(got.Deliveries.Webhooks) != 1 || got.Deliveries.Webhooks[0].ID != "deploy" || len(got.Deliveries.Ntfy) != 1 || got.Deliveries.Ntfy[0].Topic != "scriba-private" {
203+
t.Fatalf("deliveries=%+v", got.Deliveries)
204+
}
205+
206+
tests := []func(*Config){
207+
func(c *Config) { c.Deliveries.Webhooks[0].ID = "Bad_ID" },
208+
func(c *Config) { c.Deliveries.Webhooks[0].URL = "/relative" },
209+
func(c *Config) { c.Deliveries.Webhooks[0].SecretEnv = "" },
210+
func(c *Config) { c.Deliveries.Ntfy[0].Topic = " bad " },
211+
func(c *Config) { c.Deliveries.Ntfy[0].Topic = strings.Repeat("a", 65) },
212+
func(c *Config) { c.Deliveries.Ntfy[0].URL = "https://user:[email protected]" },
213+
func(c *Config) { c.Deliveries.Ntfy[0].URL = "https://ntfy.sh?token=secret" },
214+
func(c *Config) { c.Deliveries.Ntfy[0].TokenEnv = "BAD ENV" },
215+
func(c *Config) { c.Deliveries.Ntfy = append(c.Deliveries.Ntfy, c.Deliveries.Ntfy[0]) },
216+
}
217+
for i, mutate := range tests {
218+
broken := cfg
219+
broken.Deliveries.Webhooks = append([]WebhookConfig(nil), cfg.Deliveries.Webhooks...)
220+
broken.Deliveries.Ntfy = append([]NtfyConfig(nil), cfg.Deliveries.Ntfy...)
221+
mutate(&broken)
222+
if err := Validate(broken); err == nil {
223+
t.Fatalf("invalid delivery config %d accepted", i)
224+
}
225+
}
226+
}

0 commit comments

Comments
 (0)