Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Image configuration
REGISTRY ?= ghcr.io/kelos-dev
VERSION ?= latest
IMAGE_DIRS ?= cmd/kelos-controller cmd/kelos-spawner cmd/ghproxy cmd/kelos-webhook-server claude-code codex gemini opencode cursor
IMAGE_DIRS ?= cmd/kelos-controller cmd/kelos-spawner cmd/ghproxy cmd/kelos-webhook-server claude-code codex gemini opencode cursor cmd/kelos-slack-server
LOCAL_ARCH ?= $(shell go env GOARCH)

# Version injection for the kelos CLI – only set ldflags when an explicit
Expand Down
50 changes: 50 additions & 0 deletions api/v1alpha1/taskspawner_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ type When struct {
// the HMAC secret is read from the <SOURCE>_WEBHOOK_SECRET env var.
// +optional
GenericWebhook *GenericWebhook `json:"webhook,omitempty"`

// Slack discovers work items from Slack messages via Socket Mode.
// The centralized kelos-slack-server connects to Slack via an outbound
// WebSocket (no ingress required) and routes messages to matching agents.
// +optional
Slack *Slack `json:"slack,omitempty"`
}

// Cron triggers task spawning on a cron schedule.
Expand Down Expand Up @@ -483,6 +489,50 @@ type GenericWebhookFilter struct {
Pattern string `json:"pattern,omitempty"`
}

// Slack triggers task spawning from Slack messages via the centralized
// kelos-slack-server. The server connects to Slack via Socket Mode (outbound
// WebSocket — no ingress required) and routes messages to matching
// TaskSpawners. Authentication tokens (SLACK_BOT_TOKEN, SLACK_APP_TOKEN)
// are configured on the server, not per-TaskSpawner.
//
// The bot must be invited to each channel it should listen in; the Channels
// field is a post-delivery filter, not a privacy scope.
//
// Bot mention (@bot) is implicitly required by default. The handler knows its
// own bot user ID from the Slack auth response. When Triggers are configured,
// each trigger's regex pattern is AND'd with the implicit mention requirement
// (unless MentionOptional is set). Multiple triggers use OR semantics.
// Empty triggers = every bot mention fires.
type Slack struct {
// Channels optionally restricts which Slack channels the bot listens in.
// Values are channel IDs (e.g., "C0123456789"). When empty, the bot
// listens in every channel it has been invited to.
// +optional
// +kubebuilder:validation:MaxItems=64
// +kubebuilder:validation:items:Pattern=`^[CG][A-Z0-9]{8,}$`
Channels []string `json:"channels,omitempty"`

// Triggers define regex patterns that must match the message text.
// Bot mention is implicitly required unless MentionOptional is set.
// Multiple triggers use OR semantics. When empty, every bot mention fires.
// +optional
// +kubebuilder:validation:MaxItems=8
Triggers []SlackTrigger `json:"triggers,omitempty"`
}

// SlackTrigger defines a regex pattern trigger for Slack messages.
type SlackTrigger struct {
// Pattern is a Go RE2 regex matched against message text (unanchored).
// +optional
// +kubebuilder:validation:MaxLength=256
Pattern string `json:"pattern,omitempty"`

// MentionOptional, when true, fires the trigger on pattern match alone
// without requiring a bot @-mention.
// +optional
MentionOptional *bool `json:"mentionOptional,omitempty"`
}

// TaskTemplateMetadata holds optional labels and annotations for spawned Tasks.
type TaskTemplateMetadata struct {
// Labels are merged into the spawned Task's labels. Values support Go
Expand Down
52 changes: 52 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions cmd/kelos-slack-server/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
FROM gcr.io/distroless/static:nonroot
Comment thread
jkahuja marked this conversation as resolved.
WORKDIR /
COPY bin/kelos-slack-server .
USER 65532:65532
ENTRYPOINT ["/kelos-slack-server"]
126 changes: 126 additions & 0 deletions cmd/kelos-slack-server/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package main

import (
"context"
"flag"
"fmt"
"os"

"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"

kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1"
"github.com/kelos-dev/kelos/internal/logging"
kelosslack "github.com/kelos-dev/kelos/internal/slack"
)

var (
scheme = runtime.NewScheme()
setupLog = ctrl.Log.WithName("setup")
)

func init() {
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(kelosv1alpha1.AddToScheme(scheme))
}

func main() {
var (
metricsAddr string
probeAddr string
enableLeaderElection bool
)

flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.")
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager.")

opts, applyVerbosity := logging.SetupZapOptions(flag.CommandLine)
flag.Parse()

if err := applyVerbosity(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}

ctrl.SetLogger(zap.New(zap.UseFlagOptions(opts)))

botToken := os.Getenv("SLACK_BOT_TOKEN")
appToken := os.Getenv("SLACK_APP_TOKEN")
if botToken == "" || appToken == "" {
setupLog.Error(fmt.Errorf("missing tokens"), "SLACK_BOT_TOKEN and SLACK_APP_TOKEN environment variables are required")
os.Exit(1)
}

mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: metricsserver.Options{BindAddress: metricsAddr},
HealthProbeBindAddress: probeAddr,
LeaderElection: enableLeaderElection,
LeaderElectionID: "kelos-slack-server",
})
if err != nil {
setupLog.Error(err, "Unable to start manager")
os.Exit(1)
}

