From bed6152d46dd24540e04b8ebdcc4d42506aaffb3 Mon Sep 17 00:00:00 2001 From: Cameron Hotchkies Date: Tue, 24 Mar 2026 13:51:25 -0700 Subject: [PATCH] feat: add Slack channel import as a document source --- backend/internal/api/handlers/processing.go | 83 ++++- backend/internal/api/handlers/slack.go | 98 ++++++ backend/internal/api/router.go | 4 + backend/internal/config/config.go | 10 + backend/internal/domain/models/document.go | 18 + backend/internal/services/slack/client.go | 299 +++++++++++++++++ backend/internal/services/slack/extractor.go | 310 ++++++++++++++++++ .../internal/services/slack/extractor_test.go | 195 +++++++++++ docs/screenshots/slack-import-modal.png | Bin 0 -> 32047 bytes frontend/src/api/client.ts | 12 + frontend/src/api/types.ts | 7 + .../documents/AddSlackChannelModal.tsx | 141 ++++++++ frontend/src/pages/SessionPage.tsx | 19 ++ 13 files changed, 1194 insertions(+), 2 deletions(-) create mode 100644 backend/internal/api/handlers/slack.go create mode 100644 backend/internal/services/slack/client.go create mode 100644 backend/internal/services/slack/extractor.go create mode 100644 backend/internal/services/slack/extractor_test.go create mode 100644 docs/screenshots/slack-import-modal.png create mode 100644 frontend/src/components/documents/AddSlackChannelModal.tsx diff --git a/backend/internal/api/handlers/processing.go b/backend/internal/api/handlers/processing.go index c701514..3994541 100644 --- a/backend/internal/api/handlers/processing.go +++ b/backend/internal/api/handlers/processing.go @@ -18,6 +18,7 @@ import ( "github.com/nesposito/frfr/internal/services/extraction" "github.com/nesposito/frfr/internal/services/pdf" "github.com/nesposito/frfr/internal/services/session" + slackext "github.com/nesposito/frfr/internal/services/slack" ) // ProcessingHandler handles processing-related API requests @@ -249,9 +250,9 @@ func (h *ProcessingHandler) processDocuments(sessionID string, documents []strin Progress: float64(i) / float64(totalDocs), }) - // Step 1: Extract text from document + // Step 1: Extract text based on document source textFile := filepath.Join(sessionDir, "text", docName+".txt") - textContent, err := h.extractFileText(ctx, sessionID, docName, docInfo, textFile) + textContent, err := h.extractText(ctx, sessionID, docName, docInfo, textFile) if err != nil { continue } @@ -375,6 +376,84 @@ func (h *ProcessingHandler) processDocuments(sessionID string, documents []strin }) } +// extractText extracts text content from a document, dispatching to the appropriate +// source-specific method. On failure it updates the document status and broadcasts +// an error event, so callers can simply `continue` on error. +func (h *ProcessingHandler) extractText(ctx context.Context, sessionID, docName string, docInfo models.DocumentInfo, textFile string) (string, error) { + if docInfo.Source == models.DocumentSourceSlack { + return h.extractSlackText(ctx, sessionID, docName, docInfo, textFile) + } + return h.extractFileText(ctx, sessionID, docName, docInfo, textFile) +} + +// extractSlackText fetches messages from a Slack channel and returns the text content. +func (h *ProcessingHandler) extractSlackText(ctx context.Context, sessionID, docName string, docInfo models.DocumentInfo, textFile string) (string, error) { + if docInfo.SlackMeta == nil { + h.store.UpdateDocumentStatus(sessionID, docName, models.DocumentStatusFailed, "missing slack metadata") + h.broadcast(sessionID, models.ProcessingEvent{ + Type: models.EventTypeError, + Timestamp: time.Now(), + Document: docName, + Message: "Slack document missing metadata", + }) + return "", fmt.Errorf("missing slack metadata") + } + + h.broadcast(sessionID, models.ProcessingEvent{ + Type: "slack_extraction_start", + Timestamp: time.Now(), + Document: docName, + Message: fmt.Sprintf("Fetching messages from Slack channel #%s...", docInfo.SlackMeta.ChannelName), + }) + + slackExtractor := slackext.NewExtractor(h.config.SlackBotToken, h.config.SlackMaxMessages, h.config.SlackLookbackDays) + + opts := slackext.ExtractOptions{IncludeThreads: true} + if docInfo.SlackMeta.Since != "" { + if t, err := time.Parse("2006-01-02", docInfo.SlackMeta.Since); err == nil { + opts.Since = t + } + } + if docInfo.SlackMeta.Until != "" { + if t, err := time.Parse("2006-01-02", docInfo.SlackMeta.Until); err == nil { + opts.Until = t + } + } + + result, err := slackExtractor.Extract(ctx, docInfo.SlackMeta.ChannelID, textFile, opts) + if err != nil { + h.store.UpdateDocumentStatus(sessionID, docName, models.DocumentStatusFailed, err.Error()) + h.broadcast(sessionID, models.ProcessingEvent{ + Type: models.EventTypeError, + Timestamp: time.Now(), + Document: docName, + Message: fmt.Sprintf("Slack extraction failed: %v", err), + }) + return "", err + } + + h.broadcast(sessionID, models.ProcessingEvent{ + Type: "slack_extraction_complete", + Timestamp: time.Now(), + Document: docName, + Message: fmt.Sprintf("Extracted %d messages (%d threads), %d characters from #%s", + result.MessageCount, result.ThreadCount, result.TotalChars, result.ChannelName), + }) + + data, err := os.ReadFile(textFile) + if err != nil { + h.store.UpdateDocumentStatus(sessionID, docName, models.DocumentStatusFailed, err.Error()) + h.broadcast(sessionID, models.ProcessingEvent{ + Type: models.EventTypeError, + Timestamp: time.Now(), + Document: docName, + Message: fmt.Sprintf("Failed to read extracted text: %v", err), + }) + return "", err + } + return string(data), nil +} + // extractFileText extracts text from a PDF or reads a plain text/markdown file. func (h *ProcessingHandler) extractFileText(ctx context.Context, sessionID, docName string, docInfo models.DocumentInfo, textFile string) (string, error) { pdfPath := expandTilde(docInfo.OriginalPDFPath) diff --git a/backend/internal/api/handlers/slack.go b/backend/internal/api/handlers/slack.go new file mode 100644 index 0000000..3e5c188 --- /dev/null +++ b/backend/internal/api/handlers/slack.go @@ -0,0 +1,98 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/nesposito/frfr/internal/config" + "github.com/nesposito/frfr/internal/domain/models" + "github.com/nesposito/frfr/internal/services/session" +) + +// SlackHandler handles Slack-related API requests +type SlackHandler struct { + store *session.Store + config *config.Config +} + +// NewSlackHandler creates a new Slack handler +func NewSlackHandler(store *session.Store, cfg *config.Config) *SlackHandler { + return &SlackHandler{store: store, config: cfg} +} + +// AddSlackChannelRequest is the request body for importing a Slack channel +type AddSlackChannelRequest struct { + ChannelID string `json:"channel_id"` + Token string `json:"token,omitempty"` // Optional; falls back to SLACK_BOT_TOKEN env + Since string `json:"since,omitempty"` // Date string: "2025-01-01" + Until string `json:"until,omitempty"` // Date string: "2025-03-01" +} + +// Add imports a Slack channel as a document source in a session +func (h *SlackHandler) Add(w http.ResponseWriter, r *http.Request) { + sessionID := r.PathValue("id") + if sessionID == "" { + writeError(w, http.StatusBadRequest, "Session ID is required") + return + } + + var req AddSlackChannelRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "Invalid request body: "+err.Error()) + return + } + + if req.ChannelID == "" { + writeError(w, http.StatusBadRequest, "channel_id is required") + return + } + + // Check that we have a token somewhere + token := req.Token + if token == "" { + token = h.config.SlackBotToken + } + if token == "" { + writeError(w, http.StatusBadRequest, "No Slack token provided. Set SLACK_BOT_TOKEN env or pass 'token' in request.") + return + } + + // Get session + sess, err := h.store.Get(sessionID) + if err != nil { + if strings.Contains(err.Error(), "not found") { + writeError(w, http.StatusNotFound, err.Error()) + } else { + writeError(w, http.StatusInternalServerError, "Failed to get session: "+err.Error()) + } + return + } + + // Create document name from channel ID + docName := "slack-" + req.ChannelID + + // Register as a document in the session + if sess.DocumentRegistry == nil { + sess.DocumentRegistry = make(map[string]models.DocumentInfo) + } + + sess.DocumentRegistry[docName] = models.DocumentInfo{ + Status: models.DocumentStatusPending, + AddedAt: models.FlexibleTime{Time: time.Now()}, + Source: models.DocumentSourceSlack, + SlackMeta: &models.SlackDocumentMeta{ + ChannelID: req.ChannelID, + Since: req.Since, + Until: req.Until, + }, + } + + if err := h.store.Update(sess); err != nil { + writeError(w, http.StatusInternalServerError, "Failed to update session: "+err.Error()) + return + } + + writeJSON(w, http.StatusCreated, sess.DocumentRegistry[docName]) +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 1e8d80b..aab1a97 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -36,6 +36,7 @@ func (s *Server) registerRoutes() { factsHandler := handlers.NewFactsHandler(s.sessionStore) processingHandler := handlers.NewProcessingHandler(s.sessionStore, s.config) queryHandler := handlers.NewQueryHandler(s.sessionStore, s.config) + slackHandler := handlers.NewSlackHandler(s.sessionStore, s.config) filePickerHandler := handlers.NewFilePickerHandler() claudeHandler := handlers.NewClaudeHandler(s.config) @@ -60,6 +61,9 @@ func (s *Server) registerRoutes() { s.mux.HandleFunc("POST /api/sessions/{id}/query/stream", queryHandler.SubmitStream) s.mux.HandleFunc("GET /api/sessions/{id}/query/history", queryHandler.History) + // Slack + s.mux.HandleFunc("POST /api/sessions/{id}/slack", slackHandler.Add) + // Processing s.mux.HandleFunc("POST /api/sessions/{id}/process", processingHandler.Start) s.mux.HandleFunc("GET /api/sessions/{id}/process/events", processingHandler.Events) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 0c466c0..3ce192d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -30,6 +30,11 @@ type Config struct { AnthropicAPIKey string MaxWorkers int MaxRetries int + + // Slack settings + SlackBotToken string + SlackMaxMessages int + SlackLookbackDays int } // DefaultConfig returns the default configuration @@ -59,6 +64,11 @@ func DefaultConfig() *Config { AnthropicAPIKey: getAnthropicAPIKey(), MaxWorkers: getEnvInt("FRFR_MAX_WORKERS", 20), MaxRetries: getEnvInt("FRFR_MAX_RETRIES", 3), + + // Slack + SlackBotToken: os.Getenv("SLACK_BOT_TOKEN"), + SlackMaxMessages: getEnvInt("FRFR_SLACK_MAX_MESSAGES", 1000), + SlackLookbackDays: getEnvInt("FRFR_SLACK_LOOKBACK_DAYS", 90), } } diff --git a/backend/internal/domain/models/document.go b/backend/internal/domain/models/document.go index ade7d63..50d0915 100644 --- a/backend/internal/domain/models/document.go +++ b/backend/internal/domain/models/document.go @@ -10,6 +10,22 @@ const ( DocumentStatusFailed DocumentStatus = "failed" ) +// DocumentSource indicates where a document came from +type DocumentSource string + +const ( + DocumentSourceFile DocumentSource = "" // Default: local file (PDF/Markdown) + DocumentSourceSlack DocumentSource = "slack" +) + +// SlackDocumentMeta contains Slack-specific metadata for a document +type SlackDocumentMeta struct { + ChannelID string `json:"channel_id"` + ChannelName string `json:"channel_name,omitempty"` + Since string `json:"since,omitempty"` + Until string `json:"until,omitempty"` +} + // DocumentInfo contains metadata about a document in a session type DocumentInfo struct { OriginalPDFPath string `json:"original_pdf_path"` @@ -20,6 +36,8 @@ type DocumentInfo struct { AddedAt FlexibleTime `json:"added_at"` CompletedAt *FlexibleTime `json:"completed_at,omitempty"` ErrorMessage string `json:"error_message,omitempty"` + Source DocumentSource `json:"source,omitempty"` + SlackMeta *SlackDocumentMeta `json:"slack_meta,omitempty"` } // DocumentSummary contains the LLM-generated summary of a document diff --git a/backend/internal/services/slack/client.go b/backend/internal/services/slack/client.go new file mode 100644 index 0000000..bf9b1d2 --- /dev/null +++ b/backend/internal/services/slack/client.go @@ -0,0 +1,299 @@ +package slack + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// Client is a thin Slack Web API client. +type Client struct { + token string + httpClient *http.Client +} + +// NewClient creates a new Slack API client with the given bot token. +func NewClient(token string) *Client { + return &Client{ + token: token, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// Message represents a Slack message. +type Message struct { + User string `json:"user"` + Text string `json:"text"` + Timestamp string `json:"ts"` + ThreadTS string `json:"thread_ts,omitempty"` + ReplyCount int `json:"reply_count,omitempty"` + SubType string `json:"subtype,omitempty"` +} + +// User represents a Slack user profile. +type User struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + RealName string `json:"real_name"` +} + +// ChannelInfo represents basic Slack channel metadata. +type ChannelInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Topic string `json:"topic"` + Purpose string `json:"purpose"` + MemberCount int `json:"num_members"` +} + +// conversationsHistoryResponse is the Slack API response for conversations.history. +type conversationsHistoryResponse struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Messages []Message `json:"messages"` + HasMore bool `json:"has_more"` + ResponseMetadata struct { + NextCursor string `json:"next_cursor"` + } `json:"response_metadata"` +} + +// conversationsRepliesResponse is the Slack API response for conversations.replies. +type conversationsRepliesResponse struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Messages []Message `json:"messages"` + HasMore bool `json:"has_more"` + ResponseMetadata struct { + NextCursor string `json:"next_cursor"` + } `json:"response_metadata"` +} + +// conversationsInfoResponse is the Slack API response for conversations.info. +type conversationsInfoResponse struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Channel struct { + ID string `json:"id"` + Name string `json:"name"` + Topic struct { + Value string `json:"value"` + } `json:"topic"` + Purpose struct { + Value string `json:"value"` + } `json:"purpose"` + NumMembers int `json:"num_members"` + } `json:"channel"` +} + +// usersInfoResponse is the Slack API response for users.info. +type usersInfoResponse struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + User struct { + ID string `json:"id"` + Profile struct { + DisplayName string `json:"display_name"` + RealName string `json:"real_name"` + } `json:"profile"` + } `json:"user"` +} + +// GetChannelInfo fetches metadata about a channel. +func (c *Client) GetChannelInfo(ctx context.Context, channelID string) (*ChannelInfo, error) { + params := url.Values{"channel": {channelID}} + body, err := c.apiGet(ctx, "conversations.info", params) + if err != nil { + return nil, err + } + + var resp conversationsInfoResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("failed to parse conversations.info response: %w", err) + } + if !resp.OK { + return nil, fmt.Errorf("slack API error: %s", resp.Error) + } + + return &ChannelInfo{ + ID: resp.Channel.ID, + Name: resp.Channel.Name, + Topic: resp.Channel.Topic.Value, + Purpose: resp.Channel.Purpose.Value, + MemberCount: resp.Channel.NumMembers, + }, nil +} + +// ProbeVolume fetches a single page of history to check if the channel has more +// than `limit` messages. Returns (messageCount, hasMore, error). +func (c *Client) ProbeVolume(ctx context.Context, channelID string, limit int) (int, bool, error) { + params := url.Values{ + "channel": {channelID}, + "limit": {fmt.Sprintf("%d", limit)}, + } + + body, err := c.apiGet(ctx, "conversations.history", params) + if err != nil { + return 0, false, err + } + + var resp conversationsHistoryResponse + if err := json.Unmarshal(body, &resp); err != nil { + return 0, false, fmt.Errorf("failed to parse response: %w", err) + } + if !resp.OK { + return 0, false, fmt.Errorf("slack API error: %s", resp.Error) + } + + return len(resp.Messages), resp.HasMore, nil +} + +// GetHistory fetches channel message history with pagination. +// If since is non-zero, only messages after that time are returned. +func (c *Client) GetHistory(ctx context.Context, channelID string, since, until time.Time) ([]Message, error) { + var allMessages []Message + cursor := "" + + for { + params := url.Values{ + "channel": {channelID}, + "limit": {"200"}, + } + if !since.IsZero() { + params.Set("oldest", fmt.Sprintf("%d.000000", since.Unix())) + } + if !until.IsZero() { + params.Set("latest", fmt.Sprintf("%d.000000", until.Unix())) + } + if cursor != "" { + params.Set("cursor", cursor) + } + + body, err := c.apiGet(ctx, "conversations.history", params) + if err != nil { + return nil, err + } + + var resp conversationsHistoryResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("failed to parse conversations.history response: %w", err) + } + if !resp.OK { + return nil, fmt.Errorf("slack API error: %s", resp.Error) + } + + allMessages = append(allMessages, resp.Messages...) + + if !resp.HasMore || resp.ResponseMetadata.NextCursor == "" { + break + } + cursor = resp.ResponseMetadata.NextCursor + } + + return allMessages, nil +} + +// GetThreadReplies fetches all replies in a thread. +func (c *Client) GetThreadReplies(ctx context.Context, channelID, threadTS string) ([]Message, error) { + var allReplies []Message + cursor := "" + + for { + params := url.Values{ + "channel": {channelID}, + "ts": {threadTS}, + "limit": {"200"}, + } + if cursor != "" { + params.Set("cursor", cursor) + } + + body, err := c.apiGet(ctx, "conversations.replies", params) + if err != nil { + return nil, err + } + + var resp conversationsRepliesResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("failed to parse conversations.replies response: %w", err) + } + if !resp.OK { + return nil, fmt.Errorf("slack API error: %s", resp.Error) + } + + // Skip the first message (parent) — it's already in history + for _, msg := range resp.Messages { + if msg.Timestamp != threadTS { + allReplies = append(allReplies, msg) + } + } + + if !resp.HasMore || resp.ResponseMetadata.NextCursor == "" { + break + } + cursor = resp.ResponseMetadata.NextCursor + } + + return allReplies, nil +} + +// GetUserInfo fetches a user's profile. +func (c *Client) GetUserInfo(ctx context.Context, userID string) (*User, error) { + params := url.Values{"user": {userID}} + body, err := c.apiGet(ctx, "users.info", params) + if err != nil { + return nil, err + } + + var resp usersInfoResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("failed to parse users.info response: %w", err) + } + if !resp.OK { + return nil, fmt.Errorf("slack API error: %s", resp.Error) + } + + name := resp.User.Profile.DisplayName + if name == "" { + name = resp.User.Profile.RealName + } + + return &User{ + ID: resp.User.ID, + DisplayName: name, + RealName: resp.User.Profile.RealName, + }, nil +} + +// apiGet makes an authenticated GET request to a Slack API method. +func (c *Client) apiGet(ctx context.Context, method string, params url.Values) ([]byte, error) { + u := fmt.Sprintf("https://slack.com/api/%s?%s", method, params.Encode()) + + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.token) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("slack API request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("slack API returned status %d: %s", resp.StatusCode, string(body)) + } + + return body, nil +} diff --git a/backend/internal/services/slack/extractor.go b/backend/internal/services/slack/extractor.go new file mode 100644 index 0000000..eb72db6 --- /dev/null +++ b/backend/internal/services/slack/extractor.go @@ -0,0 +1,310 @@ +package slack + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" +) + +// ExtractionResult contains the result of Slack channel text extraction. +type ExtractionResult struct { + Status string `json:"status"` + Method string `json:"method"` + ChannelID string `json:"channel_id"` + ChannelName string `json:"channel_name"` + MessageCount int `json:"message_count"` + ThreadCount int `json:"thread_count"` + TotalChars int `json:"total_chars"` + OutputFile string `json:"output_file"` + Error string `json:"error,omitempty"` + ErrorType string `json:"error_type,omitempty"` +} + +// ExtractOptions configures what to extract from a Slack channel. +type ExtractOptions struct { + Since time.Time + Until time.Time + IncludeThreads bool +} + +// Extractor handles text extraction from Slack channels. +type Extractor struct { + client *Client + userCache map[string]string // userID -> display name + mu sync.Mutex + maxMessages int + lookbackDays int +} + +// NewExtractor creates a new Slack extractor with the given bot token. +func NewExtractor(token string, maxMessages, lookbackDays int) *Extractor { + return &Extractor{ + client: NewClient(token), + userCache: make(map[string]string), + maxMessages: maxMessages, + lookbackDays: lookbackDays, + } +} + +// GetInfo fetches metadata about a Slack channel. +func (e *Extractor) GetInfo(ctx context.Context, channelID string) (*ChannelInfo, error) { + return e.client.GetChannelInfo(ctx, channelID) +} + +// Extract fetches messages from a Slack channel and writes them as text to outputPath. +func (e *Extractor) Extract(ctx context.Context, channelID, outputPath string, opts ExtractOptions) (*ExtractionResult, error) { + // Fetch channel info + info, err := e.client.GetChannelInfo(ctx, channelID) + if err != nil { + return &ExtractionResult{ + Status: "error", + Method: "slack_api", + ChannelID: channelID, + Error: fmt.Sprintf("failed to get channel info: %v", err), + ErrorType: "channel_not_found", + }, fmt.Errorf("failed to get channel info: %w", err) + } + + // If no date range specified, probe volume to decide whether to limit + if opts.Since.IsZero() && opts.Until.IsZero() { + _, hasMore, err := e.client.ProbeVolume(ctx, channelID, e.maxMessages) + if err == nil && hasMore { + // High-volume channel — apply lookback + opts.Since = time.Now().AddDate(0, 0, -e.lookbackDays) + } + // Otherwise fetch everything + } + + // Fetch message history + messages, err := e.client.GetHistory(ctx, channelID, opts.Since, opts.Until) + if err != nil { + return &ExtractionResult{ + Status: "error", + Method: "slack_api", + ChannelID: channelID, + ChannelName: info.Name, + Error: fmt.Sprintf("failed to fetch history: %v", err), + ErrorType: "fetch_failed", + }, fmt.Errorf("failed to fetch channel history: %w", err) + } + + // Sort messages chronologically (Slack returns newest first) + sort.Slice(messages, func(i, j int) bool { + return messages[i].Timestamp < messages[j].Timestamp + }) + + // Filter out subtypes (joins, leaves, etc.) — keep only real messages + var realMessages []Message + for _, msg := range messages { + if msg.SubType == "" || msg.SubType == "file_share" || msg.SubType == "me_message" { + realMessages = append(realMessages, msg) + } + } + + // Render messages to text + var buf strings.Builder + threadCount := 0 + + // Write header + fmt.Fprintf(&buf, "# Slack Channel: #%s\n", info.Name) + if info.Topic != "" { + fmt.Fprintf(&buf, "# Topic: %s\n", info.Topic) + } + if !opts.Since.IsZero() || !opts.Until.IsZero() { + fmt.Fprintf(&buf, "# Date range: %s to %s\n", + formatDateOrOpen(opts.Since, "beginning"), + formatDateOrOpen(opts.Until, "now")) + } + fmt.Fprintf(&buf, "# Messages: %d\n\n", len(realMessages)) + + for _, msg := range realMessages { + userName := e.resolveUser(ctx, msg.User) + ts := parseSlackTimestamp(msg.Timestamp) + permalink := buildPermalink(info.Name, channelID, msg.Timestamp) + + fmt.Fprintf(&buf, "[%s] @%s (%s):\n%s\n\n", + ts.Format("2006-01-02 15:04"), + userName, + permalink, + e.cleanSlackMarkup(ctx, msg.Text), + ) + + // Fetch thread replies if this message has them + if opts.IncludeThreads && msg.ReplyCount > 0 { + replies, err := e.client.GetThreadReplies(ctx, channelID, msg.Timestamp) + if err != nil { + // Log but continue — don't fail the whole extraction for one thread + fmt.Fprintf(&buf, " [thread: failed to fetch %d replies]\n\n", msg.ReplyCount) + continue + } + + threadCount++ + for _, reply := range replies { + replyUser := e.resolveUser(ctx, reply.User) + replyTS := parseSlackTimestamp(reply.Timestamp) + replyPermalink := buildThreadPermalink(info.Name, channelID, reply.Timestamp, msg.Timestamp) + cleanText := e.cleanSlackMarkup(ctx, reply.Text) + + fmt.Fprintf(&buf, " [thread reply %s] @%s (%s):\n %s\n\n", + replyTS.Format("2006-01-02 15:04"), + replyUser, + replyPermalink, + strings.ReplaceAll(cleanText, "\n", "\n "), + ) + } + } + } + + text := buf.String() + + // Ensure output directory exists + if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { + return &ExtractionResult{ + Status: "error", + Method: "slack_api", + ChannelID: channelID, + ChannelName: info.Name, + Error: fmt.Sprintf("failed to create output directory: %v", err), + ErrorType: "io_error", + }, fmt.Errorf("failed to create output directory: %w", err) + } + + if err := os.WriteFile(outputPath, []byte(text), 0644); err != nil { + return &ExtractionResult{ + Status: "error", + Method: "slack_api", + ChannelID: channelID, + ChannelName: info.Name, + Error: fmt.Sprintf("failed to write output file: %v", err), + ErrorType: "io_error", + }, fmt.Errorf("failed to write output file: %w", err) + } + + absOutputPath, _ := filepath.Abs(outputPath) + + return &ExtractionResult{ + Status: "success", + Method: "slack_api", + ChannelID: channelID, + ChannelName: info.Name, + MessageCount: len(realMessages), + ThreadCount: threadCount, + TotalChars: len(text), + OutputFile: absOutputPath, + }, nil +} + +// ExtractToSessionDir extracts Slack channel text and saves it to the session's text directory. +func (e *Extractor) ExtractToSessionDir(ctx context.Context, channelID, sessionDir, docName string, opts ExtractOptions) (*ExtractionResult, error) { + textDir := filepath.Join(sessionDir, "text") + outputPath := filepath.Join(textDir, docName+".txt") + return e.Extract(ctx, channelID, outputPath, opts) +} + +// Slack markup patterns +var ( + // <@U123ABC> user mentions + slackUserMentionRe = regexp.MustCompile(`<@(U[A-Z0-9]+)>`) + // <#C123ABC|channel-name> channel references + slackChannelRefRe = regexp.MustCompile(`<#[A-Z0-9]+\|([^>]+)>`) + // <#C123ABC> channel references without label + slackChannelRefBareRe = regexp.MustCompile(`<#([A-Z0-9]+)>`) + // links with display text + slackLinkLabelRe = regexp.MustCompile(`<(https?://[^|>]+)\|([^>]+)>`) + // bare links + slackLinkBareRe = regexp.MustCompile(`<(https?://[^>]+)>`) +) + +// cleanSlackMarkup converts Slack mrkdwn to plain readable text. +// Keeps emoji shortcodes (e.g. :wave:) intact since they carry meaning. +func (e *Extractor) cleanSlackMarkup(ctx context.Context, text string) string { + // Resolve user mentions: <@U123> → @displayname + text = slackUserMentionRe.ReplaceAllStringFunc(text, func(match string) string { + userID := slackUserMentionRe.FindStringSubmatch(match)[1] + name := e.resolveUser(ctx, userID) + return "@" + name + }) + + // Channel references: <#C123|channel-name> → #channel-name + text = slackChannelRefRe.ReplaceAllString(text, "#$1") + text = slackChannelRefBareRe.ReplaceAllString(text, "#$1") + + // Links with labels: → Example (https://example.com) + text = slackLinkLabelRe.ReplaceAllString(text, "$2 ($1)") + + // Bare links: → https://example.com + text = slackLinkBareRe.ReplaceAllString(text, "$1") + + // HTML entities + text = strings.ReplaceAll(text, "&", "&") + text = strings.ReplaceAll(text, "<", "<") + text = strings.ReplaceAll(text, ">", ">") + + return text +} + +// resolveUser looks up a user's display name, caching results. +func (e *Extractor) resolveUser(ctx context.Context, userID string) string { + if userID == "" { + return "unknown" + } + + e.mu.Lock() + if name, ok := e.userCache[userID]; ok { + e.mu.Unlock() + return name + } + e.mu.Unlock() + + user, err := e.client.GetUserInfo(ctx, userID) + if err != nil { + return userID // Fall back to raw ID + } + + e.mu.Lock() + e.userCache[userID] = user.DisplayName + e.mu.Unlock() + + return user.DisplayName +} + +// parseSlackTimestamp converts a Slack timestamp (e.g., "1234567890.123456") to time.Time. +func parseSlackTimestamp(ts string) time.Time { + parts := strings.SplitN(ts, ".", 2) + if len(parts) == 0 { + return time.Time{} + } + var sec int64 + for _, c := range parts[0] { + sec = sec*10 + int64(c-'0') + } + return time.Unix(sec, 0) +} + +// buildPermalink builds a Slack message permalink. +func buildPermalink(channelName, channelID, messageTS string) string { + // Slack permalinks use the timestamp without the dot + tsNoDot := strings.ReplaceAll(messageTS, ".", "") + return fmt.Sprintf("https://slack.com/archives/%s/p%s", channelID, tsNoDot) +} + +// buildThreadPermalink builds a Slack thread reply permalink. +func buildThreadPermalink(channelName, channelID, replyTS, threadTS string) string { + tsNoDot := strings.ReplaceAll(replyTS, ".", "") + return fmt.Sprintf("https://slack.com/archives/%s/p%s?thread_ts=%s&cid=%s", + channelID, tsNoDot, threadTS, channelID) +} + +// formatDateOrOpen formats a time, returning fallback if zero. +func formatDateOrOpen(t time.Time, fallback string) string { + if t.IsZero() { + return fallback + } + return t.Format("2006-01-02") +} diff --git a/backend/internal/services/slack/extractor_test.go b/backend/internal/services/slack/extractor_test.go new file mode 100644 index 0000000..396edba --- /dev/null +++ b/backend/internal/services/slack/extractor_test.go @@ -0,0 +1,195 @@ +package slack + +import ( + "context" + "testing" +) + +// newTestExtractor creates an extractor with a pre-populated user cache and no real API client. +func newTestExtractor(users map[string]string) *Extractor { + e := NewExtractor("xoxb-fake", 1000, 90) + for k, v := range users { + e.userCache[k] = v + } + return e +} + +func TestCleanSlackMarkup_UserMentions(t *testing.T) { + e := newTestExtractor(map[string]string{"U123ABC": "alice"}) + ctx := context.Background() + + tests := []struct { + name string + input string + want string + }{ + {"resolved mention", "<@U123ABC> said hello", "@alice said hello"}, + {"cached mention", "<@U123ABC> and <@U123ABC>", "@alice and @alice"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := e.cleanSlackMarkup(ctx, tt.input) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestCleanSlackMarkup_Links(t *testing.T) { + e := newTestExtractor(nil) + ctx := context.Background() + + tests := []struct { + name string + input string + want string + }{ + {"labeled link", "", "Example Site (https://example.com)"}, + {"bare link", "", "https://example.com/path"}, + {"link with query params", "", "click here (https://example.com?foo=bar)"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := e.cleanSlackMarkup(ctx, tt.input) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestCleanSlackMarkup_Channels(t *testing.T) { + e := newTestExtractor(nil) + ctx := context.Background() + + tests := []struct { + name string + input string + want string + }{ + {"labeled channel", "<#C0123ABCDEF|general>", "#general"}, + {"bare channel", "<#C0123ABCDEF>", "#C0123ABCDEF"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := e.cleanSlackMarkup(ctx, tt.input) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestCleanSlackMarkup_HTMLEntities(t *testing.T) { + e := newTestExtractor(nil) + ctx := context.Background() + + tests := []struct { + name string + input string + want string + }{ + {"ampersand", "A & B", "A & B"}, + {"less than", "x < y", "x < y"}, + {"greater than", "x > y", "x > y"}, + {"all entities", "<div> & stuff", "
& stuff"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := e.cleanSlackMarkup(ctx, tt.input) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestCleanSlackMarkup_PreservesEmoji(t *testing.T) { + e := newTestExtractor(nil) + ctx := context.Background() + + tests := []struct { + name string + input string + want string + }{ + {"wave emoji", ":wave: hello", ":wave: hello"}, + {"thumbsup", "looks good :+1:", "looks good :+1:"}, + {"multiple emoji", ":fire: :rocket: shipped", ":fire: :rocket: shipped"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := e.cleanSlackMarkup(ctx, tt.input) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestCleanSlackMarkup_Combined(t *testing.T) { + e := newTestExtractor(map[string]string{"U999TESTID": "alice"}) + ctx := context.Background() + + input := ":wave: Hey <@U999TESTID>, check out & <#C0123ABCDEF|data-team>" + want := ":wave: Hey @alice, check out project metrics (https://example.com/metrics) & #data-team" + + got := e.cleanSlackMarkup(ctx, input) + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestParseSlackTimestamp(t *testing.T) { + tests := []struct { + name string + ts string + unix int64 + }{ + {"standard", "1762447128.235749", 1762447128}, + {"round", "1700000000.000000", 1700000000}, + {"no decimal", "1234567890", 1234567890}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseSlackTimestamp(tt.ts) + if got.Unix() != tt.unix { + t.Errorf("got unix %d, want %d", got.Unix(), tt.unix) + } + }) + } +} + +func TestBuildPermalink(t *testing.T) { + got := buildPermalink("general", "C0123ABCDEF", "1762447128.235749") + want := "https://slack.com/archives/C0123ABCDEF/p1762447128235749" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestBuildThreadPermalink(t *testing.T) { + got := buildThreadPermalink("general", "C0123ABCDEF", "1762448407.712439", "1762447128.235749") + want := "https://slack.com/archives/C0123ABCDEF/p1762448407712439?thread_ts=1762447128.235749&cid=C0123ABCDEF" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestExtractOptions_VolumeProbeDefaults(t *testing.T) { + // Verify that NewExtractor stores config values correctly + e := NewExtractor("xoxb-fake", 500, 30) + if e.maxMessages != 500 { + t.Errorf("maxMessages: got %d, want 500", e.maxMessages) + } + if e.lookbackDays != 30 { + t.Errorf("lookbackDays: got %d, want 30", e.lookbackDays) + } +} diff --git a/docs/screenshots/slack-import-modal.png b/docs/screenshots/slack-import-modal.png new file mode 100644 index 0000000000000000000000000000000000000000..22fcc3b8a2ea07aeed4f86f2097cae55e53031a3 GIT binary patch literal 32047 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 literal 0 HcmV?d00001 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 26ddac0..7540226 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -10,6 +10,7 @@ import type { ProcessingEvent, CreateSessionRequest, AddDocumentRequest, + AddSlackChannelRequest, APIError, BatchProgress, QueryStreamCallbacks, @@ -111,6 +112,17 @@ class APIClient { ); } + async addSlackChannel( + sessionId: string, + req: AddSlackChannelRequest + ): Promise { + return this.request( + 'POST', + `/sessions/${sessionId}/slack`, + req + ); + } + async reprocessDocument( sessionId: string, docName: string diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 6a34ee4..1f901be 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -160,6 +160,13 @@ export interface AddDocumentRequest { name?: string; } +export interface AddSlackChannelRequest { + channel_id: string; + token?: string; + since?: string; + until?: string; +} + // Claude status export interface ClaudeStatusResponse { available: boolean; diff --git a/frontend/src/components/documents/AddSlackChannelModal.tsx b/frontend/src/components/documents/AddSlackChannelModal.tsx new file mode 100644 index 0000000..7cf2731 --- /dev/null +++ b/frontend/src/components/documents/AddSlackChannelModal.tsx @@ -0,0 +1,141 @@ +import { useState } from 'react'; +import { api } from '../../api/client'; + +interface Props { + sessionId: string; + onClose: () => void; + onAdded: () => void; +} + +function AddSlackChannelModal({ sessionId, onClose, onAdded }: Props) { + const [channelId, setChannelId] = useState(''); + const [since, setSince] = useState(''); + const [until, setUntil] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const id = parseChannelInput(channelId.trim()); + if (!id) { + setError('Please enter a valid Slack channel ID or URL'); + return; + } + + try { + setLoading(true); + setError(null); + + await api.addSlackChannel(sessionId, { + channel_id: id, + since: since || undefined, + until: until || undefined, + }); + + onAdded(); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to add Slack channel'); + } finally { + setLoading(false); + } + }; + + return ( +
+
e.stopPropagation()}> +
+

