From a6d59fa239f3a5c82c151ec74b36d25f3a35e741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sisyphus=20=F0=9F=8F=94=EF=B8=8F?= Date: Thu, 21 May 2026 23:13:00 +0800 Subject: [PATCH 1/8] feat(messaging/feishu): use CardKit v2 action buttons for AskUserQuestion options Replace markdown bullet list with CardKit v2 `action` elements containing `copy_text` buttons. Users tap a button to copy the option label, then paste it in chat to respond. Options with descriptions show a numbered reference list above the buttons for context. Fixes #457 Co-Authored-By: Claude Opus 4.7 --- internal/messaging/feishu/card_template.go | 59 ++++++++++ .../messaging/feishu/card_template_test.go | 106 ++++++++++++++++++ internal/messaging/feishu/interaction.go | 35 ++---- 3 files changed, 175 insertions(+), 25 deletions(-) diff --git a/internal/messaging/feishu/card_template.go b/internal/messaging/feishu/card_template.go index bc7c00ff..08618eae 100644 --- a/internal/messaging/feishu/card_template.go +++ b/internal/messaging/feishu/card_template.go @@ -3,7 +3,10 @@ package feishu import ( "fmt" "path/filepath" + "slices" "strings" + + "github.com/hrygo/hotplex/pkg/events" ) // Card header template color constants (Feishu CardKit v2). @@ -168,3 +171,59 @@ func turnTags(turnNum int, model, branch, workDir string) []cardTag { } return tags } + +// buildQuestionElements builds CardKit v2 body elements for a question card. +// Each question gets a markdown element (with numbered descriptions if present) +// followed by an action element with copy_text buttons. +func buildQuestionElements(questions []events.Question) []map[string]any { + var elements []map[string]any + + for _, q := range questions { + headerLabel := q.Header + if headerLabel == "" { + headerLabel = "Question" + } + + var sb strings.Builder + fmt.Fprintf(&sb, "**%s**\n%s", headerLabel, q.Question) + + // If any option has a description, show a numbered reference list. + if slices.ContainsFunc(q.Options, func(o events.QuestionOption) bool { return o.Description != "" }) { + sb.WriteString("\n\n") + for i, opt := range q.Options { + if opt.Description != "" { + fmt.Fprintf(&sb, "%d. **%s** — %s\n", i+1, opt.Label, opt.Description) + } else { + fmt.Fprintf(&sb, "%d. **%s**\n", i+1, opt.Label) + } + } + } + + elements = append(elements, map[string]any{ + "tag": "markdown", + "content": sb.String(), + }) + + // Action buttons with copy_text behavior. + if len(q.Options) > 0 { + buttons := make([]map[string]any, 0, len(q.Options)) + for _, opt := range q.Options { + buttons = append(buttons, map[string]any{ + "tag": "button", + "text": map[string]any{"tag": "plain_text", "content": opt.Label}, + "type": "default", + "click": map[string]any{ + "tag": "copy_text", + "value": opt.Label, + }, + }) + } + elements = append(elements, map[string]any{ + "tag": "action", + "actions": buttons, + }) + } + } + + return elements +} diff --git a/internal/messaging/feishu/card_template_test.go b/internal/messaging/feishu/card_template_test.go index 38df8aa6..fa0534fb 100644 --- a/internal/messaging/feishu/card_template_test.go +++ b/internal/messaging/feishu/card_template_test.go @@ -5,6 +5,8 @@ import ( "testing" "github.com/stretchr/testify/require" + + "github.com/hrygo/hotplex/pkg/events" ) func TestCardHeaderToMap(t *testing.T) { @@ -165,3 +167,107 @@ func TestStringPtr(t *testing.T) { require.NotNil(t, p) require.Equal(t, "test", *p) } + +func TestBuildQuestionElements(t *testing.T) { + t.Parallel() + + t.Run("options without descriptions", func(t *testing.T) { + t.Parallel() + questions := []events.Question{ + { + Header: "Auth method", + Question: "Which library?", + Options: []events.QuestionOption{ + {Label: "JWT"}, + {Label: "Session"}, + {Label: "OAuth"}, + }, + }, + } + elements := buildQuestionElements(questions) + + // Expect: markdown + action + require.Len(t, elements, 2) + + // Markdown element: no numbered list (no descriptions) + md := elements[0] + require.Equal(t, "markdown", md["tag"]) + content := md["content"].(string) + require.Contains(t, content, "**Auth method**") + require.Contains(t, content, "Which library?") + require.NotContains(t, content, "1.") // no numbered list + + // Action element: 3 buttons + action := elements[1] + require.Equal(t, "action", action["tag"]) + buttons := action["actions"].([]map[string]any) + require.Len(t, buttons, 3) + require.Equal(t, "button", buttons[0]["tag"]) + require.Equal(t, "JWT", buttons[0]["text"].(map[string]any)["content"]) + click := buttons[0]["click"].(map[string]any) + require.Equal(t, "copy_text", click["tag"]) + require.Equal(t, "JWT", click["value"]) + }) + + t.Run("options with descriptions", func(t *testing.T) { + t.Parallel() + questions := []events.Question{ + { + Header: "Auth", + Question: "Pick one", + Options: []events.QuestionOption{ + {Label: "JWT", Description: "Token-based"}, + {Label: "Session", Description: "Server-side"}, + }, + }, + } + elements := buildQuestionElements(questions) + require.Len(t, elements, 2) + + // Markdown should include numbered list with descriptions + md := elements[0] + content := md["content"].(string) + require.Contains(t, content, "1. **JWT** — Token-based") + require.Contains(t, content, "2. **Session** — Server-side") + + // Buttons still have label only + buttons := elements[1]["actions"].([]map[string]any) + require.Len(t, buttons, 2) + require.Equal(t, "JWT", buttons[0]["text"].(map[string]any)["content"]) + }) + + t.Run("no options", func(t *testing.T) { + t.Parallel() + questions := []events.Question{ + {Header: "Q", Question: "What?"}, + } + elements := buildQuestionElements(questions) + require.Len(t, elements, 1) + require.Equal(t, "markdown", elements[0]["tag"]) + }) + + t.Run("multiple questions", func(t *testing.T) { + t.Parallel() + questions := []events.Question{ + {Header: "Q1", Question: "First?", Options: []events.QuestionOption{{Label: "A"}, {Label: "B"}}}, + {Header: "Q2", Question: "Second?", Options: []events.QuestionOption{{Label: "C"}}}, + } + elements := buildQuestionElements(questions) + // Q1: markdown + action, Q2: markdown + action = 4 + require.Len(t, elements, 4) + require.Equal(t, "markdown", elements[0]["tag"]) + require.Equal(t, "action", elements[1]["tag"]) + require.Equal(t, "markdown", elements[2]["tag"]) + require.Equal(t, "action", elements[3]["tag"]) + }) + + t.Run("empty header defaults to Question", func(t *testing.T) { + t.Parallel() + questions := []events.Question{ + {Question: "What?"}, + } + elements := buildQuestionElements(questions) + content := elements[0]["content"].(string) + require.Contains(t, content, "**Question**") + }) +} diff --git a/internal/messaging/feishu/interaction.go b/internal/messaging/feishu/interaction.go index 3b2277c7..2f009786 100644 --- a/internal/messaging/feishu/interaction.go +++ b/internal/messaging/feishu/interaction.go @@ -67,40 +67,25 @@ func (c *FeishuConn) sendPermissionRequest(ctx context.Context, env *events.Enve return nil } -// sendQuestionRequest posts a question request card to Feishu. +// sendQuestionRequest posts a question request card to Feishu with CardKit v2 +// action buttons. Each button uses copy_text click behavior so users can tap +// to copy the option label and paste it as their response. func (c *FeishuConn) sendQuestionRequest(ctx context.Context, env *events.Envelope) error { data, err := messaging.ExtractQuestionData(env) if err != nil { return fmt.Errorf("feishu: extract question data: %w", err) } - var sb strings.Builder - for _, q := range data.Questions { - headerLabel := q.Header - if headerLabel == "" { - headerLabel = "Question" - } - fmt.Fprintf(&sb, "**%s**\n%s\n", headerLabel, q.Question) - - // List options - if len(q.Options) > 0 { - for _, opt := range q.Options { - label := opt.Label - if opt.Description != "" { - label += " — " + opt.Description - } - fmt.Fprintf(&sb, "- %s\n", label) - } - } - sb.WriteString("\n") - } + elements := buildQuestionElements(data.Questions) + elements = append(elements, + map[string]any{"tag": "hr"}, + map[string]any{"tag": "markdown", "content": "💬 点击按钮复制选项文本,粘贴发送即可响应\n也可直接回复选项文本或自定义答案"}, + ) - footer := "---\n💬 回复选项文本或自定义答案来响应此问题" - - cardJSON := buildInteractionCard(sb.String(), footer, cardHeader{ + cardJSON := buildCard(cardHeader{ Title: "用户输入请求", Template: headerYellow, - }) + }, map[string]any{"wide_screen_mode": true}, elements) chatID := c.chatID if err := c.adapter.sendCardMessage(ctx, chatID, cardJSON); err != nil { From 18f14f68d656c99d39dc3f5600b9c3ce6be0dc97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sisyphus=20=F0=9F=8F=94=EF=B8=8F?= Date: Fri, 22 May 2026 07:45:45 +0800 Subject: [PATCH 2/8] fix(messaging/feishu): use JSON 1.0 for question cards to support action + copy_text JSON 2.0 does not support tag:"action" interactive modules or copy_text click behavior (confirmed by official Feishu docs). Question cards now use buildV1Card which omits the schema field, defaulting to JSON 1.0 where action + copy_text buttons work correctly. Co-Authored-By: Claude Opus 4.7 --- internal/messaging/feishu/card_template.go | 13 ++++ .../messaging/feishu/card_template_test.go | 60 +++++++++++++++++++ internal/messaging/feishu/interaction.go | 2 +- 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/internal/messaging/feishu/card_template.go b/internal/messaging/feishu/card_template.go index 08618eae..ecf80bf6 100644 --- a/internal/messaging/feishu/card_template.go +++ b/internal/messaging/feishu/card_template.go @@ -84,6 +84,19 @@ func buildCard(header cardHeader, config map[string]any, elements []map[string]a return encodeCard(card) } +// buildV1Card constructs a JSON 1.0 card (no schema field). Required for +// interactive elements like action + copy_text that are not supported in JSON 2.0. +func buildV1Card(header cardHeader, config map[string]any, elements []map[string]any) string { + card := map[string]any{ + "config": config, + "body": map[string]any{"elements": elements}, + } + if hm := header.toMap(); hm != nil { + card["header"] = hm + } + return encodeCard(card) +} + // toolActivityElementID is the element_id for the tool activity strip. const toolActivityElementID = "tool_activity" diff --git a/internal/messaging/feishu/card_template_test.go b/internal/messaging/feishu/card_template_test.go index fa0534fb..6041971d 100644 --- a/internal/messaging/feishu/card_template_test.go +++ b/internal/messaging/feishu/card_template_test.go @@ -134,6 +134,66 @@ func TestBuildCard(t *testing.T) { }) } +func TestBuildV1Card(t *testing.T) { + t.Parallel() + t.Run("no schema field", func(t *testing.T) { + t.Parallel() + got := buildV1Card(cardHeader{Title: "Test", Template: "yellow"}, + map[string]any{"wide_screen_mode": true}, + []map[string]any{{"tag": "markdown", "content": "hello"}}) + var card map[string]any + require.NoError(t, json.Unmarshal([]byte(got), &card)) + require.Nil(t, card["schema"], "v1 card must not have schema field") + require.NotNil(t, card["body"]) + hdr, ok := card["header"].(map[string]any) + require.True(t, ok) + require.Equal(t, "yellow", hdr["template"]) + }) + + t.Run("full question card round-trip", func(t *testing.T) { + t.Parallel() + questions := []events.Question{ + { + Header: "Auth", + Question: "Pick one", + Options: []events.QuestionOption{ + {Label: "JWT", Description: "Token-based"}, + {Label: "OAuth"}, + }, + }, + } + elements := buildQuestionElements(questions) + elements = append(elements, + map[string]any{"tag": "hr"}, + map[string]any{"tag": "markdown", "content": "回复选项"}, + ) + got := buildV1Card(cardHeader{Title: "用户输入请求", Template: "yellow"}, + map[string]any{"wide_screen_mode": true}, elements) + + var card map[string]any + require.NoError(t, json.Unmarshal([]byte(got), &card)) + require.Nil(t, card["schema"]) + body := card["body"].(map[string]any) + elems := body["elements"].([]any) + // markdown + action + hr + footer = 4 + require.Len(t, elems, 4) + require.Equal(t, "markdown", elems[0].(map[string]any)["tag"]) + require.Equal(t, "action", elems[1].(map[string]any)["tag"]) + require.Equal(t, "hr", elems[2].(map[string]any)["tag"]) + require.Equal(t, "markdown", elems[3].(map[string]any)["tag"]) + + // Verify action buttons have copy_text click behavior + actionEl := elems[1].(map[string]any) + btns := actionEl["actions"].([]any) + require.Len(t, btns, 2) + btn0 := btns[0].(map[string]any) + require.Equal(t, "button", btn0["tag"]) + click := btn0["click"].(map[string]any) + require.Equal(t, "copy_text", click["tag"]) + require.Equal(t, "JWT", click["value"]) + }) +} + func TestBuildStreamingCard(t *testing.T) { t.Parallel() t.Run("no header", func(t *testing.T) { diff --git a/internal/messaging/feishu/interaction.go b/internal/messaging/feishu/interaction.go index 2f009786..1e8f5fd0 100644 --- a/internal/messaging/feishu/interaction.go +++ b/internal/messaging/feishu/interaction.go @@ -82,7 +82,7 @@ func (c *FeishuConn) sendQuestionRequest(ctx context.Context, env *events.Envelo map[string]any{"tag": "markdown", "content": "💬 点击按钮复制选项文本,粘贴发送即可响应\n也可直接回复选项文本或自定义答案"}, ) - cardJSON := buildCard(cardHeader{ + cardJSON := buildV1Card(cardHeader{ Title: "用户输入请求", Template: headerYellow, }, map[string]any{"wide_screen_mode": true}, elements) From cfada417ea884bf9f0130b1a2896da2d50569162 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sisyphus=20=F0=9F=8F=94=EF=B8=8F?= Date: Fri, 22 May 2026 08:34:46 +0800 Subject: [PATCH 3/8] refactor(messaging/feishu): always show numbered option list as fallback Options are now always rendered as a numbered list in the markdown element, regardless of whether descriptions exist. This ensures options remain visible if the action buttons fail to render on any client. Co-Authored-By: Claude Opus 4.7 --- internal/messaging/feishu/card_template.go | 6 +++--- internal/messaging/feishu/card_template_test.go | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/messaging/feishu/card_template.go b/internal/messaging/feishu/card_template.go index ecf80bf6..07624dc8 100644 --- a/internal/messaging/feishu/card_template.go +++ b/internal/messaging/feishu/card_template.go @@ -3,7 +3,6 @@ package feishu import ( "fmt" "path/filepath" - "slices" "strings" "github.com/hrygo/hotplex/pkg/events" @@ -200,8 +199,9 @@ func buildQuestionElements(questions []events.Question) []map[string]any { var sb strings.Builder fmt.Fprintf(&sb, "**%s**\n%s", headerLabel, q.Question) - // If any option has a description, show a numbered reference list. - if slices.ContainsFunc(q.Options, func(o events.QuestionOption) bool { return o.Description != "" }) { + // Always show numbered option list as visible fallback — + // buttons may not render on all clients. + if len(q.Options) > 0 { sb.WriteString("\n\n") for i, opt := range q.Options { if opt.Description != "" { diff --git a/internal/messaging/feishu/card_template_test.go b/internal/messaging/feishu/card_template_test.go index 6041971d..4da2f2a6 100644 --- a/internal/messaging/feishu/card_template_test.go +++ b/internal/messaging/feishu/card_template_test.go @@ -249,13 +249,15 @@ func TestBuildQuestionElements(t *testing.T) { // Expect: markdown + action require.Len(t, elements, 2) - // Markdown element: no numbered list (no descriptions) + // Markdown element: always includes numbered list as fallback md := elements[0] require.Equal(t, "markdown", md["tag"]) content := md["content"].(string) require.Contains(t, content, "**Auth method**") require.Contains(t, content, "Which library?") - require.NotContains(t, content, "1.") // no numbered list + require.Contains(t, content, "1. **JWT**") + require.Contains(t, content, "2. **Session**") + require.Contains(t, content, "3. **OAuth**") // Action element: 3 buttons action := elements[1] From 5fe51e83837e570213c0274b8adddff135332b38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sisyphus=20=F0=9F=8F=94=EF=B8=8F?= Date: Fri, 22 May 2026 09:20:30 +0800 Subject: [PATCH 4/8] fix(messaging/feishu): use correct JSON 1.0 root-level elements in buildV1Card JSON 1.0 cards use "elements" at root level, not "body":{"elements":...}. Also fix misleading "CardKit v2" comments to accurately reflect JSON 1.0 usage. Co-Authored-By: Claude Opus 4.7 --- internal/messaging/feishu/card_template.go | 10 +++++----- internal/messaging/feishu/card_template_test.go | 9 ++++++--- internal/messaging/feishu/interaction.go | 5 ++--- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/internal/messaging/feishu/card_template.go b/internal/messaging/feishu/card_template.go index 07624dc8..bd7105b1 100644 --- a/internal/messaging/feishu/card_template.go +++ b/internal/messaging/feishu/card_template.go @@ -83,12 +83,12 @@ func buildCard(header cardHeader, config map[string]any, elements []map[string]a return encodeCard(card) } -// buildV1Card constructs a JSON 1.0 card (no schema field). Required for -// interactive elements like action + copy_text that are not supported in JSON 2.0. +// buildV1Card constructs a JSON 1.0 card (no schema field, elements at root level). +// Required for interactive elements like action + copy_text that are not supported in JSON 2.0. func buildV1Card(header cardHeader, config map[string]any, elements []map[string]any) string { card := map[string]any{ - "config": config, - "body": map[string]any{"elements": elements}, + "config": config, + "elements": elements, } if hm := header.toMap(); hm != nil { card["header"] = hm @@ -184,7 +184,7 @@ func turnTags(turnNum int, model, branch, workDir string) []cardTag { return tags } -// buildQuestionElements builds CardKit v2 body elements for a question card. +// buildQuestionElements builds card elements for a JSON 1.0 question card. // Each question gets a markdown element (with numbered descriptions if present) // followed by an action element with copy_text buttons. func buildQuestionElements(questions []events.Question) []map[string]any { diff --git a/internal/messaging/feishu/card_template_test.go b/internal/messaging/feishu/card_template_test.go index 4da2f2a6..74328918 100644 --- a/internal/messaging/feishu/card_template_test.go +++ b/internal/messaging/feishu/card_template_test.go @@ -144,8 +144,11 @@ func TestBuildV1Card(t *testing.T) { var card map[string]any require.NoError(t, json.Unmarshal([]byte(got), &card)) require.Nil(t, card["schema"], "v1 card must not have schema field") - require.NotNil(t, card["body"]) + require.Nil(t, card["body"], "v1 card must not have body wrapper") hdr, ok := card["header"].(map[string]any) + elems := card["elements"].([]any) + require.Len(t, elems, 1) + require.Equal(t, "markdown", elems[0].(map[string]any)["tag"]) require.True(t, ok) require.Equal(t, "yellow", hdr["template"]) }) @@ -173,8 +176,8 @@ func TestBuildV1Card(t *testing.T) { var card map[string]any require.NoError(t, json.Unmarshal([]byte(got), &card)) require.Nil(t, card["schema"]) - body := card["body"].(map[string]any) - elems := body["elements"].([]any) + require.Nil(t, card["body"], "v1 card must not have body wrapper") + elems := card["elements"].([]any) // markdown + action + hr + footer = 4 require.Len(t, elems, 4) require.Equal(t, "markdown", elems[0].(map[string]any)["tag"]) diff --git a/internal/messaging/feishu/interaction.go b/internal/messaging/feishu/interaction.go index 1e8f5fd0..76f31bb8 100644 --- a/internal/messaging/feishu/interaction.go +++ b/internal/messaging/feishu/interaction.go @@ -67,9 +67,8 @@ func (c *FeishuConn) sendPermissionRequest(ctx context.Context, env *events.Enve return nil } -// sendQuestionRequest posts a question request card to Feishu with CardKit v2 -// action buttons. Each button uses copy_text click behavior so users can tap -// to copy the option label and paste it as their response. +// sendQuestionRequest posts a question request card using JSON 1.0 format +// (required for action + copy_text interactive buttons). func (c *FeishuConn) sendQuestionRequest(ctx context.Context, env *events.Envelope) error { data, err := messaging.ExtractQuestionData(env) if err != nil { From f800ef90dc2f6e74e07f22bfa4998d5d63f60fd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sisyphus=20=F0=9F=8F=94=EF=B8=8F?= Date: Fri, 22 May 2026 02:23:02 +0800 Subject: [PATCH 5/8] docs(patrol): update admin API, feishu interaction, and security model docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin-api.md: fix cron paths /api/cron/ → /admin/cron/, add restart endpoint, add API Key User Management section (5 endpoints) - feishu-integration.md: update Q&A interaction to describe CardKit v2 copy_text buttons instead of text-only response - security-model.md: add APIKeyResolver enterprise multi-user session isolation to Layer 2 description Co-Authored-By: Claude Opus 4.7 --- docs/explanation/security-model.md | 4 +- docs/reference/admin-api.md | 56 +++++++++++++++++++++------- docs/tutorials/feishu-integration.md | 4 +- 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/docs/explanation/security-model.md b/docs/explanation/security-model.md index f89b2411..d1e28aff 100644 --- a/docs/explanation/security-model.md +++ b/docs/explanation/security-model.md @@ -42,7 +42,9 @@ HotPlex Gateway 的安全模型遵循两条核心原则: ### Layer 2:Authentication(认证) -- **API Key**:Gateway 级别的访问控制 +- **API Key**:Gateway 级别的访问控制。支持两种解析模式: + - **默认模式**:所有有效 Key 映射到 `api_user`(单用户场景) + - **企业模式**(APIKeyResolver):通过数据库映射将 Key 关联到具体用户身份,实现多用户 Session 隔离。支持 `MapResolver`(配置文件)和 `DBResolver`(SQLite)两种实现 - **JWT**:Session 级别的身份验证,携带 `user_id`、`bot_id`、`scopes` ### Layer 3:Input Validation(输入验证) diff --git a/docs/reference/admin-api.md b/docs/reference/admin-api.md index 4d98f3e1..1cc64a9c 100644 --- a/docs/reference/admin-api.md +++ b/docs/reference/admin-api.md @@ -49,8 +49,8 @@ admin: | `stats:read` | - | - | 🟢 Read | - | - | - | `GET /admin/stats`
`GET /admin/metrics` | | `config:read` | - | - | - | 🟢 Read | - | - | `POST /admin/config/validate` | | `config:write` | - | - | - | 🟠 Write | - | - | `POST /admin/config/rollback` | -| `admin:read` | - | - | - | - | 🟢 Read | 🟢 Read | `GET /admin/logs`
`GET /admin/debug/...`
`GET /admin/bots`
`GET /api/cron/jobs` | -| `admin:write` | - | - | - | - | - | 🟠 Write | `POST/PATCH/DELETE /api/cron/jobs`
`POST /api/cron/jobs/{id}/run` | +| `admin:read` | - | - | - | - | 🟢 Read | 🟢 Read | `GET /admin/logs`
`GET /admin/debug/...`
`GET /admin/bots`
`GET /admin/cron/jobs`
`GET /admin/api-keys` | +| `admin:write` | - | - | - | - | - | 🟠 Write | `POST/PATCH/DELETE /admin/cron/jobs`
`POST /admin/cron/jobs/{id}/run`
`POST/PATCH/DELETE /admin/api-keys`
`POST /admin/restart` | > 💡 **图例**:🟢 **Read** (只读查询) | 🟠 **Write** (状态变更/操作) | 🔴 **Delete** (物理删除) @@ -152,13 +152,13 @@ curl -X POST -H "Authorization: Bearer $TOKEN" \ | 方法 | 路径 | Scope | 说明 | |------|------|-------|------| -| GET | `/api/cron/jobs` | `admin:read` | 列出所有任务 | -| GET | `/api/cron/jobs/{id}` | `admin:read` | 获取单个任务 | -| POST | `/api/cron/jobs` | `admin:write` | 创建任务 | -| PATCH | `/api/cron/jobs/{id}` | `admin:write` | 更新任务 | -| DELETE | `/api/cron/jobs/{id}` | `admin:write` | 删除任务 | -| POST | `/api/cron/jobs/{id}/run` | `admin:write` | 手动触发执行 | -| GET | `/api/cron/jobs/{id}/runs` | `admin:read` | 执行历史 | +| GET | `/admin/cron/jobs` | `admin:read` | 列出所有任务 | +| GET | `/admin/cron/jobs/{id}` | `admin:read` | 获取单个任务 | +| POST | `/admin/cron/jobs` | `admin:write` | 创建任务 | +| PATCH | `/admin/cron/jobs/{id}` | `admin:write` | 更新任务 | +| DELETE | `/admin/cron/jobs/{id}` | `admin:write` | 删除任务 | +| POST | `/admin/cron/jobs/{id}/run` | `admin:write` | 手动触发执行 | +| GET | `/admin/cron/jobs/{id}/runs` | `admin:read` | 执行历史 | Cron 未启用时返回 `503 Service Unavailable`。 @@ -189,6 +189,34 @@ Bot 状态查询、配置管理和 Agent 配置文件操作端点。 **PUT /admin/bots/{name}/config/{file}`** — 写入指定 Agent 配置文件(如 `SOUL.md`、`AGENTS.md`、`USER.md`)。请求体为文件内容,Content-Type 为 `text/plain`。 +### 网关重启 + +| 方法 | 路径 | Scope | 说明 | +|------|------|-------|------| +| POST | `/admin/restart` | `admin:write` | 触发网关重启 | + +**POST /admin/restart** — 异步触发网关重启。Gateway 在 500ms 延迟后执行重启,立即返回 `{ "status": "restarting" }`。使用 `restart helper`(独立 PGID)确保安全隔离。未配置 restart handler 时返回 `503`。 + +### API Key 用户管理 + +管理 API Key 到用户身份的映射,用于企业级多用户 Session 隔离。需要数据库支持(SQLite),未配置 DB resolver 时返回 `501 Not Implemented`。 + +| 方法 | 路径 | Scope | 说明 | +|------|------|-------|------| +| GET | `/admin/api-keys` | `admin:read` | 列出所有 API Key 映射 | +| POST | `/admin/api-keys` | `admin:write` | 创建 API Key 映射 | +| GET | `/admin/api-keys/{id}` | `admin:read` | 获取单个映射详情 | +| PATCH | `/admin/api-keys/{id}` | `admin:write` | 更新映射 | +| DELETE | `/admin/api-keys/{id}` | `admin:write` | 删除映射 | + +**POST /admin/api-keys** — 创建 API Key → UserID 映射。JSON body 含 `user_id`(必填,最长 128 字符)和 `description`(可选,最长 512 字符)。API Key 由系统自动生成(32 字节随机 hex)。返回 `201 Created`。 + +**GET /admin/api-keys** — 返回所有映射列表,`api_key` 字段自动脱敏(仅显示前 8 + 后 4 位)。 + +**PATCH /admin/api-keys/{id}`** — 更新指定映射的 `user_id` 和 `description`。 + +**DELETE /admin/api-keys/{id}`** — 物理删除映射,同时清除缓存的 resolver 条目。 + ## Gateway API 端点 Gateway API(`/api/sessions`)监听在网关主端口(`8888`),面向客户端 SDK 和 WebSocket 连接,使用 API Key 或 JWT 认证(非 Bearer Token)。 @@ -205,13 +233,13 @@ Gateway API(`/api/sessions`)监听在网关主端口(`8888`),面向客 所有 Gateway API 端点启用 CORS(`Access-Control-Allow-Origin: *`),支持 `GET`、`POST`、`DELETE`、`OPTIONS` 方法。 -**POST /api/cron/jobs** — JSON body 含 `name`、`schedule`(cron:/every:/at: 前缀)、`message`、`bot_id`、`owner_id`、`enabled`。返回 `201 Created`。 +**POST /admin/cron/jobs** — JSON body 含 `name`、`schedule`(cron:/every:/at: 前缀)、`message`、`bot_id`、`owner_id`、`enabled`。返回 `201 Created`。 -**PATCH /api/cron/jobs/{id}** — 部分更新,JSON body。返回 `204 No Content`。 +**PATCH /admin/cron/jobs/{id}** — 部分更新,JSON body。返回 `204 No Content`。 -**POST /api/cron/jobs/{id}/run** — 手动触发(异步),返回 `202 Accepted`。 +**POST /admin/cron/jobs/{id}/run** — 手动触发(异步),返回 `202 Accepted`。 -**GET /api/cron/jobs/{id}/runs** — 查询执行历史。 +**GET /admin/cron/jobs/{id}/runs** — 查询执行历史。 ## 错误响应格式 @@ -255,5 +283,5 @@ curl -H "Authorization: Bearer $TOKEN" \ # 触发 Cron 任务 curl -X POST -H "Authorization: Bearer $TOKEN" \ - http://localhost:9999/api/cron/jobs/daily-health/run + http://localhost:9999/admin/cron/jobs/daily-health/run ``` diff --git a/docs/tutorials/feishu-integration.md b/docs/tutorials/feishu-integration.md index 06579357..e1ca4229 100644 --- a/docs/tutorials/feishu-integration.md +++ b/docs/tutorials/feishu-integration.md @@ -243,9 +243,9 @@ HOTPLEX_MESSAGING_TTS_MAX_CHARS=150
交互与指示器 -**权限交互**:Bot 发送确认卡片时,用户直接回复文本「允许」或「拒绝」即可,无需点击按钮。 +**权限交互**:Bot 发送确认卡片时,用户直接回复文本「允许」或「拒绝」即可。 -**Typing 指示器**:Bot 收到消息后自动添加 👀 emoji reaction,回复完成后自动移除。 +**选项交互**:当 Bot 发送多选项问题(如 AskUserQuestion)时,卡片包含可点击的复制按钮。点击按钮自动复制选项文本到剪贴板,粘贴发送即可响应。也可直接手动输入选项文本或自定义答案。 这些行为为内置默认,无需额外配置。 From d7aa9e7a0cb74bd316e2be1cbdeff7116b7cad6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sisyphus=20=F0=9F=8F=94=EF=B8=8F?= Date: Thu, 21 May 2026 02:22:38 +0800 Subject: [PATCH 6/8] docs(patrol): update cron route prefix, health probe auth, remove deprecated soul field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin-api.md: /api/cron/* → /admin/cron/* (7 routes, scope table, descriptions, curl example) - admin-api.md: /admin/health/ready exempted from Bearer token auth for k8s probes - configuration.md: remove per-bot soul field (removed from SlackBotConfig and FeishuBotConfig) --- docs/reference/admin-api.md | 10 +++++----- docs/reference/configuration.md | 4 +--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/docs/reference/admin-api.md b/docs/reference/admin-api.md index 1cc64a9c..ce1af640 100644 --- a/docs/reference/admin-api.md +++ b/docs/reference/admin-api.md @@ -10,7 +10,7 @@ HotPlex Admin API 提供网关运维管理能力:会话管理、健康检查 ## 认证 -所有 Admin 端点(`/admin/health` 除外)均需 Bearer Token 认证。Token 通过以下两种方式传递: +所有 Admin 端点(`/admin/health` 和 `/admin/health/ready` 除外)均需 Bearer Token 认证。Token 通过以下两种方式传递: ```bash # 方式一:Authorization header(推荐) @@ -42,15 +42,15 @@ admin: | Scope Token | Health | Sessions | Stats | Config | Debug | Cron | 覆盖的核心端点 (Endpoints) | |:---|:---:|:---:|:---:|:---:|:---:|:---:|:---| -| `health:read` | 🟢 Read | - | - | - | - | - | `/admin/health/workers`
`/admin/health/ready` | +| `health:read` | 🟢 Read | - | - | - | - | - | `/admin/health/workers` | | `session:read` | - | 🟢 Read | - | - | - | - | `GET /admin/sessions`
`GET /admin/sessions/{id}/stats` | | `session:write` | - | 🟠 Write | - | - | - | - | `POST /admin/sessions/{id}/terminate` | | `session:delete` | - | 🔴 Delete | - | - | - | - | `DELETE /admin/sessions/{id}` | | `stats:read` | - | - | 🟢 Read | - | - | - | `GET /admin/stats`
`GET /admin/metrics` | | `config:read` | - | - | - | 🟢 Read | - | - | `POST /admin/config/validate` | | `config:write` | - | - | - | 🟠 Write | - | - | `POST /admin/config/rollback` | -| `admin:read` | - | - | - | - | 🟢 Read | 🟢 Read | `GET /admin/logs`
`GET /admin/debug/...`
`GET /admin/bots`
`GET /admin/cron/jobs`
`GET /admin/api-keys` | -| `admin:write` | - | - | - | - | - | 🟠 Write | `POST/PATCH/DELETE /admin/cron/jobs`
`POST /admin/cron/jobs/{id}/run`
`POST/PATCH/DELETE /admin/api-keys`
`POST /admin/restart` | +| `admin:read` | - | - | - | - | 🟢 Read | 🟢 Read | `GET /admin/logs`
`GET /admin/debug/...`
`GET /admin/bots`
`GET /admin/cron/jobs` | +| `admin:write` | - | - | - | - | - | 🟠 Write | `POST/PATCH/DELETE /admin/cron/jobs`
`POST /admin/cron/jobs/{id}/run` | > 💡 **图例**:🟢 **Read** (只读查询) | 🟠 **Write** (状态变更/操作) | 🔴 **Delete** (物理删除) @@ -74,7 +74,7 @@ Rate Limit 和 IP Whitelist 支持配置热重载,无需重启生效。 |------|------|-------|------| | GET | `/admin/health` | 无需认证 | 综合健康状态(gateway + DB + workers) | | GET | `/admin/health/workers` | `health:read` | Worker 粒度健康状态 | -| GET | `/admin/health/ready` | `health:read` | 就绪探针(k8s readiness) | +| GET | `/admin/health/ready` | 无需认证 | 就绪探针(k8s readiness) | **GET /admin/health** — 无需认证,适合负载均衡器探活。返回 `status`(healthy/degraded)、`checks`(gateway + database + workers)和 `version`。数据库不可用时降级为 `degraded`,`database.error` 附带错误信息。 diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index b610b945..0d07c3dc 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -399,7 +399,7 @@ AI-native 定时任务引擎:自然语言 prompt 作为 payload,结果投递 #### 3.11.7 多 Bot 配置(Multi-Bot) -每个平台支持多个独立 bot 实例,各自拥有独立凭证、STT/TTS 和 soul 配置。 +每个平台支持多个独立 bot 实例,各自拥有独立凭证、STT/TTS 配置。 **SlackBotConfig 字段**: @@ -408,7 +408,6 @@ AI-native 定时任务引擎:自然语言 prompt 作为 payload,结果投递 | `name` | string | Bot 名称(同一平台内唯一,必填) | | `bot_token` | string | Slack Bot Token(`xoxb-...`) | | `app_token` | string | Slack App Token(`xapp-...`) | -| `soul` | string | Bot 人格标识(显示名称) | | `worker_type` | string | 覆盖 Worker 类型 | | `stt_*` | — | 覆盖 STT 配置(继承平台级 → messaging 级) | | `tts_*` | — | 覆盖 TTS 配置(继承平台级 → messaging 级) | @@ -420,7 +419,6 @@ AI-native 定时任务引擎:自然语言 prompt 作为 payload,结果投递 | `name` | string | Bot 名称(同一平台内唯一,必填) | | `app_id` | string | 飞书 App ID | | `app_secret` | string | 飞书 App Secret | -| `soul` | string | Bot 人格标识 | | `worker_type` | string | 覆盖 Worker 类型 | | `stt_*` | — | 覆盖 STT 配置 | | `tts_*` | — | 覆盖 TTS 配置 | From 55ca113cd16d8cfa324a65c9fa3917c8adc1f745 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sisyphus=20=F0=9F=8F=94=EF=B8=8F?= Date: Fri, 22 May 2026 11:12:12 +0800 Subject: [PATCH 7/8] fix(messaging/feishu): sanitize question text, handle MultiSelect, fix docs paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildQuestionElements: call SanitizeText() on all user-facing text (header, question, label, description) to match text message pattern - MultiSelect: show "(可多选)" in card, dynamic footer hint, fallback text multi-select prompt — previously silently ignored - Docs: /api/cron/ → /admin/cron/ in AI-Native-Cronjob-Spec and Turns-Materialized-Table-Spec to match actual routes.go Co-Authored-By: Claude Opus 4.7 --- docs/specs/AI-Native-Cronjob-Spec.md | 16 ++++----- docs/specs/Turns-Materialized-Table-Spec.md | 2 +- internal/messaging/feishu/card_template.go | 31 ++++++++++++---- .../messaging/feishu/card_template_test.go | 35 +++++++++++++++++++ internal/messaging/feishu/interaction.go | 8 ++++- 5 files changed, 75 insertions(+), 17 deletions(-) diff --git a/docs/specs/AI-Native-Cronjob-Spec.md b/docs/specs/AI-Native-Cronjob-Spec.md index b627d01f..7be35256 100644 --- a/docs/specs/AI-Native-Cronjob-Spec.md +++ b/docs/specs/AI-Native-Cronjob-Spec.md @@ -443,7 +443,7 @@ workerEnv = append(workerEnv, 用户说 "30分钟后提醒我检查部署" → Worker 从意识层知道 cronjob 可用 → cat ~/.hotplex/skills/cron.md 读取完整手册 - → curl POST ${HOTPLEX_ADMIN_API_URL}/api/cron/jobs 创建 cronjob + → curl POST ${HOTPLEX_ADMIN_API_URL}/admin/cron/jobs 创建 cronjob → 返回确认给用户 ``` @@ -567,13 +567,13 @@ jobs: | Method | Path | Description | |--------|------|-------------| -| GET | `/api/cron/jobs` | List all jobs | -| GET | `/api/cron/jobs/:id` | Get job detail | -| POST | `/api/cron/jobs` | Create job | -| PATCH | `/api/cron/jobs/:id` | Update job | -| DELETE | `/api/cron/jobs/:id` | Delete job | -| POST | `/api/cron/jobs/:id/run` | Trigger manual run | -| GET | `/api/cron/jobs/:id/runs` | Get run history | +| GET | `/admin/cron/jobs` | List all jobs | +| GET | `/admin/cron/jobs/{id}` | Get job detail | +| POST | `/admin/cron/jobs` | Create job | +| PATCH | `/admin/cron/jobs/{id}` | Update job | +| DELETE | `/admin/cron/jobs/{id}` | Delete job | +| POST | `/admin/cron/jobs/{id}/run` | Trigger manual run | +| GET | `/admin/cron/jobs/{id}/runs` | Get run history | --- diff --git a/docs/specs/Turns-Materialized-Table-Spec.md b/docs/specs/Turns-Materialized-Table-Spec.md index 5933d51a..804f354b 100644 --- a/docs/specs/Turns-Materialized-Table-Spec.md +++ b/docs/specs/Turns-Materialized-Table-Spec.md @@ -881,7 +881,7 @@ make check |------|------|------| | `GET /api/sessions/{id}/history` | `id` 字段类型 `string` → `int64`;查询参数 `before_seq` → `before_id`;新增 `generation`/`turn_num`/`tokens_input`/`tokens_cache_write`/`tokens_cache_read` 字段 | WebChat 前端需适配 | | `GET /admin/sessions/{id}/stats` | `TurnStats` 新增 `generation`/`total_tokens_input`/`total_tokens_cache_write`/`total_tokens_cache_read` | Admin WebUI 需适配 | -| `GET /api/cron/jobs/{id}/runs` | 同 `TurnStats` 变更 | CLI `--json` 输出新增字段(向后兼容) | +| `GET /admin/cron/jobs/{id}/runs` | 同 `TurnStats` 变更 | CLI `--json` 输出新增字段(向后兼容) | ### 11.2 内部 Go 接口 diff --git a/internal/messaging/feishu/card_template.go b/internal/messaging/feishu/card_template.go index bd7105b1..104222bc 100644 --- a/internal/messaging/feishu/card_template.go +++ b/internal/messaging/feishu/card_template.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" + "github.com/hrygo/hotplex/internal/messaging" "github.com/hrygo/hotplex/pkg/events" ) @@ -191,23 +192,28 @@ func buildQuestionElements(questions []events.Question) []map[string]any { var elements []map[string]any for _, q := range questions { - headerLabel := q.Header + headerLabel := messaging.SanitizeText(q.Header) if headerLabel == "" { headerLabel = "Question" } var sb strings.Builder - fmt.Fprintf(&sb, "**%s**\n%s", headerLabel, q.Question) + fmt.Fprintf(&sb, "**%s**\n%s", headerLabel, messaging.SanitizeText(q.Question)) + if q.MultiSelect { + sb.WriteString("\n*(可多选)*") + } // Always show numbered option list as visible fallback — // buttons may not render on all clients. if len(q.Options) > 0 { sb.WriteString("\n\n") for i, opt := range q.Options { - if opt.Description != "" { - fmt.Fprintf(&sb, "%d. **%s** — %s\n", i+1, opt.Label, opt.Description) + label := messaging.SanitizeText(opt.Label) + desc := messaging.SanitizeText(opt.Description) + if desc != "" { + fmt.Fprintf(&sb, "%d. **%s** — %s\n", i+1, label, desc) } else { - fmt.Fprintf(&sb, "%d. **%s**\n", i+1, opt.Label) + fmt.Fprintf(&sb, "%d. **%s**\n", i+1, label) } } } @@ -221,13 +227,14 @@ func buildQuestionElements(questions []events.Question) []map[string]any { if len(q.Options) > 0 { buttons := make([]map[string]any, 0, len(q.Options)) for _, opt := range q.Options { + label := messaging.SanitizeText(opt.Label) buttons = append(buttons, map[string]any{ "tag": "button", - "text": map[string]any{"tag": "plain_text", "content": opt.Label}, + "text": map[string]any{"tag": "plain_text", "content": label}, "type": "default", "click": map[string]any{ "tag": "copy_text", - "value": opt.Label, + "value": label, }, }) } @@ -240,3 +247,13 @@ func buildQuestionElements(questions []events.Question) []map[string]any { return elements } + +// questionFooterHint returns the appropriate footer hint based on question types. +func questionFooterHint(questions []events.Question) string { + for _, q := range questions { + if q.MultiSelect { + return "💬 点击按钮复制选项文本,可一次发送多个选项(用空格或逗号分隔)\n也可直接回复选项文本或自定义答案" + } + } + return "💬 点击按钮复制选项文本,粘贴发送即可响应\n也可直接回复选项文本或自定义答案" +} diff --git a/internal/messaging/feishu/card_template_test.go b/internal/messaging/feishu/card_template_test.go index 74328918..6e13eeda 100644 --- a/internal/messaging/feishu/card_template_test.go +++ b/internal/messaging/feishu/card_template_test.go @@ -335,4 +335,39 @@ func TestBuildQuestionElements(t *testing.T) { content := elements[0]["content"].(string) require.Contains(t, content, "**Question**") }) + + t.Run("multi_select adds hint", func(t *testing.T) { + t.Parallel() + questions := []events.Question{ + { + Header: "Pick tools", + Question: "Which ones?", + MultiSelect: true, + Options: []events.QuestionOption{ + {Label: "Go"}, + {Label: "Rust"}, + }, + }, + } + elements := buildQuestionElements(questions) + require.Len(t, elements, 2) + content := elements[0]["content"].(string) + require.Contains(t, content, "(可多选)") + require.Contains(t, content, "1. **Go**") + }) + + t.Run("multi_select false has no hint", func(t *testing.T) { + t.Parallel() + questions := []events.Question{ + { + Header: "Pick one", + Question: "Which?", + MultiSelect: false, + Options: []events.QuestionOption{{Label: "A"}}, + }, + } + elements := buildQuestionElements(questions) + content := elements[0]["content"].(string) + require.NotContains(t, content, "可多选") + }) } diff --git a/internal/messaging/feishu/interaction.go b/internal/messaging/feishu/interaction.go index 76f31bb8..55efb011 100644 --- a/internal/messaging/feishu/interaction.go +++ b/internal/messaging/feishu/interaction.go @@ -78,7 +78,7 @@ func (c *FeishuConn) sendQuestionRequest(ctx context.Context, env *events.Envelo elements := buildQuestionElements(data.Questions) elements = append(elements, map[string]any{"tag": "hr"}, - map[string]any{"tag": "markdown", "content": "💬 点击按钮复制选项文本,粘贴发送即可响应\n也可直接回复选项文本或自定义答案"}, + map[string]any{"tag": "markdown", "content": questionFooterHint(data.Questions)}, ) cardJSON := buildV1Card(cardHeader{ @@ -368,6 +368,12 @@ func buildQuestionFallbackText(data *events.QuestionRequestData) string { } sb.WriteString("\n回复选项文本或自定义答案来响应此问题") + for _, q := range data.Questions { + if q.MultiSelect { + sb.WriteString("\n提示: 此问题支持多选,可一次发送多个选项") + break + } + } return sb.String() } From a615c44d69530a3c34a925fed2c65e2813b6d795 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sisyphus=20=F0=9F=8F=94=EF=B8=8F?= Date: Fri, 22 May 2026 13:11:13 +0800 Subject: [PATCH 8/8] fix(messaging/feishu): pre-compute sanitized opts, cover fallback text, table-driven tests - Pre-compute sanitized options slice in buildQuestionElements (2N vs 3N) - Add SanitizeText calls to buildQuestionFallbackText (was missing) - Add TestQuestionFooterHint table-driven test (4 cases: nil/single/multi/mixed) - Convert multi_select subtests to table-driven style matching project conventions Co-Authored-By: Claude Opus 4.7 --- internal/messaging/feishu/card_template.go | 35 +++--- .../messaging/feishu/card_template_test.go | 110 +++++++++++++----- internal/messaging/feishu/interaction.go | 11 +- 3 files changed, 111 insertions(+), 45 deletions(-) diff --git a/internal/messaging/feishu/card_template.go b/internal/messaging/feishu/card_template.go index 104222bc..199659bf 100644 --- a/internal/messaging/feishu/card_template.go +++ b/internal/messaging/feishu/card_template.go @@ -197,6 +197,18 @@ func buildQuestionElements(questions []events.Question) []map[string]any { headerLabel = "Question" } + // Pre-sanitize all option fields once. + type sanitizedOpt struct { + Label, Desc string + } + opts := make([]sanitizedOpt, len(q.Options)) + for i, opt := range q.Options { + opts[i] = sanitizedOpt{ + Label: messaging.SanitizeText(opt.Label), + Desc: messaging.SanitizeText(opt.Description), + } + } + var sb strings.Builder fmt.Fprintf(&sb, "**%s**\n%s", headerLabel, messaging.SanitizeText(q.Question)) if q.MultiSelect { @@ -205,15 +217,13 @@ func buildQuestionElements(questions []events.Question) []map[string]any { // Always show numbered option list as visible fallback — // buttons may not render on all clients. - if len(q.Options) > 0 { + if len(opts) > 0 { sb.WriteString("\n\n") - for i, opt := range q.Options { - label := messaging.SanitizeText(opt.Label) - desc := messaging.SanitizeText(opt.Description) - if desc != "" { - fmt.Fprintf(&sb, "%d. **%s** — %s\n", i+1, label, desc) + for i, opt := range opts { + if opt.Desc != "" { + fmt.Fprintf(&sb, "%d. **%s** — %s\n", i+1, opt.Label, opt.Desc) } else { - fmt.Fprintf(&sb, "%d. **%s**\n", i+1, label) + fmt.Fprintf(&sb, "%d. **%s**\n", i+1, opt.Label) } } } @@ -224,17 +234,16 @@ func buildQuestionElements(questions []events.Question) []map[string]any { }) // Action buttons with copy_text behavior. - if len(q.Options) > 0 { - buttons := make([]map[string]any, 0, len(q.Options)) - for _, opt := range q.Options { - label := messaging.SanitizeText(opt.Label) + if len(opts) > 0 { + buttons := make([]map[string]any, 0, len(opts)) + for _, opt := range opts { buttons = append(buttons, map[string]any{ "tag": "button", - "text": map[string]any{"tag": "plain_text", "content": label}, + "text": map[string]any{"tag": "plain_text", "content": opt.Label}, "type": "default", "click": map[string]any{ "tag": "copy_text", - "value": label, + "value": opt.Label, }, }) } diff --git a/internal/messaging/feishu/card_template_test.go b/internal/messaging/feishu/card_template_test.go index 6e13eeda..ffc73d1f 100644 --- a/internal/messaging/feishu/card_template_test.go +++ b/internal/messaging/feishu/card_template_test.go @@ -109,6 +109,53 @@ func TestCardHeaderToMap(t *testing.T) { } } +func TestQuestionFooterHint(t *testing.T) { + t.Parallel() + tests := []struct { + name string + questions []events.Question + want string + }{ + { + name: "no questions returns single-select hint", + questions: nil, + want: "粘贴发送即可响应", + }, + { + name: "single select returns single-select hint", + questions: []events.Question{{ + Question: "Pick?", + Options: []events.QuestionOption{{Label: "A"}}, + }}, + want: "粘贴发送即可响应", + }, + { + name: "multi select returns multi-select hint", + questions: []events.Question{{ + Question: "Pick?", + MultiSelect: true, + Options: []events.QuestionOption{{Label: "A"}, {Label: "B"}}, + }}, + want: "可一次发送多个选项", + }, + { + name: "mixed questions with any multi select uses multi hint", + questions: []events.Question{ + {Question: "Q1", Options: []events.QuestionOption{{Label: "A"}}}, + {Question: "Q2", MultiSelect: true, Options: []events.QuestionOption{{Label: "X"}}}, + }, + want: "可一次发送多个选项", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := questionFooterHint(tt.questions) + require.Contains(t, got, tt.want) + }) + } +} + func TestBuildCard(t *testing.T) { t.Parallel() t.Run("no header", func(t *testing.T) { @@ -336,38 +383,47 @@ func TestBuildQuestionElements(t *testing.T) { require.Contains(t, content, "**Question**") }) - t.Run("multi_select adds hint", func(t *testing.T) { + t.Run("multi_select", func(t *testing.T) { t.Parallel() - questions := []events.Question{ + tests := []struct { + name string + questions []events.Question + wantHint string // substring expected in markdown content + dontWant string // substring that must NOT appear + }{ { - Header: "Pick tools", - Question: "Which ones?", - MultiSelect: true, - Options: []events.QuestionOption{ - {Label: "Go"}, - {Label: "Rust"}, - }, + name: "true shows multi-select marker", + questions: []events.Question{{ + Header: "Pick tools", + Question: "Which ones?", + MultiSelect: true, + Options: []events.QuestionOption{{Label: "Go"}, {Label: "Rust"}}, + }}, + wantHint: "(可多选)", }, - } - elements := buildQuestionElements(questions) - require.Len(t, elements, 2) - content := elements[0]["content"].(string) - require.Contains(t, content, "(可多选)") - require.Contains(t, content, "1. **Go**") - }) - - t.Run("multi_select false has no hint", func(t *testing.T) { - t.Parallel() - questions := []events.Question{ { - Header: "Pick one", - Question: "Which?", - MultiSelect: false, - Options: []events.QuestionOption{{Label: "A"}}, + name: "false has no marker", + questions: []events.Question{{ + Header: "Pick one", + Question: "Which?", + MultiSelect: false, + Options: []events.QuestionOption{{Label: "A"}}, + }}, + dontWant: "可多选", }, } - elements := buildQuestionElements(questions) - content := elements[0]["content"].(string) - require.NotContains(t, content, "可多选") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + elements := buildQuestionElements(tt.questions) + content := elements[0]["content"].(string) + if tt.wantHint != "" { + require.Contains(t, content, tt.wantHint) + } + if tt.dontWant != "" { + require.NotContains(t, content, tt.dontWant) + } + }) + } }) } diff --git a/internal/messaging/feishu/interaction.go b/internal/messaging/feishu/interaction.go index 55efb011..f1c7d382 100644 --- a/internal/messaging/feishu/interaction.go +++ b/internal/messaging/feishu/interaction.go @@ -349,18 +349,19 @@ func buildQuestionFallbackText(data *events.QuestionRequestData) string { sb.WriteString("❓ 问题请求\n") for i, q := range data.Questions { - headerLabel := q.Header + headerLabel := messaging.SanitizeText(q.Header) if headerLabel == "" { headerLabel = "Question" } - fmt.Fprintf(&sb, "\n%s %d: %s\n", headerLabel, i+1, q.Question) + fmt.Fprintf(&sb, "\n%s %d: %s\n", headerLabel, i+1, messaging.SanitizeText(q.Question)) if len(q.Options) > 0 { sb.WriteString("选项:\n") for j, opt := range q.Options { - label := opt.Label - if opt.Description != "" { - label += " — " + opt.Description + label := messaging.SanitizeText(opt.Label) + desc := messaging.SanitizeText(opt.Description) + if desc != "" { + label += " — " + desc } fmt.Fprintf(&sb, " %d. %s\n", j+1, label) }