@cpm-lhj/dsh-feishu 0.1.0 → 0.1.1
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.md +40 -14
- package/lib/card.js +4 -1
- package/lib/copy.js +1 -0
- package/lib/plugin.js +19 -1
- package/lib/runtime-types.js +26 -3
- package/lib/runtime.js +45 -6
- package/lib/store.js +2 -0
- package/lib/types.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,38 @@
|
|
|
1
1
|
# @cpm-lhj/dsh-feishu
|
|
2
2
|
|
|
3
|
-
飞书长连接插件。白名单里的人发消息后,常驻的 dsh
|
|
3
|
+
飞书长连接插件。白名单里的人发消息后,常驻的 dsh 在固定目录里执行。每一轮在当次触发消息下发一张卡片,过程中原地更新;这一轮结束后把结果写进这张卡片,并 @ 触发的人说「完成了,请看上方结果」。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## 配置
|
|
6
|
+
|
|
7
|
+
App ID、App Secret、工作目录和可以发起会话的人,都在 Web 的插件页里填。入口在插件包的组件行里。
|
|
8
|
+
|
|
9
|
+
用装了并且启用了本插件的那个 profile 打开 Web:
|
|
10
|
+
|
|
11
|
+
1. 左侧边栏点**插件**。
|
|
12
|
+
2. 在已安装列表里打开 **@cpm-lhj/dsh-feishu**。
|
|
13
|
+
3. 在**包含的组件**里点 **feishu**。点这一行的标题,标题右侧有箭头。旁边的开关只负责启用。
|
|
14
|
+
|
|
15
|
+
| 字段 | 填写 |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| 工作目录 | 已经存在的绝对路径。所有会话都在这个目录里执行。 |
|
|
18
|
+
| 可以发起会话的人 | 每行一个飞书 open_id。留空则没有人可以发起。 |
|
|
19
|
+
| App ID | 飞书应用的 App ID。 |
|
|
20
|
+
| App Secret | 飞书应用的 App Secret。 |
|
|
21
|
+
|
|
22
|
+
点**保存**。App ID 和 App Secret 写入凭证库,配置里只留下 `FEISHU_APP_ID` 和 `FEISHU_APP_SECRET` 这两个名字。凭证框留空表示保留已经保存的值。保存后插件按新配置重新连接。
|
|
23
|
+
|
|
24
|
+
页面写着「插件还没在运行,这个页面暂时不能保存」时,先把 `feishu` 这一行的开关打开,等它变成运行中再保存。
|
|
25
|
+
|
|
26
|
+
只有这个 profile 的 Web 里有这张表。换一个没装本插件的 Web,或者没打开 Web 的常驻进程,都到不了这里。
|
|
27
|
+
|
|
28
|
+
关掉 `tool-working-directory` 要写在该 profile 自己的 `cordis.patch.yml`。后一层按整行替换,不是深合并:
|
|
29
|
+
|
|
30
|
+
```yaml
|
|
31
|
+
- id: tool-working-directory
|
|
32
|
+
disabled: true
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
不改 `sandbox-policy` 的 `workspaceRoot`。
|
|
6
36
|
|
|
7
37
|
## 安装
|
|
8
38
|
|
|
@@ -22,20 +52,11 @@ dsh plugin --profile <name> add <本包>
|
|
|
22
52
|
name: '@cpm-lhj/dsh-feishu'
|
|
23
53
|
```
|
|
24
54
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
这个配置页只出现在加载了 Web 客户端的 profile 里。没打开 Web 的常驻进程上看不到它。
|
|
28
|
-
|
|
29
|
-
关掉 `tool-working-directory` 仍写在该 profile 自己的 `cordis.patch.yml`。后一层按整行替换,不是深合并:
|
|
30
|
-
|
|
31
|
-
```yaml
|
|
32
|
-
- id: tool-working-directory
|
|
33
|
-
disabled: true
|
|
34
|
-
```
|
|
55
|
+
装好之后按上一节打开配置页。
|
|
35
56
|
|
|
36
|
-
|
|
57
|
+
常驻 profile 只叠 base 和本插件。不要用 headless 当常驻进程,也不要在同一个 profile 上挂 Web 批准界面。同一套飞书应用和 domain 只跑一个进程;两个进程会各自处理消息。要改配置,用装了本插件的那个带 Web 的 profile 打开插件页。
|
|
37
58
|
|
|
38
|
-
|
|
59
|
+
飞书会话使用该 profile 的默认 agent preset。Web 的默认是 `standard`,因此有 shell 和读文件。已经记下 preset 的会话在恢复时沿用那一个。没有 preset 注册表的 profile 继续用进程上已有的工具。
|
|
39
60
|
|
|
40
61
|
`@deepseek-ai/cordis`、`@deepseek-ai/schemastery` 和 `@deepseek-ai/dsh-llm` 是 peer,由正在运行的 dsh 提供。不要再装一份。本包直接依赖 `@larksuiteoapi/node-sdk` 和 `zod`。
|
|
41
62
|
|
|
@@ -56,6 +77,10 @@ dsh plugin --profile <name> add <本包>
|
|
|
56
77
|
|
|
57
78
|
本包声明 `engines.dsh` 上限 `<0.3.0`。把 dsh 升到 0.3.x 之后,已安装的 profile 在启动时会静默跳过这个 bundle:结果记在 `skippedBundles`,终端不打印。机器人不再响应,仍处于执行中的卡片也不会被恢复。升级前先确认本包的兼容范围。安装和更新路径会直接拒绝范围外的 dsh;已经装好的机器才是静默跳过。
|
|
58
79
|
|
|
80
|
+
## 协议字段
|
|
81
|
+
|
|
82
|
+
适配 `@larksuiteoapi/node-sdk` 1.76。对照当前安装包,`im.message.receive_v1`、`card.action.trigger`、`client.im.message.reply`(含 `reply_in_thread`)、`client.im.message.patch` 和 `GET /open-apis/bot/v3/info` 的字段一致。话题 id 没有用一条真实群消息核对。键规则按设计实现:私聊是 `p2p:<chat_id>`;群里有 `thread_id` 时是 `group:<chat_id>:thread:<thread_id>`;否则是 `group:<chat_id>:root:<root_id 或 message_id>`。线上字段若不同,只改 `src/transport.ts` 和 `src/keys.ts`。
|
|
83
|
+
|
|
59
84
|
## 固定文案
|
|
60
85
|
|
|
61
86
|
这些句子不做成配置:
|
|
@@ -65,6 +90,7 @@ dsh plugin --profile <name> add <本包>
|
|
|
65
90
|
| 不在名单 | 你不在可触发名单里,这条消息不会执行。 |
|
|
66
91
|
| 这条线还在执行 | 这条会话还在执行,新消息不会执行。 |
|
|
67
92
|
| 没有正文 | 请写下要处理的问题。 |
|
|
93
|
+
| 这一轮结束 | @触发的人,然后「完成了,请看上方结果」。 |
|
|
68
94
|
| 卡片 | 执行中、已停止、同意、拒绝、停止、成功、失败 |
|
|
69
95
|
|
|
70
96
|
## 开发
|
package/lib/card.js
CHANGED
|
@@ -3,13 +3,16 @@ import { COPY } from './copy.js'
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Render one progress card. Button values carry the action and id only.
|
|
6
|
-
* Settled cards keep the rows and omit the running and stopped status.
|
|
6
|
+
* Settled cards keep the rows and the completion text, and omit the running and stopped status.
|
|
7
7
|
* @param doc - Card content derived from the line and the open approval, if any.
|
|
8
8
|
* @returns Feishu interactive card JSON.
|
|
9
9
|
*/
|
|
10
10
|
export function renderCard(doc ) {
|
|
11
11
|
const elements = []
|
|
12
12
|
for (const row of doc.rows) elements.push(textElement(rowText(row)))
|
|
13
|
+
if (doc.status === 'settled' && doc.answer !== undefined && doc.answer !== '') {
|
|
14
|
+
elements.push(textElement(doc.answer))
|
|
15
|
+
}
|
|
13
16
|
if (doc.approval !== undefined) {
|
|
14
17
|
if (doc.approval.reason !== '') elements.push(textElement(doc.approval.reason))
|
|
15
18
|
elements.push(actions([
|
package/lib/copy.js
CHANGED
package/lib/plugin.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
3
3
|
import { feishuDomainSpec } from './domain.js'
|
|
4
4
|
import { createRuntime } from './runtime.js'
|
|
5
|
-
|
|
5
|
+
|
|
6
6
|
import { LineStore, } from './store.js'
|
|
7
7
|
import { connectionReady, } from './startup.js'
|
|
8
8
|
import { createLarkTransport } from './transport.js'
|
|
@@ -143,6 +143,7 @@ async function startConnected(ctx , ready )
|
|
|
143
143
|
transport,
|
|
144
144
|
agents: ctx.agents,
|
|
145
145
|
modelSelection: () => ctx.agentDefaultModel.currentSelection(),
|
|
146
|
+
presets: () => presetSource(ctx.get('agentPresets', false)),
|
|
146
147
|
createUserMessage,
|
|
147
148
|
log: ctx.logger('feishu'),
|
|
148
149
|
})
|
|
@@ -162,4 +163,21 @@ async function startConnected(ctx , ready )
|
|
|
162
163
|
}
|
|
163
164
|
}
|
|
164
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Keep a preset registry when this profile has one. A profile without the
|
|
168
|
+
* service leaves tools on the host, and this returns undefined.
|
|
169
|
+
* @param value - Whatever `ctx.get` returned for `agentPresets`.
|
|
170
|
+
* @returns The registry, or undefined when the value cannot mount a preset.
|
|
171
|
+
*/
|
|
172
|
+
function presetSource(value ) {
|
|
173
|
+
return isPresetSource(value) ? value : undefined
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isPresetSource(value ) {
|
|
177
|
+
return typeof value === 'object' && value !== null
|
|
178
|
+
&& 'resolve' in value && typeof value.resolve === 'function'
|
|
179
|
+
&& 'mount' in value && typeof value.mount === 'function'
|
|
180
|
+
&& 'presetForSession' in value && typeof value.presetForSession === 'function'
|
|
181
|
+
}
|
|
182
|
+
|
|
165
183
|
|
package/lib/runtime-types.js
CHANGED
|
@@ -34,6 +34,29 @@
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
/** Session identity the preset registry reads while an agent is being composed. */
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
/** Agent handed to setup. Only the session identity is read. */
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Optional preset registry. Absent on a profile that keeps tools on the host.
|
|
49
|
+
* @param id - Explicit preset, or the registry default when omitted.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
/** Compose one agent: mount its preset, then bind Feishu listeners. */
|
|
58
|
+
|
|
59
|
+
|
|
37
60
|
/** Scoped context of one agent. Listeners see only that agent. */
|
|
38
61
|
|
|
39
62
|
|
|
@@ -55,16 +78,16 @@
|
|
|
55
78
|
/** Options for a new session whose cwd is the configured directory. */
|
|
56
79
|
|
|
57
80
|
|
|
58
|
-
|
|
81
|
+
|
|
59
82
|
|
|
60
|
-
|
|
83
|
+
|
|
61
84
|
|
|
62
85
|
|
|
63
86
|
/** Options for resuming a persisted session. */
|
|
64
87
|
|
|
65
88
|
|
|
66
89
|
|
|
67
|
-
|
|
90
|
+
|
|
68
91
|
|
|
69
92
|
|
|
70
93
|
/** Model route copied from the host default selection. */
|
package/lib/runtime.js
CHANGED
|
@@ -6,12 +6,14 @@ import { objectLabelFromArguments } from './object-label.js'
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
|
|
16
|
+
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
|
|
@@ -24,6 +26,8 @@ import { objectLabelFromArguments } from './object-label.js'
|
|
|
24
26
|
|
|
25
27
|
|
|
26
28
|
|
|
29
|
+
|
|
30
|
+
|
|
27
31
|
|
|
28
32
|
|
|
29
33
|
|
|
@@ -52,6 +56,8 @@ export function createRuntime(deps )
|
|
|
52
56
|
const assistantText = new Map ()
|
|
53
57
|
const approvals = new ApprovalBook()
|
|
54
58
|
const patches = new Map ()
|
|
59
|
+
const senders = new Map ()
|
|
60
|
+
const answers = new Map ()
|
|
55
61
|
let closed = false
|
|
56
62
|
let timer
|
|
57
63
|
|
|
@@ -81,6 +87,7 @@ export function createRuntime(deps )
|
|
|
81
87
|
return
|
|
82
88
|
}
|
|
83
89
|
if (admitted.decision.kind !== 'start' || admitted.line === undefined) return
|
|
90
|
+
senders.set(admitted.line.sessionId, event.senderOpenId)
|
|
84
91
|
await startTurn(admitted.line, event.text)
|
|
85
92
|
} catch (error) {
|
|
86
93
|
deps.log.error(`feishu message ${event.eventId} failed: ${errorText(error)}`)
|
|
@@ -94,7 +101,7 @@ export function createRuntime(deps )
|
|
|
94
101
|
if (!claimed) return
|
|
95
102
|
if (!deps.allowOpenIds.includes(event.operatorOpenId)) return
|
|
96
103
|
const line = await deps.store.findByAlias(event.messageId)
|
|
97
|
-
if (line === undefined) return
|
|
104
|
+
if (line === undefined || line.cardMessageId !== event.messageId) return
|
|
98
105
|
if (event.kind === 'stop') {
|
|
99
106
|
await stop(line)
|
|
100
107
|
return
|
|
@@ -223,7 +230,7 @@ export function createRuntime(deps )
|
|
|
223
230
|
const existing = agents.get(line.sessionId)
|
|
224
231
|
if (existing !== undefined) return existing
|
|
225
232
|
const route = routeOf(deps.modelSelection())
|
|
226
|
-
const setup = (agentCtx ) =>
|
|
233
|
+
const setup = (agentCtx , agent ) => attach(agentCtx, agent, line.sessionId)
|
|
227
234
|
try {
|
|
228
235
|
const handle = await deps.agents.resume({
|
|
229
236
|
resumeSessionId: line.sessionId,
|
|
@@ -234,17 +241,39 @@ export function createRuntime(deps )
|
|
|
234
241
|
return handle.agent
|
|
235
242
|
} catch (error) {
|
|
236
243
|
if (!isMissingSession(error, line.sessionId)) throw error
|
|
244
|
+
const agentPreset = await defaultPreset()
|
|
237
245
|
const handle = await deps.agents.create({
|
|
238
246
|
sessionId: line.sessionId,
|
|
239
|
-
meta: { cwd: deps.directory },
|
|
247
|
+
meta: { cwd: deps.directory, ...agentPreset === undefined ? {} : { agentPreset } },
|
|
240
248
|
agentOptions: route,
|
|
241
|
-
setup,
|
|
249
|
+
setup: (agentCtx, agent) => attach(agentCtx, agent, line.sessionId, agentPreset),
|
|
242
250
|
})
|
|
243
251
|
agents.set(line.sessionId, handle.agent)
|
|
244
252
|
return handle.agent
|
|
245
253
|
}
|
|
246
254
|
}
|
|
247
255
|
|
|
256
|
+
async function defaultPreset() {
|
|
257
|
+
const presets = deps.presets?.()
|
|
258
|
+
if (presets === undefined) return undefined
|
|
259
|
+
return (await presets.resolve()).id
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Mount the preset for this agent, then bind Feishu listeners.
|
|
264
|
+
* A newly created session uses the id written into its header. A resumed
|
|
265
|
+
* session uses the id its log recorded, or the current default when it has none.
|
|
266
|
+
*/
|
|
267
|
+
async function attach(agentCtx , agent , sessionId , fresh ) {
|
|
268
|
+
const presets = deps.presets?.()
|
|
269
|
+
if (presets !== undefined) {
|
|
270
|
+
const recorded = presets.presetForSession(agent.session)
|
|
271
|
+
const id = fresh ?? (recorded === null || recorded === '' ? undefined : recorded) ?? (await presets.resolve()).id
|
|
272
|
+
await presets.mount(agentCtx, id)
|
|
273
|
+
}
|
|
274
|
+
bind(agentCtx, sessionId)
|
|
275
|
+
}
|
|
276
|
+
|
|
248
277
|
function bind(agentCtx , sessionId ) {
|
|
249
278
|
agentCtx.on('session/event', (session, event) => {
|
|
250
279
|
if (session.id !== sessionId) return
|
|
@@ -349,17 +378,20 @@ export function createRuntime(deps )
|
|
|
349
378
|
const stopped = line.cancelRequested || ended.kind === 'aborted'
|
|
350
379
|
const trigger = line.triggerMessageId
|
|
351
380
|
const text = assistantText.get(sessionId) ?? ''
|
|
381
|
+
const sender = senders.get(sessionId) ?? ''
|
|
352
382
|
assistantText.delete(sessionId)
|
|
353
383
|
calls.delete(sessionId)
|
|
354
384
|
approvals.cancelSession(sessionId)
|
|
385
|
+
if (stopped || text === '') answers.delete(line.canonicalKey)
|
|
386
|
+
else answers.set(line.canonicalKey, text)
|
|
355
387
|
line.cardStatus = stopped ? 'stopped' : 'settled'
|
|
356
388
|
line.phase = 'idle'
|
|
357
389
|
line.turn = null
|
|
358
390
|
line.triggerMessageId = null
|
|
359
391
|
line.cancelRequested = false
|
|
360
392
|
await deps.store.save(line)
|
|
361
|
-
const reply = !stopped && trigger !== null
|
|
362
|
-
? { messageId: trigger, text, inThread: line.canonicalKey.startsWith('group:') }
|
|
393
|
+
const reply = !stopped && trigger !== null
|
|
394
|
+
? { messageId: trigger, text: doneNotice(sender), inThread: line.canonicalKey.startsWith('group:') }
|
|
363
395
|
: undefined
|
|
364
396
|
return { canonicalKey: line.canonicalKey, show: true, reply }
|
|
365
397
|
}
|
|
@@ -382,11 +414,13 @@ export function createRuntime(deps )
|
|
|
382
414
|
async function paintNow(seed , create ) {
|
|
383
415
|
const line = await deps.store.findByAlias(seed.canonicalKey) ?? seed
|
|
384
416
|
const view = line.phase === 'running' ? approvals.view(line.sessionId) : undefined
|
|
417
|
+
const answer = line.cardStatus === 'settled' ? answers.get(line.canonicalKey) : undefined
|
|
385
418
|
const card = renderCard({
|
|
386
419
|
status: line.cardStatus,
|
|
387
420
|
rows: line.rows,
|
|
388
421
|
showStop: line.phase === 'running' && line.cardStatus === 'running',
|
|
389
422
|
...view === undefined ? {} : { approval: view },
|
|
423
|
+
...answer === undefined || answer === '' ? {} : { answer },
|
|
390
424
|
})
|
|
391
425
|
if (line.cardMessageId === null) {
|
|
392
426
|
if (!create || line.triggerMessageId === null) return
|
|
@@ -429,6 +463,11 @@ export function createRuntime(deps )
|
|
|
429
463
|
}
|
|
430
464
|
}
|
|
431
465
|
|
|
466
|
+
function doneNotice(openId ) {
|
|
467
|
+
if (!/^ou_[0-9A-Za-z]+$/.test(openId)) return COPY.done
|
|
468
|
+
return `<at user_id="${openId}">你</at> ${COPY.done}`
|
|
469
|
+
}
|
|
470
|
+
|
|
432
471
|
function routeOf(selection ) {
|
|
433
472
|
return {
|
|
434
473
|
provider: selection.provider,
|
package/lib/store.js
CHANGED
|
@@ -82,6 +82,7 @@ export class LineStore {
|
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
84
|
* Decide one message and, when it starts a turn, mark the line running before returning.
|
|
85
|
+
* A started turn clears the card message id so the driver posts a new card under this trigger.
|
|
85
86
|
* @param message - Normalized inbound message.
|
|
86
87
|
* @param allowOpenIds - Trigger allow list.
|
|
87
88
|
* @returns The decision and the line when one exists or was created.
|
|
@@ -113,6 +114,7 @@ export class LineStore {
|
|
|
113
114
|
...addAliases(base, ids),
|
|
114
115
|
phase: 'running',
|
|
115
116
|
cardStatus: 'running',
|
|
117
|
+
cardMessageId: null,
|
|
116
118
|
turn: null,
|
|
117
119
|
triggerMessageId: message.messageId,
|
|
118
120
|
cancelRequested: false,
|
package/lib/types.js
CHANGED