Files
kandev/apps/backend/internal/slack/trigger.go
T
2026-07-06 21:43:13 +01:00

323 lines
8.0 KiB
Go

package slack
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
)
// DefaultBaseInterval is how often the trigger's base loop wakes up to check
// whether a scan is due. The actual scan cadence comes from
// SlackConfig.PollIntervalSeconds; this just bounds how soon a freshly-due
// scan gets noticed.
const DefaultBaseInterval = 5 * time.Second
// AcknowledgeReaction is the emoji we react with on a matched message before
// the agent runs.
const AcknowledgeReaction = "eyes"
// Trigger drives the polling loop that turns matched Slack messages into
// utility-agent runs. The loop is install-wide singleton — there's at most
// one Slack-search-per-PollIntervalSeconds running, regardless of how many
// Kandev workspaces exist.
type Trigger struct {
svc *Service
log *logger.Logger
interval time.Duration
mu sync.Mutex
cancel context.CancelFunc
wg sync.WaitGroup
started bool
// lastScannedAt tracks when each workspace last ran a scan so its
// PollIntervalSeconds is honoured independently. In-memory by design —
// backend restart triggers an immediate scan, which is fine.
scannedMu sync.Mutex
lastScannedAt map[string]time.Time
}
// NewTrigger returns a Trigger using the default base ticker.
func NewTrigger(svc *Service, log *logger.Logger) *Trigger {
if svc == nil {
return nil
}
return &Trigger{svc: svc, log: log, interval: DefaultBaseInterval}
}
// Start launches the loop.
func (t *Trigger) Start(ctx context.Context) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
if t.started {
return
}
t.started = true
ctx, t.cancel = context.WithCancel(ctx)
t.wg.Add(1)
go t.loop(ctx)
t.log.Info("slack trigger started")
}
// Stop cancels the loop and waits for it to drain.
func (t *Trigger) Stop() {
if t == nil {
return
}
t.mu.Lock()
if !t.started {
t.mu.Unlock()
return
}
cancel := t.cancel
t.mu.Unlock()
if cancel != nil {
cancel()
}
t.wg.Wait()
t.mu.Lock()
t.started = false
t.mu.Unlock()
t.log.Info("slack trigger stopped")
}
func (t *Trigger) loop(ctx context.Context) {
defer t.wg.Done()
t.tick(ctx)
ticker := time.NewTicker(t.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
t.tick(ctx)
}
}
}
// tick scans Slack once if the configured cadence has elapsed.
func (t *Trigger) tick(ctx context.Context) {
if t.svc.Runner() == nil {
return
}
configs, err := t.svc.Store().ListConfigs(ctx)
if err != nil || len(configs) == 0 {
return
}
for _, cfg := range configs {
if !t.dueForScan(cfg.WorkspaceID, cfg.PollIntervalSeconds) {
continue
}
t.markScanned(cfg.WorkspaceID)
t.scanWorkspace(ctx, cfg)
}
}
func (t *Trigger) scanWorkspace(ctx context.Context, cfg *SlackConfig) {
if !cfg.LastOk || cfg.SlackUserID == "" {
return
}
if cfg.UtilityAgentID == "" {
return
}
prefix := cfg.CommandPrefix
if prefix == "" {
prefix = DefaultCommandPrefix
}
client, err := t.svc.ClientForWorkspace(ctx, cfg.WorkspaceID)
if err != nil {
return
}
query := fmt.Sprintf("from:<@%s> %q", cfg.SlackUserID, prefix)
matches, err := client.SearchMessages(ctx, query)
if err != nil {
t.log.Warn("slack trigger: search failed", zap.Error(err))
return
}
t.processMatches(ctx, cfg, prefix, matches, client)
}
func (t *Trigger) processMatches(ctx context.Context, cfg *SlackConfig, prefix string, matches []SlackMessage, client Client) {
fresh := newMatchesAfter(matches, cfg.LastSeenTS, prefix)
if len(fresh) == 0 {
return
}
highest := cfg.LastSeenTS
for _, m := range fresh {
if ctx.Err() != nil {
return
}
_, err := t.handleOne(ctx, cfg, prefix, m, client)
if err != nil {
t.log.Warn("slack trigger: handle match failed",
zap.String("ts", m.TS), zap.Error(err))
if errors.Is(err, ErrNoUtilityAgent) {
if compareTS(m.TS, highest) > 0 {
highest = m.TS
}
continue
}
// Recoverable failure: stop the loop here so the watermark stays
// at the last consecutive success. See the original commit for
// why this is a `break` (not `return`) — the post-loop
// UpdateLastSeenTS still needs to persist prior successes.
break
}
if compareTS(m.TS, highest) > 0 {
highest = m.TS
}
}
if highest != cfg.LastSeenTS {
if err := t.svc.Store().UpdateLastSeenTSForWorkspace(ctx, cfg.WorkspaceID, highest); err != nil {
t.log.Warn("slack trigger: advance watermark failed", zap.Error(err))
}
}
}
// handleOne runs the per-match flow: ack reaction, fetch thread, run the
// utility agent, post the reply in-thread.
func (t *Trigger) handleOne(ctx context.Context, cfg *SlackConfig, prefix string, msg SlackMessage, client Client) (string, error) {
if err := client.ReactionsAdd(ctx, msg.ChannelID, msg.TS, AcknowledgeReaction); err != nil {
t.log.Warn("slack trigger: ack reaction failed",
zap.String("ts", msg.TS), zap.Error(err))
}
thread, err := client.ConversationsReplies(ctx, msg.ChannelID, msg.ThreadTS, msg.TS)
if err != nil {
return "", fmt.Errorf("fetch thread: %w", err)
}
permalink := msg.Permalink
if permalink == "" {
permalink, _ = client.ChatGetPermalink(ctx, msg.ChannelID, msg.TS)
}
instruction := stripPrefix(msg.Text, prefix)
runner := t.svc.Runner()
if runner == nil {
return "", errors.New("agent runner not configured")
}
reply, err := runner.RunForMatch(ctx, cfg, msg, instruction, permalink, thread)
if err != nil {
return "", err
}
t.replyInThread(ctx, client, msg, reply)
return reply, nil
}
func (t *Trigger) replyInThread(ctx context.Context, client Client, msg SlackMessage, body string) {
threadTS := msg.ThreadTS
if threadTS == "" {
threadTS = msg.TS
}
if err := client.ChatPostMessage(ctx, msg.ChannelID, threadTS, body); err != nil {
t.log.Warn("slack trigger: reply post failed",
zap.String("ts", msg.TS), zap.Error(err))
}
}
// dueForScan reports whether the configured interval has elapsed since the
// last scan. A zero/never timestamp counts as due.
func (t *Trigger) dueForScan(workspaceID string, intervalSeconds int) bool {
if intervalSeconds < MinPollIntervalSeconds {
intervalSeconds = DefaultPollIntervalSeconds
}
t.scannedMu.Lock()
defer t.scannedMu.Unlock()
if t.lastScannedAt == nil {
return true
}
last := t.lastScannedAt[normalizeWorkspaceID(workspaceID)]
if last.IsZero() {
return true
}
return time.Since(last) >= time.Duration(intervalSeconds)*time.Second
}
func (t *Trigger) markScanned(workspaceID string) {
t.scannedMu.Lock()
if t.lastScannedAt == nil {
t.lastScannedAt = make(map[string]time.Time)
}
t.lastScannedAt[normalizeWorkspaceID(workspaceID)] = time.Now()
t.scannedMu.Unlock()
}
func normalizeWorkspaceID(workspaceID string) string {
if workspaceID == "" {
return "default"
}
return workspaceID
}
func newMatchesAfter(matches []SlackMessage, watermark, prefix string) []SlackMessage {
out := make([]SlackMessage, 0, len(matches))
for _, m := range matches {
if compareTS(m.TS, watermark) <= 0 {
continue
}
if !startsWithPrefix(m.Text, prefix) {
continue
}
out = append(out, m)
}
sort.SliceStable(out, func(i, j int) bool {
return compareTS(out[i].TS, out[j].TS) < 0
})
return out
}
func startsWithPrefix(text, prefix string) bool {
t := strings.TrimSpace(text)
t = strings.TrimPrefix(t, "> ")
t = strings.TrimSpace(t)
if !strings.HasPrefix(strings.ToLower(t), strings.ToLower(prefix)) {
return false
}
rest := t[len(prefix):]
if rest == "" {
return true
}
r := rest[0]
return r == ' ' || r == '\t' || r == '\n' || r == ':' || r == ','
}
func stripPrefix(text, prefix string) string {
t := strings.TrimSpace(text)
t = strings.TrimPrefix(t, "> ")
t = strings.TrimSpace(t)
if len(t) >= len(prefix) && strings.EqualFold(t[:len(prefix)], prefix) {
t = t[len(prefix):]
}
t = strings.TrimSpace(t)
t = strings.TrimLeft(t, ":, ")
return strings.TrimSpace(t)
}
func compareTS(a, b string) int {
if a == b {
return 0
}
if a == "" {
return -1
}
if b == "" {
return 1
}
if a < b {
return -1
}
return 1
}