eve-lark 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.zh-CN.md CHANGED
@@ -2,48 +2,48 @@
2
2
 
3
3
  [English](./README.md) | 简体中文
4
4
 
5
- 一个为 [eve](https://eve.dev) agent 框架打造的 [Lark](https://www.larksuite.com) / [Feishu](https://www.feishu.cn) 通道。把工厂函数放到 `agent/channels/lark.ts`,eve 就会挂载一个 Lark webhook,把收到的私聊消息和群组 @ 提及转换成流式交互卡片回复。
5
+ 一个为 [eve](https://eve.dev) agent 框架打造的 [Lark](https://www.larksuite.com) / [Feishu](https://www.feishu.cn) 通道。把工厂函数放到 `agent/channels/lark.ts`,eve 就会挂载一个 Lark webhook,把收到的私聊消息和群组 @ 提及转成回复。
6
6
 
7
7
  ## 特性
8
8
 
9
9
  **入站**
10
- - 文本、富文本(`post`)、`@` 提及(包括 `@all`)
11
- - 图片和文件附件(服务端下载并暂存给模型使用)
12
- - 通过 `root_id` / `parent_id` 实现的话题回复
13
- - `event_id` 去重(处理 Feishu 的 at-least-once 重试)
10
+ - 文本、富文本(`post`)、`@`-提及(包括 `@all`)
11
+ - 图片和文件附件(服务端下载并 stage 给模型)
12
+ - 通过 `root_id` / `parent_id` 跟踪线程
13
+ - `event_id` 去重(应对飞书 at-least-once 重试)
14
14
 
15
15
  **出站**
16
- - 流式交互卡片(在对话过程中实时 patch 更新)—— 默认模式
17
- - 静态一次性卡片回复 —— 可配置
18
- - 话题回复保留原始 `root_id`
16
+ - 流式交互卡片(对话过程中实时 patch)—— 可选模式
17
+ - `post` 富文本消息(**默认**,渲染为原生聊天消息大小,支持 markdown)
18
+ - 静态一次性卡片 —— 可选
19
+ - 线程回复保留原始 `root_id`
19
20
 
20
21
  **安全**
21
- - `X-Lark-Signature` 签名校验(`sha256(timestamp + nonce + encrypt_key + body)`,恒定时间比较)
22
- - 当配置了 `encryptKey` 时,对 `encrypt` 信封进行 AES-256-CBC 解密
23
- - 时间戳偏移窗口(默认 5 分钟)
24
- - 抑制 bot 自身发出的消息
22
+ - `X-Lark-Signature` 校验(`sha256(timestamp + nonce + encrypt_key + body)`,constant-time)
23
+ - 当配置了 `encryptKey` 时,AES-256-CBC 解密 `encrypt` 信封
24
+ - 时间戳偏差窗口(默认 5 分钟)
25
+ - 抑制 bot 自己发的消息
25
26
 
26
- **Feishu Lark 都支持**,只需切换一个 `baseUrl` 即可。
27
+ **交互式 ask_question**——当模型调用 eve 内置的 `ask_question` 工具时,eve-lark 会把提示渲染成一张飞书交互卡片,每个选项对应一个按钮(option.style `primary` / `default` / `danger` 直接映射到飞书 button type)。用户点击触发 `card.action.trigger` 回调,channel 把答案作为 `InputResponse` 发回 eve,parked session 恢复。`allowFreeform: true` 允许用户直接回复普通聊天消息代替点击——下一条同 chat 内的消息会被拦截为答案。回答后卡片原地更新(移除按钮,选中项以绿色 ✓ 标记)。
27
28
 
28
- ### v1 暂不支持
29
+ **Feishu(飞书)和 Lark(国际版)** 通过单一的 `baseUrl` 切换支持。
29
30
 
30
- 以下能力**有意**没有发布 —— 如果需要请提 issue:
31
- - 入站的音频 / 媒体 / 贴纸 / share_chat / share_user(仅 ack 并跳过)
31
+ ### 不在 v1 范围内
32
+
33
+ 以下功能**未实现**——需要的话请提 issue:
34
+ - 音频 / 媒体 / sticker / share_chat / share_user 入站(仅 ack-and-skip)
32
35
  - 多账号配置
33
- - 用户级 OAuth(`user_access_token` 设备流程)
34
- - Feishu API 工具(文档 / 多维表格 / 日历 / 任务 / 云盘)
35
- - 卡片动作按钮(不支持交互式表单)
36
+ - 用户级 OAuth(`user_access_token` device flow)
37
+ - 飞书 API 工具(docs / bitable / calendar / tasks / drive)
36
38
 
37
- ## 快速开始
39
+ > `ask_question` 的卡片按钮**已实现**(0.3.0+)。下面列表中剩下的「Card action buttons」指的是 agent 自己生成的完全自定义的卡片 schema。
40
+ - Card action buttons(agent 自定义的交互表单)
38
41
 
39
- 安装:
42
+ ## 快速开始
40
43
 
41
- ```bash
42
- pnpm add eve-lark
43
- # 或者:npm install eve-lark / yarn add eve-lark
44
- ```
44
+ 两步。一个文件,一条命令。
45
45
 
46
- 在你的 eve agent 中创建通道:
46
+ **1. 声明 channel:**
47
47
 
48
48
  ```ts
49
49
  // agent/channels/lark.ts
@@ -58,19 +58,29 @@ export default createLarkChannel({
58
58
  });
59
59
  ```
60
60
 
61
- 然后在 [Feishu 开发者后台](https://open.feishu.cn/app)(或 [Lark 开发者后台](https://open.larksuite.com/app))中:
61
+ **2. `eve dev`:**
62
62
 
63
- 1. 创建一个**自建应用**,记录 `App ID` 和 `App Secret`。
64
- 2. 在**事件订阅**中,把请求 URL 设置为你的 agent 的 `/lark/webhook`(可通过 `webhookPath` 选项覆盖)。
65
- 3. 生成 **Verification Token** 和 **Encrypt Key** —— 都复制到你的环境变量里。
66
- 4. 订阅 `im.message.receive_v1` 事件。
67
- 5. 把 bot 拉进群组或直接私聊。
63
+ ```bash
64
+ pnpm add eve-lark eve
65
+ eve dev
66
+ ```
67
+
68
+ 完事。channel 在构造时副作用启动一个飞书 `WSClient`——飞书只看到这条出站 WebSocket,**本地开发不需要公网 webhook URL**。每个事件被重新签名 + 重新加密,POST 到 channel 自己在 `localhost` 的 webhook,由标准 handler 处理(完整 `send()` 访问权限)。
69
+
70
+ 在 [飞书开发者后台](https://open.feishu.cn/app):
71
+ 1. 创建**自建应用**。记下 `App ID` 和 `App Secret`。
72
+ 2. 进入**事件订阅**,选择**「使用长连接接收事件」**模式(不是 HTTP 回调)。
73
+ 3. 生成**Verification Token** 和**Encrypt Key**——都填进你的 env。
74
+ 4. 订阅 `im.message.receive_v1`。
75
+ 5. 把 bot 拉进群或直接私聊。
76
+
77
+ 生产部署时,在飞书后台切回 HTTP 回调模式,并给 `createLarkChannel` 传 `mode: "webhook"`。详见[生产部署](#生产部署)。
68
78
 
69
79
  ## 配置参考
70
80
 
71
- 所有字段都可以通过选项传入,或从对应的环境变量读取(选项优先)。
81
+ 所有字段既能作为选项传入,也能从对应 env var 读取(选项优先)。
72
82
 
73
- | 字段 | 类型 | 必填 | 默认值 | 环境变量 |
83
+ | 字段 | 类型 | 必填 | 默认 | env var |
74
84
  |---|---|---|---|---|
75
85
  | `appId` | `string` | 是 | — | `LARK_APP_ID` |
76
86
  | `appSecret` | `string` | 是 | — | `LARK_APP_SECRET` |
@@ -78,8 +88,10 @@ export default createLarkChannel({
78
88
  | `encryptKey` | `string` | 否 | — | `LARK_ENCRYPT_KEY` |
79
89
  | `baseUrl` | `string` | 否 | `https://open.feishu.cn` | `LARK_BASE_URL` |
80
90
  | `botOpenId` | `string` | 否 | — | `LARK_BOT_OPEN_ID` |
91
+ | `mode` | `"long-connection" \| "webhook"` | 否 | `"long-connection"` | `LARK_MODE` |
92
+ | `port` | `number` | 否 | `$PORT` 或 `2000` | `PORT` |
81
93
  | `webhookPath` | `string` | 否 | `/lark/webhook` | — |
82
- | `replyMode` | `"streaming" \| "static"` | 否 | `"streaming"` | |
94
+ | `replyMode` | `"post" \| "streaming" \| "static"` | 否 | `"post"` | `LARK_REPLY_MODE` |
83
95
  | `streamPatchIntervalMs` | `number` | 否 | `1000` | — |
84
96
  | `streamCreateThresholdMs` | `number` | 否 | `400` | — |
85
97
  | `dedupTtlMs` | `number` | 否 | `1_800_000`(30 分钟) | — |
@@ -88,11 +100,12 @@ export default createLarkChannel({
88
100
  | `maxRetries` | `number` | 否 | `2` | — |
89
101
  | `tokenRefreshBufferMs` | `number` | 否 | `300_000`(5 分钟) | — |
90
102
  | `signatureSkewMs` | `number` | 否 | `300_000`(5 分钟) | — |
103
+ | `ackReaction` | `string \| readonly string[] \| false` | 否 | `"Typing"` | — |
91
104
  | `fetch` | `typeof fetch` | 否 | `globalThis.fetch` | — |
92
105
 
93
- ## Feishu Lark(国际版)
106
+ ## Feishu vs Lark(国际版)
94
107
 
95
- 两套部署使用相同的 API。通过 `baseUrl` 切换:
108
+ 两个部署用同一套 API。通过 `baseUrl` 切换:
96
109
 
97
110
  ```ts
98
111
  createLarkChannel({
@@ -101,122 +114,141 @@ createLarkChannel({
101
114
  });
102
115
  ```
103
116
 
104
- 或通过环境变量:`LARK_BASE_URL=https://open.larksuite.com`。
117
+ 或通过 env:`LARK_BASE_URL=https://open.larksuite.com`。
118
+
119
+ ## 回复模式
105
120
 
106
- ## 流式 vs 静态模式
121
+ - **`post`**(默认):channel `message.completed`,把回复作为 `msg_type: "post"` 富文本消息发出。**渲染为原生聊天消息大小**,完整支持 markdown(粗体、链接、代码、`<font>` 颜色 tag)。代价:不能流式——用户在 turn 完成时才看到回复。
122
+ - **`streaming`**:channel 在第一个 delta 时创建交互卡片,节流地实时 patch(约 1 秒一次),turn 完成时收尾。**实时 UX 好**,但卡片文字比原生消息字号小(飞书把卡片当作「结构化内容」)。
123
+ - **`static`**:和 `post` 一样等完成再发,但用交互卡片而非 post。适合需要卡片特性(按钮、多列布局)且不在乎字号小的场景。
107
124
 
108
- - **`streaming`**(默认):通道在第一个 delta 时创建交互卡片,节流地实时 patch(约 1 秒一次),并在回合结束时收尾。用户体验最好。
109
- - **`static`**:通道等待 `message.completed`,然后一次性发送包含完整答案的卡片。API 调用量更低;当你撞上 Feishu 的 PATCH 限流时有用。
125
+ 流式节流通过 `streamPatchIntervalMs` 调整(值越小越平滑,但 API 调用越多)。
110
126
 
111
- 通过 `streamPatchIntervalMs` 调节节流间隔(值越小越平滑,API 调用越多)。
127
+ ```bash
128
+ LARK_REPLY_MODE=streaming # 切到实时 patch
129
+ ```
112
130
 
113
- ## 续接 token 与话题
131
+ ## Continuation token 与线程
114
132
 
115
- eve-lark 使用 chat id 加上话题根消息 id 作为会话续接 token:
133
+ eve-lark chat id 加线程 root message id 作为 session continuation token:
116
134
 
117
135
  ```
118
136
  <chat_id>:<root_message_id>
119
137
  ```
120
138
 
121
- 对于顶层会话,root 是 `_`:
139
+ 对于顶层对话,root 是 `_`:
122
140
 
123
141
  ```
124
- oc_xxx:_ — 顶层对话
125
- oc_xxx:om_yyy — om_yyy 话题中的回复
142
+ oc_xxx:_ — 顶层
143
+ oc_xxx:om_yyy — om_yyy 线程里的回复
126
144
  ```
127
145
 
128
- 话题中的回复会跨回合保持话题锚点。该 token 按通道 id 命名空间化(eve 框架会前置通道文件名),所以同时部署多个自定义通道与 `lark` 共存是安全的。
146
+ 线程内的回复跨 turn 保留 thread anchor。token channel id 命名空间隔离(eve 框架在前面拼上 channel 文件名),所以可以同时挂多个自定义 channel。
129
147
 
130
148
  ## 安全模型
131
149
 
132
- - **签名校验**:当设置了 `encryptKey` 时,每个入站 webhook 必须携带有效的 `X-Lark-Signature` 头。不匹配返回 HTTP 401。
133
- - **AES 解密**:设置了 `encryptKey` 时,使用 AES-256-CBC 解密 `encrypt` 信封,其中 `key = SHA256(encrypt_key)`,IV 取前 16 字节。
134
- - **时间戳偏移**:早于 `signatureSkewMs` 的请求会以 HTTP 408 拒绝。
135
- - **去重**:`event_id` 会被记住 `dedupTtlMs` 时间。重放返回 200 但不会重新启动回合。
136
- - **Serverless 注意事项**:去重是进程内的。多实例部署在极端时序窗口下可能会重复处理同一事件 —— 请把你的工具做成幂等的。
150
+ - **签名校验**:设置了 `encryptKey` 时,每个入站 webhook 必须带有效的 `X-Lark-Signature` 头。不匹配返回 HTTP 401。
151
+ - **AES 解密**:设置了 `encryptKey` 时,`encrypt` 信封用 AES-256-CBC 解密,`key = SHA256(encrypt_key)`,前 16 字节作为 IV。
152
+ - **时间戳偏差**:超过 `signatureSkewMs` 的请求返回 HTTP 408
153
+ - **去重**:`event_id` 记忆 `dedupTtlMs` 时长。重放返回 200,不重新启动 turn。
154
+ - **Serverless 注意**:去重是进程内的。多实例部署在极少数 timing 窗口下可能 double-process 事件——让你的工具幂等。
137
155
 
138
- ## 文件与图片入站
156
+ ## 文件 & 图片入站
139
157
 
140
- 入站的图片/文件消息会被转换成 eve `UserContent` 文件 part。其 `data` 字段是一个指向 Lark 资源端点的 `URL`,所以 eve 的管道会调用通道的 `fetchFile` 钩子(使用 bot 的 `tenant_access_token`)把字节暂存给模型。
158
+ 入站的图片/文件消息会被转成 eve `UserContent` file part。`data` 字段是指向飞书 resource endpoint `URL`,所以 eve pipeline 会调用 channel 的 `fetchFile` 钩子(用 bot 的 `tenant_access_token`)把字节 stage 给模型。
141
159
 
142
- 如果你希望 URL part 直接透传而不暂存字节(例如在 eve sandbox 之外运行),不要设置 `encryptKey`,并在你的工具里检查 `attributes`。
160
+ 如果你想让 URL 部分直接透传(比如在 eve sandbox 外运行),不要设 `encryptKey`,改在工具里读 `attributes`。
143
161
 
144
162
  ## 错误
145
163
 
146
- eve-lark 抛出一个小的有类型层次结构:
164
+ eve-lark 抛出一组类型化错误:
147
165
 
148
166
  ```
149
167
  LarkChannelError
150
- ├── LarkConfigError — 缺少必填选项
151
- ├── LarkSignatureError — 签名校验失败(很少抛出;通常返回 401 Response
168
+ ├── LarkConfigError — 缺必填选项
169
+ ├── LarkSignatureError — 签名校验失败(很少抛;通常返回 401)
152
170
  ├── LarkDecryptError — AES 解密失败
153
- └── LarkApiError — Lark API 调用失败(携带 .code、.status、.body)
171
+ └── LarkApiError — Lark API 调用失败(带 .code、.status、.body)
154
172
  ```
155
173
 
156
- webhook 处理器返回结构化的 HTTP 响应,方便服务端处理:
174
+ webhook handler 返回结构化 HTTP 响应,方便服务端处理:
157
175
 
158
- | 状态码 | 原因 |
176
+ | Status | 原因 |
159
177
  |---|---|
160
- | 200 | Ack(成功或故意忽略的事件) |
161
- | 400 | JSON 无效 / 解密失败 |
162
- | 401 | 签名缺失/无效,或 verification token 不匹配 |
163
- | 408 | 超出时间戳偏移窗口 |
178
+ | 200 | Ack(成功或有意忽略的事件) |
179
+ | 400 | 无效 JSON / 解密失败 |
180
+ | 401 | 签名缺失/无效或 verification token 不匹配 |
181
+ | 408 | 时间戳偏差超过窗口 |
182
+ | 413 | 请求 body 超过 1 MB 上限 |
164
183
 
165
- ## 限制与路线图
184
+ ## 限制 & roadmap
166
185
 
167
- **v1 限制**:见 [暂不支持](#v1-暂不支持)。
186
+ **v1 限制**:见[不在范围内](#不在-v1-范围内)。
168
187
 
169
- **v2 规划**(如果你希望优先实现某项,欢迎提 issue):
170
- - 卡片动作按钮处理(交互式表单、确认流程)
188
+ **v2 计划**(想优先哪个就开 issue):
189
+ - 完全自定义的卡片交互(agent 自渲染表单、确认流)
171
190
  - 音频 / 媒体入站转写
172
- - 可选的 Redis 支持的多实例去重
173
- - 用户级 OAuth(`user_access_token`),用于 Feishu API 工具
191
+ - 可选的 Redis 后端去重,支持多实例部署
192
+ - 用户级 OAuth(`user_access_token`)用于飞书 API 工具
174
193
 
175
194
  ## 开发
176
195
 
177
196
  ```bash
178
197
  pnpm install
179
- pnpm test # 运行 vitest 测试套件
180
- pnpm test:watch # 交互式 watch 模式
198
+ pnpm test # vitest 测试套件
199
+ pnpm test:watch # 交互式 watch
181
200
  pnpm typecheck # tsc --noEmit
182
201
  pnpm lint # eslint
183
- pnpm build # tsup 构建 → dist/
202
+ pnpm build # tsup build → dist/
184
203
  ```
185
204
 
186
- ## 对接真实 Feishu 应用做冒烟测试
205
+ ## 真实飞书应用冒烟测试
206
+
207
+ 完整流程见 [`examples/README.md`](./examples/README.md)。TL;DR 跟[快速开始](#快速开始)一样:装依赖、填 `.env`、跑 `eve dev`。给 bot 发 `ping`,应该收到 `pong` 回复。
208
+
209
+ ## 生产部署
210
+
211
+ 生产环境切到 HTTP 回调模式:
212
+
213
+ ```ts
214
+ // agent/channels/lark.ts
215
+ export default createLarkChannel({
216
+ // ... 凭据 ...
217
+ mode: "webhook", // 关闭 WSClient 副作用
218
+ });
219
+ ```
187
220
 
188
- 参见 [`examples/README.md`](./examples/README.md),其中介绍了一种双进程的搭建方式,使用 Feishu 的长连接传输(无需公网 webhook URL)。简单来说:
221
+ 在飞书后台,把**事件订阅**从「长连接」切回**HTTP 回调**,URL 设为你部署的 agent 的 `/lark/webhook`。然后:
189
222
 
190
223
  ```bash
191
- pnpm build # 构建 eve-lark,让 agent 能 import
192
- cp examples/agent/.env.example examples/agent/.env
193
- $EDITOR examples/agent/.env # 填入凭据
194
- cd examples/agent && pnpm install
195
- # 终端 A:
196
- cd examples/agent && pnpm dev # eve dev server
197
- # 终端 B(在 repo 根目录):
198
- pnpm tsx examples/ws-forwarder.ts # Feishu WS → localhost HTTP
224
+ eve build
225
+ eve deploy # 或:在有公网 URL 的服务器上跑 eve start
199
226
  ```
200
227
 
201
- 在 Feishu 中向 bot 发送 `ping`,应该能收到 `pong` 的流式卡片回复。
228
+ 其他逻辑(签名、AES、去重、流式)不变。
202
229
 
203
- 测试布局:
230
+ 测试目录:
204
231
 
205
232
  ```
206
233
  test/
207
- ├── crypto.spec.ts # 签名 & AES 向量(包含一个 round-trip 辅助函数)
208
- ├── dedup.spec.ts # TTL、FIFO 淘汰、惰性清理
234
+ ├── crypto.spec.ts # 签名 & AES 测试向量(含 round-trip helper)
235
+ ├── dedup.spec.ts # TTL、FIFO 淘汰、惰性 sweep
209
236
  ├── options.spec.ts # env 回退、默认值、校验
210
- ├── parse.spec.ts # text/image/file/post/mention fixture
211
- ├── lark-client.spec.ts # token 互斥锁、重试策略(429/5xx/401)、nock 等价的 mock
237
+ ├── parse.spec.ts # text/image/file/post/mention fixtures
238
+ ├── lark-client.spec.ts # token mutex、retry policy(429/5xx/401)、mock fetch
212
239
  ├── streaming-controller.spec.ts # FSM 状态转换、节流、降级
213
- ├── card.spec.ts # 卡片构建器
214
- ├── channel.spec.ts # 端到端 webhook:校验、解密、去重、会话启动、流式串联
240
+ ├── card.spec.ts # 卡片构造器
241
+ ├── feishu-emoji.spec.ts # 飞书 emoji 白名单
242
+ ├── launcher-detection.spec.ts # eve start launcher 进程识别
243
+ ├── long-connection.spec.ts # WSClient 单例守卫、转发签名/AES
244
+ ├── channel.spec.ts # 端到端 webhook:校验、解密、去重、session 启动、ack reaction
245
+ ├── ask-card.spec.ts # ask_question 卡片构造器
246
+ ├── ask-flow.spec.ts # ask_question 端到端:渲染、点击回调、freeform 拦截
215
247
  └── helpers/
216
- ├── encrypt.ts # 仅测试用的 AES 加密镜像
217
- └── mock-fetch.ts # 替代 nock 的微型 mock fetch(兼容原生 fetch)
248
+ ├── encrypt.ts # 仅测试用的 AES cipher 镜像
249
+ └── mock-fetch.ts # 替代 nock 的迷你 mock fetch
218
250
  ```
219
251
 
220
- ## 协议
252
+ ## License
221
253
 
222
254
  MIT —— 见 [LICENSE](./LICENSE)。
package/dist/index.d.ts CHANGED
@@ -197,9 +197,34 @@ type LarkCardElement = {
197
197
  }>;
198
198
  } | {
199
199
  tag: "action";
200
- actions: LarkCardButton[];
200
+ actions: LarkCardActionItem[];
201
201
  layout?: "bisected" | "trisection" | "flow";
202
202
  };
203
+ /** Union of action-row item shapes: buttons (yes/no confirm style) and
204
+ * select menus (dropdowns for longer option lists). */
205
+ type LarkCardActionItem = LarkCardButton | LarkCardSelectMenu;
206
+ interface LarkCardSelectMenu {
207
+ tag: "select_static";
208
+ placeholder?: {
209
+ tag: "plain_text";
210
+ content: string;
211
+ };
212
+ /** Initially-selected option id (string). */
213
+ initial_option?: string;
214
+ /** Selectable options. `value` carries the optionId we get back in
215
+ * `action.option` when the user picks one. */
216
+ options: Array<{
217
+ text: {
218
+ tag: "plain_text";
219
+ content: string;
220
+ };
221
+ value: string;
222
+ }>;
223
+ /** Same marker payload as a button so the dispatcher can recognise our
224
+ * own callbacks. `optionId` is NOT set here — it comes back via
225
+ * `action.option` instead. */
226
+ value?: Record<string, unknown>;
227
+ }
203
228
  interface LarkCardButton {
204
229
  tag: "button";
205
230
  text: {
package/dist/index.js CHANGED
@@ -564,26 +564,56 @@ function buildErrorCard(message) {
564
564
  }
565
565
  __name(buildErrorCard, "buildErrorCard");
566
566
  var ASK_BUTTON_VALUE_MARKER = "__eveLarkAsk";
567
+ var ASK_OPTIONS_BUTTON_MAX = 3;
567
568
  function buildAskCard(request) {
568
569
  const elements = [
569
570
  { tag: "div", text: { tag: "lark_md", content: request.prompt } }
570
571
  ];
571
- if (request.options && request.options.length > 0) {
572
- const buttons = request.options.map((opt) => ({
573
- tag: "button",
574
- text: { tag: "plain_text", content: opt.label },
575
- type: opt.style ?? "default",
576
- value: {
577
- [ASK_BUTTON_VALUE_MARKER]: true,
578
- requestId: request.requestId,
579
- optionId: opt.id
580
- },
581
- ...opt.description ? { confirm: { title: { tag: "plain_text", content: opt.label }, text: { tag: "plain_text", content: opt.description } } } : {}
582
- }));
583
- elements.push({ tag: "action", actions: buttons });
572
+ const optionCount = request.options?.length ?? 0;
573
+ if (optionCount > 0) {
574
+ const useSelect = request.display === "select" || optionCount > ASK_OPTIONS_BUTTON_MAX;
575
+ if (useSelect) {
576
+ elements.push({
577
+ tag: "action",
578
+ actions: [
579
+ {
580
+ tag: "select_static",
581
+ placeholder: { tag: "plain_text", content: "Select an option\u2026" },
582
+ options: request.options.map((opt) => ({
583
+ text: { tag: "plain_text", content: opt.label },
584
+ value: opt.id
585
+ })),
586
+ // Marker carries requestId; optionId is returned via action.option.
587
+ value: {
588
+ [ASK_BUTTON_VALUE_MARKER]: true,
589
+ requestId: request.requestId,
590
+ __larkSelect: true
591
+ }
592
+ }
593
+ ]
594
+ });
595
+ } else {
596
+ const buttons = request.options.map((opt) => ({
597
+ tag: "button",
598
+ text: { tag: "plain_text", content: opt.label },
599
+ type: opt.style ?? "default",
600
+ value: {
601
+ [ASK_BUTTON_VALUE_MARKER]: true,
602
+ requestId: request.requestId,
603
+ optionId: opt.id
604
+ },
605
+ ...opt.description ? {
606
+ confirm: {
607
+ title: { tag: "plain_text", content: opt.label },
608
+ text: { tag: "plain_text", content: opt.description }
609
+ }
610
+ } : {}
611
+ }));
612
+ elements.push({ tag: "action", actions: buttons });
613
+ }
584
614
  }
585
615
  if (request.allowFreeform) {
586
- const hint = request.options && request.options.length > 0 ? "_\u2026or reply to this chat with your own answer_" : "_Reply to this chat with your answer_";
616
+ const hint = optionCount > 0 ? "_\u2026or reply to this chat with your own answer_" : "_Reply to this chat with your answer_";
587
617
  elements.push({ tag: "div", text: { tag: "lark_md", content: hint } });
588
618
  }
589
619
  return { config: { ...BASE_CONFIG }, elements };
@@ -1327,17 +1357,39 @@ function buildUserContent(text, files, options, messageId) {
1327
1357
  return parts;
1328
1358
  }
1329
1359
  __name(buildUserContent, "buildUserContent");
1330
- function errMsgFrom(data, fallback) {
1331
- if (typeof data !== "object" || data === null) return fallback;
1332
- const err = data.error;
1333
- if (typeof err === "string") return err;
1334
- if (typeof err === "object" && err !== null) {
1335
- const msg = err.message;
1336
- if (typeof msg === "string") return msg;
1337
- }
1338
- return fallback;
1360
+ function formatErrorHint(data) {
1361
+ if (typeof data !== "object" || data === null) return "";
1362
+ const d = data;
1363
+ const detailsName = typeof d.details === "object" && d.details !== null ? d.details.name : void 0;
1364
+ const name = typeof detailsName === "string" && detailsName.length > 0 ? detailsName : void 0;
1365
+ const message = typeof d.message === "string" ? d.message.trim() : "";
1366
+ if (name && message.length > 0) return ` (${name}: ${truncateForDisplay(message)})`;
1367
+ if (name) return ` (${name})`;
1368
+ if (message.length > 0) return ` (${truncateForDisplay(message)})`;
1369
+ return "";
1339
1370
  }
1340
- __name(errMsgFrom, "errMsgFrom");
1371
+ __name(formatErrorHint, "formatErrorHint");
1372
+ function extractErrorId(details) {
1373
+ if (typeof details === "object" && details !== null) {
1374
+ const id = details.errorId;
1375
+ return typeof id === "string" && id.length > 0 ? id : void 0;
1376
+ }
1377
+ return void 0;
1378
+ }
1379
+ __name(extractErrorId, "extractErrorId");
1380
+ function truncateForDisplay(s, max = 160) {
1381
+ return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}\u2026`;
1382
+ }
1383
+ __name(truncateForDisplay, "truncateForDisplay");
1384
+ function formatFailureMessage(data, fallback, opts = { sentence: "turn" }) {
1385
+ const hint = formatErrorHint(data);
1386
+ const errorId = extractErrorId(data?.details);
1387
+ const lead = opts.sentence === "session" ? "This session couldn't recover from an error" : "I hit an error while handling your request";
1388
+ const idSuffix = errorId ? ` (Error id: ${errorId})` : "";
1389
+ if (!hint && !errorId) return `\u26A0 ${fallback}`;
1390
+ return `\u26A0 ${lead}${hint}.${idSuffix}`;
1391
+ }
1392
+ __name(formatFailureMessage, "formatFailureMessage");
1341
1393
  function createLarkChannel(optionsInput) {
1342
1394
  const options = resolveOptions(optionsInput);
1343
1395
  const client = new LarkClient(options);
@@ -1671,50 +1723,69 @@ function createLarkChannel(optionsInput) {
1671
1723
  return ackOk();
1672
1724
  }
1673
1725
  const requestId = typeof value.requestId === "string" ? value.requestId : "";
1674
- const optionId = typeof value.optionId === "string" ? value.optionId : "";
1726
+ const optionId = (typeof value.optionId === "string" ? value.optionId : "") || (typeof evt.action?.option === "string" ? evt.action.option : "");
1675
1727
  if (!requestId) return ackOk();
1676
1728
  const pending = pendingInputsByRequestId.get(requestId);
1677
1729
  if (!pending) {
1678
- console.warn(`[eve-lark] card action for unknown requestId=${requestId} (already answered or expired)`);
1679
- return ackOk();
1680
- }
1681
- const resp = { requestId, optionId: optionId || void 0 };
1682
- const resumeToken = larkContinuationToken(pending.chatId, pending.parentId ?? pending.rootId ?? null);
1683
- const resumeAuth = {
1684
- authenticator: "lark",
1685
- principalType: "user",
1686
- principalId: evt.open_id,
1687
- attributes: {
1688
- chatId: pending.chatId,
1689
- rootMessageId: pending.rootId,
1690
- messageId: evt.open_message_id,
1691
- chatType: pending.request.display === "confirmation" ? "p2p" : "group"
1692
- }
1693
- };
1694
- try {
1695
- await helpers.send(
1696
- { inputResponses: [resp] },
1697
- { auth: resumeAuth, continuationToken: resumeToken }
1698
- );
1699
- console.log(`[eve-lark] ask answered via button click requestId=${requestId} optionId=${optionId}`);
1700
- } catch (e) {
1701
- console.error(
1702
- `[eve-lark] ask input-response send failed (requestId=${requestId}):`,
1703
- e instanceof Error ? e.message : e
1730
+ console.warn(
1731
+ `[eve-lark] card action for unknown requestId=${requestId} (already answered or expired)`
1704
1732
  );
1733
+ return ackOk();
1705
1734
  }
1706
1735
  const selectedOpt = pending.request.options?.find((o) => o.id === optionId);
1707
- if (pending.cardMessageId && selectedOpt) {
1708
- try {
1709
- await client.patchCard({
1710
- messageId: pending.cardMessageId,
1711
- card: buildAskAnsweredCard(pending.request, { kind: "option", label: selectedOpt.label })
1712
- });
1713
- } catch (e) {
1714
- console.warn("[eve-lark] patchCard after ask-answer failed:", e instanceof Error ? e.message : e);
1715
- }
1716
- }
1717
- dropPendingInput(pending);
1736
+ helpers.waitUntil(
1737
+ (async () => {
1738
+ if (pending.cardMessageId && selectedOpt) {
1739
+ try {
1740
+ await client.patchCard({
1741
+ messageId: pending.cardMessageId,
1742
+ card: buildAskAnsweredCard(pending.request, {
1743
+ kind: "option",
1744
+ label: selectedOpt.label
1745
+ })
1746
+ });
1747
+ } catch (e) {
1748
+ console.warn(
1749
+ "[eve-lark] patchCard after ask-answer failed:",
1750
+ e instanceof Error ? e.message : e
1751
+ );
1752
+ }
1753
+ }
1754
+ const resp = { requestId, optionId: optionId || void 0 };
1755
+ const resumeToken = larkContinuationToken(
1756
+ pending.chatId,
1757
+ pending.parentId ?? pending.rootId ?? null
1758
+ );
1759
+ const resumeAuth = {
1760
+ authenticator: "lark",
1761
+ principalType: "user",
1762
+ principalId: evt.open_id,
1763
+ attributes: {
1764
+ chatId: pending.chatId,
1765
+ rootMessageId: pending.rootId,
1766
+ messageId: evt.open_message_id,
1767
+ chatType: pending.request.display === "confirmation" ? "p2p" : "group"
1768
+ }
1769
+ };
1770
+ try {
1771
+ await helpers.send(
1772
+ { inputResponses: [resp] },
1773
+ { auth: resumeAuth, continuationToken: resumeToken }
1774
+ );
1775
+ console.log(
1776
+ `[eve-lark] ask answered via card action requestId=${requestId} optionId=${optionId}`
1777
+ );
1778
+ } catch (e) {
1779
+ console.error(
1780
+ `[eve-lark] ask input-response send failed (requestId=${requestId}):`,
1781
+ e instanceof Error ? e.message : e
1782
+ );
1783
+ }
1784
+ dropPendingInput(pending);
1785
+ })().catch((e) => {
1786
+ console.error("[eve-lark] card action background work failed:", e);
1787
+ })
1788
+ );
1718
1789
  return ackOk();
1719
1790
  }
1720
1791
  __name(handleCardAction, "handleCardAction");
@@ -1815,15 +1886,15 @@ function createLarkChannel(optionsInput) {
1815
1886
  console.warn(`[eve-lark] turn.failed: no session info (sessionId=${sessionId})`);
1816
1887
  return;
1817
1888
  }
1818
- const errMsg = errMsgFrom(data, "turn failed");
1889
+ const userText = formatFailureMessage(data, "turn failed", { sentence: "turn" });
1890
+ const errorId = extractErrorId(data?.details);
1819
1891
  console.warn(
1820
- `[eve-lark] turn.failed sessionId=${sessionId} chatId=${info.chatId} err="${errMsg.slice(0, 200)}"`
1892
+ `[eve-lark] turn.failed sessionId=${sessionId} chatId=${info.chatId} err="${userText.slice(0, 200)}"` + (errorId ? ` errorId=${errorId}` : "")
1821
1893
  );
1822
- const userText = `\u26A0 ${errMsg}`;
1823
1894
  const ctrl = controllers.get(sessionId);
1824
1895
  if (ctrl) {
1825
1896
  try {
1826
- await ctrl.abort(errMsg);
1897
+ await ctrl.abort(userText);
1827
1898
  console.log(`[eve-lark] error shown via streaming abort (sessionId=${sessionId})`);
1828
1899
  } catch (e) {
1829
1900
  console.warn(
@@ -1845,8 +1916,11 @@ function createLarkChannel(optionsInput) {
1845
1916
  dropController(sessionId);
1846
1917
  },
1847
1918
  async "session.failed"(data) {
1848
- const errMsg = errMsgFrom(data, "session failed");
1849
- console.error("[eve-lark] session.failed:", errMsg);
1919
+ const userText = formatFailureMessage(data, "session failed", { sentence: "session" });
1920
+ const errorId = extractErrorId(data?.details);
1921
+ console.error(
1922
+ `[eve-lark] session.failed: ${userText}` + (errorId ? ` (errorId=${errorId})` : "")
1923
+ );
1850
1924
  }
1851
1925
  };
1852
1926
  const channel = defineChannel({