From e5410a5d11c302182a1e0dd999d6e1f4783f1b8c Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Tue, 10 Mar 2026 23:55:19 +0000 Subject: [PATCH 1/8] feat: add group chat support Set signal_group_id in config to send to a group instead of a DM. Group ID takes priority over signal_recipient when both are set. --- config.example.toml | 5 ++++- config.go | 5 ++++- main.go | 17 +++++++++++------ 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/config.example.toml b/config.example.toml index 143720d..22bdcbc 100644 --- a/config.example.toml +++ b/config.example.toml @@ -10,5 +10,8 @@ signal_url = "http://127.0.0.1:8080" # signal-cli account (phone number registered with signal-cli) signal_account = "+1234567890" -# Signal recipient UUID or phone number to send notifications to +# Signal recipient UUID for DM notifications signal_recipient = "" + +# OR: Signal group ID for group notifications (overrides signal_recipient) +# signal_group_id = "" diff --git a/config.go b/config.go index 0149696..8e2c4c3 100644 --- a/config.go +++ b/config.go @@ -12,8 +12,10 @@ type Config struct { SignalURL string // SignalAccount is the signal-cli account (phone number or UUID). SignalAccount string - // SignalRecipient is the default Signal recipient for notifications. + // SignalRecipient is the default Signal recipient UUID for DM notifications. SignalRecipient string + // SignalGroupID is the Signal group ID for group notifications (overrides SignalRecipient). + SignalGroupID string } func loadConfig() Config { @@ -31,5 +33,6 @@ func loadConfig() Config { SignalURL: jety.GetString("signal_url"), SignalAccount: jety.GetString("signal_account"), SignalRecipient: jety.GetString("signal_recipient"), + SignalGroupID: jety.GetString("signal_group_id"), } } diff --git a/main.go b/main.go index c51df94..8a6ed3d 100644 --- a/main.go +++ b/main.go @@ -19,8 +19,8 @@ func main() { if cfg.SignalAccount == "" { log.Fatal("signal_account is required (set GH2SIG_SIGNAL_ACCOUNT or config.toml)") } - if cfg.SignalRecipient == "" { - log.Fatal("signal_recipient is required (set GH2SIG_SIGNAL_RECIPIENT or config.toml)") + if cfg.SignalRecipient == "" && cfg.SignalGroupID == "" { + log.Fatal("signal_recipient or signal_group_id is required") } signal := signalcli.NewClient(cfg.SignalURL, cfg.SignalAccount) @@ -29,6 +29,7 @@ func main() { notifier := ¬ifier{ signal: signal, recipient: cfg.SignalRecipient, + groupID: cfg.SignalGroupID, } // Register event handlers. @@ -73,16 +74,20 @@ func main() { type notifier struct { signal *signalcli.Client recipient string + groupID string } func (n *notifier) send(ctx context.Context, msg string) { if msg == "" { return } - _, err := n.signal.Send(ctx, signalcli.SendParams{ - Recipient: n.recipient, - Message: msg, - }) + params := signalcli.SendParams{Message: msg} + if n.groupID != "" { + params.GroupID = n.groupID + } else { + params.Recipient = n.recipient + } + _, err := n.signal.Send(ctx, params) if err != nil { log.Printf("signal send error: %v", err) } From b5ca99ad9ab8202219a7544481bf9649bb38def8 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Wed, 11 Mar 2026 00:09:35 +0000 Subject: [PATCH 2/8] fix: use default.target for user systemd units User units don't have multi-user.target. Also removed User/Group and hardening directives that don't apply to user services. --- deploy/github-to-signal.service | 13 ++----------- deploy/signal-cli-bot.service | 12 +----------- 2 files changed, 3 insertions(+), 22 deletions(-) diff --git a/deploy/github-to-signal.service b/deploy/github-to-signal.service index a1d8dcc..8613831 100644 --- a/deploy/github-to-signal.service +++ b/deploy/github-to-signal.service @@ -6,18 +6,9 @@ Requires=signal-cli-bot.service [Service] Type=exec ExecStart=/usr/local/bin/github-to-signal -WorkingDirectory=/etc/github-to-signal +WorkingDirectory=/home/tai/code/foss/github-to-signal Restart=on-failure RestartSec=5 -# Hardening -NoNewPrivileges=true -ProtectSystem=strict -ProtectHome=true -PrivateTmp=true - -User=signal-bot -Group=signal-bot - [Install] -WantedBy=multi-user.target +WantedBy=default.target diff --git a/deploy/signal-cli-bot.service b/deploy/signal-cli-bot.service index 3531c32..44b761e 100644 --- a/deploy/signal-cli-bot.service +++ b/deploy/signal-cli-bot.service @@ -9,15 +9,5 @@ ExecStart=/usr/local/bin/signal-cli -a +1YOURNUMBER daemon --http 127.0.0.1:8081 Restart=on-failure RestartSec=5 -# Hardening -NoNewPrivileges=true -ProtectSystem=strict -ProtectHome=read-only -PrivateTmp=true -ReadWritePaths=/home/signal-bot/.local/share/signal-cli - -User=signal-bot -Group=signal-bot - [Install] -WantedBy=multi-user.target +WantedBy=default.target From 19a7b31dea666b56477c4e8a3c4166694ec340ee Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Wed, 11 Mar 2026 02:04:30 +0000 Subject: [PATCH 3/8] feat: add event type and action filtering Configure which events to forward via comma-separated 'events' config. Supports event-level ('push', 'pull_request') or event:action-level ('pull_request:opened', 'issues:closed') filtering. Empty/omitted = forward everything (backwards compatible). --- config.example.toml | 15 +++++++ config.go | 21 +++++++++- filter.go | 82 ++++++++++++++++++++++++++++++++++++++ filter_test.go | 32 +++++++++++++++ main.go | 97 ++++++++++++++++++++++++++++++++++++++------- 5 files changed, 232 insertions(+), 15 deletions(-) create mode 100644 filter.go create mode 100644 filter_test.go diff --git a/config.example.toml b/config.example.toml index 22bdcbc..f2f07d8 100644 --- a/config.example.toml +++ b/config.example.toml @@ -15,3 +15,18 @@ signal_recipient = "" # OR: Signal group ID for group notifications (overrides signal_recipient) # signal_group_id = "" + +# Event filter — comma-separated list of event types to forward. +# Use "event" for all actions, or "event:action" for specific actions. +# Leave empty or omit to forward everything. +# +# Examples: +# events = "pull_request:opened, pull_request:closed, issues:opened" +# events = "push, pull_request, workflow_run" +# events = "pull_request:opened" +# +# Available events: push, issues, issue_comment, pull_request, +# pull_request_review, pull_request_review_comment, release, +# star, fork, workflow_run, create, delete +# +# events = "" diff --git a/config.go b/config.go index 8e2c4c3..64b6ce3 100644 --- a/config.go +++ b/config.go @@ -1,6 +1,10 @@ package main -import "github.com/taigrr/jety" +import ( + "strings" + + "github.com/taigrr/jety" +) // Config holds the application configuration. type Config struct { @@ -16,6 +20,8 @@ type Config struct { 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 } func loadConfig() Config { @@ -27,6 +33,18 @@ 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 != "" { + for _, s := range strings.Split(raw, ",") { + s = strings.TrimSpace(s) + if s != "" { + filters = append(filters, s) + } + } + } + return Config{ ListenAddr: jety.GetString("listen_addr"), WebhookSecret: jety.GetString("webhook_secret"), @@ -34,5 +52,6 @@ func loadConfig() Config { SignalAccount: jety.GetString("signal_account"), SignalRecipient: jety.GetString("signal_recipient"), SignalGroupID: jety.GetString("signal_group_id"), + Events: ParseEventFilter(filters), } } diff --git a/filter.go b/filter.go new file mode 100644 index 0000000..507fd18 --- /dev/null +++ b/filter.go @@ -0,0 +1,82 @@ +package main + +import "strings" + +// EventFilter determines which event/action combinations to forward. +// An empty filter allows everything (default behavior). +type EventFilter struct { + rules map[string]map[string]bool // event -> actions (empty map = all actions) +} + +// ParseEventFilter parses a list of filter strings into an EventFilter. +// Format: "event" (all actions) or "event:action" (specific action). +// +// Examples: +// +// ["pull_request:opened", "pull_request:closed"] — only PR open/close +// ["push", "issues"] — all push and issue events +// ["pull_request:opened", "workflow_run"] — PR opens + all workflow runs +// [] — everything allowed (no filtering) +func ParseEventFilter(filters []string) EventFilter { + ef := EventFilter{rules: make(map[string]map[string]bool)} + + for _, f := range filters { + f = strings.TrimSpace(f) + if f == "" { + continue + } + + event, action, hasAction := strings.Cut(f, ":") + event = strings.ToLower(strings.TrimSpace(event)) + if event == "" { + continue + } + + if _, ok := ef.rules[event]; !ok { + ef.rules[event] = make(map[string]bool) + } + + if hasAction { + action = strings.ToLower(strings.TrimSpace(action)) + if action != "" { + ef.rules[event][action] = true + } + } + } + + return ef +} + +// IsEmpty returns true if no filters are configured (allow everything). +func (ef EventFilter) IsEmpty() bool { + return len(ef.rules) == 0 +} + +// EventEnabled returns true if the event type is in the filter. +func (ef EventFilter) EventEnabled(event string) bool { + if ef.IsEmpty() { + return true + } + _, ok := ef.rules[strings.ToLower(event)] + return ok +} + +// Allowed returns true if the event/action combination should be forwarded. +func (ef EventFilter) Allowed(event, action string) bool { + if ef.IsEmpty() { + return true + } + + event = strings.ToLower(event) + actions, ok := ef.rules[event] + if !ok { + return false + } + + // No specific actions configured = all actions allowed + if len(actions) == 0 { + return true + } + + return actions[strings.ToLower(action)] +} diff --git a/filter_test.go b/filter_test.go new file mode 100644 index 0000000..b2f1bda --- /dev/null +++ b/filter_test.go @@ -0,0 +1,32 @@ +package main + +import "testing" + +func TestEventFilter(t *testing.T) { + tests := []struct { + name string + filters []string + event string + action string + want bool + }{ + {"empty allows all", nil, "push", "", true}, + {"event only", []string{"push"}, "push", "", true}, + {"event only blocks other", []string{"push"}, "issues", "opened", false}, + {"event:action match", []string{"pull_request:opened"}, "pull_request", "opened", true}, + {"event:action no match", []string{"pull_request:opened"}, "pull_request", "closed", false}, + {"multiple actions", []string{"pull_request:opened", "pull_request:closed"}, "pull_request", "closed", true}, + {"mixed event and action", []string{"push", "pull_request:opened"}, "push", "", true}, + {"mixed blocks filtered", []string{"push", "pull_request:opened"}, "pull_request", "closed", false}, + {"case insensitive", []string{"Pull_Request:Opened"}, "pull_request", "opened", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ef := ParseEventFilter(tt.filters) + if got := ef.Allowed(tt.event, tt.action); got != tt.want { + t.Errorf("Allowed(%q, %q) = %v, want %v", tt.event, tt.action, got, tt.want) + } + }) + } +} diff --git a/main.go b/main.go index 8a6ed3d..c8882f4 100644 --- a/main.go +++ b/main.go @@ -26,25 +26,57 @@ func main() { signal := signalcli.NewClient(cfg.SignalURL, cfg.SignalAccount) handle := githubevents.New(cfg.WebhookSecret) - notifier := ¬ifier{ + n := ¬ifier{ signal: signal, recipient: cfg.SignalRecipient, groupID: cfg.SignalGroupID, + filter: cfg.Events, } - // Register event handlers. - handle.OnPushEventAny(notifier.onPush) - handle.OnIssuesEventAny(notifier.onIssue) - handle.OnIssueCommentEventAny(notifier.onIssueComment) - handle.OnPullRequestEventAny(notifier.onPR) - handle.OnPullRequestReviewEventAny(notifier.onPRReview) - handle.OnPullRequestReviewCommentEventAny(notifier.onPRReviewComment) - handle.OnReleaseEventAny(notifier.onRelease) - handle.OnStarEventAny(notifier.onStar) - handle.OnForkEventAny(notifier.onFork) - handle.OnWorkflowRunEventAny(notifier.onWorkflowRun) - handle.OnCreateEventAny(notifier.onCreate) - handle.OnDeleteEventAny(notifier.onDelete) + // Register event handlers (only for enabled events). + f := cfg.Events + if f.EventEnabled("push") { + handle.OnPushEventAny(n.onPush) + } + if f.EventEnabled("issues") { + handle.OnIssuesEventAny(n.onIssue) + } + if f.EventEnabled("issue_comment") { + handle.OnIssueCommentEventAny(n.onIssueComment) + } + if f.EventEnabled("pull_request") { + handle.OnPullRequestEventAny(n.onPR) + } + if f.EventEnabled("pull_request_review") { + handle.OnPullRequestReviewEventAny(n.onPRReview) + } + if f.EventEnabled("pull_request_review_comment") { + handle.OnPullRequestReviewCommentEventAny(n.onPRReviewComment) + } + if f.EventEnabled("release") { + handle.OnReleaseEventAny(n.onRelease) + } + if f.EventEnabled("star") { + handle.OnStarEventAny(n.onStar) + } + if f.EventEnabled("fork") { + handle.OnForkEventAny(n.onFork) + } + if f.EventEnabled("workflow_run") { + handle.OnWorkflowRunEventAny(n.onWorkflowRun) + } + if f.EventEnabled("create") { + handle.OnCreateEventAny(n.onCreate) + } + if f.EventEnabled("delete") { + handle.OnDeleteEventAny(n.onDelete) + } + + if f.IsEmpty() { + log.Println("event filter: all events enabled") + } else { + log.Printf("event filter: %v", f.rules) + } handle.OnError(func(_ context.Context, _ string, _ string, _ interface{}, err error) error { log.Printf("webhook error: %v", err) @@ -75,6 +107,7 @@ type notifier struct { signal *signalcli.Client recipient string groupID string + filter EventFilter } func (n *notifier) send(ctx context.Context, msg string) { @@ -94,61 +127,97 @@ func (n *notifier) send(ctx context.Context, msg string) { } func (n *notifier) onPush(ctx context.Context, _ string, _ string, event *github.PushEvent) error { + if !n.filter.Allowed("push", "") { + return nil + } n.send(ctx, formatPush(event)) return nil } func (n *notifier) onIssue(ctx context.Context, _ string, _ string, event *github.IssuesEvent) error { + if !n.filter.Allowed("issues", event.GetAction()) { + return nil + } n.send(ctx, formatIssue(event)) return nil } func (n *notifier) onIssueComment(ctx context.Context, _ string, _ string, event *github.IssueCommentEvent) error { + if !n.filter.Allowed("issue_comment", event.GetAction()) { + return nil + } n.send(ctx, formatIssueComment(event)) return nil } func (n *notifier) onPR(ctx context.Context, _ string, _ string, event *github.PullRequestEvent) error { + if !n.filter.Allowed("pull_request", event.GetAction()) { + return nil + } n.send(ctx, formatPR(event)) return nil } func (n *notifier) onPRReview(ctx context.Context, _ string, _ string, event *github.PullRequestReviewEvent) error { + if !n.filter.Allowed("pull_request_review", event.GetAction()) { + return nil + } n.send(ctx, formatPRReview(event)) return nil } func (n *notifier) onPRReviewComment(ctx context.Context, _ string, _ string, event *github.PullRequestReviewCommentEvent) error { + if !n.filter.Allowed("pull_request_review_comment", event.GetAction()) { + return nil + } n.send(ctx, formatPRReviewComment(event)) return nil } func (n *notifier) onRelease(ctx context.Context, _ string, _ string, event *github.ReleaseEvent) error { + if !n.filter.Allowed("release", event.GetAction()) { + return nil + } n.send(ctx, formatRelease(event)) return nil } func (n *notifier) onStar(ctx context.Context, _ string, _ string, event *github.StarEvent) error { + if !n.filter.Allowed("star", event.GetAction()) { + return nil + } n.send(ctx, formatStar(event)) return nil } func (n *notifier) onFork(ctx context.Context, _ string, _ string, event *github.ForkEvent) error { + if !n.filter.Allowed("fork", "") { + return nil + } n.send(ctx, formatFork(event)) return nil } func (n *notifier) onWorkflowRun(ctx context.Context, _ string, _ string, event *github.WorkflowRunEvent) error { + if !n.filter.Allowed("workflow_run", event.GetAction()) { + return nil + } n.send(ctx, formatWorkflowRun(event)) return nil } func (n *notifier) onCreate(ctx context.Context, _ string, _ string, event *github.CreateEvent) error { + if !n.filter.Allowed("create", "") { + return nil + } n.send(ctx, formatCreate(event)) return nil } func (n *notifier) onDelete(ctx context.Context, _ string, _ string, event *github.DeleteEvent) error { + if !n.filter.Allowed("delete", "") { + return nil + } n.send(ctx, formatDelete(event)) return nil } From 6204f083429d5810e227228f81cad79edc9a05ab Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Tue, 10 Mar 2026 19:38:54 -0400 Subject: [PATCH 4/8] update example docs update nginx config --- .gitignore | 1 + deploy/github-to-signal.nginx.conf | 8 +++++--- deploy/signal-cli-bot.service | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 877bc76..a69b303 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ config.toml config.yaml config.json *.env +.crush diff --git a/deploy/github-to-signal.nginx.conf b/deploy/github-to-signal.nginx.conf index 6b7646f..fe5ed4e 100644 --- a/deploy/github-to-signal.nginx.conf +++ b/deploy/github-to-signal.nginx.conf @@ -1,9 +1,11 @@ server { - listen 443 ssl; + listen 80; + # listen 443 ssl; server_name ghwebhook.example.com; - ssl_certificate /etc/letsencrypt/live/ghwebhook.example.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/ghwebhook.example.com/privkey.pem; + # let certbot handle the SSL setup and renewal + # ssl_certificate /etc/letsencrypt/live/ghwebhook.example.com/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/ghwebhook.example.com/privkey.pem; location /webhook { proxy_pass http://127.0.0.1:9900; diff --git a/deploy/signal-cli-bot.service b/deploy/signal-cli-bot.service index 44b761e..49d56a8 100644 --- a/deploy/signal-cli-bot.service +++ b/deploy/signal-cli-bot.service @@ -5,7 +5,7 @@ Wants=network-online.target [Service] Type=exec -ExecStart=/usr/local/bin/signal-cli -a +1YOURNUMBER daemon --http 127.0.0.1:8081 --no-receive-stdout +ExecStart=/usr/local/bin/signal-cli -a +YOURNUMBER daemon --http 127.0.0.1:8081 --no-receive-stdout Restart=on-failure RestartSec=5 From cf5e8fe6d4cc36fba39c38cef233ac4531d150ff Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Tue, 10 Mar 2026 23:25:46 -0400 Subject: [PATCH 5/8] Update signal URL and account in config example --- config.example.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config.example.toml b/config.example.toml index f2f07d8..e7eae4c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -5,10 +5,10 @@ webhook_secret = "" listen_addr = ":9900" # signal-cli JSON-RPC endpoint -signal_url = "http://127.0.0.1:8080" +signal_url = "http://127.0.0.1:8081" # signal-cli account (phone number registered with signal-cli) -signal_account = "+1234567890" +signal_account = "+YOURNUMBER" # Signal recipient UUID for DM notifications signal_recipient = "" From 138217a327388ae4fa2e35180fd7dc6b15cd0547 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Tue, 10 Mar 2026 23:27:32 -0400 Subject: [PATCH 6/8] Update signal-cli HTTP port in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 73574be..1f21961 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ signal-cli -a +1YOURNUMBER updateProfile --avatar assets/octocat.png ### 2. Run signal-cli daemon ```bash -signal-cli -a +1YOURNUMBER daemon --http 127.0.0.1:8080 --no-receive-stdout +signal-cli -a +1YOURNUMBER daemon --http 127.0.0.1:8081 --no-receive-stdout ``` ### 3. Configure @@ -49,7 +49,7 @@ webhook_secret = "your-secret-here" listen_addr = ":9900" # signal-cli JSON-RPC endpoint -signal_url = "http://127.0.0.1:8080" +signal_url = "http://127.0.0.1:8081" # signal-cli account (phone number registered with signal-cli) signal_account = "+1YOURNUMBER" From ec5d11eabb08a944de755880614ff17828086cc8 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Tue, 10 Mar 2026 23:27:49 -0400 Subject: [PATCH 7/8] update certbot --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 1f21961..830e6e6 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,23 @@ sudo nginx -t && sudo systemctl reload nginx Edit the service files first to set your phone number and paths. The signal-cli daemon listens on `127.0.0.1:8081` (not 8080, to avoid conflicts). Update `signal_url` in your config.toml to match. +### SSL with Certbot + +Install certbot and the nginx plugin, then request a certificate: + +```bash +# Install certbot (Debian/Ubuntu) +sudo apt install certbot python3-certbot-nginx + +# Request certificate (certbot auto-configures nginx) +sudo certbot --nginx -d ghwebhook.example.com + +# Verify auto-renewal is enabled +sudo systemctl status certbot.timer +``` + +Certbot will automatically modify the nginx config to enable SSL and set up renewal. The signal-cli daemon listens on `127.0.0.1:8081` (not 8080, to avoid conflicts). Update `signal_url` in your config.toml to match. + ## Dependencies - [cbrgm/githubevents](https://github.com/cbrgm/githubevents) — GitHub webhook event handling From 6432874a88fe7283477cc167e6fcc8b4a9fe570a Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Wed, 11 Mar 2026 17:47:20 -0400 Subject: [PATCH 8/8] Add GitHub Sponsors username to FUNDING.yml --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..8859aa4 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: taigrr