@modesto_ma/dsh-channels 0.0.0-stage → 0.1.0
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 +302 -2
- package/cordis.patch.yml +3 -0
- package/docs/access-control-design.md +550 -0
- package/docs/assets/web-settings-panel.png +0 -0
- package/docs/assets/web-settings-restart-pending.png +0 -0
- package/docs/feasibility-and-design.md +213 -0
- package/docs/feishu-onboarding.md +268 -0
- package/docs/persistence.md +199 -0
- package/docs/refactor-plan.md +56 -0
- package/docs/review-dsh-0.2.0.md +389 -0
- package/docs/slash-commands-implementation-plan.md +410 -0
- package/docs/web-display-only-surface-roadmap.md +363 -0
- package/lib/client.js +2226 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +10217 -0
- package/lib/index.js.map +1 -0
- package/package.json +98 -4
- package/src/access/cards.ts +188 -0
- package/src/access/controller.ts +201 -0
- package/src/access/index.ts +13 -0
- package/src/access/policy.ts +103 -0
- package/src/access/requests.ts +512 -0
- package/src/access/store.ts +220 -0
- package/src/approvals.ts +8 -0
- package/src/cards/approval.ts +114 -0
- package/src/cards/index.ts +11 -0
- package/src/cards/primitives.ts +82 -0
- package/src/cards/question.ts +141 -0
- package/src/cards/streaming.ts +563 -0
- package/src/cards.ts +8 -0
- package/src/cleanup.ts +398 -0
- package/src/client/api.ts +262 -0
- package/src/client/catalog.ts +92 -0
- package/src/client/index.ts +61 -0
- package/src/client/panel.tsx +1475 -0
- package/src/client/styles.ts +98 -0
- package/src/commands/registry.ts +155 -0
- package/src/commands.ts +335 -0
- package/src/config.ts +206 -0
- package/src/domain.ts +428 -0
- package/src/dsh-types.ts +11 -0
- package/src/feishu/agent-context.ts +62 -0
- package/src/feishu/delivery.ts +188 -0
- package/src/feishu/inbound-attachments.ts +436 -0
- package/src/feishu-channel.ts +339 -0
- package/src/health.ts +233 -0
- package/src/host/agent.ts +181 -0
- package/src/host/commands.ts +63 -0
- package/src/host/core.ts +345 -0
- package/src/host/index.ts +12 -0
- package/src/host/interaction.ts +101 -0
- package/src/host/services.ts +320 -0
- package/src/index.ts +122 -0
- package/src/interactions/approvals.ts +154 -0
- package/src/interactions/pending.ts +162 -0
- package/src/interactions/questions.ts +274 -0
- package/src/questions.ts +8 -0
- package/src/routing/inbound.ts +524 -0
- package/src/service.ts +2138 -0
- package/src/session/manager.ts +352 -0
- package/src/session/repair.ts +157 -0
- package/src/session/request-policy.ts +192 -0
- package/src/session/routes.ts +165 -0
- package/src/session-commands.ts +310 -0
- package/src/session-export.ts +112 -0
- package/src/settings/catalog.ts +309 -0
- package/src/settings/contract.ts +508 -0
- package/src/settings/overlay.ts +164 -0
- package/src/settings/routes.ts +409 -0
- package/src/settings/surface.ts +773 -0
- package/src/stats.ts +485 -0
- package/src/streamer.ts +1108 -0
- package/src/workspaces.ts +199 -0
package/src/service.ts
ADDED
|
@@ -0,0 +1,2138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Feishu bridge service: inbound routing, workspace/session ownership,
|
|
3
|
+
* topic creation, and streaming-card orchestration.
|
|
4
|
+
*
|
|
5
|
+
* Ownership model (one workspace per user × chat):
|
|
6
|
+
* - p2p: one workspace per (bot, user), all of that user's sessions inside;
|
|
7
|
+
* - group: one workspace per (chat, user), sessions grouped per user.
|
|
8
|
+
*
|
|
9
|
+
* Routing model:
|
|
10
|
+
* - `thread_id` → bound session (no @ needed inside an owned topic);
|
|
11
|
+
* - degraded chat-scoped keys when Feishu refuses topic replies;
|
|
12
|
+
* - new topic + new session for an @-triggered message without a route.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-channels/service
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { lstat, readdir, realpath, rm } from 'node:fs/promises'
|
|
18
|
+
import path from 'node:path'
|
|
19
|
+
import { AccessController } from './access/controller.ts'
|
|
20
|
+
import { AccessRequests } from './access/requests.ts'
|
|
21
|
+
import { AccessStore } from './access/store.ts'
|
|
22
|
+
import { ApprovalAnswerer } from './approvals.ts'
|
|
23
|
+
import type { ApprovalRoute } from './approvals.ts'
|
|
24
|
+
import { QuestionAnswerer } from './questions.ts'
|
|
25
|
+
import type {
|
|
26
|
+
CommandInvocation,
|
|
27
|
+
CommandResult,
|
|
28
|
+
CredentialInfo,
|
|
29
|
+
CredentialProvider,
|
|
30
|
+
CordisFiber,
|
|
31
|
+
HostContext,
|
|
32
|
+
LlmService,
|
|
33
|
+
Session,
|
|
34
|
+
SessionId,
|
|
35
|
+
SessionProjections,
|
|
36
|
+
TokenMeter,
|
|
37
|
+
Workspace,
|
|
38
|
+
} from './dsh-types.ts'
|
|
39
|
+
import { standaloneInfoCard } from './cards.ts'
|
|
40
|
+
import {
|
|
41
|
+
armEffortCommand, armModelCommand, armRenewCommand, registerModelCommand, commandResultCardKind, parseSlashCommand,
|
|
42
|
+
registerEffortCommand, registerRenewCommand, RouteOperationQueue, supportedCommandHelp, supportedCommandList,
|
|
43
|
+
} from './commands.ts'
|
|
44
|
+
import type { EffortCommandAction, ParsedSlashCommand } from './commands.ts'
|
|
45
|
+
import { commandSpec } from './commands/registry.ts'
|
|
46
|
+
import type { ChannelCommandSpec } from './commands/registry.ts'
|
|
47
|
+
import type { PermissionPresets, SessionQuery } from './dsh-types.ts'
|
|
48
|
+
import { feishuDomain } from './domain.ts'
|
|
49
|
+
import {
|
|
50
|
+
applyWorkspaceCleanup, isPathInside, planWorkspaceCleanup, retentionMs,
|
|
51
|
+
} from './cleanup.ts'
|
|
52
|
+
import type { CleanupDeps, WorkspaceStats } from './cleanup.ts'
|
|
53
|
+
import type {
|
|
54
|
+
AccessGroupRecord, AccessRequestRecord, AccessUserRecord,
|
|
55
|
+
ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot, ProcessedRecord, RawDomainHandle,
|
|
56
|
+
RouteRecord,
|
|
57
|
+
} from './domain.ts'
|
|
58
|
+
import { RouteStore } from './session/routes.ts'
|
|
59
|
+
import { InflightRepair } from './session/repair.ts'
|
|
60
|
+
import { SessionManager } from './session/manager.ts'
|
|
61
|
+
import type { LiveAgent, RenewCommit } from './session/manager.ts'
|
|
62
|
+
import { CardDelivery } from './feishu/delivery.ts'
|
|
63
|
+
import { AttachmentIntake } from './feishu/inbound-attachments.ts'
|
|
64
|
+
import { InboundRouter } from './routing/inbound.ts'
|
|
65
|
+
import type { RequestPolicyController } from './session/request-policy.ts'
|
|
66
|
+
import type { ChannelFactory, FeishuChannel, NormalizedMessage } from './feishu-channel.ts'
|
|
67
|
+
import { buildHealthCard, foldHealthSnapshot, HEALTH_DEPENDENCY_NAMES } from './health.ts'
|
|
68
|
+
import type { HealthDependencyName, HealthSnapshot } from './health.ts'
|
|
69
|
+
import {
|
|
70
|
+
buildCompactComparisonCard, buildRenewCard, buildSessionHistoryCard, buildSessionStatusCard, buildWorkDirCard,
|
|
71
|
+
compactFailureContext, resolveWorkDir, SESSION_HISTORY_DISPLAY_LIMIT,
|
|
72
|
+
} from './session-commands.ts'
|
|
73
|
+
import type { SessionHistoryAnnotation } from './session-commands.ts'
|
|
74
|
+
import { normalizeSessionHistory } from './domain.ts'
|
|
75
|
+
import { foldSessionStats, measureSessionContext, readProjectionViews } from './stats.ts'
|
|
76
|
+
import type { ContextMeasurement, ContextServices } from './stats.ts'
|
|
77
|
+
import { createLogicalSessionExport } from './session-export.ts'
|
|
78
|
+
import type { SessionExportBuilder } from './session-export.ts'
|
|
79
|
+
import { ACTIVITY_BUILD_MARKER, TurnStreamer } from './streamer.ts'
|
|
80
|
+
import type { ProjectedStats, StreamerHooks } from './streamer.ts'
|
|
81
|
+
import type { FeishuConfig } from './config.ts'
|
|
82
|
+
import type {
|
|
83
|
+
AccessMutationResponse, AccessRequestWire, AccessWire,
|
|
84
|
+
} from './settings/contract.ts'
|
|
85
|
+
import type { OverlayStore } from './settings/overlay.ts'
|
|
86
|
+
|
|
87
|
+
/** Periodic route-GC sweep cadence (a long-running instance also accumulates
|
|
88
|
+
* dead records; TTL semantics make 6 h granularity more than sufficient). */
|
|
89
|
+
const GC_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000
|
|
90
|
+
|
|
91
|
+
/** Dedup-record retention: Feishu redelivery windows are minutes-scale, so a
|
|
92
|
+
* day is 100×+ headroom while keeping the table bounded. */
|
|
93
|
+
const PROCESSED_RETENTION_MS = 24 * 60 * 60 * 1000
|
|
94
|
+
|
|
95
|
+
/** Workspace-retention sweep cadence when automatic cleanup is enabled. */
|
|
96
|
+
const RETENTION_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
interface RenewAdmission {
|
|
100
|
+
route: RouteRecord
|
|
101
|
+
live: LiveAgent
|
|
102
|
+
commit?: RenewCommit
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface EffortAdmission {
|
|
106
|
+
live: LiveAgent
|
|
107
|
+
controller: RequestPolicyController
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface ModelAdmission {
|
|
111
|
+
live: LiveAgent
|
|
112
|
+
controller: RequestPolicyController
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Wrap the host's storage facility open behind a stable local type. */
|
|
116
|
+
async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
|
|
117
|
+
const domain = await ctx.storageDomain.open(feishuDomain) as unknown as RawDomainHandle
|
|
118
|
+
const table = domain.table('routes')
|
|
119
|
+
const inflightRaw = domain.table('inflight')
|
|
120
|
+
const processedRaw = domain.table('processed')
|
|
121
|
+
const expiredRoutesRaw = domain.table('expired_routes')
|
|
122
|
+
const accessGroupsRaw = domain.table('access_groups')
|
|
123
|
+
const accessUsersRaw = domain.table('access_users')
|
|
124
|
+
const accessRequestsRaw = domain.table('access_requests')
|
|
125
|
+
return {
|
|
126
|
+
routes: {
|
|
127
|
+
get: key => table.get(key) as RouteRecord | undefined,
|
|
128
|
+
put: (key, value) => table.put(key, value),
|
|
129
|
+
delete: key => table.delete(key),
|
|
130
|
+
entries: () => table.entries() as IterableIterator<[string, RouteRecord]>,
|
|
131
|
+
},
|
|
132
|
+
inflight: {
|
|
133
|
+
get: key => inflightRaw.get(key) as InflightSnapshot | undefined,
|
|
134
|
+
put: (key, value) => inflightRaw.put(key, value),
|
|
135
|
+
update: (key, fn) => inflightRaw.update(key, current => fn(current as InflightSnapshot)),
|
|
136
|
+
delete: key => inflightRaw.delete(key),
|
|
137
|
+
entries: () => inflightRaw.entries() as IterableIterator<[string, InflightSnapshot]>,
|
|
138
|
+
},
|
|
139
|
+
processed: {
|
|
140
|
+
get: key => processedRaw.get(key) as ProcessedRecord | undefined,
|
|
141
|
+
put: (key, value) => processedRaw.put(key, value),
|
|
142
|
+
delete: key => processedRaw.delete(key),
|
|
143
|
+
entries: () => processedRaw.entries() as IterableIterator<[string, ProcessedRecord]>,
|
|
144
|
+
},
|
|
145
|
+
expiredRoutes: {
|
|
146
|
+
get: key => expiredRoutesRaw.get(key) as ExpiredRouteRecord | undefined,
|
|
147
|
+
put: (key, value) => expiredRoutesRaw.put(key, value),
|
|
148
|
+
entries: () => expiredRoutesRaw.entries() as IterableIterator<[string, ExpiredRouteRecord]>,
|
|
149
|
+
},
|
|
150
|
+
accessGroups: {
|
|
151
|
+
get: key => accessGroupsRaw.get(key) as AccessGroupRecord | undefined,
|
|
152
|
+
put: (key, value) => accessGroupsRaw.put(key, value),
|
|
153
|
+
delete: key => accessGroupsRaw.delete(key),
|
|
154
|
+
entries: () => accessGroupsRaw.entries() as IterableIterator<[string, AccessGroupRecord]>,
|
|
155
|
+
},
|
|
156
|
+
accessUsers: {
|
|
157
|
+
get: key => accessUsersRaw.get(key) as AccessUserRecord | undefined,
|
|
158
|
+
put: (key, value) => accessUsersRaw.put(key, value),
|
|
159
|
+
delete: key => accessUsersRaw.delete(key),
|
|
160
|
+
entries: () => accessUsersRaw.entries() as IterableIterator<[string, AccessUserRecord]>,
|
|
161
|
+
},
|
|
162
|
+
accessRequests: {
|
|
163
|
+
get: key => accessRequestsRaw.get(key) as AccessRequestRecord | undefined,
|
|
164
|
+
put: (key, value) => accessRequestsRaw.put(key, value),
|
|
165
|
+
delete: key => accessRequestsRaw.delete(key),
|
|
166
|
+
entries: () => accessRequestsRaw.entries() as IterableIterator<[string, AccessRequestRecord]>,
|
|
167
|
+
},
|
|
168
|
+
getGlobal: () => (domain.global.get() ?? {}) as FeishuDomainGlobal,
|
|
169
|
+
setGlobal: value => domain.global.set(value),
|
|
170
|
+
close: () => domain.close(),
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve one credential reference through the host credentials seam.
|
|
176
|
+
*
|
|
177
|
+
* Precedence is owned by the provider (env > `$DSH_HOME/.credentials.yaml`
|
|
178
|
+
* > `.env` fallbacks). Without a mounted seam the process environment is the
|
|
179
|
+
* whole credential plane.
|
|
180
|
+
*/
|
|
181
|
+
async function resolveCredentialRef(ctx: HostContext, ref: string): Promise<string> {
|
|
182
|
+
const provider = ctx.get?.('credentials') as CredentialProvider | undefined
|
|
183
|
+
if (provider !== undefined) {
|
|
184
|
+
const hit = await provider.resolve(ref)
|
|
185
|
+
if (hit !== undefined) return hit.value
|
|
186
|
+
} else {
|
|
187
|
+
const ambient = process.env[ref]
|
|
188
|
+
if (ambient !== undefined && ambient !== '') return ambient
|
|
189
|
+
}
|
|
190
|
+
throw new Error(
|
|
191
|
+
`dsh-channels: credential "${ref}" is not configured; add it to $DSH_HOME/.credentials.yaml `
|
|
192
|
+
+ `(e.g. "${ref}: <value>") or export it in the launching environment`,
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Best-effort human-readable error text for the settings panel. */
|
|
197
|
+
function messageOf(error: unknown): string {
|
|
198
|
+
return error instanceof Error ? error.message : String(error)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Measure one workspace directory for the cleanup preview: total regular-file
|
|
203
|
+
* bytes and file count. Symlinks are never followed; unreadable entries are
|
|
204
|
+
* skipped so a permission problem cannot fail the whole pass.
|
|
205
|
+
*/
|
|
206
|
+
async function measureWorkspace(dir: string): Promise<WorkspaceStats | undefined> {
|
|
207
|
+
let info
|
|
208
|
+
try {
|
|
209
|
+
info = await lstat(dir)
|
|
210
|
+
} catch {
|
|
211
|
+
return undefined
|
|
212
|
+
}
|
|
213
|
+
if (!info.isDirectory()) return undefined
|
|
214
|
+
let bytes = 0
|
|
215
|
+
let files = 0
|
|
216
|
+
const pending = [dir]
|
|
217
|
+
while (pending.length > 0) {
|
|
218
|
+
const current = pending.pop()!
|
|
219
|
+
let entries
|
|
220
|
+
try {
|
|
221
|
+
entries = await readdir(current, { withFileTypes: true })
|
|
222
|
+
} catch {
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
for (const entry of entries) {
|
|
226
|
+
const child = path.join(current, entry.name)
|
|
227
|
+
if (entry.isSymbolicLink()) continue
|
|
228
|
+
if (entry.isDirectory()) {
|
|
229
|
+
pending.push(child)
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
if (!entry.isFile()) continue
|
|
233
|
+
try {
|
|
234
|
+
const stat = await lstat(child)
|
|
235
|
+
if (stat.isFile()) {
|
|
236
|
+
bytes += stat.size
|
|
237
|
+
files += 1
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
// The directory changed while walking it; the byte total is advisory.
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { mtimeMs: info.mtimeMs, bytes, files }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export class FeishuService {
|
|
248
|
+
private readonly ctx: HostContext
|
|
249
|
+
/** Live config object. Mutated in place — collaborators captured this reference. */
|
|
250
|
+
private readonly config: FeishuConfig
|
|
251
|
+
/** Loader/profile-provided config, the layer below the Web overlay. */
|
|
252
|
+
private readonly baseConfig: FeishuConfig
|
|
253
|
+
/** Web overlay store (`$DSH_HOME/dsh-channels/settings.json`), when mounted. */
|
|
254
|
+
private readonly overlay: OverlayStore | undefined
|
|
255
|
+
private readonly channelFactory: ChannelFactory
|
|
256
|
+
private readonly sessionExportBuilder: SessionExportBuilder
|
|
257
|
+
private channel: FeishuChannel | undefined
|
|
258
|
+
private streamer: TurnStreamer | undefined
|
|
259
|
+
private domain: FeishuDomainHandle | undefined
|
|
260
|
+
/** Durable route store (cache + TTL retirement). */
|
|
261
|
+
private readonly routes: RouteStore
|
|
262
|
+
/** In-flight turn snapshots + startup repair. */
|
|
263
|
+
private readonly repair: InflightRepair
|
|
264
|
+
/** Outbound card delivery + reaction lifecycle. */
|
|
265
|
+
private readonly delivery: CardDelivery
|
|
266
|
+
/** Inbound attachment archival (Feishu resources → workspace files). */
|
|
267
|
+
private readonly attachments: AttachmentIntake
|
|
268
|
+
/** Inbound routing: dedup, admission, decision tree, session establishment. */
|
|
269
|
+
private readonly inbound: InboundRouter
|
|
270
|
+
/** Agent/session lifecycle: live registry, resume, presets, renew. */
|
|
271
|
+
private readonly sessions: SessionManager
|
|
272
|
+
/** FIFO admission for ordinary turns and commands targeting one route. */
|
|
273
|
+
private readonly routeOperations = new RouteOperationQueue()
|
|
274
|
+
/** Active Host command executions, aborted before shutdown tears peers down. */
|
|
275
|
+
private readonly commandControllers = new Set<AbortController>()
|
|
276
|
+
/** Whole command admissions, drained before channel/storage teardown. */
|
|
277
|
+
private readonly commandOperations = new Set<Promise<void>>()
|
|
278
|
+
/** `<provider>/<model>` → context window (lazy async fill; sync reads). */
|
|
279
|
+
private readonly contextWindows = new Map<string, number>()
|
|
280
|
+
private readonly windowRequests = new Set<string>()
|
|
281
|
+
private started = false
|
|
282
|
+
/** Epoch ms when `start()` completed (uptime for `/status`). */
|
|
283
|
+
private startedAt: number | undefined
|
|
284
|
+
/** Last failed `boot()` message, surfaced in the Web settings panel. */
|
|
285
|
+
private lastStartError: string | undefined
|
|
286
|
+
/** Effective config captured at the last successful connect (restart-pending diff). */
|
|
287
|
+
private runningConfig: FeishuConfig | undefined
|
|
288
|
+
/** Guards overlapping panel-triggered reconnects. */
|
|
289
|
+
private restarting = false
|
|
290
|
+
private unlisten: (() => void) | undefined
|
|
291
|
+
/** Periodic route-GC timer (unref'd; only armed when `routeTtlMs > 0`). */
|
|
292
|
+
private gcTimer: ReturnType<typeof setInterval> | undefined
|
|
293
|
+
/** Periodic workspace-retention timer (only armed when auto cleanup is on). */
|
|
294
|
+
private retentionTimer: ReturnType<typeof setInterval> | undefined
|
|
295
|
+
/** Guards overlapping cleanup passes (panel button / daily timer). */
|
|
296
|
+
private cleanupRunning = false
|
|
297
|
+
/** HITL approval answerer (created in `start()`). */
|
|
298
|
+
private approvalAnswerer: ApprovalAnswerer | undefined
|
|
299
|
+
/** HITL structured-question answerer (`ask_user_question`). */
|
|
300
|
+
private questionAnswerer: QuestionAnswerer | undefined
|
|
301
|
+
/** Disposers for the host approval waterfall listener + card actions. */
|
|
302
|
+
private unlistenApproval: (() => boolean) | undefined
|
|
303
|
+
/** Disposer for the host user-questions waterfall listener. */
|
|
304
|
+
private unlistenQuestions: (() => boolean) | undefined
|
|
305
|
+
private unlistenCardAction: (() => void) | undefined
|
|
306
|
+
/** Durable access-control whitelists (groups + users + authorization requests). */
|
|
307
|
+
private readonly accessStore: AccessStore
|
|
308
|
+
/** Channel-plane admission gate (administrator / group / user). */
|
|
309
|
+
private readonly accessController: AccessController
|
|
310
|
+
/** Administrator DM authorization cards + bot-added group info cards. */
|
|
311
|
+
private accessRequests: AccessRequests | undefined
|
|
312
|
+
private unlistenBotAdded: (() => void) | undefined
|
|
313
|
+
|
|
314
|
+
constructor(
|
|
315
|
+
ctx: HostContext,
|
|
316
|
+
config: FeishuConfig,
|
|
317
|
+
channelFactory: ChannelFactory,
|
|
318
|
+
sessionExportBuilder: SessionExportBuilder = createLogicalSessionExport,
|
|
319
|
+
overlay?: OverlayStore,
|
|
320
|
+
) {
|
|
321
|
+
this.ctx = ctx
|
|
322
|
+
this.config = config
|
|
323
|
+
this.baseConfig = { ...config }
|
|
324
|
+
this.overlay = overlay
|
|
325
|
+
this.channelFactory = channelFactory
|
|
326
|
+
this.sessionExportBuilder = sessionExportBuilder
|
|
327
|
+
this.routes = new RouteStore({
|
|
328
|
+
domain: () => this.domain,
|
|
329
|
+
ttlMs: () => this.config.routeTtlMs,
|
|
330
|
+
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
331
|
+
})
|
|
332
|
+
this.repair = new InflightRepair({
|
|
333
|
+
domain: () => this.domain,
|
|
334
|
+
channel: () => this.channel,
|
|
335
|
+
ttlMs: () => this.config.routeTtlMs,
|
|
336
|
+
info: (message, ...args) => this.ctx.logger?.info?.(message, ...args),
|
|
337
|
+
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
338
|
+
})
|
|
339
|
+
this.sessions = new SessionManager({
|
|
340
|
+
ctx: this.ctx,
|
|
341
|
+
config: this.config,
|
|
342
|
+
persistRoute: (key, record) => this.routes.put(key, record),
|
|
343
|
+
started: () => this.started,
|
|
344
|
+
detachStream: sessionId => this.streamer?.detach(sessionId),
|
|
345
|
+
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
346
|
+
})
|
|
347
|
+
this.attachments = new AttachmentIntake({
|
|
348
|
+
enabled: () => this.config.acceptAttachments,
|
|
349
|
+
channel: () => this.channel,
|
|
350
|
+
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
351
|
+
})
|
|
352
|
+
this.delivery = new CardDelivery({
|
|
353
|
+
channel: () => this.channel,
|
|
354
|
+
started: () => this.started,
|
|
355
|
+
routeForSession: sessionId => this.routes.findBySession(sessionId),
|
|
356
|
+
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
357
|
+
})
|
|
358
|
+
this.accessStore = new AccessStore({
|
|
359
|
+
domain: () => this.domain,
|
|
360
|
+
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
361
|
+
})
|
|
362
|
+
this.accessController = new AccessController({
|
|
363
|
+
config: () => this.config,
|
|
364
|
+
store: this.accessStore,
|
|
365
|
+
channel: () => this.channel,
|
|
366
|
+
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
367
|
+
onDeniedGroup: msg => {
|
|
368
|
+
void this.accessRequests?.ensureGroupRequest(msg).catch((error: unknown) => {
|
|
369
|
+
this.ctx.logger?.warn?.('dsh-channels: group authorization request failed: %s', String(error))
|
|
370
|
+
})
|
|
371
|
+
},
|
|
372
|
+
onDeniedUser: msg => {
|
|
373
|
+
void this.accessRequests?.ensureUserRequest(msg).catch((error: unknown) => {
|
|
374
|
+
this.ctx.logger?.warn?.('dsh-channels: user authorization request failed: %s', String(error))
|
|
375
|
+
})
|
|
376
|
+
},
|
|
377
|
+
})
|
|
378
|
+
this.inbound = new InboundRouter({
|
|
379
|
+
ctx: this.ctx,
|
|
380
|
+
config: this.config,
|
|
381
|
+
started: () => this.started,
|
|
382
|
+
domain: () => this.domain,
|
|
383
|
+
routes: this.routes,
|
|
384
|
+
repair: this.repair,
|
|
385
|
+
delivery: this.delivery,
|
|
386
|
+
sessions: this.sessions,
|
|
387
|
+
attachments: this.attachments,
|
|
388
|
+
access: this.accessController,
|
|
389
|
+
queue: this.routeOperations,
|
|
390
|
+
streamer: () => this.streamer,
|
|
391
|
+
questionAnswerer: () => this.questionAnswerer,
|
|
392
|
+
commands: { handleSlash: (msg, line) => this.handleSlashMessage(msg, line) },
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ---- lifecycle / Web settings panel ----------------------------------
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Load the Web overlay, then open the channel. Startup errors are recorded
|
|
400
|
+
* for the settings panel and the partial service is torn down before the
|
|
401
|
+
* error is rethrown (the plugin fiber still fails the same way).
|
|
402
|
+
*/
|
|
403
|
+
async boot(): Promise<void> {
|
|
404
|
+
try {
|
|
405
|
+
await this.loadOverlay()
|
|
406
|
+
await this.start()
|
|
407
|
+
this.lastStartError = undefined
|
|
408
|
+
this.runningConfig = { ...this.config }
|
|
409
|
+
} catch (error) {
|
|
410
|
+
this.lastStartError = messageOf(error)
|
|
411
|
+
try {
|
|
412
|
+
await this.shutdown()
|
|
413
|
+
} catch {
|
|
414
|
+
// The original startup error is the useful one.
|
|
415
|
+
}
|
|
416
|
+
throw error
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Re-read the Web overlay over the profile config (no channel touch). */
|
|
421
|
+
private async loadOverlay(): Promise<void> {
|
|
422
|
+
const overlay = this.overlay === undefined ? {} : await this.overlay.load()
|
|
423
|
+
this.replaceConfig({ ...this.baseConfig, ...overlay })
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Replace every own key of the live config object. `Object.assign` alone
|
|
428
|
+
* would keep stale overlay-only keys (e.g. `provider`) when the next layer
|
|
429
|
+
* no longer defines them; collaborators hold this object identity, so the
|
|
430
|
+
* mutation must happen in place.
|
|
431
|
+
*/
|
|
432
|
+
private replaceConfig(next: FeishuConfig): void {
|
|
433
|
+
const live = this.config as unknown as Record<string, unknown>
|
|
434
|
+
for (const key of Object.keys(live)) delete live[key]
|
|
435
|
+
Object.assign(live, next)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Apply an already-validated overlay over the profile config. Fields read
|
|
440
|
+
* per event/turn take effect immediately; `RESTART_KEYS` need `restart()`.
|
|
441
|
+
*/
|
|
442
|
+
applyOverlay(overlay: Partial<FeishuConfig>): void {
|
|
443
|
+
this.replaceConfig({ ...this.baseConfig, ...overlay })
|
|
444
|
+
this.refreshRetentionTimer()
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Tear the connection down and bring it back with the latest config. */
|
|
448
|
+
async restart(): Promise<void> {
|
|
449
|
+
if (this.restarting) throw new Error('飞书连接正在重启,请稍候再试')
|
|
450
|
+
this.restarting = true
|
|
451
|
+
try {
|
|
452
|
+
await this.shutdown()
|
|
453
|
+
await this.loadOverlay()
|
|
454
|
+
await this.start()
|
|
455
|
+
this.lastStartError = undefined
|
|
456
|
+
this.runningConfig = { ...this.config }
|
|
457
|
+
} catch (error) {
|
|
458
|
+
this.lastStartError = messageOf(error)
|
|
459
|
+
// `start()` flips `started` before wiring; a failed reconnect must not
|
|
460
|
+
// leave the panel claiming a live connection.
|
|
461
|
+
try {
|
|
462
|
+
await this.shutdown()
|
|
463
|
+
} catch {
|
|
464
|
+
// The restart error is the useful one.
|
|
465
|
+
}
|
|
466
|
+
throw error
|
|
467
|
+
} finally {
|
|
468
|
+
this.restarting = false
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
isRunning(): boolean { return this.started }
|
|
473
|
+
isRestarting(): boolean { return this.restarting }
|
|
474
|
+
startedAtMs(): number | undefined { return this.startedAt }
|
|
475
|
+
uptimeMs(): number | undefined {
|
|
476
|
+
return this.startedAt === undefined || !this.started ? undefined : Math.max(0, Date.now() - this.startedAt)
|
|
477
|
+
}
|
|
478
|
+
startError(): string | undefined { return this.lastStartError }
|
|
479
|
+
configSnapshot(): FeishuConfig { return { ...this.config } }
|
|
480
|
+
baseConfigSnapshot(): FeishuConfig { return { ...this.baseConfig } }
|
|
481
|
+
runningConfigSnapshot(): FeishuConfig | undefined {
|
|
482
|
+
return this.runningConfig === undefined ? undefined : { ...this.runningConfig }
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ---- access-control panel surface ------------------------------------
|
|
486
|
+
|
|
487
|
+
/** Whitelists + pending authorization requests (read-only Web projection). */
|
|
488
|
+
accessSnapshot(): AccessWire {
|
|
489
|
+
const admin = this.config.adminOpenId
|
|
490
|
+
return {
|
|
491
|
+
mode: this.config.accessMode,
|
|
492
|
+
admin: {
|
|
493
|
+
...(admin === undefined ? {} : { openId: admin }),
|
|
494
|
+
configured: admin !== undefined,
|
|
495
|
+
},
|
|
496
|
+
groups: this.accessStore.groupRecords().map(record => ({
|
|
497
|
+
chatId: record.chatId,
|
|
498
|
+
...(record.name === undefined ? {} : { name: record.name }),
|
|
499
|
+
source: record.source,
|
|
500
|
+
addedAt: record.addedAt,
|
|
501
|
+
})),
|
|
502
|
+
users: this.accessStore.userRecords().map(record => ({
|
|
503
|
+
openId: record.openId,
|
|
504
|
+
...(record.name === undefined ? {} : { name: record.name }),
|
|
505
|
+
source: record.source,
|
|
506
|
+
addedAt: record.addedAt,
|
|
507
|
+
})),
|
|
508
|
+
requests: this.accessStore.pendingRequests().map(record => {
|
|
509
|
+
const wire: AccessRequestWire = {
|
|
510
|
+
token: record.token,
|
|
511
|
+
kind: record.kind,
|
|
512
|
+
targetId: record.targetId,
|
|
513
|
+
createdAt: record.createdAt,
|
|
514
|
+
expiresAt: record.expiresAt,
|
|
515
|
+
}
|
|
516
|
+
const name = record.kind === 'group' ? record.groupName : record.requesterName
|
|
517
|
+
if (name !== undefined) wire.name = name
|
|
518
|
+
if (record.chatId !== undefined) wire.chatId = record.chatId
|
|
519
|
+
if (record.groupName !== undefined) wire.groupName = record.groupName
|
|
520
|
+
const requester = record.requesterName ?? record.requesterOpenId
|
|
521
|
+
if (requester !== undefined) wire.requester = requester
|
|
522
|
+
return wire
|
|
523
|
+
}),
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Panel: authorize one group by id. */
|
|
528
|
+
async addAccessGroup(chatId: string, name?: string): Promise<AccessMutationResponse> {
|
|
529
|
+
const id = this.cleanAccessTargetId(chatId)
|
|
530
|
+
if (id === undefined) return { ok: false, error: 'chat_id 不合法(应为 oc_ 开头的群 ID)' }
|
|
531
|
+
const admin = this.config.adminOpenId
|
|
532
|
+
if (admin === undefined) return { ok: false, error: '请先在常用配置中设置管理员 open_id' }
|
|
533
|
+
try {
|
|
534
|
+
if (!this.accessStore.hasGroup(id)) {
|
|
535
|
+
await this.accessStore.addGroup({
|
|
536
|
+
chatId: id,
|
|
537
|
+
...(name === undefined || name.trim() === '' ? {} : { name: name.trim() }),
|
|
538
|
+
source: 'panel',
|
|
539
|
+
addedBy: admin,
|
|
540
|
+
addedAt: Date.now(),
|
|
541
|
+
})
|
|
542
|
+
}
|
|
543
|
+
return { ok: true, access: this.accessSnapshot() }
|
|
544
|
+
} catch (error) {
|
|
545
|
+
return { ok: false, error: `写入授权失败:${messageOf(error)}` }
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Panel: revoke one group. */
|
|
550
|
+
async removeAccessGroup(chatId: string): Promise<AccessMutationResponse> {
|
|
551
|
+
const id = this.cleanAccessTargetId(chatId)
|
|
552
|
+
if (id === undefined) return { ok: false, error: 'chat_id 不合法' }
|
|
553
|
+
try {
|
|
554
|
+
await this.accessStore.removeGroup(id)
|
|
555
|
+
return { ok: true, access: this.accessSnapshot() }
|
|
556
|
+
} catch (error) {
|
|
557
|
+
return { ok: false, error: `撤销授权失败:${messageOf(error)}` }
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Panel: authorize one user (global across authorized groups). */
|
|
562
|
+
async addAccessUser(openId: string, name?: string): Promise<AccessMutationResponse> {
|
|
563
|
+
const id = this.cleanAccessUserId(openId)
|
|
564
|
+
if (id === undefined) return { ok: false, error: 'open_id 不合法(应以 ou_ 开头)' }
|
|
565
|
+
const admin = this.config.adminOpenId
|
|
566
|
+
if (admin === undefined) return { ok: false, error: '请先在常用配置中设置管理员 open_id' }
|
|
567
|
+
try {
|
|
568
|
+
if (!this.accessStore.hasUser(id)) {
|
|
569
|
+
await this.accessStore.addUser({
|
|
570
|
+
openId: id,
|
|
571
|
+
...(name === undefined || name.trim() === '' ? {} : { name: name.trim() }),
|
|
572
|
+
source: 'panel',
|
|
573
|
+
addedBy: admin,
|
|
574
|
+
addedAt: Date.now(),
|
|
575
|
+
})
|
|
576
|
+
}
|
|
577
|
+
return { ok: true, access: this.accessSnapshot() }
|
|
578
|
+
} catch (error) {
|
|
579
|
+
return { ok: false, error: `写入授权失败:${messageOf(error)}` }
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Panel: revoke one user. */
|
|
584
|
+
async removeAccessUser(openId: string): Promise<AccessMutationResponse> {
|
|
585
|
+
const id = this.cleanAccessUserId(openId)
|
|
586
|
+
if (id === undefined) return { ok: false, error: 'open_id 不合法' }
|
|
587
|
+
try {
|
|
588
|
+
await this.accessStore.removeUser(id)
|
|
589
|
+
return { ok: true, access: this.accessSnapshot() }
|
|
590
|
+
} catch (error) {
|
|
591
|
+
return { ok: false, error: `撤销授权失败:${messageOf(error)}` }
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Panel: decide one pending authorization request (same path as the card). */
|
|
596
|
+
async resolveAccessRequest(token: string, decision: 'allow' | 'deny'): Promise<AccessMutationResponse> {
|
|
597
|
+
const requests = this.accessRequests
|
|
598
|
+
if (requests === undefined) return { ok: false, error: '授权服务尚未启动' }
|
|
599
|
+
const actor = this.config.adminOpenId
|
|
600
|
+
if (actor === undefined) return { ok: false, error: '请先配置管理员 open_id' }
|
|
601
|
+
try {
|
|
602
|
+
const result = await requests.resolve(token, decision, actor)
|
|
603
|
+
if (!result.ok) return { ok: false, error: result.error }
|
|
604
|
+
return { ok: true, access: this.accessSnapshot() }
|
|
605
|
+
} catch (error) {
|
|
606
|
+
return { ok: false, error: `处理授权申请失败:${messageOf(error)}` }
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Trim + bound one panel-supplied identifier; rejects control characters. */
|
|
611
|
+
private cleanAccessTargetId(raw: string): string | undefined {
|
|
612
|
+
const value = raw.trim()
|
|
613
|
+
if (value === '' || value.length > 128) return undefined
|
|
614
|
+
// eslint-disable-next-line no-control-regex
|
|
615
|
+
if (/[\u0000-\u001f\u007f]/u.test(value)) return undefined
|
|
616
|
+
return value
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Same normalization for user open_ids, which must look like ou_xxx. */
|
|
620
|
+
private cleanAccessUserId(raw: string): string | undefined {
|
|
621
|
+
const value = this.cleanAccessTargetId(raw)
|
|
622
|
+
if (value === undefined || !/^ou_[A-Za-z0-9_-]+$/u.test(value)) return undefined
|
|
623
|
+
return value
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ---- workspace retention cleanup -------------------------------------
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Workspace-retention preview for the Web panel: how many plugin-created
|
|
630
|
+
* workspaces exist, how many are past the configured window, and their
|
|
631
|
+
* measured footprint. Never deletes anything.
|
|
632
|
+
*/
|
|
633
|
+
async cleanupPreview(): Promise<{
|
|
634
|
+
retention: string
|
|
635
|
+
thresholdMs?: number
|
|
636
|
+
auto: boolean
|
|
637
|
+
workspaces: number
|
|
638
|
+
candidates: number
|
|
639
|
+
candidateBytes: number
|
|
640
|
+
error?: string
|
|
641
|
+
}> {
|
|
642
|
+
const retention = this.config.workspaceRetention
|
|
643
|
+
const window = retentionMs(retention)
|
|
644
|
+
const base = {
|
|
645
|
+
retention,
|
|
646
|
+
...(window === undefined ? {} : { thresholdMs: window }),
|
|
647
|
+
auto: this.config.workspaceRetentionAuto,
|
|
648
|
+
}
|
|
649
|
+
try {
|
|
650
|
+
const workspaces = this.ctx.workspaceRegistry.list().filter(workspace => this.isPluginWorkspace(workspace))
|
|
651
|
+
const plan = await planWorkspaceCleanup(this.cleanupDeps())
|
|
652
|
+
return {
|
|
653
|
+
...base,
|
|
654
|
+
workspaces: workspaces.length,
|
|
655
|
+
candidates: plan?.candidates.length ?? 0,
|
|
656
|
+
candidateBytes: plan?.candidates.reduce((total, candidate) => total + candidate.bytes, 0) ?? 0,
|
|
657
|
+
}
|
|
658
|
+
} catch (error) {
|
|
659
|
+
return { ...base, workspaces: 0, candidates: 0, candidateBytes: 0, error: messageOf(error) }
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Delete every stale plugin workspace the configured retention allows.
|
|
665
|
+
* Routes bound to a removed workspace are retired first (thread routes leave
|
|
666
|
+
* a tombstone), so an old topic fails with the explicit expired card instead
|
|
667
|
+
* of resuming into a missing directory. DSH Session logs are not touched.
|
|
668
|
+
*/
|
|
669
|
+
async runWorkspaceCleanup(): Promise<{
|
|
670
|
+
ok: boolean
|
|
671
|
+
deletedWorkspaces: number
|
|
672
|
+
retiredRoutes: number
|
|
673
|
+
freedBytes: number
|
|
674
|
+
failures: string[]
|
|
675
|
+
error?: string
|
|
676
|
+
}> {
|
|
677
|
+
const empty = { ok: false, deletedWorkspaces: 0, retiredRoutes: 0, freedBytes: 0, failures: [] as string[] }
|
|
678
|
+
if (retentionMs(this.config.workspaceRetention) === undefined) {
|
|
679
|
+
return { ...empty, error: '未启用工作区清理阈值(workspaceRetention 为 off)' }
|
|
680
|
+
}
|
|
681
|
+
if (this.cleanupRunning) return { ...empty, error: '工作区清理正在进行中,请稍候' }
|
|
682
|
+
this.cleanupRunning = true
|
|
683
|
+
try {
|
|
684
|
+
const deps = this.cleanupDeps()
|
|
685
|
+
const plan = await planWorkspaceCleanup(deps)
|
|
686
|
+
if (plan === undefined) return { ...empty, error: '工作区清理不可用:baseDir 配置无效' }
|
|
687
|
+
const outcome = await applyWorkspaceCleanup(deps, plan)
|
|
688
|
+
this.ctx.logger?.info?.(
|
|
689
|
+
'dsh-channels: workspace cleanup removed %d workspace(s), retired %d route(s), freed %d bytes',
|
|
690
|
+
outcome.deletedWorkspaces, outcome.retiredRoutes, outcome.freedBytes,
|
|
691
|
+
)
|
|
692
|
+
return {
|
|
693
|
+
ok: true,
|
|
694
|
+
deletedWorkspaces: outcome.deletedWorkspaces,
|
|
695
|
+
retiredRoutes: outcome.retiredRoutes,
|
|
696
|
+
freedBytes: outcome.freedBytes,
|
|
697
|
+
failures: outcome.failures.map(failure => `${failure.path}: ${failure.message}`),
|
|
698
|
+
}
|
|
699
|
+
} catch (error) {
|
|
700
|
+
return { ...empty, error: messageOf(error) }
|
|
701
|
+
} finally {
|
|
702
|
+
this.cleanupRunning = false
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Whether one registry workspace was created by this plugin inside `baseDir`. */
|
|
707
|
+
private isPluginWorkspace(workspace: Workspace): boolean {
|
|
708
|
+
const pluginTitle = workspace.title.startsWith('飞书私聊 ·') || workspace.title.startsWith('飞书群聊 ·')
|
|
709
|
+
return pluginTitle && isPathInside(path.resolve(this.config.baseDir), workspace.path)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Effect seams for the cleanup policy module (all I/O stays here). */
|
|
713
|
+
private cleanupDeps(): CleanupDeps {
|
|
714
|
+
const baseDir = path.resolve(this.config.baseDir)
|
|
715
|
+
return {
|
|
716
|
+
baseDir,
|
|
717
|
+
retention: this.config.workspaceRetention,
|
|
718
|
+
listWorkspaces: () => this.ctx.workspaceRegistry.list(),
|
|
719
|
+
activeRoutes: () => [...(this.domain?.routes.entries() ?? [])].map(([key, record]) => ({
|
|
720
|
+
key,
|
|
721
|
+
kind: record.kind,
|
|
722
|
+
workspacePath: record.workspacePath,
|
|
723
|
+
sessionId: record.sessionId,
|
|
724
|
+
lastActivityAt: record.lastActivityAt,
|
|
725
|
+
})),
|
|
726
|
+
tombstones: () => [...(this.domain?.expiredRoutes.entries() ?? [])].map(([key, record]) => ({
|
|
727
|
+
key,
|
|
728
|
+
...(record.workspacePath === undefined ? {} : { workspacePath: record.workspacePath }),
|
|
729
|
+
expiredAt: record.expiredAt,
|
|
730
|
+
...(record.lastActivityAt === undefined ? {} : { lastActivityAt: record.lastActivityAt }),
|
|
731
|
+
})),
|
|
732
|
+
isLive: sessionId => this.sessions.get(sessionId) !== undefined
|
|
733
|
+
|| this.ctx.agents.get(sessionId as SessionId) !== undefined,
|
|
734
|
+
statWorkspace: workspacePath => measureWorkspace(workspacePath),
|
|
735
|
+
removeWorkspace: async workspace => {
|
|
736
|
+
const target = path.resolve(workspace.path)
|
|
737
|
+
if (!isPathInside(baseDir, target)) {
|
|
738
|
+
throw new Error(`refusing to delete a path outside baseDir (${target})`)
|
|
739
|
+
}
|
|
740
|
+
// Re-verify the physical path: a symlinked ancestor inside baseDir
|
|
741
|
+
// must not let the recursive delete escape it.
|
|
742
|
+
try {
|
|
743
|
+
const [physicalBase, physicalTarget] = await Promise.all([realpath(baseDir), realpath(target)])
|
|
744
|
+
if (!isPathInside(physicalBase, physicalTarget)) {
|
|
745
|
+
throw new Error(`refusing to delete a symlink escape outside baseDir (${physicalTarget})`)
|
|
746
|
+
}
|
|
747
|
+
} catch (error) {
|
|
748
|
+
if (error instanceof Error && error.message.startsWith('refusing to delete a symlink escape')) throw error
|
|
749
|
+
// A missing workspace is already effectively removed (rm is idempotent).
|
|
750
|
+
}
|
|
751
|
+
await rm(target, { recursive: true, force: true })
|
|
752
|
+
try {
|
|
753
|
+
await this.ctx.workspaceRegistry.delete(workspace.id)
|
|
754
|
+
} catch (error) {
|
|
755
|
+
this.ctx.logger?.warn?.(
|
|
756
|
+
'dsh-channels: unregistering cleaned workspace %s failed: %s', workspace.path, String(error),
|
|
757
|
+
)
|
|
758
|
+
}
|
|
759
|
+
},
|
|
760
|
+
retireRoute: async route => {
|
|
761
|
+
if (route.kind === 'thread') await this.routes.retire(route.key)
|
|
762
|
+
else await this.routes.remove(route.key)
|
|
763
|
+
},
|
|
764
|
+
warn: message => this.ctx.logger?.warn?.(message),
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* (Re)arm the daily retention timer to match the live config. `runNow`
|
|
770
|
+
* performs one pass immediately; it is only used at startup so saving any
|
|
771
|
+
* unrelated setting can never trigger a surprise deletion pass.
|
|
772
|
+
*/
|
|
773
|
+
private refreshRetentionTimer(runNow = false): void {
|
|
774
|
+
if (this.retentionTimer !== undefined) {
|
|
775
|
+
clearInterval(this.retentionTimer)
|
|
776
|
+
this.retentionTimer = undefined
|
|
777
|
+
}
|
|
778
|
+
if (!this.started
|
|
779
|
+
|| !this.config.workspaceRetentionAuto
|
|
780
|
+
|| retentionMs(this.config.workspaceRetention) === undefined) return
|
|
781
|
+
const sweep = (): void => {
|
|
782
|
+
void this.runWorkspaceCleanup().then(result => {
|
|
783
|
+
if (!result.ok && result.error !== '工作区清理正在进行中,请稍候') {
|
|
784
|
+
this.ctx.logger?.warn?.('dsh-channels: automatic workspace cleanup failed: %s', result.error)
|
|
785
|
+
}
|
|
786
|
+
})
|
|
787
|
+
}
|
|
788
|
+
if (runNow) sweep()
|
|
789
|
+
const timer = setInterval(sweep, RETENTION_SWEEP_INTERVAL_MS)
|
|
790
|
+
timer.unref?.()
|
|
791
|
+
this.retentionTimer = timer
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Aggregate health used by `/health` and the Web settings panel. */
|
|
795
|
+
health(): HealthSnapshot { return this.healthSnapshot() }
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Credential facts for the two Feishu refs. Values are never returned:
|
|
799
|
+
* `describe()` reports presence/source/writability only.
|
|
800
|
+
*/
|
|
801
|
+
async credentialStates(): Promise<{ appId: CredentialInfo; appSecret: CredentialInfo }> {
|
|
802
|
+
const provider = this.credentialProvider()
|
|
803
|
+
const read = async (ref: string): Promise<CredentialInfo> => {
|
|
804
|
+
try {
|
|
805
|
+
if (provider?.describe !== undefined) {
|
|
806
|
+
const info = await provider.describe(ref)
|
|
807
|
+
return {
|
|
808
|
+
configured: info?.configured === true,
|
|
809
|
+
writable: info?.writable !== false && provider.set !== undefined,
|
|
810
|
+
...(info?.source === undefined ? {} : { source: info.source }),
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
if (provider === undefined) {
|
|
814
|
+
const ambient = process.env[ref]
|
|
815
|
+
return { configured: ambient !== undefined && ambient !== '', writable: false }
|
|
816
|
+
}
|
|
817
|
+
return { configured: (await provider.resolve(ref)) !== undefined, writable: provider.set !== undefined }
|
|
818
|
+
} catch {
|
|
819
|
+
return { configured: false, writable: false }
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
const [appId, appSecret] = await Promise.all([read(this.config.appId), read(this.config.appSecret)])
|
|
823
|
+
return { appId, appSecret }
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** The host credentials seam, when mounted. */
|
|
827
|
+
private credentialProvider(): CredentialProvider | undefined {
|
|
828
|
+
return this.ctx.get?.('credentials') as CredentialProvider | undefined
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Store one Feishu credential value through the host seam; the value is never logged. */
|
|
832
|
+
async setCredential(field: 'appId' | 'appSecret', value: string): Promise<void> {
|
|
833
|
+
const provider = this.credentialProvider()
|
|
834
|
+
if (provider?.set === undefined) {
|
|
835
|
+
throw new Error('主机的凭据服务不可写(未挂载 credentials.set,或当前提供方只读)')
|
|
836
|
+
}
|
|
837
|
+
const ref = this.credentialRef(field)
|
|
838
|
+
try {
|
|
839
|
+
await provider.set(ref, value)
|
|
840
|
+
} catch (error) {
|
|
841
|
+
throw new Error(`写入凭据引用 ${ref} 失败:${messageOf(error)}`)
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** Remove the stored value of one Feishu credential reference. */
|
|
846
|
+
async clearCredential(field: 'appId' | 'appSecret'): Promise<void> {
|
|
847
|
+
const provider = this.credentialProvider()
|
|
848
|
+
if (provider?.unset === undefined) {
|
|
849
|
+
throw new Error('主机的凭据服务不支持移除凭据')
|
|
850
|
+
}
|
|
851
|
+
const ref = this.credentialRef(field)
|
|
852
|
+
try {
|
|
853
|
+
await provider.unset(ref)
|
|
854
|
+
} catch (error) {
|
|
855
|
+
throw new Error(`移除凭据引用 ${ref} 失败:${messageOf(error)}`)
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** The overlay-independent ref name backing one editable credential slot. */
|
|
860
|
+
private credentialRef(field: 'appId' | 'appSecret'): string {
|
|
861
|
+
return field === 'appId' ? this.config.appId : this.config.appSecret
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** Wire events and open the long connection. */
|
|
865
|
+
async start(): Promise<void> {
|
|
866
|
+
if (this.started) return
|
|
867
|
+
this.started = true
|
|
868
|
+
|
|
869
|
+
// 1. Resolve credentials through the host seam (env > .credentials.yaml > .env).
|
|
870
|
+
const appId = await resolveCredentialRef(this.ctx, this.config.appId)
|
|
871
|
+
const appSecret = await resolveCredentialRef(this.ctx, this.config.appSecret)
|
|
872
|
+
|
|
873
|
+
// 2. Build the channel with the resolved secrets.
|
|
874
|
+
const channel = this.channelFactory({
|
|
875
|
+
appId,
|
|
876
|
+
appSecret,
|
|
877
|
+
loggerLevel: this.config.loggerLevel,
|
|
878
|
+
requireMention: this.config.requireMention,
|
|
879
|
+
dmMode: this.config.dmMode,
|
|
880
|
+
})
|
|
881
|
+
this.channel = channel
|
|
882
|
+
const hooks: StreamerHooks = {
|
|
883
|
+
measureContext: session => this.measureContext(session),
|
|
884
|
+
projectStats: session => this.projectCardStats(session),
|
|
885
|
+
sessionLabel: sessionId => sessionId,
|
|
886
|
+
onError: (_key, error) => {
|
|
887
|
+
this.ctx.logger?.warn?.('dsh-channels stream error: %s', String(error))
|
|
888
|
+
},
|
|
889
|
+
onTurnFinalized: (_sessionId, _turn, mainCardMessageId) => {
|
|
890
|
+
// The turn settled (final patches fired, reaction swap requested):
|
|
891
|
+
// retire its repair snapshot. Kept deliberately non-awaited — repair
|
|
892
|
+
// state is a safety net, never a dependency of the turn lifecycle.
|
|
893
|
+
void this.repair.clear(mainCardMessageId)
|
|
894
|
+
},
|
|
895
|
+
onSegmentDelivered: (mainCardMessageId, segmentIndex, messageId, mode) => {
|
|
896
|
+
void this.repair.recordSegment(mainCardMessageId, segmentIndex, messageId, mode)
|
|
897
|
+
},
|
|
898
|
+
sendSegmentCard: (sessionId, segment, card, fallbackText) =>
|
|
899
|
+
this.delivery.segmentCard(sessionId, segment, card, fallbackText),
|
|
900
|
+
}
|
|
901
|
+
this.streamer = new TurnStreamer(
|
|
902
|
+
channel,
|
|
903
|
+
this.config.refreshMs,
|
|
904
|
+
hooks,
|
|
905
|
+
this.config.statusRefreshMs,
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
this.domain = await openFeishuDomain(this.ctx)
|
|
909
|
+
this.routes.load()
|
|
910
|
+
this.accessStore.load()
|
|
911
|
+
|
|
912
|
+
// HITL: answer the host's approval/request waterfall for sessions this
|
|
913
|
+
// channel routes. `prepend` claims such requests BEFORE the web gateway's
|
|
914
|
+
// generic answerer (its panel would be invisible to a Feishu user), while
|
|
915
|
+
// non-owned sessions delegate through `next()`.
|
|
916
|
+
const answerer = new ApprovalAnswerer(channel, {
|
|
917
|
+
routeFor: sessionId => this.approvalRouteFor(sessionId),
|
|
918
|
+
onError: (sessionId, error) => {
|
|
919
|
+
this.ctx.logger?.warn?.('dsh-channels approval error (session %s): %s', sessionId, String(error))
|
|
920
|
+
},
|
|
921
|
+
}, this.config.approvalTimeoutMs)
|
|
922
|
+
this.approvalAnswerer = answerer
|
|
923
|
+
this.unlistenApproval = this.ctx.on('approval/request', (req, next) => answerer.onRequest(req, next), { prepend: true })
|
|
924
|
+
// Structured questions (`ask_user_question`): the host's only shipped
|
|
925
|
+
// answerer is the Web client, so a Feishu-only session would otherwise
|
|
926
|
+
// fail every ask with NO_PROVIDER.
|
|
927
|
+
const questions = new QuestionAnswerer(channel, {
|
|
928
|
+
routeFor: sessionId => this.approvalRouteFor(sessionId),
|
|
929
|
+
onError: (sessionId, error) => {
|
|
930
|
+
this.ctx.logger?.warn?.('dsh-channels question error (session %s): %s', sessionId, String(error))
|
|
931
|
+
},
|
|
932
|
+
}, this.config.questionTimeoutMs)
|
|
933
|
+
this.questionAnswerer = questions
|
|
934
|
+
this.unlistenQuestions = this.ctx.on(
|
|
935
|
+
'user-questions/request',
|
|
936
|
+
(request, next) => questions.onRequest(request, next),
|
|
937
|
+
{ prepend: true },
|
|
938
|
+
)
|
|
939
|
+
const accessRequests = new AccessRequests({
|
|
940
|
+
config: () => this.config,
|
|
941
|
+
store: this.accessStore,
|
|
942
|
+
channel: () => this.channel,
|
|
943
|
+
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
944
|
+
})
|
|
945
|
+
this.accessRequests = accessRequests
|
|
946
|
+
this.unlistenBotAdded = channel.onBotAdded(event => {
|
|
947
|
+
void accessRequests.handleBotAdded(event).catch((error: unknown) => {
|
|
948
|
+
this.ctx.logger?.warn?.('dsh-channels: bot-added group info card failed: %s', String(error))
|
|
949
|
+
})
|
|
950
|
+
})
|
|
951
|
+
void accessRequests.expirePending().catch((error: unknown) => {
|
|
952
|
+
this.ctx.logger?.warn?.('dsh-channels: access request expiry sweep failed: %s', String(error))
|
|
953
|
+
})
|
|
954
|
+
this.unlistenCardAction = channel.onCardAction(event => {
|
|
955
|
+
void accessRequests.onCardAction(event)
|
|
956
|
+
answerer.onCardAction(event)
|
|
957
|
+
questions.onCardAction(event)
|
|
958
|
+
})
|
|
959
|
+
|
|
960
|
+
this.ctx.on('session/event', (session, event) => {
|
|
961
|
+
this.streamer?.onSessionEvent(session, event)
|
|
962
|
+
})
|
|
963
|
+
// DSH removed the durable `assistant/chunk` event (format v2 embeds the
|
|
964
|
+
// stream in the settlement); the live deltas arrive here instead.
|
|
965
|
+
this.ctx.on('agent/assistant-stream', ({ agent, frame }) => {
|
|
966
|
+
this.streamer?.onAssistantFrame(agent.session, frame)
|
|
967
|
+
})
|
|
968
|
+
this.unlisten = channel.onMessage(message => {
|
|
969
|
+
void this.inbound.handle(message).catch((error: unknown) => {
|
|
970
|
+
this.ctx.logger?.warn?.('dsh-channels inbound error: %s', String(error))
|
|
971
|
+
// User-facing safety net: never leave the user without a reply.
|
|
972
|
+
void channel
|
|
973
|
+
.send(message.chatId, { text: `dsh-channels 处理失败:${String(error)}` }, { replyTo: message.messageId })
|
|
974
|
+
.catch(() => {})
|
|
975
|
+
})
|
|
976
|
+
})
|
|
977
|
+
await channel.connect()
|
|
978
|
+
// Repair cards a previous instance left mid-flight (best-effort, background).
|
|
979
|
+
void this.repair.repairAll().catch((error: unknown) => {
|
|
980
|
+
this.ctx.logger?.warn?.('dsh-channels: inflight repair failed: %s', String(error))
|
|
981
|
+
})
|
|
982
|
+
// Record GC: one sweep right after boot, then a slow periodic sweep.
|
|
983
|
+
// Dedup records are pruned unconditionally (fixed retention); route
|
|
984
|
+
// records of deleted topics (their thread id never reappears) are pruned
|
|
985
|
+
// when `routeTtlMs` is enabled.
|
|
986
|
+
void this.gcExpiredEntries().catch((error: unknown) => {
|
|
987
|
+
this.ctx.logger?.warn?.('dsh-channels: record GC failed: %s', String(error))
|
|
988
|
+
})
|
|
989
|
+
const timer = setInterval(() => {
|
|
990
|
+
void this.gcExpiredEntries().catch(() => {})
|
|
991
|
+
}, GC_SWEEP_INTERVAL_MS)
|
|
992
|
+
timer.unref?.()
|
|
993
|
+
this.gcTimer = timer
|
|
994
|
+
// Workspace retention: opt-in automatic cleanup (default off).
|
|
995
|
+
this.refreshRetentionTimer(true)
|
|
996
|
+
this.ctx.logger?.info?.(
|
|
997
|
+
'dsh-channels: feishu long connection established (bot open_id %s, %d route(s) loaded)',
|
|
998
|
+
channel.botOpenId() ?? 'unknown',
|
|
999
|
+
this.routes.size(),
|
|
1000
|
+
)
|
|
1001
|
+
console.info('[dsh-channels] feishu long connection established, bot open_id:', channel.botOpenId())
|
|
1002
|
+
console.info('[dsh-channels] activity build:', ACTIVITY_BUILD_MARKER)
|
|
1003
|
+
this.startedAt = Date.now()
|
|
1004
|
+
console.info('[dsh-channels] model request overrides:', {
|
|
1005
|
+
provider: this.config.provider ?? 'deployment-default',
|
|
1006
|
+
model: this.config.model ?? 'deployment-default',
|
|
1007
|
+
reasoningEffort: this.config.reasoningEffort ?? 'adapter-default',
|
|
1008
|
+
})
|
|
1009
|
+
if (this.config.accessMode === 'open') {
|
|
1010
|
+
console.warn('[dsh-channels] 准入=open:任何应用可用范围内的用户都可以对话(不安全,仅调试)')
|
|
1011
|
+
} else if (this.config.adminOpenId === undefined) {
|
|
1012
|
+
console.warn(
|
|
1013
|
+
'[dsh-channels] 准入=guarded 但未配置管理员 open_id:所有对话都会被拒绝;'
|
|
1014
|
+
+ '私聊机器人发送 /userid 取得 open_id 后填入 Web 设置面板',
|
|
1015
|
+
)
|
|
1016
|
+
} else {
|
|
1017
|
+
console.info('[dsh-channels] 准入=guarded,管理员已配置')
|
|
1018
|
+
}
|
|
1019
|
+
const approvalMounted = this.ctx.get?.('approval') !== undefined
|
|
1020
|
+
console.info(
|
|
1021
|
+
approvalMounted
|
|
1022
|
+
? `[dsh-channels] HITL 审批应答已就绪(决策卡片超时 ${this.config.approvalTimeoutMs} ms)`
|
|
1023
|
+
: '[dsh-channels] 主机未挂载 approval 服务,HITL 审批应答关闭',
|
|
1024
|
+
)
|
|
1025
|
+
const questionsMounted = this.ctx.get?.('userQuestions') !== undefined
|
|
1026
|
+
console.info(
|
|
1027
|
+
questionsMounted
|
|
1028
|
+
? `[dsh-channels] ask_user_question 应答已就绪(提问卡片超时 ${this.config.questionTimeoutMs} ms)`
|
|
1029
|
+
: '[dsh-channels] 主机未挂载 userQuestions 服务,结构化提问应答关闭',
|
|
1030
|
+
)
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** Tear down: close the connection and dispose all live agents. */
|
|
1034
|
+
async shutdown(): Promise<void> {
|
|
1035
|
+
// Close admission first. In-flight Host commands receive cancellation
|
|
1036
|
+
// before their Agent/channel/storage dependencies are disposed, while
|
|
1037
|
+
// already queued route operations observe `started === false` and retire.
|
|
1038
|
+
this.started = false
|
|
1039
|
+
for (const controller of this.commandControllers) controller.abort()
|
|
1040
|
+
await Promise.allSettled([...this.commandOperations])
|
|
1041
|
+
this.commandControllers.clear()
|
|
1042
|
+
this.commandOperations.clear()
|
|
1043
|
+
this.unlisten?.()
|
|
1044
|
+
this.unlisten = undefined
|
|
1045
|
+
this.unlistenCardAction?.()
|
|
1046
|
+
this.unlistenCardAction = undefined
|
|
1047
|
+
this.unlistenBotAdded?.()
|
|
1048
|
+
this.unlistenBotAdded = undefined
|
|
1049
|
+
this.accessRequests?.clear()
|
|
1050
|
+
this.accessRequests = undefined
|
|
1051
|
+
this.accessController.clear()
|
|
1052
|
+
this.unlistenApproval?.()
|
|
1053
|
+
this.unlistenApproval = undefined
|
|
1054
|
+
this.unlistenQuestions?.()
|
|
1055
|
+
this.unlistenQuestions = undefined
|
|
1056
|
+
// Withdraw in-flight approval questions (the host settles them cancelled).
|
|
1057
|
+
this.approvalAnswerer?.dispose()
|
|
1058
|
+
this.approvalAnswerer = undefined
|
|
1059
|
+
this.questionAnswerer?.dispose()
|
|
1060
|
+
this.questionAnswerer = undefined
|
|
1061
|
+
if (this.gcTimer !== undefined) {
|
|
1062
|
+
clearInterval(this.gcTimer)
|
|
1063
|
+
this.gcTimer = undefined
|
|
1064
|
+
}
|
|
1065
|
+
if (this.retentionTimer !== undefined) {
|
|
1066
|
+
clearInterval(this.retentionTimer)
|
|
1067
|
+
this.retentionTimer = undefined
|
|
1068
|
+
}
|
|
1069
|
+
await this.sessions.disposeAll()
|
|
1070
|
+
this.routeOperations.clear()
|
|
1071
|
+
this.inbound.clear()
|
|
1072
|
+
this.routes.clear()
|
|
1073
|
+
this.accessStore.clear()
|
|
1074
|
+
try {
|
|
1075
|
+
await this.channel?.disconnect()
|
|
1076
|
+
} catch {
|
|
1077
|
+
// Already disconnected is fine.
|
|
1078
|
+
}
|
|
1079
|
+
this.channel = undefined
|
|
1080
|
+
this.streamer = undefined
|
|
1081
|
+
await this.domain?.close()
|
|
1082
|
+
this.domain = undefined
|
|
1083
|
+
this.runningConfig = undefined
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// ---- inbound routing -------------------------------------------------
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
/** Slash-command plane. Nothing in this method calls `agent.followup()`. */
|
|
1090
|
+
/**
|
|
1091
|
+
* Slash-command admission. The command vocabulary and its admission rules
|
|
1092
|
+
* come from the command registry; nothing here calls `agent.followup()`.
|
|
1093
|
+
*/
|
|
1094
|
+
private async handleSlashMessage(msg: NormalizedMessage, line: string): Promise<void> {
|
|
1095
|
+
if (!this.started) return
|
|
1096
|
+
const parsed = parseSlashCommand(line)
|
|
1097
|
+
// Channel-plane command admission runs before ANY card/topic/side effect:
|
|
1098
|
+
// denied senders get at most a fixed private-chat notice (or silence in
|
|
1099
|
+
// groups), never an unknown-command card or a command thread.
|
|
1100
|
+
if (!(await this.accessController.admitCommand(msg, parsed?.name))) return
|
|
1101
|
+
if (parsed === undefined) {
|
|
1102
|
+
await this.delivery.commandCard(msg, {
|
|
1103
|
+
kind: 'input-error',
|
|
1104
|
+
text: '命令名必须以小写字母开头,且只能包含小写字母、数字、下划线或连字符。',
|
|
1105
|
+
supported: supportedCommandList(),
|
|
1106
|
+
})
|
|
1107
|
+
return
|
|
1108
|
+
}
|
|
1109
|
+
const spec = commandSpec(parsed.name)
|
|
1110
|
+
if (spec === undefined) {
|
|
1111
|
+
await this.delivery.commandCard(msg, {
|
|
1112
|
+
kind: 'unknown', command: parsed.name,
|
|
1113
|
+
text: `未找到命令 /${parsed.name}。`, supported: supportedCommandList(),
|
|
1114
|
+
})
|
|
1115
|
+
return
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// `/auth` is administrator-only, route-free, and handled right here so it
|
|
1119
|
+
// works even inside an unauthorized group (that is the "authorize from
|
|
1120
|
+
// the conversation" entry point).
|
|
1121
|
+
if (spec.name === 'auth') {
|
|
1122
|
+
await this.runAuthCommand(msg, parsed)
|
|
1123
|
+
return
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Route-free commands answer without any Session work.
|
|
1127
|
+
if (spec.routeFree === true) {
|
|
1128
|
+
await this.runRouteFreeCommand(spec, msg, parsed)
|
|
1129
|
+
return
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// A TTL-retired topic is permanently closed except for session-optional
|
|
1133
|
+
// diagnostics (`/health`).
|
|
1134
|
+
const expiredRoute = msg.threadId === undefined ? undefined : this.routes.expiredRoute(msg.threadId)
|
|
1135
|
+
if (expiredRoute !== undefined) {
|
|
1136
|
+
if (spec.sessionOptional === true) {
|
|
1137
|
+
await this.runRouteFreeCommand(spec, msg, parsed)
|
|
1138
|
+
return
|
|
1139
|
+
}
|
|
1140
|
+
await this.delivery.expiredRouteCard(msg, parsed.name)
|
|
1141
|
+
return
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
const route = await this.inbound.resolveRoute(msg)
|
|
1145
|
+
if (route === undefined) {
|
|
1146
|
+
if (spec.sessionOptional === true) {
|
|
1147
|
+
await this.runRouteFreeCommand(spec, msg, parsed)
|
|
1148
|
+
return
|
|
1149
|
+
}
|
|
1150
|
+
await this.delivery.commandCard(msg, {
|
|
1151
|
+
kind: 'unbound', command: parsed.name,
|
|
1152
|
+
text: '当前话题尚未绑定 Session。请先发送普通消息创建会话,或使用 /health 检查通道。',
|
|
1153
|
+
})
|
|
1154
|
+
return
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (msg.senderId !== route.senderOpenId) {
|
|
1158
|
+
await this.delivery.commandCard(msg, {
|
|
1159
|
+
kind: 'forbidden', command: parsed.name, text: '无权操作此会话。',
|
|
1160
|
+
})
|
|
1161
|
+
return
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
const operation = this.routeOperations.run(route.key, async () => {
|
|
1165
|
+
if (!this.started) return
|
|
1166
|
+
const current = this.routes.get(route.key) ?? route
|
|
1167
|
+
if (msg.senderId !== current.senderOpenId) {
|
|
1168
|
+
await this.delivery.commandCard(msg, {
|
|
1169
|
+
kind: 'forbidden', command: parsed.name, text: '无权操作此会话。',
|
|
1170
|
+
})
|
|
1171
|
+
return
|
|
1172
|
+
}
|
|
1173
|
+
await this.runRouteCommand(spec, msg, parsed, current)
|
|
1174
|
+
})
|
|
1175
|
+
this.commandOperations.add(operation)
|
|
1176
|
+
try {
|
|
1177
|
+
await operation
|
|
1178
|
+
} finally {
|
|
1179
|
+
this.commandOperations.delete(operation)
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* `/auth`: administrator-only access management on the channel plane. It
|
|
1185
|
+
* never creates a Session or resumes an Agent; every reply is a fixed card.
|
|
1186
|
+
*/
|
|
1187
|
+
private async runAuthCommand(msg: NormalizedMessage, parsed: ParsedSlashCommand): Promise<void> {
|
|
1188
|
+
if (!this.accessController.isAdmin(msg.senderId)) return
|
|
1189
|
+
const direct = { createThread: false }
|
|
1190
|
+
const words = parsed.rawInput.trim().split(/\s+/u).filter(word => word !== '')
|
|
1191
|
+
const action = words[0] ?? 'status'
|
|
1192
|
+
const now = Date.now()
|
|
1193
|
+
try {
|
|
1194
|
+
if (action === 'status') {
|
|
1195
|
+
const groups = this.accessStore.groupRecords()
|
|
1196
|
+
const users = this.accessStore.userRecords()
|
|
1197
|
+
const pending = this.accessStore.pendingRequests()
|
|
1198
|
+
await this.delivery.standaloneReply(msg, standaloneInfoCard('**🔐 访问授权状态**', [
|
|
1199
|
+
`**准入模式**:\`${this.config.accessMode}\``,
|
|
1200
|
+
`**管理员**:${this.config.adminOpenId === undefined ? '未配置' : `\`${this.config.adminOpenId}\``}`,
|
|
1201
|
+
...(msg.chatType === 'group'
|
|
1202
|
+
? [`**本群**:${this.accessStore.hasGroup(msg.chatId) ? '已授权' : '未授权'}(\`${msg.chatId}\`)`]
|
|
1203
|
+
: []),
|
|
1204
|
+
`**白名单**:群 ${groups.length} 个 · 用户 ${users.length} 个 · 待处理申请 ${pending.length} 个`,
|
|
1205
|
+
'群授权:`/auth allow` 或 `/auth revoke`(在目标群内发);用户授权:`/auth user add <open_id>`。',
|
|
1206
|
+
]), direct)
|
|
1207
|
+
return
|
|
1208
|
+
}
|
|
1209
|
+
if (action === 'allow' || action === 'revoke') {
|
|
1210
|
+
if (msg.chatType !== 'group') {
|
|
1211
|
+
await this.delivery.commandCard(msg, {
|
|
1212
|
+
kind: 'input-error', command: 'auth',
|
|
1213
|
+
text: '群授权命令只能在群聊中使用:在目标群里发送 /auth allow 或 /auth revoke。',
|
|
1214
|
+
}, direct)
|
|
1215
|
+
return
|
|
1216
|
+
}
|
|
1217
|
+
if (action === 'allow') {
|
|
1218
|
+
if (this.accessStore.hasGroup(msg.chatId)) {
|
|
1219
|
+
await this.delivery.commandCard(msg, {
|
|
1220
|
+
kind: 'success', command: 'auth', text: `本群已经授权(\`${msg.chatId}\`)。`,
|
|
1221
|
+
}, direct)
|
|
1222
|
+
return
|
|
1223
|
+
}
|
|
1224
|
+
let name: string | undefined
|
|
1225
|
+
try {
|
|
1226
|
+
name = (await this.channel?.getChatInfo(msg.chatId))?.name
|
|
1227
|
+
} catch {
|
|
1228
|
+
name = undefined
|
|
1229
|
+
}
|
|
1230
|
+
await this.accessStore.addGroup({
|
|
1231
|
+
chatId: msg.chatId,
|
|
1232
|
+
...(name === undefined || name === '' ? {} : { name }),
|
|
1233
|
+
source: 'command',
|
|
1234
|
+
addedBy: msg.senderId,
|
|
1235
|
+
addedAt: now,
|
|
1236
|
+
})
|
|
1237
|
+
await this.delivery.commandCard(msg, {
|
|
1238
|
+
kind: 'success', command: 'auth',
|
|
1239
|
+
text: '✅ 已授权本群。群内只有管理员与已授权用户能与我对话。',
|
|
1240
|
+
}, direct)
|
|
1241
|
+
return
|
|
1242
|
+
}
|
|
1243
|
+
const removed = await this.accessStore.removeGroup(msg.chatId)
|
|
1244
|
+
await this.delivery.commandCard(msg, {
|
|
1245
|
+
kind: removed ? 'success' : 'unavailable', command: 'auth',
|
|
1246
|
+
text: removed ? '已撤销本群授权,立即生效。' : '本群当前未被授权。',
|
|
1247
|
+
}, direct)
|
|
1248
|
+
return
|
|
1249
|
+
}
|
|
1250
|
+
if (action === 'user') {
|
|
1251
|
+
const sub = words[1]
|
|
1252
|
+
const openId = words[2]
|
|
1253
|
+
if ((sub !== 'add' && sub !== 'remove') || openId === undefined
|
|
1254
|
+
|| !/^ou_[A-Za-z0-9_-]+$/u.test(openId)) {
|
|
1255
|
+
await this.delivery.commandCard(msg, {
|
|
1256
|
+
kind: 'input-error', command: 'auth',
|
|
1257
|
+
text: '用法:/auth user add <open_id> 或 /auth user remove <open_id>(open_id 以 ou_ 开头,可用 /userid 查看)。',
|
|
1258
|
+
}, direct)
|
|
1259
|
+
return
|
|
1260
|
+
}
|
|
1261
|
+
if (sub === 'add') {
|
|
1262
|
+
if (this.accessStore.hasUser(openId)) {
|
|
1263
|
+
await this.delivery.commandCard(msg, {
|
|
1264
|
+
kind: 'success', command: 'auth', text: `该用户已在白名单中(\`${openId}\`)。`,
|
|
1265
|
+
}, direct)
|
|
1266
|
+
return
|
|
1267
|
+
}
|
|
1268
|
+
await this.accessStore.addUser({
|
|
1269
|
+
openId, source: 'command', addedBy: msg.senderId, addedAt: now,
|
|
1270
|
+
})
|
|
1271
|
+
await this.delivery.commandCard(msg, {
|
|
1272
|
+
kind: 'success', command: 'auth',
|
|
1273
|
+
text: `✅ 已授权用户 \`${openId}\`(在所有已授权群里生效)。`,
|
|
1274
|
+
}, direct)
|
|
1275
|
+
return
|
|
1276
|
+
}
|
|
1277
|
+
const removed = await this.accessStore.removeUser(openId)
|
|
1278
|
+
await this.delivery.commandCard(msg, {
|
|
1279
|
+
kind: removed ? 'success' : 'unavailable', command: 'auth',
|
|
1280
|
+
text: removed ? `已移除用户 \`${openId}\`,立即生效。` : `用户 \`${openId}\` 不在白名单中。`,
|
|
1281
|
+
}, direct)
|
|
1282
|
+
return
|
|
1283
|
+
}
|
|
1284
|
+
if (action === 'list') {
|
|
1285
|
+
const groups = this.accessStore.groupRecords()
|
|
1286
|
+
const users = this.accessStore.userRecords()
|
|
1287
|
+
const pending = this.accessStore.pendingRequests()
|
|
1288
|
+
const groupLines = groups.length === 0
|
|
1289
|
+
? ['(暂无)']
|
|
1290
|
+
: groups.slice(0, 30).map(group => `- ${group.name ?? group.chatId} \`${group.chatId}\``)
|
|
1291
|
+
const userLines = users.length === 0
|
|
1292
|
+
? ['(暂无)']
|
|
1293
|
+
: users.slice(0, 30).map(user => `- ${user.name ?? user.openId} \`${user.openId}\``)
|
|
1294
|
+
await this.delivery.standaloneReply(msg, standaloneInfoCard('**🔐 访问授权清单**', [
|
|
1295
|
+
`**已授权群(${groups.length})**`,
|
|
1296
|
+
...groupLines,
|
|
1297
|
+
`**已授权用户(${users.length})**`,
|
|
1298
|
+
...userLines,
|
|
1299
|
+
`**待处理申请**:${pending.length} 个(可在 Web 设置面板处理)`,
|
|
1300
|
+
]), direct)
|
|
1301
|
+
return
|
|
1302
|
+
}
|
|
1303
|
+
await this.delivery.commandCard(msg, {
|
|
1304
|
+
kind: 'input-error', command: 'auth',
|
|
1305
|
+
text: '用法:/auth [status|allow|revoke|list|user add <open_id>|user remove <open_id>]',
|
|
1306
|
+
}, direct)
|
|
1307
|
+
} catch (error) {
|
|
1308
|
+
this.ctx.logger?.warn?.('dsh-channels: /auth failed: %s', String(error))
|
|
1309
|
+
await this.delivery.commandCard(msg, {
|
|
1310
|
+
kind: 'unavailable', command: 'auth', text: `授权操作失败:${messageOf(error)}`,
|
|
1311
|
+
}, direct).catch(() => {})
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/** Answer one command that needs no Session (help / userid / groupid / health). */
|
|
1316
|
+
private async runRouteFreeCommand(
|
|
1317
|
+
spec: ChannelCommandSpec,
|
|
1318
|
+
msg: NormalizedMessage,
|
|
1319
|
+
parsed: ParsedSlashCommand,
|
|
1320
|
+
): Promise<void> {
|
|
1321
|
+
const createThread = spec.directReply !== true
|
|
1322
|
+
if (parsed.rawInput.trim() !== '') {
|
|
1323
|
+
await this.delivery.commandCard(msg, {
|
|
1324
|
+
kind: 'input-error', command: spec.name, text: `用法错误:/${spec.name} 不接受参数。`,
|
|
1325
|
+
}, { createThread })
|
|
1326
|
+
return
|
|
1327
|
+
}
|
|
1328
|
+
if (spec.mentionGate === true
|
|
1329
|
+
&& msg.chatType === 'group'
|
|
1330
|
+
&& msg.threadId === undefined
|
|
1331
|
+
&& this.config.requireMention
|
|
1332
|
+
&& !msg.mentionedBot) {
|
|
1333
|
+
return
|
|
1334
|
+
}
|
|
1335
|
+
if (spec.chatType !== undefined && msg.chatType !== spec.chatType) {
|
|
1336
|
+
await this.delivery.commandCard(msg, {
|
|
1337
|
+
kind: 'context-error', command: spec.name,
|
|
1338
|
+
text: spec.chatType === 'p2p'
|
|
1339
|
+
? `命令 /${spec.name} 仅支持在私聊中使用。`
|
|
1340
|
+
: `命令 /${spec.name} 仅支持在群聊中使用。`,
|
|
1341
|
+
}, { createThread })
|
|
1342
|
+
return
|
|
1343
|
+
}
|
|
1344
|
+
await this.delivery.withCommandReaction(msg, async () => {
|
|
1345
|
+
switch (spec.name) {
|
|
1346
|
+
case 'help':
|
|
1347
|
+
await this.delivery.commandCard(msg, { kind: 'help', text: supportedCommandHelp() }, { createThread })
|
|
1348
|
+
return
|
|
1349
|
+
case 'userid':
|
|
1350
|
+
await this.delivery.commandCard(msg, {
|
|
1351
|
+
kind: 'success', command: spec.name, text: `当前用户 ID:\`${msg.senderId}\``,
|
|
1352
|
+
}, { createThread })
|
|
1353
|
+
return
|
|
1354
|
+
case 'groupid':
|
|
1355
|
+
await this.delivery.commandCard(msg, {
|
|
1356
|
+
kind: 'success', command: spec.name, text: `当前群组 ID:\`${msg.chatId}\``,
|
|
1357
|
+
}, { createThread })
|
|
1358
|
+
return
|
|
1359
|
+
case 'health':
|
|
1360
|
+
await this.delivery.standaloneReply(msg, buildHealthCard(this.healthSnapshot()), { createThread })
|
|
1361
|
+
return
|
|
1362
|
+
default:
|
|
1363
|
+
await this.delivery.commandCard(msg, {
|
|
1364
|
+
kind: 'unavailable', command: spec.name, text: `命令 /${spec.name} 暂不可用。`,
|
|
1365
|
+
}, { createThread })
|
|
1366
|
+
}
|
|
1367
|
+
})
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/** Answer one route-bound command inside the per-route admission queue. */
|
|
1371
|
+
private async runRouteCommand(
|
|
1372
|
+
spec: ChannelCommandSpec,
|
|
1373
|
+
msg: NormalizedMessage,
|
|
1374
|
+
parsed: ParsedSlashCommand,
|
|
1375
|
+
current: RouteRecord,
|
|
1376
|
+
): Promise<void> {
|
|
1377
|
+
await this.delivery.withCommandReaction(msg, async () => {
|
|
1378
|
+
try {
|
|
1379
|
+
if (spec.channelPlane === true) {
|
|
1380
|
+
await this.runChannelCommand(spec, msg, parsed, current)
|
|
1381
|
+
return
|
|
1382
|
+
}
|
|
1383
|
+
await this.runAgentCommand(spec, msg, parsed, current)
|
|
1384
|
+
} catch (error) {
|
|
1385
|
+
this.ctx.logger?.warn?.(
|
|
1386
|
+
'dsh-channels: command /%s failed for session %s: %s',
|
|
1387
|
+
spec.name, current.sessionId, String(error),
|
|
1388
|
+
)
|
|
1389
|
+
await this.delivery.commandCard(msg, {
|
|
1390
|
+
kind: 'internal-error', command: spec.name,
|
|
1391
|
+
text: '命令执行失败,请稍后重试;若持续发生,请查看 Harness 日志。',
|
|
1392
|
+
sessionId: current.sessionId,
|
|
1393
|
+
})
|
|
1394
|
+
}
|
|
1395
|
+
})
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/**
|
|
1399
|
+
* Commands the channel answers itself. `/stop` aborts the live turn
|
|
1400
|
+
* directly and deliberately never cold-resumes a Session just to cancel
|
|
1401
|
+
* nothing.
|
|
1402
|
+
*/
|
|
1403
|
+
private async runChannelCommand(
|
|
1404
|
+
spec: ChannelCommandSpec,
|
|
1405
|
+
msg: NormalizedMessage,
|
|
1406
|
+
parsed: ParsedSlashCommand,
|
|
1407
|
+
current: RouteRecord,
|
|
1408
|
+
): Promise<void> {
|
|
1409
|
+
if (spec.name === 'permissions') {
|
|
1410
|
+
await this.runPermissionsCommand(msg, parsed, current)
|
|
1411
|
+
return
|
|
1412
|
+
}
|
|
1413
|
+
if (spec.name === 'stop') {
|
|
1414
|
+
const agent = this.sessions.get(current.sessionId)?.agent
|
|
1415
|
+
?? this.ctx.agents.get(current.sessionId as SessionId)
|
|
1416
|
+
const running = agent?.status === 'running'
|
|
1417
|
+
agent?.cancel?.({ kind: 'user' })
|
|
1418
|
+
await this.delivery.commandCard(msg, {
|
|
1419
|
+
kind: 'success', command: 'stop', sessionId: current.sessionId,
|
|
1420
|
+
text: running ? '已请求停止当前回合。' : '当前没有正在运行的回合。',
|
|
1421
|
+
})
|
|
1422
|
+
return
|
|
1423
|
+
}
|
|
1424
|
+
await this.delivery.commandCard(msg, {
|
|
1425
|
+
kind: 'unavailable', command: spec.name, text: `命令 /${spec.name} 暂不可用。`,
|
|
1426
|
+
sessionId: current.sessionId,
|
|
1427
|
+
})
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
/**
|
|
1431
|
+
* `/permissions`: show this Session's effective sandbox+approval preset and
|
|
1432
|
+
* the selectable catalog; switch only when the deployment opted in.
|
|
1433
|
+
*/
|
|
1434
|
+
private async runPermissionsCommand(
|
|
1435
|
+
msg: NormalizedMessage,
|
|
1436
|
+
parsed: ParsedSlashCommand,
|
|
1437
|
+
current: RouteRecord,
|
|
1438
|
+
): Promise<void> {
|
|
1439
|
+
const presets = this.ctx.get?.('permissionPresets') as PermissionPresets | undefined
|
|
1440
|
+
if (presets === undefined) {
|
|
1441
|
+
await this.delivery.commandCard(msg, {
|
|
1442
|
+
kind: 'unavailable', command: 'permissions', sessionId: current.sessionId,
|
|
1443
|
+
text: '当前部署未挂载 permissionPresets 服务,无法查看权限预设。',
|
|
1444
|
+
})
|
|
1445
|
+
return
|
|
1446
|
+
}
|
|
1447
|
+
const live = await this.sessions.ensureLive(current)
|
|
1448
|
+
let active: string | undefined
|
|
1449
|
+
let catalogLine = ''
|
|
1450
|
+
try {
|
|
1451
|
+
active = presets.current(live.agent.session)
|
|
1452
|
+
const catalog = presets.catalog()
|
|
1453
|
+
catalogLine = catalog.options.map(option => `\`${option.value}\``).join('、')
|
|
1454
|
+
} catch {
|
|
1455
|
+
active = undefined
|
|
1456
|
+
}
|
|
1457
|
+
const requested = parsed.rawInput.trim()
|
|
1458
|
+
if (requested === '') {
|
|
1459
|
+
const lines = [
|
|
1460
|
+
`**当前权限预设**:${active === undefined ? '暂无数据' : `\`${active}\``}`,
|
|
1461
|
+
...(catalogLine === '' ? [] : [`**可选预设**:${catalogLine}`]),
|
|
1462
|
+
...(this.config.allowPermissionSwitch
|
|
1463
|
+
? ['使用 `/permissions <name>` 切换;切换会同时调整沙箱与审批策略。']
|
|
1464
|
+
: ['当前部署未开启飞书侧切换(`allowPermissionSwitch: false`),仅支持查看。']),
|
|
1465
|
+
]
|
|
1466
|
+
await this.delivery.standaloneReply(msg, standaloneInfoCard('**🔐 权限预设**', lines))
|
|
1467
|
+
return
|
|
1468
|
+
}
|
|
1469
|
+
if (!this.config.allowPermissionSwitch) {
|
|
1470
|
+
await this.delivery.commandCard(msg, {
|
|
1471
|
+
kind: 'unavailable', command: 'permissions', sessionId: current.sessionId,
|
|
1472
|
+
text: '当前部署未开启飞书侧权限切换,仅支持 /permissions 查看。',
|
|
1473
|
+
})
|
|
1474
|
+
return
|
|
1475
|
+
}
|
|
1476
|
+
try {
|
|
1477
|
+
presets.set(live.agent.session, requested)
|
|
1478
|
+
await this.delivery.standaloneReply(msg, standaloneInfoCard('**🔐 权限预设已切换**', [
|
|
1479
|
+
`**当前权限预设**:\`${requested}\``,
|
|
1480
|
+
'沙箱模式与审批策略已按该预设更新。',
|
|
1481
|
+
]))
|
|
1482
|
+
} catch (error) {
|
|
1483
|
+
this.ctx.logger?.warn?.(
|
|
1484
|
+
'dsh-channels: permission switch for session %s failed: %s', current.sessionId, String(error),
|
|
1485
|
+
)
|
|
1486
|
+
await this.delivery.commandCard(msg, {
|
|
1487
|
+
kind: 'input-error', command: 'permissions', sessionId: current.sessionId,
|
|
1488
|
+
text: `未知或不可用的权限预设:\`${requested}\`。可用:${catalogLine === '' ? '暂无数据' : catalogLine}`,
|
|
1489
|
+
})
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
/**
|
|
1494
|
+
* Execute one command through the agent command plane: resolve the live
|
|
1495
|
+
* agent, mount borrowed channel commands when needed, arm the channel-scoped
|
|
1496
|
+
* `/renew` and `/effort` handlers, then render the settled result.
|
|
1497
|
+
*/
|
|
1498
|
+
private async runAgentCommand(
|
|
1499
|
+
spec: ChannelCommandSpec,
|
|
1500
|
+
msg: NormalizedMessage,
|
|
1501
|
+
parsed: ParsedSlashCommand,
|
|
1502
|
+
current: RouteRecord,
|
|
1503
|
+
): Promise<void> {
|
|
1504
|
+
// An alias (`/new`) resolves to this canonical spec; execute the canonical
|
|
1505
|
+
// line so the agent command plane finds the registered definition.
|
|
1506
|
+
const line = `/${spec.name}${parsed.rawInput}`
|
|
1507
|
+
const live = await this.sessions.ensureLive(current)
|
|
1508
|
+
let definition = this.ctx.commands.find(live.agent, spec.name)
|
|
1509
|
+
let unregisterBorrowedCommand: (() => void) | undefined
|
|
1510
|
+
if (definition === undefined && spec.name === 'renew') {
|
|
1511
|
+
unregisterBorrowedCommand = registerRenewCommand(live.agent.ctx.commands)
|
|
1512
|
+
definition = this.ctx.commands.find(live.agent, spec.name)
|
|
1513
|
+
}
|
|
1514
|
+
if (definition === undefined && spec.name === 'effort') {
|
|
1515
|
+
unregisterBorrowedCommand = registerEffortCommand(live.agent.ctx.commands)
|
|
1516
|
+
definition = this.ctx.commands.find(live.agent, spec.name)
|
|
1517
|
+
}
|
|
1518
|
+
if (definition === undefined && spec.name === 'model') {
|
|
1519
|
+
unregisterBorrowedCommand = registerModelCommand(live.agent.ctx.commands)
|
|
1520
|
+
definition = this.ctx.commands.find(live.agent, spec.name)
|
|
1521
|
+
}
|
|
1522
|
+
if (definition === undefined) {
|
|
1523
|
+
unregisterBorrowedCommand?.()
|
|
1524
|
+
const text = spec.name === 'compact'
|
|
1525
|
+
? '当前部署未挂载 Harness 原生 /compact 命令。'
|
|
1526
|
+
: `当前 Session 未挂载 /${spec.name} 命令。`
|
|
1527
|
+
await this.delivery.commandCard(msg, {
|
|
1528
|
+
kind: 'unavailable', command: spec.name, text, sessionId: current.sessionId,
|
|
1529
|
+
})
|
|
1530
|
+
return
|
|
1531
|
+
}
|
|
1532
|
+
const compactBefore = spec.name === 'compact'
|
|
1533
|
+
? await this.measureCommandContext(live.agent.session)
|
|
1534
|
+
: undefined
|
|
1535
|
+
const controller = new AbortController()
|
|
1536
|
+
this.commandControllers.add(controller)
|
|
1537
|
+
const renewAdmission: RenewAdmission | undefined = spec.name === 'renew'
|
|
1538
|
+
? { route: current, live }
|
|
1539
|
+
: undefined
|
|
1540
|
+
const effortAdmission: EffortAdmission | undefined = spec.name === 'effort'
|
|
1541
|
+
? { live, controller: this.sessions.requestPolicy(live.agent) }
|
|
1542
|
+
: undefined
|
|
1543
|
+
const modelAdmission: ModelAdmission | undefined = spec.name === 'model'
|
|
1544
|
+
? { live, controller: this.sessions.requestPolicy(live.agent) }
|
|
1545
|
+
: undefined
|
|
1546
|
+
const disarmRenew = renewAdmission === undefined
|
|
1547
|
+
? undefined
|
|
1548
|
+
: armRenewCommand(controller.signal, invocation => this.runRenewCommand(invocation, renewAdmission))
|
|
1549
|
+
const disarmEffort = effortAdmission === undefined
|
|
1550
|
+
? undefined
|
|
1551
|
+
: armEffortCommand(
|
|
1552
|
+
controller.signal,
|
|
1553
|
+
(invocation, action) => this.runEffortCommand(invocation, action, effortAdmission),
|
|
1554
|
+
)
|
|
1555
|
+
const disarmModel = modelAdmission === undefined
|
|
1556
|
+
? undefined
|
|
1557
|
+
: armModelCommand(
|
|
1558
|
+
controller.signal,
|
|
1559
|
+
(invocation, rawInput) => this.runModelCommand(invocation, rawInput, modelAdmission),
|
|
1560
|
+
)
|
|
1561
|
+
let execution
|
|
1562
|
+
try {
|
|
1563
|
+
execution = await this.ctx.commands.execute(live.agent, line, [], controller.signal)
|
|
1564
|
+
} finally {
|
|
1565
|
+
this.commandControllers.delete(controller)
|
|
1566
|
+
disarmRenew?.()
|
|
1567
|
+
disarmEffort?.()
|
|
1568
|
+
disarmModel?.()
|
|
1569
|
+
unregisterBorrowedCommand?.()
|
|
1570
|
+
if (renewAdmission?.commit !== undefined) {
|
|
1571
|
+
await this.sessions.releaseRenewed(renewAdmission.commit)
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
if (controller.signal.aborted || !this.started) return
|
|
1575
|
+
if (execution === undefined) {
|
|
1576
|
+
await this.delivery.commandCard(msg, {
|
|
1577
|
+
kind: 'unavailable', command: spec.name,
|
|
1578
|
+
text: `当前 Session 无法执行 /${spec.name}。`, sessionId: current.sessionId,
|
|
1579
|
+
})
|
|
1580
|
+
return
|
|
1581
|
+
}
|
|
1582
|
+
await this.renderCommandResult({
|
|
1583
|
+
spec, msg, current, live,
|
|
1584
|
+
result: execution.result,
|
|
1585
|
+
compactBefore,
|
|
1586
|
+
renewAdmission,
|
|
1587
|
+
effortAdmission,
|
|
1588
|
+
modelAdmission,
|
|
1589
|
+
})
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/** Render one settled agent-plane command result. */
|
|
1593
|
+
private async renderCommandResult(input: {
|
|
1594
|
+
spec: ChannelCommandSpec
|
|
1595
|
+
msg: NormalizedMessage
|
|
1596
|
+
current: RouteRecord
|
|
1597
|
+
live: LiveAgent
|
|
1598
|
+
result: CommandResult
|
|
1599
|
+
compactBefore: ContextMeasurement | undefined
|
|
1600
|
+
renewAdmission: RenewAdmission | undefined
|
|
1601
|
+
effortAdmission: EffortAdmission | undefined
|
|
1602
|
+
modelAdmission: ModelAdmission | undefined
|
|
1603
|
+
}): Promise<void> {
|
|
1604
|
+
const { spec, msg, current, live, result, compactBefore, renewAdmission, effortAdmission, modelAdmission } = input
|
|
1605
|
+
if (spec.name === 'effort') effortAdmission?.controller.sync(live.agent.session.snapshotEvents())
|
|
1606
|
+
if (spec.name === 'model') modelAdmission?.controller.sync(live.agent.session.snapshotEvents())
|
|
1607
|
+
if (spec.name === 'renew' && result.kind === 'success' && renewAdmission?.commit !== undefined) {
|
|
1608
|
+
await this.delivery.standaloneReply(msg, buildRenewCard({
|
|
1609
|
+
previousSessionId: renewAdmission.commit.previous.sessionId,
|
|
1610
|
+
currentSessionId: renewAdmission.commit.current.sessionId,
|
|
1611
|
+
workspacePath: renewAdmission.commit.current.workspacePath,
|
|
1612
|
+
}))
|
|
1613
|
+
return
|
|
1614
|
+
}
|
|
1615
|
+
if (spec.name === 'health' && result.kind === 'success') {
|
|
1616
|
+
await this.delivery.standaloneReply(msg, buildHealthCard(this.healthSnapshot()))
|
|
1617
|
+
return
|
|
1618
|
+
}
|
|
1619
|
+
if (spec.name === 'status' && result.kind === 'success') {
|
|
1620
|
+
const session = live.agent.session
|
|
1621
|
+
const stats = foldSessionStats(session, this.projectionRegistry())
|
|
1622
|
+
const context = await measureSessionContext(
|
|
1623
|
+
session, this.commandContextServices(), stats.lastHeader,
|
|
1624
|
+
)
|
|
1625
|
+
await this.delivery.standaloneReply(msg, buildSessionStatusCard({
|
|
1626
|
+
sessionId: current.sessionId,
|
|
1627
|
+
stats,
|
|
1628
|
+
context,
|
|
1629
|
+
extras: {
|
|
1630
|
+
permissionPreset: this.permissionPresetOf(session),
|
|
1631
|
+
workspacePath: current.workspacePath,
|
|
1632
|
+
uptimeMs: this.startedAt === undefined ? undefined : Date.now() - this.startedAt,
|
|
1633
|
+
liveAgents: this.sessions.liveCount(),
|
|
1634
|
+
routeCount: this.routes.size(),
|
|
1635
|
+
},
|
|
1636
|
+
}))
|
|
1637
|
+
return
|
|
1638
|
+
}
|
|
1639
|
+
if (spec.name === 'workspace' && result.kind === 'success') {
|
|
1640
|
+
const resolved = resolveWorkDir(live.agent.session.header.cwd, current.workspacePath)
|
|
1641
|
+
if (resolved.kind === 'success') {
|
|
1642
|
+
await this.delivery.standaloneReply(msg, buildWorkDirCard(current.sessionId, resolved.path))
|
|
1643
|
+
return
|
|
1644
|
+
}
|
|
1645
|
+
if (resolved.kind === 'mismatch') {
|
|
1646
|
+
this.ctx.logger?.warn?.(
|
|
1647
|
+
'dsh-channels: workspace mismatch for session %s (paths redacted)', current.sessionId,
|
|
1648
|
+
)
|
|
1649
|
+
}
|
|
1650
|
+
await this.delivery.commandCard(msg, {
|
|
1651
|
+
kind: 'unavailable', command: spec.name,
|
|
1652
|
+
text: resolved.text, sessionId: current.sessionId,
|
|
1653
|
+
})
|
|
1654
|
+
return
|
|
1655
|
+
}
|
|
1656
|
+
if (spec.name === 'history' && result.kind === 'success') {
|
|
1657
|
+
await this.delivery.standaloneReply(msg, buildSessionHistoryCard(
|
|
1658
|
+
current, await this.sessionHistoryAnnotations(current),
|
|
1659
|
+
))
|
|
1660
|
+
return
|
|
1661
|
+
}
|
|
1662
|
+
if (spec.name === 'export' && result.kind === 'success') {
|
|
1663
|
+
try {
|
|
1664
|
+
const file = await this.sessionExportBuilder(live.agent.session)
|
|
1665
|
+
await this.delivery.requireChannel().send(msg.chatId, { file }, {
|
|
1666
|
+
replyTo: msg.messageId,
|
|
1667
|
+
replyInThread: true,
|
|
1668
|
+
})
|
|
1669
|
+
} catch (error) {
|
|
1670
|
+
this.ctx.logger?.warn?.(
|
|
1671
|
+
'dsh-channels: logical export delivery failed for session %s: %s',
|
|
1672
|
+
current.sessionId, String(error),
|
|
1673
|
+
)
|
|
1674
|
+
await this.delivery.commandCard(msg, {
|
|
1675
|
+
kind: 'internal-error', command: spec.name,
|
|
1676
|
+
text: 'Session 逻辑日志导出失败,未交付文件;请稍后重试并查看 Harness 日志。',
|
|
1677
|
+
sessionId: current.sessionId,
|
|
1678
|
+
})
|
|
1679
|
+
}
|
|
1680
|
+
return
|
|
1681
|
+
}
|
|
1682
|
+
if (spec.name === 'compact' && compactBefore !== undefined) {
|
|
1683
|
+
const compactAfter = await this.measureCommandContext(live.agent.session)
|
|
1684
|
+
if (result.kind === 'success') {
|
|
1685
|
+
await this.delivery.standaloneReply(msg, buildCompactComparisonCard({
|
|
1686
|
+
sessionId: current.sessionId,
|
|
1687
|
+
resultText: result.text ?? '命令已执行。',
|
|
1688
|
+
before: compactBefore,
|
|
1689
|
+
after: compactAfter,
|
|
1690
|
+
}))
|
|
1691
|
+
return
|
|
1692
|
+
}
|
|
1693
|
+
await this.delivery.commandCard(msg, {
|
|
1694
|
+
kind: commandResultCardKind(result), command: spec.name,
|
|
1695
|
+
text: `${result.text}\n\n${compactFailureContext(compactBefore, compactAfter)}`,
|
|
1696
|
+
sessionId: current.sessionId,
|
|
1697
|
+
})
|
|
1698
|
+
return
|
|
1699
|
+
}
|
|
1700
|
+
await this.delivery.commandCard(msg, {
|
|
1701
|
+
kind: commandResultCardKind(result), command: spec.name,
|
|
1702
|
+
text: result.text ?? '命令已执行。', sessionId: current.sessionId,
|
|
1703
|
+
})
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
/**
|
|
1707
|
+
* Annotate a route's session history with host-query facts: the latest
|
|
1708
|
+
* log-backed title per session and whether it is currently live. Both are
|
|
1709
|
+
* best-effort — a missing or failing `sessionQuery` only drops the titles.
|
|
1710
|
+
*/
|
|
1711
|
+
private async sessionHistoryAnnotations(record: RouteRecord): Promise<Record<string, SessionHistoryAnnotation>> {
|
|
1712
|
+
const ids = normalizeSessionHistory(record)
|
|
1713
|
+
.slice(-SESSION_HISTORY_DISPLAY_LIMIT)
|
|
1714
|
+
.map(entry => entry.sessionId)
|
|
1715
|
+
const annotations: Record<string, SessionHistoryAnnotation> = {}
|
|
1716
|
+
for (const id of ids) {
|
|
1717
|
+
annotations[id] = { live: this.sessions.get(id) !== undefined || this.ctx.agents.get(id as SessionId) !== undefined }
|
|
1718
|
+
}
|
|
1719
|
+
const query = this.ctx.get?.('sessionQuery') as SessionQuery | undefined
|
|
1720
|
+
if (query === undefined) return annotations
|
|
1721
|
+
try {
|
|
1722
|
+
const results = await query.readTitleSnapshots(ids as SessionId[])
|
|
1723
|
+
for (const item of results) {
|
|
1724
|
+
if (item.status !== 'fulfilled' || item.value.title === undefined) continue
|
|
1725
|
+
annotations[item.sessionId] = { ...annotations[item.sessionId], title: item.value.title.title }
|
|
1726
|
+
}
|
|
1727
|
+
} catch (error) {
|
|
1728
|
+
this.ctx.logger?.warn?.('dsh-channels: session title lookup failed: %s', String(error))
|
|
1729
|
+
}
|
|
1730
|
+
return annotations
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
/** Effective permission preset of one Session, when the service is mounted. */
|
|
1734
|
+
private permissionPresetOf(session: Session): string | undefined {
|
|
1735
|
+
const presets = this.ctx.get?.('permissionPresets') as PermissionPresets | undefined
|
|
1736
|
+
try {
|
|
1737
|
+
return presets?.current(session)
|
|
1738
|
+
} catch {
|
|
1739
|
+
return undefined
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/** Optional diagnostic services resolved without topology-sensitive property access. */
|
|
1744
|
+
private commandContextServices(): ContextServices {
|
|
1745
|
+
return {
|
|
1746
|
+
tokenMeter: this.ctx.get?.('tokenMeter') as TokenMeter | undefined,
|
|
1747
|
+
llm: this.ctx.get?.('llm') as LlmService | undefined,
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
/** Exact command-time measurement shared by `/status` and `/compact`. */
|
|
1752
|
+
private measureCommandContext(session: Session): Promise<ContextMeasurement> {
|
|
1753
|
+
return measureSessionContext(session, this.commandContextServices())
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
/** Capture aggregate health only; raw configuration and errors are excluded. */
|
|
1757
|
+
private healthSnapshot(): HealthSnapshot {
|
|
1758
|
+
const serviceKeys: Record<HealthDependencyName, string> = {
|
|
1759
|
+
agents: 'agents',
|
|
1760
|
+
commands: 'commands',
|
|
1761
|
+
workspace: 'workspaceRegistry',
|
|
1762
|
+
storage: 'storageDomain',
|
|
1763
|
+
credentials: 'credentials',
|
|
1764
|
+
llm: 'llm',
|
|
1765
|
+
'token-meter': 'tokenMeter',
|
|
1766
|
+
}
|
|
1767
|
+
const directServices: Record<HealthDependencyName, () => unknown> = {
|
|
1768
|
+
agents: () => this.ctx.agents,
|
|
1769
|
+
commands: () => this.ctx.commands,
|
|
1770
|
+
workspace: () => this.ctx.workspaceRegistry,
|
|
1771
|
+
storage: () => this.ctx.storageDomain,
|
|
1772
|
+
credentials: () => this.ctx.credentials,
|
|
1773
|
+
llm: () => this.ctx.llm,
|
|
1774
|
+
'token-meter': () => this.ctx.tokenMeter,
|
|
1775
|
+
}
|
|
1776
|
+
const dependencies = Object.fromEntries(HEALTH_DEPENDENCY_NAMES.map(name => {
|
|
1777
|
+
try {
|
|
1778
|
+
const service = this.ctx.get === undefined
|
|
1779
|
+
? directServices[name]()
|
|
1780
|
+
: this.ctx.get(serviceKeys[name])
|
|
1781
|
+
return [name, service !== undefined]
|
|
1782
|
+
} catch {
|
|
1783
|
+
return [name, false]
|
|
1784
|
+
}
|
|
1785
|
+
})) as Record<HealthDependencyName, boolean>
|
|
1786
|
+
|
|
1787
|
+
const fibers: CordisFiber[] = []
|
|
1788
|
+
try {
|
|
1789
|
+
for (const runtime of this.ctx.registry.values()) {
|
|
1790
|
+
for (const fiber of runtime.fibers) fibers.push(fiber)
|
|
1791
|
+
}
|
|
1792
|
+
} catch {
|
|
1793
|
+
// Registry inspection is diagnostic only. An empty fold is safer than
|
|
1794
|
+
// exposing or propagating an internal registry error to the user.
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
const inflightTurnCount = this.repair.count()
|
|
1798
|
+
|
|
1799
|
+
let connection
|
|
1800
|
+
let botOpenId: string | undefined
|
|
1801
|
+
try { connection = this.channel?.getConnectionStatus() } catch {}
|
|
1802
|
+
try { botOpenId = this.channel?.botOpenId() } catch {}
|
|
1803
|
+
|
|
1804
|
+
return foldHealthSnapshot({
|
|
1805
|
+
fibers,
|
|
1806
|
+
dependencies,
|
|
1807
|
+
connection,
|
|
1808
|
+
botOpenId,
|
|
1809
|
+
started: this.started,
|
|
1810
|
+
routeCount: this.routes.size(),
|
|
1811
|
+
liveAgentCount: this.sessions.liveCount(),
|
|
1812
|
+
inflightTurnCount,
|
|
1813
|
+
})
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
|
|
1817
|
+
|
|
1818
|
+
|
|
1819
|
+
|
|
1820
|
+
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
|
|
1824
|
+
|
|
1825
|
+
|
|
1826
|
+
/**
|
|
1827
|
+
* GC pass over the routing domain. Two independent halves:
|
|
1828
|
+
*
|
|
1829
|
+
* 1. Dedup records (unconditional): `processed` records and in-process
|
|
1830
|
+
* guard entries older than `PROCESSED_RETENTION_MS` are dropped —
|
|
1831
|
+
* redelivery windows are minutes, so a day of retention is ample and
|
|
1832
|
+
* keeps the table bounded regardless of the route TTL setting.
|
|
1833
|
+
* 2. Route/inflight GC (`routeTtlMs > 0`): thread routes idle past the TTL
|
|
1834
|
+
* are retired into minimal persistent tombstones before their active
|
|
1835
|
+
* records are deleted. A later message in that visible topic is then
|
|
1836
|
+
* rejected instead of silently creating a context-free Session. Degraded
|
|
1837
|
+
* chat-scoped routes have no topic boundary and are deleted normally.
|
|
1838
|
+
* Stale in-flight snapshots are deleted without surprising the user with
|
|
1839
|
+
* a repair of an ancient card. Sessions and workspaces remain host-owned.
|
|
1840
|
+
*
|
|
1841
|
+
* Per-key failures are logged and skipped; concurrent writes are safe
|
|
1842
|
+
* because `entries()` snapshots.
|
|
1843
|
+
*/
|
|
1844
|
+
private async gcExpiredEntries(): Promise<void> {
|
|
1845
|
+
const now = Date.now()
|
|
1846
|
+
const processedCutoff = now - PROCESSED_RETENTION_MS
|
|
1847
|
+
let removedRoutes = 0
|
|
1848
|
+
let retiredThreadRoutes = 0
|
|
1849
|
+
let removedInflight = 0
|
|
1850
|
+
let removedProcessed = 0
|
|
1851
|
+
|
|
1852
|
+
const processed = this.domain?.processed
|
|
1853
|
+
if (processed !== undefined) {
|
|
1854
|
+
for (const [key, record] of [...processed.entries()]) {
|
|
1855
|
+
if (record.processedAt >= processedCutoff) continue
|
|
1856
|
+
try {
|
|
1857
|
+
await processed.delete(key)
|
|
1858
|
+
removedProcessed++
|
|
1859
|
+
} catch (error) {
|
|
1860
|
+
this.ctx.logger?.warn?.('dsh-channels: GC of processed record %s failed: %s', key, String(error))
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
this.inbound.pruneAccepted(processedCutoff)
|
|
1865
|
+
|
|
1866
|
+
const ttlMs = this.config.routeTtlMs
|
|
1867
|
+
if (ttlMs === 0) {
|
|
1868
|
+
if (removedProcessed > 0) {
|
|
1869
|
+
this.ctx.logger?.info?.('dsh-channels: record GC removed %d dedup record(s)', removedProcessed)
|
|
1870
|
+
}
|
|
1871
|
+
return
|
|
1872
|
+
}
|
|
1873
|
+
const cutoff = now - ttlMs
|
|
1874
|
+
|
|
1875
|
+
const routes = this.domain?.routes
|
|
1876
|
+
if (routes !== undefined) {
|
|
1877
|
+
for (const [key, record] of [...routes.entries()]) {
|
|
1878
|
+
if (record.lastActivityAt >= cutoff) continue
|
|
1879
|
+
try {
|
|
1880
|
+
if (record.kind === 'thread') {
|
|
1881
|
+
await this.routes.expireThread(key, now)
|
|
1882
|
+
retiredThreadRoutes++
|
|
1883
|
+
} else {
|
|
1884
|
+
await this.routes.remove(key)
|
|
1885
|
+
}
|
|
1886
|
+
removedRoutes++
|
|
1887
|
+
} catch (error) {
|
|
1888
|
+
this.ctx.logger?.warn?.('dsh-channels: GC of route %s failed: %s', key, String(error))
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
const inflight = this.domain?.inflight
|
|
1894
|
+
if (inflight !== undefined) {
|
|
1895
|
+
for (const [key, snapshot] of [...inflight.entries()]) {
|
|
1896
|
+
if (snapshot.startedAt >= cutoff) continue
|
|
1897
|
+
try {
|
|
1898
|
+
await inflight.delete(key)
|
|
1899
|
+
removedInflight++
|
|
1900
|
+
} catch (error) {
|
|
1901
|
+
this.ctx.logger?.warn?.('dsh-channels: GC of inflight snapshot %s failed: %s', key, String(error))
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
if (removedRoutes > 0 || removedInflight > 0 || removedProcessed > 0) {
|
|
1907
|
+
this.ctx.logger?.info?.(
|
|
1908
|
+
'dsh-channels: record GC removed %d route(s) (%d thread tombstone(s)), %d stale snapshot(s), %d dedup record(s)',
|
|
1909
|
+
removedRoutes, retiredThreadRoutes, removedInflight, removedProcessed,
|
|
1910
|
+
)
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
|
|
1915
|
+
/**
|
|
1916
|
+
* HITL answerer hook: the Feishu placement of one session's approval cards,
|
|
1917
|
+
* or `undefined` when this channel does not route the session (the answerer
|
|
1918
|
+
* then delegates to other answerers).
|
|
1919
|
+
*/
|
|
1920
|
+
private approvalRouteFor(sessionId: string): ApprovalRoute | undefined {
|
|
1921
|
+
const record = this.routes.findBySession(sessionId)
|
|
1922
|
+
if (record === undefined) return undefined
|
|
1923
|
+
return {
|
|
1924
|
+
chatId: record.chatId,
|
|
1925
|
+
cardMessageId: record.cardMessageId,
|
|
1926
|
+
inThread: record.kind === 'thread',
|
|
1927
|
+
senderOpenId: record.senderOpenId,
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
/**
|
|
1932
|
+
* Send one body-segment continuation card into the session's topic (or, for
|
|
1933
|
+
* degraded chat-scoped routes, as a plain reply anchored to the main card).
|
|
1934
|
+
* When Feishu rejects the card (e.g. per-card table limit, error 11310) the
|
|
1935
|
+
* segment is re-delivered as a plain-text message so the content is never
|
|
1936
|
+
* lost — text messages carry no card constraints.
|
|
1937
|
+
*/
|
|
1938
|
+
/** Authorized Session-level `/model` view/switch operation. */
|
|
1939
|
+
private async runModelCommand(
|
|
1940
|
+
invocation: CommandInvocation,
|
|
1941
|
+
rawInput: string,
|
|
1942
|
+
admission: ModelAdmission,
|
|
1943
|
+
): Promise<CommandResult> {
|
|
1944
|
+
if (admission.live.agent !== invocation.agent) {
|
|
1945
|
+
return { kind: 'error', text: '/model 只能通过已授权的飞书话题执行。' }
|
|
1946
|
+
}
|
|
1947
|
+
const session = invocation.agent.session
|
|
1948
|
+
const state = admission.controller.state
|
|
1949
|
+
const header = session.requestHeader?.()?.config
|
|
1950
|
+
const currentProvider = state.effectiveModel?.provider
|
|
1951
|
+
?? (typeof header?.provider === 'string' && header.provider !== '' ? header.provider : undefined)
|
|
1952
|
+
const currentModel = state.effectiveModel?.model
|
|
1953
|
+
?? (typeof header?.model === 'string' && header.model !== '' ? header.model : undefined)
|
|
1954
|
+
const source = state.modelSource === 'session'
|
|
1955
|
+
? 'Session 覆盖'
|
|
1956
|
+
: state.modelSource === 'config' ? '配置默认' : '部署默认'
|
|
1957
|
+
|
|
1958
|
+
if (rawInput === '') {
|
|
1959
|
+
const lines = [
|
|
1960
|
+
`**当前模型**:${currentModel === undefined ? '尚无模型请求记录' : `\`${currentModel}\``}`
|
|
1961
|
+
+ `${currentProvider === undefined ? '' : `(provider \`${currentProvider}\`)`} · ${source}`,
|
|
1962
|
+
]
|
|
1963
|
+
const models = await this.listModels(currentProvider)
|
|
1964
|
+
if (models.length > 0) {
|
|
1965
|
+
lines.push(`**可选模型(${models.length})**:${models.slice(0, 20).map(id => `\`${id}\``).join('、')}`
|
|
1966
|
+
+ (models.length > 20 ? ' …' : ''))
|
|
1967
|
+
} else if (currentProvider === undefined) {
|
|
1968
|
+
lines.push('当前还没有模型请求记录;可先用 `/model <provider>/<model>` 指定,或先发一条普通消息。')
|
|
1969
|
+
}
|
|
1970
|
+
lines.push('用法:`/model <模型-id>`(沿用当前 provider)· `/model <provider>/<model>` · `/model reset`')
|
|
1971
|
+
return { kind: 'success', text: lines.join('\n') }
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
if (rawInput === 'reset') {
|
|
1975
|
+
return { kind: 'success', text: '已恢复部署默认模型(下一条消息生效)。' }
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
const slash = rawInput.indexOf('/')
|
|
1979
|
+
const provider = slash === -1 ? currentProvider : rawInput.slice(0, slash)
|
|
1980
|
+
const model = slash === -1 ? rawInput : rawInput.slice(slash + 1)
|
|
1981
|
+
if (model === '' || (slash !== -1 && (provider === undefined || provider === ''))) {
|
|
1982
|
+
return { kind: 'error', text: '用法错误:/model [模型-id|provider/model|reset]。' }
|
|
1983
|
+
}
|
|
1984
|
+
// Validate against the advertised catalog when the deployment exposes one;
|
|
1985
|
+
// an unsupported value would otherwise only fail at the next request.
|
|
1986
|
+
if (provider !== undefined) {
|
|
1987
|
+
const models = await this.listModels(provider)
|
|
1988
|
+
if (models.length > 0 && !models.includes(model)) {
|
|
1989
|
+
return {
|
|
1990
|
+
kind: 'error',
|
|
1991
|
+
text: `provider \`${provider}\` 未提供模型 \`${model}\`。可选:${models.slice(0, 20).map(id => `\`${id}\``).join('、')}`,
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
const label = provider === undefined ? model : `${provider}/${model}`
|
|
1996
|
+
return { kind: 'success', text: `当前 Session 模型已切换为 \`${label}\`(下一条消息生效)。` }
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
/** Model ids advertised for one provider route (empty when unavailable). */
|
|
2000
|
+
private async listModels(provider: string | undefined): Promise<string[]> {
|
|
2001
|
+
if (provider === undefined) return []
|
|
2002
|
+
try {
|
|
2003
|
+
const llm = this.ctx.get?.('llm') as LlmService | undefined
|
|
2004
|
+
const models = await llm?.listModels?.(provider)
|
|
2005
|
+
return (models ?? []).map(entry => entry.id ?? entry.name ?? '').filter(id => id !== '')
|
|
2006
|
+
} catch {
|
|
2007
|
+
return []
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
/**
|
|
2012
|
+
* Agent-scoped `/renew` handler. Only the Feishu admission path arms its
|
|
2013
|
+
* AbortSignal, preventing another Host client from switching a Feishu route
|
|
2014
|
+
* by invoking the scoped command out of band.
|
|
2015
|
+
*/
|
|
2016
|
+
private async runRenewCommand(
|
|
2017
|
+
invocation: CommandInvocation,
|
|
2018
|
+
admission: RenewAdmission,
|
|
2019
|
+
): Promise<CommandResult> {
|
|
2020
|
+
if (admission.live.agent !== invocation.agent) {
|
|
2021
|
+
return { kind: 'error', text: '/renew 只能通过已授权的飞书话题执行。' }
|
|
2022
|
+
}
|
|
2023
|
+
const current = this.routes.get(admission.route.key)
|
|
2024
|
+
if (current === undefined || current.sessionId !== admission.route.sessionId) {
|
|
2025
|
+
return { kind: 'error', text: '当前话题绑定已变化,请重试 /renew。' }
|
|
2026
|
+
}
|
|
2027
|
+
try {
|
|
2028
|
+
admission.commit = await this.sessions.renew(current, admission.live)
|
|
2029
|
+
return { kind: 'success', text: '新 Session 已创建并绑定。' }
|
|
2030
|
+
} catch (error) {
|
|
2031
|
+
this.ctx.logger?.warn?.(
|
|
2032
|
+
'dsh-channels: renew transaction failed for session %s: %s',
|
|
2033
|
+
current.sessionId, String(error),
|
|
2034
|
+
)
|
|
2035
|
+
return { kind: 'error', text: 'Session 续建失败,原 Session 仍保持绑定;请稍后重试。' }
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
/** Authorized Session-level `/effort` view/switch operation. */
|
|
2040
|
+
private runEffortCommand(
|
|
2041
|
+
invocation: CommandInvocation,
|
|
2042
|
+
action: EffortCommandAction,
|
|
2043
|
+
admission: EffortAdmission,
|
|
2044
|
+
): CommandResult {
|
|
2045
|
+
if (admission.live.agent !== invocation.agent) {
|
|
2046
|
+
return { kind: 'error', text: '/effort 只能通过已授权的飞书话题执行。' }
|
|
2047
|
+
}
|
|
2048
|
+
const state = admission.controller.state
|
|
2049
|
+
if (action.kind === 'show') {
|
|
2050
|
+
if (state.effortSource === 'session') {
|
|
2051
|
+
return { kind: 'success', text: `当前 Session 推理强度:\`${state.effectiveEffort}\`(Session 覆盖)。` }
|
|
2052
|
+
}
|
|
2053
|
+
if (state.effortSource === 'config') {
|
|
2054
|
+
return { kind: 'success', text: `当前 Session 推理强度:\`${state.effectiveEffort}\`(配置默认)。` }
|
|
2055
|
+
}
|
|
2056
|
+
return { kind: 'success', text: '当前 Session 未设置推理强度覆盖,使用模型适配器/provider 默认。' }
|
|
2057
|
+
}
|
|
2058
|
+
if (action.kind === 'reset') {
|
|
2059
|
+
return state.configuredEffort === undefined
|
|
2060
|
+
? { kind: 'success', text: '已恢复模型适配器/provider 默认推理强度。' }
|
|
2061
|
+
: { kind: 'success', text: `已恢复配置默认推理强度:\`${state.configuredEffort}\`。` }
|
|
2062
|
+
}
|
|
2063
|
+
return { kind: 'success', text: `当前 Session 推理强度已切换为:\`${action.value}\`。` }
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
// ---- outbound helpers ------------------------------------------------
|
|
2067
|
+
|
|
2068
|
+
/** Context pressure for the card's second info line (synchronous reads only). */
|
|
2069
|
+
private measureContext(session: Session): { contextTokens?: number; contextWindow?: number } {
|
|
2070
|
+
let contextTokens: number | undefined
|
|
2071
|
+
try {
|
|
2072
|
+
// Optional peers are read through ctx.get (safe lookup); direct property
|
|
2073
|
+
// access on a cordis context throws for services the plugin did not inject.
|
|
2074
|
+
const meter = this.ctx.get?.('tokenMeter') as TokenMeter | undefined
|
|
2075
|
+
contextTokens = meter?.measure(session).totalTokens
|
|
2076
|
+
} catch {
|
|
2077
|
+
contextTokens = undefined
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
// The route-capacity window is published by the host's `contextPressure`
|
|
2081
|
+
// projection; the old request/header scan + resolveModelInfo call stays as
|
|
2082
|
+
// a fallback for deployments without the projection registry.
|
|
2083
|
+
let contextWindow = readProjectionViews(this.projectionRegistry(), session).contextPressure?.contextWindow
|
|
2084
|
+
if (contextWindow === undefined) {
|
|
2085
|
+
try {
|
|
2086
|
+
const header = session.requestHeader?.()?.config
|
|
2087
|
+
const provider = typeof header?.provider === 'string' ? header.provider : undefined
|
|
2088
|
+
const model = typeof header?.model === 'string' ? header.model : undefined
|
|
2089
|
+
if (provider !== undefined && provider !== '' && model !== undefined && model !== '') {
|
|
2090
|
+
const routeKey = `${provider}/${model}`
|
|
2091
|
+
contextWindow = this.contextWindows.get(routeKey)
|
|
2092
|
+
if (contextWindow === undefined && !this.windowRequests.has(routeKey)) {
|
|
2093
|
+
this.windowRequests.add(routeKey)
|
|
2094
|
+
const llm = this.ctx.get?.('llm') as LlmService | undefined
|
|
2095
|
+
void llm?.resolveModelInfo(provider, model)
|
|
2096
|
+
.then(info => {
|
|
2097
|
+
if (info?.context?.contextWindow !== undefined) {
|
|
2098
|
+
this.contextWindows.set(routeKey, info.context.contextWindow)
|
|
2099
|
+
}
|
|
2100
|
+
})
|
|
2101
|
+
.catch(() => {})
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
} catch {
|
|
2105
|
+
contextWindow = undefined
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
return { contextTokens, contextWindow }
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
/** Host projection registry, when this deployment mounts one. */
|
|
2113
|
+
private projectionRegistry(): SessionProjections | undefined {
|
|
2114
|
+
return this.ctx.get?.('sessionProjections') as SessionProjections | undefined
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
/** Card figures from host projections (token totals / cache hit / window). */
|
|
2118
|
+
private projectCardStats(session: Session): ProjectedStats | undefined {
|
|
2119
|
+
const views = readProjectionViews(this.projectionRegistry(), session)
|
|
2120
|
+
const usage = views.tokenUsage
|
|
2121
|
+
const pressure = views.contextPressure
|
|
2122
|
+
if (usage === undefined && pressure === undefined) return undefined
|
|
2123
|
+
const last = usage?.last?.buckets
|
|
2124
|
+
const hit = last === undefined ? undefined : last.cacheReadTokens
|
|
2125
|
+
const uncached = last === undefined ? undefined : last.uncachedInputTokens
|
|
2126
|
+
const cacheHit = hit !== undefined && uncached !== undefined && hit + uncached > 0
|
|
2127
|
+
? hit / (hit + uncached)
|
|
2128
|
+
: undefined
|
|
2129
|
+
return {
|
|
2130
|
+
...(usage === undefined ? {} : {
|
|
2131
|
+
inputTokens: usage.totals.uncachedInputTokens,
|
|
2132
|
+
outputTokens: usage.totals.outputTokens,
|
|
2133
|
+
}),
|
|
2134
|
+
...(cacheHit === undefined ? {} : { cacheHitPct: cacheHit }),
|
|
2135
|
+
...(pressure?.contextWindow === undefined ? {} : { contextWindow: pressure.contextWindow }),
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
}
|