@lark-apaas/coding-steering 0.1.6-alpha.0 → 0.1.6-alpha.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.
Files changed (25) hide show
  1. package/package.json +1 -1
  2. package/steering/nestjs-react-fullstack/skills/.gitkeep +0 -0
  3. package/steering/nestjs-react-fullstack/tech.md +21 -0
  4. package/steering/nestjs-react-fullstack/skills/authn-guide/SKILL.md +0 -122
  5. package/steering/nestjs-react-fullstack/skills/client-add-aily-web-chat/SKILL.md +0 -139
  6. package/steering/nestjs-react-fullstack/skills/client-builtins-user-service/SKILL.md +0 -628
  7. package/steering/nestjs-react-fullstack/skills/code-fix/SKILL.md +0 -246
  8. package/steering/nestjs-react-fullstack/skills/coding-guide/SKILL.md +0 -707
  9. package/steering/nestjs-react-fullstack/skills/feishu/SKILL.md +0 -270
  10. package/steering/nestjs-react-fullstack/skills/feishu/references/approval.md +0 -214
  11. package/steering/nestjs-react-fullstack/skills/feishu/references/attendance.md +0 -163
  12. package/steering/nestjs-react-fullstack/skills/feishu/references/bitable.md +0 -309
  13. package/steering/nestjs-react-fullstack/skills/feishu/references/calendar.md +0 -190
  14. package/steering/nestjs-react-fullstack/skills/feishu/references/contacts.md +0 -160
  15. package/steering/nestjs-react-fullstack/skills/feishu/references/doc.md +0 -256
  16. package/steering/nestjs-react-fullstack/skills/feishu/references/drive.md +0 -103
  17. package/steering/nestjs-react-fullstack/skills/feishu/references/events.md +0 -198
  18. package/steering/nestjs-react-fullstack/skills/feishu/references/id-convert.md +0 -128
  19. package/steering/nestjs-react-fullstack/skills/feishu/references/messaging.md +0 -207
  20. package/steering/nestjs-react-fullstack/skills/feishu/references/oauth.md +0 -164
  21. package/steering/nestjs-react-fullstack/skills/feishu/references/perm.md +0 -90
  22. package/steering/nestjs-react-fullstack/skills/feishu/references/wiki.md +0 -164
  23. package/steering/nestjs-react-fullstack/skills/openapi-guide/SKILL.md +0 -267
  24. package/steering/nestjs-react-fullstack/skills/trigger-guide/SKILL.md +0 -452
  25. package/steering/nestjs-react-fullstack/skills/user-identity/SKILL.md +0 -300
