Files
multica/server/internal/integrations/slack/mrkdwn.go
Bohan Jiang cb6616f530 feat(slack): Socket Mode channel.Channel adapter (MUL-3516) (#4523)
* 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>
2026-06-25 14:29:00 +08:00

135 lines
5.4 KiB
Go

// The formatMrkdwn function below is a Go port of the Markdown-to-mrkdwn
// converter (format_message) from Nous Research's Hermes Agent, used under the
// MIT License. Source:
// https://github.com/NousResearch/hermes-agent/blob/main/plugins/platforms/slack/adapter.py
//
// Copyright (c) 2025 Nous Research
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package slack
import (
"regexp"
"strconv"
"strings"
)
// Slack renders its own "mrkdwn" dialect, not standard Markdown: bold is *one*
// star (not two), italic is _underscore_, links are <url|label>, headers and
// ~~strike~~ are not supported. The agent emits standard Markdown, so an
// unconverted reply shows literal `**`, `##`, and `[text](url)` in Slack. This
// converter is a faithful Go port of Hermes Agent's slack `format_message`
// (MIT; see the license notice at the top of this file): protected regions
// (code, converted links, existing Slack
// entities) are stashed behind NUL-delimited placeholders so later passes never
// mangle them, then restored last in reverse order so nested placeholders
// resolve.
var (
reFenced = regexp.MustCompile("(?s)(```(?:[^\\n]*\\n)?.*?```)")
reInlineCode = regexp.MustCompile("(`[^`]+`)")
reMdLink = regexp.MustCompile(`(!?)\[([^\]]+)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)`)
reSlackEntity = regexp.MustCompile(`(<(?:[@#!]|(?:https?|mailto|tel):)[^>\n]+>)`)
reBlockquote = regexp.MustCompile(`(?m)^(>+\s)`)
reHeader = regexp.MustCompile(`(?m)^#{1,6}\s+(.+)$`)
reInnerBold = regexp.MustCompile(`\*\*(.+?)\*\*`)
reBoldItalic = regexp.MustCompile(`\*\*\*(.+?)\*\*\*`)
reBold = regexp.MustCompile(`\*\*(.+?)\*\*`)
reItalic = regexp.MustCompile(`\*(\S(?:[^*\n]*?\S)?)\*`)
reStrike = regexp.MustCompile(`~~(.+?)~~`)
)
// formatMrkdwn converts standard Markdown to Slack mrkdwn.
func formatMrkdwn(content string) string {
if content == "" {
return content
}
p := &mrkdwnPlaceholders{values: map[string]string{}}
text := content
// 1) Protect fenced code blocks, then 2) inline code.
text = reFenced.ReplaceAllStringFunc(text, p.stash)
text = reInlineCode.ReplaceAllStringFunc(text, p.stash)
// 3) Markdown links [text](url) -> <url|text>; image links (![..]) are left
// untouched (Slack does not render inline images from markdown).
text = reMdLink.ReplaceAllStringFunc(text, func(m string) string {
sub := reMdLink.FindStringSubmatch(m)
if sub[1] == "!" {
return m
}
url := strings.TrimSpace(sub[3])
if strings.HasPrefix(url, "<") && strings.HasSuffix(url, ">") {
url = strings.TrimSpace(url[1 : len(url)-1])
}
return p.stash("<" + url + "|" + sub[2] + ">")
})
// 4) Protect existing Slack entities / manual links, 5) blockquote markers.
text = reSlackEntity.ReplaceAllStringFunc(text, p.stash)
text = reBlockquote.ReplaceAllStringFunc(text, p.stash)
// 6) Escape Slack control chars (unescape first so input isn't double-escaped).
text = strings.NewReplacer("&amp;", "&", "&lt;", "<", "&gt;", ">").Replace(text)
text = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;").Replace(text)
// 7) Headers (## Title) -> *Title* (strip redundant bold inside).
text = reHeader.ReplaceAllStringFunc(text, func(m string) string {
inner := strings.TrimSpace(reHeader.FindStringSubmatch(m)[1])
inner = reInnerBold.ReplaceAllString(inner, "$1")
return p.stash("*" + inner + "*")
})
// 8) ***bold italic*** -> *_text_*, 9) **bold** -> *bold*,
// 10) *italic* -> _italic_, 11) ~~strike~~ -> ~strike~.
text = reBoldItalic.ReplaceAllStringFunc(text, func(m string) string {
return p.stash("*_" + reBoldItalic.FindStringSubmatch(m)[1] + "_*")
})
text = reBold.ReplaceAllStringFunc(text, func(m string) string {
return p.stash("*" + reBold.FindStringSubmatch(m)[1] + "*")
})
text = reItalic.ReplaceAllStringFunc(text, func(m string) string {
return p.stash("_" + reItalic.FindStringSubmatch(m)[1] + "_")
})
text = reStrike.ReplaceAllStringFunc(text, func(m string) string {
return p.stash("~" + reStrike.FindStringSubmatch(m)[1] + "~")
})
// 13) Restore placeholders in reverse insertion order (nested ones resolve).
for i := len(p.order) - 1; i >= 0; i-- {
k := p.order[i]
text = strings.ReplaceAll(text, k, p.values[k])
}
return text
}
type mrkdwnPlaceholders struct {
values map[string]string
order []string
n int
}
func (p *mrkdwnPlaceholders) stash(v string) string {
key := "\x00SL" + strconv.Itoa(p.n) + "\x00"
p.n++
p.values[key] = v
p.order = append(p.order, key)
return key
}