feat: initial github-to-signal webhook server

HTTP server that receives GitHub webhook events and sends formatted
notifications to Signal via signal-cli's JSON-RPC API.

Supported events: push, issues, issue comments, pull requests,
PR reviews, PR review comments, releases, stars, forks, workflow
runs, branch/tag creation, branch/tag deletion.

Uses cbrgm/githubevents for webhook handling and taigrr/signalcli
for Signal delivery. Config via TOML file or GH2SIG_ env vars
(powered by taigrr/jety).
This commit is contained in:
2026-03-10 23:25:54 +00:00
commit b73aeaed91
9 changed files with 485 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
# Binaries
github-to-signal
*.exe
# Config with secrets
config.toml
config.yaml
config.json
*.env

12
LICENSE Normal file
View File

@@ -0,0 +1,12 @@
Copyright (C) 2026 Tai Groot
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.

19
README.md Normal file
View File

@@ -0,0 +1,19 @@
# github-to-signal
HTTP server that receives GitHub webhook events and forwards them as Signal messages via [signal-cli](https://github.com/AsamK/signal-cli).
## Events
Push, issues, issue comments, pull requests, PR reviews, PR review comments, releases, stars, forks, workflow runs, branch/tag creation and deletion.
## Setup
1. Copy `config.example.toml` to `config.toml` and fill in values
2. Run the server: `go run .`
3. Add a webhook in your GitHub repo pointing to `https://your-host:9900/webhook`
All config values can also be set via environment variables with `GH2SIG_` prefix (e.g. `GH2SIG_SIGNAL_ACCOUNT`).
## Requirements
- [signal-cli](https://github.com/AsamK/signal-cli) running in daemon mode with JSON-RPC enabled

14
config.example.toml Normal file
View File

@@ -0,0 +1,14 @@
# GitHub webhook secret (set in your GitHub webhook settings)
webhook_secret = ""
# Address to listen on
listen_addr = ":9900"
# signal-cli JSON-RPC endpoint
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 = ""

35
config.go Normal file
View File

@@ -0,0 +1,35 @@
package main
import "github.com/taigrr/jety"
// 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 for notifications.
SignalRecipient string
}
func loadConfig() Config {
jety.SetDefault("listen_addr", ":9900")
jety.SetDefault("signal_url", "http://127.0.0.1:8080")
jety.SetEnvPrefix("GH2SIG")
jety.SetConfigFile("config.toml")
jety.SetConfigType("toml")
_ = jety.ReadInConfig()
return Config{
ListenAddr: jety.GetString("listen_addr"),
WebhookSecret: jety.GetString("webhook_secret"),
SignalURL: jety.GetString("signal_url"),
SignalAccount: jety.GetString("signal_account"),
SignalRecipient: jety.GetString("signal_recipient"),
}
}

206
format.go Normal file
View File

@@ -0,0 +1,206 @@
package main
import (
"fmt"
"strings"
"github.com/google/go-github/v70/github"
)
// formatPush formats a push event into a Signal message.
func formatPush(event *github.PushEvent) string {
repo := event.GetRepo().GetFullName()
ref := strings.TrimPrefix(event.GetRef(), "refs/heads/")
pusher := event.GetPusher().GetName()
count := len(event.Commits)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("[%s] %s pushed %d commit", repo, pusher, count))
if count != 1 {
sb.WriteString("s")
}
sb.WriteString(fmt.Sprintf(" to %s\n", ref))
for _, commit := range event.Commits {
short := commit.GetID()
if len(short) > 7 {
short = short[:7]
}
msg := firstLine(commit.GetMessage())
sb.WriteString(fmt.Sprintf(" %s %s\n", short, msg))
}
return strings.TrimSpace(sb.String())
}
// formatIssue formats an issue event into a Signal message.
func formatIssue(event *github.IssuesEvent) string {
repo := event.GetRepo().GetFullName()
action := event.GetAction()
issue := event.GetIssue()
sender := event.GetSender().GetLogin()
msg := fmt.Sprintf("[%s] %s %s issue #%d: %s\n%s",
repo, sender, action, issue.GetNumber(), issue.GetTitle(), issue.GetHTMLURL())
if action == "opened" && issue.GetBody() != "" {
body := truncate(issue.GetBody(), 200)
msg += "\n\n" + body
}
return msg
}
// formatIssueComment formats an issue comment event into a Signal message.
func formatIssueComment(event *github.IssueCommentEvent) string {
repo := event.GetRepo().GetFullName()
sender := event.GetSender().GetLogin()
issue := event.GetIssue()
comment := event.GetComment()
body := truncate(comment.GetBody(), 300)
return fmt.Sprintf("[%s] %s commented on #%d (%s):\n%s\n%s",
repo, sender, issue.GetNumber(), issue.GetTitle(), body, comment.GetHTMLURL())
}
// formatPR formats a pull request event into a Signal message.
func formatPR(event *github.PullRequestEvent) string {
repo := event.GetRepo().GetFullName()
action := event.GetAction()
pr := event.GetPullRequest()
sender := event.GetSender().GetLogin()
msg := fmt.Sprintf("[%s] %s %s PR #%d: %s\n%s",
repo, sender, action, pr.GetNumber(), pr.GetTitle(), pr.GetHTMLURL())
if action == "opened" && pr.GetBody() != "" {
body := truncate(pr.GetBody(), 200)
msg += "\n\n" + body
}
return msg
}
// formatPRReview formats a pull request review event into a Signal message.
func formatPRReview(event *github.PullRequestReviewEvent) string {
repo := event.GetRepo().GetFullName()
sender := event.GetSender().GetLogin()
pr := event.GetPullRequest()
review := event.GetReview()
state := review.GetState()
body := truncate(review.GetBody(), 200)
msg := fmt.Sprintf("[%s] %s %s PR #%d: %s\n%s",
repo, sender, state, pr.GetNumber(), pr.GetTitle(), review.GetHTMLURL())
if body != "" {
msg += "\n\n" + body
}
return msg
}
// formatPRReviewComment formats a pull request review comment event.
func formatPRReviewComment(event *github.PullRequestReviewCommentEvent) string {
repo := event.GetRepo().GetFullName()
sender := event.GetSender().GetLogin()
pr := event.GetPullRequest()
comment := event.GetComment()
body := truncate(comment.GetBody(), 300)
return fmt.Sprintf("[%s] %s commented on PR #%d (%s):\n%s\n%s",
repo, sender, pr.GetNumber(), pr.GetTitle(), body, comment.GetHTMLURL())
}
// formatRelease formats a release event into a Signal message.
func formatRelease(event *github.ReleaseEvent) string {
repo := event.GetRepo().GetFullName()
release := event.GetRelease()
sender := event.GetSender().GetLogin()
return fmt.Sprintf("[%s] %s %s release %s\n%s",
repo, sender, event.GetAction(), release.GetTagName(), release.GetHTMLURL())
}
// formatStar formats a star event into a Signal message.
func formatStar(event *github.StarEvent) string {
repo := event.GetRepo().GetFullName()
sender := event.GetSender().GetLogin()
action := event.GetAction()
count := event.GetRepo().GetStargazersCount()
if action == "deleted" {
return fmt.Sprintf("[%s] %s unstarred (now %d)", repo, sender, count)
}
return fmt.Sprintf("[%s] %s starred (now %d)", repo, sender, count)
}
// formatFork formats a fork event into a Signal message.
func formatFork(event *github.ForkEvent) string {
repo := event.GetRepo().GetFullName()
forkee := event.GetForkee().GetFullName()
sender := event.GetSender().GetLogin()
return fmt.Sprintf("[%s] %s forked to %s", repo, sender, forkee)
}
// formatWorkflowRun formats a workflow run event into a Signal message.
func formatWorkflowRun(event *github.WorkflowRunEvent) string {
repo := event.GetRepo().GetFullName()
run := event.GetWorkflowRun()
conclusion := run.GetConclusion()
name := run.GetName()
branch := run.GetHeadBranch()
// Only notify on completion
if event.GetAction() != "completed" {
return ""
}
emoji := "✅"
if conclusion == "failure" {
emoji = "❌"
} else if conclusion == "cancelled" {
emoji = "⚠️"
}
return fmt.Sprintf("%s [%s] workflow %q %s on %s\n%s",
emoji, repo, name, conclusion, branch, run.GetHTMLURL())
}
// formatCreate formats a create event (branch/tag) into a Signal message.
func formatCreate(event *github.CreateEvent) string {
repo := event.GetRepo().GetFullName()
sender := event.GetSender().GetLogin()
refType := event.GetRefType()
ref := event.GetRef()
return fmt.Sprintf("[%s] %s created %s %s", repo, sender, refType, ref)
}
// formatDelete formats a delete event (branch/tag) into a Signal message.
func formatDelete(event *github.DeleteEvent) string {
repo := event.GetRepo().GetFullName()
sender := event.GetSender().GetLogin()
refType := event.GetRefType()
ref := event.GetRef()
return fmt.Sprintf("[%s] %s deleted %s %s", repo, sender, refType, ref)
}
func firstLine(s string) string {
if idx := strings.IndexByte(s, '\n'); idx >= 0 {
return s[:idx]
}
return s
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}

