From f0e387eac7036bf3c68b6a54e9c50444ea3373ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=A3=9E=E8=99=B9?= Date: Wed, 25 Feb 2026 01:12:29 +0800 Subject: [PATCH 01/10] fix(chatapps): fix Slack Socket Mode message handling and bidirectional communication - Fix Socket Mode payload parsing: use "payload" field instead of "body" - Add proper Slack API response validation (check "ok" field) - Fix subtype filtering: allow file_share/bot_message, only skip message_changed/deleted - Fix nil pointer panic in EngineMessageHandler by checking workDirFn and taskInstrFn - Preserve original message metadata (channel_id, thread_ts) in StreamCallback for replies Co-Authored-By: Claude Opus 4.6 --- chatapps/engine_handler.go | 42 +++++++++++++++++++----- chatapps/slack/adapter.go | 62 ++++++++++++++++++++++++++++++----- chatapps/slack/socket_mode.go | 59 +++++++++++++++++++++++++++------ 3 files changed, 136 insertions(+), 27 deletions(-) diff --git a/chatapps/engine_handler.go b/chatapps/engine_handler.go index 81796ba9..05f6d96b 100644 --- a/chatapps/engine_handler.go +++ b/chatapps/engine_handler.go @@ -93,10 +93,11 @@ type StreamCallback struct { logger *slog.Logger mu sync.Mutex isFirst bool + metadata map[string]any // Original message metadata (channel_id, thread_ts, etc.) } // NewStreamCallback creates a new StreamCallback -func NewStreamCallback(ctx context.Context, sessionID, platform string, adapters *AdapterManager, logger *slog.Logger) *StreamCallback { +func NewStreamCallback(ctx context.Context, sessionID, platform string, adapters *AdapterManager, logger *slog.Logger, metadata map[string]any) *StreamCallback { return &StreamCallback{ ctx: ctx, sessionID: sessionID, @@ -104,6 +105,7 @@ func NewStreamCallback(ctx context.Context, sessionID, platform string, adapters adapters: adapters, logger: logger, isFirst: true, + metadata: metadata, } } @@ -231,14 +233,30 @@ func (c *StreamCallback) sendMessage(content string, eventType string) error { return nil } + // Build metadata with original message's platform-specific data (channel_id, thread_ts, etc.) + metadata := map[string]any{ + "stream": true, + "event_type": eventType, + } + + // Copy important metadata from original message + if c.metadata != nil { + if channelID, ok := c.metadata["channel_id"]; ok { + metadata["channel_id"] = channelID + } + if channelType, ok := c.metadata["channel_type"]; ok { + metadata["channel_type"] = channelType + } + if threadTS, ok := c.metadata["thread_ts"]; ok { + metadata["thread_ts"] = threadTS + } + } + msg := &ChatMessage{ Platform: c.platform, SessionID: c.sessionID, Content: content, - Metadata: map[string]any{ - "stream": true, - "event_type": eventType, - }, + Metadata: metadata, } return c.adapters.SendMessage(c.ctx, c.platform, c.sessionID, msg) @@ -300,13 +318,19 @@ func WithConfigLoader(loader *ConfigLoader) EngineMessageHandlerOption { // Handle implements MessageHandler func (h *EngineMessageHandler) Handle(ctx context.Context, msg *ChatMessage) error { // Determine work directory - workDir := h.workDirFn(msg.SessionID) + workDir := "" + if h.workDirFn != nil { + workDir = h.workDirFn(msg.SessionID) + } if workDir == "" { workDir = "/tmp/hotplex-chatapps" } // Determine task instructions - taskInstr := h.taskInstrFn(msg.SessionID) + taskInstr := "" + if h.taskInstrFn != nil { + taskInstr = h.taskInstrFn(msg.SessionID) + } if taskInstr == "" && h.configLoader != nil { taskInstr = h.configLoader.GetTaskInstructions(msg.Platform) } @@ -336,8 +360,8 @@ func (h *EngineMessageHandler) Handle(ctx context.Context, msg *ChatMessage) err TaskInstructions: fullInstructions, } - // Create stream callback - callback := NewStreamCallback(ctx, msg.SessionID, msg.Platform, h.adapters, h.logger) + // Create stream callback with original message metadata + callback := NewStreamCallback(ctx, msg.SessionID, msg.Platform, h.adapters, h.logger, msg.Metadata) wrappedCallback := func(eventType string, data any) error { return callback.Handle(eventType, data) } diff --git a/chatapps/slack/adapter.go b/chatapps/slack/adapter.go index f645ffaf..c200dfdf 100644 --- a/chatapps/slack/adapter.go +++ b/chatapps/slack/adapter.go @@ -201,7 +201,16 @@ func (a *Adapter) handleEventCallback(ctx context.Context, eventData json.RawMes return } - if msgEvent.BotID != "" || (msgEvent.SubType != "" && msgEvent.SubType != "message_changed") { + // Skip bot messages + if msgEvent.BotID != "" { + a.Logger().Debug("Skipping bot message", "bot_id", msgEvent.BotID) + return + } + + // Skip certain subtypes that don't need processing + switch msgEvent.SubType { + case "message_changed", "message_deleted", "thread_broadcast": + a.Logger().Debug("Skipping message subtype", "subtype", msgEvent.SubType) return } @@ -229,6 +238,11 @@ func (a *Adapter) handleEventCallback(ctx context.Context, eventData json.RawMes msg.Metadata["thread_ts"] = msgEvent.ThreadTS } + // Add subtype info for downstream processing + if msgEvent.SubType != "" { + msg.Metadata["subtype"] = msgEvent.SubType + } + a.webhook.Run(ctx, a.Handler(), msg) } @@ -267,8 +281,17 @@ func (a *Adapter) handleSocketModeEvent(eventType string, data json.RawMessage) return } - // Skip bot messages and certain subtypes - if msgEvent.BotID != "" || (msgEvent.SubType != "" && msgEvent.SubType != "message_changed") { + // Skip bot messages (unless it's a message we should process) + if msgEvent.BotID != "" { + a.Logger().Debug("Skipping bot message", "bot_id", msgEvent.BotID) + return + } + + // Skip certain subtypes that don't need processing + // Reference: OpenClaw allows file_share and bot_message, skips message_changed/deleted/thread_broadcast + switch msgEvent.SubType { + case "message_changed", "message_deleted", "thread_broadcast": + a.Logger().Debug("Skipping message subtype", "subtype", msgEvent.SubType) return } @@ -296,12 +319,17 @@ func (a *Adapter) handleSocketModeEvent(eventType string, data json.RawMessage) msg.Metadata["thread_ts"] = msgEvent.ThreadTS } + // Add subtype info for downstream processing + if msgEvent.SubType != "" { + msg.Metadata["subtype"] = msgEvent.SubType + } + handler := a.Handler() if handler == nil { a.Logger().Error("Handler is nil, message will not be processed") return } - a.Logger().Debug("Forwarding message to handler", "sessionID", sessionID, "content", msg.Content) + a.Logger().Info("Forwarding message to handler", "sessionID", sessionID, "content", msg.Content, "subtype", msgEvent.SubType) a.webhook.Run(context.Background(), handler, msg) } @@ -387,18 +415,36 @@ func (a *Adapter) sendToChannelOnce(ctx context.Context, channelID, text, thread } defer func() { _ = resp.Body.Close() }() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + // Check for rate limit (429) if resp.StatusCode == http.StatusTooManyRequests { return fmt.Errorf("rate limited: 429") } if resp.StatusCode >= 400 { - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("send failed with status %d (failed to read body: %v)", resp.StatusCode, err) - } return fmt.Errorf("send failed: %d %s", resp.StatusCode, string(respBody)) } + // Parse Slack API response to check "ok" field + // Slack API may return HTTP 200 with {"ok": false, "error": "..."} + var slackResp struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + } + if err := json.Unmarshal(respBody, &slackResp); err != nil { + a.Logger().Warn("Failed to parse Slack response", "body", string(respBody)) + // Don't fail - message might have been sent + return nil + } + + if !slackResp.OK { + return fmt.Errorf("slack API error: %s", slackResp.Error) + } + + a.Logger().Debug("Message sent successfully", "channel", channelID) return nil } diff --git a/chatapps/slack/socket_mode.go b/chatapps/slack/socket_mode.go index d5bf2006..4f341afc 100644 --- a/chatapps/slack/socket_mode.go +++ b/chatapps/slack/socket_mode.go @@ -253,8 +253,10 @@ func (s *SocketModeConnection) readLoop() { // handleMessage processes incoming WebSocket messages func (s *SocketModeConnection) handleMessage(data []byte) { var msg struct { - Type string `json:"type"` - Body json.RawMessage `json:"body,omitempty"` + Type string `json:"type"` + EnvelopeID string `json:"envelope_id,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` + Body json.RawMessage `json:"body,omitempty"` // fallback for some message types } if err := json.Unmarshal(data, &msg); err != nil { @@ -262,6 +264,9 @@ func (s *SocketModeConnection) handleMessage(data []byte) { return } + // Log all incoming messages for debugging + s.logger.Debug("Received WebSocket message", "type", msg.Type, "envelope_id", msg.EnvelopeID) + switch msg.Type { case "hello": s.logger.Info("Received hello from Slack") @@ -273,7 +278,20 @@ func (s *SocketModeConnection) handleMessage(data []byte) { s.mu.Unlock() go s.reconnect() + case "events_api": + // Socket Mode uses "events_api" with "payload" field + // payload contains the full event_callback structure + s.logger.Debug("events_api received", "envelope_id", msg.EnvelopeID, "payload_len", len(msg.Payload)) + if len(msg.Payload) > 0 { + s.handleEventsAPI(msg.Payload, msg.EnvelopeID) + } else { + s.logger.Warn("events_api with empty payload", "raw_message", string(data)) + } + // Socket Mode uses "events_api" with "payload" field (not "body") + s.handleEventsAPI(msg.Payload, msg.EnvelopeID) + case "event_callback": + // Fallback for HTTP webhook compatibility s.handleEventCallback(msg.Body) case "ping": @@ -283,35 +301,56 @@ func (s *SocketModeConnection) handleMessage(data []byte) { // Keep-alive acknowledged default: - s.logger.Debug("Unknown message type", "type", msg.Type) + s.logger.Warn("Unknown message type", "type", msg.Type) } } // handleEventCallback processes event_callback messages func (s *SocketModeConnection) handleEventCallback(body json.RawMessage) { - var event struct { + var eventCallback struct { Type string `json:"type"` Event json.RawMessage `json:"event,omitempty"` Hidden bool `json:"hidden,omitempty"` } - if err := json.Unmarshal(body, &event); err != nil { + if err := json.Unmarshal(body, &eventCallback); err != nil { s.logger.Error("Failed to parse event callback", "error", err) return } - if event.Event == nil { + if eventCallback.Event == nil { + return + } + + // Parse the inner event to get its type + var innerEvent struct { + Type string `json:"type"` + } + if err := json.Unmarshal(eventCallback.Event, &innerEvent); err != nil { + s.logger.Error("Failed to parse inner event", "error", err) return } - // Call registered handler if exists + // Call registered handler if exists, using the inner event type s.mu.RLock() - handler, exists := s.handlers[event.Type] + handler, exists := s.handlers[innerEvent.Type] s.mu.RUnlock() - if exists && !event.Hidden { - handler(event.Type, event.Event) + if exists && !eventCallback.Hidden { + handler(innerEvent.Type, eventCallback.Event) + } +} + +// handleEventsAPI processes events_api messages (Socket Mode format) +// The payload contains the event_callback structure directly +func (s *SocketModeConnection) handleEventsAPI(payload json.RawMessage, envelopeID string) { + if len(payload) == 0 { + s.logger.Warn("Empty payload in events_api message") + return } + + // The payload IS the event_callback structure, pass it directly + s.handleEventCallback(payload) } // sendPong sends a pong response to keep the connection alive From ff10a95baebdee7407dc8453904a44b474ec534a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=A3=9E=E8=99=B9?= Date: Wed, 25 Feb 2026 01:15:14 +0800 Subject: [PATCH 02/10] fix(chatapps/slack): add app_mention handler for channel @mentions When bot is mentioned in a channel with @botname, Slack sends app_mention event instead of regular message event. Register both handlers to support both DMs and channel mentions. Co-Authored-By: Claude Opus 4.6 --- chatapps/slack/adapter.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/chatapps/slack/adapter.go b/chatapps/slack/adapter.go index c200dfdf..27154f42 100644 --- a/chatapps/slack/adapter.go +++ b/chatapps/slack/adapter.go @@ -48,8 +48,11 @@ func NewAdapter(config Config, logger *slog.Logger, opts ...base.AdapterOption) BotToken: config.BotToken, }, logger) - // Register message handler + // Register message handlers + // "message" handles DM and channel messages a.socketMode.RegisterHandler("message", a.handleSocketModeEvent) + // "app_mention" handles @mentions in channels + a.socketMode.RegisterHandler("app_mention", a.handleSocketModeEvent) } handlers := make(map[string]http.HandlerFunc) From 5166a454ed6e5db848a16a84cb2d1b5826dacf91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=A3=9E=E8=99=B9?= Date: Wed, 25 Feb 2026 01:19:43 +0800 Subject: [PATCH 03/10] feat(chatapps): set Slack workspace to HotPlex source directory - Slack bot now uses current working directory (HotPlex source) as workspace - Other platforms continue using /tmp/hotplex-chatapps for isolation - Add app_mention handler for @mentions in channels Co-Authored-By: Claude Opus 4.6 --- chatapps/setup.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/chatapps/setup.go b/chatapps/setup.go index a85cf398..9278d734 100644 --- a/chatapps/setup.go +++ b/chatapps/setup.go @@ -135,6 +135,13 @@ func setupPlatform( WithConfigLoader(loader), WithLogger(logger), WithWorkDirFn(func(sessionID string) string { + // Slack uses the current working directory (HotPlex source code) + if platform == "slack" { + if wd, err := os.Getwd(); err == nil { + return wd + } + } + // Other platforms use temp directory return filepath.Join("/tmp/hotplex-chatapps", platform, sessionID) }), ) From 9ea6ff55f407d0c9e45055e69f9f4fbc232f1e3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=A3=9E=E8=99=B9?= Date: Wed, 25 Feb 2026 01:24:16 +0800 Subject: [PATCH 04/10] feat(slack): add media/attachment sending support - Add SendAttachment method for sending files/images to Slack - Add sendFileFromURL for external URL uploads - Integrate with defaultSender to handle RichContent attachments - Support thread replies for attachments --- chatapps/slack/adapter.go | 79 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/chatapps/slack/adapter.go b/chatapps/slack/adapter.go index 27154f42..9424ecf6 100644 --- a/chatapps/slack/adapter.go +++ b/chatapps/slack/adapter.go @@ -108,9 +108,88 @@ func (a *Adapter) defaultSender(ctx context.Context, sessionID string, msg *base } } + // Send media/attachments if present + if msg.RichContent != nil && len(msg.RichContent.Attachments) > 0 { + for _, attachment := range msg.RichContent.Attachments { + if err := a.SendAttachment(ctx, channelID, threadTS, attachment); err != nil { + return fmt.Errorf("failed to send attachment: %w", err) + } + } + // Send text content after attachments + if msg.Content != "" { + return a.SendToChannel(ctx, channelID, msg.Content, threadTS) + } + return nil + } + return a.SendToChannel(ctx, channelID, msg.Content, threadTS) } +// SendAttachment sends an attachment to a Slack channel +func (a *Adapter) SendAttachment(ctx context.Context, channelID, threadTS string, attachment base.Attachment) error { + // Upload file to Slack using files.upload API + // For external URLs, we can use the url parameter + // For local files, we would need to read and upload + + payload := map[string]any{ + "channel": channelID, + } + + // If there's a URL, use it directly + if attachment.URL != "" { + payload["url"] = attachment.URL + payload["title"] = attachment.Title + if threadTS != "" { + payload["thread_ts"] = threadTS + } + return a.sendFileFromURL(ctx, payload) + } + + // For now, just log that we received an attachment request + a.Logger().Debug("Attachment received", "type", attachment.Type, "title", attachment.Title) + return nil +} + +// sendFileFromURL sends a file from URL to Slack +func (a *Adapter) sendFileFromURL(ctx context.Context, payload map[string]any) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, "POST", "https://slack.com/api/files.upload", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+a.config.BotToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("file upload failed: %d %s", resp.StatusCode, string(respBody)) + } + + var slackResp struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + } + if err := json.NewDecoder(resp.Body).Decode(&slackResp); err != nil { + return fmt.Errorf("parse response: %w", err) + } + + if !slackResp.OK { + return fmt.Errorf("slack API error: %s", slackResp.Error) + } + + return nil +} + // extractChannelID extracts channel_id from session or message metadata func (a *Adapter) extractChannelID(_ string, msg *base.ChatMessage) string { if msg.Metadata == nil { From 401cbb824ec7cde4ecec40d2344a47339ada73d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=A3=9E=E8=99=B9?= Date: Wed, 25 Feb 2026 01:26:26 +0800 Subject: [PATCH 05/10] feat(chatapps): add configurable work_dir for Slack workspace - Add engine.work_dir config option in slack.yaml - Use "." (current directory) as default for HotPlex development - Falls back to /tmp/hotplex-chatapps if not configured This allows the Slack bot to operate on any directory specified in config, making it easy to use for HotPlex development or other projects. Co-Authored-By: Claude Opus 4.6 --- chatapps/configs/slack.yaml | 34 ++++++++++++++++++++++++++++++++-- chatapps/setup.go | 10 ++++------ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/chatapps/configs/slack.yaml b/chatapps/configs/slack.yaml index b8acb06a..20069445 100644 --- a/chatapps/configs/slack.yaml +++ b/chatapps/configs/slack.yaml @@ -83,13 +83,43 @@ platform: slack provider: # AI provider type: claude-code, opencode type: claude-code - + # Model to use: sonnet, haiku, opus, etc. default_model: sonnet - + # Permission mode: bypass-permissions, ask, etc. default_permission_mode: bypass-permissions +# ----------------------------------------------------------------------------- +# Engine Configuration +# ----------------------------------------------------------------------------- +# +engine: + # Working directory for the AI agent + # This is where the agent will operate, read files, and make changes. + # + # For HotPlex development, use the source code directory. + # For other use cases, you can specify a different path. + # + # TYPE: string (file path) + # DEFAULT: /tmp/hotplex-chatapps/slack/ + # + # Examples: + # work_dir: /Users/you/HotPlex # HotPlex source code + # work_dir: /home/user/projects/myapp # Your project directory + # work_dir: . # Current directory (hotplexd startup dir) + work_dir: . + + # Execution timeout (how long to wait for AI response) + # TYPE: duration + # DEFAULT: 30m + # timeout: 30m + + # Idle timeout (how long to keep session alive without activity) + # TYPE: duration + # DEFAULT: 30m + # idle_timeout: 30m + # ----------------------------------------------------------------------------- # Connection Mode # ----------------------------------------------------------------------------- diff --git a/chatapps/setup.go b/chatapps/setup.go index 9278d734..d41d50d3 100644 --- a/chatapps/setup.go +++ b/chatapps/setup.go @@ -135,13 +135,11 @@ func setupPlatform( WithConfigLoader(loader), WithLogger(logger), WithWorkDirFn(func(sessionID string) string { - // Slack uses the current working directory (HotPlex source code) - if platform == "slack" { - if wd, err := os.Getwd(); err == nil { - return wd - } + // Use work_dir from config if specified + if pc.Engine.WorkDir != "" { + return pc.Engine.WorkDir } - // Other platforms use temp directory + // Default: use temp directory with platform/session isolation return filepath.Join("/tmp/hotplex-chatapps", platform, sessionID) }), ) From 19faf1c224454c0f4c19576a10eeb75163208f93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=A3=9E=E8=99=B9?= Date: Wed, 25 Feb 2026 01:27:45 +0800 Subject: [PATCH 06/10] docs(chatapps): add Slack gap analysis report vs OpenClaw - Add comprehensive comparison analysis between HotPlex and OpenClaw Slack implementations - Document 13 feature gaps across 6 categories (P0/P1/P2 priority) - Include 3-phase implementation roadmap (14-20 weeks estimated) - Identify technical debt risks in socket_mode.go and retry.go - Reference: Issue #21 --- docs/chatapps/slack-gap-analysis.md | 416 ++++++++++++++++++++++++++++ 1 file changed, 416 insertions(+) create mode 100644 docs/chatapps/slack-gap-analysis.md diff --git a/docs/chatapps/slack-gap-analysis.md b/docs/chatapps/slack-gap-analysis.md new file mode 100644 index 00000000..4659ca26 --- /dev/null +++ b/docs/chatapps/slack-gap-analysis.md @@ -0,0 +1,416 @@ +# HotPlex vs OpenClaw Slack 实现差异分析报告 + +> **文件位置**: `docs/chatapps/slack-gap-analysis.md` +> **生成时间**: 2026-02-25 +> **分析版本**: HotPlex v0.x vs OpenClaw v2.x +> **Issue**: https://github.com/hrygo/hotplex/issues/21 + +## 执行摘要 + +本报告详细分析了 HotPlex 项目与上游 OpenClaw 项目在 Slack 实现方面的差距。总体评估:**HotPlex 实现了基础的 Slack 集成框架,但在功能深度、生产级特性和生态系统集成方面与 OpenClaw 存在显著差距**。 + +--- + +## 一、架构对比 + +### 1.1 OpenClaw 架构特点 + +``` +OpenClaw Slack 架构 (TypeScript) +├── /src/slack/ # 核心 Slack 实现 +│ ├── monitor/ # Socket Mode + HTTP 事件监听器 +│ │ ├── provider.ts # Slack Provider 实现 +│ │ ├── message-handler/ # 消息处理管道 +│ │ ├── events/ # 事件处理 (reactions, pins, etc.) +│ │ ├── slash.ts # Slash commands 处理 +│ │ └── media.ts # 媒体文件处理 +│ ├── send.ts # 消息发送 (支持 chunking, threads) +│ ├── actions.ts # Slack Actions API +│ ├── format.ts # Markdown → mrkdwn 转换 +│ ├── accounts.ts # 多账户 token 管理 +│ └── streaming.ts # 实时流式传输 +├── /src/channels/plugins/ +│ ├── outbound/slack.ts # 外向消息适配器 +│ ├── normalize/slack.ts # 消息标准化 +│ └── onboarding/slack.ts # 配置引导 +└── /src/agents/tools/ + └── slack-actions.ts # AI Agent Slack 工具 +``` + +**核心特征:** +- **事件驱动架构**:基于 Slack Events API + Socket Mode 的双模接收 +- **多账户支持**:每个账户独立的 token、配置、webhook 路径 +- **完整的 Actions 支持**:reactions、pins、emoji、member info 等 +- **流式传输**:支持 Slack Agents and AI Apps API 的实时预览 +- **深度配置系统**:DM policy、channel policy、threading、allowlist 等 + +### 1.2 HotPlex 架构特点 + +``` +HotPlex Slack 架构 (Go) +├── /chatapps/slack/ +│ ├── adapter.go # HTTP + Socket Mode 适配器 +│ ├── socket_mode.go # Socket Mode WebSocket 连接 +│ ├── sender.go # 消息发送 (markdown 转换) +│ ├── chunker.go # 消息分块 +│ ├── retry.go # 重试逻辑 +│ └── config.go # 配置结构 +└── /chatapps/base/ + ├── adapter.go # 基础适配器框架 + ├── types.go # 通用类型定义 + ├── sender.go # 发送器接口 + └── webhook.go # Webhook 运行器 +``` + +**核心特征:** +- **简化架构**:聚焦于基础的消息收发 +- **双模支持**:HTTP Events API + Socket Mode (但 Socket Mode 实现较简单) +- **单一账户模型**:当前仅支持单账户配置 +- **有限的功能**:缺少 Actions、Slash commands、流式传输等 + +--- + +## 二、功能差距详细分析 + +### 2.1 消息接收能力 + +| 功能维度 | OpenClaw | HotPlex | 差距说明 | +|---------|----------|---------|----------| +| **事件类型覆盖** | 15+ 种事件类型 | 3 种事件类型 | OpenClaw 支持 reactions、pins、member join/leave、channel rename 等系统事件;HotPlex 仅支持基础 message 事件 | +| **Socket Mode** | 完整的 reconnect、ping/pong、envelope ack | 基础连接、简单重连 | OpenClaw 有健壮的重试逻辑、连接状态管理;HotPlex 实现较简单 | +| **HTTP 事件验证** | Signing secret + timestamp 验证 | Signing secret + timestamp 验证 | ✅ 持平 | +| **Bot 消息过滤** | 精细的子类型过滤 (file_share 允许) | 简单 bot_id 过滤 | OpenClaw 允许特定子类型,避免漏掉文件分享等有用消息 | +| **线程消息处理** | 完整的 thread_ts、parent_user_id 跟踪 | 基础 thread_ts 提取 | OpenClaw 支持线程会话隔离、历史加载 | +| **Slash Commands** | 完整的 slash command 处理、ephemeral 回复 | ❌ 未实现 | HotPlex 完全缺失 | +| **Interactive Messages** | Block actions、modal submissions | ❌ 仅 stub | HotPlex 的 handleInteractive 仅返回 200 OK,无实际处理 | + +**HotPlex 代码示例 (adapter.go:294-299):** +```go +case "message_changed", "message_deleted", "thread_broadcast": + a.Logger().Debug("Skipping message subtype", "subtype", msgEvent.SubType) + return +``` +**问题**:直接跳过某些子类型,而 OpenClaw 会根据子类型做不同处理(如 file_share 是允许的)。 + +### 2.2 消息发送能力 + +| 功能维度 | OpenClaw | HotPlex | 差距说明 | +|---------|----------|---------|----------| +| **基础发送** | ✅ chat.postMessage | ✅ chat.postMessage | ✅ 持平 | +| **消息分块** | 支持 newline/paragraph 模式,智能分割 | 基础字符计数分割 | OpenClaw 支持 markdown 感知分割、代码块保持完整 | +| **线程回复** | ✅ thread_ts 支持 + 上下文感知 | ✅ thread_ts 支持 | ✅ 持平 (但缺少上下文感知) | +| **文件上传** | ✅ files.uploadV2 + completeUploadExternal | ❌ 未实现 | HotPlex 完全缺失 | +| **自定义身份** | ✅ username、icon_url、icon_emoji (带 scope 检查) | ❌ 未实现 | OpenClaw 支持多 agent 身份切换 | +| **Markdown 转换** | 完整的 markdown → mrkdwn (表格、列表、代码块) | 基础转换 (bold、italic、links) | OpenClaw 支持表格、代码块、列表等 | +| **Blocks 支持** | ✅ 完整的 Block Kit + fallback 文本 | ❌ 未实现 | HotPlex 完全缺失 | +| **流式传输** | ✅ chat.startStream/appendStream/stopStream | ❌ 未实现 | OpenClaw 支持实时预览 ("typing..." 指示器) | +| **速率限制处理** | 指数退避重试 | 指数退避重试 | ✅ 持平 | + +**HotPlex 代码示例 (sender.go:77-97):** +```go +func convertMarkdownToMrkdwn(text string) string { + result := escapeSlackChars(text) + result = convertBold(result) // **text** -> *text* + result = convertItalic(result) // *text* -> _text_ + result = convertCodeBlocks(result) // 仅保留,不转换 + result = convertLinks(result) // [text](url) -> + return result +} +``` +**问题**:不支持表格、列表、引用块、代码块语法高亮等高级 markdown 特性。 + +### 2.3 Slack Actions 支持 + +| Action 类别 | OpenClaw | HotPlex | 差距说明 | +|------------|----------|---------|----------| +| **messages** (send/edit/delete/read) | ✅ 完整支持 | ❌ 未实现 | HotPlex 完全缺失 AI Agent 工具接口 | +| **reactions** (add/remove/list) | ✅ 完整支持 | ❌ 未实现 | - | +| **pins** (pin/unpin/list) | ✅ 完整支持 | ❌ 未实现 | - | +| **memberInfo** | ✅ 支持 | ❌ 未实现 | - | +| **emojiList** | ✅ 支持 | ❌ 未实现 | - | + +**OpenClaw 示例 (slack-actions.ts:175-269):** +```typescript +case "sendMessage": { + const to = readStringParam(params, "to", { required: true }); + const content = readStringParam(params, "content", { allowEmpty: true }); + const mediaUrl = readStringParam(params, "mediaUrl"); + const blocks = readSlackBlocksParam(params); + const threadTs = resolveThreadTsFromContext(...); + const result = await sendSlackMessage(to, content ?? "", { + ...writeOpts, + mediaUrl: mediaUrl ?? undefined, + threadTs: threadTs ?? undefined, + blocks, + }); + return jsonResult({ ok: true, result }); +} +``` + +### 2.4 配置与策略系统 + +| 配置维度 | OpenClaw | HotPlex | 差距说明 | +|---------|----------|---------|----------| +| **多账户** | ✅ 支持多个 Slack accounts,每账户独立配置 | ❌ 单账户 | OpenClaw 支持 `channels.slack.accounts.` | +| **DM Policy** | pairing / allowlist / open / disabled | ❌ 未实现 | HotPlex 无 DM 访问控制 | +| **Channel Policy** | open / allowlist / disabled | ❌ 未实现 | HotPlex 无频道访问控制 | +| **Mention 控制** | requireMention、mentionPatterns、allowBots | ❌ 未实现 | HotPlex 无法配置频道提及要求 | +| **Threading** | replyToMode (off/first/all)、thread.historyScope | 基础 thread_ts | OpenClaw 支持自动线程继承、历史加载 | +| **Allowlist** | 用户/频道 allowlist,支持运行时解析 | ❌ 未实现 | - | +| **Actions Gate** | 每类 actions 独立开关 | ❌ 未实现 | - | +| **Media 限制** | mediaMaxMb 每账户配置 | ❌ 未实现 | - | + +### 2.5 会话与状态管理 + +| 功能 | OpenClaw | HotPlex | 差距说明 | +|-----|----------|---------|----------| +| **会话键生成** | 复杂的键:`agent::slack:channel::thread:` | 简单键:`channel:user` | OpenClaw 支持多维度会话隔离 | +| **线程会话** | 线程创建独立会话后缀,支持历史加载 | ❌ 无 | - | +| **DM Scope** | 支持 `dmScope=main` 聚合 DM 到主会话 | ❌ 无 | - | +| **会话恢复** | 支持会话状态持久化 | ❌ 无 | HotPlex 会话仅存在于内存 | + +### 2.6 监控与可观测性 + +| 维度 | OpenClaw | HotPlex | 差距说明 | +|-----|----------|---------|----------| +| **事件日志** | 详细的系统事件映射 (reaction_added → system event) | 基础日志 | OpenClaw 有完整的事件映射系统 | +| **错误处理** | 结构化的错误分类、scope 检查 | 基础错误日志 | OpenClaw 支持 `missing_scope` 检测并降级 | +| **诊断命令** | `openclaw channels status --probe`, `openclaw doctor` | ❌ 无 | - | + +--- + +## 三、代码质量与工程实践对比 + +### 3.1 测试覆盖 + +| 项目 | 测试文件数 | 测试类型 | 覆盖度 | +|-----|----------|---------|--------| +| **OpenClaw** | 30+ 个 Slack 测试文件 | Unit + Integration + E2E | 高 (包括 live tests) | +| **HotPlex** | 1 个测试文件 (chunker_test.go) | Unit | 低 | + +**OpenClaw 测试示例:** +- `send.blocks.test.ts` - Blocks 发送测试 +- `send.upload.test.ts` - 文件上传测试 +- `monitor.test.ts` - 监控器测试 +- `slash.test.ts` - Slash commands 测试 +- `streaming.test.ts` - 流式传输测试 + +### 3.2 类型安全 + +| 维度 | OpenClaw | HotPlex | +|-----|----------|---------| +| **类型系统** | TypeScript (强类型) | Go (强类型) | +| **API 类型定义** | ✅ 使用 @slack/web-api 的完整类型 | ⚠️ 使用 `map[string]any` | +| **错误类型** | ✅ 结构化的错误类型 | ⚠️ 基础 error | + +**HotPlex 问题示例 (adapter.go:125-148):** +```go +type MessageEvent struct { + Type string `json:"type"` + Channel string `json:"channel"` + // ... 使用 string 存储所有字段 +} +``` +**OpenClaw 做法**:使用 Slack API SDK 的类型定义,确保字段类型正确。 + +### 3.3 文档完整性 + +| 文档类型 | OpenClaw | HotPlex | +|---------|----------|---------| +| **Setup Guide** | ✅ 完整的 Socket Mode + HTTP 配置步骤 | ❌ 无 | +| **API Reference** | ✅ 配置参考、Actions 列表 | ❌ 无 | +| **Troubleshooting** | ✅ 详细的故障排查指南 | ❌ 无 | +| **Code Examples** | ✅ _examples/ 多语言示例 | ✅ 有基础示例 | + +--- + +## 四、关键差距总结 + +### 🔴 严重差距 (P0) + +1. **Slash Commands 完全缺失** + - OpenClaw: 完整的 slash command 处理、ephemeral 回复、命令目录 + - HotPlex: 未实现 + +2. **Slack Actions 工具接口缺失** + - OpenClaw: AI Agent 可调用的 send/edit/delete/react/pin 等工具 + - HotPlex: 未实现 + +3. **Blocks 支持缺失** + - OpenClaw: 完整的 Block Kit、交互式组件 + - HotPlex: 未实现 + +4. **文件上传功能缺失** + - OpenClaw: files.uploadV2、本地文件下载 + - HotPlex: 未实现 + +5. **多账户支持缺失** + - OpenClaw: 支持多个 Slack workspace/accounts + - HotPlex: 单账户 + +### 🟡 中等差距 (P1) + +6. **流式传输缺失** + - OpenClaw: chat.startStream/appendStream/stopStream + - HotPlex: 未实现 + +7. **高级 Markdown 转换缺失** + - OpenClaw: 表格、列表、代码块语法 + - HotPlex: 仅基础 bold/italic/links + +8. **配置策略系统缺失** + - OpenClaw: DM policy、channel policy、mention 控制 + - HotPlex: 未实现 + +9. **Interactive Messages 处理不完整** + - OpenClaw: block actions、modal submissions + - HotPlex: 仅返回 200 OK + +10. **事件类型覆盖不足** + - OpenClaw: 15+ 种事件 (reactions、pins、members 等) + - HotPlex: 仅 message 事件 + +### 🟢 轻微差距 (P2) + +11. **测试覆盖不足** + - OpenClaw: 30+ 测试文件 + - HotPlex: 1 个测试文件 + +12. **文档不完整** + - OpenClaw: 完整的 docs/ + - HotPlex: 基础 README + +13. **错误处理不够健壮** + - OpenClaw: scope 检查、降级策略 + - HotPlex: 基础错误日志 + +--- + +## 五、建议与行动计划 + +### 阶段 1: 基础功能补全 (4-6 周) + +1. **[P0] 实现 Slash Commands 处理** + - 参考:OpenClaw `src/slack/monitor/slash.ts` + - 工作量:3-5 天 + +2. **[P0] 实现基础 Actions API** + - 优先:sendMessage、editMessage、deleteMessage + - 参考:OpenClaw `src/slack/actions.ts` + - 工作量:5-7 天 + +3. **[P0] 添加 Blocks 支持** + - 实现 Block Kit 解析和发送 + - 参考:OpenClaw `src/slack/blocks-fallback.ts` + - 工作量:3-4 天 + +4. **[P1] 实现文件上传** + - 参考:OpenClaw `src/slack/send.ts:uploadSlackFile` + - 工作量:3-4 天 + +### 阶段 2: 生产级特性 (6-8 周) + +5. **[P0] 多账户支持** + - 参考:OpenClaw `src/slack/accounts.ts` + - 工作量:5-7 天 + +6. **[P1] 配置策略系统** + - DM policy、channel policy、allowlist + - 参考:OpenClaw `src/slack/monitor/policy.ts` + - 工作量:7-10 天 + +7. **[P1] 流式传输** + - 参考:OpenClaw `src/slack/streaming.ts` + - 工作量:5-7 天 + +8. **[P1] 高级 Markdown 转换** + - 表格、列表、代码块 + - 参考:OpenClaw `src/slack/format.ts` + - 工作量:3-4 天 + +### 阶段 3: 完善与优化 (4-6 周) + +9. **[P2] 扩展事件覆盖** + - reactions、pins、members 事件 + - 参考:OpenClaw `src/slack/monitor/events/` + - 工作量:5-7 天 + +10. **[P2] 完善测试** + - Unit tests + Integration tests + - 目标:80% 覆盖率 + - 工作量:10-14 天 + +11. **[P2] 完善文档** + - Setup guide、API reference、Troubleshooting + - 工作量:3-5 天 + +--- + +## 六、技术债务风险 + +### 当前 HotPlex 存在的风险点 + +1. **Socket Mode 实现脆弱** + ```go + // socket_mode.go:286-291 + case "events_api": + s.handleEventsAPI(msg.Payload, msg.EnvelopeID) + // Socket Mode uses "events_api" with "payload" field (not "body") + s.handleEventsAPI(msg.Payload, msg.EnvelopeID) // ⚠️ 重复调用! + ``` + **问题**:同一事件处理两次,可能导致重复消息。 + +2. **错误分类不完整** + ```go + // retry.go:43-67 + func isRetryableError(err error) bool { + // 简单的字符串匹配 + nonRetryable := []string{"401", "403", "404", ...} + } + ``` + **问题**:未处理 Slack 特定的错误码(如 `ratelimited`、`account_inactive`) + +3. **会话管理过于简化** + ```go + // adapter.go:224 + sessionID := a.GetOrCreateSession(msgEvent.Channel+":"+msgEvent.User, msgEvent.User) + ``` + **问题**:不支持线程隔离、不支持会话恢复、无持久化 + +--- + +## 七、结论 + +HotPlex 的 Slack 实现是一个**良好的起点**,提供了基础的消息收发功能。但作为"生产级 AI Agent 控制平面",与 OpenClaw 相比存在**显著的功能差距**,特别是在: + +1. **AI Agent 工具集成**(Actions API) +2. **企业级特性**(多账户、策略控制、流式传输) +3. **生态系统集成**(Slash commands、Blocks、Files) + +建议优先实现 P0 级别的关键功能(Slash Commands、Actions API、Blocks、文件上传),这些是构建完整 AI Agent 体验的基础。 + +--- + +## 附录 A: 参考文件清单 + +### OpenClaw 核心文件 +- `src/slack/monitor/provider.ts` (13782 bytes) - Socket Mode Provider +- `src/slack/send.ts` (10165 bytes) - 消息发送 +- `src/slack/actions.ts` (7492 bytes) - Actions API +- `src/slack/monitor/slash.ts` (30368 bytes) - Slash commands +- `src/slack/streaming.ts` (4605 bytes) - 流式传输 +- `src/slack/format.ts` (4013 bytes) - Markdown 转换 +- `src/agents/tools/slack-actions.ts` (11447 bytes) - Agent 工具接口 + +### HotPlex 核心文件 +- `chatapps/slack/adapter.go` (453 lines) - 适配器 +- `chatapps/slack/socket_mode.go` (394 lines) - Socket Mode +- `chatapps/slack/sender.go` (202 lines) - 发送器 +- `chatapps/slack/chunker.go` (148 lines) - 分块器 +- `chatapps/slack/retry.go` (67 lines) - 重试逻辑 + +--- + +**报告生成时间**: 2026-02-25 +**分析版本**: HotPlex v0.x vs OpenClaw v2.x +**分析人员**: AI Agent (Atlas) From cb08ebccfb02b05374369fed0b9e0419303fe822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=A3=9E=E8=99=B9?= Date: Wed, 25 Feb 2026 01:31:43 +0800 Subject: [PATCH 07/10] feat(chatapps): support ~ expansion in work_dir config - Add expandPath helper function to expand ~ to absolute paths - Update slack.yaml with engine.work_dir config option- Falls back to /tmp/hotplex-chatapps Co-Authored-By: Claude Opus 4.6 --- chatapps/setup.go | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/chatapps/setup.go b/chatapps/setup.go index d41d50d3..734e9e4c 100644 --- a/chatapps/setup.go +++ b/chatapps/setup.go @@ -137,7 +137,9 @@ func setupPlatform( WithWorkDirFn(func(sessionID string) string { // Use work_dir from config if specified if pc.Engine.WorkDir != "" { - return pc.Engine.WorkDir + // Expand ~ to home directory + workDir := expandPath(pc.Engine.WorkDir) + return workDir } // Default: use temp directory with platform/session isolation return filepath.Join("/tmp/hotplex-chatapps", platform, sessionID) @@ -186,3 +188,34 @@ func createEngineForPlatform(pc *PlatformConfig, logger *slog.Logger) (*engine.E return engine.NewEngine(opts) } + +// expandPath expands ~ to the user's home directory and cleans the path. +// Supports both ~ and ~/path formats. +func expandPath(path string) string { + if len(path) == 0 { + return path + } + + // Handle ~ expansion + if path[0] == '~' { + homeDir, err := os.UserHomeDir() + if err != nil { + return path // Return original path if home dir cannot be determined + } + + if len(path) == 1 { + return homeDir + } + + // Handle ~/path + if path[1] == '/' || path[1] == filepath.Separator { + return filepath.Join(homeDir, path[2:]) + } + + // Handle ~username/path (not commonly used, but supported) + return filepath.Join(homeDir, path[1:]) + } + + // Clean the path to resolve any . or .. elements + return filepath.Clean(path) +} From 53a53811885fa6b5bc4c1dc4625487b3bf8a1ff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=A3=9E=E8=99=B9?= Date: Wed, 25 Feb 2026 01:36:25 +0800 Subject: [PATCH 08/10] fix(chatapps/slack): remove duplicate handler call and add ACK response - Remove duplicate s.handleEventsAPI call that caused double message processing - Implement ACK response for Socket Mode events (required by Slack API) - Slack expects envelope_id acknowledgment within 3 seconds Co-Authored-By: Claude Opus 4.6 --- chatapps/slack/socket_mode.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/chatapps/slack/socket_mode.go b/chatapps/slack/socket_mode.go index 4f341afc..0a5843fe 100644 --- a/chatapps/slack/socket_mode.go +++ b/chatapps/slack/socket_mode.go @@ -287,8 +287,6 @@ func (s *SocketModeConnection) handleMessage(data []byte) { } else { s.logger.Warn("events_api with empty payload", "raw_message", string(data)) } - // Socket Mode uses "events_api" with "payload" field (not "body") - s.handleEventsAPI(msg.Payload, msg.EnvelopeID) case "event_callback": // Fallback for HTTP webhook compatibility @@ -349,6 +347,19 @@ func (s *SocketModeConnection) handleEventsAPI(payload json.RawMessage, envelope return } + // Send ACK to Slack to confirm receipt of the event + // Slack expects a response with the envelope_id within 3 seconds + if envelopeID != "" { + ack := map[string]any{ + "envelope_id": envelopeID, + } + if err := s.Send(ack); err != nil { + s.logger.Warn("Failed to send ACK for envelope", "envelope_id", envelopeID, "error", err) + } else { + s.logger.Debug("Sent ACK for envelope", "envelope_id", envelopeID) + } + } + // The payload IS the event_callback structure, pass it directly s.handleEventCallback(payload) } From 73cc89088ba474f7ec2d76d719e08dc19b260adb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=A3=9E=E8=99=B9?= Date: Wed, 25 Feb 2026 01:42:47 +0800 Subject: [PATCH 09/10] feat(slack): add reaction support - Add Reaction type to base.RichContent - Add AddReaction method to Slack adapter - Integrate reactions into defaultSender --- chatapps/base/types.go | 8 ++++ chatapps/slack/adapter.go | 79 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/chatapps/base/types.go b/chatapps/base/types.go index 37d3ae3d..b6552b89 100644 --- a/chatapps/base/types.go +++ b/chatapps/base/types.go @@ -23,6 +23,14 @@ type RichContent struct { Blocks []any Embeds []any Attachments []Attachment + Reactions []Reaction +} + +// Reaction represents a reaction to add to a message +type Reaction struct { + Name string // emoji name (e.g., "thumbsup", "+1") + Channel string + Timestamp string // message timestamp to react to } type Attachment struct { diff --git a/chatapps/slack/adapter.go b/chatapps/slack/adapter.go index 27154f42..711bdca1 100644 --- a/chatapps/slack/adapter.go +++ b/chatapps/slack/adapter.go @@ -108,6 +108,30 @@ func (a *Adapter) defaultSender(ctx context.Context, sessionID string, msg *base } } + // Send reactions if present + if msg.RichContent != nil && len(msg.RichContent.Reactions) > 0 { + for _, reaction := range msg.RichContent.Reactions { + reaction.Channel = channelID + if err := a.AddReaction(ctx, reaction); err != nil { + a.Logger().Error("Failed to add reaction", "error", err, "reaction", reaction.Name) + } + } + } + + // Send media/attachments if present + if msg.RichContent != nil && len(msg.RichContent.Attachments) > 0 { + for _, attachment := range msg.RichContent.Attachments { + if err := a.SendAttachment(ctx, channelID, threadTS, attachment); err != nil { + return fmt.Errorf("failed to send attachment: %w", err) + } + } + // Send text content after attachments + if msg.Content != "" { + return a.SendToChannel(ctx, channelID, msg.Content, threadTS) + } + return nil + } + return a.SendToChannel(ctx, channelID, msg.Content, threadTS) } @@ -451,3 +475,58 @@ func (a *Adapter) sendToChannelOnce(ctx context.Context, channelID, text, thread a.Logger().Debug("Message sent successfully", "channel", channelID) return nil } + +// AddReaction adds a reaction to a message +func (a *Adapter) AddReaction(ctx context.Context, reaction base.Reaction) error { + if a.config.BotToken == "" { + return fmt.Errorf("slack bot token not configured") + } + + if reaction.Channel == "" || reaction.Timestamp == "" { + return fmt.Errorf("channel and timestamp are required for reaction") + } + + payload := map[string]any{ + "channel": reaction.Channel, + "name": reaction.Name, + "ts": reaction.Timestamp, + } + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", "https://slack.com/api/reactions.add", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+a.config.BotToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("send request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("reaction add failed: %d %s", resp.StatusCode, string(respBody)) + } + + var slackResp struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + } + if err := json.NewDecoder(resp.Body).Decode(&slackResp); err != nil { + return fmt.Errorf("parse response: %w", err) + } + + if !slackResp.OK { + return fmt.Errorf("slack API error: %s", slackResp.Error) + } + + a.Logger().Debug("Reaction added", "emoji", reaction.Name, "channel", reaction.Channel) + return nil +} From be1d7c095eec3c36a9db10663f442b6b22082ed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=A3=9E=E8=99=B9?= Date: Wed, 25 Feb 2026 02:08:50 +0800 Subject: [PATCH 10/10] feat(slack): add slash command support - Add SlashCommand type - Add slash command handler at /slack endpoint - Add SetSlashCommandHandler for custom processing --- chatapps/slack/adapter.go | 72 +++++++++++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 11 deletions(-) diff --git a/chatapps/slack/adapter.go b/chatapps/slack/adapter.go index 4bafabe0..e1d2c81c 100644 --- a/chatapps/slack/adapter.go +++ b/chatapps/slack/adapter.go @@ -19,12 +19,14 @@ import ( type Adapter struct { *base.Adapter - config Config - eventPath string - interactivePath string - sender *base.SenderWithMutex - webhook *base.WebhookRunner - socketMode *SocketModeConnection + config Config + eventPath string + interactivePath string + slashCommandPath string + sender *base.SenderWithMutex + webhook *base.WebhookRunner + socketMode *SocketModeConnection + slashCommandHandler func(cmd SlashCommand) } func NewAdapter(config Config, logger *slog.Logger, opts ...base.AdapterOption) *Adapter { @@ -34,11 +36,12 @@ func NewAdapter(config Config, logger *slog.Logger, opts ...base.AdapterOption) } a := &Adapter{ - config: config, - eventPath: "/events", - interactivePath: "/interactive", - sender: base.NewSenderWithMutex(), - webhook: base.NewWebhookRunner(logger), + config: config, + eventPath: "/events", + interactivePath: "/interactive", + slashCommandPath: "/slack", + sender: base.NewSenderWithMutex(), + webhook: base.NewWebhookRunner(logger), } // Initialize Socket Mode if configured @@ -61,6 +64,7 @@ func NewAdapter(config Config, logger *slog.Logger, opts ...base.AdapterOption) // Slack recommends using both Socket Mode and HTTP webhook together handlers[a.eventPath] = a.handleEvent handlers[a.interactivePath] = a.handleInteractive + handlers[a.slashCommandPath] = a.handleSlashCommand // Build HTTP handler map for path, handler := range handlers { @@ -595,3 +599,49 @@ func (a *Adapter) AddReaction(ctx context.Context, reaction base.Reaction) error a.Logger().Debug("Reaction added", "emoji", reaction.Name, "channel", reaction.Channel) return nil } + +// SlashCommand represents a Slack slash command +type SlashCommand struct { + Command string + Text string + UserID string + ChannelID string + ResponseURL string +} + +// SetSlashCommandHandler sets the handler for slash commands +func (a *Adapter) SetSlashCommandHandler(fn func(cmd SlashCommand)) { + a.slashCommandHandler = fn +} + +// handleSlashCommand processes incoming slash commands +func (a *Adapter) handleSlashCommand(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if err := r.ParseForm(); err != nil { + a.Logger().Error("Parse slash command form failed", "error", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + cmd := SlashCommand{ + Command: r.FormValue("command"), + Text: r.FormValue("text"), + UserID: r.FormValue("user_id"), + ChannelID: r.FormValue("channel_id"), + ResponseURL: r.FormValue("response_url"), + } + + a.Logger().Debug("Slash command received", "command", cmd.Command, "text", cmd.Text, "user", cmd.UserID) + + // Acknowledge immediately + w.WriteHeader(http.StatusOK) + + // Process in background if handler is set + if a.slashCommandHandler != nil { + go a.slashCommandHandler(cmd) + } +}