mirror of
https://github.com/taigrr/github-to-signal.git
synced 2026-04-02 03:09:09 -07:00
dynamic endpoints
This commit is contained in:
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
77
config.go
77
config.go
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
32
examples/signal-notify.yml
Normal file
32
examples/signal-notify.yml
Normal 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 }}"
|
||||||
|
}'
|
||||||
4
go.mod
4
go.mod
@@ -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
4
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/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
81
main.go
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user