conversations.go•31.8 kB
package handler
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/gocarina/gocsv"
"github.com/korotovsky/slack-mcp-server/pkg/provider"
"github.com/korotovsky/slack-mcp-server/pkg/server/auth"
"github.com/korotovsky/slack-mcp-server/pkg/text"
"github.com/mark3labs/mcp-go/mcp"
"github.com/slack-go/slack"
slackGoUtil "github.com/takara2314/slack-go-util"
"go.uber.org/zap"
)
const (
defaultConversationsNumericLimit = 50
defaultConversationsExpressionLimit = "1d"
)
var validFilterKeys = map[string]struct{}{
"is": {},
"in": {},
"from": {},
"with": {},
"before": {},
"after": {},
"on": {},
"during": {},
}
type Message struct {
MsgID string `json:"msgID"`
UserID string `json:"userID"`
UserName string `json:"userUser"`
RealName string `json:"realName"`
Channel string `json:"channelID"`
ThreadTs string `json:"ThreadTs"`
Text string `json:"text"`
Time string `json:"time"`
Reactions string `json:"reactions,omitempty"`
Cursor string `json:"cursor"`
}
type User struct {
UserID string `json:"userID"`
UserName string `json:"userName"`
RealName string `json:"realName"`
}
type conversationParams struct {
channel string
limit int
oldest string
latest string
cursor string
activity bool
}
type searchParams struct {
query string
limit int
page int
}
type addMessageParams struct {
channel string
threadTs string
text string
contentType string
}
type ConversationsHandler struct {
apiProvider *provider.ApiProvider
logger *zap.Logger
}
func NewConversationsHandler(apiProvider *provider.ApiProvider, logger *zap.Logger) *ConversationsHandler {
return &ConversationsHandler{
apiProvider: apiProvider,
logger: logger,
}
}
// UsersResource streams a CSV of all users
func (ch *ConversationsHandler) UsersResource(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
ch.logger.Debug("UsersResource called", zap.Any("params", request.Params))
// authentication
if authenticated, err := auth.IsAuthenticated(ctx, ch.apiProvider.ServerTransport(), ch.logger); !authenticated {
ch.logger.Error("Authentication failed for users resource", zap.Error(err))
return nil, err
}
// provider readiness
if ready, err := ch.apiProvider.IsReady(); !ready {
ch.logger.Error("API provider not ready", zap.Error(err))
return nil, err
}
// Slack auth test
ar, err := ch.apiProvider.Slack().AuthTest()
if err != nil {
ch.logger.Error("Slack AuthTest failed", zap.Error(err))
return nil, err
}
ws, err := text.Workspace(ar.URL)
if err != nil {
ch.logger.Error("Failed to parse workspace from URL",
zap.String("url", ar.URL),
zap.Error(err),
)
return nil, fmt.Errorf("failed to parse workspace from URL: %v", err)
}
// collect users
usersMaps := ch.apiProvider.ProvideUsersMap()
users := usersMaps.Users
usersList := make([]User, 0, len(users))
for _, user := range users {
usersList = append(usersList, User{
UserID: user.ID,
UserName: user.Name,
RealName: user.RealName,
})
}
// marshal CSV
csvBytes, err := gocsv.MarshalBytes(&usersList)
if err != nil {
ch.logger.Error("Failed to marshal users to CSV", zap.Error(err))
return nil, err
}
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: "slack://" + ws + "/users",
MIMEType: "text/csv",
Text: string(csvBytes),
},
}, nil
}
// ConversationsAddMessageHandler posts a message and returns it as CSV
func (ch *ConversationsHandler) ConversationsAddMessageHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ch.logger.Debug("ConversationsAddMessageHandler called", zap.Any("params", request.Params))
params, err := ch.parseParamsToolAddMessage(request)
if err != nil {
ch.logger.Error("Failed to parse add-message params", zap.Error(err))
return nil, err
}
var options []slack.MsgOption
if params.threadTs != "" {
options = append(options, slack.MsgOptionTS(params.threadTs))
}
switch params.contentType {
case "text/plain":
options = append(options, slack.MsgOptionDisableMarkdown())
options = append(options, slack.MsgOptionText(params.text, false))
case "text/markdown":
blocks, err := slackGoUtil.ConvertMarkdownTextToBlocks(params.text)
if err != nil {
ch.logger.Warn("Markdown parsing error", zap.Error(err))
options = append(options, slack.MsgOptionDisableMarkdown())
options = append(options, slack.MsgOptionText(params.text, false))
} else {
options = append(options, slack.MsgOptionBlocks(blocks...))
}
default:
return nil, errors.New("content_type must be either 'text/plain' or 'text/markdown'")
}
unfurlOpt := os.Getenv("SLACK_MCP_ADD_MESSAGE_UNFURLING")
if text.IsUnfurlingEnabled(params.text, unfurlOpt, ch.logger) {
options = append(options, slack.MsgOptionEnableLinkUnfurl())
} else {
options = append(options, slack.MsgOptionDisableLinkUnfurl())
options = append(options, slack.MsgOptionDisableMediaUnfurl())
}
ch.logger.Debug("Posting Slack message",
zap.String("channel", params.channel),
zap.String("thread_ts", params.threadTs),
zap.String("content_type", params.contentType),
)
respChannel, respTimestamp, err := ch.apiProvider.Slack().PostMessageContext(ctx, params.channel, options...)
if err != nil {
ch.logger.Error("Slack PostMessageContext failed", zap.Error(err))
return nil, err
}
toolConfig := os.Getenv("SLACK_MCP_ADD_MESSAGE_MARK")
if toolConfig == "1" || toolConfig == "true" || toolConfig == "yes" {
err := ch.apiProvider.Slack().MarkConversationContext(ctx, params.channel, respTimestamp)
if err != nil {
ch.logger.Error("Slack MarkConversationContext failed", zap.Error(err))
return nil, err
}
}
// fetch the single message we just posted
historyParams := slack.GetConversationHistoryParameters{
ChannelID: respChannel,
Limit: 1,
Oldest: respTimestamp,
Latest: respTimestamp,
Inclusive: true,
}
history, err := ch.apiProvider.Slack().GetConversationHistoryContext(ctx, &historyParams)
if err != nil {
ch.logger.Error("GetConversationHistoryContext failed", zap.Error(err))
return nil, err
}
ch.logger.Debug("Fetched conversation history", zap.Int("message_count", len(history.Messages)))
messages := ch.convertMessagesFromHistory(history.Messages, historyParams.ChannelID, false)
return marshalMessagesToCSV(messages)
}
// ConversationsHistoryHandler streams conversation history as CSV
func (ch *ConversationsHandler) ConversationsHistoryHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ch.logger.Debug("ConversationsHistoryHandler called", zap.Any("params", request.Params))
params, err := ch.parseParamsToolConversations(request)
if err != nil {
ch.logger.Error("Failed to parse history params", zap.Error(err))
return nil, err
}
ch.logger.Debug("History params parsed",
zap.String("channel", params.channel),
zap.Int("limit", params.limit),
zap.String("oldest", params.oldest),
zap.String("latest", params.latest),
zap.Bool("include_activity", params.activity),
)
historyParams := slack.GetConversationHistoryParameters{
ChannelID: params.channel,
Limit: params.limit,
Oldest: params.oldest,
Latest: params.latest,
Cursor: params.cursor,
Inclusive: false,
}
history, err := ch.apiProvider.Slack().GetConversationHistoryContext(ctx, &historyParams)
if err != nil {
ch.logger.Error("GetConversationHistoryContext failed", zap.Error(err))
return nil, err
}
ch.logger.Debug("Fetched conversation history", zap.Int("message_count", len(history.Messages)))
messages := ch.convertMessagesFromHistory(history.Messages, params.channel, params.activity)
if len(messages) > 0 && history.HasMore {
messages[len(messages)-1].Cursor = history.ResponseMetaData.NextCursor
}
return marshalMessagesToCSV(messages)
}
// ConversationsRepliesHandler streams thread replies as CSV
func (ch *ConversationsHandler) ConversationsRepliesHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ch.logger.Debug("ConversationsRepliesHandler called", zap.Any("params", request.Params))
params, err := ch.parseParamsToolConversations(request)
if err != nil {
ch.logger.Error("Failed to parse replies params", zap.Error(err))
return nil, err
}
threadTs := request.GetString("thread_ts", "")
if threadTs == "" {
ch.logger.Error("thread_ts not provided for replies", zap.String("thread_ts", threadTs))
return nil, errors.New("thread_ts must be a string")
}
repliesParams := slack.GetConversationRepliesParameters{
ChannelID: params.channel,
Timestamp: threadTs,
Limit: params.limit,
Oldest: params.oldest,
Latest: params.latest,
Cursor: params.cursor,
Inclusive: false,
}
replies, hasMore, nextCursor, err := ch.apiProvider.Slack().GetConversationRepliesContext(ctx, &repliesParams)
if err != nil {
ch.logger.Error("GetConversationRepliesContext failed", zap.Error(err))
return nil, err
}
ch.logger.Debug("Fetched conversation replies", zap.Int("count", len(replies)))
messages := ch.convertMessagesFromHistory(replies, params.channel, params.activity)
if len(messages) > 0 && hasMore {
messages[len(messages)-1].Cursor = nextCursor
}
return marshalMessagesToCSV(messages)
}
func (ch *ConversationsHandler) ConversationsSearchHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ch.logger.Debug("ConversationsSearchHandler called", zap.Any("params", request.Params))
params, err := ch.parseParamsToolSearch(request)
if err != nil {
ch.logger.Error("Failed to parse search params", zap.Error(err))
return nil, err
}
ch.logger.Debug("Search params parsed", zap.String("query", params.query), zap.Int("limit", params.limit), zap.Int("page", params.page))
searchParams := slack.SearchParameters{
Sort: slack.DEFAULT_SEARCH_SORT,
SortDirection: slack.DEFAULT_SEARCH_SORT_DIR,
Highlight: false,
Count: params.limit,
Page: params.page,
}
messagesRes, _, err := ch.apiProvider.Slack().SearchContext(ctx, params.query, searchParams)
if err != nil {
ch.logger.Error("Slack SearchContext failed", zap.Error(err))
return nil, err
}
ch.logger.Debug("Search completed", zap.Int("matches", len(messagesRes.Matches)))
messages := ch.convertMessagesFromSearch(messagesRes.Matches)
if len(messages) > 0 && ((messagesRes.Pagination.PerPage * messagesRes.Pagination.PageCount) < messagesRes.Pagination.TotalCount) {
nextCursor := fmt.Sprintf("page:%d", messagesRes.Pagination.PageCount+1)
messages[len(messages)-1].Cursor = base64.StdEncoding.EncodeToString([]byte(nextCursor))
}
return marshalMessagesToCSV(messages)
}
func isChannelAllowed(channel string) bool {
config := os.Getenv("SLACK_MCP_ADD_MESSAGE_TOOL")
if config == "" || config == "true" || config == "1" {
return true
}
items := strings.Split(config, ",")
isNegated := strings.HasPrefix(strings.TrimSpace(items[0]), "!")
for _, item := range items {
item = strings.TrimSpace(item)
if isNegated {
if strings.TrimPrefix(item, "!") == channel {
return false
}
} else {
if item == channel {
return true
}
}
}
return !isNegated
}
func (ch *ConversationsHandler) convertMessagesFromHistory(slackMessages []slack.Message, channel string, includeActivity bool) []Message {
usersMap := ch.apiProvider.ProvideUsersMap()
var messages []Message
warn := false
for _, msg := range slackMessages {
if (msg.SubType != "" && msg.SubType != "bot_message") && !includeActivity {
continue
}
userName, realName, ok := getUserInfo(msg.User, usersMap.Users)
if !ok && msg.SubType == "bot_message" {
userName, realName, ok = getBotInfo(msg.Username)
}
if !ok {
warn = true
}
timestamp, err := text.TimestampToIsoRFC3339(msg.Timestamp)
if err != nil {
ch.logger.Error("Failed to convert timestamp to RFC3339", zap.Error(err))
continue
}
msgText := msg.Text + text.AttachmentsTo2CSV(msg.Text, msg.Attachments)
var reactionParts []string
for _, r := range msg.Reactions {
reactionParts = append(reactionParts, fmt.Sprintf("%s:%d", r.Name, r.Count))
}
reactionsString := strings.Join(reactionParts, "|")
messages = append(messages, Message{
MsgID: msg.Timestamp,
UserID: msg.User,
UserName: userName,
RealName: realName,
Text: text.ProcessText(msgText),
Channel: channel,
ThreadTs: msg.ThreadTimestamp,
Time: timestamp,
Reactions: reactionsString,
})
}
if ready, err := ch.apiProvider.IsReady(); !ready {
if warn && errors.Is(err, provider.ErrUsersNotReady) {
ch.logger.Warn(
"WARNING: Slack users sync is not ready yet, you may experience some limited functionality and see UIDs instead of resolved names as well as unable to query users by their @handles. Users sync is part of channels sync and operations on channels depend on users collection (IM, MPIM). Please wait until users are synced and try again",
zap.Error(err),
)
}
}
return messages
}
func (ch *ConversationsHandler) convertMessagesFromSearch(slackMessages []slack.SearchMessage) []Message {
usersMap := ch.apiProvider.ProvideUsersMap()
var messages []Message
warn := false
for _, msg := range slackMessages {
userName, realName, ok := getUserInfo(msg.User, usersMap.Users)
if !ok && msg.User == "" && msg.Username != "" {
userName, realName, ok = getBotInfo(msg.Username)
} else if !ok {
warn = true
}
threadTs, _ := extractThreadTS(msg.Permalink)
timestamp, err := text.TimestampToIsoRFC3339(msg.Timestamp)
if err != nil {
ch.logger.Error("Failed to convert timestamp to RFC3339", zap.Error(err))
continue
}
msgText := msg.Text + text.AttachmentsTo2CSV(msg.Text, msg.Attachments)
messages = append(messages, Message{
MsgID: msg.Timestamp,
UserID: msg.User,
UserName: userName,
RealName: realName,
Text: text.ProcessText(msgText),
Channel: fmt.Sprintf("#%s", msg.Channel.Name),
ThreadTs: threadTs,
Time: timestamp,
Reactions: "",
})
}
if ready, err := ch.apiProvider.IsReady(); !ready {
if warn && errors.Is(err, provider.ErrUsersNotReady) {
ch.logger.Warn(
"Slack users sync not ready; you may see raw UIDs instead of names and lose some functionality.",
zap.Error(err),
)
}
}
return messages
}
func (ch *ConversationsHandler) parseParamsToolConversations(request mcp.CallToolRequest) (*conversationParams, error) {
channel := request.GetString("channel_id", "")
if channel == "" {
ch.logger.Error("channel_id missing in conversations params")
return nil, errors.New("channel_id must be a string")
}
limit := request.GetString("limit", "")
cursor := request.GetString("cursor", "")
activity := request.GetBool("include_activity_messages", false)
var (
paramLimit int
paramOldest string
paramLatest string
err error
)
if strings.HasSuffix(limit, "d") || strings.HasSuffix(limit, "w") || strings.HasSuffix(limit, "m") {
paramLimit, paramOldest, paramLatest, err = limitByExpression(limit, defaultConversationsExpressionLimit)
if err != nil {
ch.logger.Error("Invalid duration limit", zap.String("limit", limit), zap.Error(err))
return nil, err
}
} else if cursor == "" {
paramLimit, err = limitByNumeric(limit, defaultConversationsNumericLimit)
if err != nil {
ch.logger.Error("Invalid numeric limit", zap.String("limit", limit), zap.Error(err))
return nil, err
}
}
if strings.HasPrefix(channel, "#") || strings.HasPrefix(channel, "@") {
if ready, err := ch.apiProvider.IsReady(); !ready {
if errors.Is(err, provider.ErrUsersNotReady) {
ch.logger.Warn(
"WARNING: Slack users sync is not ready yet, you may experience some limited functionality and see UIDs instead of resolved names as well as unable to query users by their @handles. Users sync is part of channels sync and operations on channels depend on users collection (IM, MPIM). Please wait until users are synced and try again",
zap.Error(err),
)
}
if errors.Is(err, provider.ErrChannelsNotReady) {
ch.logger.Warn(
"WARNING: Slack channels sync is not ready yet, you may experience some limited functionality and be able to request conversation only by Channel ID, not by its name. Please wait until channels are synced and try again.",
zap.Error(err),
)
}
return nil, fmt.Errorf("channel %q not found in empty cache", channel)
}
channelsMaps := ch.apiProvider.ProvideChannelsMaps()
chn, ok := channelsMaps.ChannelsInv[channel]
if !ok {
ch.logger.Error("Channel not found in synced cache", zap.String("channel", channel))
return nil, fmt.Errorf("channel %q not found in synced cache. Try to remove old cache file and restart MCP Server", channel)
}
channel = channelsMaps.Channels[chn].ID
}
return &conversationParams{
channel: channel,
limit: paramLimit,
oldest: paramOldest,
latest: paramLatest,
cursor: cursor,
activity: activity,
}, nil
}
func (ch *ConversationsHandler) parseParamsToolAddMessage(request mcp.CallToolRequest) (*addMessageParams, error) {
toolConfig := os.Getenv("SLACK_MCP_ADD_MESSAGE_TOOL")
if toolConfig == "" {
ch.logger.Error("Add-message tool disabled by default")
return nil, errors.New(
"by default, the conversations_add_message tool is disabled to guard Slack workspaces against accidental spamming." +
"To enable it, set the SLACK_MCP_ADD_MESSAGE_TOOL environment variable to true, 1, or comma separated list of channels" +
"to limit where the MCP can post messages, e.g. 'SLACK_MCP_ADD_MESSAGE_TOOL=C1234567890,D0987654321', 'SLACK_MCP_ADD_MESSAGE_TOOL=!C1234567890'" +
"to enable all except one or 'SLACK_MCP_ADD_MESSAGE_TOOL=true' for all channels and DMs",
)
}
channel := request.GetString("channel_id", "")
if channel == "" {
ch.logger.Error("channel_id missing in add-message params")
return nil, errors.New("channel_id must be a string")
}
if strings.HasPrefix(channel, "#") || strings.HasPrefix(channel, "@") {
channelsMaps := ch.apiProvider.ProvideChannelsMaps()
chn, ok := channelsMaps.ChannelsInv[channel]
if !ok {
ch.logger.Error("Channel not found", zap.String("channel", channel))
return nil, fmt.Errorf("channel %q not found", channel)
}
channel = channelsMaps.Channels[chn].ID
}
if !isChannelAllowed(channel) {
ch.logger.Warn("Add-message tool not allowed for channel", zap.String("channel", channel), zap.String("policy", toolConfig))
return nil, fmt.Errorf("conversations_add_message tool is not allowed for channel %q, applied policy: %s", channel, toolConfig)
}
threadTs := request.GetString("thread_ts", "")
if threadTs != "" && !strings.Contains(threadTs, ".") {
ch.logger.Error("Invalid thread_ts format", zap.String("thread_ts", threadTs))
return nil, errors.New("thread_ts must be a valid timestamp in format 1234567890.123456")
}
msgText := request.GetString("payload", "")
if msgText == "" {
ch.logger.Error("Message text missing")
return nil, errors.New("text must be a string")
}
contentType := request.GetString("content_type", "text/markdown")
if contentType != "text/plain" && contentType != "text/markdown" {
ch.logger.Error("Invalid content_type", zap.String("content_type", contentType))
return nil, errors.New("content_type must be either 'text/plain' or 'text/markdown'")
}
return &addMessageParams{
channel: channel,
threadTs: threadTs,
text: msgText,
contentType: contentType,
}, nil
}
func (ch *ConversationsHandler) parseParamsToolSearch(req mcp.CallToolRequest) (*searchParams, error) {
rawQuery := strings.TrimSpace(req.GetString("search_query", ""))
freeText, filters := splitQuery(rawQuery)
if req.GetBool("filter_threads_only", false) {
addFilter(filters, "is", "thread")
}
if chName := req.GetString("filter_in_channel", ""); chName != "" {
f, err := ch.paramFormatChannel(chName)
if err != nil {
ch.logger.Error("Invalid channel filter", zap.String("filter", chName), zap.Error(err))
return nil, err
}
addFilter(filters, "in", f)
} else if im := req.GetString("filter_in_im_or_mpim", ""); im != "" {
f, err := ch.paramFormatUser(im)
if err != nil {
ch.logger.Error("Invalid IM/MPIM filter", zap.String("filter", im), zap.Error(err))
return nil, err
}
addFilter(filters, "in", f)
}
if with := req.GetString("filter_users_with", ""); with != "" {
f, err := ch.paramFormatUser(with)
if err != nil {
ch.logger.Error("Invalid with-user filter", zap.String("filter", with), zap.Error(err))
return nil, err
}
addFilter(filters, "with", f)
}
if from := req.GetString("filter_users_from", ""); from != "" {
f, err := ch.paramFormatUser(from)
if err != nil {
ch.logger.Error("Invalid from-user filter", zap.String("filter", from), zap.Error(err))
return nil, err
}
addFilter(filters, "from", f)
}
dateMap, err := buildDateFilters(
req.GetString("filter_date_before", ""),
req.GetString("filter_date_after", ""),
req.GetString("filter_date_on", ""),
req.GetString("filter_date_during", ""),
)
if err != nil {
ch.logger.Error("Invalid date filters", zap.Error(err))
return nil, err
}
for key, val := range dateMap {
addFilter(filters, key, val)
}
finalQuery := buildQuery(freeText, filters)
limit := req.GetInt("limit", 100)
cursor := req.GetString("cursor", "")
var (
page int
decodedCursor []byte
)
if cursor != "" {
decodedCursor, err = base64.StdEncoding.DecodeString(cursor)
if err != nil {
ch.logger.Error("Invalid cursor decoding", zap.String("cursor", cursor), zap.Error(err))
return nil, fmt.Errorf("invalid cursor: %v", err)
}
parts := strings.Split(string(decodedCursor), ":")
if len(parts) != 2 {
ch.logger.Error("Invalid cursor format", zap.String("cursor", cursor))
return nil, fmt.Errorf("invalid cursor: %v", cursor)
}
page, err = strconv.Atoi(parts[1])
if err != nil || page < 1 {
ch.logger.Error("Invalid cursor page", zap.String("cursor", cursor), zap.Error(err))
return nil, fmt.Errorf("invalid cursor page: %v", err)
}
} else {
page = 1
}
ch.logger.Debug("Search parameters built",
zap.String("query", finalQuery),
zap.Int("limit", limit),
zap.Int("page", page),
)
return &searchParams{
query: finalQuery,
limit: limit,
page: page,
}, nil
}
func (ch *ConversationsHandler) paramFormatUser(raw string) (string, error) {
users := ch.apiProvider.ProvideUsersMap()
raw = strings.TrimSpace(raw)
if strings.HasPrefix(raw, "U") {
u, ok := users.Users[raw]
if !ok {
return "", fmt.Errorf("user %q not found", raw)
}
return fmt.Sprintf("<@%s>", u.ID), nil
}
if strings.HasPrefix(raw, "<@") {
raw = raw[2:]
}
if strings.HasPrefix(raw, "@") {
raw = raw[1:]
}
uid, ok := users.UsersInv[raw]
if !ok {
return "", fmt.Errorf("user %q not found", raw)
}
return fmt.Sprintf("<@%s>", uid), nil
}
func (ch *ConversationsHandler) paramFormatChannel(raw string) (string, error) {
raw = strings.TrimSpace(raw)
cms := ch.apiProvider.ProvideChannelsMaps()
if strings.HasPrefix(raw, "#") {
if id, ok := cms.ChannelsInv[raw]; ok {
return "#" + cms.Channels[id].Name, nil
}
return "", fmt.Errorf("channel %q not found", raw)
}
if strings.HasPrefix(raw, "C") {
if chn, ok := cms.Channels[raw]; ok {
return "#" + chn.Name, nil
}
return "", fmt.Errorf("channel %q not found", raw)
}
return "", fmt.Errorf("invalid channel format: %q", raw)
}
func marshalMessagesToCSV(messages []Message) (*mcp.CallToolResult, error) {
csvBytes, err := gocsv.MarshalBytes(&messages)
if err != nil {
return nil, err
}
return mcp.NewToolResultText(string(csvBytes)), nil
}
func getUserInfo(userID string, usersMap map[string]slack.User) (userName, realName string, ok bool) {
if u, ok := usersMap[userID]; ok {
return u.Name, u.RealName, true
}
return userID, userID, false
}
func getBotInfo(botID string) (userName, realName string, ok bool) {
return botID, botID, true
}
func limitByNumeric(limit string, defaultLimit int) (int, error) {
if limit == "" {
return defaultLimit, nil
}
n, err := strconv.Atoi(limit)
if err != nil {
return 0, fmt.Errorf("invalid numeric limit: %q", limit)
}
return n, nil
}
func limitByExpression(limit, defaultLimit string) (slackLimit int, oldest, latest string, err error) {
if limit == "" {
limit = defaultLimit
}
if len(limit) < 2 {
return 0, "", "", fmt.Errorf("invalid duration limit %q: too short", limit)
}
suffix := limit[len(limit)-1]
numStr := limit[:len(limit)-1]
n, err := strconv.Atoi(numStr)
if err != nil || n <= 0 {
return 0, "", "", fmt.Errorf("invalid duration limit %q: must be a positive integer followed by 'd', 'w', or 'm'", limit)
}
now := time.Now()
loc := now.Location()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
var oldestTime time.Time
switch suffix {
case 'd':
oldestTime = startOfToday.AddDate(0, 0, -n+1)
case 'w':
oldestTime = startOfToday.AddDate(0, 0, -n*7+1)
case 'm':
oldestTime = startOfToday.AddDate(0, -n, 0)
default:
return 0, "", "", fmt.Errorf("invalid duration limit %q: must end in 'd', 'w', or 'm'", limit)
}
latest = fmt.Sprintf("%d.000000", now.Unix())
oldest = fmt.Sprintf("%d.000000", oldestTime.Unix())
return 100, oldest, latest, nil
}
func extractThreadTS(rawurl string) (string, error) {
u, err := url.Parse(rawurl)
if err != nil {
return "", err
}
return u.Query().Get("thread_ts"), nil
}
func parseFlexibleDate(dateStr string) (time.Time, string, error) {
dateStr = strings.TrimSpace(dateStr)
standardFormats := []string{
"2006-01-02", // YYYY-MM-DD
"2006/01/02", // YYYY/MM/DD
"01-02-2006", // MM-DD-YYYY
"01/02/2006", // MM/DD/YYYY
"02-01-2006", // DD-MM-YYYY
"02/01/2006", // DD/MM/YYYY
"Jan 2, 2006", // Jan 2, 2006
"January 2, 2006", // January 2, 2006
"2 Jan 2006", // 2 Jan 2006
"2 January 2006", // 2 January 2006
}
for _, fmtStr := range standardFormats {
if t, err := time.Parse(fmtStr, dateStr); err == nil {
return t, t.Format("2006-01-02"), nil
}
}
monthMap := map[string]int{
"january": 1, "jan": 1,
"february": 2, "feb": 2,
"march": 3, "mar": 3,
"april": 4, "apr": 4,
"may": 5,
"june": 6, "jun": 6,
"july": 7, "jul": 7,
"august": 8, "aug": 8,
"september": 9, "sep": 9, "sept": 9,
"october": 10, "oct": 10,
"november": 11, "nov": 11,
"december": 12, "dec": 12,
}
// Month-Year patterns
monthYear := regexp.MustCompile(`^(\d{4})\s+([A-Za-z]+)$|^([A-Za-z]+)\s+(\d{4})$`)
if m := monthYear.FindStringSubmatch(dateStr); m != nil {
var year int
var monStr string
if m[1] != "" && m[2] != "" {
year, _ = strconv.Atoi(m[1])
monStr = strings.ToLower(m[2])
} else {
year, _ = strconv.Atoi(m[4])
monStr = strings.ToLower(m[3])
}
if mon, ok := monthMap[monStr]; ok {
t := time.Date(year, time.Month(mon), 1, 0, 0, 0, 0, time.UTC)
return t, t.Format("2006-01-02"), nil
}
}
// Day-Month-Year and Month-Day-Year patterns
dmy1 := regexp.MustCompile(`^(\d{1,2})[-\s]+([A-Za-z]+)[-\s]+(\d{4})$`)
if m := dmy1.FindStringSubmatch(dateStr); m != nil {
day, _ := strconv.Atoi(m[1])
year, _ := strconv.Atoi(m[3])
monStr := strings.ToLower(m[2])
if mon, ok := monthMap[monStr]; ok {
t := time.Date(year, time.Month(mon), day, 0, 0, 0, 0, time.UTC)
if t.Day() == day {
return t, t.Format("2006-01-02"), nil
}
}
}
mdy := regexp.MustCompile(`^([A-Za-z]+)[-\s]+(\d{1,2})[-\s]+(\d{4})$`)
if m := mdy.FindStringSubmatch(dateStr); m != nil {
monStr := strings.ToLower(m[1])
day, _ := strconv.Atoi(m[2])
year, _ := strconv.Atoi(m[3])
if mon, ok := monthMap[monStr]; ok {
t := time.Date(year, time.Month(mon), day, 0, 0, 0, 0, time.UTC)
if t.Day() == day {
return t, t.Format("2006-01-02"), nil
}
}
}
ymd := regexp.MustCompile(`^(\d{4})[-\s]+([A-Za-z]+)[-\s]+(\d{1,2})$`)
if m := ymd.FindStringSubmatch(dateStr); m != nil {
year, _ := strconv.Atoi(m[1])
monStr := strings.ToLower(m[2])
day, _ := strconv.Atoi(m[3])
if mon, ok := monthMap[monStr]; ok {
t := time.Date(year, time.Month(mon), day, 0, 0, 0, 0, time.UTC)
if t.Day() == day {
return t, t.Format("2006-01-02"), nil
}
}
}
lower := strings.ToLower(dateStr)
now := time.Now().UTC()
switch lower {
case "today":
t := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
return t, t.Format("2006-01-02"), nil
case "yesterday":
t := now.AddDate(0, 0, -1)
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
return t, t.Format("2006-01-02"), nil
case "tomorrow":
t := now.AddDate(0, 0, 1)
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
return t, t.Format("2006-01-02"), nil
}
daysAgo := regexp.MustCompile(`^(\d+)\s+days?\s+ago$`)
if m := daysAgo.FindStringSubmatch(lower); m != nil {
days, _ := strconv.Atoi(m[1])
t := now.AddDate(0, 0, -days)
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
return t, t.Format("2006-01-02"), nil
}
return time.Time{}, "", fmt.Errorf("unable to parse date: %s", dateStr)
}
func buildDateFilters(before, after, on, during string) (map[string]string, error) {
out := make(map[string]string)
if on != "" {
if during != "" || before != "" || after != "" {
return nil, fmt.Errorf("'on' cannot be combined with other date filters")
}
_, normalized, err := parseFlexibleDate(on)
if err != nil {
return nil, fmt.Errorf("invalid 'on' date: %v", err)
}
out["on"] = normalized
return out, nil
}
if during != "" {
if before != "" || after != "" {
return nil, fmt.Errorf("'during' cannot be combined with 'before' or 'after'")
}
_, normalized, err := parseFlexibleDate(during)
if err != nil {
return nil, fmt.Errorf("invalid 'during' date: %v", err)
}
out["during"] = normalized
return out, nil
}
if after != "" {
_, normalized, err := parseFlexibleDate(after)
if err != nil {
return nil, fmt.Errorf("invalid 'after' date: %v", err)
}
out["after"] = normalized
}
if before != "" {
_, normalized, err := parseFlexibleDate(before)
if err != nil {
return nil, fmt.Errorf("invalid 'before' date: %v", err)
}
out["before"] = normalized
}
if after != "" && before != "" {
a, _, _ := parseFlexibleDate(after)
b, _, _ := parseFlexibleDate(before)
if a.After(b) {
return nil, fmt.Errorf("'after' date is after 'before' date")
}
}
return out, nil
}
func isFilterKey(key string) bool {
_, ok := validFilterKeys[strings.ToLower(key)]
return ok
}
func splitQuery(q string) (freeText []string, filters map[string][]string) {
filters = make(map[string][]string)
for _, tok := range strings.Fields(q) {
parts := strings.SplitN(tok, ":", 2)
if len(parts) == 2 && isFilterKey(parts[0]) {
key := strings.ToLower(parts[0])
filters[key] = append(filters[key], parts[1])
} else {
freeText = append(freeText, tok)
}
}
return
}
func addFilter(filters map[string][]string, key, val string) {
for _, existing := range filters[key] {
if existing == val {
return
}
}
filters[key] = append(filters[key], val)
}
func buildQuery(freeText []string, filters map[string][]string) string {
var out []string
out = append(out, freeText...)
for _, key := range []string{"is", "in", "from", "with", "before", "after", "on", "during"} {
for _, val := range filters[key] {
out = append(out, fmt.Sprintf("%s:%s", key, val))
}
}
return strings.Join(out, " ")
}