Import from Slack

+ +
+ +
+
+ + setChannelId(e.target.value)} + disabled={loading} + autoFocus + /> +

+ Find the channel ID in Slack's channel details +

+
+ +
+
+ + setSince(e.target.value)} + disabled={loading} + /> +
+
+ + setUntil(e.target.value)} + disabled={loading} + /> +
+
+ + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+
+
+ ); +} + +// parseChannelInput extracts a channel ID from a raw ID or Slack URL +function parseChannelInput(input: string): string | null { + // Direct channel ID (starts with C, D, or G) + if (/^[CDG][A-Z0-9]{8,}$/.test(input)) { + return input; + } + // Slack archive URL: https://slack.com/archives/C0123ABCDEF or similar + const match = input.match(/\/archives\/([CDG][A-Z0-9]+)/); + if (match) { + return match[1]; + } + // If it looks like just an ID without the right prefix, still accept it + if (/^[A-Z0-9]{9,}$/.test(input)) { + return input; + } + return null; +} + +export default AddSlackChannelModal; diff --git a/frontend/src/pages/SessionPage.tsx b/frontend/src/pages/SessionPage.tsx index ca491c4..261620b 100644 --- a/frontend/src/pages/SessionPage.tsx +++ b/frontend/src/pages/SessionPage.tsx @@ -4,6 +4,7 @@ import { api } from '../api/client'; import type { Session, DocumentListItem, ProcessingEvent } from '../api/types'; import DocumentsTable from '../components/documents/DocumentsTable'; import AddDocumentModal from '../components/documents/AddDocumentModal'; +import AddSlackChannelModal from '../components/documents/AddSlackChannelModal'; import ProcessingView from '../components/processing/ProcessingView'; function SessionPage() { @@ -13,6 +14,7 @@ function SessionPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [showAddDoc, setShowAddDoc] = useState(false); + const [showAddSlack, setShowAddSlack] = useState(false); const [processing, setProcessing] = useState(false); const [events, setEvents] = useState([]); const subscriptionRef = useRef<(() => void) | null>(null); @@ -203,6 +205,12 @@ function SessionPage() { Process {pendingDocs.length} Document{pendingDocs.length !== 1 ? 's' : ''} )} +
); }