Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(slack): add file send tool and thread-scoped history context (#16)

* feat(slack): add file send tool and thread-scoped history context

* fix(slack): address review issues in history scope and file upload

authored by

Lyric Wai and committed by
GitHub
b6be4309 5eced1f2

+1214 -21
+1
.gitignore
··· 13 13 web/console/dist/ 14 14 15 15 dist/ 16 + .pnpm-store/
+1 -1
assets/config/config.example.yaml
··· 456 456 # Base directory for local state (memory/skills/heartbeat). 457 457 file_state_dir: "~/.morph" 458 458 # Prompt profile context injection. 459 - # Global temporary file cache directory used for inbound/outbound file handling (e.g. Telegram). 459 + # Global temporary file cache directory used for inbound/outbound file handling (e.g. Telegram/Slack tools). 460 460 file_cache_dir: "~/.cache/morph" 461 461 file_cache: 462 462 # Note: cleanup runs on startup (best-effort).
+49
docs/feat/feat_20260301_slack.md
··· 143 143 - [x] PR-3: Mainline 2 (plan progress rendering) 144 144 - [x] PR-4: Mainline 3 (Slack prompt block) 145 145 - [x] PR-5: Mainline 4 (heartbeat integration) 146 + 147 + ## 5) Thread-Scoped History Plan (New) 148 + 149 + ### Objective 150 + 151 + - [x] When inbound message has `thread_ts`, use thread-scoped history/context for addressing + task execution. 152 + - [x] When inbound message has empty `thread_ts`, keep existing channel-scoped behavior. 153 + - [x] Keep bus `conversation_key` unchanged (`slack:<team_id>:<channel_id>`) in V1. 154 + 155 + ### Design Constraints 156 + 157 + - [ ] Do not change `internal/bus/adapters/slack/*` conversation-key grammar in this PR. 158 + - [ ] Do not change outbound thread delivery priority (`extensions.thread_ts` -> `extensions.reply_to` -> envelope `reply_to`). 159 + - [ ] Keep worker serialization scope channel-level in V1 to minimize behavior risk; change only history scope. 160 + 161 + ### Implementation Checklist 162 + 163 + - [x] Add helper: `buildSlackHistoryScopeKey(teamID, channelID, threadTS string)`. 164 + - [x] Keep `slackJob` minimal: do not add duplicated history-scope state. 165 + - [x] Derive history scope key on demand from `team_id` + `channel_id` + `thread_ts`. 166 + - [x] Replace history map indexing from channel conversation key to `HistoryScopeKey`: 167 + - [x] path: worker pre-run snapshot (`history[...]` read). 168 + - [x] path: worker post-run append (`history[...]` write). 169 + - [x] path: ignored inbound append in talkative mode. 170 + - [x] Replace sticky-skills map indexing to `HistoryScopeKey` so thread and channel contexts do not cross-contaminate. 171 + - [x] Keep `ConversationKey` unchanged for bus/hook/daemon metadata. 172 + - [x] Keep `ReplyToMessageID = ThreadTS` in history items (no schema change). 173 + 174 + ### Test Checklist 175 + 176 + - [x] Unit test for scope-key builder: 177 + - [x] no-thread case -> `slack:<team>:<channel>` 178 + - [x] threaded case -> `slack:<team>:<channel>:thread:<thread_ts>` 179 + - [x] Runtime behavior tests: 180 + - [x] same channel + different `thread_ts` -> isolated history contexts 181 + - [x] same `thread_ts` -> shared thread history 182 + - [x] no `thread_ts` -> unchanged channel history behavior 183 + - [x] Regression tests: 184 + - [x] outbound replies still target correct thread 185 + - [x] `slack.group_trigger_mode=smart` still works in both thread and non-thread messages 186 + 187 + ### Explicit Non-Goals (V1) 188 + 189 + - [ ] Do not change memory subject/session key scope (keep current channel scope). 190 + - [ ] Do not add Slack API thread backfill (`conversations.replies`) in this PR. 191 + 192 + ### Follow-up (V2 Candidate) 193 + 194 + - [ ] Add optional thread backfill on first-seen thread to improve cold-start thread context.
+12 -9
docs/slack.md
··· 159 159 - Bus ordering/sharding key is `conversation_key = slack:<team_id>:<channel_id>`. 160 160 Thread is not part of sharding, so different threads in the same channel share the same serialized worker. 161 161 162 - ### Pending: Thread-Aware Context for `smart` Group Trigger 162 + ### Status: Thread-Aware Context for `smart` Group Trigger 163 163 164 - Current limitation: 164 + Implemented in V1: 165 + 166 + - Runtime history scope is now thread-aware: 167 + - when `thread_ts` is present, history context is isolated by thread; 168 + - when `thread_ts` is empty, behavior remains channel-scoped. 169 + - This applies to both addressing classification context and main task execution context. 170 + - Bus `conversation_key` remains `slack:<team_id>:<channel_id>` in V1. 165 171 166 - - In `slack.group_trigger_mode=smart`, when a user first `@` mentions the bot inside an existing thread, the bot knows the current message is in a thread (`thread_ts` is present), but its LLM history is still channel-scoped rather than thread-scoped. 167 - - As a result, the bot may miss earlier messages from that thread, or mix them with unrelated messages from the same channel. 172 + Remaining limitations: 168 173 169 - Pending requirement: 174 + - History is thread-scoped only for messages observed after runtime start; there is no first-hit thread backfill from Slack API yet. 175 + - Memory subject/session keys are still channel-scoped in V1. 170 176 171 - - When the bot is triggered from a Slack thread, it should have a reliable way to perceive thread context before deciding or replying. 172 - - At minimum, this means the runtime should provide thread-scoped context to addressing and main task execution, instead of relying only on channel-scoped rolling history. 173 - - Acceptable implementations may include fetching prior thread replies on demand, maintaining thread-specific short-term history, or both. 174 - - The goal is that when a user says "as discussed above" and then `@` mentions the bot in a thread, the bot can understand what "above" refers to within that thread. 177 + - Implementation checklist is tracked in `docs/feat/feat_20260301_slack.md` ("Thread-Scoped History Plan (New)"). 175 178 176 179 ## 11. Heartbeat Delivery 177 180
+4
internal/channelopts/options.go
··· 239 239 TaskTimeout time.Duration 240 240 GlobalTaskTimeout time.Duration 241 241 MaxConcurrency int 242 + FileCacheDir string 242 243 ServerListen string 243 244 ServerAuthToken string 244 245 ServerMaxQueue int ··· 281 282 TaskTimeout: r.GetDuration("slack.task_timeout"), 282 283 GlobalTaskTimeout: r.GetDuration("timeout"), 283 284 MaxConcurrency: r.GetInt("slack.max_concurrency"), 285 + FileCacheDir: strings.TrimSpace(r.GetString("file_cache_dir")), 284 286 ServerListen: strings.TrimSpace(r.GetString("server.listen")), 285 287 ServerAuthToken: strings.TrimSpace(r.GetString("server.auth_token")), 286 288 ServerMaxQueue: r.GetInt("server.max_queue"), ··· 337 339 if maxConcurrency <= 0 { 338 340 maxConcurrency = cfg.MaxConcurrency 339 341 } 342 + fileCacheDir := strings.TrimSpace(cfg.FileCacheDir) 340 343 serverListen := normalizeServerListen(cfg.ServerListen) 341 344 baseURL := strings.TrimSpace(in.BaseURL) 342 345 if baseURL == "" { ··· 353 356 AddressingInterjectThreshold: addressingInterjectThreshold, 354 357 TaskTimeout: taskTimeout, 355 358 MaxConcurrency: maxConcurrency, 359 + FileCacheDir: fileCacheDir, 356 360 Server: slackruntime.ServerOptions{ 357 361 Listen: serverListen, 358 362 AuthToken: cfg.ServerAuthToken,
+4
internal/channelopts/options_test.go
··· 110 110 TaskTimeout: 0, 111 111 GlobalTaskTimeout: 3 * time.Minute, 112 112 MaxConcurrency: 3, 113 + FileCacheDir: "/tmp/morph-cache", 113 114 AgentLimits: agent.Limits{ToolRepeatLimit: 11}, 114 115 DefaultGroupTriggerMode: "smart", 115 116 DefaultAddressingConfidenceThreshold: 0.6, ··· 130 131 } 131 132 if opts.AgentLimits.ToolRepeatLimit != 11 { 132 133 t.Fatalf("agent tool repeat limit = %d, want 11", opts.AgentLimits.ToolRepeatLimit) 134 + } 135 + if opts.FileCacheDir != "/tmp/morph-cache" { 136 + t.Fatalf("file cache dir = %q, want %q", opts.FileCacheDir, "/tmp/morph-cache") 133 137 } 134 138 if !opts.MemoryEnabled || opts.MemoryShortTermDays != 9 || !opts.MemoryInjectionEnabled || opts.MemoryInjectionMaxItems != 33 { 135 139 t.Fatalf("memory options mismatch: %#v", opts)
+25 -10
internal/channelruntime/slack/runtime.go
··· 40 40 AddressingInterjectThreshold float64 41 41 TaskTimeout time.Duration 42 42 MaxConcurrency int 43 + FileCacheDir string 43 44 Server ServerOptions 44 45 BaseURL string 45 46 BusMaxInFlight int ··· 362 363 sem := make(chan struct{}, maxConc) 363 364 364 365 groupTriggerMode := strings.ToLower(strings.TrimSpace(opts.GroupTriggerMode)) 366 + fileCacheDir := strings.TrimSpace(opts.FileCacheDir) 365 367 slackHistoryCap := slackHistoryCapForMode(groupTriggerMode) 366 368 addressingLLMTimeout := addressingRoute.ClientConfig.RequestTimeout 367 369 if addressingLLMTimeout <= 0 { ··· 478 480 Sem: sem, 479 481 Jobs: w.Jobs, 480 482 Handle: func(workerCtx context.Context, job slackJob) { 483 + historyScopeKey := slackHistoryScopeKeyForJob(job) 484 + if historyScopeKey == "" { 485 + historyScopeKey = conversationKey 486 + } 481 487 mu.Lock() 482 - h := append([]chathistory.ChatHistoryItem(nil), history[conversationKey]...) 488 + h := append([]chathistory.ChatHistoryItem(nil), history[historyScopeKey]...) 483 489 curVersion := w.Version 484 - sticky := append([]string(nil), stickySkillsByConv[conversationKey]...) 490 + sticky := append([]string(nil), stickySkillsByConv[historyScopeKey]...) 485 491 mu.Unlock() 486 492 if job.Version != curVersion { 487 493 h = nil ··· 511 517 sticky, 512 518 allowedChannels, 513 519 availableEmojiNames, 520 + fileCacheDir, 514 521 taskRuntimeOpts, 515 522 func(ctx context.Context, text, correlationID string) error { 516 523 if ctx == nil { ··· 621 628 622 629 mu.Lock() 623 630 if w.Version != curVersion { 624 - history[conversationKey] = nil 625 - stickySkillsByConv[conversationKey] = nil 631 + history[historyScopeKey] = nil 632 + stickySkillsByConv[historyScopeKey] = nil 626 633 } 627 634 if w.Version == curVersion && len(loadedSkills) > 0 { 628 - stickySkillsByConv[conversationKey] = capUniqueStrings(loadedSkills, slackStickySkillsCap) 635 + stickySkillsByConv[historyScopeKey] = capUniqueStrings(loadedSkills, slackStickySkillsCap) 629 636 } 630 - cur := history[conversationKey] 637 + cur := history[historyScopeKey] 631 638 cur = append(cur, newSlackInboundHistoryItem(job)) 632 639 if reaction != nil { 633 640 note := "[reacted]" ··· 639 646 if outText != "" { 640 647 cur = append(cur, newSlackOutboundAgentHistoryItem(job, outText, time.Now().UTC(), botUserID)) 641 648 } 642 - history[conversationKey] = trimChatHistoryItems(cur, slackHistoryCapForMode(groupTriggerMode)) 649 + history[historyScopeKey] = trimChatHistoryItems(cur, slackHistoryCapForMode(groupTriggerMode)) 643 650 mu.Unlock() 644 651 }, 645 652 }) ··· 771 778 if err != nil { 772 779 return 773 780 } 781 + historyScopeKey, err := buildSlackHistoryScopeKey(event.TeamID, event.ChannelID, event.ThreadTS) 782 + if err != nil { 783 + return 784 + } 774 785 mu.Lock() 775 - cur := history[conversationKey] 786 + cur := history[historyScopeKey] 776 787 cur = append(cur, newSlackInboundHistoryItem(slackJob{ 777 788 ConversationKey: conversationKey, 778 789 TeamID: event.TeamID, ··· 787 798 SentAt: event.SentAt, 788 799 MentionUsers: append([]string(nil), event.MentionUsers...), 789 800 })) 790 - history[conversationKey] = trimChatHistoryItems(cur, slackHistoryCapForMode(groupTriggerMode)) 801 + history[historyScopeKey] = trimChatHistoryItems(cur, slackHistoryCapForMode(groupTriggerMode)) 791 802 mu.Unlock() 792 803 } 793 804 ··· 855 866 if err != nil { 856 867 return err 857 868 } 869 + historyScopeKey, err := buildSlackHistoryScopeKey(event.TeamID, event.ChannelID, event.ThreadTS) 870 + if err != nil { 871 + return err 872 + } 858 873 username, displayName, identityErr := resolveSlackUserIdentity(context.Background(), event.TeamID, event.UserID) 859 874 if identityErr != nil { 860 875 logger.Warn("slack_user_identity_enrichment_failed", ··· 880 895 isGroup := isSlackGroupChat(event.ChatType) 881 896 if isGroup { 882 897 mu.Lock() 883 - historySnapshot := append([]chathistory.ChatHistoryItem(nil), history[conversationKey]...) 898 + historySnapshot := append([]chathistory.ChatHistoryItem(nil), history[historyScopeKey]...) 884 899 mu.Unlock() 885 900 decisionCtx := llmstats.WithRunID(context.Background(), slackTaskID(event.TeamID, event.ChannelID, event.MessageTS)) 886 901 var addressingReactionTool *slacktools.ReactTool
+8
internal/channelruntime/slack/runtime_options.go
··· 5 5 "time" 6 6 7 7 "github.com/quailyquaily/mistermorph/agent" 8 + "github.com/quailyquaily/mistermorph/internal/pathutil" 8 9 ) 9 10 10 11 type runtimeLoopOptions struct { ··· 17 18 AddressingInterjectThreshold float64 18 19 TaskTimeout time.Duration 19 20 MaxConcurrency int 21 + FileCacheDir string 20 22 Server ServerOptions 21 23 Hooks Hooks 22 24 BaseURL string ··· 42 44 AddressingInterjectThreshold: opts.AddressingInterjectThreshold, 43 45 TaskTimeout: opts.TaskTimeout, 44 46 MaxConcurrency: opts.MaxConcurrency, 47 + FileCacheDir: strings.TrimSpace(opts.FileCacheDir), 45 48 Server: ServerOptions{ 46 49 Listen: strings.TrimSpace(opts.Server.Listen), 47 50 AuthToken: strings.TrimSpace(opts.Server.AuthToken), ··· 69 72 opts.AllowedTeamIDs = normalizeRunStringSlice(opts.AllowedTeamIDs) 70 73 opts.AllowedChannelIDs = normalizeRunStringSlice(opts.AllowedChannelIDs) 71 74 opts.GroupTriggerMode = strings.ToLower(strings.TrimSpace(opts.GroupTriggerMode)) 75 + opts.FileCacheDir = strings.TrimSpace(opts.FileCacheDir) 72 76 opts.Server.Listen = strings.TrimSpace(opts.Server.Listen) 73 77 opts.Server.AuthToken = strings.TrimSpace(opts.Server.AuthToken) 74 78 opts.BaseURL = strings.TrimSpace(opts.BaseURL) ··· 101 105 if opts.BaseURL == "" { 102 106 opts.BaseURL = "https://slack.com/api" 103 107 } 108 + if opts.FileCacheDir == "" { 109 + opts.FileCacheDir = "~/.cache/morph" 110 + } 111 + opts.FileCacheDir = pathutil.ExpandHomePath(opts.FileCacheDir) 104 112 if opts.Server.Listen == "" { 105 113 opts.Server.Listen = "127.0.0.1:8787" 106 114 }
+8
internal/channelruntime/slack/runtime_options_test.go
··· 5 5 "time" 6 6 7 7 "github.com/quailyquaily/mistermorph/agent" 8 + "github.com/quailyquaily/mistermorph/internal/pathutil" 8 9 ) 9 10 10 11 func TestNormalizeSlackRunStringSlice(t *testing.T) { ··· 54 55 } 55 56 if got.BaseURL != "https://slack.com/api" { 56 57 t.Fatalf("base url = %q, want https://slack.com/api", got.BaseURL) 58 + } 59 + if got.FileCacheDir != pathutil.ExpandHomePath("~/.cache/morph") { 60 + t.Fatalf("file cache dir = %q, want %q", got.FileCacheDir, pathutil.ExpandHomePath("~/.cache/morph")) 57 61 } 58 62 if got.AddressingConfidenceThreshold != 0.6 { 59 63 t.Fatalf("confidence threshold = %v, want 0.6", got.AddressingConfidenceThreshold) ··· 74 78 AddressingInterjectThreshold: 0.2, 75 79 TaskTimeout: 3 * time.Minute, 76 80 MaxConcurrency: 7, 81 + FileCacheDir: " ~/.cache/custom ", 77 82 Server: ServerOptions{ 78 83 Listen: " 127.0.0.1:8080 ", 79 84 }, ··· 104 109 } 105 110 if got.BaseURL != "https://example.com/api" || got.BusMaxInFlight != 4096 || got.AgentLimits.ParseRetries != 5 || got.AgentLimits.ToolRepeatLimit != 6 { 106 111 t.Fatalf("resolved options mismatch: %#v", got) 112 + } 113 + if got.FileCacheDir != pathutil.ExpandHomePath("~/.cache/custom") { 114 + t.Fatalf("file cache dir = %q, want %q", got.FileCacheDir, pathutil.ExpandHomePath("~/.cache/custom")) 107 115 } 108 116 if got.Server.Listen != "127.0.0.1:8080" { 109 117 t.Fatalf("server listen = %q, want 127.0.0.1:8080", got.Server.Listen)
+58
internal/channelruntime/slack/runtime_outbound_test.go
··· 1 1 package slack 2 2 3 3 import ( 4 + "context" 5 + "io" 6 + "log/slog" 7 + "strings" 4 8 "testing" 5 9 "time" 6 10 ··· 65 69 t.Fatalf("kind(message) = %q, want message", got) 66 70 } 67 71 } 72 + 73 + func TestPublishSlackBusOutboundPreservesThreadTS(t *testing.T) { 74 + t.Parallel() 75 + 76 + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 77 + inprocBus, err := busruntime.StartInproc(busruntime.BootstrapOptions{ 78 + MaxInFlight: 32, 79 + Logger: logger, 80 + Component: "slack-test", 81 + }) 82 + if err != nil { 83 + t.Fatalf("StartInproc() error = %v", err) 84 + } 85 + defer inprocBus.Close() 86 + 87 + gotCh := make(chan busruntime.BusMessage, 1) 88 + if err := inprocBus.Subscribe(busruntime.TopicChatMessage, func(ctx context.Context, msg busruntime.BusMessage) error { 89 + select { 90 + case gotCh <- msg: 91 + default: 92 + } 93 + return nil 94 + }); err != nil { 95 + t.Fatalf("Subscribe() error = %v", err) 96 + } 97 + 98 + const threadTS = "1700000000.000100" 99 + _, err = publishSlackBusOutbound(context.Background(), inprocBus, "T111", "C222", "hello", threadTS, "corr:test") 100 + if err != nil { 101 + t.Fatalf("publishSlackBusOutbound() error = %v", err) 102 + } 103 + 104 + select { 105 + case msg := <-gotCh: 106 + if msg.ConversationKey != "slack:T111:C222" { 107 + t.Fatalf("conversation_key = %q, want %q", msg.ConversationKey, "slack:T111:C222") 108 + } 109 + if strings.TrimSpace(msg.Extensions.ThreadTS) != threadTS { 110 + t.Fatalf("extensions.thread_ts = %q, want %q", msg.Extensions.ThreadTS, threadTS) 111 + } 112 + if strings.TrimSpace(msg.Extensions.ReplyTo) != threadTS { 113 + t.Fatalf("extensions.reply_to = %q, want %q", msg.Extensions.ReplyTo, threadTS) 114 + } 115 + env, err := msg.Envelope() 116 + if err != nil { 117 + t.Fatalf("Envelope() error = %v", err) 118 + } 119 + if strings.TrimSpace(env.ReplyTo) != threadTS { 120 + t.Fatalf("envelope.reply_to = %q, want %q", env.ReplyTo, threadTS) 121 + } 122 + case <-time.After(2 * time.Second): 123 + t.Fatalf("did not receive outbound bus message") 124 + } 125 + }
+41 -1
internal/channelruntime/slack/runtime_task.go
··· 51 51 stickySkills []string, 52 52 allowedChannelIDs map[string]bool, 53 53 availableEmojiNames []string, 54 + fileCacheDir string, 54 55 runtimeOpts runtimeTaskOptions, 55 56 sendSlackText func(context.Context, string, string) error, 56 57 ) (*agent.Final, *agent.Context, []string, *slacktools.Reaction, error) { ··· 79 80 PlanCreateModel: runtimeOpts.PlanCreateModel, 80 81 }) 81 82 toolsutil.SetTodoUpdateToolAddContext(reg, todoResolveContextForSlack(job)) 83 + toolAPI := newSlackToolAPI(api) 84 + if api != nil && strings.TrimSpace(job.ChannelID) != "" { 85 + reg.Register(slacktools.NewSendFileTool(toolAPI, job.ChannelID, job.ThreadTS, allowedChannelIDs, fileCacheDir, 0)) 86 + } 82 87 var reactTool *slacktools.ReactTool 83 88 if api != nil && 84 89 strings.TrimSpace(job.ChannelID) != "" && 85 90 strings.TrimSpace(job.MessageTS) != "" { 86 - reactTool = slacktools.NewReactTool(newSlackToolAPI(api), job.ChannelID, job.MessageTS, allowedChannelIDs, availableEmojiNames) 91 + reactTool = slacktools.NewReactTool(toolAPI, job.ChannelID, job.MessageTS, allowedChannelIDs, availableEmojiNames) 87 92 reg.Register(reactTool) 88 93 } 89 94 ··· 399 404 400 405 func buildSlackConversationKey(teamID, channelID string) (string, error) { 401 406 return busruntime.BuildSlackChannelConversationKey(strings.TrimSpace(teamID) + ":" + strings.TrimSpace(channelID)) 407 + } 408 + 409 + func buildSlackHistoryScopeKey(teamID, channelID, threadTS string) (string, error) { 410 + conversationKey, err := buildSlackConversationKey(teamID, channelID) 411 + if err != nil { 412 + return "", err 413 + } 414 + threadTS = strings.TrimSpace(threadTS) 415 + if threadTS == "" { 416 + return conversationKey, nil 417 + } 418 + return conversationKey + ":thread:" + threadTS, nil 419 + } 420 + 421 + func slackHistoryScopeKeyForJob(job slackJob) string { 422 + teamID := strings.TrimSpace(job.TeamID) 423 + channelID := strings.TrimSpace(job.ChannelID) 424 + if teamID != "" && channelID != "" { 425 + threadTS := strings.TrimSpace(job.ThreadTS) 426 + // In smart group mode we may synthesize quote-reply delivery by setting 427 + // thread_ts to message_ts for non-thread channel mentions. Keep history 428 + // channel-scoped for that case to preserve the "empty inbound thread_ts" 429 + // behavior. 430 + if threadTS != "" && threadTS == strings.TrimSpace(job.MessageTS) { 431 + threadTS = "" 432 + } 433 + scope, err := buildSlackHistoryScopeKey(teamID, channelID, threadTS) 434 + if err == nil { 435 + scope = strings.TrimSpace(scope) 436 + if scope != "" { 437 + return scope 438 + } 439 + } 440 + } 441 + return strings.TrimSpace(job.ConversationKey) 402 442 } 403 443 404 444 func busErrorCodeString(err error) string {
+119
internal/channelruntime/slack/runtime_task_test.go
··· 113 113 t.Fatalf("current message should still be present: %#v", currentMsg) 114 114 } 115 115 } 116 + 117 + func TestBuildSlackHistoryScopeKey(t *testing.T) { 118 + t.Run("channel scope when thread ts is empty", func(t *testing.T) { 119 + got, err := buildSlackHistoryScopeKey("T1", "C1", "") 120 + if err != nil { 121 + t.Fatalf("buildSlackHistoryScopeKey() error = %v", err) 122 + } 123 + if got != "slack:T1:C1" { 124 + t.Fatalf("history scope key = %q, want %q", got, "slack:T1:C1") 125 + } 126 + }) 127 + 128 + t.Run("thread scope when thread ts exists", func(t *testing.T) { 129 + got, err := buildSlackHistoryScopeKey("T1", "C1", "1739667600.000100") 130 + if err != nil { 131 + t.Fatalf("buildSlackHistoryScopeKey() error = %v", err) 132 + } 133 + if got != "slack:T1:C1:thread:1739667600.000100" { 134 + t.Fatalf("history scope key = %q, want %q", got, "slack:T1:C1:thread:1739667600.000100") 135 + } 136 + }) 137 + } 138 + 139 + func TestSlackHistoryScopeKeyForJob(t *testing.T) { 140 + if got := slackHistoryScopeKeyForJob(slackJob{ 141 + TeamID: "T1", 142 + ChannelID: "C1", 143 + ThreadTS: "1739667600.000100", 144 + ConversationKey: "slack:T1:C1", 145 + }); got != "slack:T1:C1:thread:1739667600.000100" { 146 + t.Fatalf("scope = %q, want thread scope key", got) 147 + } 148 + if got := slackHistoryScopeKeyForJob(slackJob{ 149 + TeamID: "T1", 150 + ChannelID: "C1", 151 + MessageTS: "1739667600.000100", 152 + ThreadTS: "1739667600.000100", 153 + ConversationKey: "slack:T1:C1", 154 + }); got != "slack:T1:C1" { 155 + t.Fatalf("scope = %q, want channel scope key for synthetic thread", got) 156 + } 157 + if got := slackHistoryScopeKeyForJob(slackJob{ 158 + ConversationKey: "slack:T1:C1", 159 + }); got != "slack:T1:C1" { 160 + t.Fatalf("scope = %q, want conversation key fallback", got) 161 + } 162 + } 163 + 164 + func TestSlackHistoryScopeBehavior_DifferentThreadsIsolated(t *testing.T) { 165 + history := map[string][]string{} 166 + appendByJob := func(job slackJob, text string) { 167 + scope := slackHistoryScopeKeyForJob(job) 168 + history[scope] = append(history[scope], text) 169 + } 170 + 171 + scopeA, err := buildSlackHistoryScopeKey("T1", "C1", "1739667600.000100") 172 + if err != nil { 173 + t.Fatalf("buildSlackHistoryScopeKey(scopeA) error = %v", err) 174 + } 175 + scopeB, err := buildSlackHistoryScopeKey("T1", "C1", "1739667600.000200") 176 + if err != nil { 177 + t.Fatalf("buildSlackHistoryScopeKey(scopeB) error = %v", err) 178 + } 179 + if scopeA == scopeB { 180 + t.Fatalf("thread scope keys should differ: %q", scopeA) 181 + } 182 + 183 + appendByJob(slackJob{ConversationKey: "slack:T1:C1", TeamID: "T1", ChannelID: "C1", ThreadTS: "1739667600.000100"}, "thread-a-1") 184 + appendByJob(slackJob{ConversationKey: "slack:T1:C1", TeamID: "T1", ChannelID: "C1", ThreadTS: "1739667600.000200"}, "thread-b-1") 185 + appendByJob(slackJob{ConversationKey: "slack:T1:C1", TeamID: "T1", ChannelID: "C1", ThreadTS: "1739667600.000100"}, "thread-a-2") 186 + 187 + if got := history[scopeA]; len(got) != 2 || got[0] != "thread-a-1" || got[1] != "thread-a-2" { 188 + t.Fatalf("scopeA history = %#v, want [thread-a-1 thread-a-2]", got) 189 + } 190 + if got := history[scopeB]; len(got) != 1 || got[0] != "thread-b-1" { 191 + t.Fatalf("scopeB history = %#v, want [thread-b-1]", got) 192 + } 193 + } 194 + 195 + func TestSlackHistoryScopeBehavior_SameThreadShared(t *testing.T) { 196 + history := map[string][]string{} 197 + appendByJob := func(job slackJob, text string) { 198 + scope := slackHistoryScopeKeyForJob(job) 199 + history[scope] = append(history[scope], text) 200 + } 201 + 202 + scope, err := buildSlackHistoryScopeKey("T1", "C1", "1739667600.000100") 203 + if err != nil { 204 + t.Fatalf("buildSlackHistoryScopeKey() error = %v", err) 205 + } 206 + appendByJob(slackJob{ConversationKey: "slack:T1:C1", TeamID: "T1", ChannelID: "C1", ThreadTS: "1739667600.000100"}, "m1") 207 + appendByJob(slackJob{ConversationKey: "slack:T1:C1", TeamID: "T1", ChannelID: "C1", ThreadTS: "1739667600.000100"}, "m2") 208 + 209 + if got := history[scope]; len(got) != 2 || got[0] != "m1" || got[1] != "m2" { 210 + t.Fatalf("scope history = %#v, want [m1 m2]", got) 211 + } 212 + } 213 + 214 + func TestSlackHistoryScopeBehavior_NoThreadUsesChannelScope(t *testing.T) { 215 + history := map[string][]string{} 216 + appendByJob := func(job slackJob, text string) { 217 + scope := slackHistoryScopeKeyForJob(job) 218 + history[scope] = append(history[scope], text) 219 + } 220 + 221 + channelScope, err := buildSlackHistoryScopeKey("T1", "C1", "") 222 + if err != nil { 223 + t.Fatalf("buildSlackHistoryScopeKey() error = %v", err) 224 + } 225 + if channelScope != "slack:T1:C1" { 226 + t.Fatalf("channel scope = %q, want slack:T1:C1", channelScope) 227 + } 228 + 229 + appendByJob(slackJob{ConversationKey: "slack:T1:C1"}, "m1") 230 + appendByJob(slackJob{ConversationKey: "slack:T1:C1"}, "m2") 231 + if got := history[channelScope]; len(got) != 2 || got[0] != "m1" || got[1] != "m2" { 232 + t.Fatalf("channel history = %#v, want [m1 m2]", got) 233 + } 234 + }
+45
internal/channelruntime/slack/runtime_test.go
··· 132 132 } 133 133 } 134 134 135 + func TestDecideSlackGroupTrigger_SmartExplicitMentionThreadAndNonThread(t *testing.T) { 136 + t.Parallel() 137 + 138 + cases := []struct { 139 + name string 140 + event slackInboundEvent 141 + }{ 142 + { 143 + name: "non_thread_app_mention", 144 + event: slackInboundEvent{ 145 + Text: "<@U999> hello", 146 + IsAppMention: true, 147 + IsThreadMessage: false, 148 + MessageTS: "1739667600.000100", 149 + }, 150 + }, 151 + { 152 + name: "thread_app_mention", 153 + event: slackInboundEvent{ 154 + Text: "<@U999> follow up", 155 + IsAppMention: true, 156 + IsThreadMessage: true, 157 + ThreadTS: "1739667600.000010", 158 + MessageTS: "1739667600.000200", 159 + }, 160 + }, 161 + } 162 + 163 + for _, tc := range cases { 164 + tc := tc 165 + t.Run(tc.name, func(t *testing.T) { 166 + dec, ok, err := decideSlackGroupTrigger(nil, nil, "", tc.event, "U999", "", "smart", 0, 0.6, 0.6, nil, nil) 167 + if err != nil { 168 + t.Fatalf("decideSlackGroupTrigger() error = %v", err) 169 + } 170 + if !ok { 171 + t.Fatalf("decideSlackGroupTrigger() ok=false, want true") 172 + } 173 + if dec.Addressing.Impulse != 1 { 174 + t.Fatalf("addressing_impulse mismatch: got %v want 1", dec.Addressing.Impulse) 175 + } 176 + }) 177 + } 178 + } 179 + 135 180 func TestIntersectSlackCommonReactionEmojiNames(t *testing.T) { 136 181 t.Parallel() 137 182
+197
internal/channelruntime/slack/slack_api.go
··· 8 8 "io" 9 9 "net/http" 10 10 "net/url" 11 + "os" 12 + "path/filepath" 11 13 "regexp" 12 14 "sort" 13 15 "strings" ··· 299 301 Error string `json:"error,omitempty"` 300 302 } 301 303 304 + type slackGetUploadURLExternalResponse struct { 305 + OK bool `json:"ok"` 306 + Error string `json:"error,omitempty"` 307 + UploadURL string `json:"upload_url,omitempty"` 308 + FileID string `json:"file_id,omitempty"` 309 + } 310 + 311 + type slackCompleteUploadExternalResponse struct { 312 + OK bool `json:"ok"` 313 + Error string `json:"error,omitempty"` 314 + } 315 + 302 316 func (api *slackAPI) openSocketURL(ctx context.Context) (string, error) { 303 317 if api == nil { 304 318 return "", fmt.Errorf("slack api is not initialized") ··· 386 400 return nil 387 401 } 388 402 return fmt.Errorf("slack reactions.add failed: %s", code) 403 + } 404 + return nil 405 + } 406 + 407 + func (api *slackAPI) uploadFile(ctx context.Context, channelID, threadTS, filePath, filename, title, initialComment string) error { 408 + if api == nil || api.http == nil { 409 + return fmt.Errorf("slack api is not initialized") 410 + } 411 + channelID = strings.TrimSpace(channelID) 412 + threadTS = strings.TrimSpace(threadTS) 413 + filePath = strings.TrimSpace(filePath) 414 + filename = strings.TrimSpace(filename) 415 + title = strings.TrimSpace(title) 416 + initialComment = strings.TrimSpace(initialComment) 417 + if channelID == "" { 418 + return fmt.Errorf("channel_id is required") 419 + } 420 + if filePath == "" { 421 + return fmt.Errorf("file path is required") 422 + } 423 + if filename == "" { 424 + filename = filepath.Base(filePath) 425 + } 426 + if title == "" { 427 + title = filename 428 + } 429 + st, err := os.Stat(filePath) 430 + if err != nil { 431 + return err 432 + } 433 + if st.IsDir() { 434 + return fmt.Errorf("file path is a directory: %s", filePath) 435 + } 436 + uploadURL, fileID, err := api.getUploadURLExternal(ctx, filename, st.Size()) 437 + if err != nil { 438 + return err 439 + } 440 + if err := api.uploadFileToExternalURL(ctx, uploadURL, filePath, st.Size()); err != nil { 441 + return err 442 + } 443 + if err := api.completeUploadExternal(ctx, channelID, threadTS, fileID, title, initialComment); err != nil { 444 + return err 445 + } 446 + return nil 447 + } 448 + 449 + func (api *slackAPI) getUploadURLExternal(ctx context.Context, filename string, length int64) (string, string, error) { 450 + if api == nil || api.http == nil { 451 + return "", "", fmt.Errorf("slack api is not initialized") 452 + } 453 + filename = strings.TrimSpace(filename) 454 + if filename == "" { 455 + return "", "", fmt.Errorf("filename is required") 456 + } 457 + if length < 0 { 458 + return "", "", fmt.Errorf("file length is invalid") 459 + } 460 + body, status, _, err := api.postAuthJSON(ctx, api.botToken, "/files.getUploadURLExternal", map[string]any{ 461 + "filename": filename, 462 + "length": length, 463 + }) 464 + if err != nil { 465 + return "", "", err 466 + } 467 + if status < 200 || status >= 300 { 468 + return "", "", fmt.Errorf("slack files.getUploadURLExternal http %d", status) 469 + } 470 + var out slackGetUploadURLExternalResponse 471 + if err := json.Unmarshal(body, &out); err != nil { 472 + return "", "", err 473 + } 474 + if !out.OK { 475 + code := strings.TrimSpace(out.Error) 476 + if code == "" { 477 + code = "unknown_error" 478 + } 479 + return "", "", fmt.Errorf("slack files.getUploadURLExternal failed: %s", code) 480 + } 481 + uploadURL := strings.TrimSpace(out.UploadURL) 482 + fileID := strings.TrimSpace(out.FileID) 483 + if uploadURL == "" || fileID == "" { 484 + return "", "", fmt.Errorf("slack files.getUploadURLExternal returned incomplete payload") 485 + } 486 + return uploadURL, fileID, nil 487 + } 488 + 489 + func (api *slackAPI) uploadFileToExternalURL(ctx context.Context, uploadURL, filePath string, contentLength int64) error { 490 + if api == nil || api.http == nil { 491 + return fmt.Errorf("slack api is not initialized") 492 + } 493 + uploadURL = strings.TrimSpace(uploadURL) 494 + filePath = strings.TrimSpace(filePath) 495 + if uploadURL == "" { 496 + return fmt.Errorf("upload url is required") 497 + } 498 + if filePath == "" { 499 + return fmt.Errorf("file path is required") 500 + } 501 + 502 + f, err := os.Open(filePath) 503 + if err != nil { 504 + return err 505 + } 506 + defer f.Close() 507 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, f) 508 + if err != nil { 509 + return err 510 + } 511 + req.Header.Set("Content-Type", "application/octet-stream") 512 + if contentLength >= 0 { 513 + req.ContentLength = contentLength 514 + } 515 + resp, err := api.http.Do(req) 516 + if err != nil { 517 + return err 518 + } 519 + raw, readErr := io.ReadAll(resp.Body) 520 + _ = resp.Body.Close() 521 + if readErr != nil { 522 + return readErr 523 + } 524 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { 525 + msg := strings.TrimSpace(string(raw)) 526 + if msg == "" { 527 + return fmt.Errorf("slack external file upload http %d", resp.StatusCode) 528 + } 529 + return fmt.Errorf("slack external file upload http %d: %s", resp.StatusCode, msg) 530 + } 531 + return nil 532 + } 533 + 534 + func (api *slackAPI) completeUploadExternal(ctx context.Context, channelID, threadTS, fileID, title, initialComment string) error { 535 + if api == nil || api.http == nil { 536 + return fmt.Errorf("slack api is not initialized") 537 + } 538 + channelID = strings.TrimSpace(channelID) 539 + threadTS = strings.TrimSpace(threadTS) 540 + fileID = strings.TrimSpace(fileID) 541 + title = strings.TrimSpace(title) 542 + initialComment = strings.TrimSpace(initialComment) 543 + if channelID == "" { 544 + return fmt.Errorf("channel_id is required") 545 + } 546 + if fileID == "" { 547 + return fmt.Errorf("file_id is required") 548 + } 549 + if title == "" { 550 + title = "file" 551 + } 552 + 553 + payload := map[string]any{ 554 + "channel_id": channelID, 555 + "files": []map[string]string{ 556 + { 557 + "id": fileID, 558 + "title": title, 559 + }, 560 + }, 561 + } 562 + if threadTS != "" { 563 + payload["thread_ts"] = threadTS 564 + } 565 + if initialComment != "" { 566 + payload["initial_comment"] = initialComment 567 + } 568 + 569 + body, status, _, err := api.postAuthJSON(ctx, api.botToken, "/files.completeUploadExternal", payload) 570 + if err != nil { 571 + return err 572 + } 573 + if status < 200 || status >= 300 { 574 + return fmt.Errorf("slack files.completeUploadExternal http %d", status) 575 + } 576 + var out slackCompleteUploadExternalResponse 577 + if err := json.Unmarshal(body, &out); err != nil { 578 + return err 579 + } 580 + if !out.OK { 581 + code := strings.TrimSpace(out.Error) 582 + if code == "" { 583 + code = "unknown_error" 584 + } 585 + return fmt.Errorf("slack files.completeUploadExternal failed: %s", code) 389 586 } 390 587 return nil 391 588 }
+130
internal/channelruntime/slack/slack_api_test.go
··· 7 7 "net/http" 8 8 "net/http/httptest" 9 9 "net/url" 10 + "os" 11 + "path/filepath" 10 12 "reflect" 11 13 "strings" 12 14 "testing" ··· 231 233 } 232 234 if !strings.Contains(err.Error(), "invalid_name") { 233 235 t.Fatalf("error = %v, want invalid_name", err) 236 + } 237 + }) 238 + } 239 + 240 + func TestSlackAPIUploadFile(t *testing.T) { 241 + t.Run("ok", func(t *testing.T) { 242 + var gotFileContent string 243 + var server *httptest.Server 244 + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 245 + switch r.URL.Path { 246 + case "/files.getUploadURLExternal": 247 + if got := strings.TrimSpace(r.Header.Get("Authorization")); got != "Bearer xoxb-test" { 248 + t.Fatalf("authorization = %q", got) 249 + } 250 + var payload map[string]any 251 + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { 252 + t.Fatalf("decode payload: %v", err) 253 + } 254 + if got := strings.TrimSpace(payload["filename"].(string)); got != "result.txt" { 255 + t.Fatalf("filename = %q, want %q", got, "result.txt") 256 + } 257 + if got := int64(payload["length"].(float64)); got != int64(len("hello slack")) { 258 + t.Fatalf("length = %d, want %d", got, len("hello slack")) 259 + } 260 + _ = json.NewEncoder(w).Encode(map[string]any{ 261 + "ok": true, 262 + "upload_url": server.URL + "/upload/v1/mock", 263 + "file_id": "F123", 264 + }) 265 + case "/upload/v1/mock": 266 + if got := strings.TrimSpace(r.Header.Get("Authorization")); got != "" { 267 + t.Fatalf("authorization = %q, want empty", got) 268 + } 269 + raw, err := io.ReadAll(r.Body) 270 + if err != nil { 271 + t.Fatalf("read upload body: %v", err) 272 + } 273 + gotFileContent = string(raw) 274 + _, _ = w.Write([]byte("ok")) 275 + case "/files.completeUploadExternal": 276 + if got := strings.TrimSpace(r.Header.Get("Authorization")); got != "Bearer xoxb-test" { 277 + t.Fatalf("authorization = %q", got) 278 + } 279 + var payload map[string]any 280 + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { 281 + t.Fatalf("decode payload: %v", err) 282 + } 283 + if got := strings.TrimSpace(payload["channel_id"].(string)); got != "C123" { 284 + t.Fatalf("channel_id = %q, want %q", got, "C123") 285 + } 286 + if got := strings.TrimSpace(payload["thread_ts"].(string)); got != "1739667600.000100" { 287 + t.Fatalf("thread_ts = %q, want %q", got, "1739667600.000100") 288 + } 289 + if got := strings.TrimSpace(payload["initial_comment"].(string)); got != "done" { 290 + t.Fatalf("initial_comment = %q, want %q", got, "done") 291 + } 292 + files, ok := payload["files"].([]any) 293 + if !ok || len(files) != 1 { 294 + t.Fatalf("files payload = %#v, want one item", payload["files"]) 295 + } 296 + fileMeta, ok := files[0].(map[string]any) 297 + if !ok { 298 + t.Fatalf("files[0] payload = %#v, want map", files[0]) 299 + } 300 + if got := strings.TrimSpace(fileMeta["id"].(string)); got != "F123" { 301 + t.Fatalf("file id = %q, want %q", got, "F123") 302 + } 303 + if got := strings.TrimSpace(fileMeta["title"].(string)); got != "Result" { 304 + t.Fatalf("file title = %q, want %q", got, "Result") 305 + } 306 + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) 307 + default: 308 + t.Fatalf("unexpected path: %s", r.URL.Path) 309 + } 310 + })) 311 + defer server.Close() 312 + 313 + tmp := t.TempDir() 314 + localFile := filepath.Join(tmp, "result.txt") 315 + if err := os.WriteFile(localFile, []byte("hello slack"), 0o600); err != nil { 316 + t.Fatalf("write temp file: %v", err) 317 + } 318 + 319 + api := newSlackAPI(server.Client(), server.URL, "xoxb-test", "xapp-test") 320 + if err := api.uploadFile(context.Background(), "C123", "1739667600.000100", localFile, "result.txt", "Result", "done"); err != nil { 321 + t.Fatalf("uploadFile() error = %v", err) 322 + } 323 + if gotFileContent != "hello slack" { 324 + t.Fatalf("uploaded file content = %q, want %q", gotFileContent, "hello slack") 325 + } 326 + }) 327 + 328 + t.Run("complete upload slack error", func(t *testing.T) { 329 + var server *httptest.Server 330 + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 331 + switch r.URL.Path { 332 + case "/files.getUploadURLExternal": 333 + _ = json.NewEncoder(w).Encode(map[string]any{ 334 + "ok": true, 335 + "upload_url": server.URL + "/upload/v1/mock", 336 + "file_id": "F123", 337 + }) 338 + case "/upload/v1/mock": 339 + _, _ = w.Write([]byte("ok")) 340 + case "/files.completeUploadExternal": 341 + _ = json.NewEncoder(w).Encode(map[string]any{ 342 + "ok": false, 343 + "error": "missing_scope", 344 + }) 345 + default: 346 + t.Fatalf("unexpected path: %s", r.URL.Path) 347 + } 348 + })) 349 + defer server.Close() 350 + 351 + tmp := t.TempDir() 352 + localFile := filepath.Join(tmp, "result.txt") 353 + if err := os.WriteFile(localFile, []byte("hello slack"), 0o600); err != nil { 354 + t.Fatalf("write temp file: %v", err) 355 + } 356 + 357 + api := newSlackAPI(server.Client(), server.URL, "xoxb-test", "xapp-test") 358 + err := api.uploadFile(context.Background(), "C123", "", localFile, "", "", "") 359 + if err == nil { 360 + t.Fatalf("expected error") 361 + } 362 + if !strings.Contains(err.Error(), "missing_scope") { 363 + t.Fatalf("error = %v, want missing_scope", err) 234 364 } 235 365 }) 236 366 }
+7
internal/channelruntime/slack/tool_adapter.go
··· 24 24 } 25 25 return a.api.addReaction(ctx, channelID, messageTS, emoji) 26 26 } 27 + 28 + func (a *slackToolAPI) SendFile(ctx context.Context, channelID, threadTS, filePath, filename, title, initialComment string) error { 29 + if a == nil || a.api == nil { 30 + return fmt.Errorf("slack api not available") 31 + } 32 + return a.api.uploadFile(ctx, channelID, threadTS, filePath, filename, title, initialComment) 33 + }
+66
tools/slack/cache_file.go
··· 1 + package slack 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "strings" 8 + 9 + "github.com/quailyquaily/mistermorph/internal/pathutil" 10 + ) 11 + 12 + func resolveFileCachePath(cacheDir string, rawPath string, maxBytes int64) (string, error) { 13 + cacheDir = strings.TrimSpace(cacheDir) 14 + if cacheDir == "" { 15 + return "", fmt.Errorf("file_cache_dir is required") 16 + } 17 + rawPath = pathutil.NormalizeFileCacheDirPath(strings.TrimSpace(rawPath)) 18 + 19 + p := rawPath 20 + if !filepath.IsAbs(p) { 21 + p = filepath.Join(cacheDir, p) 22 + } 23 + p = filepath.Clean(p) 24 + 25 + cacheAbs, err := filepath.Abs(cacheDir) 26 + if err != nil { 27 + return "", err 28 + } 29 + pathAbs, err := filepath.Abs(p) 30 + if err != nil { 31 + return "", err 32 + } 33 + 34 + // Resolve symlinks before containment check so links under file_cache_dir 35 + // cannot escape to arbitrary filesystem locations. 36 + cacheResolved := cacheAbs 37 + if resolved, resolveErr := filepath.EvalSymlinks(cacheAbs); resolveErr == nil { 38 + cacheResolved = resolved 39 + } else if !os.IsNotExist(resolveErr) { 40 + return "", resolveErr 41 + } 42 + pathResolved, err := filepath.EvalSymlinks(pathAbs) 43 + if err != nil { 44 + return "", err 45 + } 46 + 47 + rel, err := filepath.Rel(cacheResolved, pathResolved) 48 + if err != nil { 49 + return "", err 50 + } 51 + if rel == "." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || rel == ".." { 52 + return "", fmt.Errorf("refusing to send file outside file_cache_dir: %s", pathResolved) 53 + } 54 + 55 + st, err := os.Stat(pathResolved) 56 + if err != nil { 57 + return "", err 58 + } 59 + if st.IsDir() { 60 + return "", fmt.Errorf("path is a directory: %s", pathResolved) 61 + } 62 + if maxBytes > 0 && st.Size() > maxBytes { 63 + return "", fmt.Errorf("file too large to send (>%d bytes): %s", maxBytes, pathResolved) 64 + } 65 + return pathResolved, nil 66 + }
+98
tools/slack/cache_file_test.go
··· 1 + package slack 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "strings" 7 + "testing" 8 + ) 9 + 10 + func TestResolveFileCachePath(t *testing.T) { 11 + cacheDir := t.TempDir() 12 + filePath := filepath.Join(cacheDir, "nested", "report.txt") 13 + if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { 14 + t.Fatalf("MkdirAll() error = %v", err) 15 + } 16 + if err := os.WriteFile(filePath, []byte("ok"), 0o600); err != nil { 17 + t.Fatalf("WriteFile() error = %v", err) 18 + } 19 + 20 + got, err := resolveFileCachePath(cacheDir, "file_cache_dir/nested/report.txt", 1024) 21 + if err != nil { 22 + t.Fatalf("resolveFileCachePath() error = %v", err) 23 + } 24 + if got != filePath { 25 + t.Fatalf("path = %q, want %q", got, filePath) 26 + } 27 + } 28 + 29 + func TestResolveFileCachePathRejectsDirectory(t *testing.T) { 30 + cacheDir := t.TempDir() 31 + dirPath := filepath.Join(cacheDir, "nested") 32 + if err := os.MkdirAll(dirPath, 0o755); err != nil { 33 + t.Fatalf("MkdirAll() error = %v", err) 34 + } 35 + 36 + _, err := resolveFileCachePath(cacheDir, "nested", 1024) 37 + if err == nil { 38 + t.Fatalf("resolveFileCachePath() error = nil, want directory error") 39 + } 40 + if !strings.Contains(err.Error(), "path is a directory") { 41 + t.Fatalf("error = %v, want directory error", err) 42 + } 43 + } 44 + 45 + func TestResolveFileCachePathRejectsOutsideCacheDir(t *testing.T) { 46 + cacheDir := t.TempDir() 47 + outsideDir := t.TempDir() 48 + outsidePath := filepath.Join(outsideDir, "report.txt") 49 + if err := os.WriteFile(outsidePath, []byte("ok"), 0o600); err != nil { 50 + t.Fatalf("WriteFile() error = %v", err) 51 + } 52 + 53 + _, err := resolveFileCachePath(cacheDir, outsidePath, 1024) 54 + if err == nil { 55 + t.Fatalf("resolveFileCachePath() error = nil, want outside-file error") 56 + } 57 + if !strings.Contains(err.Error(), "outside file_cache_dir") { 58 + t.Fatalf("error = %v, want outside file_cache_dir", err) 59 + } 60 + } 61 + 62 + func TestResolveFileCachePathRejectsTooLargeFile(t *testing.T) { 63 + cacheDir := t.TempDir() 64 + filePath := filepath.Join(cacheDir, "big.bin") 65 + if err := os.WriteFile(filePath, []byte("12345"), 0o600); err != nil { 66 + t.Fatalf("WriteFile() error = %v", err) 67 + } 68 + 69 + _, err := resolveFileCachePath(cacheDir, "big.bin", 4) 70 + if err == nil { 71 + t.Fatalf("resolveFileCachePath() error = nil, want size error") 72 + } 73 + if !strings.Contains(err.Error(), "file too large to send") { 74 + t.Fatalf("error = %v, want size error", err) 75 + } 76 + } 77 + 78 + func TestResolveFileCachePathRejectsSymlinkEscapingCacheDir(t *testing.T) { 79 + cacheDir := t.TempDir() 80 + outsideDir := t.TempDir() 81 + outsidePath := filepath.Join(outsideDir, "secret.txt") 82 + if err := os.WriteFile(outsidePath, []byte("secret"), 0o600); err != nil { 83 + t.Fatalf("WriteFile() error = %v", err) 84 + } 85 + 86 + linkPath := filepath.Join(cacheDir, "escape.txt") 87 + if err := os.Symlink(outsidePath, linkPath); err != nil { 88 + t.Skipf("symlink not supported in this environment: %v", err) 89 + } 90 + 91 + _, err := resolveFileCachePath(cacheDir, "escape.txt", 1024) 92 + if err == nil { 93 + t.Fatalf("resolveFileCachePath() error = nil, want outside-file error") 94 + } 95 + if !strings.Contains(err.Error(), "outside file_cache_dir") { 96 + t.Fatalf("error = %v, want outside file_cache_dir", err) 97 + } 98 + }
+11
tools/slack/react_tool_test.go
··· 22 22 return s.err 23 23 } 24 24 25 + func (s *stubReactAPI) SendFile(ctx context.Context, channelID, threadTS, filePath, filename, title, initialComment string) error { 26 + _ = ctx 27 + _ = channelID 28 + _ = threadTS 29 + _ = filePath 30 + _ = filename 31 + _ = title 32 + _ = initialComment 33 + return nil 34 + } 35 + 25 36 func TestSlackReactToolExecute_DefaultTarget(t *testing.T) { 26 37 api := &stubReactAPI{} 27 38 tool := NewReactTool(api, "C123", "1739667600.000100", nil, []string{"thumbsup"})
+172
tools/slack/send_file_tool.go
··· 1 + package slack 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "path/filepath" 8 + "strings" 9 + ) 10 + 11 + type SendFileTool struct { 12 + api API 13 + defaultChannelID string 14 + defaultThreadTS string 15 + allowedChannelIDs map[string]bool 16 + cacheDir string 17 + maxBytes int64 18 + } 19 + 20 + func NewSendFileTool(api API, defaultChannelID, defaultThreadTS string, allowedChannelIDs map[string]bool, cacheDir string, maxBytes int64) *SendFileTool { 21 + if maxBytes <= 0 { 22 + maxBytes = 20 * 1024 * 1024 23 + } 24 + allowed := make(map[string]bool, len(allowedChannelIDs)) 25 + for raw := range allowedChannelIDs { 26 + channelID := strings.TrimSpace(raw) 27 + if channelID == "" { 28 + continue 29 + } 30 + allowed[channelID] = true 31 + } 32 + return &SendFileTool{ 33 + api: api, 34 + defaultChannelID: strings.TrimSpace(defaultChannelID), 35 + defaultThreadTS: strings.TrimSpace(defaultThreadTS), 36 + allowedChannelIDs: allowed, 37 + cacheDir: strings.TrimSpace(cacheDir), 38 + maxBytes: maxBytes, 39 + } 40 + } 41 + 42 + func (t *SendFileTool) Name() string { return "slack_send_file" } 43 + 44 + func (t *SendFileTool) Description() string { 45 + return "Uploads a local file under file_cache_dir to Slack. Use this when you need to send generated artifacts back to the current channel." 46 + } 47 + 48 + func (t *SendFileTool) ParameterSchema() string { 49 + s := map[string]any{ 50 + "type": "object", 51 + "additionalProperties": false, 52 + "properties": map[string]any{ 53 + "channel_id": map[string]any{ 54 + "type": "string", 55 + "description": "Target Slack channel id. Optional in active channel context.", 56 + }, 57 + "thread_ts": map[string]any{ 58 + "type": "string", 59 + "description": "Optional thread timestamp to keep upload in the same thread.", 60 + }, 61 + "path": map[string]any{ 62 + "type": "string", 63 + "description": "Path to a local file under file_cache_dir (absolute or relative to that directory).", 64 + }, 65 + "filename": map[string]any{ 66 + "type": "string", 67 + "description": "Optional display filename (default: basename of path).", 68 + }, 69 + "title": map[string]any{ 70 + "type": "string", 71 + "description": "Optional title shown in Slack.", 72 + }, 73 + "initial_comment": map[string]any{ 74 + "type": "string", 75 + "description": "Optional message text attached to the file upload.", 76 + }, 77 + }, 78 + "required": []string{"path"}, 79 + } 80 + b, _ := json.MarshalIndent(s, "", " ") 81 + return string(b) 82 + } 83 + 84 + func (t *SendFileTool) Execute(ctx context.Context, params map[string]any) (string, error) { 85 + if t == nil || t.api == nil { 86 + return "", fmt.Errorf("slack_send_file is disabled") 87 + } 88 + 89 + channelID := strings.TrimSpace(t.defaultChannelID) 90 + if v, ok := params["channel_id"].(string); ok { 91 + channelID = strings.TrimSpace(v) 92 + } 93 + if channelID == "" { 94 + return "", fmt.Errorf("missing required param: channel_id") 95 + } 96 + if len(t.allowedChannelIDs) > 0 && !t.allowedChannelIDs[channelID] { 97 + return "", fmt.Errorf("unauthorized channel_id: %s", channelID) 98 + } 99 + 100 + threadTS := strings.TrimSpace(t.defaultThreadTS) 101 + if v, ok := params["thread_ts"].(string); ok { 102 + threadTS = strings.TrimSpace(v) 103 + } 104 + 105 + rawPath, _ := params["path"].(string) 106 + rawPath = strings.TrimSpace(rawPath) 107 + if rawPath == "" { 108 + return "", fmt.Errorf("missing required param: path") 109 + } 110 + cacheDir := strings.TrimSpace(t.cacheDir) 111 + if cacheDir == "" { 112 + return "", fmt.Errorf("file cache dir is not configured") 113 + } 114 + pathAbs, err := resolveFileCachePath(cacheDir, rawPath, t.maxBytes) 115 + if err != nil { 116 + return "", err 117 + } 118 + 119 + filename, _ := params["filename"].(string) 120 + filename = strings.TrimSpace(filename) 121 + if filename == "" { 122 + filename = filepath.Base(pathAbs) 123 + } 124 + filename = sanitizeFilename(filename) 125 + 126 + title, _ := params["title"].(string) 127 + title = strings.TrimSpace(title) 128 + if title == "" { 129 + title = filename 130 + } 131 + 132 + initialComment, _ := params["initial_comment"].(string) 133 + initialComment = strings.TrimSpace(initialComment) 134 + 135 + if err := t.api.SendFile(ctx, channelID, threadTS, pathAbs, filename, title, initialComment); err != nil { 136 + return "", err 137 + } 138 + return fmt.Sprintf("uploaded file: %s", filename), nil 139 + } 140 + 141 + func sanitizeFilename(name string) string { 142 + name = strings.TrimSpace(name) 143 + if name == "" { 144 + return "file" 145 + } 146 + name = filepath.Base(name) 147 + var b strings.Builder 148 + b.Grow(len(name)) 149 + for _, r := range name { 150 + switch { 151 + case r >= 'a' && r <= 'z': 152 + b.WriteRune(r) 153 + case r >= 'A' && r <= 'Z': 154 + b.WriteRune(r) 155 + case r >= '0' && r <= '9': 156 + b.WriteRune(r) 157 + case r == '.' || r == '_' || r == '-' || r == '+': 158 + b.WriteRune(r) 159 + default: 160 + b.WriteByte('_') 161 + } 162 + } 163 + out := strings.Trim(b.String(), "._- ") 164 + if out == "" { 165 + return "file" 166 + } 167 + const max = 120 168 + if len(out) > max { 169 + out = out[:max] 170 + } 171 + return out 172 + }
+157
tools/slack/send_file_tool_test.go
··· 1 + package slack 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + "path/filepath" 8 + "testing" 9 + ) 10 + 11 + type stubSendFileAPI struct { 12 + channelID string 13 + threadTS string 14 + filePath string 15 + filename string 16 + title string 17 + initialComment string 18 + err error 19 + } 20 + 21 + func (s *stubSendFileAPI) AddReaction(ctx context.Context, channelID, messageTS, emoji string) error { 22 + _ = ctx 23 + _ = channelID 24 + _ = messageTS 25 + _ = emoji 26 + return nil 27 + } 28 + 29 + func (s *stubSendFileAPI) SendFile(ctx context.Context, channelID, threadTS, filePath, filename, title, initialComment string) error { 30 + _ = ctx 31 + s.channelID = channelID 32 + s.threadTS = threadTS 33 + s.filePath = filePath 34 + s.filename = filename 35 + s.title = title 36 + s.initialComment = initialComment 37 + return s.err 38 + } 39 + 40 + func TestSlackSendFileToolExecute_DefaultContext(t *testing.T) { 41 + cacheDir := t.TempDir() 42 + localFile := filepath.Join(cacheDir, "nested", "result.txt") 43 + if err := os.MkdirAll(filepath.Dir(localFile), 0o755); err != nil { 44 + t.Fatalf("MkdirAll() error = %v", err) 45 + } 46 + if err := os.WriteFile(localFile, []byte("ok"), 0o600); err != nil { 47 + t.Fatalf("WriteFile() error = %v", err) 48 + } 49 + 50 + api := &stubSendFileAPI{} 51 + tool := NewSendFileTool(api, "C123", "1739667600.000100", nil, cacheDir, 1024) 52 + out, err := tool.Execute(context.Background(), map[string]any{ 53 + "path": "nested/result.txt", 54 + "initial_comment": "done", 55 + }) 56 + if err != nil { 57 + t.Fatalf("Execute() error = %v", err) 58 + } 59 + if out != "uploaded file: result.txt" { 60 + t.Fatalf("output = %q, want %q", out, "uploaded file: result.txt") 61 + } 62 + if api.channelID != "C123" || api.threadTS != "1739667600.000100" { 63 + t.Fatalf("target mismatch: channel=%q thread=%q", api.channelID, api.threadTS) 64 + } 65 + if api.filePath != localFile { 66 + t.Fatalf("file path = %q, want %q", api.filePath, localFile) 67 + } 68 + if api.filename != "result.txt" || api.title != "result.txt" { 69 + t.Fatalf("name/title mismatch: filename=%q title=%q", api.filename, api.title) 70 + } 71 + if api.initialComment != "done" { 72 + t.Fatalf("initial comment = %q, want %q", api.initialComment, "done") 73 + } 74 + } 75 + 76 + func TestSlackSendFileToolExecute_OverrideTargetAndMetadata(t *testing.T) { 77 + cacheDir := t.TempDir() 78 + localFile := filepath.Join(cacheDir, "report.txt") 79 + if err := os.WriteFile(localFile, []byte("ok"), 0o600); err != nil { 80 + t.Fatalf("WriteFile() error = %v", err) 81 + } 82 + 83 + api := &stubSendFileAPI{} 84 + tool := NewSendFileTool(api, "C123", "1739667600.000100", map[string]bool{ 85 + "C123": true, 86 + "C456": true, 87 + }, cacheDir, 1024) 88 + _, err := tool.Execute(context.Background(), map[string]any{ 89 + "channel_id": "C456", 90 + "thread_ts": "1739667600.000200", 91 + "path": "report.txt", 92 + "filename": "final report.md", 93 + "title": "Final Report", 94 + "initial_comment": "artifact", 95 + }) 96 + if err != nil { 97 + t.Fatalf("Execute() error = %v", err) 98 + } 99 + if api.channelID != "C456" || api.threadTS != "1739667600.000200" { 100 + t.Fatalf("target mismatch: channel=%q thread=%q", api.channelID, api.threadTS) 101 + } 102 + if api.filename != "final_report.md" { 103 + t.Fatalf("filename = %q, want %q", api.filename, "final_report.md") 104 + } 105 + if api.title != "Final Report" { 106 + t.Fatalf("title = %q, want %q", api.title, "Final Report") 107 + } 108 + } 109 + 110 + func TestSlackSendFileToolExecute_ValidationAndAPIError(t *testing.T) { 111 + cacheDir := t.TempDir() 112 + localFile := filepath.Join(cacheDir, "result.txt") 113 + if err := os.WriteFile(localFile, []byte("ok"), 0o600); err != nil { 114 + t.Fatalf("WriteFile() error = %v", err) 115 + } 116 + 117 + t.Run("missing channel_id without runtime context", func(t *testing.T) { 118 + api := &stubSendFileAPI{} 119 + tool := NewSendFileTool(api, "", "", nil, cacheDir, 1024) 120 + if _, err := tool.Execute(context.Background(), map[string]any{ 121 + "path": "result.txt", 122 + }); err == nil { 123 + t.Fatalf("expected error") 124 + } 125 + }) 126 + 127 + t.Run("missing path", func(t *testing.T) { 128 + api := &stubSendFileAPI{} 129 + tool := NewSendFileTool(api, "C123", "", nil, cacheDir, 1024) 130 + if _, err := tool.Execute(context.Background(), map[string]any{}); err == nil { 131 + t.Fatalf("expected error") 132 + } 133 + }) 134 + 135 + t.Run("unauthorized channel", func(t *testing.T) { 136 + api := &stubSendFileAPI{} 137 + tool := NewSendFileTool(api, "C123", "", map[string]bool{ 138 + "C123": true, 139 + }, cacheDir, 1024) 140 + if _, err := tool.Execute(context.Background(), map[string]any{ 141 + "channel_id": "C999", 142 + "path": "result.txt", 143 + }); err == nil { 144 + t.Fatalf("expected error") 145 + } 146 + }) 147 + 148 + t.Run("api error", func(t *testing.T) { 149 + api := &stubSendFileAPI{err: fmt.Errorf("slack files.upload failed: invalid_auth")} 150 + tool := NewSendFileTool(api, "C123", "", nil, cacheDir, 1024) 151 + if _, err := tool.Execute(context.Background(), map[string]any{ 152 + "path": "result.txt", 153 + }); err == nil { 154 + t.Fatalf("expected error") 155 + } 156 + }) 157 + }
+1
tools/slack/tools.go
··· 5 5 // API is the minimal Slack transport surface needed by Slack tools. 6 6 type API interface { 7 7 AddReaction(ctx context.Context, channelID, messageTS, emoji string) error 8 + SendFile(ctx context.Context, channelID, threadTS, filePath, filename, title, initialComment string) error 8 9 } 9 10 10 11 type Reaction struct {