2 Commits

Author SHA1 Message Date
816ec8fd6b dynamic endpoints 2026-03-23 21:32:20 -04:00
03791ecca8 update truncation 2026-03-23 21:32:20 -04:00
8 changed files with 341 additions and 24 deletions

132
AGENTS.md Normal file
View File

@@ -0,0 +1,132 @@
# Agent Guide for github-to-signal
HTTP server that receives GitHub webhook events and forwards them as Signal messages via signal-cli.
## Commands
```bash
# Build
GOWORK=off go build -o github-to-signal .
# Test
GOWORK=off go test .
# Run (requires config.toml)
./github-to-signal
```
**Note**: This repo may not be in a parent `go.work` workspace. Use `GOWORK=off` to ensure commands work standalone.
## Project Structure
```
.
├── main.go # Entry point, HTTP server, event handlers
├── config.go # Configuration loading (TOML + env vars)
├── filter.go # Event filtering logic
├── filter_test.go # Tests for event filtering
├── format.go # Message formatting for each event type
├── config.example.toml
└── deploy/ # Systemd services and nginx config
```
All source files are in the root directory (single `main` package, no subdirectories).
## Configuration
Configuration via `config.toml` or environment variables with `GH2SIG_` prefix:
| Config Key | Env Variable | Description |
|------------|--------------|-------------|
| `webhook_secret` | `GH2SIG_WEBHOOK_SECRET` | GitHub webhook secret |
| `listen_addr` | `GH2SIG_LISTEN_ADDR` | Server address (default `:9900`) |
| `signal_url` | `GH2SIG_SIGNAL_URL` | signal-cli JSON-RPC endpoint |
| `signal_account` | `GH2SIG_SIGNAL_ACCOUNT` | Phone number for signal-cli |
| `signal_recipient` | `GH2SIG_SIGNAL_RECIPIENT` | Recipient UUID for DMs |
| `signal_group_id` | `GH2SIG_SIGNAL_GROUP_ID` | Group ID (overrides recipient) |
| `events` | `GH2SIG_EVENTS` | Comma-separated event filter |
Configuration is loaded using [jety](https://github.com/taigrr/jety) library.
## Code Patterns
### Event Handlers
Each GitHub event type has:
1. A handler method on `notifier` struct in `main.go`
2. A `format*` function in `format.go` that returns the Signal message
Handler pattern:
```go
func (n *notifier) onEventName(ctx context.Context, _ string, _ string, event *github.EventType) error {
if !n.filter.Allowed("event_name", event.GetAction()) {
return nil
}
n.send(ctx, formatEventName(event))
return nil
}
```
### Event Filtering
`EventFilter` in `filter.go` supports:
- Empty filter = allow all events
- `"event"` = all actions of that event type
- `"event:action"` = specific action only
Two-level check:
1. `EventEnabled(event)` — used at registration time to skip registering handlers
2. `Allowed(event, action)` — checked at runtime for action-level filtering
### Message Formatting
All `format*` functions in `format.go`:
- Return a string (empty string = no message sent)
- Use `[repo] user action ...` prefix format
- Include relevant URLs
- Truncate bodies with `truncate()` helper
### Dependencies
- `cbrgm/githubevents` — GitHub webhook event parsing and routing
- `google/go-github` — GitHub API types
- `taigrr/signalcli` — signal-cli JSON-RPC client
- `taigrr/jety` — Configuration (TOML/JSON/YAML/env)
## Adding New Event Types
1. Add handler method in `main.go` following existing pattern
2. Register handler in `main()` with `EventEnabled` check
3. Add `format*` function in `format.go`
4. Add event name to filter docs in `config.example.toml`
## Testing
Only `filter_test.go` exists — table-driven tests for `EventFilter`.
```bash
GOWORK=off go test -v .
```
## HTTP Endpoints
| Path | Method | Description |
|------|--------|-------------|
| `/webhook` | POST | GitHub webhook receiver |
| `/health` | GET | Health check (returns `ok`) |
## Deployment
Systemd services in `deploy/`:
- `signal-cli-bot.service` — runs signal-cli daemon
- `github-to-signal.service` — runs this server (depends on signal-cli)
- `github-to-signal.nginx.conf` — nginx reverse proxy config
The server expects signal-cli to be running on `127.0.0.1:8081`.
## Gotchas
- **GOWORK**: May need `GOWORK=off` if a parent `go.work` exists
- **signal-cli port**: Default in code is `8080`, but deployment uses `8081` to avoid conflicts
- **Workflow runs**: Only notifies on `completed` action, ignores `requested`/`in_progress`
- **Empty message**: Returning `""` from a formatter skips sending (used by workflow_run filter)

View File

@@ -1,6 +1,10 @@
# GitHub webhook secret (set in your GitHub webhook settings) # GitHub webhook secret (set in your GitHub webhook settings)
webhook_secret = "" 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 # Address to listen on
listen_addr = ":9900" 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-cli account (phone number registered with signal-cli)
signal_account = "+YOURNUMBER" signal_account = "+YOURNUMBER"
# Signal recipient UUID for DM notifications # Signal recipient UUID for DM notifications (used by GitHub webhook handler)
signal_recipient = "" signal_recipient = ""
# OR: Signal group ID for group notifications (overrides signal_recipient) # OR: Signal group ID for group notifications (overrides signal_recipient)
@@ -30,3 +34,22 @@ signal_recipient = ""
# star, fork, workflow_run, create, delete # star, fork, workflow_run, create, delete
# #
# events = "" # 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",
]

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"log"
"strings" "strings"
"github.com/taigrr/jety" "github.com/taigrr/jety"
@@ -8,20 +9,21 @@ import (
// Config holds the application configuration. // Config holds the application configuration.
type Config struct { type Config struct {
// ListenAddr is the address to bind the webhook server to. ListenAddr string
ListenAddr string WebhookSecret string
// WebhookSecret is the GitHub webhook secret for signature validation. CISecret string
WebhookSecret string SignalURL string
// SignalURL is the signal-cli JSON-RPC base URL. SignalAccount string
SignalURL string
// SignalAccount is the signal-cli account (phone number or UUID).
SignalAccount string
// SignalRecipient is the default Signal recipient UUID for DM notifications.
SignalRecipient string SignalRecipient string
// SignalGroupID is the Signal group ID for group notifications (overrides SignalRecipient). SignalGroupID string
SignalGroupID string Events EventFilter
// Events is the event filter. Empty means all events are forwarded. Endpoints []Endpoint
Events EventFilter }
// 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 { func loadConfig() Config {
@@ -33,7 +35,6 @@ func loadConfig() Config {
jety.SetConfigType("toml") jety.SetConfigType("toml")
_ = jety.ReadInConfig() _ = jety.ReadInConfig()
// Parse events filter from comma-separated string or TOML array.
var filters []string var filters []string
raw := jety.GetString("events") raw := jety.GetString("events")
if raw != "" { if raw != "" {
@@ -48,10 +49,58 @@ func loadConfig() Config {
return Config{ return Config{
ListenAddr: jety.GetString("listen_addr"), ListenAddr: jety.GetString("listen_addr"),
WebhookSecret: jety.GetString("webhook_secret"), WebhookSecret: jety.GetString("webhook_secret"),
CISecret: jety.GetString("ci_secret"),
SignalURL: jety.GetString("signal_url"), SignalURL: jety.GetString("signal_url"),
SignalAccount: jety.GetString("signal_account"), SignalAccount: jety.GetString("signal_account"),
SignalRecipient: jety.GetString("signal_recipient"), SignalRecipient: jety.GetString("signal_recipient"),
SignalGroupID: jety.GetString("signal_group_id"), SignalGroupID: jety.GetString("signal_group_id"),
Events: ParseEventFilter(filters), 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
}

View File

@@ -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 }}"
}'

View File

@@ -44,7 +44,7 @@ func formatIssue(event *github.IssuesEvent) string {
repo, sender, action, issue.GetNumber(), issue.GetTitle(), issue.GetHTMLURL()) repo, sender, action, issue.GetNumber(), issue.GetTitle(), issue.GetHTMLURL())
if action == "opened" && issue.GetBody() != "" { if action == "opened" && issue.GetBody() != "" {
body := truncate(issue.GetBody(), 200) body := truncate(issue.GetBody(), 2000)
msg += "\n\n" + body msg += "\n\n" + body
} }
@@ -58,7 +58,7 @@ func formatIssueComment(event *github.IssueCommentEvent) string {
issue := event.GetIssue() issue := event.GetIssue()
comment := event.GetComment() comment := event.GetComment()
body := truncate(comment.GetBody(), 300) body := truncate(comment.GetBody(), 2000)
return fmt.Sprintf("[%s] %s commented on #%d (%s):\n%s\n%s", return fmt.Sprintf("[%s] %s commented on #%d (%s):\n%s\n%s",
repo, sender, issue.GetNumber(), issue.GetTitle(), body, comment.GetHTMLURL()) repo, sender, issue.GetNumber(), issue.GetTitle(), body, comment.GetHTMLURL())
@@ -75,7 +75,7 @@ func formatPR(event *github.PullRequestEvent) string {
repo, sender, action, pr.GetNumber(), pr.GetTitle(), pr.GetHTMLURL()) repo, sender, action, pr.GetNumber(), pr.GetTitle(), pr.GetHTMLURL())
if action == "opened" && pr.GetBody() != "" { if action == "opened" && pr.GetBody() != "" {
body := truncate(pr.GetBody(), 200) body := truncate(pr.GetBody(), 2000)
msg += "\n\n" + body msg += "\n\n" + body
} }
@@ -90,7 +90,7 @@ func formatPRReview(event *github.PullRequestReviewEvent) string {
review := event.GetReview() review := event.GetReview()
state := review.GetState() state := review.GetState()
body := truncate(review.GetBody(), 200) body := truncate(review.GetBody(), 2000)
msg := fmt.Sprintf("[%s] %s %s PR #%d: %s\n%s", msg := fmt.Sprintf("[%s] %s %s PR #%d: %s\n%s",
repo, sender, state, pr.GetNumber(), pr.GetTitle(), review.GetHTMLURL()) repo, sender, state, pr.GetNumber(), pr.GetTitle(), review.GetHTMLURL())
@@ -109,7 +109,7 @@ func formatPRReviewComment(event *github.PullRequestReviewCommentEvent) string {
pr := event.GetPullRequest() pr := event.GetPullRequest()
comment := event.GetComment() comment := event.GetComment()
body := truncate(comment.GetBody(), 300) body := truncate(comment.GetBody(), 2000)
return fmt.Sprintf("[%s] %s commented on PR #%d (%s):\n%s\n%s", return fmt.Sprintf("[%s] %s commented on PR #%d (%s):\n%s\n%s",
repo, sender, pr.GetNumber(), pr.GetTitle(), body, comment.GetHTMLURL()) repo, sender, pr.GetNumber(), pr.GetTitle(), body, comment.GetHTMLURL())

4
go.mod
View File

@@ -1,11 +1,11 @@
module github.com/taigrr/github-to-signal module github.com/taigrr/github-to-signal
go 1.26.0 go 1.26.1
require ( require (
github.com/cbrgm/githubevents/v2 v2.2.0 github.com/cbrgm/githubevents/v2 v2.2.0
github.com/google/go-github/v70 v70.0.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 github.com/taigrr/signalcli v0.0.0-20260301154901-6aed09f3f2af
) )

4
go.sum
View File

@@ -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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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.4.0 h1:BECC3r3CdQOxN/OdJpJ1VFH6DCJnmNby4vxRwL2wcZQ=
github.com/taigrr/jety v0.2.0/go.mod h1:PtJDxNFDqR6739at6QrjlE4MGf/zcs21Y86KVQMKPEk= 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 h1:qavmWkSyV3NeiZ+HHVj1I/6c8VQosUKFV1SfN1X/4DM=
github.com/taigrr/signalcli v0.0.0-20260301154901-6aed09f3f2af/go.mod h1:Jgly+nAwowk5O5ZUXSAAxCOVK9nlsAvPIfdNg5kXWSM= 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 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=

81
main.go
View File

@@ -4,9 +4,12 @@ package main
import ( import (
"context" "context"
"crypto/subtle"
"encoding/json"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"strings"
"github.com/cbrgm/githubevents/v2/githubevents" "github.com/cbrgm/githubevents/v2/githubevents"
"github.com/google/go-github/v70/github" "github.com/google/go-github/v70/github"
@@ -92,6 +95,10 @@ func main() {
} }
w.WriteHeader(http.StatusOK) 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) { mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok") fmt.Fprintln(w, "ok")
@@ -221,3 +228,77 @@ func (n *notifier) onDelete(ctx context.Context, _ string, _ string, event *gith
n.send(ctx, formatDelete(event)) n.send(ctx, formatDelete(event))
return nil 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
}