ctx := ctrl.SetupSignalHandler()

// Create the Slack handler
handler, err := kelosslack.NewSlackHandler(
ctx,
mgr.GetClient(),
botToken,
appToken,
ctrl.Log.WithName("slack"),
)
if err != nil {
setupLog.Error(err, "Unable to create Slack handler")
os.Exit(1)
}

// Register Socket Mode listener as a leader-elected runnable so that only
// one replica opens the single-connection Socket Mode WebSocket.
if err := mgr.Add(&slackRunnable{handler: handler}); err != nil {
setupLog.Error(err, "Unable to register Slack handler with manager")
os.Exit(1)
}

// Health checks
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
setupLog.Error(err, "Unable to set up health check")
os.Exit(1)
}
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
setupLog.Error(err, "Unable to set up ready check")
os.Exit(1)
}

setupLog.Info("Starting manager")
if err := mgr.Start(ctx); err != nil {
setupLog.Error(err, "Problem running manager")
os.Exit(1)
}
}

// slackRunnable wraps the SlackHandler as a leader-elected manager.Runnable.
// This ensures only the leader replica opens the Socket Mode connection.
type slackRunnable struct {
handler *kelosslack.SlackHandler
}

func (r *slackRunnable) Start(ctx context.Context) error {
setupLog.Info("Starting Slack Socket Mode listener")
err := r.handler.Start(ctx)
if err != nil && ctx.Err() == nil {
return err
}
return nil
}

func (r *slackRunnable) NeedLeaderElection() bool { return true }
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require (
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
github.com/robfig/cron/v3 v3.0.1
github.com/slack-go/slack v0.20.0
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
go.uber.org/zap v1.27.0
Expand Down Expand Up @@ -62,6 +63,7 @@ require (
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/google/renameio/v2 v2.0.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
Expand Down Expand Up @@ -111,6 +113,8 @@ 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/yamlfmt v0.21.0 h1:9FKApQkDpMKgBjwLFytBHUCgqnQgxaQnci0uiESfbzs=
github.com/google/yamlfmt v0.21.0/go.mod h1:q6FYExB+Ueu7jZDjKECJk+EaeDXJzJ6Ne0dxx69GWfI=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
Expand Down Expand Up @@ -195,6 +199,8 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEV
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/slack-go/slack v0.20.0 h1:gbDdbee8+Z2o+DWx05Spq3GzbrLLleiRwHUKs+hZLSU=
github.com/slack-go/slack v0.20.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
Expand Down
4 changes: 3 additions & 1 deletion internal/controller/taskspawner_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ func isCronBased(ts *kelosv1alpha1.TaskSpawner) bool {
}

// isWebhookBased returns true if the TaskSpawner is webhook-driven.
// Slack uses Socket Mode (outbound WebSocket) handled by the centralized
// kelos-slack-server, so it follows the same no-deployment pattern.
func isWebhookBased(ts *kelosv1alpha1.TaskSpawner) bool {
return ts.Spec.When.GitHubWebhook != nil || ts.Spec.When.LinearWebhook != nil || ts.Spec.When.GenericWebhook != nil
return ts.Spec.When.GitHubWebhook != nil || ts.Spec.When.LinearWebhook != nil || ts.Spec.When.GenericWebhook != nil || ts.Spec.When.Slack != nil
}

// Reconcile handles TaskSpawner reconciliation.
Expand Down
13 changes: 13 additions & 0 deletions internal/controller/taskspawner_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,19 @@ func TestIsWebhookBased(t *testing.T) {
},
want: true,
},
{
name: "Slack TaskSpawner",
ts: &kelosv1alpha1.TaskSpawner{
Spec: kelosv1alpha1.TaskSpawnerSpec{
When: kelosv1alpha1.When{
Slack: &kelosv1alpha1.Slack{
Channels: []string{"C0123456789"},
},
},
},
},
want: true,
},
}

for _, tt := range tests {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,45 @@ spec:
required:
- types
type: object
slack:
description: |-
Slack discovers work items from Slack messages via Socket Mode.
The centralized kelos-slack-server connects to Slack via an outbound
WebSocket (no ingress required) and routes messages to matching agents.
properties:
channels:
description: |-
Channels optionally restricts which Slack channels the bot listens in.
Values are channel IDs (e.g., "C0123456789"). When empty, the bot
listens in every channel it has been invited to.
items:
pattern: ^[CG][A-Z0-9]{8,}$
type: string
maxItems: 64
type: array
triggers:
description: |-
Triggers define regex patterns that must match the message text.
Bot mention is implicitly required unless MentionOptional is set.
Multiple triggers use OR semantics. When empty, every bot mention fires.
items:
description: SlackTrigger defines a regex pattern trigger
for Slack messages.
properties:
mentionOptional:
description: |-
MentionOptional, when true, fires the trigger on pattern match alone
without requiring a bot @-mention.
type: boolean
pattern:
description: Pattern is a Go RE2 regex matched against
message text (unanchored).
maxLength: 256
type: string
type: object
maxItems: 8
type: array
type: object
webhook:
description: |-
GenericWebhook triggers task spawning from arbitrary HTTP POST payloads.
Expand Down
Loading
Loading