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..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 /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` | +| `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` 附带错误信息。 @@ -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/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 配置 | 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/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)时,卡片包含可点击的复制按钮。点击按钮自动复制选项文本到剪贴板,粘贴发送即可响应。也可直接手动输入选项文本或自定义答案。 这些行为为内置默认,无需额外配置。 diff --git a/internal/messaging/feishu/card_template.go b/internal/messaging/feishu/card_template.go index bc7c00ff..199659bf 100644 --- a/internal/messaging/feishu/card_template.go +++ b/internal/messaging/feishu/card_template.go @@ -4,6 +4,9 @@ import ( "fmt" "path/filepath" "strings" + + "github.com/hrygo/hotplex/internal/messaging" + "github.com/hrygo/hotplex/pkg/events" ) // Card header template color constants (Feishu CardKit v2). @@ -81,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, 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, + "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" @@ -168,3 +184,85 @@ func turnTags(turnNum int, model, branch, workDir string) []cardTag { } return tags } + +// 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 { + var elements []map[string]any + + for _, q := range questions { + headerLabel := messaging.SanitizeText(q.Header) + if headerLabel == "" { + 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 { + sb.WriteString("\n*(可多选)*") + } + + // Always show numbered option list as visible fallback — + // buttons may not render on all clients. + if len(opts) > 0 { + sb.WriteString("\n\n") + 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, opt.Label) + } + } + } + + elements = append(elements, map[string]any{ + "tag": "markdown", + "content": sb.String(), + }) + + // Action buttons with copy_text behavior. + 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": 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 +} + +// 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 38df8aa6..ffc73d1f 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) { @@ -107,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) { @@ -132,6 +181,69 @@ 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.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"]) + }) + + 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"]) + 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"]) + 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) { @@ -165,3 +277,153 @@ 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: 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.Contains(t, content, "1. **JWT**") + require.Contains(t, content, "2. **Session**") + require.Contains(t, content, "3. **OAuth**") + + // 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**") + }) + + t.Run("multi_select", func(t *testing.T) { + t.Parallel() + tests := []struct { + name string + questions []events.Question + wantHint string // substring expected in markdown content + dontWant string // substring that must NOT appear + }{ + { + 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: "(可多选)", + }, + { + name: "false has no marker", + questions: []events.Question{{ + Header: "Pick one", + Question: "Which?", + MultiSelect: false, + Options: []events.QuestionOption{{Label: "A"}}, + }}, + dontWant: "可多选", + }, + } + 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 3b2277c7..f1c7d382 100644 --- a/internal/messaging/feishu/interaction.go +++ b/internal/messaging/feishu/interaction.go @@ -67,40 +67,24 @@ 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 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 { 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") - } - - footer := "---\n💬 回复选项文本或自定义答案来响应此问题" + elements := buildQuestionElements(data.Questions) + elements = append(elements, + map[string]any{"tag": "hr"}, + map[string]any{"tag": "markdown", "content": questionFooterHint(data.Questions)}, + ) - cardJSON := buildInteractionCard(sb.String(), footer, cardHeader{ + cardJSON := buildV1Card(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 { @@ -365,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) } @@ -384,6 +369,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() }