From 0654712484d5bf4e21661eed3dc9acf7adeb9783 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 05:20:58 +0000 Subject: [PATCH] Add export_thread message action Staff-only message action that fetches a whole thread, renders it as Markdown (resolving mentions, channel references and links) and sends it to the requester as a file via DM. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A6cUQyKYWYm11Eha7pWKMS --- README.md | 1 + bot/export_thread.go | 233 ++++++++++++++++++++++++++++++++++++++ bot/export_thread_test.go | 87 ++++++++++++++ bot/interact.go | 15 +++ 4 files changed, 336 insertions(+) create mode 100644 bot/export_thread.go create mode 100644 bot/export_thread_test.go diff --git a/README.md b/README.md index e3b1e9e..62aaf49 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Our lovely opinionated Slack bot. Find it in [BcnEng Slack workspace](https://sl - Tracking parameter detection. Detects privacy-invasive tracking parameters in shared URLs and privately warns users with cleaned alternatives. - Message actions. For example: - Deleting a message and the whole thread. Only available to admins. + - Exporting a whole thread as a Markdown file, sent to the requester via DM. Only available to admins. - Report messages to the admins. ## Configuration diff --git a/bot/export_thread.go b/bot/export_thread.go new file mode 100644 index 0000000..6b38cfc --- /dev/null +++ b/bot/export_thread.go @@ -0,0 +1,233 @@ +package bot + +import ( + "bytes" + "fmt" + "log" + "regexp" + "strconv" + "strings" + "time" + + "github.com/newrelic/newrelic-telemetry-sdk-go/telemetry" + "github.com/slack-go/slack" + + "github.com/bcneng/candebot/slackx" +) + +// exportThread fetches the whole thread the given message belongs to, renders it as Markdown +// and sends it as a file to the requester via DM. +func exportThread(botContext Context, requesterID, channelID, channelName, threadTS string) { + dm, _, _, err := botContext.Client.OpenConversation(&slack.OpenConversationParameters{Users: []string{requesterID}}) + if err != nil { + log.Printf("Thread export: could not open DM with %s: %v", requesterID, err) + return + } + + notify := func(msg string) { + _ = slackx.Send(botContext.Client, "", dm.ID, msg, false) + } + + messages, err := fetchThreadMessages(botContext.Client, channelID, threadTS) + if err != nil { + // The bot can only read channels it is a member of. Fall back to the admin token. + log.Printf("Thread export: bot client could not read thread %s/%s (%v), retrying with admin client", channelID, threadTS, err) + messages, err = fetchThreadMessages(botContext.AdminClient, channelID, threadTS) + } + if err != nil { + log.Printf("Thread export: could not read thread %s/%s: %v", channelID, threadTS, err) + notify(fmt.Sprintf("Could not export the thread %s: %s", slackx.LinkToMessage(channelID, threadTS), err)) + return + } + + names := newUserNameResolver(botContext.Client) + content := renderThreadMarkdown(messages, threadExportMeta{ + ChannelName: channelName, + Link: slackx.LinkToMessage(channelID, threadTS), + ExportedAt: time.Now().UTC(), + }, names.Name) + + source := channelName + if source == "" { + source = channelID + } + filename := fmt.Sprintf("thread-%s-%s.md", source, strings.Replace(threadTS, ".", "", 1)) + _, err = botContext.Client.UploadFileV2(slack.UploadFileV2Parameters{ + Reader: bytes.NewReader([]byte(content)), + FileSize: len(content), + Filename: filename, + Title: filename, + Channel: dm.ID, + InitialComment: fmt.Sprintf("Export of %s (%d messages)", slackx.LinkToMessage(channelID, threadTS), len(messages)), + }) + if err != nil { + log.Printf("Thread export: could not upload file for %s/%s: %v", channelID, threadTS, err) + notify(fmt.Sprintf("Could not upload the export of %s: %s", slackx.LinkToMessage(channelID, threadTS), err)) + return + } + + log.Printf("Thread export finished successfully, %d messages exported: %s/%s", len(messages), channelID, threadTS) + + // Sending metrics + botContext.Harvester.RecordMetric(telemetry.Count{ + Name: fmt.Sprintf("%s.%s", strings.ToLower(botContext.Config.Bot.Name), "thread.exported"), + Attributes: map[string]interface{}{ + "messages": len(messages), + }, + Value: 1, + Timestamp: time.Now(), + }) +} + +func fetchThreadMessages(client *slack.Client, channelID, threadTS string) ([]slack.Message, error) { + params := &slack.GetConversationRepliesParameters{ + ChannelID: channelID, + Timestamp: threadTS, + } + + var messages []slack.Message + for more := true; more; { + var replies []slack.Message + var err error + replies, more, params.Cursor, err = client.GetConversationReplies(params) + if err != nil { + return nil, err + } + messages = append(messages, replies...) + } + + return messages, nil +} + +type userNameResolver struct { + client *slack.Client + cache map[string]string +} + +func newUserNameResolver(client *slack.Client) *userNameResolver { + return &userNameResolver{client: client, cache: map[string]string{}} +} + +// Name returns the display name of the user, falling back to the user ID if it cannot be resolved. +func (r *userNameResolver) Name(userID string) string { + if name, ok := r.cache[userID]; ok { + return name + } + + name := userID + if u, err := r.client.GetUserInfo(userID); err != nil { + log.Printf("Thread export: could not resolve user %s: %v", userID, err) + } else { + switch { + case u.Profile.DisplayName != "": + name = u.Profile.DisplayName + case u.RealName != "": + name = u.RealName + case u.Name != "": + name = u.Name + } + } + + r.cache[userID] = name + return name +} + +type threadExportMeta struct { + ChannelName string + Link string + ExportedAt time.Time +} + +func renderThreadMarkdown(messages []slack.Message, meta threadExportMeta, userName func(string) string) string { + var b strings.Builder + + _, _ = b.WriteString("# Slack thread export\n\n") + if meta.ChannelName != "" { + fmt.Fprintf(&b, "- Channel: #%s\n", meta.ChannelName) + } + fmt.Fprintf(&b, "- Link: %s\n", meta.Link) + fmt.Fprintf(&b, "- Messages: %d\n", len(messages)) + fmt.Fprintf(&b, "- Exported at: %s\n", meta.ExportedAt.Format(time.RFC3339)) + + for _, m := range messages { + _, _ = b.WriteString("\n---\n\n") + fmt.Fprintf(&b, "**%s** · %s\n\n", messageAuthor(m, userName), formatSlackTS(m.Timestamp)) + + if text := slackToMarkdown(m.Text, userName); text != "" { + _, _ = b.WriteString(text) + _, _ = b.WriteString("\n") + } + + if len(m.Files) > 0 { + _, _ = b.WriteString("\nFiles:\n") + for _, f := range m.Files { + fmt.Fprintf(&b, "- [%s](%s)\n", f.Name, f.Permalink) + } + } + } + + return b.String() +} + +func messageAuthor(m slack.Message, userName func(string) string) string { + switch { + case m.User != "": + return userName(m.User) + case m.Username != "": + return m.Username + case m.BotProfile != nil && m.BotProfile.Name != "": + return m.BotProfile.Name + case m.BotID != "": + return m.BotID + default: + return "unknown" + } +} + +func formatSlackTS(ts string) string { + sec, _, _ := strings.Cut(ts, ".") + unix, err := strconv.ParseInt(sec, 10, 64) + if err != nil { + return ts + } + + return time.Unix(unix, 0).UTC().Format("2006-01-02 15:04 MST") +} + +var slackEntityRegexp = regexp.MustCompile(`<([^<>]+)>`) + +// slackToMarkdown converts Slack mrkdwn entities (mentions, channel references, links) to plain Markdown. +func slackToMarkdown(text string, userName func(string) string) string { + text = slackEntityRegexp.ReplaceAllStringFunc(text, func(entity string) string { + target, label, hasLabel := strings.Cut(entity[1:len(entity)-1], "|") + + switch { + case strings.HasPrefix(target, "@"): + if hasLabel { + return "@" + label + } + return "@" + userName(target[1:]) + case strings.HasPrefix(target, "#"): + if hasLabel && label != "" { + return "#" + label + } + return "#" + target[1:] + case strings.HasPrefix(target, "!subteam^"): + if hasLabel { + return label + } + return "@group" + case strings.HasPrefix(target, "!"): + if hasLabel { + return label + } + return "@" + target[1:] + case hasLabel: + return fmt.Sprintf("[%s](%s)", label, target) + default: + return target + } + }) + + return strings.NewReplacer("<", "<", ">", ">", "&", "&").Replace(text) +} diff --git a/bot/export_thread_test.go b/bot/export_thread_test.go new file mode 100644 index 0000000..657f554 --- /dev/null +++ b/bot/export_thread_test.go @@ -0,0 +1,87 @@ +package bot + +import ( + "testing" + "time" + + "github.com/slack-go/slack" + "github.com/stretchr/testify/require" +) + +func fakeUserName(id string) string { + return map[string]string{"U1": "alice", "U2": "bob"}[id] +} + +func TestSlackToMarkdown(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"plain text", "hello world", "hello world"}, + {"user mention", "ping <@U1>", "ping @alice"}, + {"user mention with label", "ping <@U1|al>", "ping @al"}, + {"channel reference", "see <#C123|general>", "see #general"}, + {"channel reference without label", "see <#C123>", "see #C123"}, + {"special mention", " look", "@here look"}, + {"user group", " look", "@staff look"}, + {"link with label", "", "[BcnEng](https://bcneng.org)"}, + {"bare link", "", "https://bcneng.org"}, + {"html entities", "a < b && c > d", "a < b && c > d"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, slackToMarkdown(tt.in, fakeUserName)) + }) + } +} + +func TestRenderThreadMarkdown(t *testing.T) { + messages := []slack.Message{ + {Msg: slack.Msg{User: "U1", Timestamp: "1700000000.000100", Text: "Spec for <@U2>"}}, + {Msg: slack.Msg{ + User: "U2", + Timestamp: "1700000060.000200", + Text: "LGTM", + Files: []slack.File{{Name: "diagram.png", Permalink: "https://files.example/diagram.png"}}, + }}, + {Msg: slack.Msg{BotID: "B1", Username: "candebot", Timestamp: "1700000120.000300", Text: "Done"}}, + } + + got := renderThreadMarkdown(messages, threadExportMeta{ + ChannelName: "general", + Link: "https://bcneng.slack.com/archives/C1/p1700000000000100", + ExportedAt: time.Date(2026, 9, 29, 10, 0, 0, 0, time.UTC), + }, fakeUserName) + + want := `# Slack thread export + +- Channel: #general +- Link: https://bcneng.slack.com/archives/C1/p1700000000000100 +- Messages: 3 +- Exported at: 2026-09-29T10:00:00Z + +--- + +**alice** · 2023-11-14 22:13 UTC + +Spec for @bob + +--- + +**bob** · 2023-11-14 22:14 UTC + +LGTM + +Files: +- [diagram.png](https://files.example/diagram.png) + +--- + +**candebot** · 2023-11-14 22:15 UTC + +Done +` + require.Equal(t, want, got) +} diff --git a/bot/interact.go b/bot/interact.go index 9db6468..f2427c5 100644 --- a/bot/interact.go +++ b/bot/interact.go @@ -72,7 +72,22 @@ func interactAPIHandler(botContext Context) http.HandlerFunc { if resp, err := botContext.Client.OpenView(message.TriggerID, modal); err != nil { logModalError(err, resp) } + case "export_thread": + if !botContext.IsStaff(message.User.ID) { + if resp, err := botContext.Client.OpenView(message.TriggerID, userNotAllowedModal()); err != nil { + logModalError(err, resp) + } + log.Printf("The user @%s (%s) is trying to execute the message action `export_thread` and it doesn't have permissions", message.User.Name, message.User.ID) + break + } + + threadTS := message.Message.ThreadTimestamp // set when the action is triggered on a reply + if threadTS == "" { + threadTS = message.MessageTs + } + // Slack expects a response within 3 seconds, so the export runs in background and is delivered via DM. + go exportThread(botContext, message.User.ID, message.Channel.ID, message.Channel.Name, threadTS) } case slack.InteractionTypeViewSubmission: switch message.View.CallbackID {