@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 CHANGED
@@ -1,8 +1,38 @@
1
1
  # @cpm-lhj/dsh-feishu
2
2
 
3
- 飞书长连接插件。白名单里的人发消息后,常驻的 dsh 在固定目录里执行;进度和批准显示在触发消息下的卡片上,最终回答回复在那条消息下面。
3
+ 飞书长连接插件。白名单里的人发消息后,常驻的 dsh 在固定目录里执行。每一轮在当次触发消息下发一张卡片,过程中原地更新;这一轮结束后把结果写进这张卡片,并 @ 触发的人说「完成了,请看上方结果」。
4
4
 
5
- 适配 `@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`。
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
- 装好之后,用带 Web 界面的同一个 profile 打开**插件**,进入 `@cpm-lhj/dsh-feishu`,点 `feishu` 这一行的**配置**。页面上填写工作目录、每行一个 open_id、App ID 和 App Secret,然后保存。App ID 和 App Secret 写入凭证库,配置里只留下 `FEISHU_APP_ID` 和 `FEISHU_APP_SECRET` 这两个名字。留空的凭证不会覆盖已经保存的值。目录必须是已存在的绝对路径;open_id 留空时没有人可以触发。保存后插件按新配置重新连接。
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
- 不改 `sandbox-policy` 的 `workspaceRoot`。
57
+ 常驻 profile 只叠 base 和本插件。不要用 headless 当常驻进程,也不要在同一个 profile 上挂 Web 批准界面。同一套飞书应用和 domain 只跑一个进程;两个进程会各自处理消息。要改配置,用装了本插件的那个带 Web 的 profile 打开插件页。
37
58
 
38
- 常驻 profile 只叠 base 和本插件。不要用 headless 当常驻进程,也不要在同一个 profile 上挂 Web 批准界面。同一套飞书应用和 domain 只跑一个进程;两个进程会各自处理消息。
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
@@ -11,4 +11,5 @@ export const COPY = {
11
11
  denied: '你不在可触发名单里,这条消息不会执行。',
12
12
  busy: '这条会话还在执行,新消息不会执行。',
13
13
  empty: '请写下要处理的问题。',
14
+ done: '完成了,请看上方结果',
14
15
  }
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
 
@@ -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 ) => bind(agentCtx, line.sessionId)
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 && text !== ''
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
@@ -64,6 +64,8 @@
64
64
 
65
65
 
66
66
 
67
+
68
+
67
69
 
68
70
 
69
71
  /** Feishu JSON object sent as an interactive card. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cpm-lhj/dsh-feishu",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Feishu long-connection plugin that runs a resident dsh session from allowlisted messages.",