@@ -1,256 +0,0 @@
1
- # 云文档 (Document)
2
-
3
- > 开放平台文档(Markdown 版):https://open.larkoffice.com/document/server-docs/docs/docs-overview
4
-
5
- 使用 `@larksuiteoapi/node-sdk` 在 NestJS 中操作飞书云文档。
6
-
7
- ## 所需权限
8
-
9
- | 权限标识 | 说明 |
10
- |----------|------|
11
- | `docx:document` | 读写新版文档 |
12
- | `docx:document:readonly` | 只读新版文档 |
13
- | `docx:document.block:convert` | Markdown 转 Block(写入/追加需要) |
14
- | `drive:drive` | 访问云空间(创建文档到指定文件夹时需要) |
15
-
16
- > 对于已有的文档,需确保应用已被添加为协作者:文档右上角「...」→「更多」→「添加文档应用」。
17
-
18
- ## 从 URL 提取 Token
19
-
20
- URL 格式:`https://xxx.feishu.cn/docx/{doc_token}`
21
-
22
- ```typescript
23
- const url = 'https://xxx.feishu.cn/docx/ABC123def';
24
- const docToken = new URL(url).pathname.split('/docx/')[1]; // 'ABC123def'
25
- ```
26
-
27
- ## 创建文档
28
-
29
- ```typescript
30
- const res = await client.docx.document.create({
31
- data: {
32
- title: '新文档',
33
- // folder_token: 'fldcnXXX', // 可选:目标文件夹
34
- },
35
- });
36
- const docId = res.data?.document?.document_id;
37
- const docUrl = `https://feishu.cn/docx/${docId}`;
38
- ```
39
-
40
- ## 读取文档
41
-
42
- ### 读取工作流
43
-
44
- 1. **先获取纯文本** — 快速了解文档内容
45
- 2. **检查 hint** — 如果返回中包含 `hint` 字段,说明文档含有表格、图片等结构化内容
46
- 3. **获取 Block 列表** — 需要结构化内容时使用 `documentBlock.list()`
47
-
48
- ### 读取纯文本
49
-
50
- ```typescript
51
- const [contentRes, infoRes] = await Promise.all([
52
- client.docx.document.rawContent({ path: { document_id: docToken } }),
53
- client.docx.document.get({ path: { document_id: docToken } }),
54
- ]);
55
- const title = infoRes.data?.document?.title;
56
- const content = contentRes.data?.content;
57
- ```
58
-
59
- ### 读取 Block 列表(结构化内容)
60
-
61
- ```typescript
62
- const blocksRes = await client.docx.documentBlock.list({
63
- path: { document_id: docToken },
64
- });
65
- const blocks = blocksRes.data?.items ?? [];
66
- ```
67
-
68
- ## 写入文档(替换全部内容)
69
-
70
- 工作流:清空现有内容 → 转换 Markdown → 插入新 Block
71
-
72
- ```typescript
73
- // 1. 清空文档内容(保留 Page block)
74
- const existing = await client.docx.documentBlock.list({
75
- path: { document_id: docToken },
76
- });
77
- const childIds = existing.data?.items
78
- ?.filter(b => b.parent_id === docToken && b.block_type !== 1)
79
- .map(b => b.block_id) ?? [];
80
-
81
- if (childIds.length > 0) {
82
- await client.docx.documentBlockChildren.batchDelete({
83
- path: { document_id: docToken, block_id: docToken },
84
- data: { start_index: 0, end_index: childIds.length },
85
- });
86
- }
87
-
88
- // 2. 转换 Markdown 为 Block
89
- const convertRes = await client.docx.document.convert({
90
- data: { content_type: 'markdown', content: markdownContent },
91
- });
92
- const blocks = convertRes.data?.blocks ?? [];
93
-
94
- // 3. 插入 Block
95
- await client.docx.documentBlockChildren.create({
96
- path: { document_id: docToken, block_id: docToken },
97
- data: { children: blocks },
98
- });
99
- ```
100
-
101
- ## 追加内容
102
-
103
- 与写入相同,但跳过清空步骤:
104
-
105
- ```typescript
106
- // 转换 + 插入(不清空)
107
- const convertRes = await client.docx.document.convert({
108
- data: { content_type: 'markdown', content: markdownContent },
109
- });
110
- await client.docx.documentBlockChildren.create({
111
- path: { document_id: docToken, block_id: docToken },
112
- data: { children: convertRes.data?.blocks ?? [] },
113
- });
114
- ```
115
-
116
- ## Block 操作
117
-
118
- ### 获取单个 Block
119
-
120
- ```typescript
121
- const res = await client.docx.documentBlock.get({
122
- path: { document_id: docToken, block_id: blockId },
123
- });
124
- ```
125
-
126
- ### 更新 Block 文本
127
-
128
- ```typescript
129
- await client.docx.documentBlock.patch({
130
- path: { document_id: docToken, block_id: blockId },
131
- data: {
132
- update_text_elements: {
133
- elements: [{ text_run: { content: '新文本内容' } }],
134
- },
135
- },
136
- });
137
- ```
138
-
139
- ### 删除 Block
140
-
141
- ```typescript
142
- // 需要知道 block 在父容器中的 index
143
- const children = await client.docx.documentBlockChildren.get({
144
- path: { document_id: docToken, block_id: parentId },
145
- });
146
- const index = children.data?.items?.findIndex(item => item.block_id === blockId);
147
-
148
- await client.docx.documentBlockChildren.batchDelete({
149
- path: { document_id: docToken, block_id: parentId },
150
- data: { start_index: index, end_index: index + 1 },
151
- });
152
- ```
153
-
154
- ## 图片上传
155
-
156
- ```typescript
157
- import { Readable } from 'stream';
158
-
159
- // 1. 上传图片到文档
160
- const uploadRes = await client.drive.media.uploadAll({
161
- data: {
162
- file_name: 'image.png',
163
- parent_type: 'docx_image',
164
- parent_node: blockId, // 图片 Block 的 block_id
165
- size: imageBuffer.length,
166
- file: Readable.from(imageBuffer) as any,
167
- },
168
- });
169
- if (uploadRes.code !== 0) {
170
- throw new Error(`Image upload failed [${uploadRes.code}]: ${uploadRes.msg}`);
171
- }
172
- const fileToken = uploadRes.data?.file_token;
173
-
174
- // 2. 替换图片 Block 的内容
175
- await client.docx.documentBlock.patch({
176
- path: { document_id: docToken, block_id: blockId },
177
- data: {
178
- replace_image: { token: fileToken },
179
- },
180
- });
181
- ```
182
-
183
- ## Block 类型参考
184
-
185
- | block_type | 名称 | 说明 | 可编辑 |
186
- |------------|------|------|--------|
187
- | 1 | Page | 文档根节点(包含标题) | 否 |
188
- | 2 | Text | 纯文本段落 | 是 |
189
- | 3 | Heading1 | 一级标题 | 是 |
190
- | 4 | Heading2 | 二级标题 | 是 |
191
- | 5 | Heading3 | 三级标题 | 是 |
192
- | 6 | Heading4 | 四级标题 | 是 |
193
- | 7 | Heading5 | 五级标题 | 是 |
194
- | 8 | Heading6 | 六级标题 | 是 |
195
- | 9 | Heading7 | 七级标题 | 是 |
196
- | 10 | Heading8 | 八级标题 | 是 |
197
- | 11 | Heading9 | 九级标题 | 是 |
198
- | 12 | Bullet | 无序列表项 | 是 |
199
- | 13 | Ordered | 有序列表项 | 是 |
200
- | 14 | Code | 代码块 | 是 |
201
- | 15 | Quote | 引用块 | 是 |
202
- | 16 | Equation | LaTeX 公式 | 部分 |
203
- | 17 | Todo | 任务/复选框 | 是 |
204
- | 18 | Bitable | 多维表格嵌入 | 否 |
205
- | 19 | Callout | 高亮块 | 是 |
206
- | 20 | ChatCard | 会话卡片嵌入 | 否 |
207
- | 21 | Diagram | 绘图嵌入 | 否 |
208
- | 22 | Divider | 分割线 | 否 |
209
- | 23 | File | 文件附件 | 否 |
210
- | 24 | Grid | 分栏布局容器 | 否 |
211
- | 25 | GridColumn | 分栏列 | 否 |
212
- | 26 | Iframe | 内嵌网页 | 否 |
213
- | 27 | Image | 图片 | 部分(replace_image) |
214
- | 28 | ISV | 第三方小组件 | 否 |
215
- | 29 | MindnoteBlock | 思维导图嵌入 | 否 |
216
- | 30 | Sheet | 电子表格嵌入 | 否 |
217
- | 31 | Table | 表格 | 部分(仅读取,无法通过 API 创建) |
218
- | 32 | TableCell | 表格单元格 | 是 |
219
- | 33 | View | 视图嵌入 | 否 |
220
- | 34 | Undefined | 未知类型 | 否 |
221
- | 35 | QuoteContainer | 引用容器 | 否 |
222
- | 36 | Task | 飞书任务集成 | 否 |
223
- | 37 | OKR | OKR 集成 | 否 |
224
- | 38 | OKRObjective | OKR 目标 | 否 |
225
- | 39 | OKRKeyResult | OKR 关键结果 | 否 |
226
- | 40 | OKRProgress | OKR 进展 | 否 |
227
- | 41 | AddOns | 扩展块 | 否 |
228
- | 42 | JiraIssue | Jira Issue 嵌入 | 否 |
229
- | 43 | WikiCatalog | 知识库目录 | 否 |
230
- | 44 | Board | 画板嵌入 | 否 |
231
- | 45 | Agenda | 议程块 | 否 |
232
- | 46 | AgendaItem | 议程项 | 否 |
233
- | 47 | AgendaItemTitle | 议程项标题 | 否 |
234
- | 48 | SyncedBlock | 同步块引用 | 否 |
235
-
236
- > 可编辑的文本类 block(2-17, 19)通过 `documentBlock.patch()` 的 `update_text_elements` 更新;容器类 block(24, 25, 35)需编辑其子 block。
237
-
238
- ## Markdown 写入限制
239
-
240
- - **表格不支持**:Markdown 中的表格无法通过 `document.convert()` → `documentBlockChildren.create()` 创建(error 1770029)
241
- - 支持的 Markdown 元素:标题、列表、代码块、引用、链接、图片(`![](url)` 自动上传)、加粗/斜体/删除线
242
-
243
- ## Common Mistakes
244
-
245
- | 错误 | 正确做法 |
246
- |------|----------|
247
- | 直接用 wiki token 调用 docx API | Wiki 页面需先通过 `wiki.space.getNode()` 获取 `obj_token`,再用 `obj_token` 作为 `doc_token` |
248
- | 写入时未清空旧内容导致重复 | 写入前先调用 `documentBlockChildren.batchDelete()` 清空 |
249
- | 删除 Block 传 block_id 而非 index | `batchDelete` 使用 `start_index` 和 `end_index`,非 block_id |
250
- | 尝试通过 API 创建表格 Block | Table block 无法通过 `documentBlockChildren.create()` 创建 |
251
- | `document.convert()` 缺少权限 | 需要 `docx:document.block:convert` 权限 |
252
- | 图片上传 parent_type 填错 | 文档图片必须用 `docx_image`,不是 `doc_image` |
253
- | 更新文本时覆盖了富文本样式 | `update_text_elements` 会替换整个文本内容,包括样式 |
254
- | 并发写入同一文档 | 飞书文档不支持并发写入,需串行操作 |
255
- | 忘记检查 `res.code !== 0` | 所有 API 调用都需检查返回码 |
256
- | 清空文档时删除了 Page block | `block_type !== 1` 的才能删除,Page block 是根节点 |
@@ -1,103 +0,0 @@
1
- # 云空间 (Drive)
2
-
3
- > 开放平台文档(Markdown 版):https://open.larkoffice.com/document/server-docs/docs/drive-v1/introduction
4
-
5
- 使用 `@larksuiteoapi/node-sdk` 在 NestJS 中管理飞书云空间文件和文件夹。
6
-
7
- ## 所需权限
8
-
9
- | 权限标识 | 说明 |
10
- |----------|------|
11
- | `drive:drive` | 读写云空间(创建、移动、删除) |
12
- | `drive:drive:readonly` | 只读云空间(列出、查看) |
13
-
14
- ## 从 URL 提取 Token
15
-
16
- 文件夹 URL 格式:`https://xxx.feishu.cn/drive/folder/{folder_token}`
17
-
18
- ```typescript
19
- const url = 'https://xxx.feishu.cn/drive/folder/ABC123';
20
- const folderToken = new URL(url).pathname.split('/drive/folder/')[1]; // 'ABC123'
21
- ```
22
-
23
- ## 列出文件夹内容
24
-
25
- ```typescript
26
- // 列出指定文件夹
27
- const res = await client.drive.file.list({
28
- params: { folder_token: 'fldcnXXX' },
29
- });
30
- const files = res.data?.files ?? [];
31
- // files: [{token, name, type, url, created_time, modified_time, owner_id}, ...]
32
-
33
- // 列出根目录(不传 folder_token)
34
- const rootRes = await client.drive.file.list({
35
- params: {},
36
- });
37
- ```
38
-
39
- ## 创建文件夹
40
-
41
- ```typescript
42
- const res = await client.drive.file.createFolder({
43
- data: {
44
- name: '新文件夹',
45
- folder_token: 'fldcnXXX', // 父文件夹 token
46
- },
47
- });
48
- const folderToken = res.data?.token;
49
- const folderUrl = res.data?.url;
50
- ```
51
-
52
- ## 移动文件
53
-
54
- ```typescript
55
- const res = await client.drive.file.move({
56
- path: { file_token: 'ABC123' },
57
- data: {
58
- type: 'docx', // 文件类型
59
- folder_token: 'fldcnXXX', // 目标文件夹
60
- },
61
- });
62
- ```
63
-
64
- ## 删除文件
65
-
66
- ```typescript
67
- const res = await client.drive.file.delete({
68
- path: { file_token: 'ABC123' },
69
- params: {
70
- type: 'docx', // 文件类型
71
- },
72
- });
73
- ```
74
-
75
- ## 文件类型参考
76
-
77
- | 类型 | 说明 |
78
- |------|------|
79
- | `doc` | 旧版文档 |
80
- | `docx` | 新版文档 |
81
- | `sheet` | 电子表格 |
82
- | `bitable` | 多维表格 |
83
- | `folder` | 文件夹 |
84
- | `file` | 上传的文件 |
85
- | `mindnote` | 思维导图 |
86
- | `shortcut` | 快捷方式 |
87
-
88
- ## 机器人根文件夹限制
89
-
90
- 飞书机器人使用 `tenant_access_token`,没有自己的「我的空间」根文件夹。这意味着:
91
-
92
- - 不指定 `folder_token` 创建文件夹会失败(400 错误)
93
- - 机器人只能访问**已共享给它**的文件和文件夹
94
- - **解决方案**:用户先手动创建一个文件夹并共享给机器人,机器人就可以在其中创建子文件夹和文件
95
-
96
- ## Common Mistakes
97
-
98
- | 错误 | 正确做法 |
99
- |------|----------|
100
- | 机器人不指定 folder_token 创建文件夹 | 必须指定一个已共享给机器人的 folder_token |
101
- | move/delete 时 type 参数搞错 | type 必须与文件实际类型一致 |
102
- | 用 folder_token 当 file_token | `folder_token` 用于列出目录,`file_token` 用于移动/删除 |
103
- | 文件不在机器人可访问范围内 | 需先将文件/文件夹共享给机器人应用 |
@@ -1,198 +0,0 @@
1
- # 事件订阅 (Events)
2
-
3
- > 开放平台文档(Markdown 版):https://open.larkoffice.com/document/server-docs/event-subscription-guide/overview
4
-
5
- 通过 WebSocket 长连接接收飞书事件推送,无需公网 IP / 域名,无需加解密。
6
-
7
- ## WebSocket 长连接(推荐)
8
-
9
- 使用 NestJS `OnModuleInit` 生命周期钩子启动 WebSocket 长连接:
10
-
11
- ```typescript
12
- import { Injectable, OnModuleInit } from '@nestjs/common';
13
- import * as lark from '@larksuiteoapi/node-sdk';
14
-
15
- @Injectable()
16
- export class FeishuEventService implements OnModuleInit {
17
- private readonly wsClient: lark.WSClient;
18
-
19
- constructor(private readonly feishuService: FeishuService) {
20
- const client = this.feishuService.getClient();
21
- this.wsClient = new lark.WSClient({
22
- appId: FEISHU_APP_ID,
23
- appSecret: FEISHU_APP_SECRET,
24
- loggerLevel: lark.LoggerLevel.info,
25
- });
26
- }
27
-
28
- onModuleInit() {
29
- this.wsClient.start({
30
- eventDispatcher: new lark.EventDispatcher({}).register({
31
- 'im.message.receive_v1': async (data) => {
32
- // 处理收到的消息事件
33
- },
34
- }),
35
- });
36
- }
37
- }
38
- ```
39
-
40
- ## 消息卡片回调(card.action.trigger)
41
-
42
- 用户点击卡片按钮、选择下拉项等交互操作会触发 `card.action.trigger` 回调,通过长连接接收,在 `EventDispatcher.register()` 中注册处理函数即可。
43
-
44
- > **前置配置**:开发者后台 → 事件与回调 → 订阅方式 → 选择「**使用长连接接收事件/回调**」(而非 HTTP 回调地址)
45
-
46
- ```typescript
47
- import { Injectable, OnModuleInit } from '@nestjs/common';
48
- import * as lark from '@larksuiteoapi/node-sdk';
49
-
50
- @Injectable()
51
- export class FeishuEventService implements OnModuleInit {
52
- private readonly wsClient: lark.WSClient;
53
-
54
- constructor(private readonly feishuService: FeishuService) {
55
- this.wsClient = new lark.WSClient({
56
- appId: FEISHU_APP_ID,
57
- appSecret: FEISHU_APP_SECRET,
58
- loggerLevel: lark.LoggerLevel.info,
59
- });
60
- }
61
-
62
- onModuleInit() {
63
- this.wsClient.start({
64
- eventDispatcher: new lark.EventDispatcher({}).register({
65
- // 普通消息事件
66
- 'im.message.receive_v1': async (data) => {
67
- // 处理收到的消息事件
68
- },
69
-
70
- // 消息卡片交互回调
71
- 'card.action.trigger': async (data) => {
72
- const {
73
- action, // 用户触发的交互动作
74
- operator, // 操作者信息
75
- context, // 卡片上下文
76
- } = data;
77
-
78
- // action.value — 交互元素配置的 value(JSON 对象)
79
- // action.tag — 触发的组件类型:'button' | 'select_static' | 'date_picker' 等
80
- // action.option — 下拉选框选中的值(select_static 专用)
81
- // action.form_value — 表单容器提交数据(Record<string, string | string[]>)
82
- // action.name — 用户操作的交互组件名称(开发者自定义)
83
- // operator.open_id — 操作者 open_id(直接字段,非嵌套)
84
- // operator.user_id — 操作者 user_id(字符串)
85
- // operator.union_id — 操作者 union_id
86
- // context.open_message_id — 卡片消息 ID(可用于 patch 更新)
87
- // context.open_chat_id — 所在会话 ID
88
-
89
- // 返回新卡片内容可直接更新该卡片(返回 undefined 则不更新)
90
- // 响应体为 v2 格式:{ toast?, card: { type: 'raw', data: { schema: '2.0', ... } } }
91
- if (action.value?.confirm) {
92
- return {
93
- toast: {
94
- type: 'success',
95
- content: '操作已完成',
96
- i18n: { zh_cn: '操作已完成', en_us: 'Done' },
97
- },
98
- card: {
99
- type: 'raw',
100
- data: {
101
- schema: '2.0',
102
- config: { update_multi: true },
103
- header: {
104
- template: 'green',
105
- title: { content: '已确认', tag: 'plain_text' },
106
- },
107
- body: {
108
- direction: 'vertical',
109
- elements: [
110
- { tag: 'markdown', content: '操作已完成 ✅' },
111
- ],
112
- },
113
- },
114
- },
115
- };
116
- }
117
- },
118
- }),
119
- });
120
- }
121
- }
122
- ```
123
-
124
- ### card.action.trigger 事件数据结构
125
-
126
- 长连接模式下事件为 v2 格式,`EventDispatcher` 解析后将 `header` 和 `event` 字段平铺到 `data` 中:
127
-
128
- | 字段 | 类型 | 说明 |
129
- |------|------|------|
130
- | `action.value` | `Record<string, any>` | 交互元素配置的 value 数据 |
131
- | `action.tag` | `string` | 组件类型:`button` / `select_static` / `date_picker` 等 |
132
- | `action.option` | `string?` | 下拉选框选中项(select_static 触发时有值) |
133
- | `action.timezone` | `string?` | 时区(日期选择器触发时有值) |
134
- | `action.form_value` | `Record<string, string \| string[]>?` | 表单容器提交数据(form 组件触发时有值) |
135
- | `action.name` | `string?` | 用户操作的交互组件名称(开发者在组件上自定义) |
136
- | `operator.open_id` | `string` | 操作者 open_id(直接字段,非嵌套) |
137
- | `operator.user_id` | `string?` | 操作者 user_id |
138
- | `operator.union_id` | `string?` | 操作者 union_id |
139
- | `operator.tenant_key` | `string?` | 操作者所在租户 key |
140
- | `context.open_message_id` | `string` | 卡片所在消息 ID |
141
- | `context.open_chat_id` | `string` | 卡片所在会话 ID |
142
- | `token` | `string` | 回调 token(验签用,长连接模式无需手动验签) |
143
- | `host` | `string` | 宿主环境,如 `im_message` |
144
-
145
- ### card.action.trigger 响应体结构(v2 格式)
146
-
147
- 处理函数 return 的对象会作为响应体直接更新卡片,必须使用 v2 格式:
148
-
149
- ```json
150
- {
151
- "toast": {
152
- "type": "info",
153
- "content": "提示内容",
154
- "i18n": { "zh_cn": "提示内容", "en_us": "Message" }
155
- },
156
- "card": {
157
- "type": "raw",
158
- "data": {
159
- "schema": "2.0",
160
- "config": { "update_multi": true },
161
- "header": {
162
- "title": { "tag": "plain_text", "content": "标题" },
163
- "template": "blue"
164
- },
165
- "body": {
166
- "direction": "vertical",
167
- "elements": [
168
- { "tag": "markdown", "content": "卡片内容" }
169
- ]
170
- }
171
- }
172
- }
173
- }
174
- ```
175
-
176
- | 字段 | 必需 | 说明 |
177
- |------|------|------|
178
- | `toast` | 否 | 在用户侧显示浮层提示;`type` 可选 `info` / `success` / `error` / `warning` |
179
- | `card` | 否 | 更新后的卡片内容;不返回则卡片保持不变 |
180
- | `card.type` | 是 | 固定为 `"raw"` |
181
- | `card.data.schema` | 是 | 固定为 `"2.0"` |
182
- | `card.data.body.elements` | 是 | 卡片元素列表(嵌套在 `body` 中,非根级) |
183
-
184
- > **与旧版区别**:v1 格式直接在根级放 `elements`;v2 格式必须将元素放在 `card.data.body.elements` 中,且外层需要 `card.type: "raw"` 包装。
185
-
186
- ### 卡片更新方式对比
187
-
188
- | 方式 | 说明 |
189
- |------|------|
190
- | 处理函数直接 return 卡片 | 即时更新触发交互的卡片(推荐,3 秒内处理完成) |
191
- | `client.im.message.patch()` | 异步更新,适合耗时操作(需在另一协程/队列中处理) |
192
-
193
- ## 当前不支持
194
-
195
- 以下模式需要飞书通过 HTTP 回调应用服务,当前环境暂不支持:
196
-
197
- - Webhook 事件订阅(HTTP 模式)— 使用上方 WebSocket 长连接代替
198
- - HTTP 模式 CardActionHandler — 使用长连接 `card.action.trigger` 代替(见上方)
@@ -1,128 +0,0 @@
1
- # 妙搭与飞书开放平台 — ID 识别与转换
2
-
3
- > 开放平台文档(Markdown 版):https://open.larkoffice.com/document/uAjLw4CM/ukTMukTMukTM/spark-v1/overview.md
4
- >
5
- > 飞书用户身份体系官方说明:https://open.larkoffice.com/document/platform-overveiw/basic-concepts/user-identity-introduction/introduction
6
- >
7
- > **承接 `user-identity` skill 决策树**:如果只需 妙搭 userId → 飞书 user_id(employee_id),在 nestjs-react-fullstack 项目内优先用 `AuthNPaasService`。本文覆盖 `AuthNPaasService` 不支持的场景:需要 `open_id` / `union_id`、或需要任何方向的反查、或不在该模板内的项目。
8
-
9
- ## ID 识别速查
10
-
11
- 拿到一个 ID 后,先判断它属于哪个体系:
12
-
13
- | ID 来源 | 获取方式 | 格式特征 | 说明 |
14
- |---------|---------|---------|------|
15
- | 妙搭 userId | 后端 `req.userContext.userId`、前端 `useCurrentUserProfile().user_id` | 纯数字字符串 | **两者是同一个 ID**(dataloom 底层同源) |
16
- | 妙搭 tenantId | `req.userContext.tenantId` | 纯数字字符串 | 租户/工作区标识 |
17
- | 妙搭 appId | `req.userContext.appId` | 纯数字字符串 | 当前应用标识 |
18
- | 飞书 open_id | 飞书开放平台 API 返回 | `ou_` 开头 | 应用级,同一用户在不同应用中 open_id 不同 |
19
- | 飞书 union_id | 飞书开放平台 API 返回 | `on_` 开头 | 开发者级,同一开发者下的多个应用中相同 |
20
- | 飞书 user_id(即 `employee_id`) | 飞书通讯录 API 返回 / `AuthNPaasService` 返回 | 无固定前缀 | 企业级,用户在企业内的身份标识,全企业唯一 |
21
-
22
- > 官方定义:`user_id` 也称为 `employee_id`,两者在多数业务场景等价(除飞书招聘)。它**不是**员工工号(`employee_no` 是另一个独立字段)。
23
- >
24
- > 飞书还有第四种 ID `lark_id`(跨企业全局物理身份),仅用于跨企业场景,应用开发流程中基本不接触,本文不涉及。
25
-
26
- ## 决策树:我需要转换吗?
27
-
28
- ```
29
- 拿到的 ID 是什么?
30
- ├─ 妙搭 userId(纯数字)
31
- │ ├─ nestjs-react-fullstack 项目内要拿飞书 user_id → 用 AuthNPaasService(见 user-identity skill),单向
32
- │ ├─ 要调飞书开放平台 API 拿 open_id/union_id → 用下方 spark id_convert API
33
- │ └─ 仅妙搭内部使用 → 直接用,不需要转换
34
- ├─ 飞书 open_id(ou_ 开头)→ 要在妙搭中查用户?
35
- │ ├─ 是 → 转换为妙搭 userId(用下方 spark id_convert API type 20)
36
- │ └─ 否(仅飞书 API 使用)→ 直接用
37
- ├─ 飞书 union_id(on_ 开头)→ 要在妙搭中查用户?
38
- │ ├─ 是 → 转换为妙搭 userId(用下方 spark id_convert API type 21)
39
- │ └─ 否 → 直接用
40
- └─ 飞书 user_id(即 employee_id)
41
- ├─ 妙搭 userId → 飞书 user_id:nestjs-react-fullstack 项目内用 AuthNPaasService(仅此一种方法)
42
- └─ 飞书 user_id → 妙搭 userId:无单步方案,必须两步走(见下方"反向:飞书 user_id → 妙搭 userId")
43
- ```
44
-
45
- ### 反向:飞书 user_id → 妙搭 userId(两步走)
46
-
47
- `spark id_convert` 不接受 `user_id` 作为输入;`AuthNPaasService` 也没有反向方法。要把飞书 user_id 转回妙搭 userId,必须分两步:
48
-
49
- ```typescript
50
- // Step 1: 飞书通讯录 API,用 user_id 查询用户,从响应里读 open_id
51
- // 需要权限 contact:contact.base:readonly
52
- const userRes = await client.contact.user.get({
53
- path: { user_id: '<飞书 user_id / employee_id>' },
54
- params: { user_id_type: 'user_id' }, // ← 告诉接口"我传入的是 user_id 类型"
55
- });
56
- if (userRes.code !== 0) throw new Error(`contact.user.get failed: ${userRes.msg}`);
57
- const openId = userRes.data?.user?.open_id;
58
- if (!openId) throw new Error('open_id missing in contact.user.get response');
59
-
60
- // Step 2: spark id_convert type 20,open_id → 妙搭 userId
61
- // 需要权限"获取 ID 转换信息"
62
- type IdConvertResp = { items: { source_id: string; target_id: string }[] };
63
- const convRes = await client.request<IdConvertResp>({
64
- method: 'POST',
65
- url: '/open-apis/spark/v1/directory/user/id_convert',
66
- data: { id_convert_type: 20, ids: [openId] },
67
- });
68
- if (convRes.code !== 0) throw new Error(`id_convert failed: ${convRes.msg}`);
69
- const miaodaUserId = convRes.data?.items?.[0]?.target_id;
70
- ```
71
-
72
- > 批量场景同理:先调通讯录批量接口(`GET /open-apis/contact/v3/users/batch`,参数 `user_ids[]` + `user_id_type=user_id`,可通过 `client.request({ method: 'GET', url: '...', params: {...} })` 直接调)拿一组 `open_id`,再传给 spark id_convert(最多 100 个)。**响应里用 `source_id` 字段匹配回原 user_id**,不要按下标对位,飞书不保证返回顺序与请求一致。
73
-
74
- ## 转换 API
75
-
76
- ### 所需权限
77
-
78
- | 权限标识 | 说明 |
79
- |----------|------|
80
- | `获取 ID 转换信息` | spark 通讯录 ID 转换权限(需在开发者后台申请) |
81
-
82
- ### 转换类型
83
-
84
- | id_convert_type | 方向 |
85
- |-----------------|------|
86
- | `10` | 妙搭 userId → 飞书 open_id |
87
- | `11` | 妙搭 userId → 飞书 union_id |
88
- | `20` | 飞书 open_id → 妙搭 userId |
89
- | `21` | 飞书 union_id → 妙搭 userId |
90
-
91
- ### 调用示例
92
-
93
- ```typescript
94
- // 妙搭 userId → 飞书 open_id
95
- const res = await client.request({
96
- method: 'POST',
97
- url: '/open-apis/spark/v1/directory/user/id_convert',
98
- data: {
99
- id_convert_type: 10,
100
- ids: ['123456789837364'], // 妙搭 userId 列表,最多 100 个
101
- },
102
- });
103
- // res.data.items — [{ source_id: '123456789837364', target_id: 'ou_1234cdjhjfedgfhgdhy3884' }]
104
-
105
- // 飞书 open_id → 妙搭 userId
106
- const res2 = await client.request({
107
- method: 'POST',
108
- url: '/open-apis/spark/v1/directory/user/id_convert',
109
- data: {
110
- id_convert_type: 20,
111
- ids: ['ou_1234cdjhjfedgfhgdhy3884'],
112
- },
113
- });
114
- ```
115
-
116
- > 频率限制 50 次/秒,批量最多 100 个 ID。优先批量调用,避免逐个转换。
117
-
118
- ## Common Mistakes
119
-
120
- | 错误 | 正确做法 |
121
- |------|----------|
122
- | 把妙搭 userId 当飞书 open_id 传给飞书 API | 两套体系不互通;nestjs-react-fullstack 项目内用 `AuthNPaasService`,其他场景调 spark `id_convert` |
123
- | 混淆 `req.userContext.userId` 和 `useCurrentUserProfile().user_id` | 同一个 ID,dataloom 同源,前后端获取方式不同而已 |
124
- | 把 `employee_id` 当作员工工号 | `employee_id` 等价于 `user_id`,是企业内身份标识;工号是另一个独立字段 `employee_no` |
125
- | 误以为 `AuthNPaasService` 能反查(飞书 user_id → 妙搭 userId) | 只支持 妙搭 userId → 飞书 user_id 单向;反向需要"通讯录 API user_id → open_id"+"spark id_convert type 20 open_id → 妙搭 userId"两步 |
126
- | 想用飞书 user_id (employee_id) 直接调 spark `id_convert` 反查妙搭 | spark `id_convert` 只支持 open_id (type 20) / union_id (type 21) 反查,不支持 user_id;走两步方案 |
127
- | 逐个调用 ID 转换接口 | 一次传入多个 ID(最多 100 个),减少请求次数 |
128
- | 用 `client.spark.*` 语义化调用 | SDK 无内置 spark 模块,需用 `client.request()` 通用方式 |