@modesto_ma/dsh-channels 0.7.0 → 0.8.0-beta.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 +31 -47
- package/lib/client.js +44 -122
- package/lib/client.js.map +1 -1
- package/lib/index.js +436 -558
- package/lib/index.js.map +1 -1
- package/package.json +4 -1
- package/src/access/controller.ts +3 -3
- package/src/cards/index.ts +1 -1
- package/src/cards/topic.ts +104 -0
- package/src/client/origin.tsx +1 -1
- package/src/client/panel/advanced-tab.tsx +5 -29
- package/src/client/panel/index.tsx +2 -39
- package/src/client/panel/shared.tsx +0 -12
- package/src/config.ts +26 -16
- package/src/domain.ts +89 -32
- package/src/host/core.ts +32 -1
- package/src/host/services.ts +5 -0
- package/src/index.ts +11 -4
- package/src/routing/inbound.ts +1 -1
- package/src/service.ts +20 -25
- package/src/settings/contract.ts +6 -21
- package/src/settings/overlay.ts +3 -1
- package/src/settings/routes.ts +2 -3
- package/src/settings/surface.ts +1 -14
- package/src/streamer.ts +91 -5
- package/src/topics/contract.ts +149 -0
- package/src/topics/launcher.ts +428 -0
- package/src/workspaces.ts +25 -18
- package/src/analyze/contract.ts +0 -126
- package/src/analyze/launcher.ts +0 -376
- package/src/analyze/server.ts +0 -207
- package/src/cards/analyze.ts +0 -116
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types of the `feishuTopics` service: another DSH plugin starts a
|
|
3
|
+
* group-shared Feishu topic Session from an external trigger (for example
|
|
4
|
+
* 观象's log-platform analyze API).
|
|
5
|
+
*
|
|
6
|
+
* One launch posts a root card into an authorized group, opens a topic below
|
|
7
|
+
* it, creates the Session in a workspace below the channel's `baseDir`, and
|
|
8
|
+
* streams the first turn (the prompt, verbatim) in that topic. The root card's
|
|
9
|
+
* status tag follows the first turn (进行中 → 已完成 / 已停止 / 失败) and turns
|
|
10
|
+
* 已中断 when DSH restarts before it settles. Every member admitted in the
|
|
11
|
+
* group may continue the topic.
|
|
12
|
+
*
|
|
13
|
+
* This module has no runtime exports, and the package subpath
|
|
14
|
+
* `@modesto_ma/dsh-channels/topics` exposes only its types. A consumer imports
|
|
15
|
+
* them with `import type`, looks the service up per call with
|
|
16
|
+
* `ctx.get('feishuTopics')` (absent while dsh-channels is not loaded or
|
|
17
|
+
* reloading), and checks {@link FeishuTopicService.version} before calling.
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-channels/topics/contract
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Name the service is provided under (`ctx.get('feishuTopics')`). */
|
|
23
|
+
export type FeishuTopicsServiceName = 'feishuTopics'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Version of this contract. It changes only when a change would make an older
|
|
27
|
+
* consumer misbehave; a consumer refuses a provider whose version it does not
|
|
28
|
+
* know.
|
|
29
|
+
*/
|
|
30
|
+
export type FeishuTopicsVersion = 1
|
|
31
|
+
|
|
32
|
+
/** One run of the root card's lead sentence. */
|
|
33
|
+
export interface TopicCardText {
|
|
34
|
+
/** Plain text; Markdown characters are shown literally. */
|
|
35
|
+
readonly text: string
|
|
36
|
+
/** Shown in bold. */
|
|
37
|
+
readonly bold?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One linked row of the root card's table. */
|
|
41
|
+
export interface TopicCardLink {
|
|
42
|
+
/** Row label. */
|
|
43
|
+
readonly name: string
|
|
44
|
+
/** `http(s)` address opened by the row's link. */
|
|
45
|
+
readonly url: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What the root card shows besides its status. */
|
|
49
|
+
export interface TopicRootCard {
|
|
50
|
+
/** Header title. */
|
|
51
|
+
readonly title: string
|
|
52
|
+
/**
|
|
53
|
+
* Word naming the work in the status texts, e.g. `分析` gives the tag
|
|
54
|
+
* `分析中` and the footer `分析已完成,结果见话题…`.
|
|
55
|
+
*/
|
|
56
|
+
readonly taskName: string
|
|
57
|
+
/** Lead sentence, shown after an @-mention of the requester. */
|
|
58
|
+
readonly lead: readonly TopicCardText[]
|
|
59
|
+
/** Optional table of links (at most 50 rows). */
|
|
60
|
+
readonly links?: {
|
|
61
|
+
/** Header of the label column. */
|
|
62
|
+
readonly nameHeader: string
|
|
63
|
+
/** Header of the link column. */
|
|
64
|
+
readonly linkHeader: string
|
|
65
|
+
readonly rows: readonly TopicCardLink[]
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One topic to start. */
|
|
70
|
+
export interface TopicLaunchSpec {
|
|
71
|
+
/**
|
|
72
|
+
* Idempotency key (1–200 visible ASCII characters). A launch whose key was
|
|
73
|
+
* already accepted answers that launch's result instead of posting again.
|
|
74
|
+
* Keys are shared by every caller; a caller that may collide with another
|
|
75
|
+
* prefixes its keys.
|
|
76
|
+
*/
|
|
77
|
+
readonly key: string
|
|
78
|
+
/**
|
|
79
|
+
* Caller-defined fingerprint of the request behind {@link key}, compared on
|
|
80
|
+
* replay: the same key with another fingerprint is refused (`key-conflict`).
|
|
81
|
+
*/
|
|
82
|
+
readonly fingerprint: string
|
|
83
|
+
/** Target group chat (`oc_…`). It must be an authorized group, or the channel must run in `open` access mode. */
|
|
84
|
+
readonly chatId: string
|
|
85
|
+
/** Feishu `user_id` of the person the launch is for: @-mentioned on the root card and recorded as the route's requester. */
|
|
86
|
+
readonly requesterUserId: string
|
|
87
|
+
readonly card: TopicRootCard
|
|
88
|
+
/**
|
|
89
|
+
* Workspace of the Session: directory segments below the channel's
|
|
90
|
+
* `baseDir` (1–8 segments of `A-Z a-z 0-9 . _ -`, the first not one of the
|
|
91
|
+
* channel's own `p2p`, `groups`, `topics`, `_staging`), and its title in
|
|
92
|
+
* the DSH workspace list. Launches with the same segments share the
|
|
93
|
+
* workspace, each in its own topic directory.
|
|
94
|
+
*/
|
|
95
|
+
readonly workspace: {
|
|
96
|
+
readonly segments: readonly string[]
|
|
97
|
+
readonly title: string
|
|
98
|
+
}
|
|
99
|
+
/** First user message of the Session, sent verbatim (at most 100 000 characters). */
|
|
100
|
+
readonly prompt: string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Why a launch was refused. Nothing was posted for `invalid`,
|
|
105
|
+
* `not-connected`, `forbidden-chat`, and `key-conflict`; `send-failed` means
|
|
106
|
+
* the root card could not be posted.
|
|
107
|
+
*/
|
|
108
|
+
export type TopicLaunchFailureCode =
|
|
109
|
+
/** The spec breaks a rule documented on {@link TopicLaunchSpec}. */
|
|
110
|
+
| 'invalid'
|
|
111
|
+
/** The Feishu connection is not established, or the channel is shutting down. */
|
|
112
|
+
| 'not-connected'
|
|
113
|
+
/** The target group is not authorized. */
|
|
114
|
+
| 'forbidden-chat'
|
|
115
|
+
/** The key was accepted before with another fingerprint. */
|
|
116
|
+
| 'key-conflict'
|
|
117
|
+
/** Feishu refused the root card (usually: the bot is not in the group). */
|
|
118
|
+
| 'send-failed'
|
|
119
|
+
|
|
120
|
+
/** Outcome of {@link FeishuTopicService.launch}. */
|
|
121
|
+
export type TopicLaunchResult =
|
|
122
|
+
| {
|
|
123
|
+
readonly ok: true
|
|
124
|
+
/** Whether this answers an earlier launch with the same key. */
|
|
125
|
+
readonly replayed: boolean
|
|
126
|
+
readonly chatId: string
|
|
127
|
+
/** Root card message id; the topic hangs below it. */
|
|
128
|
+
readonly messageId: string
|
|
129
|
+
}
|
|
130
|
+
| {
|
|
131
|
+
readonly ok: false
|
|
132
|
+
readonly code: TopicLaunchFailureCode
|
|
133
|
+
/** Readable reason (Chinese). */
|
|
134
|
+
readonly message: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The `feishuTopics` service. */
|
|
138
|
+
export interface FeishuTopicService {
|
|
139
|
+
readonly version: FeishuTopicsVersion
|
|
140
|
+
/**
|
|
141
|
+
* Post the root card and start the topic. The promise settles once the
|
|
142
|
+
* root card is posted and recorded; the topic, Session, and first turn start
|
|
143
|
+
* in the background, and a failure there ends on the root card (失败, with
|
|
144
|
+
* the reason) instead of rejecting.
|
|
145
|
+
* @param spec - the topic to start.
|
|
146
|
+
* @returns the root card, or why nothing was started.
|
|
147
|
+
*/
|
|
148
|
+
launch(spec: TopicLaunchSpec): Promise<TopicLaunchResult>
|
|
149
|
+
}
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `feishuTopics` service: other plugins start group-shared topics (see
|
|
3
|
+
* `./contract.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Synchronously (before {@link TopicLauncher.launch} settles): spec
|
|
6
|
+
* validation, service state, target-group permission, idempotency replay, and
|
|
7
|
+
* the root card (进行中). In the background: the topic (opened by the first
|
|
8
|
+
* streaming card, replied in thread below the root card), the launch workspace
|
|
9
|
+
* and its per-topic directory, the Session, the group-shared route, and the
|
|
10
|
+
* first turn: the prompt verbatim, streamed into that card like any topic
|
|
11
|
+
* turn. When the first turn settles, the root card's status follows it
|
|
12
|
+
* (已完成 / 已停止 / 失败), and the launch record keeps the same status. At
|
|
13
|
+
* channel start, records still `running` belong to topics a previous instance
|
|
14
|
+
* never finished; {@link TopicLauncher.markInterrupted} marks their cards
|
|
15
|
+
* 已中断.
|
|
16
|
+
*
|
|
17
|
+
* The topic is reserved in the inbound router from its creation until the
|
|
18
|
+
* route and the first prompt are published, so replies posted meanwhile queue
|
|
19
|
+
* behind the first turn. Follow-ups then use the ordinary topic routing: the
|
|
20
|
+
* route is `shared`, so every member admitted in the group may continue it. A
|
|
21
|
+
* failure before the first turn (including a disconnect meanwhile) marks the
|
|
22
|
+
* root card failed with the reason, closes the streaming card, and leaves no
|
|
23
|
+
* route.
|
|
24
|
+
*
|
|
25
|
+
* @module dsh-channels/topics/launcher
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { randomUUID } from 'node:crypto'
|
|
29
|
+
import { z } from 'zod'
|
|
30
|
+
import type { AgentHandle, HostContext, SessionId } from '../dsh-types.ts'
|
|
31
|
+
import type { FeishuConfig } from '../config.ts'
|
|
32
|
+
import { buildCard, buildTopicRootCard, initialStreamingCard } from '../cards.ts'
|
|
33
|
+
import type { TopicStatus } from '../cards.ts'
|
|
34
|
+
import type { RouteOperationQueue } from '../commands.ts'
|
|
35
|
+
import { FEISHU_USER_ID_PATTERN, rootCardOf } from '../domain.ts'
|
|
36
|
+
import type { FeishuDomainHandle, RouteRecord, TopicLaunchRecord } from '../domain.ts'
|
|
37
|
+
import type { FeishuChannel } from '../feishu-channel.ts'
|
|
38
|
+
import { feishuUserMessage } from '../routing/inbound.ts'
|
|
39
|
+
import { enterTopic, placementServices, planTopic } from '../session/isolation.ts'
|
|
40
|
+
import type { TopicPlacement } from '../session/isolation.ts'
|
|
41
|
+
import type { SessionManager } from '../session/manager.ts'
|
|
42
|
+
import type { InflightRepair } from '../session/repair.ts'
|
|
43
|
+
import type { RouteStore } from '../session/routes.ts'
|
|
44
|
+
import type { TurnStreamer } from '../streamer.ts'
|
|
45
|
+
import { ensureLaunchWorkspace, RESERVED_WORKSPACE_ROOTS, topicTokenFor } from '../workspaces.ts'
|
|
46
|
+
import type {
|
|
47
|
+
FeishuTopicService, FeishuTopicsServiceName, FeishuTopicsVersion, TopicLaunchFailureCode,
|
|
48
|
+
TopicLaunchResult, TopicLaunchSpec, TopicRootCard,
|
|
49
|
+
} from './contract.ts'
|
|
50
|
+
|
|
51
|
+
/** Name the launcher is provided under. */
|
|
52
|
+
export const FEISHU_TOPICS_SERVICE: FeishuTopicsServiceName = 'feishuTopics'
|
|
53
|
+
|
|
54
|
+
/** Contract version the launcher implements. */
|
|
55
|
+
export const FEISHU_TOPICS_VERSION: FeishuTopicsVersion = 1
|
|
56
|
+
|
|
57
|
+
const text = (max: number) => z.string().min(1).max(max).refine(value => value.trim() !== '', 'must not be blank')
|
|
58
|
+
|
|
59
|
+
/** Workspace path segment: path-safe, never `.` or `..`. */
|
|
60
|
+
const WORKSPACE_SEGMENT = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/u
|
|
61
|
+
|
|
62
|
+
/** Feishu group chat id (`oc_…`). */
|
|
63
|
+
const CHAT_ID = /^oc_[A-Za-z0-9_-]{1,128}$/u
|
|
64
|
+
|
|
65
|
+
/** Launch spec validation: the rules {@link TopicLaunchSpec} documents. */
|
|
66
|
+
const topicLaunchSpecSchema = z.object({
|
|
67
|
+
key: z.string().regex(/^[\x21-\x7E]{1,200}$/u, 'must be 1-200 visible ASCII characters'),
|
|
68
|
+
fingerprint: z.string().min(1).max(512),
|
|
69
|
+
chatId: z.string().regex(CHAT_ID, 'must be a Feishu group chat_id (oc_...)'),
|
|
70
|
+
requesterUserId: z.string().regex(FEISHU_USER_ID_PATTERN, 'must be a Feishu user_id').max(128),
|
|
71
|
+
card: z.object({
|
|
72
|
+
title: text(100),
|
|
73
|
+
taskName: text(20),
|
|
74
|
+
lead: z.array(z.object({ text: z.string().max(1000), bold: z.boolean().optional() }).strict()).min(1).max(20),
|
|
75
|
+
links: z.object({
|
|
76
|
+
nameHeader: text(20),
|
|
77
|
+
linkHeader: text(20),
|
|
78
|
+
rows: z.array(z.object({
|
|
79
|
+
name: text(200),
|
|
80
|
+
url: z.url({ protocol: /^https?$/u, error: 'must be an http(s) URL' }).max(2048),
|
|
81
|
+
}).strict()).max(50),
|
|
82
|
+
}).strict().optional(),
|
|
83
|
+
}).strict(),
|
|
84
|
+
workspace: z.object({
|
|
85
|
+
segments: z.array(z.string().regex(WORKSPACE_SEGMENT, 'must be 1-128 of A-Z a-z 0-9 . _ -')).min(1).max(8)
|
|
86
|
+
.refine(segments => !RESERVED_WORKSPACE_ROOTS.includes(segments[0] ?? ''), `must not start with ${RESERVED_WORKSPACE_ROOTS.join(', ')}`),
|
|
87
|
+
title: text(200),
|
|
88
|
+
}).strict(),
|
|
89
|
+
prompt: text(100_000),
|
|
90
|
+
}).strict()
|
|
91
|
+
|
|
92
|
+
/** Collaborators of the launcher (late-bound where the service swaps them per connection). */
|
|
93
|
+
export interface TopicLauncherDeps {
|
|
94
|
+
ctx: HostContext
|
|
95
|
+
config: FeishuConfig
|
|
96
|
+
started(): boolean
|
|
97
|
+
channel(): FeishuChannel | undefined
|
|
98
|
+
domain(): FeishuDomainHandle | undefined
|
|
99
|
+
routes: RouteStore
|
|
100
|
+
repair: InflightRepair
|
|
101
|
+
sessions: SessionManager
|
|
102
|
+
streamer(): TurnStreamer | undefined
|
|
103
|
+
/** Shared per-route FIFO (the first turn must precede any follow-up). */
|
|
104
|
+
queue: RouteOperationQueue
|
|
105
|
+
/**
|
|
106
|
+
* Reserve an opened topic until its route is published, so replies posted
|
|
107
|
+
* meanwhile wait instead of being dropped or starting a competing Session.
|
|
108
|
+
* The returned closer is called exactly once.
|
|
109
|
+
*/
|
|
110
|
+
reserveTopic(threadId: string): () => void
|
|
111
|
+
/** Whether a launch may post into the group. */
|
|
112
|
+
mayLaunchIn(chatId: string): boolean
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** One accepted launch still posting its root card. */
|
|
116
|
+
interface PendingAccept {
|
|
117
|
+
readonly fingerprint: string
|
|
118
|
+
readonly result: Promise<TopicLaunchResult>
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Readable reason of an error value. */
|
|
122
|
+
function messageOf(error: unknown): string {
|
|
123
|
+
return error instanceof Error ? error.message : String(error)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export class TopicLauncher implements FeishuTopicService {
|
|
127
|
+
readonly version = FEISHU_TOPICS_VERSION
|
|
128
|
+
/** Idempotency key → launch still being accepted (concurrent retries share it). */
|
|
129
|
+
private readonly accepting = new Map<string, PendingAccept>()
|
|
130
|
+
/** Background topics still starting (drained on shutdown). */
|
|
131
|
+
private readonly running = new Set<Promise<void>>()
|
|
132
|
+
/** Whether launches are admitted: from {@link open} after start until {@link close}. */
|
|
133
|
+
private admitting = false
|
|
134
|
+
|
|
135
|
+
constructor(private readonly deps: TopicLauncherDeps) {}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Start admitting launches. Called at channel start after
|
|
139
|
+
* {@link markInterrupted}, so a new launch is never marked interrupted.
|
|
140
|
+
*/
|
|
141
|
+
open(): void {
|
|
142
|
+
this.admitting = true
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Refuse launches (`not-connected`) ahead of shutdown. */
|
|
146
|
+
close(): void {
|
|
147
|
+
this.admitting = false
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async launch(spec: TopicLaunchSpec): Promise<TopicLaunchResult> {
|
|
151
|
+
const parsed = topicLaunchSpecSchema.safeParse(spec)
|
|
152
|
+
if (!parsed.success) {
|
|
153
|
+
const details = parsed.error.issues
|
|
154
|
+
.map(issue => `${issue.path.join('.') || '(spec)'}: ${issue.message}`)
|
|
155
|
+
.join('; ')
|
|
156
|
+
return refused('invalid', `话题参数不合法:${details}`)
|
|
157
|
+
}
|
|
158
|
+
const valid = parsed.data
|
|
159
|
+
const pending = this.accepting.get(valid.key)
|
|
160
|
+
if (pending !== undefined) {
|
|
161
|
+
if (pending.fingerprint !== valid.fingerprint) return reusedKey()
|
|
162
|
+
return await pending.result
|
|
163
|
+
}
|
|
164
|
+
const result = this.accept(valid)
|
|
165
|
+
this.accepting.set(valid.key, { fingerprint: valid.fingerprint, result })
|
|
166
|
+
try {
|
|
167
|
+
return await result
|
|
168
|
+
} finally {
|
|
169
|
+
this.accepting.delete(valid.key)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Wait for every acceptance in flight, then every background topic that is
|
|
175
|
+
* still starting. Called after `started` turns false, so each one ends on
|
|
176
|
+
* its failure card or, when its first prompt was already sent, as an
|
|
177
|
+
* ordinary topic.
|
|
178
|
+
*/
|
|
179
|
+
async drain(): Promise<void> {
|
|
180
|
+
// Acceptances still sending the root card or writing the record finish
|
|
181
|
+
// first; each one adds its launch to `running` before it settles.
|
|
182
|
+
await Promise.allSettled([...this.accepting.values()].map(pending => pending.result))
|
|
183
|
+
await Promise.allSettled([...this.running])
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Mark every launch still `running` as interrupted, on its card and in its
|
|
188
|
+
* record. Called at channel start before admission opens, so each such
|
|
189
|
+
* record belongs to a previous instance whose first turn never settled. A
|
|
190
|
+
* card that cannot be updated (deleted, recalled) is logged and still
|
|
191
|
+
* recorded as interrupted, so it is not retried on every start.
|
|
192
|
+
*/
|
|
193
|
+
async markInterrupted(): Promise<void> {
|
|
194
|
+
const channel = this.deps.channel()
|
|
195
|
+
const domain = this.deps.domain()
|
|
196
|
+
if (channel === undefined || domain === undefined) return
|
|
197
|
+
for (const [key, record] of [...domain.topicLaunches.entries()]) {
|
|
198
|
+
if (record.status !== 'running') continue
|
|
199
|
+
const root = rootCardOf(record)
|
|
200
|
+
if (root !== undefined) {
|
|
201
|
+
await channel.updateCard(record.messageId, buildTopicRootCard(root.requesterUserId, root.card, { kind: 'interrupted' }))
|
|
202
|
+
.catch((error: unknown) => {
|
|
203
|
+
this.deps.ctx.logger?.warn?.('dsh-channels: marking topic card %s interrupted failed: %s', key, String(error))
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
await this.recordStatus(key, 'interrupted')
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private async accept(spec: TopicLaunchSpec): Promise<TopicLaunchResult> {
|
|
211
|
+
const channel = this.deps.channel()
|
|
212
|
+
const domain = this.deps.domain()
|
|
213
|
+
if (!this.admitting || !this.deps.started() || channel === undefined || domain === undefined) {
|
|
214
|
+
return refused('not-connected', '飞书渠道尚未连接,请稍后重试')
|
|
215
|
+
}
|
|
216
|
+
const stored = domain.topicLaunches.get(spec.key)
|
|
217
|
+
if (stored !== undefined) {
|
|
218
|
+
if (stored.bodyHash !== spec.fingerprint) return reusedKey()
|
|
219
|
+
return { ok: true, replayed: true, chatId: stored.chatId, messageId: stored.messageId }
|
|
220
|
+
}
|
|
221
|
+
if (!this.deps.mayLaunchIn(spec.chatId)) {
|
|
222
|
+
return refused('forbidden-chat', '目标群未授权:请管理员在该群发送 /auth group add,或在设置面板「访问与授权」中添加')
|
|
223
|
+
}
|
|
224
|
+
const card = copyCard(spec.card)
|
|
225
|
+
let rootId: string
|
|
226
|
+
try {
|
|
227
|
+
rootId = (await channel.send(spec.chatId, {
|
|
228
|
+
card: buildTopicRootCard(spec.requesterUserId, card, { kind: 'running' }),
|
|
229
|
+
})).messageId
|
|
230
|
+
} catch (error) {
|
|
231
|
+
return refused('send-failed', `${spec.card.taskName}卡片发送失败(机器人需在目标群内):${messageOf(error)}`)
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
await domain.topicLaunches.put(spec.key, {
|
|
235
|
+
key: spec.key, bodyHash: spec.fingerprint, chatId: spec.chatId, messageId: rootId, acceptedAt: Date.now(),
|
|
236
|
+
requesterUserId: spec.requesterUserId, card, status: 'running',
|
|
237
|
+
})
|
|
238
|
+
} catch (error) {
|
|
239
|
+
// The card is visible already; a lost record only means a retry of this
|
|
240
|
+
// key would post a second topic.
|
|
241
|
+
this.deps.ctx.logger?.warn?.('dsh-channels: recording topic launch %s failed: %s', spec.key, String(error))
|
|
242
|
+
}
|
|
243
|
+
const run = this.start(channel, spec, card, rootId)
|
|
244
|
+
this.running.add(run)
|
|
245
|
+
void run.finally(() => { this.running.delete(run) })
|
|
246
|
+
return { ok: true, replayed: false, chatId: spec.chatId, messageId: rootId }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Start the topic below an accepted root card; every failure ends on the cards. */
|
|
250
|
+
private async start(
|
|
251
|
+
channel: FeishuChannel,
|
|
252
|
+
spec: TopicLaunchSpec,
|
|
253
|
+
card: TopicRootCard,
|
|
254
|
+
rootId: string,
|
|
255
|
+
): Promise<void> {
|
|
256
|
+
const warn = (message: string, ...args: unknown[]): void => {
|
|
257
|
+
this.deps.ctx.logger?.warn?.(message, ...args)
|
|
258
|
+
}
|
|
259
|
+
const showStatus = async (status: TopicStatus): Promise<void> => {
|
|
260
|
+
await channel.updateCard(rootId, buildTopicRootCard(spec.requesterUserId, card, status)).catch((error: unknown) => {
|
|
261
|
+
warn('dsh-channels: updating topic card %s failed: %s', rootId, String(error))
|
|
262
|
+
})
|
|
263
|
+
await this.recordStatus(spec.key, status.kind)
|
|
264
|
+
}
|
|
265
|
+
const sessionId = randomUUID() as SessionId
|
|
266
|
+
let handle: AgentHandle | undefined
|
|
267
|
+
let cardId: string | undefined
|
|
268
|
+
let threadId: string | undefined
|
|
269
|
+
let releaseTopic: (() => void) | undefined
|
|
270
|
+
let published = false
|
|
271
|
+
try {
|
|
272
|
+
const topic = await this.openTopic(channel, spec.chatId, rootId, sessionId)
|
|
273
|
+
cardId = topic.cardId
|
|
274
|
+
threadId = topic.threadId
|
|
275
|
+
releaseTopic = this.deps.reserveTopic(threadId)
|
|
276
|
+
const services = placementServices(this.deps.ctx)
|
|
277
|
+
const workspace = await ensureLaunchWorkspace(
|
|
278
|
+
this.deps.ctx, this.deps.config.baseDir, spec.workspace.segments, spec.workspace.title,
|
|
279
|
+
)
|
|
280
|
+
const topicToken = topicTokenFor(threadId, 'thread', sessionId)
|
|
281
|
+
const plan = await planTopic(services, this.deps.config, workspace.path, topicToken, warn)
|
|
282
|
+
this.assertStarted()
|
|
283
|
+
handle = await this.deps.sessions.createSession(sessionId, plan.cwd)
|
|
284
|
+
const placement: TopicPlacement = await enterTopic(
|
|
285
|
+
services, handle.agent, plan, workspace.path, topicToken, warn,
|
|
286
|
+
)
|
|
287
|
+
if (plan.cwd === workspace.path) {
|
|
288
|
+
void Promise.resolve(workspace.attachSession(sessionId)).catch((error: unknown) => {
|
|
289
|
+
warn('dsh-channels: attaching topic session %s to workspace failed: %s', sessionId, String(error))
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
await this.deps.sessions.mountGuidance(handle.agent, placement.sessionDir)
|
|
293
|
+
this.assertStarted()
|
|
294
|
+
|
|
295
|
+
const createdAt = handle.agent.session.header.createdAt
|
|
296
|
+
const record: RouteRecord = {
|
|
297
|
+
key: threadId,
|
|
298
|
+
kind: 'thread',
|
|
299
|
+
sessionId,
|
|
300
|
+
chatId: spec.chatId,
|
|
301
|
+
chatType: 'group',
|
|
302
|
+
senderUserId: spec.requesterUserId,
|
|
303
|
+
workspacePath: workspace.path,
|
|
304
|
+
sessionDir: placement.sessionDir,
|
|
305
|
+
...(placement.worktreeBranch === undefined ? {} : { worktreeBranch: placement.worktreeBranch }),
|
|
306
|
+
cardMessageId: cardId,
|
|
307
|
+
createdAt,
|
|
308
|
+
lastActivityAt: createdAt,
|
|
309
|
+
sessionHistory: [{ sessionId, createdAt }],
|
|
310
|
+
shared: true,
|
|
311
|
+
}
|
|
312
|
+
await this.deps.repair.write({
|
|
313
|
+
key: cardId,
|
|
314
|
+
sessionId,
|
|
315
|
+
chatId: spec.chatId,
|
|
316
|
+
triggerMessageId: rootId,
|
|
317
|
+
cards: [{ messageId: cardId, mode: 'card' }],
|
|
318
|
+
startedAt: Date.now(),
|
|
319
|
+
attempts: 0,
|
|
320
|
+
})
|
|
321
|
+
const agent = handle.agent
|
|
322
|
+
const routeKey = threadId
|
|
323
|
+
const streamCardId = cardId
|
|
324
|
+
// Route, stream binding, and prompt land in one queue slot, so a reply
|
|
325
|
+
// posted in the topic meanwhile is delivered after the first turn.
|
|
326
|
+
await this.deps.queue.run(routeKey, async () => {
|
|
327
|
+
this.assertStarted()
|
|
328
|
+
await this.deps.routes.put(routeKey, record)
|
|
329
|
+
published = true
|
|
330
|
+
this.deps.streamer()?.attach(agent.session, streamCardId, rootId, undefined, {
|
|
331
|
+
onSettled: outcome => { void showStatus({ kind: outcome }) },
|
|
332
|
+
})
|
|
333
|
+
agent.followup(feishuUserMessage(spec.prompt))
|
|
334
|
+
})
|
|
335
|
+
} catch (error) {
|
|
336
|
+
const reason = messageOf(error)
|
|
337
|
+
warn('dsh-channels: topic %s failed to start: %s', spec.key, reason)
|
|
338
|
+
if (published && threadId !== undefined) {
|
|
339
|
+
await this.deps.routes.remove(threadId).catch((removeError: unknown) => {
|
|
340
|
+
warn('dsh-channels: removing topic route %s failed: %s', threadId, String(removeError))
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
await handle?.dispose().catch(() => {})
|
|
344
|
+
if (cardId !== undefined) {
|
|
345
|
+
void this.deps.repair.clear(cardId)
|
|
346
|
+
await channel.updateCard(cardId, buildCard({
|
|
347
|
+
phase: 'error',
|
|
348
|
+
reasoning: '',
|
|
349
|
+
body: `❌ ${card.taskName}未能开始:${reason}`,
|
|
350
|
+
stats: { sessionId, inputTokens: 0, outputTokens: 0 },
|
|
351
|
+
})).catch(() => {})
|
|
352
|
+
}
|
|
353
|
+
await showStatus({ kind: 'failed', reason })
|
|
354
|
+
} finally {
|
|
355
|
+
releaseTopic?.()
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Open the topic: the first streaming card, replied in thread below the
|
|
361
|
+
* root card. The topic id is read from that card (or the root card, in
|
|
362
|
+
* topic groups).
|
|
363
|
+
*/
|
|
364
|
+
private async openTopic(
|
|
365
|
+
channel: FeishuChannel,
|
|
366
|
+
chatId: string,
|
|
367
|
+
rootId: string,
|
|
368
|
+
sessionId: SessionId,
|
|
369
|
+
): Promise<{ cardId: string; threadId: string }> {
|
|
370
|
+
const sent = await channel.send(chatId, { card: initialStreamingCard(sessionId) }, {
|
|
371
|
+
replyTo: rootId, replyInThread: true,
|
|
372
|
+
})
|
|
373
|
+
const threadId = await channel.threadIdOfMessage(sent.messageId).catch(() => undefined)
|
|
374
|
+
?? await channel.threadIdOfMessage(rootId).catch(() => undefined)
|
|
375
|
+
if (threadId === undefined) {
|
|
376
|
+
await channel.updateCard(sent.messageId, buildCard({
|
|
377
|
+
phase: 'error',
|
|
378
|
+
reasoning: '',
|
|
379
|
+
body: '❌ 无法在目标群创建话题',
|
|
380
|
+
stats: { sessionId, inputTokens: 0, outputTokens: 0 },
|
|
381
|
+
})).catch(() => {})
|
|
382
|
+
throw new Error('无法在目标群创建话题(读取话题 id 失败)')
|
|
383
|
+
}
|
|
384
|
+
return { cardId: sent.messageId, threadId }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Store a root card status in the launch record (best-effort; the card already shows it). */
|
|
388
|
+
private async recordStatus(key: string, status: TopicLaunchRecord['status']): Promise<void> {
|
|
389
|
+
const domain = this.deps.domain()
|
|
390
|
+
const record = domain?.topicLaunches.get(key)
|
|
391
|
+
if (domain === undefined || record === undefined) return
|
|
392
|
+
try {
|
|
393
|
+
await domain.topicLaunches.put(key, { ...record, status })
|
|
394
|
+
} catch (error) {
|
|
395
|
+
this.deps.ctx.logger?.warn?.('dsh-channels: recording topic %s status failed: %s', key, String(error))
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private assertStarted(): void {
|
|
400
|
+
if (!this.deps.started()) throw new Error('飞书渠道已断开,已取消')
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** A detached copy of the card, so the caller cannot change what is recorded. */
|
|
405
|
+
function copyCard(card: TopicRootCard): NonNullable<TopicLaunchRecord['card']> {
|
|
406
|
+
return {
|
|
407
|
+
title: card.title,
|
|
408
|
+
taskName: card.taskName,
|
|
409
|
+
lead: card.lead.map(run => (run.bold === undefined ? { text: run.text } : { text: run.text, bold: run.bold })),
|
|
410
|
+
...(card.links === undefined
|
|
411
|
+
? {}
|
|
412
|
+
: {
|
|
413
|
+
links: {
|
|
414
|
+
nameHeader: card.links.nameHeader,
|
|
415
|
+
linkHeader: card.links.linkHeader,
|
|
416
|
+
rows: card.links.rows.map(row => ({ name: row.name, url: row.url })),
|
|
417
|
+
},
|
|
418
|
+
}),
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function refused(code: TopicLaunchFailureCode, message: string): TopicLaunchResult {
|
|
423
|
+
return { ok: false, code, message }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function reusedKey(): TopicLaunchResult {
|
|
427
|
+
return refused('key-conflict', '幂等键已被内容不同的请求使用')
|
|
428
|
+
}
|
package/src/workspaces.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* - p2p: workspace `<baseDir>/p2p/<user_id>/`
|
|
6
6
|
* - group: workspace `<baseDir>/groups/<chat_id>/<user_id>/`
|
|
7
7
|
* - topic: `<workspace>/topics/<topic-token>/` (the Session's effective cwd)
|
|
8
|
-
* -
|
|
8
|
+
* - `feishuTopics` launch: workspace `<baseDir>/<caller segments…>/` (观象's
|
|
9
|
+
* log analyses use `<baseDir>/analyze/<app>/<errorCode>/`)
|
|
9
10
|
*
|
|
10
11
|
* The workspace root is the Session header cwd: it keeps the Session grouped
|
|
11
12
|
* under the user's project in the GUI and stays the sandbox write boundary.
|
|
@@ -235,31 +236,37 @@ export async function ensureWorkspace(
|
|
|
235
236
|
}
|
|
236
237
|
|
|
237
238
|
/**
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
* application and error code is grouped there, each in its own topic
|
|
241
|
-
* directory.
|
|
239
|
+
* First path segments this module lays out itself; a `feishuTopics` launch
|
|
240
|
+
* workspace may not start with one of them.
|
|
242
241
|
*/
|
|
243
|
-
export
|
|
244
|
-
return path.join(baseDir, 'analyze', safeSegment(app, 'app'), safeSegment(errorCode, 'errorCode'))
|
|
245
|
-
}
|
|
242
|
+
export const RESERVED_WORKSPACE_ROOTS: readonly string[] = ['p2p', 'groups', 'topics', '_staging']
|
|
246
243
|
|
|
247
|
-
/**
|
|
248
|
-
|
|
249
|
-
|
|
244
|
+
/**
|
|
245
|
+
* Workspace directory of one `feishuTopics` launch:
|
|
246
|
+
* `<baseDir>/<segments…>/`. Launches with the same segments share it, each in
|
|
247
|
+
* its own topic directory.
|
|
248
|
+
* @throws when a segment is unsafe or the first one is reserved.
|
|
249
|
+
*/
|
|
250
|
+
export function launchWorkspacePathFor(baseDir: string, segments: readonly string[]): string {
|
|
251
|
+
if (segments.length === 0) throw new Error('dsh-channels: a launch workspace needs at least one path segment')
|
|
252
|
+
const safe = segments.map(segment => safeSegment(segment, 'launch workspace segment'))
|
|
253
|
+
if (RESERVED_WORKSPACE_ROOTS.includes(safe[0]!)) {
|
|
254
|
+
throw new Error(`dsh-channels: launch workspace may not start with reserved segment ${JSON.stringify(safe[0])}`)
|
|
255
|
+
}
|
|
256
|
+
return path.join(baseDir, ...safe)
|
|
250
257
|
}
|
|
251
258
|
|
|
252
259
|
/**
|
|
253
|
-
* Ensure one
|
|
254
|
-
*
|
|
260
|
+
* Ensure one launch workspace exists and is registered (idempotent like
|
|
261
|
+
* {@link ensureWorkspace}).
|
|
255
262
|
*/
|
|
256
|
-
export async function
|
|
263
|
+
export async function ensureLaunchWorkspace(
|
|
257
264
|
ctx: HostContext,
|
|
258
265
|
baseDir: string,
|
|
259
|
-
|
|
260
|
-
|
|
266
|
+
segments: readonly string[],
|
|
267
|
+
title: string,
|
|
261
268
|
): Promise<Workspace> {
|
|
262
|
-
const dir =
|
|
269
|
+
const dir = launchWorkspacePathFor(baseDir, segments)
|
|
263
270
|
await mkdir(dir, { recursive: true })
|
|
264
|
-
return ctx.workspaceRegistry.create(dir,
|
|
271
|
+
return ctx.workspaceRegistry.create(dir, title)
|
|
265
272
|
}
|