mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
feat(platform): add slack platform support (#385)
* feat(platform): add slack platform support * docs: add slack channel setup guide * feat: normalize slack unicode reactions * chore(docs): remove unsupport feature * fix(slack): harden adapter stream and identity handling - ignore reaction and speech stream events in Slack outbound streams - normalize Slack conversation types to framework-standard values - route DiscoverSelf through the adapter API factory - add config-scoped Slack user display-name caching - expand adapter interface assertions and add regression coverage - add ChannelTypeSlack to well-known channel constants
This commit is contained in:
@@ -1074,6 +1074,7 @@
|
||||
"wechatoa": "WeChat Official Account",
|
||||
"wecom": "WeCom",
|
||||
"dingtalk": "DingTalk",
|
||||
"slack": "Slack",
|
||||
"web": "Web",
|
||||
"cli": "CLI",
|
||||
"local": "Local"
|
||||
@@ -1089,6 +1090,7 @@
|
||||
"wechatoa": "OA",
|
||||
"wecom": "WC",
|
||||
"dingtalk": "DT",
|
||||
"slack": "SK",
|
||||
"web": "Web",
|
||||
"cli": "CLI",
|
||||
"local": "LC"
|
||||
|
||||
@@ -1070,6 +1070,7 @@
|
||||
"wechatoa": "微信服务号",
|
||||
"wecom": "企业微信",
|
||||
"dingtalk": "钉钉",
|
||||
"slack": "Slack",
|
||||
"web": "Web",
|
||||
"cli": "本地 CLI",
|
||||
"local": "本地"
|
||||
@@ -1085,6 +1086,7 @@
|
||||
"wechatoa": "OA",
|
||||
"wecom": "企微",
|
||||
"dingtalk": "钉",
|
||||
"slack": "SK",
|
||||
"web": "Web",
|
||||
"cli": "CLI",
|
||||
"local": "本地"
|
||||
|
||||
@@ -292,7 +292,7 @@ function platformLabel(platformKey: string): string {
|
||||
}
|
||||
|
||||
const platformOptions = computed(() => {
|
||||
const options = new Set<string>(['telegram', 'feishu', 'discord', 'qq', 'matrix'])
|
||||
const options = new Set<string>(['telegram', 'feishu', 'discord', 'qq', 'matrix', 'slack'])
|
||||
for (const identity of identities.value) {
|
||||
const platform = identity.channel.trim()
|
||||
if (platform) {
|
||||
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"github.com/memohai/memoh/internal/channel/adapters/matrix"
|
||||
"github.com/memohai/memoh/internal/channel/adapters/misskey"
|
||||
"github.com/memohai/memoh/internal/channel/adapters/qq"
|
||||
slackadapter "github.com/memohai/memoh/internal/channel/adapters/slack"
|
||||
"github.com/memohai/memoh/internal/channel/adapters/telegram"
|
||||
"github.com/memohai/memoh/internal/channel/adapters/wechatoa"
|
||||
"github.com/memohai/memoh/internal/channel/adapters/wecom"
|
||||
@@ -297,6 +298,11 @@ func provideChannelRegistry(log *slog.Logger, hub *local.RouteHub, mediaService
|
||||
feishuAdapter := feishu.NewFeishuAdapter(log)
|
||||
feishuAdapter.SetAssetOpener(mediaService)
|
||||
registry.MustRegister(feishuAdapter)
|
||||
|
||||
slackAdapter := slackadapter.NewSlackAdapter(log)
|
||||
slackAdapter.SetAssetOpener(mediaService)
|
||||
registry.MustRegister(slackAdapter)
|
||||
|
||||
registry.MustRegister(wecom.NewWeComAdapter(log))
|
||||
|
||||
dingTalkAdapter := dingtalk.NewDingTalkAdapter(log)
|
||||
|
||||
@@ -175,6 +175,10 @@ export const en = [
|
||||
text: 'WeChat Official Account',
|
||||
link: '/channels/wechatoa.md'
|
||||
},
|
||||
{
|
||||
text: 'Slack',
|
||||
link: '/channels/slack.md'
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
@@ -4,6 +4,7 @@ Channels are the gateways that connect your Memoh Bots to the outside world. By
|
||||
|
||||
Memoh currently supports the following channels:
|
||||
|
||||
- **[Slack](./slack)**: Workspace messaging with Socket Mode, threads, files, and reactions.
|
||||
- **[Telegram](./telegram)**: Feature-rich integration with streaming and attachment support.
|
||||
- **[Feishu (Lark)](./feishu)**: Enterprise-ready integration for business workflows.
|
||||
- **[Discord](./discord)**: Community-focused integration for servers and direct messages.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Slack Channel Configuration
|
||||
|
||||
Connecting your Memoh Bot to Slack allows it to receive direct messages, participate in channels and threads, read attachments, send files, and use streaming replies.
|
||||
|
||||
## Step 1: Create a Slack App
|
||||
|
||||
1. Go to the Slack API dashboard and create a new app.
|
||||
2. Choose the workspace where you want to install the bot.
|
||||
3. Open **Basic Information** and keep this app page open for the next steps.
|
||||
|
||||
## Step 2: Enable Socket Mode
|
||||
|
||||
Memoh's Slack adapter uses Socket Mode, so you need an app-level token in addition to the bot token.
|
||||
|
||||
1. In **Basic Information**, enable **Socket Mode**.
|
||||
2. Create an **App-Level Token** with the `connections:write` scope.
|
||||
3. Copy the generated token. It starts with `xapp-`.
|
||||
|
||||
## Step 3: Configure Bot Token Scopes
|
||||
|
||||
In **OAuth & Permissions**, add the bot token scopes required by the current Slack adapter:
|
||||
|
||||
- `app_mentions:read` - receive bot mentions in channels
|
||||
- `channels:history` - read messages in public channels
|
||||
- `groups:history` - read messages in private channels
|
||||
- `im:history` - read direct messages
|
||||
- `mpim:history` - read group direct messages
|
||||
- `chat:write` - send replies and thread messages
|
||||
- `files:read` - read uploaded files and images
|
||||
- `files:write` - upload outbound files
|
||||
- `reactions:write` - add and remove reactions
|
||||
|
||||
You should also add these recommended scopes if you want Slack conversation names and metadata to show up more completely in Memoh:
|
||||
|
||||
- `channels:read`
|
||||
- `groups:read`
|
||||
- `im:read`
|
||||
- `mpim:read`
|
||||
|
||||
## Step 4: Subscribe to Bot Events
|
||||
|
||||
In **Event Subscriptions**, enable bot events and add:
|
||||
|
||||
- `app_mention`
|
||||
- `message.channels`
|
||||
- `message.groups`
|
||||
- `message.im`
|
||||
- `message.mpim`
|
||||
|
||||
These are the inbound event types currently handled by the Slack adapter.
|
||||
|
||||
## Step 5: Install the App to Your Workspace
|
||||
|
||||
1. In **OAuth & Permissions**, click **Install to Workspace**.
|
||||
2. Review the permission screen.
|
||||
3. Authorize the app.
|
||||
4. Copy the **Bot User OAuth Token**. It starts with `xoxb-`.
|
||||
|
||||
Make sure the `xoxb-...` bot token and the `xapp-...` app-level token come from the same Slack app and workspace.
|
||||
|
||||
## Step 6: Configure Memoh
|
||||
|
||||
1. Open your Bot detail page in the Memoh Web UI.
|
||||
2. Go to the **Platforms** tab.
|
||||
3. Click **Add Channel** and select **Slack**.
|
||||
4. Fill in:
|
||||
- **Bot Token**: your `xoxb-...` token
|
||||
- **App-Level Token**: your `xapp-...` token
|
||||
5. Click **Save and Enable**.
|
||||
|
||||
## Step 7: Add the Bot to Conversations
|
||||
|
||||
After the channel is enabled, the Slack app still needs to be present in the conversations where you want it to work.
|
||||
|
||||
- For direct messages: open a DM with the app and send a message.
|
||||
- For public channels: invite the bot to the channel.
|
||||
- For private channels: invite the bot explicitly after installation.
|
||||
|
||||
If the bot can send messages but cannot read uploaded images or files, check that `files:read` is enabled. If it connects but receives no incoming messages, check the bot events and the matching history scopes again.
|
||||
|
||||
## Features Supported
|
||||
|
||||
- **Direct Messages and Channels**: Support for DMs, public channels, private channels, and threads.
|
||||
- **Attachments**: Read uploaded images and files from Slack, and send files back.
|
||||
@@ -26,6 +26,7 @@ Configure your bot's connections from the **Platforms** tab in the Bot Detail pa
|
||||
| WeCom (WeWork) | [WeCom Configuration](/channels/wecom) | Enterprise workspace integration |
|
||||
| WeChat | [WeChat Configuration](/channels/weixin) | Personal QR login flow |
|
||||
| WeChat Official Account | [WeChat Official Account Configuration](/channels/wechatoa) | Official account webhook flow |
|
||||
| Slack | [Slack Configuration](/channels/slack) | Replies, no streaming |
|
||||
|
||||
Two WeChat adapters exist on purpose:
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ require (
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kenshaw/emoji v0.4.1 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
@@ -157,6 +158,7 @@ require (
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/segmentio/encoding v0.5.4 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/slack-go/slack v0.19.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
|
||||
@@ -257,6 +257,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kenshaw/emoji v0.4.1 h1:w0SQHeU4iLt+6UCulY88Zr3uIjBU23mjOlbDs4trif8=
|
||||
github.com/kenshaw/emoji v0.4.1/go.mod h1:elpkKAS92j09SJvW/0sXfdMgltL7TQi7ToZD4tehPko=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
@@ -391,6 +393,8 @@ github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/slack-go/slack v0.19.0 h1:J8lL/nGTsIUX53HU8YxZeI3PDkA+sxZsFrI2Dew7h44=
|
||||
github.com/slack-go/slack v0.19.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/memohai/memoh/internal/channel"
|
||||
)
|
||||
|
||||
// Config holds the Slack bot credentials extracted from a channel configuration.
|
||||
type Config struct {
|
||||
BotToken string // xoxb-...
|
||||
AppToken string // xapp-... (required for Socket Mode)
|
||||
}
|
||||
|
||||
// UserConfig holds the identifiers used to target a Slack user or channel.
|
||||
type UserConfig struct {
|
||||
UserID string
|
||||
ChannelID string
|
||||
Username string
|
||||
}
|
||||
|
||||
func normalizeConfig(raw map[string]any) (map[string]any, error) {
|
||||
cfg, err := parseConfig(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"botToken": cfg.BotToken,
|
||||
"appToken": cfg.AppToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeUserConfig(raw map[string]any) (map[string]any, error) {
|
||||
cfg, err := parseUserConfig(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := map[string]any{}
|
||||
if cfg.UserID != "" {
|
||||
result["user_id"] = cfg.UserID
|
||||
}
|
||||
if cfg.ChannelID != "" {
|
||||
result["channel_id"] = cfg.ChannelID
|
||||
}
|
||||
if cfg.Username != "" {
|
||||
result["username"] = cfg.Username
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func resolveTarget(raw map[string]any) (string, error) {
|
||||
cfg, err := parseUserConfig(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if cfg.ChannelID != "" {
|
||||
return cfg.ChannelID, nil
|
||||
}
|
||||
if cfg.UserID != "" {
|
||||
return cfg.UserID, nil
|
||||
}
|
||||
return "", errors.New("slack binding is incomplete")
|
||||
}
|
||||
|
||||
func matchBinding(raw map[string]any, criteria channel.BindingCriteria) bool {
|
||||
cfg, err := parseUserConfig(raw)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if value := strings.TrimSpace(criteria.Attribute("user_id")); value != "" && value == cfg.UserID {
|
||||
return true
|
||||
}
|
||||
if value := strings.TrimSpace(criteria.Attribute("channel_id")); value != "" && value == cfg.ChannelID {
|
||||
return true
|
||||
}
|
||||
if value := strings.TrimSpace(criteria.Attribute("username")); value != "" && strings.EqualFold(value, cfg.Username) {
|
||||
return true
|
||||
}
|
||||
if criteria.SubjectID != "" {
|
||||
if criteria.SubjectID == cfg.UserID || criteria.SubjectID == cfg.ChannelID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildUserConfig(identity channel.Identity) map[string]any {
|
||||
result := map[string]any{}
|
||||
if value := strings.TrimSpace(identity.Attribute("user_id")); value != "" {
|
||||
result["user_id"] = value
|
||||
}
|
||||
if value := strings.TrimSpace(identity.Attribute("channel_id")); value != "" {
|
||||
result["channel_id"] = value
|
||||
}
|
||||
if value := strings.TrimSpace(identity.Attribute("username")); value != "" {
|
||||
result["username"] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseConfig(raw map[string]any) (Config, error) {
|
||||
botToken := strings.TrimSpace(channel.ReadString(raw, "botToken", "bot_token"))
|
||||
if botToken == "" {
|
||||
return Config{}, errors.New("slack botToken is required")
|
||||
}
|
||||
appToken := strings.TrimSpace(channel.ReadString(raw, "appToken", "app_token"))
|
||||
if appToken == "" {
|
||||
return Config{}, errors.New("slack appToken is required for Socket Mode")
|
||||
}
|
||||
return Config{BotToken: botToken, AppToken: appToken}, nil
|
||||
}
|
||||
|
||||
func parseUserConfig(raw map[string]any) (UserConfig, error) {
|
||||
userID := strings.TrimSpace(channel.ReadString(raw, "userId", "user_id"))
|
||||
channelID := strings.TrimSpace(channel.ReadString(raw, "channelId", "channel_id"))
|
||||
username := strings.TrimSpace(channel.ReadString(raw, "username"))
|
||||
|
||||
if userID == "" && channelID == "" {
|
||||
return UserConfig{}, errors.New("slack user config requires user_id or channel_id")
|
||||
}
|
||||
|
||||
return UserConfig{
|
||||
UserID: userID,
|
||||
ChannelID: channelID,
|
||||
Username: username,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeTarget(raw string) string {
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package slack
|
||||
|
||||
import "github.com/memohai/memoh/internal/channel"
|
||||
|
||||
const Type channel.ChannelType = "slack"
|
||||
@@ -0,0 +1,91 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/kenshaw/emoji"
|
||||
)
|
||||
|
||||
// resolveSlackEmoji converts a Unicode emoji character to its Slack shortcode
|
||||
// name using the Gemoji dataset. Slack's reactions.add API requires shortcode
|
||||
// names (e.g. "thumbsup") rather than Unicode characters (e.g. "👍").
|
||||
// If the input is already a valid shortcode (ASCII text) or cannot be resolved,
|
||||
// it is returned after stripping any surrounding colons.
|
||||
func resolveSlackEmoji(raw string) string {
|
||||
raw = strings.Trim(raw, ":")
|
||||
if raw == "" {
|
||||
return raw
|
||||
}
|
||||
if resolved, ok := resolveSlackEmojiAlias(raw); ok {
|
||||
return resolved
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func resolveSlackEmojiAlias(raw string) (string, bool) {
|
||||
e := emoji.FromCode(raw)
|
||||
if e != nil && len(e.Aliases) > 0 {
|
||||
return e.Aliases[0], true
|
||||
}
|
||||
|
||||
base, tone, changed := splitEmojiSkinTone(raw)
|
||||
if !changed {
|
||||
return "", false
|
||||
}
|
||||
|
||||
e = emoji.FromCode(base)
|
||||
if e == nil || len(e.Aliases) == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
alias := e.Aliases[0]
|
||||
if tone == emoji.Neutral {
|
||||
return alias, true
|
||||
}
|
||||
return alias + "::skin-tone-" + slackSkinToneSuffix(tone), true
|
||||
}
|
||||
|
||||
func splitEmojiSkinTone(raw string) (string, emoji.SkinTone, bool) {
|
||||
var (
|
||||
tone emoji.SkinTone
|
||||
changed bool
|
||||
runes = make([]rune, 0, utf8.RuneCountInString(raw))
|
||||
)
|
||||
|
||||
for _, r := range raw {
|
||||
switch emoji.SkinTone(r) {
|
||||
case emoji.Light, emoji.MediumLight, emoji.Medium, emoji.MediumDark, emoji.Dark:
|
||||
tone = emoji.SkinTone(r)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if r == '\uFE0F' {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
runes = append(runes, r)
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return raw, emoji.Neutral, false
|
||||
}
|
||||
return string(runes), tone, true
|
||||
}
|
||||
|
||||
func slackSkinToneSuffix(tone emoji.SkinTone) string {
|
||||
switch tone {
|
||||
case emoji.Light:
|
||||
return "2"
|
||||
case emoji.MediumLight:
|
||||
return "3"
|
||||
case emoji.Medium:
|
||||
return "4"
|
||||
case emoji.MediumDark:
|
||||
return "5"
|
||||
case emoji.Dark:
|
||||
return "6"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,395 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
slackapi "github.com/slack-go/slack"
|
||||
|
||||
"github.com/memohai/memoh/internal/channel"
|
||||
)
|
||||
|
||||
const (
|
||||
slackStreamUpdateThrottle = 1500 * time.Millisecond
|
||||
slackStreamRetryFallback = 2 * time.Second
|
||||
slackStreamFinalMaxRetries = 3
|
||||
)
|
||||
|
||||
type slackOutboundStream struct {
|
||||
adapter *SlackAdapter
|
||||
cfg channel.ChannelConfig
|
||||
target string
|
||||
reply *channel.ReplyRef
|
||||
api *slackapi.Client
|
||||
closed atomic.Bool
|
||||
mu sync.Mutex
|
||||
msgTS string // Slack message timestamp (used as message ID)
|
||||
buffer strings.Builder
|
||||
lastSent string
|
||||
lastUpdate time.Time
|
||||
nextUpdate time.Time
|
||||
}
|
||||
|
||||
var _ channel.PreparedOutboundStream = (*slackOutboundStream)(nil)
|
||||
|
||||
func (s *slackOutboundStream) Push(ctx context.Context, event channel.PreparedStreamEvent) error {
|
||||
if s == nil || s.adapter == nil {
|
||||
return errors.New("slack stream not configured")
|
||||
}
|
||||
if s.closed.Load() {
|
||||
return errors.New("slack stream is closed")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case channel.StreamEventStatus:
|
||||
if event.Status == channel.StreamStatusStarted {
|
||||
return s.ensureMessage(ctx, "Thinking...")
|
||||
}
|
||||
return nil
|
||||
|
||||
case channel.StreamEventDelta:
|
||||
if event.Delta == "" || event.Phase == channel.StreamPhaseReasoning {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.buffer.WriteString(event.Delta)
|
||||
s.mu.Unlock()
|
||||
|
||||
return s.updateMessage(ctx)
|
||||
|
||||
case channel.StreamEventFinal:
|
||||
if event.Final == nil {
|
||||
return errors.New("slack stream final payload is required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
bufText := strings.TrimSpace(s.buffer.String())
|
||||
s.mu.Unlock()
|
||||
finalText := bufText
|
||||
if authoritative := strings.TrimSpace(event.Final.Message.Message.PlainText()); authoritative != "" {
|
||||
finalText = authoritative
|
||||
}
|
||||
if finalText != "" {
|
||||
if err := s.finalizeMessage(ctx, finalText); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := s.clearPlaceholder(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, att := range event.Final.Message.Attachments {
|
||||
if err := s.sendAttachment(ctx, att); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
case channel.StreamEventError:
|
||||
errText := channel.RedactIMErrorText(strings.TrimSpace(event.Error))
|
||||
if errText == "" {
|
||||
return nil
|
||||
}
|
||||
return s.finalizeMessage(ctx, "Error: "+errText)
|
||||
|
||||
case channel.StreamEventAttachment:
|
||||
if len(event.Attachments) == 0 {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
finalText := strings.TrimSpace(s.buffer.String())
|
||||
s.mu.Unlock()
|
||||
if finalText != "" {
|
||||
if err := s.finalizeMessage(ctx, finalText); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := s.clearPlaceholder(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, att := range event.Attachments {
|
||||
if err := s.sendAttachment(ctx, att); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
case channel.StreamEventAgentStart, channel.StreamEventAgentEnd,
|
||||
channel.StreamEventPhaseStart, channel.StreamEventPhaseEnd,
|
||||
channel.StreamEventProcessingStarted, channel.StreamEventProcessingCompleted,
|
||||
channel.StreamEventProcessingFailed,
|
||||
channel.StreamEventToolCallStart, channel.StreamEventToolCallEnd,
|
||||
channel.StreamEventReaction, channel.StreamEventSpeech:
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported stream event type: %s", event.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *slackOutboundStream) Close(ctx context.Context) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
s.closed.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *slackOutboundStream) ensureMessage(ctx context.Context, text string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.msgTS != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
text = truncateSlackText(text)
|
||||
|
||||
ts, err := s.postMessageWithRetry(ctx, text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.msgTS = ts
|
||||
s.lastSent = normalizeSlackStreamText(text)
|
||||
s.lastUpdate = time.Now()
|
||||
s.nextUpdate = s.lastUpdate.Add(slackStreamUpdateThrottle)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *slackOutboundStream) updateMessage(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
msgTS := s.msgTS
|
||||
content := truncateSlackText(strings.TrimSpace(s.buffer.String()))
|
||||
lastSent := s.lastSent
|
||||
nextUpdate := s.nextUpdate
|
||||
s.mu.Unlock()
|
||||
|
||||
if msgTS == "" || content == "" {
|
||||
return nil
|
||||
}
|
||||
if normalizeSlackStreamText(content) == normalizeSlackStreamText(lastSent) {
|
||||
return nil
|
||||
}
|
||||
if time.Now().Before(nextUpdate) {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := s.updateMessageText(ctx, msgTS, content)
|
||||
if err == nil {
|
||||
s.mu.Lock()
|
||||
s.lastSent = normalizeSlackStreamText(content)
|
||||
s.lastUpdate = time.Now()
|
||||
s.nextUpdate = s.lastUpdate.Add(slackStreamUpdateThrottle)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if delay, ok := slackRetryDelay(err); ok {
|
||||
s.mu.Lock()
|
||||
s.nextUpdate = time.Now().Add(delay)
|
||||
s.mu.Unlock()
|
||||
if s.adapter != nil && s.adapter.logger != nil {
|
||||
s.adapter.logger.Warn("slack stream update throttled",
|
||||
slog.String("config_id", s.cfg.ID),
|
||||
slog.String("target", s.target),
|
||||
slog.Duration("retry_after", delay),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if s.adapter != nil && s.adapter.logger != nil {
|
||||
s.adapter.logger.Warn("slack stream update failed",
|
||||
slog.String("config_id", s.cfg.ID),
|
||||
slog.String("target", s.target),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *slackOutboundStream) finalizeMessage(ctx context.Context, text string) error {
|
||||
s.mu.Lock()
|
||||
text = truncateSlackText(text)
|
||||
msgTS := s.msgTS
|
||||
lastSent := s.lastSent
|
||||
s.mu.Unlock()
|
||||
|
||||
if normalizeSlackStreamText(text) == normalizeSlackStreamText(lastSent) && msgTS != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if msgTS == "" {
|
||||
ts, err := s.postMessageWithRetry(ctx, text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.msgTS = ts
|
||||
s.lastSent = normalizeSlackStreamText(text)
|
||||
s.lastUpdate = time.Now()
|
||||
s.nextUpdate = s.lastUpdate.Add(slackStreamUpdateThrottle)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
err := s.updateMessageTextWithRetry(ctx, msgTS, text)
|
||||
if err == nil {
|
||||
s.mu.Lock()
|
||||
s.lastSent = normalizeSlackStreamText(text)
|
||||
s.lastUpdate = time.Now()
|
||||
s.nextUpdate = s.lastUpdate.Add(slackStreamUpdateThrottle)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
if s.adapter != nil && s.adapter.logger != nil {
|
||||
s.adapter.logger.Warn("slack stream final update failed, falling back to new message",
|
||||
slog.String("config_id", s.cfg.ID),
|
||||
slog.String("target", s.target),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
|
||||
if err := s.clearPlaceholder(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ts, postErr := s.postMessageWithRetry(ctx, text)
|
||||
if postErr != nil {
|
||||
return postErr
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.msgTS = ts
|
||||
s.lastSent = normalizeSlackStreamText(text)
|
||||
s.lastUpdate = time.Now()
|
||||
s.nextUpdate = s.lastUpdate.Add(slackStreamUpdateThrottle)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *slackOutboundStream) clearPlaceholder(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
msgTS := s.msgTS
|
||||
s.mu.Unlock()
|
||||
|
||||
if msgTS == "" {
|
||||
return nil
|
||||
}
|
||||
if _, _, err := s.api.DeleteMessageContext(ctx, s.target, msgTS); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.msgTS = ""
|
||||
s.lastSent = ""
|
||||
s.lastUpdate = time.Time{}
|
||||
s.nextUpdate = time.Time{}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *slackOutboundStream) sendAttachment(ctx context.Context, att channel.PreparedAttachment) error {
|
||||
threadTS := ""
|
||||
if s.reply != nil && s.reply.MessageID != "" {
|
||||
threadTS = s.reply.MessageID
|
||||
}
|
||||
return s.adapter.uploadPreparedAttachment(ctx, s.api, s.target, threadTS, att)
|
||||
}
|
||||
|
||||
func (s *slackOutboundStream) postMessageWithRetry(ctx context.Context, text string) (string, error) {
|
||||
opts := []slackapi.MsgOption{
|
||||
slackapi.MsgOptionText(text, false),
|
||||
}
|
||||
if s.reply != nil && s.reply.MessageID != "" {
|
||||
opts = append(opts, slackapi.MsgOptionTS(s.reply.MessageID))
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < slackStreamFinalMaxRetries; attempt++ {
|
||||
_, ts, err := s.api.PostMessageContext(ctx, s.target, opts...)
|
||||
if err == nil {
|
||||
return ts, nil
|
||||
}
|
||||
lastErr = err
|
||||
delay, ok := slackRetryDelay(err)
|
||||
if !ok {
|
||||
return "", err
|
||||
}
|
||||
if err := sleepWithContext(ctx, delay); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
func (s *slackOutboundStream) updateMessageText(ctx context.Context, msgTS string, text string) error {
|
||||
_, _, _, err := s.api.UpdateMessageContext(
|
||||
ctx,
|
||||
s.target,
|
||||
msgTS,
|
||||
slackapi.MsgOptionText(text, false),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *slackOutboundStream) updateMessageTextWithRetry(ctx context.Context, msgTS string, text string) error {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < slackStreamFinalMaxRetries; attempt++ {
|
||||
err := s.updateMessageText(ctx, msgTS, text)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
delay, ok := slackRetryDelay(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
if err := sleepWithContext(ctx, delay); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func slackRetryDelay(err error) (time.Duration, bool) {
|
||||
var rateLimitedErr *slackapi.RateLimitedError
|
||||
if errors.As(err, &rateLimitedErr) {
|
||||
if rateLimitedErr.RetryAfter > 0 {
|
||||
return rateLimitedErr.RetryAfter, true
|
||||
}
|
||||
return slackStreamRetryFallback, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func sleepWithContext(ctx context.Context, delay time.Duration) error {
|
||||
if delay <= 0 {
|
||||
return nil
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSlackStreamText(value string) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
@@ -446,4 +446,5 @@ const (
|
||||
ChannelTypeWeixin ChannelType = "weixin"
|
||||
ChannelTypeWeChatOA ChannelType = "wechatoa"
|
||||
ChannelTypeLocal ChannelType = "local"
|
||||
ChannelTypeSlack ChannelType = "slack"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user