mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +02:00
* feat(slack): Socket Mode channel.Channel adapter (MUL-3516) First slice of the Slack adapter: implements channel.Channel (Type/Connect/Disconnect/Send/Capabilities) over Slack Socket Mode, normalizes inbound events to channel.InboundMessage (DM, channel @mention, thread reply; bot-loop + edit/delete guards), decodes the per-installation config/secret blob, and registers the Factory under TypeSlack. No engine, core, or channel_* schema change. Unit-tested (translation, capabilities, config decode, chunking, Send via httptest). Resolvers + engine wiring + Block Kit binding replier follow. Co-authored-by: multica-agent <github@multica.ai> * fix(slack): address adapter review (MUL-3516) - Propagate InboundHandler errors through dispatchEventsAPI/handleSocketEvent to Connect so an infra failure tears down the connection for Supervisor reconnect/backoff instead of being silently swallowed (ACK still happens first). - Capabilities: declare only CapText | CapThreadReply; drop CapRichCard/CapAttachment/CapMessageEdit until those Send paths are wired. - slackChatType: map mpim (multi-party DM) to group, not p2p, so the 'must address bot' filter applies; only 1:1 im is p2p. - Document the group-addressing decision: explicit @bot mention required in groups; mention-free thread continuation deferred to the session-aware layer. - Tests: handler-error propagation, slackChatType table, mpim-requires-mention, capabilities negative assertions. Co-authored-by: multica-agent <github@multica.ai> * refactor(channel): shared channel-agnostic ChatSession service (MUL-3516) Extract the session/append//issue machinery — currently locked inside the Feishu-pinned lark.chatSessionService — into a shared engine.ChatSession parameterized by channel_type + session titles, so every IM adapter reuses it instead of re-implementing it. Logic is verbatim (find-or-create session+binding with unique-violation race re-read; append+touch+reply-target+in-tx dedup Mark; /issue parse with bare-command previous-message fallback) but channel-neutral: command-parse source is supplied by the adapter (enrichment is platform-specific). Backed by a narrow SessionQueries interface so it is unit-tested with an in-memory fake (no DB). /issue parser moved to engine.ParseIssueCommand. Next: migrate Feishu onto it and wire Slack's ResolverSet, removing the lark duplicate. Co-authored-by: multica-agent <github@multica.ai> * fix(channel): decouple session binding key from outbound target (MUL-3516) Addresses Elon's round-2 review. engine.ChatSession.EnsureSession previously keyed the binding on a raw chat id (EnsureSessionInput.ChatID), so a resolver wiring Slack straight through would collapse every @bot thread in one channel into a single chat_session and overwrite last_thread_id. Make the API un-misusable: - EnsureSessionInput.ChatID -> BindingKey: the explicit session-isolation key (Feishu: chat id; Slack DM: channel id; Slack channel: channel id + thread root), documented so a raw threaded-platform chat id is never passed straight through. - Add EnsureSessionInput.BindingConfig (opaque) persisted on the binding's config column, so the real outbound channel/thread is preserved when BindingKey is composite — outbound routing stays separate from the isolation key. - channel.sql CreateChannelChatSessionBinding now writes config (additive, uses the existing NOT NULL column; lark caller passes '{}', no schema change, no Feishu regression). - Tests: TestEnsureSession_ThreadRootIsolation (two thread roots in one channel -> two sessions; same root reuses) and TestEnsureSession_StoresBindingConfig. No production wiring change yet (per review, the not-yet-wired shared service is an accepted preparatory state); this makes the API correct before Feishu/Slack are migrated onto it. Co-authored-by: multica-agent <github@multica.ai> * feat(slack): Slack ResolverSet with thread-root session isolation (MUL-3516) Wires Slack into the channel-agnostic engine.Router via a ResolverSet built on the generic channel_* queries (installation route by team_id, identity + workspace-membership recheck, two-phase dedup, audit) plus the shared engine.ChatSession. No new query, no schema change. slackSessionRouting is the per-message isolation rule (Elon round-2 / Niko round-3): a DM is one session per channel; a channel/group message is isolated by thread root (key = channel:threadRoot, root = inbound thread_ts or the message ts for a top-level @mention), so two @bot threads in one channel are two sessions. The real channel id rides in BindingConfig for outbound; the reply thread is returned separately. Tests cover DM/channel/thread routing, config, and that distinct thread roots isolate while a same-thread follow-up reuses its key. Not yet wired into router.go (still a preparatory commit, per review); Feishu migration onto the shared service, router/config wiring, and the Slack outbound path follow. Co-authored-by: multica-agent <github@multica.ai> * feat(slack): Markdown->mrkdwn outbound formatting (MUL-3516) Slack renders mrkdwn, not Markdown, so an unconverted agent reply shows literal ** , ## and [text](url). Add formatMrkdwn — a faithful Go port of Hermes Agent's slack format_message (MIT) — and apply it in slackChannel.Send before chunking/posting. Protects fenced+inline code, converted links, and existing Slack entities behind placeholders; converts headers/bold/italic/strike/links; escapes control chars. Unit tests cover each construct plus fenced-code protection and a link nested in bold. Co-authored-by: multica-agent <github@multica.ai> * docs(slack): preserve Hermes MIT notice for ported mrkdwn converter (MUL-3516) Addresses Niko's review. formatMrkdwn is a substantial port of Hermes Agent's slack format_message; MIT requires preserving the copyright + permission notice. Add the full Hermes MIT copyright/permission notice + source URL as a header on mrkdwn.go (no repo-level third-party notice file exists, and the header cannot get separated from the ported code). Also add the suggested Send-layer regression test (TestSend_AppliesMrkdwn) that pins the wiring: slackChannel.Send converts Markdown to mrkdwn before posting. Co-authored-by: multica-agent <github@multica.ai> * refactor(lark): migrate Feishu onto shared engine.ChatSession, drop duplicate (MUL-3516) Completes 'every IM reuses one shared session service' and removes the dual-path the reviewers flagged as temporary. Feishu's ResolverSet now drives the channel-agnostic engine.ChatSession (channel_type=feishu, Lark session titles preserved) instead of the Feishu-specific lark.chatSessionService, which is deleted. Behavior is unchanged: engine.ChatSession is the verbatim port of the old logic and is unit-tested; the new Feishu binder param-mapping (BindingKey=chat id, CommandText=un-enriched CommandBody from Raw) is covered by feishu_resolvers_test.go. - Delete chat_service.go (chatSessionService + helpers) and issue_command.go/_test.go (parser now engine.ParseIssueCommand). Relocate the shared TxStarter interface to tx.go (still used by binding-token + registration services). - chat.go keeps only the AuditLogger seam; remove the now-dead ChatSessionService / EnsureChatSessionParams / AppendUserMessageParams / AppendResult / IssueCommand types. - router.go constructs engine.NewChatSession for Feishu; inbound_enricher_test + doc.go updated. make-test parity: go build ./..., go vet, gofmt, and go test ./internal/integrations/{lark,channel/...,slack} all pass (full Feishu suite green). Co-authored-by: multica-agent <github@multica.ai> * feat(slack): wire Slack adapter + ResolverSet + outbound into router (MUL-3516) Activates the full Slack pipeline, gated by MULTICA_SLACK_SECRET_KEY (the bot/app-token decryption key). When unset the block is skipped, so existing deployments are unaffected and Feishu is untouched. - router.go registers slack.RegisterSlack (Socket Mode connect/send Factory) + channelRouter.Register(TypeSlack, NewSlackResolverSet) (inbound pipeline) + slack.NewOutbound(...).Register(bus) (outbound). - New slack/outbound.go: an EventChatDone subscriber mirroring the Feishu Patcher. It finds the Slack chat binding for the finished session, recovers the real channel from the binding config (the channel_chat_id may be a composite thread-isolation key) + the reply thread from last_thread_id, and posts via slackChannel.Send (reusing formatMrkdwn / chunking / threading). Sessions with no Slack binding are ignored, so it coexists with the Feishu Patcher on the shared bus. - Tests: posts to the bound channel/thread with the real channel id; ignores non-Slack sessions, empty completions, revoked installations, and non-chat events. Slack now shares engine.ChatSession, channel_* tables, IssueService and TaskService with Feishu. Remaining: config-driven installation provisioning (an operator currently creates the channel_type='slack' row; the config block shape — which workspace/agent — is a product decision) and a live end-to-end smoke. go build ./..., go vet, gofmt, and go test ./internal/integrations/{slack,channel/...,lark} all pass. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
371 lines
12 KiB
Go
371 lines
12 KiB
Go
package slack
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"testing"
|
|
|
|
"github.com/slack-go/slack"
|
|
"github.com/slack-go/slack/slackevents"
|
|
|
|
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
|
)
|
|
|
|
func testChannel(botUserID string) *slackChannel {
|
|
return newSlackChannel(credentials{TeamID: "T1", BotUserID: botUserID}, nil, nil, nil)
|
|
}
|
|
|
|
func eventsAPI(inner any) slackevents.EventsAPIEvent {
|
|
return slackevents.EventsAPIEvent{
|
|
TeamID: "T1",
|
|
APIAppID: "A1",
|
|
InnerEvent: slackevents.EventsAPIInnerEvent{
|
|
Data: inner,
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestInboundFromMessage_DM(t *testing.T) {
|
|
c := testChannel("UBOT")
|
|
e := eventsAPI(nil)
|
|
msg, ok := c.inboundFromMessage(e, &slackevents.MessageEvent{
|
|
User: "UALICE",
|
|
Text: "hello bot",
|
|
Channel: "D123",
|
|
ChannelType: "im",
|
|
TimeStamp: "1700000000.000100",
|
|
})
|
|
if !ok {
|
|
t.Fatal("expected DM message to be ingestable")
|
|
}
|
|
if msg.Source.ChatType != channel.ChatTypeP2P {
|
|
t.Errorf("ChatType = %q, want p2p", msg.Source.ChatType)
|
|
}
|
|
if !msg.AddressedToBot {
|
|
t.Error("DM should always be addressed to bot")
|
|
}
|
|
if msg.Source.ChannelType != TypeSlack {
|
|
t.Errorf("ChannelType = %q, want slack", msg.Source.ChannelType)
|
|
}
|
|
if msg.MessageID != "1700000000.000100" || msg.EventID != msg.MessageID {
|
|
t.Errorf("MessageID/EventID = %q/%q, want the ts", msg.MessageID, msg.EventID)
|
|
}
|
|
if msg.Source.SenderID != "UALICE" || msg.Source.ChatID != "D123" {
|
|
t.Errorf("sender/chat = %q/%q", msg.Source.SenderID, msg.Source.ChatID)
|
|
}
|
|
if msg.Text != "hello bot" {
|
|
t.Errorf("Text = %q", msg.Text)
|
|
}
|
|
// team_id must be in Raw so the installation resolver can route.
|
|
var raw slackRawEvent
|
|
if err := json.Unmarshal(msg.Raw, &raw); err != nil {
|
|
t.Fatalf("decode raw: %v", err)
|
|
}
|
|
if raw.TeamID != "T1" || raw.EventType != "message" {
|
|
t.Errorf("raw = %+v", raw)
|
|
}
|
|
}
|
|
|
|
func TestInboundFromMessage_ChannelMention(t *testing.T) {
|
|
c := testChannel("UBOT")
|
|
msg, ok := c.inboundFromMessage(eventsAPI(nil), &slackevents.MessageEvent{
|
|
User: "UALICE",
|
|
Text: "<@UBOT> create an issue",
|
|
Channel: "C123",
|
|
ChannelType: "channel",
|
|
TimeStamp: "1700000000.000200",
|
|
})
|
|
if !ok {
|
|
t.Fatal("expected channel message to be ingestable")
|
|
}
|
|
if msg.Source.ChatType != channel.ChatTypeGroup {
|
|
t.Errorf("ChatType = %q, want group", msg.Source.ChatType)
|
|
}
|
|
if !msg.AddressedToBot {
|
|
t.Error("channel message mentioning the bot should be addressed to bot")
|
|
}
|
|
if msg.Text != "create an issue" {
|
|
t.Errorf("Text = %q, want mention stripped", msg.Text)
|
|
}
|
|
}
|
|
|
|
func TestInboundFromMessage_ChannelNoMention(t *testing.T) {
|
|
c := testChannel("UBOT")
|
|
msg, ok := c.inboundFromMessage(eventsAPI(nil), &slackevents.MessageEvent{
|
|
User: "UALICE",
|
|
Text: "just chatting with the team",
|
|
Channel: "C123",
|
|
ChannelType: "channel",
|
|
TimeStamp: "1700000000.000300",
|
|
})
|
|
if !ok {
|
|
t.Fatal("a non-mention channel message is still ingested; the engine group filter drops it")
|
|
}
|
|
if msg.AddressedToBot {
|
|
t.Error("channel message without a mention must not be addressed to bot")
|
|
}
|
|
}
|
|
|
|
func TestInboundFromMessage_ThreadReply(t *testing.T) {
|
|
c := testChannel("UBOT")
|
|
msg, ok := c.inboundFromMessage(eventsAPI(nil), &slackevents.MessageEvent{
|
|
User: "UALICE",
|
|
Text: "<@UBOT> follow up",
|
|
Channel: "C123",
|
|
ChannelType: "channel",
|
|
TimeStamp: "1700000000.000500",
|
|
ThreadTimeStamp: "1700000000.000400",
|
|
})
|
|
if !ok {
|
|
t.Fatal("thread reply should be ingestable")
|
|
}
|
|
if msg.Source.ThreadID != "1700000000.000400" {
|
|
t.Errorf("ThreadID = %q", msg.Source.ThreadID)
|
|
}
|
|
if msg.ReplyTo == nil || msg.ReplyTo.MessageID != "1700000000.000400" {
|
|
t.Errorf("ReplyTo = %+v, want the thread root", msg.ReplyTo)
|
|
}
|
|
}
|
|
|
|
func TestInboundFromMessage_SkipsBotAndOwnAndEdits(t *testing.T) {
|
|
c := testChannel("UBOT")
|
|
cases := []struct {
|
|
name string
|
|
m *slackevents.MessageEvent
|
|
}{
|
|
{"own message", &slackevents.MessageEvent{User: "UBOT", Text: "hi", Channel: "D1", ChannelType: "im", TimeStamp: "1.1"}},
|
|
{"other bot", &slackevents.MessageEvent{User: "UX", BotID: "B1", Text: "hi", Channel: "C1", TimeStamp: "1.2"}},
|
|
{"bot_message subtype", &slackevents.MessageEvent{SubType: "bot_message", Text: "hi", Channel: "C1", TimeStamp: "1.3"}},
|
|
{"edit", &slackevents.MessageEvent{User: "UALICE", SubType: "message_changed", Text: "hi", Channel: "C1", TimeStamp: "1.4"}},
|
|
{"delete", &slackevents.MessageEvent{User: "UALICE", SubType: "message_deleted", Channel: "C1", TimeStamp: "1.5"}},
|
|
{"empty user", &slackevents.MessageEvent{Text: "hi", Channel: "C1", TimeStamp: "1.6"}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if _, ok := c.inboundFromMessage(eventsAPI(nil), tc.m); ok {
|
|
t.Errorf("%s should not be ingested", tc.name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInboundFromAppMention(t *testing.T) {
|
|
c := testChannel("UBOT")
|
|
msg, ok := c.inboundFromAppMention(eventsAPI(nil), &slackevents.AppMentionEvent{
|
|
User: "UALICE",
|
|
Text: "<@UBOT> hi",
|
|
Channel: "C123",
|
|
TimeStamp: "1700000000.000700",
|
|
})
|
|
if !ok {
|
|
t.Fatal("app_mention should be ingestable")
|
|
}
|
|
if msg.Source.ChatType != channel.ChatTypeGroup || !msg.AddressedToBot {
|
|
t.Errorf("app_mention must be a group message addressed to bot: %+v", msg.Source)
|
|
}
|
|
if msg.Text != "hi" {
|
|
t.Errorf("Text = %q, want mention stripped", msg.Text)
|
|
}
|
|
// The bot's own app_mention echo (BotID set) must be skipped.
|
|
if _, ok := c.inboundFromAppMention(eventsAPI(nil), &slackevents.AppMentionEvent{User: "UBOT", Channel: "C1", TimeStamp: "1.9"}); ok {
|
|
t.Error("bot's own mention should be skipped")
|
|
}
|
|
}
|
|
|
|
func TestCapabilitiesAndType(t *testing.T) {
|
|
c := testChannel("UBOT")
|
|
if c.Type() != TypeSlack {
|
|
t.Errorf("Type = %q", c.Type())
|
|
}
|
|
caps := c.Capabilities()
|
|
if !caps.Has(channel.CapText) || !caps.Has(channel.CapThreadReply) {
|
|
t.Errorf("capabilities = %s, want text + thread_reply", caps)
|
|
}
|
|
// Capabilities the Send path cannot fulfil yet must NOT be declared.
|
|
for _, cap := range []channel.Capability{channel.CapRichCard, channel.CapAttachment, channel.CapMessageEdit} {
|
|
if caps.Has(cap) {
|
|
t.Errorf("capability %s must not be declared until implemented", cap)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSlackChatType(t *testing.T) {
|
|
cases := []struct {
|
|
channelID, channelType string
|
|
want channel.ChatType
|
|
}{
|
|
{"D123", "im", channel.ChatTypeP2P},
|
|
{"G123", "mpim", channel.ChatTypeGroup}, // multi-party DM is a group
|
|
{"C123", "channel", channel.ChatTypeGroup},
|
|
{"C123", "private_channel", channel.ChatTypeGroup},
|
|
{"D999", "", channel.ChatTypeP2P}, // fallback by id prefix
|
|
{"C999", "", channel.ChatTypeGroup},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := slackChatType(tc.channelID, tc.channelType); got != tc.want {
|
|
t.Errorf("slackChatType(%q,%q) = %q, want %q", tc.channelID, tc.channelType, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMpimRequiresMention(t *testing.T) {
|
|
c := testChannel("UBOT")
|
|
// A multi-party DM is a group: plain chatter must NOT be addressed to bot.
|
|
msg, ok := c.inboundFromMessage(eventsAPI(nil), &slackevents.MessageEvent{
|
|
User: "UALICE", Text: "team lunch?", Channel: "G123", ChannelType: "mpim", TimeStamp: "1.1",
|
|
})
|
|
if !ok {
|
|
t.Fatal("mpim message should still be ingested (engine group filter decides)")
|
|
}
|
|
if msg.Source.ChatType != channel.ChatTypeGroup {
|
|
t.Errorf("mpim ChatType = %q, want group", msg.Source.ChatType)
|
|
}
|
|
if msg.AddressedToBot {
|
|
t.Error("plain mpim chatter must not be addressed to bot")
|
|
}
|
|
}
|
|
|
|
func TestDispatchEventsAPI_PropagatesHandlerError(t *testing.T) {
|
|
wantErr := errors.New("db down")
|
|
calls := 0
|
|
c := newSlackChannel(credentials{TeamID: "T1", BotUserID: "UBOT"}, nil, func(_ context.Context, _ channel.InboundMessage) error {
|
|
calls++
|
|
return wantErr
|
|
}, nil)
|
|
|
|
e := eventsAPI(&slackevents.MessageEvent{User: "UALICE", Text: "hi", Channel: "D1", ChannelType: "im", TimeStamp: "1.1"})
|
|
if err := c.dispatchEventsAPI(context.Background(), e); !errors.Is(err, wantErr) {
|
|
t.Errorf("dispatchEventsAPI error = %v, want %v (infra error must propagate to Connect→Supervisor)", err, wantErr)
|
|
}
|
|
if calls != 1 {
|
|
t.Errorf("handler called %d times, want 1", calls)
|
|
}
|
|
|
|
// A non-ingestable event (the bot's own message) must not reach the handler
|
|
// and must not error.
|
|
calls = 0
|
|
skip := eventsAPI(&slackevents.MessageEvent{User: "UBOT", Text: "echo", Channel: "D1", ChannelType: "im", TimeStamp: "1.2"})
|
|
if err := c.dispatchEventsAPI(context.Background(), skip); err != nil {
|
|
t.Errorf("skipped event should not error: %v", err)
|
|
}
|
|
if calls != 0 {
|
|
t.Errorf("handler called %d times for skipped event, want 0", calls)
|
|
}
|
|
}
|
|
|
|
func TestDecodeCredentials(t *testing.T) {
|
|
// app_id holds the team_id routing key; tokens stored as base64 plaintext
|
|
// here (nil Decrypter = identity).
|
|
raw := json.RawMessage(`{
|
|
"app_id": "T1",
|
|
"bot_user_id": "UBOT",
|
|
"bot_token_encrypted": "eG94Yi1ib3Q=",
|
|
"app_token_encrypted": "eGFwcC1hcHA="
|
|
}`)
|
|
creds, err := decodeCredentials(raw, nil)
|
|
if err != nil {
|
|
t.Fatalf("decodeCredentials: %v", err)
|
|
}
|
|
if creds.TeamID != "T1" || creds.BotUserID != "UBOT" {
|
|
t.Errorf("creds = %+v", creds)
|
|
}
|
|
if creds.BotToken != "xoxb-bot" || creds.AppToken != "xapp-app" {
|
|
t.Errorf("tokens = %q / %q", creds.BotToken, creds.AppToken)
|
|
}
|
|
if _, err := decodeCredentials(nil, nil); err == nil {
|
|
t.Error("empty config should error")
|
|
}
|
|
}
|
|
|
|
func TestChunkMessage(t *testing.T) {
|
|
if got := chunkMessage("short", 100); len(got) != 1 || got[0] != "short" {
|
|
t.Errorf("short message should be one chunk: %v", got)
|
|
}
|
|
long := make([]rune, 250)
|
|
for i := range long {
|
|
long[i] = 'a'
|
|
}
|
|
chunks := chunkMessage(string(long), 100)
|
|
if len(chunks) != 3 {
|
|
t.Fatalf("250 runes / 100 = 3 chunks, got %d", len(chunks))
|
|
}
|
|
if len([]rune(chunks[0])) != 100 || len([]rune(chunks[2])) != 50 {
|
|
t.Errorf("chunk sizes wrong: %d / %d", len([]rune(chunks[0])), len([]rune(chunks[2])))
|
|
}
|
|
}
|
|
|
|
func TestSend(t *testing.T) {
|
|
var gotForm url.Values
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
gotForm = r.PostForm
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ok":true,"channel":"C123","ts":"1700000000.111111"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
api := slack.New("xoxb-test", slack.OptionAPIURL(srv.URL+"/"))
|
|
c := newSlackChannel(credentials{TeamID: "T1"}, api, nil, nil)
|
|
|
|
res, err := c.Send(context.Background(), channel.OutboundMessage{
|
|
ChatID: "C123",
|
|
Text: "reply body",
|
|
ThreadID: "1700000000.000400",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Send: %v", err)
|
|
}
|
|
if res.MessageID != "1700000000.111111" {
|
|
t.Errorf("MessageID = %q", res.MessageID)
|
|
}
|
|
if gotForm.Get("channel") != "C123" || gotForm.Get("text") != "reply body" {
|
|
t.Errorf("posted channel/text = %q / %q", gotForm.Get("channel"), gotForm.Get("text"))
|
|
}
|
|
if gotForm.Get("thread_ts") != "1700000000.000400" {
|
|
t.Errorf("thread_ts = %q, want the inbound thread", gotForm.Get("thread_ts"))
|
|
}
|
|
}
|
|
|
|
// TestSend_AppliesMrkdwn guards the wiring: Send must run the agent's Markdown
|
|
// through formatMrkdwn before posting, so Slack renders it instead of showing
|
|
// literal markup. (The converter itself is covered in mrkdwn_test.go.)
|
|
func TestSend_AppliesMrkdwn(t *testing.T) {
|
|
var gotText string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
gotText = r.PostForm.Get("text")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ok":true,"channel":"C1","ts":"1.1"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
api := slack.New("xoxb-test", slack.OptionAPIURL(srv.URL+"/"))
|
|
c := newSlackChannel(credentials{TeamID: "T1"}, api, nil, nil)
|
|
|
|
if _, err := c.Send(context.Background(), channel.OutboundMessage{
|
|
ChatID: "C1",
|
|
Text: "**bold** see [docs](http://x.com)",
|
|
}); err != nil {
|
|
t.Fatalf("Send: %v", err)
|
|
}
|
|
if gotText != "*bold* see <http://x.com|docs>" {
|
|
t.Errorf("Send must convert Markdown to mrkdwn before posting, got %q", gotText)
|
|
}
|
|
}
|
|
|
|
func TestOutboundThreadTS(t *testing.T) {
|
|
if got := outboundThreadTS(channel.OutboundMessage{ReplyTo: "111.1", ThreadID: "222.2"}); got != "111.1" {
|
|
t.Errorf("explicit ReplyTo should win: %q", got)
|
|
}
|
|
if got := outboundThreadTS(channel.OutboundMessage{ThreadID: "222.2"}); got != "222.2" {
|
|
t.Errorf("thread fallback: %q", got)
|
|
}
|
|
if got := outboundThreadTS(channel.OutboundMessage{}); got != "" {
|
|
t.Errorf("top-level send has no thread: %q", got)
|
|
}
|
|
}
|