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()
}