18
go.mod Normal file
View File

@@ -0,0 +1,18 @@
module github.com/taigrr/github-to-signal
go 1.26.0
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/signalcli v0.0.0-20260301154901-6aed09f3f2af
)
require (
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/google/uuid v1.6.0 // indirect
golang.org/x/sync v0.12.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

23
go.sum Normal file
View File

@@ -0,0 +1,23 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/cbrgm/githubevents/v2 v2.2.0 h1:nttKcls+vGtq+osxirM1qpY287hkXyJmTjHWHjQT1/A=
github.com/cbrgm/githubevents/v2 v2.2.0/go.mod h1:3J4oJkVO5NlZNEtEyUb8B7sW7+q8OZgHdTBvw9dPsMs=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v70 v70.0.0 h1:/tqCp5KPrcvqCc7vIvYyFYTiCGrYvaWoYMGHSQbo55o=
github.com/google/go-github/v70 v70.0.0/go.mod h1:xBUZgo8MI3lUL/hwxl3hlceJW1U8MVnXP3zUyI+rhQY=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
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/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=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

149
main.go Normal file
View File

@@ -0,0 +1,149 @@
// github-to-signal is an HTTP server that receives GitHub webhook events
// and forwards formatted notifications to Signal via signal-cli's JSON-RPC API.
package main
import (
"context"
"fmt"
"log"
"net/http"
"github.com/cbrgm/githubevents/v2/githubevents"
"github.com/google/go-github/v70/github"
"github.com/taigrr/signalcli"
)
func main() {
cfg := loadConfig()
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)")
}
signal := signalcli.NewClient(cfg.SignalURL, cfg.SignalAccount)
handle := githubevents.New(cfg.WebhookSecret)
notifier := &notifier{
signal: signal,
recipient: cfg.SignalRecipient,
}
// 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)
handle.OnError(func(_ context.Context, _ string, _ string, _ interface{}, err error) error {
log.Printf("webhook error: %v", err)
return nil
})
mux := http.NewServeMux()
mux.HandleFunc("POST /webhook", func(w http.ResponseWriter, r *http.Request) {
if err := handle.HandleEventRequest(r); err != nil {
log.Printf("handle event: %v", err)
http.Error(w, "webhook processing failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
})
log.Printf("listening on %s", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
log.Fatal(err)
}
}
type notifier struct {
signal *signalcli.Client
recipient 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,
})
if err != nil {
log.Printf("signal send error: %v", err)
}
}
func (n *notifier) onPush(ctx context.Context, _ string, _ string, event *github.PushEvent) error {
n.send(ctx, formatPush(event))
return nil
}
func (n *notifier) onIssue(ctx context.Context, _ string, _ string, event *github.IssuesEvent) error {
n.send(ctx, formatIssue(event))
return nil
}
func (n *notifier) onIssueComment(ctx context.Context, _ string, _ string, event *github.IssueCommentEvent) error {
n.send(ctx, formatIssueComment(event))
return nil
}
func (n *notifier) onPR(ctx context.Context, _ string, _ string, event *github.PullRequestEvent) error {
n.send(ctx, formatPR(event))
return nil
}
func (n *notifier) onPRReview(ctx context.Context, _ string, _ string, event *github.PullRequestReviewEvent) error {
n.send(ctx, formatPRReview(event))
return nil
}
func (n *notifier) onPRReviewComment(ctx context.Context, _ string, _ string, event *github.PullRequestReviewCommentEvent) error {
n.send(ctx, formatPRReviewComment(event))
return nil
}
func (n *notifier) onRelease(ctx context.Context, _ string, _ string, event *github.ReleaseEvent) error {
n.send(ctx, formatRelease(event))
return nil
}
func (n *notifier) onStar(ctx context.Context, _ string, _ string, event *github.StarEvent) error {
n.send(ctx, formatStar(event))
return nil
}
func (n *notifier) onFork(ctx context.Context, _ string, _ string, event *github.ForkEvent) error {
n.send(ctx, formatFork(event))
return nil
}
func (n *notifier) onWorkflowRun(ctx context.Context, _ string, _ string, event *github.WorkflowRunEvent) error {
n.send(ctx, formatWorkflowRun(event))
return nil
}
func (n *notifier) onCreate(ctx context.Context, _ string, _ string, event *github.CreateEvent) error {
n.send(ctx, formatCreate(event))
return nil
}
func (n *notifier) onDelete(ctx context.Context, _ string, _ string, event *github.DeleteEvent) error {
n.send(ctx, formatDelete(event))
return nil
}