Skip to content
4 changes: 3 additions & 1 deletion docs/explanation/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(输入验证)
Expand Down
62 changes: 45 additions & 17 deletions docs/reference/admin-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(推荐)
Expand Down Expand Up @@ -42,15 +42,15 @@ admin:

| Scope Token | Health | Sessions | Stats | Config | Debug | Cron | 覆盖的核心端点 (Endpoints) |
|:---|:---:|:---:|:---:|:---:|:---:|:---:|:---|
| `health:read` | 🟢 Read | - | - | - | - | - | `/admin/health/workers`<br>`/admin/health/ready` |
| `health:read` | 🟢 Read | - | - | - | - | - | `/admin/health/workers` |
| `session:read` | - | 🟢 Read | - | - | - | - | `GET /admin/sessions`<br>`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`<br>`GET /admin/metrics` |
| `config:read` | - | - | - | 🟢 Read | - | - | `POST /admin/config/validate` |
| `config:write` | - | - | - | 🟠 Write | - | - | `POST /admin/config/rollback` |
| `admin:read` | - | - | - | - | 🟢 Read | 🟢 Read | `GET /admin/logs`<br>`GET /admin/debug/...`<br>`GET /admin/bots`<br>`GET /api/cron/jobs` |
| `admin:write` | - | - | - | - | - | 🟠 Write | `POST/PATCH/DELETE /api/cron/jobs`<br>`POST /api/cron/jobs/{id}/run` |
| `admin:read` | - | - | - | - | 🟢 Read | 🟢 Read | `GET /admin/logs`<br>`GET /admin/debug/...`<br>`GET /admin/bots`<br>`GET /admin/cron/jobs` |
| `admin:write` | - | - | - | - | - | 🟠 Write | `POST/PATCH/DELETE /admin/cron/jobs`<br>`POST /admin/cron/jobs/{id}/run` |

> 💡 **图例**:🟢 **Read** (只读查询) | 🟠 **Write** (状态变更/操作) | 🔴 **Delete** (物理删除)

Expand All @@ -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` 附带错误信息。

Expand Down Expand Up @@ -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`。

Expand Down Expand Up @@ -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)。
Expand All @@ -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** — 查询执行历史。

## 错误响应格式

Expand Down Expand Up @@ -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
```
4 changes: 1 addition & 3 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ AI-native 定时任务引擎:自然语言 prompt 作为 payload,结果投递

#### 3.11.7 多 Bot 配置(Multi-Bot)

每个平台支持多个独立 bot 实例,各自拥有独立凭证、STT/TTS 和 soul 配置。
每个平台支持多个独立 bot 实例,各自拥有独立凭证、STT/TTS 配置。

**SlackBotConfig 字段**:

Expand All @@ -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 级) |
Expand All @@ -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 配置 |
Expand Down
16 changes: 8 additions & 8 deletions docs/specs/AI-Native-Cronjob-Spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
→ 返回确认给用户
```

Expand Down Expand Up @@ -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 |

---

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/Turns-Materialized-Table-Spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 接口

Expand Down
4 changes: 2 additions & 2 deletions docs/tutorials/feishu-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,9 @@ HOTPLEX_MESSAGING_TTS_MAX_CHARS=150
<details>
<summary>交互与指示器</summary>

**权限交互**:Bot 发送确认卡片时,用户直接回复文本「允许」或「拒绝」即可,无需点击按钮
**权限交互**:Bot 发送确认卡片时,用户直接回复文本「允许」或「拒绝」即可。

**Typing 指示器**:Bot 收到消息后自动添加 👀 emoji reaction,回复完成后自动移除
**选项交互**:Bot 发送多选项问题(如 AskUserQuestion)时,卡片包含可点击的复制按钮。点击按钮自动复制选项文本到剪贴板,粘贴发送即可响应。也可直接手动输入选项文本或自定义答案

这些行为为内置默认,无需额外配置。

Expand Down
98 changes: 98 additions & 0 deletions internal/messaging/feishu/card_template.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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也可直接回复选项文本或自定义答案"
}
Loading