Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
233 changes: 233 additions & 0 deletions bot/export_thread.go
Original file line number Diff line number Diff line change
@@ -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("&lt;", "<", "&gt;", ">", "&amp;", "&").Replace(text)
}
87 changes: 87 additions & 0 deletions bot/export_thread_test.go
Original file line number Diff line number Diff line change
@@ -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", "<!here> look", "@here look"},
{"user group", "<!subteam^S1|@staff> look", "@staff look"},
{"link with label", "<https://bcneng.org|BcnEng>", "[BcnEng](https://bcneng.org)"},
{"bare link", "<https://bcneng.org>", "https://bcneng.org"},
{"html entities", "a &lt; b &amp;&amp; c &gt; 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)
}
15 changes: 15 additions & 0 deletions bot/interact.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading