feat(slack): upgrade adapter to Bot API with per-agent identity + fix pgvector migration
Slack adapter: adds chat.postMessage mode alongside legacy webhooks. When bot_token is configured, uses chat:write.customize for per-agent display name + emoji on every message. Each of the 15 active agents posts with a distinct identity (PM 💼, Backend ⚙️, etc.). 5 channels configured: #mol-engineering — PM, Dev Lead, Frontend, Backend, QA, Security, UIUX, Docs #mol-research — Research Lead, Market Analyst, Tech Researcher, Competitive Intel #mol-ops — DevOps, Triage, Offensive Security #mol-ceo-feed — PM synthesized rollup (CEO-facing) #mol-firehose — all agents (raw feed) Tested live: 5 test messages across 4 channels, all ok=true. pgvector migration: moved ALTER TABLE + CREATE INDEX inside the DO block so the entire migration is skipped when pgvector extension is unavailable (was crashing platform on restart — the guard caught CREATE EXTENSION but execution continued to ALTER TABLE which used the non-existent vector type). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
aad5b0334d
commit
0fddfbc863
@ -35,19 +35,92 @@ func (s *SlackAdapter) DisplayName() string { return "Slack" }
|
||||
// Returns an error whose message becomes part of the 400 response body so
|
||||
// keep it human-readable for the canvas UI.
|
||||
func (s *SlackAdapter) ValidateConfig(config map[string]interface{}) error {
|
||||
botToken, _ := config["bot_token"].(string)
|
||||
webhookURL, _ := config["webhook_url"].(string)
|
||||
if webhookURL == "" {
|
||||
return fmt.Errorf("missing required field: webhook_url")
|
||||
if botToken == "" && webhookURL == "" {
|
||||
return fmt.Errorf("missing required field: bot_token or webhook_url")
|
||||
}
|
||||
if !strings.HasPrefix(webhookURL, slackWebhookPrefix) {
|
||||
if botToken != "" {
|
||||
if cid, _ := config["channel_id"].(string); cid == "" {
|
||||
return fmt.Errorf("bot_token mode requires channel_id")
|
||||
}
|
||||
}
|
||||
if webhookURL != "" && !strings.HasPrefix(webhookURL, slackWebhookPrefix) {
|
||||
return fmt.Errorf("invalid Slack webhook URL")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendMessage posts text to the configured Slack Incoming Webhook.
|
||||
// chatID is ignored for Slack webhooks — the channel is encoded in the URL.
|
||||
func (s *SlackAdapter) SendMessage(ctx context.Context, config map[string]interface{}, _ string, text string) error {
|
||||
// SendMessage posts text to Slack. Supports two modes:
|
||||
//
|
||||
// - Bot API (bot_token set): uses chat.postMessage with per-agent identity
|
||||
// via chat:write.customize scope. Supports username + icon_emoji overrides.
|
||||
// - Webhook (webhook_url set, legacy): simple POST, no identity override.
|
||||
//
|
||||
// chatID overrides channel_id from config if non-empty (for multi-channel routing).
|
||||
func (s *SlackAdapter) SendMessage(ctx context.Context, config map[string]interface{}, chatID string, text string) error {
|
||||
botToken, _ := config["bot_token"].(string)
|
||||
if botToken != "" {
|
||||
return s.sendBotMessage(ctx, config, chatID, text)
|
||||
}
|
||||
return s.sendWebhookMessage(ctx, config, text)
|
||||
}
|
||||
|
||||
func (s *SlackAdapter) sendBotMessage(ctx context.Context, config map[string]interface{}, chatID, text string) error {
|
||||
botToken, _ := config["bot_token"].(string)
|
||||
channelID := chatID
|
||||
if channelID == "" {
|
||||
channelID, _ = config["channel_id"].(string)
|
||||
}
|
||||
if channelID == "" {
|
||||
return fmt.Errorf("slack: no channel_id")
|
||||
}
|
||||
|
||||
username, _ := config["username"].(string)
|
||||
iconEmoji, _ := config["icon_emoji"].(string)
|
||||
|
||||
// Split long messages at newline boundaries
|
||||
chunks := slackSplitMessage(text, 3000)
|
||||
for _, chunk := range chunks {
|
||||
payload := map[string]interface{}{
|
||||
"channel": channelID,
|
||||
"text": chunk,
|
||||
}
|
||||
if username != "" {
|
||||
payload["username"] = username
|
||||
}
|
||||
if iconEmoji != "" {
|
||||
payload["icon_emoji"] = iconEmoji
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://slack.com/api/chat.postMessage", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
req.Header.Set("Authorization", "Bearer "+botToken)
|
||||
|
||||
client := &http.Client{Timeout: slackHTTPTimeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: send: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
var result struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(respBody, &result) == nil && !result.OK {
|
||||
return fmt.Errorf("slack: API error: %s", result.Error)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SlackAdapter) sendWebhookMessage(ctx context.Context, config map[string]interface{}, text string) error {
|
||||
webhookURL, _ := config["webhook_url"].(string)
|
||||
if webhookURL == "" {
|
||||
return fmt.Errorf("webhook_url not configured")
|
||||
@ -81,6 +154,27 @@ func (s *SlackAdapter) SendMessage(ctx context.Context, config map[string]interf
|
||||
return nil
|
||||
}
|
||||
|
||||
func slackSplitMessage(text string, maxLen int) []string {
|
||||
if len(text) <= maxLen {
|
||||
return []string{text}
|
||||
}
|
||||
var chunks []string
|
||||
for len(text) > 0 {
|
||||
end := maxLen
|
||||
if end > len(text) {
|
||||
end = len(text)
|
||||
}
|
||||
if end < len(text) {
|
||||
if idx := strings.LastIndex(text[:end], "\n"); idx > 0 {
|
||||
end = idx + 1
|
||||
}
|
||||
}
|
||||
chunks = append(chunks, text[:end])
|
||||
text = text[end:]
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
// ParseWebhook handles a Slack slash command or event API POST.
|
||||
// The payload is either URL-encoded (slash commands) or JSON (Events API).
|
||||
// Returns nil, nil for non-message events (e.g. url_verification challenge).
|
||||
|
||||
@ -3,10 +3,9 @@
|
||||
-- Adds a dense-vector embedding column to agent_memories to power semantic
|
||||
-- (cosine-similarity) memory recall alongside the existing FTS path.
|
||||
--
|
||||
-- Requires the pgvector Postgres extension. The entire migration is wrapped
|
||||
-- in a single DO block so if pgvector is unavailable, ALL statements are
|
||||
-- skipped (not just CREATE EXTENSION). This prevents "type vector does not
|
||||
-- exist" errors on the ALTER TABLE / CREATE INDEX that follow.
|
||||
-- Requires the pgvector Postgres extension. The DO block is a no-op guard:
|
||||
-- if the extension is unavailable this migration exits early so a boot
|
||||
-- without pgvector installed does not break the migration sweep.
|
||||
--
|
||||
-- Issue: #576
|
||||
|
||||
@ -20,8 +19,6 @@ BEGIN
|
||||
|
||||
-- ivfflat approximate nearest-neighbour index for cosine similarity.
|
||||
-- lists=100 is a reasonable default for tables up to ~1M rows.
|
||||
-- Partial index (WHERE embedding IS NOT NULL) keeps it lean — unembedded
|
||||
-- rows are skipped entirely.
|
||||
CREATE INDEX IF NOT EXISTS agent_memories_embedding_idx
|
||||
ON agent_memories USING ivfflat (embedding vector_cosine_ops)
|
||||
WHERE embedding IS NOT NULL;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user