diff --git a/config.example.toml b/config.example.toml index e7eae4c..279db59 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,6 +1,10 @@ # GitHub webhook secret (set in your GitHub webhook settings) webhook_secret = "" +# Shared secret for custom endpoints (validated via X-CI-Secret header). +# Leave empty to allow unauthenticated access to custom endpoints. +ci_secret = "" + # Address to listen on listen_addr = ":9900" @@ -10,7 +14,7 @@ signal_url = "http://127.0.0.1:8081" # signal-cli account (phone number registered with signal-cli) signal_account = "+YOURNUMBER" -# Signal recipient UUID for DM notifications +# Signal recipient UUID for DM notifications (used by GitHub webhook handler) signal_recipient = "" # OR: Signal group ID for group notifications (overrides signal_recipient) @@ -30,3 +34,22 @@ signal_recipient = "" # star, fork, workflow_run, create, delete # # events = "" + +# Custom endpoints — each [[endpoints]] block registers a POST route +# that accepts {"source":"...","message":"..."} JSON and forwards the +# message to the listed Signal group IDs. Auth via X-CI-Secret header +# (if ci_secret is set). +# +# POST /prs -> sends to one group +# POST /releases -> sends to two groups + +[[endpoints]] +slug = "/prs" +group_ids = ["group-id-for-pr-notifications"] + +[[endpoints]] +slug = "/releases" +group_ids = [ + "group-id-for-release-channel-1", + "group-id-for-release-channel-2", +] diff --git a/config.go b/config.go index 64b6ce3..44d7c64 100644 --- a/config.go +++ b/config.go @@ -1,6 +1,7 @@ package main import ( + "log" "strings" "github.com/taigrr/jety" @@ -8,20 +9,21 @@ import ( // Config holds the application configuration. type Config struct { - // ListenAddr is the address to bind the webhook server to. - ListenAddr string - // WebhookSecret is the GitHub webhook secret for signature validation. - WebhookSecret string - // SignalURL is the signal-cli JSON-RPC base URL. - SignalURL string - // SignalAccount is the signal-cli account (phone number or UUID). - SignalAccount string - // SignalRecipient is the default Signal recipient UUID for DM notifications. + ListenAddr string + WebhookSecret string + CISecret string + SignalURL string + SignalAccount string SignalRecipient string - // SignalGroupID is the Signal group ID for group notifications (overrides SignalRecipient). - SignalGroupID string - // Events is the event filter. Empty means all events are forwarded. - Events EventFilter + SignalGroupID string + Events EventFilter + Endpoints []Endpoint +} + +// Endpoint defines a custom HTTP endpoint that forwards messages to one or more Signal groups. +type Endpoint struct { + Slug string + GroupIDs []string } func loadConfig() Config { @@ -33,7 +35,6 @@ func loadConfig() Config { jety.SetConfigType("toml") _ = jety.ReadInConfig() - // Parse events filter from comma-separated string or TOML array. var filters []string raw := jety.GetString("events") if raw != "" { @@ -48,10 +49,58 @@ func loadConfig() Config { return Config{ ListenAddr: jety.GetString("listen_addr"), WebhookSecret: jety.GetString("webhook_secret"), + CISecret: jety.GetString("ci_secret"), SignalURL: jety.GetString("signal_url"), SignalAccount: jety.GetString("signal_account"), SignalRecipient: jety.GetString("signal_recipient"), SignalGroupID: jety.GetString("signal_group_id"), Events: ParseEventFilter(filters), + Endpoints: parseEndpoints(), } } + +func parseEndpoints() []Endpoint { + raw := jety.Get("endpoints") + if raw == nil { + return nil + } + + tables, ok := raw.([]map[string]any) + if !ok { + log.Printf("warning: endpoints config is not a valid TOML array of tables") + return nil + } + + var endpoints []Endpoint + for _, t := range tables { + slug, _ := t["slug"].(string) + if slug == "" { + log.Printf("warning: endpoint missing slug, skipping") + continue + } + if !strings.HasPrefix(slug, "/") { + slug = "/" + slug + } + + var groupIDs []string + switch v := t["group_ids"].(type) { + case []any: + for _, item := range v { + if s, ok := item.(string); ok && s != "" { + groupIDs = append(groupIDs, s) + } + } + case []string: + groupIDs = v + } + + if len(groupIDs) == 0 { + log.Printf("warning: endpoint %q has no group_ids, skipping", slug) + continue + } + + endpoints = append(endpoints, Endpoint{Slug: slug, GroupIDs: groupIDs}) + } + + return endpoints +} diff --git a/examples/signal-notify.yml b/examples/signal-notify.yml new file mode 100644 index 0000000..6952ed4 --- /dev/null +++ b/examples/signal-notify.yml @@ -0,0 +1,32 @@ +name: Signal Notification + +on: + workflow_run: + workflows: ["CI"] + types: [completed] + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - name: Notify Signal on failure + if: ${{ github.event.workflow_run.conclusion == 'failure' }} + run: | + curl -sf -X POST "${{ secrets.SIGNAL_ENDPOINT }}/ci" \ + -H "Content-Type: application/json" \ + -H "X-CI-Secret: ${{ secrets.SIGNAL_API_KEY }}" \ + -d '{ + "source": "${{ github.repository }}", + "message": "❌ Workflow \"${{ github.event.workflow_run.name }}\" failed on ${{ github.event.workflow_run.head_branch }}\n${{ github.event.workflow_run.html_url }}" + }' + + - name: Notify Signal on success + if: ${{ github.event.workflow_run.conclusion == 'success' }} + run: | + curl -sf -X POST "${{ secrets.SIGNAL_ENDPOINT }}/ci" \ + -H "Content-Type: application/json" \ + -H "X-CI-Secret: ${{ secrets.SIGNAL_API_KEY }}" \ + -d '{ + "source": "${{ github.repository }}", + "message": "✅ Workflow \"${{ github.event.workflow_run.name }}\" succeeded on ${{ github.event.workflow_run.head_branch }}\n${{ github.event.workflow_run.html_url }}" + }' diff --git a/go.mod b/go.mod index d0e109f..2519160 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module github.com/taigrr/github-to-signal -go 1.26.0 +go 1.26.1 require ( github.com/cbrgm/githubevents/v2 v2.2.0 github.com/google/go-github/v70 v70.0.0 - github.com/taigrr/jety v0.2.0 + github.com/taigrr/jety v0.4.0 github.com/taigrr/signalcli v0.0.0-20260301154901-6aed09f3f2af ) diff --git a/go.sum b/go.sum index d8b81f3..f2e9b62 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfh github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/taigrr/jety v0.2.0 h1:oGv6i1yBxdV5YnRD4f7V9Nv74PxY0kVIXCTJsRkqgmo= -github.com/taigrr/jety v0.2.0/go.mod h1:PtJDxNFDqR6739at6QrjlE4MGf/zcs21Y86KVQMKPEk= +github.com/taigrr/jety v0.4.0 h1:BECC3r3CdQOxN/OdJpJ1VFH6DCJnmNby4vxRwL2wcZQ= +github.com/taigrr/jety v0.4.0/go.mod h1:Z8O3yHvOIv0O+KTadzHl58/gfAtLwNo8FlsL2JwpaKA= github.com/taigrr/signalcli v0.0.0-20260301154901-6aed09f3f2af h1:qavmWkSyV3NeiZ+HHVj1I/6c8VQosUKFV1SfN1X/4DM= github.com/taigrr/signalcli v0.0.0-20260301154901-6aed09f3f2af/go.mod h1:Jgly+nAwowk5O5ZUXSAAxCOVK9nlsAvPIfdNg5kXWSM= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= diff --git a/main.go b/main.go index c8882f4..d367a41 100644 --- a/main.go +++ b/main.go @@ -4,9 +4,12 @@ package main import ( "context" + "crypto/subtle" + "encoding/json" "fmt" "log" "net/http" + "strings" "github.com/cbrgm/githubevents/v2/githubevents" "github.com/google/go-github/v70/github" @@ -92,6 +95,10 @@ func main() { } w.WriteHeader(http.StatusOK) }) + for _, ep := range cfg.Endpoints { + mux.HandleFunc("POST "+ep.Slug, n.handleCustom(cfg.CISecret, ep.GroupIDs)) + log.Printf("custom endpoint enabled: POST %s -> %d group(s)", ep.Slug, len(ep.GroupIDs)) + } mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprintln(w, "ok") @@ -221,3 +228,77 @@ func (n *notifier) onDelete(ctx context.Context, _ string, _ string, event *gith n.send(ctx, formatDelete(event)) return nil } + +type customMessage struct { + Source string `json:"source"` + Message string `json:"message"` +} + +func (n *notifier) handleCustom(secret string, groupIDs []string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if secret != "" { + provided := r.Header.Get("X-CI-Secret") + if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + } + + var msg customMessage + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + http.Error(w, "invalid JSON body", http.StatusBadRequest) + return + } + if msg.Message == "" { + http.Error(w, "message is required", http.StatusBadRequest) + return + } + + text := msg.Message + if msg.Source != "" { + text = fmt.Sprintf("[%s] %s", msg.Source, msg.Message) + } + + n.sendToGroups(r.Context(), text, groupIDs) + w.WriteHeader(http.StatusOK) + } +} + +const maxMessageLen = 2000 + +func (n *notifier) sendToGroups(ctx context.Context, msg string, groupIDs []string) { + chunks := splitMessage(msg) + for _, gid := range groupIDs { + for _, chunk := range chunks { + params := signalcli.SendParams{Message: chunk, GroupID: gid} + if _, err := n.signal.Send(ctx, params); err != nil { + log.Printf("signal send error (group %s): %v", gid, err) + } + } + } +} + +func splitMessage(msg string) []string { + if len(msg) <= maxMessageLen { + return []string{msg} + } + + var chunks []string + for len(msg) > 0 { + end := maxMessageLen + if end > len(msg) { + end = len(msg) + } + if end < len(msg) { + if idx := strings.LastIndex(msg[:end], "\n"); idx > 0 { + end = idx + 1 + } + } + chunk := strings.TrimSpace(msg[:end]) + if chunk != "" { + chunks = append(chunks, chunk) + } + msg = msg[end:] + } + return chunks +}