From 1d1eb8135fe1090f354a8f032eea4f429365c51f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sisyphus=20=F0=9F=8F=94=EF=B8=8F?= Date: Fri, 29 May 2026 22:49:10 +0800 Subject: [PATCH] feat(messaging/slack): Assistant branding + DataTableBlock skills (#565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1.2: Assistant Status branding - Add DisplayName/IconEmoji to SlackConfig and SlackBotConfig - fillSlackExtras injects display_name/icon_emoji with bot-level override - SetAssistantStatus passes Username/IconEmoji to Slack API Phase 3: DataTableBlock for skills list - Each skill group rendered as DataTableBlock (Name + Description columns) - Vertical layout: all groups visible without interaction - Validator: CardBlock title/body, CarouselBlock 1-10 cards, nil-check on sanitize - Sanitizer: truncate card text, cap carousel cards, drop empty carousels - Fallback via isInvalidBlocksError → postSkillsMessageFallback (plain text) Closes #565 Co-Authored-By: Claude Opus 4.8 --- cmd/hotplex/messaging_init.go | 18 ++++++ configs/config.yaml | 3 + docs/specs/slack-block-kit-upgrade.md | 10 ++-- internal/config/config.go | 8 +++ internal/messaging/skills_helpers.go | 11 ++-- internal/messaging/slack/adapter.go | 8 +++ internal/messaging/slack/skills_list.go | 75 +++++++++++++++---------- internal/messaging/slack/status.go | 8 +++ 8 files changed, 101 insertions(+), 40 deletions(-) diff --git a/cmd/hotplex/messaging_init.go b/cmd/hotplex/messaging_init.go index c6e8218e..df87be76 100644 --- a/cmd/hotplex/messaging_init.go +++ b/cmd/hotplex/messaging_init.go @@ -306,6 +306,24 @@ func fillSlackExtras(acfg *messaging.AdapterConfig, appCfg *config.Config, botCf acfg.Extras["reconnect_base_delay"] = platformCfg.ReconnectBaseDelay acfg.Extras["reconnect_max_delay"] = platformCfg.ReconnectMaxDelay + // Branding: bot-level override with platform-level fallback. + displayName := platformCfg.DisplayName + iconEmoji := platformCfg.IconEmoji + if botCfg != nil { + if botCfg.DisplayName != "" { + displayName = botCfg.DisplayName + } + if botCfg.IconEmoji != "" { + iconEmoji = botCfg.IconEmoji + } + } + if displayName != "" { + acfg.Extras["display_name"] = displayName + } + if iconEmoji != "" { + acfg.Extras["icon_emoji"] = iconEmoji + } + sttCfg := platformCfg.STTConfig ttsCfg := platformCfg.TTSConfig if botCfg != nil { diff --git a/configs/config.yaml b/configs/config.yaml index c757e2ce..a1cc9013 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -278,6 +278,9 @@ messaging: require_mention: true # Override shared defaults here if needed, e.g.: # tts_provider: "edge" + # Assistant status branding (optional): + # display_name: "HotPlex AI" + # icon_emoji: ":robot_face:" # Multi-bot support (uncomment to use): # bots: # - name: tech-support diff --git a/docs/specs/slack-block-kit-upgrade.md b/docs/specs/slack-block-kit-upgrade.md index 09e443db..cdda9f64 100644 --- a/docs/specs/slack-block-kit-upgrade.md +++ b/docs/specs/slack-block-kit-upgrade.md @@ -3,7 +3,7 @@ **Epic Issue**: #565 **分支**: `feat/slack-block-kit-upgrade-565` **前置**: PR #562 (deps upgrade, slack-go v0.24.0 已合并) -**状态**: Phase 2 已完成 (PR #566),Phase 1/3 待后续 PR +**状态**: Phase 1.2 + Phase 2 + Phase 3 已完成,Phase 1.1 AlertBlock 已放弃 --- @@ -161,14 +161,14 @@ Skills 列表和多结果输出用 CardBlock/CarouselBlock 结构化展示。 ## 验收标准 - [ ] Phase 1: ~AlertBlock 在所有错误/状态提示场景替换完成~ → **不可行**,AlertBlock 仅支持 modal surface,不支持 `chat.postMessage` -- [ ] Phase 1: Assistant status 显示 bot username 和 icon(待后续 PR) +- [x] Phase 1: Assistant status 显示 bot username 和 icon(`SlackConfig.DisplayName`/`IconEmoji` → `status.go`) - [ ] Phase 1: `make check` 全量通过 - [x] Phase 2: DataTable 替换所有 TableBlock - [x] Phase 2: `isInvalidBlocksError` helper 统一到 8 个调用点 - [x] Phase 2: validator/sanitizer 支持 DataTableBlock - [x] Phase 2: `make check` 全量通过 + CI 6/6 绿 -- [ ] Phase 3: Skills 列表用 CarouselBlock 展示 -- [ ] Phase 3: 单条消息内无 50-block 限制溢出 +- [x] Phase 3: Skills 列表用 DataTableBlock 展示(每个 SkillGroup → 独立 DataTableBlock,columns: Name / Description) +- [x] Phase 3: 单条消息内无 block 限制溢出(每个 DataTableBlock 占 1 block 位,行数保护 maxDataTableRows) - [ ] Phase 3: `make check` 全量通过 ## 风险 @@ -176,5 +176,5 @@ Skills 列表和多结果输出用 CardBlock/CarouselBlock 结构化展示。 | 风险 | 缓解 | |------|------| | AlertBlock/DataTable 不被部分 workspace 支持 | 保留 fallback 路径 | -| CarouselBlock 移动端渲染差异 | Block Kit Builder 测试 + fallback | +| DataTableBlock 部分工作区不支持 | isInvalidBlocksError → postSkillsMessageFallback | | slack-go v0.24.0 新 API 有 bug | 关注 upstream issues | diff --git a/internal/config/config.go b/internal/config/config.go index c7f3059f..b9605b3c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -267,6 +267,10 @@ type SlackConfig struct { ReconnectBaseDelay time.Duration `mapstructure:"reconnect_base_delay"` ReconnectMaxDelay time.Duration `mapstructure:"reconnect_max_delay"` + // Branding for Assistant status (paid workspaces). + DisplayName string `mapstructure:"display_name,omitempty"` + IconEmoji string `mapstructure:"icon_emoji,omitempty"` + // Multi-bot configuration. When non-empty, takes precedence over top-level credentials. Bots []SlackBotConfig `mapstructure:"bots"` } @@ -287,6 +291,10 @@ type SlackBotConfig struct { AllowDMFrom []string `mapstructure:"allow_dm_from,omitempty"` AllowGroupFrom []string `mapstructure:"allow_group_from,omitempty"` + // Per-bot branding override (falls back to platform-level when empty). + DisplayName string `mapstructure:"display_name,omitempty"` + IconEmoji string `mapstructure:"icon_emoji,omitempty"` + STTConfig `mapstructure:",squash"` TTSConfig `mapstructure:",squash"` } diff --git a/internal/messaging/skills_helpers.go b/internal/messaging/skills_helpers.go index f9ab46bd..c0af1b4e 100644 --- a/internal/messaging/skills_helpers.go +++ b/internal/messaging/skills_helpers.go @@ -7,11 +7,12 @@ import ( ) const ( - SkillsDescMaxRunes = 80 - SkillsDescCutRunes = 77 - SkillsBlockSoftLimit = 48 - SkillsBlockHardLimit = 50 - SkillsPerPage = 20 + SkillsDescMaxRunes = 80 + SkillsDescCutRunes = 77 + // SkillsPerPage controls pagination for plain-text fallback (Slack) and + // the primary path in the Feishu adapter. The Slack DataTableBlock path + // uses maxDataTableRows (validator.go) instead. + SkillsPerPage = 20 SourceProject = "project" SourceGlobal = "global" diff --git a/internal/messaging/slack/adapter.go b/internal/messaging/slack/adapter.go index a48a3c69..6b7b6b40 100644 --- a/internal/messaging/slack/adapter.go +++ b/internal/messaging/slack/adapter.go @@ -99,6 +99,8 @@ type Adapter struct { phrases *phrases.Phrases Extras map[string]any botName string + displayName string + iconEmoji string rateLimiter *ChannelRateLimiter slashLimiter *SlashRateLimiter @@ -159,6 +161,12 @@ func (a *Adapter) ConfigureWith(config messaging.AdapterConfig) error { if config.BotName != "" { a.botName = config.BotName } + if v := config.ExtrasString("display_name"); v != "" { + a.displayName = v + } + if v := config.ExtrasString("icon_emoji"); v != "" { + a.iconEmoji = v + } return nil } diff --git a/internal/messaging/slack/skills_list.go b/internal/messaging/slack/skills_list.go index ad3800de..df574ba4 100644 --- a/internal/messaging/slack/skills_list.go +++ b/internal/messaging/slack/skills_list.go @@ -21,45 +21,60 @@ func (c *SlackConn) sendSkillsList(ctx context.Context, env *events.Envelope) er } groups := messaging.GroupSkillsBySource(d.Skills) - pages := messaging.PaginateSkillGroups(groups, messaging.SkillsPerPage) + // page=1, total=1: non-paginated display, suppresses "Part X/Y" suffix. + header := messaging.SkillsHeader(d, 1, 1) + + // Build DataTableBlocks — one table per skill group. + var blocks []slack.Block + var shown int + blocks = append(blocks, slack.NewSectionBlock( + slack.NewTextBlockObject(slack.PlainTextType, header, false, false), nil, nil)) + + for i, g := range groups { + // Reserve 1 slot for the header SectionBlock above. + if len(blocks) >= maxBlocksPerMessage-1 { + break + } + blocks = append(blocks, buildSkillGroupTable(g, fmt.Sprintf("skills_%s_%d", g.Source, i))) + shown = i + 1 + } + // Append truncation notice if some groups were omitted (very rare: requires 99+ sources). + if shown < len(groups) { + remaining := len(groups) - shown + blocks = append(blocks, slack.NewSectionBlock( + slack.NewTextBlockObject(slack.PlainTextType, + fmt.Sprintf("… and %d more group(s) — use `$skills` for full list", remaining), false, false), + nil, nil)) + } - for i, page := range pages { - var blocks []slack.Block + fallback := header + "\n" + formatSkillsPlainText(groups) + return c.postSkillsMessage(ctx, fallback, blocks) +} - header := messaging.SkillsHeader(d, i+1, len(pages)) - blocks = append(blocks, slack.NewSectionBlock( - slack.NewTextBlockObject(slack.PlainTextType, header, false, false), nil, nil)) - - for _, g := range page { - emoji := messaging.SourceEmoji(g.Source) - - var sb strings.Builder - fmt.Fprintf(&sb, "*%s %s (%d)*\n", emoji, g.Source, len(g.Entries)) - for _, s := range g.Entries { - desc := messaging.TruncateDesc(s.Description) - fmt.Fprintf(&sb, "• %s — %s\n", s.Name, desc) - } - blocks = append(blocks, slack.NewSectionBlock( - slack.NewTextBlockObject(slack.MarkdownType, sb.String(), false, false), nil, nil)) - - if len(blocks) >= messaging.SkillsBlockSoftLimit { - break - } - } +// buildSkillGroupTable creates a DataTableBlock for a single skill group. +func buildSkillGroupTable(g messaging.SkillGroup, blockID string) *slack.DataTableBlock { + emoji := messaging.SourceEmoji(g.Source) + caption := fmt.Sprintf("%s %s (%d)", emoji, g.Source, len(g.Entries)) - if len(blocks) > messaging.SkillsBlockHardLimit { - blocks = blocks[:messaging.SkillsBlockHardLimit] - } + table := slack.NewDataTableBlock(caption, slack.DataTableBlockOptionBlockID(blockID)) - fallback := header + "\n" + formatSkillsPlainText(page) - if err := c.postSkillsMessage(ctx, fallback, blocks); err != nil { - return err + // Header row. + table.AddRow(dataTableCell("Name"), dataTableCell("Description")) + + // Data rows. Cap at maxDataTableRows-1 (excluding header) to prevent Slack rejection. + maxRows := maxDataTableRows - 1 + for i, s := range g.Entries { + if i >= maxRows { + table.AddRow(dataTableCell("..."), dataTableCell(fmt.Sprintf("and %d more", len(g.Entries)-maxRows))) + break } + table.AddRow(dataTableCell(s.Name), dataTableCell(messaging.TruncateDesc(s.Description))) } - return nil + return table } +// postSkillsMessageFallback sends skills as plain text when blocks are rejected. func (c *SlackConn) postSkillsMessageFallback(ctx context.Context, env *events.Envelope) error { d, err := messaging.ExtractSkillsListData(env) if err != nil { diff --git a/internal/messaging/slack/status.go b/internal/messaging/slack/status.go index 6a1d3c02..f0615aab 100644 --- a/internal/messaging/slack/status.go +++ b/internal/messaging/slack/status.go @@ -305,6 +305,8 @@ func (m *StatusManager) shortenPaths(s string) string { } // SetAssistantStatus sets the native assistant status text via Slack API. +// When displayName or iconEmoji are configured, they are included as branding +// on the status event (Username/IconEmoji fields). func (a *Adapter) SetAssistantStatus(ctx context.Context, channelID, threadTS, status string) error { if a.client == nil || threadTS == "" { return nil @@ -315,6 +317,12 @@ func (a *Adapter) SetAssistantStatus(ctx context.Context, channelID, threadTS, s ThreadTS: threadTS, Status: status, } + if a.displayName != "" { + params.Username = a.displayName + } + if a.iconEmoji != "" { + params.IconEmoji = a.iconEmoji + } return a.client.SetAssistantThreadsStatusContext(ctx, params) }