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 +128 -96
- package/dist/index.d.ts +26 -1
- package/dist/index.js +142 -68
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
11
|
-
-
|
|
12
|
-
- 通过 `root_id` / `parent_id`
|
|
13
|
-
- `event_id`
|
|
10
|
+
- 文本、富文本(`post`)、`@`-提及(包括 `@all`)
|
|
11
|
+
- 图片和文件附件(服务端下载并 stage 给模型)
|
|
12
|
+
- 通过 `root_id` / `parent_id` 跟踪线程
|
|
13
|
+
- `event_id` 去重(应对飞书 at-least-once 重试)
|
|
14
14
|
|
|
15
15
|
**出站**
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
16
|
+
- 流式交互卡片(对话过程中实时 patch)—— 可选模式
|
|
17
|
+
- `post` 富文本消息(**默认**,渲染为原生聊天消息大小,支持 markdown)
|
|
18
|
+
- 静态一次性卡片 —— 可选
|
|
19
|
+
- 线程回复保留原始 `root_id`
|
|
19
20
|
|
|
20
21
|
**安全**
|
|
21
|
-
- `X-Lark-Signature`
|
|
22
|
-
- 当配置了 `encryptKey`
|
|
23
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
+
**Feishu(飞书)和 Lark(国际版)** 通过单一的 `baseUrl` 切换支持。
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
### 不在 v1 范围内
|
|
32
|
+
|
|
33
|
+
以下功能**未实现**——需要的话请提 issue:
|
|
34
|
+
- 音频 / 媒体 / sticker / share_chat / share_user 入站(仅 ack-and-skip)
|
|
32
35
|
- 多账号配置
|
|
33
|
-
- 用户级 OAuth(`user_access_token`
|
|
34
|
-
-
|
|
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
|
-
|
|
42
|
-
pnpm add eve-lark
|
|
43
|
-
# 或者:npm install eve-lark / yarn add eve-lark
|
|
44
|
-
```
|
|
44
|
+
两步。一个文件,一条命令。
|
|
45
45
|
|
|
46
|
-
|
|
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
|
-
|
|
61
|
+
**2. 跑 `eve dev`:**
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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"` | 否 | `"
|
|
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
|
|
106
|
+
## Feishu vs Lark(国际版)
|
|
94
107
|
|
|
95
|
-
|
|
108
|
+
两个部署用同一套 API。通过 `baseUrl` 切换:
|
|
96
109
|
|
|
97
110
|
```ts
|
|
98
111
|
createLarkChannel({
|
|
@@ -101,122 +114,141 @@ createLarkChannel({
|
|
|
101
114
|
});
|
|
102
115
|
```
|
|
103
116
|
|
|
104
|
-
|
|
117
|
+
或通过 env:`LARK_BASE_URL=https://open.larksuite.com`。
|
|
118
|
+
|
|
119
|
+
## 回复模式
|
|
105
120
|
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
- **`static`**:通道等待 `message.completed`,然后一次性发送包含完整答案的卡片。API 调用量更低;当你撞上 Feishu 的 PATCH 限流时有用。
|
|
125
|
+
流式节流通过 `streamPatchIntervalMs` 调整(值越小越平滑,但 API 调用越多)。
|
|
110
126
|
|
|
111
|
-
|
|
127
|
+
```bash
|
|
128
|
+
LARK_REPLY_MODE=streaming # 切到实时 patch
|
|
129
|
+
```
|
|
112
130
|
|
|
113
|
-
##
|
|
131
|
+
## Continuation token 与线程
|
|
114
132
|
|
|
115
|
-
eve-lark
|
|
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
|
-
|
|
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
|
-
|
|
146
|
+
线程内的回复跨 turn 保留 thread anchor。token 由 channel id 命名空间隔离(eve 框架在前面拼上 channel 文件名),所以可以同时挂多个自定义 channel。
|
|
129
147
|
|
|
130
148
|
## 安全模型
|
|
131
149
|
|
|
132
|
-
-
|
|
133
|
-
- **AES 解密**:设置了 `encryptKey`
|
|
134
|
-
-
|
|
135
|
-
- **去重**:`event_id`
|
|
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
|
-
|
|
158
|
+
入站的图片/文件消息会被转成 eve `UserContent` 的 file part。`data` 字段是指向飞书 resource endpoint 的 `URL`,所以 eve 的 pipeline 会调用 channel 的 `fetchFile` 钩子(用 bot 的 `tenant_access_token`)把字节 stage 给模型。
|
|
141
159
|
|
|
142
|
-
|
|
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 —
|
|
168
|
+
├── LarkConfigError — 缺必填选项
|
|
169
|
+
├── LarkSignatureError — 签名校验失败(很少抛;通常返回 401)
|
|
152
170
|
├── LarkDecryptError — AES 解密失败
|
|
153
|
-
└── LarkApiError — Lark API
|
|
171
|
+
└── LarkApiError — Lark API 调用失败(带 .code、.status、.body)
|
|
154
172
|
```
|
|
155
173
|
|
|
156
|
-
webhook
|
|
174
|
+
webhook handler 返回结构化 HTTP 响应,方便服务端处理:
|
|
157
175
|
|
|
158
|
-
|
|
|
176
|
+
| Status | 原因 |
|
|
159
177
|
|---|---|
|
|
160
|
-
| 200 | Ack
|
|
161
|
-
| 400 | JSON
|
|
162
|
-
| 401 |
|
|
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 限制**:见
|
|
186
|
+
**v1 限制**:见[不在范围内](#不在-v1-范围内)。
|
|
168
187
|
|
|
169
|
-
**v2
|
|
170
|
-
-
|
|
188
|
+
**v2 计划**(想优先哪个就开 issue):
|
|
189
|
+
- 完全自定义的卡片交互(agent 自渲染表单、确认流)
|
|
171
190
|
- 音频 / 媒体入站转写
|
|
172
|
-
- 可选的 Redis
|
|
173
|
-
- 用户级 OAuth(`user_access_token
|
|
191
|
+
- 可选的 Redis 后端去重,支持多实例部署
|
|
192
|
+
- 用户级 OAuth(`user_access_token`)用于飞书 API 工具
|
|
174
193
|
|
|
175
194
|
## 开发
|
|
176
195
|
|
|
177
196
|
```bash
|
|
178
197
|
pnpm install
|
|
179
|
-
pnpm test #
|
|
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
|
|
202
|
+
pnpm build # tsup build → dist/
|
|
184
203
|
```
|
|
185
204
|
|
|
186
|
-
##
|
|
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
|
-
|
|
221
|
+
在飞书后台,把**事件订阅**从「长连接」切回**HTTP 回调**,URL 设为你部署的 agent 的 `/lark/webhook`。然后:
|
|
189
222
|
|
|
190
223
|
```bash
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
228
|
+
其他逻辑(签名、AES、去重、流式)不变。
|
|
202
229
|
|
|
203
|
-
|
|
230
|
+
测试目录:
|
|
204
231
|
|
|
205
232
|
```
|
|
206
233
|
test/
|
|
207
|
-
├── crypto.spec.ts # 签名 & AES
|
|
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
|
|
211
|
-
├── lark-client.spec.ts # token
|
|
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
|
-
├──
|
|
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
|
|
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:
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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 =
|
|
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
|
|
1331
|
-
if (typeof data !== "object" || data === null) return
|
|
1332
|
-
const
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
}
|
|
1338
|
-
return
|
|
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(
|
|
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(
|
|
1679
|
-
|
|
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
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
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
|
|
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="${
|
|
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(
|
|
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
|
|
1849
|
-
|
|
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({
|