@modesto_ma/dsh-channels 0.3.0 → 0.5.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -53
- package/docs/access-control-design.md +7 -5
- package/docs/attachment-pipeline-v2.md +310 -0
- package/docs/feasibility-and-design.md +1 -1
- package/docs/feishu-onboarding.md +127 -21
- package/docs/persistence.md +6 -4
- package/docs/refactor-plan.md +2 -2
- package/docs/workspace-isolation-design.md +13 -11
- package/lib/client.js +179 -288
- package/lib/client.js.map +1 -1
- package/lib/index.js +12125 -6382
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/cards.ts +9 -9
- package/src/access/controller.ts +10 -10
- package/src/access/index.ts +1 -0
- package/src/access/policy.ts +10 -10
- package/src/access/requests.ts +32 -31
- package/src/access/store.ts +12 -12
- package/src/access/targets.ts +56 -0
- package/src/cards/approval.ts +96 -26
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +1 -0
- package/src/cards/picker.ts +115 -0
- package/src/cards/streaming.ts +56 -7
- package/src/client/api.ts +5 -25
- package/src/client/panel/access-card.tsx +24 -15
- package/src/client/panel/access-tab.tsx +3 -3
- package/src/client/panel/connection-tab.tsx +70 -1
- package/src/client/panel/index.tsx +11 -62
- package/src/client/panel/shared.tsx +0 -23
- package/src/client/panel/workspace-tab.tsx +2 -18
- package/src/client/styles.ts +2 -0
- package/src/commands/registry.ts +2 -2
- package/src/config.ts +190 -32
- package/src/domain.ts +28 -18
- package/src/feishu/agent-context.ts +65 -19
- package/src/feishu/inbound-attachments.ts +440 -3
- package/src/feishu/manifest.ts +247 -0
- package/src/feishu/message-read.ts +1330 -0
- package/src/feishu/native-attachments.ts +998 -0
- package/src/feishu/scopes.ts +171 -0
- package/src/feishu/send-file.ts +543 -0
- package/src/feishu/slash-commands.ts +134 -0
- package/src/feishu/tools.ts +2305 -0
- package/src/feishu-channel.ts +322 -6
- package/src/host/agent.ts +55 -0
- package/src/host/attachments.ts +210 -0
- package/src/host/index.ts +1 -0
- package/src/index.ts +5 -4
- package/src/interactions/approvals.ts +187 -7
- package/src/interactions/pending.ts +3 -3
- package/src/routing/inbound.ts +620 -49
- package/src/service.ts +808 -375
- package/src/session/manager.ts +67 -13
- package/src/session/routes.ts +2 -3
- package/src/settings/contract.ts +90 -89
- package/src/settings/overlay.ts +124 -13
- package/src/settings/routes.ts +3 -17
- package/src/settings/surface.ts +156 -82
- package/src/streamer.ts +94 -5
- package/src/workspaces.ts +39 -26
- package/src/cleanup.ts +0 -535
- package/src/client/panel/cleanup-card.tsx +0 -106
package/src/service.ts
CHANGED
|
@@ -18,11 +18,10 @@
|
|
|
18
18
|
* @module dsh-channels/service
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { lstat, readdir, realpath, rm } from 'node:fs/promises'
|
|
22
|
-
import path from 'node:path'
|
|
23
21
|
import { AccessController } from './access/controller.ts'
|
|
24
22
|
import { AccessRequests } from './access/requests.ts'
|
|
25
23
|
import { AccessStore } from './access/store.ts'
|
|
24
|
+
import { AUTH_USER_TARGET_ERRORS, resolveAuthUserTarget } from './access/targets.ts'
|
|
26
25
|
import { ApprovalAnswerer } from './approvals.ts'
|
|
27
26
|
import type { ApprovalRoute } from './approvals.ts'
|
|
28
27
|
import { QuestionAnswerer } from './questions.ts'
|
|
@@ -33,14 +32,15 @@ import type {
|
|
|
33
32
|
CredentialProvider,
|
|
34
33
|
CordisFiber,
|
|
35
34
|
HostContext,
|
|
35
|
+
LlmModelInfo,
|
|
36
36
|
LlmService,
|
|
37
37
|
Session,
|
|
38
38
|
SessionId,
|
|
39
39
|
SessionProjections,
|
|
40
40
|
TokenMeter,
|
|
41
|
-
Workspace,
|
|
42
41
|
} from './dsh-types.ts'
|
|
43
|
-
import { standaloneInfoCard } from './cards.ts'
|
|
42
|
+
import { buildPickerCard, parsePickAction, standaloneInfoCard, STOP_ACTION } from './cards.ts'
|
|
43
|
+
import type { PickerOption } from './cards.ts'
|
|
44
44
|
import {
|
|
45
45
|
armEffortCommand, armModelCommand, armRenewCommand, registerModelCommand, commandResultCardKind, parseSlashCommand,
|
|
46
46
|
registerEffortCommand, registerRenewCommand, RouteOperationQueue, supportedCommandHelp, supportedCommandList,
|
|
@@ -49,15 +49,11 @@ import type { EffortCommandAction, ParsedSlashCommand } from './commands.ts'
|
|
|
49
49
|
import { commandSpec } from './commands/registry.ts'
|
|
50
50
|
import type { ChannelCommandSpec } from './commands/registry.ts'
|
|
51
51
|
import type { PermissionPresets, SessionQuery } from './dsh-types.ts'
|
|
52
|
-
import { feishuDomain, sessionDirOf } from './domain.ts'
|
|
53
|
-
import {
|
|
54
|
-
applyWorkspaceCleanup, isPathInside, planWorkspaceCleanup, retentionMs,
|
|
55
|
-
} from './cleanup.ts'
|
|
56
|
-
import type { CleanupDeps, CleanupRoute, CleanupTombstone, ScannedSessionDir, WorkspaceStats } from './cleanup.ts'
|
|
52
|
+
import { FEISHU_USER_ID_PATTERN, feishuDomain, sessionDirOf } from './domain.ts'
|
|
57
53
|
import type {
|
|
58
54
|
AccessGroupRecord, AccessRequestRecord, AccessUserRecord,
|
|
59
|
-
ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot,
|
|
60
|
-
RouteRecord,
|
|
55
|
+
ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot,
|
|
56
|
+
ProcessedRecord, RawDomainHandle, RouteRecord,
|
|
61
57
|
} from './domain.ts'
|
|
62
58
|
import { RouteStore } from './session/routes.ts'
|
|
63
59
|
import { InflightRepair } from './session/repair.ts'
|
|
@@ -65,9 +61,21 @@ import { SessionManager } from './session/manager.ts'
|
|
|
65
61
|
import type { LiveAgent, RenewCommit } from './session/manager.ts'
|
|
66
62
|
import { CardDelivery } from './feishu/delivery.ts'
|
|
67
63
|
import { AttachmentIntake } from './feishu/inbound-attachments.ts'
|
|
64
|
+
import { InboundAttachmentStore, effectiveAttachmentMode, resolveAttachmentService } from './feishu/native-attachments.ts'
|
|
65
|
+
import { AttachmentFetchToolRuntime, TopicMessagesToolRuntime } from './feishu/tools.ts'
|
|
66
|
+
import { SendFileToolRuntime } from './feishu/send-file.ts'
|
|
67
|
+
import type { KnownAttachmentMessageInput, KnownAttachmentSource } from './feishu/tools.ts'
|
|
68
|
+
import {
|
|
69
|
+
MessageReader, TOPIC_MESSAGES_MAX_LIMIT, buildHistoryContextBlock,
|
|
70
|
+
buildQuoteContextBlock, clampInt, knownMessageOfItems, knownMessagesOfHistory, quoteItemsOf,
|
|
71
|
+
readQuoteItems,
|
|
72
|
+
} from './feishu/message-read.ts'
|
|
73
|
+
import type { MessageReadRoute, RawMessageItem } from './feishu/message-read.ts'
|
|
74
|
+
import type { AttachmentStore } from './host/attachments.ts'
|
|
68
75
|
import { InboundRouter } from './routing/inbound.ts'
|
|
69
76
|
import type { RequestPolicyController } from './session/request-policy.ts'
|
|
70
|
-
import type { ChannelFactory, FeishuChannel, NormalizedMessage } from './feishu-channel.ts'
|
|
77
|
+
import type { CardActionEvent, ChannelFactory, FeishuChannel, NormalizedMessage } from './feishu-channel.ts'
|
|
78
|
+
import { AppOwnerLookupError, FeishuApiError } from './feishu-channel.ts'
|
|
71
79
|
import { buildHealthCard, foldHealthSnapshot, HEALTH_DEPENDENCY_NAMES } from './health.ts'
|
|
72
80
|
import type { HealthDependencyName, HealthSnapshot } from './health.ts'
|
|
73
81
|
import {
|
|
@@ -84,10 +92,12 @@ import { ACTIVITY_BUILD_MARKER, TurnStreamer } from './streamer.ts'
|
|
|
84
92
|
import type { ProjectedStats, StreamerHooks } from './streamer.ts'
|
|
85
93
|
import type { FeishuConfig } from './config.ts'
|
|
86
94
|
import type {
|
|
87
|
-
AccessMutationResponse, AccessRequestWire, AccessWire,
|
|
95
|
+
AccessMutationResponse, AccessRequestWire, AccessWire, ScopeCheckWire, SlashCommandSyncWire,
|
|
88
96
|
} from './settings/contract.ts'
|
|
89
|
-
import
|
|
90
|
-
import {
|
|
97
|
+
import { checkScopes } from './feishu/scopes.ts'
|
|
98
|
+
import { desiredSlashCommands, syncSlashCommands } from './feishu/slash-commands.ts'
|
|
99
|
+
import { mergeOverlay } from './settings/overlay.ts'
|
|
100
|
+
import type { OverlayPatch, OverlayStore } from './settings/overlay.ts'
|
|
91
101
|
|
|
92
102
|
/** Periodic route-GC sweep cadence (a long-running instance also accumulates
|
|
93
103
|
* dead records; TTL semantics make 6 h granularity more than sufficient). */
|
|
@@ -96,9 +106,18 @@ const GC_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000
|
|
|
96
106
|
/** Dedup-record retention: Feishu redelivery windows are minutes-scale, so a
|
|
97
107
|
* day is 100×+ headroom while keeping the table bounded. */
|
|
98
108
|
const PROCESSED_RETENTION_MS = 24 * 60 * 60 * 1000
|
|
109
|
+
/** Upper bound on the boot-time app-owner lookup before the connection opens. */
|
|
110
|
+
const APP_OWNER_LOOKUP_TIMEOUT_MS = 10_000
|
|
111
|
+
/** Upper bound on the post-connect scope grant query. */
|
|
112
|
+
const SCOPE_CHECK_TIMEOUT_MS = 10_000
|
|
113
|
+
/** Upper bound on the whole post-connect slash-command panel sync (one list plus per-command writes). */
|
|
114
|
+
const SLASH_COMMAND_SYNC_TIMEOUT_MS = 30_000
|
|
99
115
|
|
|
100
|
-
/**
|
|
101
|
-
|
|
116
|
+
/** Outcome of the app-owner lookup for the current connection. */
|
|
117
|
+
type AppOwnerState =
|
|
118
|
+
| { readonly userId: string }
|
|
119
|
+
/** `apiRejected`: Feishu answered with an error code (typically a missing app-info scope). */
|
|
120
|
+
| { readonly error: string; readonly apiRejected: boolean }
|
|
102
121
|
|
|
103
122
|
|
|
104
123
|
interface RenewAdmission {
|
|
@@ -203,52 +222,6 @@ function messageOf(error: unknown): string {
|
|
|
203
222
|
return error instanceof Error ? error.message : String(error)
|
|
204
223
|
}
|
|
205
224
|
|
|
206
|
-
/**
|
|
207
|
-
* Measure one workspace directory for the cleanup preview: total regular-file
|
|
208
|
-
* bytes and file count. Symlinks are never followed; unreadable entries are
|
|
209
|
-
* skipped so a permission problem cannot fail the whole pass.
|
|
210
|
-
*/
|
|
211
|
-
async function measureWorkspace(dir: string): Promise<WorkspaceStats | undefined> {
|
|
212
|
-
let info
|
|
213
|
-
try {
|
|
214
|
-
info = await lstat(dir)
|
|
215
|
-
} catch {
|
|
216
|
-
return undefined
|
|
217
|
-
}
|
|
218
|
-
if (!info.isDirectory()) return undefined
|
|
219
|
-
let bytes = 0
|
|
220
|
-
let files = 0
|
|
221
|
-
const pending = [dir]
|
|
222
|
-
while (pending.length > 0) {
|
|
223
|
-
const current = pending.pop()!
|
|
224
|
-
let entries
|
|
225
|
-
try {
|
|
226
|
-
entries = await readdir(current, { withFileTypes: true })
|
|
227
|
-
} catch {
|
|
228
|
-
continue
|
|
229
|
-
}
|
|
230
|
-
for (const entry of entries) {
|
|
231
|
-
const child = path.join(current, entry.name)
|
|
232
|
-
if (entry.isSymbolicLink()) continue
|
|
233
|
-
if (entry.isDirectory()) {
|
|
234
|
-
pending.push(child)
|
|
235
|
-
continue
|
|
236
|
-
}
|
|
237
|
-
if (!entry.isFile()) continue
|
|
238
|
-
try {
|
|
239
|
-
const stat = await lstat(child)
|
|
240
|
-
if (stat.isFile()) {
|
|
241
|
-
bytes += stat.size
|
|
242
|
-
files += 1
|
|
243
|
-
}
|
|
244
|
-
} catch {
|
|
245
|
-
// The directory changed while walking it; the byte total is advisory.
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
return { mtimeMs: info.mtimeMs, bytes, files }
|
|
250
|
-
}
|
|
251
|
-
|
|
252
225
|
export class FeishuService {
|
|
253
226
|
private readonly ctx: HostContext
|
|
254
227
|
/** Live config object. Mutated in place — collaborators captured this reference. */
|
|
@@ -270,10 +243,31 @@ export class FeishuService {
|
|
|
270
243
|
private readonly delivery: CardDelivery
|
|
271
244
|
/** Inbound attachment archival (Feishu resources → workspace files). */
|
|
272
245
|
private readonly attachments: AttachmentIntake
|
|
246
|
+
/**
|
|
247
|
+
* Backend-selecting inbound storage facade. The native path stays lazy in
|
|
248
|
+
* admission (prefetch images + manifest); this facade owns the legacy
|
|
249
|
+
* workspace download used by `attachmentStore: 'archive'` and by the
|
|
250
|
+
* fallback when the host does not mount `ctx.attachments`.
|
|
251
|
+
*/
|
|
252
|
+
private readonly inboundAttachmentStore: InboundAttachmentStore
|
|
253
|
+
/**
|
|
254
|
+
* Read-only Feishu message access (P2): the quoted-message excerpt and the
|
|
255
|
+
* first-turn topic history. Constructed before the inbound router so the
|
|
256
|
+
* turn assembly can reach it; it resolves the live channel lazily.
|
|
257
|
+
*/
|
|
258
|
+
private readonly messageReader = new MessageReader({ channel: () => this.channel })
|
|
273
259
|
/** Inbound routing: dedup, admission, decision tree, session establishment. */
|
|
274
260
|
private readonly inbound: InboundRouter
|
|
275
261
|
/** Agent/session lifecycle: live registry, resume, presets, renew. */
|
|
276
262
|
private readonly sessions: SessionManager
|
|
263
|
+
/**
|
|
264
|
+
* Model-facing Feishu tools. `feishu_attachment_fetch` plus the shared
|
|
265
|
+
* per-route known-message whitelist the admission, quote and history layers
|
|
266
|
+
* populate; `feishu_topic_messages` reads the current topic on demand.
|
|
267
|
+
*/
|
|
268
|
+
private readonly attachmentFetchTools: AttachmentFetchToolRuntime
|
|
269
|
+
private readonly topicMessagesTools: TopicMessagesToolRuntime
|
|
270
|
+
private readonly sendFileTools: SendFileToolRuntime
|
|
277
271
|
/** FIFO admission for ordinary turns and commands targeting one route. */
|
|
278
272
|
private readonly routeOperations = new RouteOperationQueue()
|
|
279
273
|
/** Active Host command executions, aborted before shutdown tears peers down. */
|
|
@@ -298,15 +292,15 @@ export class FeishuService {
|
|
|
298
292
|
private runningConfig: FeishuConfig | undefined
|
|
299
293
|
/** Guards overlapping panel-triggered reconnects. */
|
|
300
294
|
private restarting = false
|
|
295
|
+
/** One-shot warning for a native deployment without the host attachment library. */
|
|
296
|
+
private warnedNativeAttachmentsUnmounted = false
|
|
297
|
+
/** One-shot warning for silently degraded quote/history reads (missing scope). */
|
|
298
|
+
private warnedPromptReadDegraded = false
|
|
301
299
|
private unlisten: (() => void) | undefined
|
|
302
300
|
/** Disposers for host-scoped listeners registered by `start()`. */
|
|
303
301
|
private readonly unlistenHost: Array<() => boolean> = []
|
|
304
302
|
/** Periodic route-GC timer (unref'd; only armed when `routeTtlMs > 0`). */
|
|
305
303
|
private gcTimer: ReturnType<typeof setInterval> | undefined
|
|
306
|
-
/** Periodic workspace-retention timer (only armed when auto cleanup is on). */
|
|
307
|
-
private retentionTimer: ReturnType<typeof setInterval> | undefined
|
|
308
|
-
/** Guards overlapping cleanup passes (panel button / daily timer). */
|
|
309
|
-
private cleanupRunning = false
|
|
310
304
|
/** HITL approval answerer (created in `start()`). */
|
|
311
305
|
private approvalAnswerer: ApprovalAnswerer | undefined
|
|
312
306
|
/** HITL structured-question answerer (`ask_user_question`). */
|
|
@@ -323,6 +317,17 @@ export class FeishuService {
|
|
|
323
317
|
/** Administrator DM authorization cards + bot-added group info cards. */
|
|
324
318
|
private accessRequests: AccessRequests | undefined
|
|
325
319
|
private unlistenBotAdded: (() => void) | undefined
|
|
320
|
+
/**
|
|
321
|
+
* App owner resolved at `start()`; the administrator whenever `adminUserId`
|
|
322
|
+
* is unset. Cleared on shutdown so a reconnect looks it up again.
|
|
323
|
+
*/
|
|
324
|
+
private appOwner: AppOwnerState | undefined
|
|
325
|
+
/** Post-connect scope self-check; cleared on shutdown. */
|
|
326
|
+
private scopeCheckState: ScopeCheckWire | undefined
|
|
327
|
+
/** Post-connect slash-command panel sync; cleared on shutdown. */
|
|
328
|
+
private slashCommandSyncState: SlashCommandSyncWire | undefined
|
|
329
|
+
/** Aborts the running panel sync's remaining writes (timeout, shutdown, reconnect). */
|
|
330
|
+
private slashCommandSyncAbort: AbortController | undefined
|
|
326
331
|
|
|
327
332
|
constructor(
|
|
328
333
|
ctx: HostContext,
|
|
@@ -355,6 +360,26 @@ export class FeishuService {
|
|
|
355
360
|
persistRoute: (key, record) => this.routes.put(key, record),
|
|
356
361
|
started: () => this.started,
|
|
357
362
|
detachStream: sessionId => this.streamer?.detach(sessionId),
|
|
363
|
+
mountTools: (agentCtx, sessionId) => {
|
|
364
|
+
// The fetch tool only makes sense for the lazy native backend: in
|
|
365
|
+
// archive deployments (explicit, missing host library, or fetch tool
|
|
366
|
+
// disabled) every attachment is downloaded before the turn and the
|
|
367
|
+
// Agent reads the workspace copy, so mounting the tool would only
|
|
368
|
+
// advertise an operation that can never be authorized.
|
|
369
|
+
//
|
|
370
|
+
// The topic-messages tool is backend-independent: quoted excerpts and
|
|
371
|
+
// topic history are read-only, so it is mounted for both backends and
|
|
372
|
+
// self-gates on `feishuTools.topicMessages`; `feishu_send_file`
|
|
373
|
+
// self-gates on `feishuTools.sendFile`. The mounts start together and
|
|
374
|
+
// resolve as one promise (no serial blocking).
|
|
375
|
+
const mounts: Array<Promise<void>> = []
|
|
376
|
+
if (this.effectiveAttachmentBackend() === 'native') {
|
|
377
|
+
mounts.push(this.attachmentFetchTools.mount(agentCtx, sessionId))
|
|
378
|
+
}
|
|
379
|
+
mounts.push(this.topicMessagesTools.mount(agentCtx, sessionId))
|
|
380
|
+
mounts.push(this.sendFileTools.mount(agentCtx, sessionId))
|
|
381
|
+
return Promise.all(mounts).then(() => undefined)
|
|
382
|
+
},
|
|
358
383
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
359
384
|
})
|
|
360
385
|
this.attachments = new AttachmentIntake({
|
|
@@ -362,6 +387,70 @@ export class FeishuService {
|
|
|
362
387
|
channel: () => this.channel,
|
|
363
388
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
364
389
|
})
|
|
390
|
+
this.inboundAttachmentStore = new InboundAttachmentStore({
|
|
391
|
+
enabled: () => this.config.acceptAttachments,
|
|
392
|
+
// Effective backend, not the raw config: `store()` is only called after
|
|
393
|
+
// admission already decided `archive`, so a native-configured deployment
|
|
394
|
+
// with the fetch tool disabled must download to the workspace here too.
|
|
395
|
+
mode: () => this.effectiveAttachmentBackend(),
|
|
396
|
+
service: () => this.nativeAttachmentService(),
|
|
397
|
+
channel: () => this.channel,
|
|
398
|
+
archive: this.attachments,
|
|
399
|
+
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
400
|
+
})
|
|
401
|
+
this.attachmentFetchTools = new AttachmentFetchToolRuntime({
|
|
402
|
+
config: () => this.config,
|
|
403
|
+
channel: () => this.channel,
|
|
404
|
+
routeOfSession: sessionId => {
|
|
405
|
+
const record = this.routes.findBySession(sessionId)
|
|
406
|
+
if (record === undefined) return undefined
|
|
407
|
+
return {
|
|
408
|
+
key: record.key,
|
|
409
|
+
kind: record.kind,
|
|
410
|
+
chatId: record.chatId,
|
|
411
|
+
chatType: record.chatType,
|
|
412
|
+
senderUserId: record.senderUserId,
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
416
|
+
})
|
|
417
|
+
this.topicMessagesTools = new TopicMessagesToolRuntime({
|
|
418
|
+
config: () => this.config,
|
|
419
|
+
channel: () => this.channel,
|
|
420
|
+
routeOfSession: sessionId => {
|
|
421
|
+
const record = this.routes.findBySession(sessionId)
|
|
422
|
+
if (record === undefined) return undefined
|
|
423
|
+
return {
|
|
424
|
+
key: record.key,
|
|
425
|
+
kind: record.kind,
|
|
426
|
+
chatId: record.chatId,
|
|
427
|
+
chatType: record.chatType,
|
|
428
|
+
senderUserId: record.senderUserId,
|
|
429
|
+
}
|
|
430
|
+
},
|
|
431
|
+
noteKnownMessage: (route, message, source) => {
|
|
432
|
+
this.attachmentFetchTools.noteKnownMessage(route, message, source)
|
|
433
|
+
},
|
|
434
|
+
isKnownMessage: (route, messageId) => this.attachmentFetchTools.isKnownMessage(route, messageId),
|
|
435
|
+
// The tool description must not promise a fetch tool that archive
|
|
436
|
+
// deployments do not mount; the runtime appends a degradation note.
|
|
437
|
+
fetchAvailable: () => this.effectiveAttachmentBackend() === 'native',
|
|
438
|
+
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
439
|
+
})
|
|
440
|
+
this.sendFileTools = new SendFileToolRuntime({
|
|
441
|
+
config: () => this.config,
|
|
442
|
+
channel: () => (this.started ? this.channel : undefined),
|
|
443
|
+
targetOfSession: sessionId => {
|
|
444
|
+
const record = this.routes.findBySession(sessionId)
|
|
445
|
+
if (record === undefined) return undefined
|
|
446
|
+
return {
|
|
447
|
+
replyTo: record.cardMessageId,
|
|
448
|
+
inThread: record.kind === 'thread',
|
|
449
|
+
rootDir: sessionDirOf(record),
|
|
450
|
+
}
|
|
451
|
+
},
|
|
452
|
+
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
453
|
+
})
|
|
365
454
|
this.delivery = new CardDelivery({
|
|
366
455
|
channel: () => this.channel,
|
|
367
456
|
started: () => this.started,
|
|
@@ -373,7 +462,7 @@ export class FeishuService {
|
|
|
373
462
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
374
463
|
})
|
|
375
464
|
this.accessController = new AccessController({
|
|
376
|
-
config: () => this.
|
|
465
|
+
config: () => this.accessConfig(),
|
|
377
466
|
store: this.accessStore,
|
|
378
467
|
channel: () => this.channel,
|
|
379
468
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
@@ -397,15 +486,247 @@ export class FeishuService {
|
|
|
397
486
|
repair: this.repair,
|
|
398
487
|
delivery: this.delivery,
|
|
399
488
|
sessions: this.sessions,
|
|
400
|
-
|
|
489
|
+
attachmentMode: () => this.config.attachmentStore,
|
|
490
|
+
nativeAttachments: () => this.nativeAttachmentService(),
|
|
491
|
+
attachmentStore: this.inboundAttachmentStore,
|
|
492
|
+
noteKnownAttachments: (record, message, source) => {
|
|
493
|
+
this.noteKnownAttachmentMessage(record, message, source ?? 'trigger')
|
|
494
|
+
},
|
|
495
|
+
messageRead: {
|
|
496
|
+
quote: input => this.readQuoteContext(input),
|
|
497
|
+
history: input => this.readHistoryContext(input),
|
|
498
|
+
},
|
|
499
|
+
prefetchImages: () => ({
|
|
500
|
+
enabled: this.config.prefetchImages,
|
|
501
|
+
maxImageBytes: this.config.prefetchImageMaxBytes,
|
|
502
|
+
maxTotalBytes: this.config.prefetchTotalMaxBytes,
|
|
503
|
+
timeoutMs: this.config.prefetchTimeoutMs,
|
|
504
|
+
}),
|
|
401
505
|
access: this.accessController,
|
|
402
506
|
queue: this.routeOperations,
|
|
403
507
|
streamer: () => this.streamer,
|
|
404
508
|
questionAnswerer: () => this.questionAnswerer,
|
|
509
|
+
approvalAnswerer: () => this.approvalAnswerer,
|
|
405
510
|
commands: { handleSlash: (msg, line) => this.handleSlashMessage(msg, line) },
|
|
406
511
|
})
|
|
407
512
|
}
|
|
408
513
|
|
|
514
|
+
/** Effective inbound attachment backend (matches the inbound router). */
|
|
515
|
+
private effectiveAttachmentBackend(): 'native' | 'archive' {
|
|
516
|
+
return effectiveAttachmentMode(
|
|
517
|
+
this.config.attachmentStore,
|
|
518
|
+
this.nativeAttachmentService(),
|
|
519
|
+
this.config.feishuTools.attachmentFetch,
|
|
520
|
+
this.config.acceptAttachments,
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Resolve the host's native attachment library from the plugin scope.
|
|
526
|
+
*
|
|
527
|
+
* The service is registered host-wide (`@deepseek-ai/dsh-attachment-local`),
|
|
528
|
+
* so the safe `ctx.get` lookup resolves it even though this plugin does not
|
|
529
|
+
* declare `attachments` in its cordis `inject` list (the per-Agent scopes
|
|
530
|
+
* still inject it for the fetch tool). A missing service reads as
|
|
531
|
+
* `undefined` and switches admission to the legacy archive backend.
|
|
532
|
+
*/
|
|
533
|
+
private nativeAttachmentService(): AttachmentStore | undefined {
|
|
534
|
+
const service = resolveAttachmentService(this.ctx)
|
|
535
|
+
if (service === undefined && this.config.attachmentStore === 'native'
|
|
536
|
+
&& !this.warnedNativeAttachmentsUnmounted) {
|
|
537
|
+
// The facade now receives the effective backend, so its own downgrade
|
|
538
|
+
// warning cannot fire: report the missing library once from the resolver.
|
|
539
|
+
this.warnedNativeAttachmentsUnmounted = true
|
|
540
|
+
this.ctx.logger?.warn?.(
|
|
541
|
+
'dsh-channels: attachmentStore=native 但宿主未挂载 ctx.attachments,'
|
|
542
|
+
+ '入站附件回退话题 attachments/ 工作区落盘',
|
|
543
|
+
)
|
|
544
|
+
}
|
|
545
|
+
return service
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Pre-authorize one admitted Feishu message for `feishu_attachment_fetch`.
|
|
550
|
+
*
|
|
551
|
+
* The admission and quote layers call this after their access gates for the
|
|
552
|
+
* triggering / quoted message; the tool otherwise only authorizes the route
|
|
553
|
+
* owner's own messages it reads back from Feishu. Registering is a pure
|
|
554
|
+
* in-memory operation (one LRU entry per route) and never fails the caller.
|
|
555
|
+
*/
|
|
556
|
+
noteKnownAttachmentMessage(
|
|
557
|
+
record: RouteRecord,
|
|
558
|
+
message: KnownAttachmentMessageInput,
|
|
559
|
+
source: KnownAttachmentSource = 'trigger',
|
|
560
|
+
): void {
|
|
561
|
+
this.attachmentFetchTools.noteKnownMessage({
|
|
562
|
+
key: record.key,
|
|
563
|
+
kind: record.kind,
|
|
564
|
+
chatId: record.chatId,
|
|
565
|
+
chatType: record.chatType,
|
|
566
|
+
senderUserId: record.senderUserId,
|
|
567
|
+
}, message, source)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ---- read-only message context (quoted excerpt / topic history) -------
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Channel-plane identity of one route for message reads (structurally the
|
|
574
|
+
* same as the attachment-tool route: key/kind/chat/sender).
|
|
575
|
+
*/
|
|
576
|
+
private messageReadRouteOf(record: RouteRecord): MessageReadRoute {
|
|
577
|
+
return {
|
|
578
|
+
key: record.key,
|
|
579
|
+
kind: record.kind,
|
|
580
|
+
chatId: record.chatId,
|
|
581
|
+
chatType: record.chatType,
|
|
582
|
+
senderUserId: record.senderUserId,
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Resolve one quoted message into the bounded `<feishu_quote>` block and the
|
|
588
|
+
* whitelist registration of the quoted message (merged-forward containers
|
|
589
|
+
* keep their container id, per the P0 download rule).
|
|
590
|
+
*
|
|
591
|
+
* The quoted message must belong to this route's visible container: another
|
|
592
|
+
* chat is always rejected; on a thread route a quoted message of a
|
|
593
|
+
* *different* topic is rejected as well (a main-timeline message carries no
|
|
594
|
+
* thread id and stays allowed — quotes happen before the bot owns a topic);
|
|
595
|
+
* on a degraded chat route any topic message is rejected (a chat route must
|
|
596
|
+
* not read another topic of the same group).
|
|
597
|
+
*
|
|
598
|
+
* The read budget is the short prompt-enrichment budget, never the model
|
|
599
|
+
* tool timeout. Read failures (missing scope, deleted message, channel down)
|
|
600
|
+
* degrade to `undefined`: the turn runs without an excerpt (AC6).
|
|
601
|
+
*/
|
|
602
|
+
private async readQuoteContext(input: {
|
|
603
|
+
messageId: string
|
|
604
|
+
record: RouteRecord
|
|
605
|
+
}): Promise<{ block: string; message?: KnownAttachmentMessageInput } | undefined> {
|
|
606
|
+
try {
|
|
607
|
+
const result = await readQuoteItems(this.messageReader, input.messageId, {
|
|
608
|
+
timeoutMs: this.config.promptContextTimeoutMs,
|
|
609
|
+
})
|
|
610
|
+
if (!result.ok) {
|
|
611
|
+
this.reportDegradedRead('quoted message', input.messageId, result.reason)
|
|
612
|
+
return undefined
|
|
613
|
+
}
|
|
614
|
+
const container: RawMessageItem | undefined = quoteItemsOf(result.value)
|
|
615
|
+
if (container === undefined) return undefined
|
|
616
|
+
if (!this.quotedMessageBelongsToRoute(input.record, container)) {
|
|
617
|
+
this.ctx.logger?.debug?.(
|
|
618
|
+
'dsh-channels: dropping quoted message %s from another chat/topic', input.messageId,
|
|
619
|
+
)
|
|
620
|
+
return undefined
|
|
621
|
+
}
|
|
622
|
+
const block = buildQuoteContextBlock(result.value, {
|
|
623
|
+
attachmentFetchAvailable: this.attachmentFetchMounted(),
|
|
624
|
+
})
|
|
625
|
+
if (block === '') return undefined
|
|
626
|
+
const message = knownMessageOfItems(result.value)
|
|
627
|
+
return { block, ...(message === undefined ? {} : { message }) }
|
|
628
|
+
} catch (error) {
|
|
629
|
+
// Defensive: the reader reports structured failures; a raw throw is still
|
|
630
|
+
// only ever a missing excerpt, never a failed turn.
|
|
631
|
+
this.ctx.logger?.debug?.(
|
|
632
|
+
'dsh-channels: reading quoted message %s failed: %s', input.messageId, String(error),
|
|
633
|
+
)
|
|
634
|
+
return undefined
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Whether one quoted message may be shown/authorized for one route. */
|
|
639
|
+
private quotedMessageBelongsToRoute(record: RouteRecord, container: RawMessageItem): boolean {
|
|
640
|
+
// Fail closed on a missing/foreign chat id: every real Feishu item carries
|
|
641
|
+
// one, so an absent id is a malformed response, never a legitimate quote.
|
|
642
|
+
if (container.chatId === undefined || container.chatId !== record.chatId) return false
|
|
643
|
+
// A main-timeline message has no thread id; a message of another topic must
|
|
644
|
+
// never leak into this route's excerpt or whitelist. A degraded chat route
|
|
645
|
+
// (no topic) must reject topic messages entirely, even in the same chat.
|
|
646
|
+
if (record.kind === 'thread') {
|
|
647
|
+
return container.threadId === undefined || container.threadId === record.key
|
|
648
|
+
}
|
|
649
|
+
return container.threadId === undefined
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Resolve the bounded first-turn `<feishu_history>` block plus the whitelist
|
|
654
|
+
* registrations of the history messages (chronological order).
|
|
655
|
+
*
|
|
656
|
+
* `limit` is `historyBootstrapLimit` (0/negative → no read at all). The page
|
|
657
|
+
* is requested newest-first so a long topic yields the most recent messages;
|
|
658
|
+
* excluded ids (the trigger and the streaming card), deleted messages, and
|
|
659
|
+
* messages outside the route container are filtered before the newest
|
|
660
|
+
* `limit` rows are reversed into chronological order for rendering. The read
|
|
661
|
+
* uses the short prompt-enrichment budget and degrades to `undefined` (AC6).
|
|
662
|
+
*/
|
|
663
|
+
private async readHistoryContext(input: {
|
|
664
|
+
record: RouteRecord
|
|
665
|
+
limit: number
|
|
666
|
+
excludeMessageIds: readonly string[]
|
|
667
|
+
}): Promise<{ block: string; messages: readonly KnownAttachmentMessageInput[] } | undefined> {
|
|
668
|
+
if (!Number.isFinite(input.limit) || input.limit <= 0) return undefined
|
|
669
|
+
try {
|
|
670
|
+
const route = this.messageReadRouteOf(input.record)
|
|
671
|
+
const pageSize = clampInt(
|
|
672
|
+
input.limit + input.excludeMessageIds.length,
|
|
673
|
+
1,
|
|
674
|
+
TOPIC_MESSAGES_MAX_LIMIT,
|
|
675
|
+
)
|
|
676
|
+
const result = await this.messageReader.listRoute(route, {
|
|
677
|
+
pageSize,
|
|
678
|
+
order: 'newest-first',
|
|
679
|
+
timeoutMs: this.config.promptContextTimeoutMs,
|
|
680
|
+
})
|
|
681
|
+
if (!result.ok) {
|
|
682
|
+
this.reportDegradedRead('topic history', input.record.key, result.reason)
|
|
683
|
+
return undefined
|
|
684
|
+
}
|
|
685
|
+
const excluded = new Set(input.excludeMessageIds)
|
|
686
|
+
const recent = result.value.items
|
|
687
|
+
.filter(item => !item.deleted && !excluded.has(item.messageId))
|
|
688
|
+
.slice(0, input.limit)
|
|
689
|
+
.reverse()
|
|
690
|
+
const block = buildHistoryContextBlock(recent, {
|
|
691
|
+
maxMessages: input.limit,
|
|
692
|
+
attachmentFetchAvailable: this.attachmentFetchMounted(),
|
|
693
|
+
})
|
|
694
|
+
if (block === '') return undefined
|
|
695
|
+
return { block, messages: knownMessagesOfHistory(recent) }
|
|
696
|
+
} catch (error) {
|
|
697
|
+
this.ctx.logger?.debug?.(
|
|
698
|
+
'dsh-channels: reading topic history of %s failed: %s', input.record.key, String(error),
|
|
699
|
+
)
|
|
700
|
+
return undefined
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** Whether this deployment mounts the lazy attachment fetch tool. */
|
|
705
|
+
private attachmentFetchMounted(): boolean {
|
|
706
|
+
return this.effectiveAttachmentBackend() === 'native'
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Report one silently degraded prompt read: debug per occurrence (expected
|
|
711
|
+
* when a deployment misses a scope) plus ONE warn per service lifetime so an
|
|
712
|
+
* operator can find the cause without log spam.
|
|
713
|
+
*/
|
|
714
|
+
private reportDegradedRead(kind: string, target: string, reason: string): void {
|
|
715
|
+
if (this.warnedPromptReadDegraded) {
|
|
716
|
+
this.ctx.logger?.debug?.(
|
|
717
|
+
'dsh-channels: %s read of %s failed (degraded): %s', kind, target, reason,
|
|
718
|
+
)
|
|
719
|
+
return
|
|
720
|
+
}
|
|
721
|
+
this.warnedPromptReadDegraded = true
|
|
722
|
+
this.ctx.logger?.warn?.(
|
|
723
|
+
'dsh-channels: %s(%s)读取失败,已静默降级(不带引用/历史上下文继续回合):%s。'
|
|
724
|
+
+ '如持续出现请检查飞书应用的消息读取权限(如 im:message:readonly / im:message.group_msg),'
|
|
725
|
+
+ '或确认该消息未被撤回、网络正常',
|
|
726
|
+
kind, target, reason,
|
|
727
|
+
)
|
|
728
|
+
}
|
|
729
|
+
|
|
409
730
|
// ---- lifecycle / Web settings panel ----------------------------------
|
|
410
731
|
|
|
411
732
|
/**
|
|
@@ -511,7 +832,7 @@ export class FeishuService {
|
|
|
511
832
|
/** Re-read the Web overlay over the profile config (no channel touch). */
|
|
512
833
|
private async loadOverlay(): Promise<void> {
|
|
513
834
|
const overlay = this.overlay === undefined ? {} : await this.overlay.load()
|
|
514
|
-
this.replaceConfig(
|
|
835
|
+
this.replaceConfig(mergeOverlay(this.baseConfig, overlay))
|
|
515
836
|
}
|
|
516
837
|
|
|
517
838
|
/**
|
|
@@ -530,9 +851,8 @@ export class FeishuService {
|
|
|
530
851
|
* Apply an already-validated overlay over the profile config. Fields read
|
|
531
852
|
* per event/turn take effect immediately; `RESTART_KEYS` need `restart()`.
|
|
532
853
|
*/
|
|
533
|
-
applyOverlay(overlay:
|
|
534
|
-
this.replaceConfig(
|
|
535
|
-
this.refreshRetentionTimer()
|
|
854
|
+
applyOverlay(overlay: OverlayPatch): void {
|
|
855
|
+
this.replaceConfig(mergeOverlay(this.baseConfig, overlay))
|
|
536
856
|
}
|
|
537
857
|
|
|
538
858
|
/**
|
|
@@ -581,15 +901,17 @@ export class FeishuService {
|
|
|
581
901
|
|
|
582
902
|
/** Whitelists + pending authorization requests (read-only Web projection). */
|
|
583
903
|
accessSnapshot(): AccessWire {
|
|
584
|
-
const admin = this.
|
|
904
|
+
const admin = this.effectiveAdminUserId()
|
|
905
|
+
const owner = this.appOwner
|
|
585
906
|
return {
|
|
586
907
|
mode: this.config.accessMode,
|
|
587
908
|
// Whitelists live in the storage domain, which is only open while the
|
|
588
909
|
// channel runs; an unconnected panel must not present them as empty.
|
|
589
910
|
loaded: this.domain !== undefined,
|
|
590
911
|
admin: {
|
|
591
|
-
...(admin === undefined ? {} : {
|
|
912
|
+
...(admin === undefined ? {} : { userId: admin, source: this.configuredAdminUserId() === undefined ? 'owner' : 'config' }),
|
|
592
913
|
configured: admin !== undefined,
|
|
914
|
+
...(owner !== undefined && 'error' in owner ? { ownerLookupError: owner.error } : {}),
|
|
593
915
|
},
|
|
594
916
|
groups: this.accessStore.groupRecords().map(record => ({
|
|
595
917
|
chatId: record.chatId,
|
|
@@ -598,7 +920,7 @@ export class FeishuService {
|
|
|
598
920
|
addedAt: record.addedAt,
|
|
599
921
|
})),
|
|
600
922
|
users: this.accessStore.userRecords().map(record => ({
|
|
601
|
-
|
|
923
|
+
userId: record.userId,
|
|
602
924
|
...(record.name === undefined ? {} : { name: record.name }),
|
|
603
925
|
source: record.source,
|
|
604
926
|
addedAt: record.addedAt,
|
|
@@ -615,7 +937,7 @@ export class FeishuService {
|
|
|
615
937
|
if (name !== undefined) wire.name = name
|
|
616
938
|
if (record.chatId !== undefined) wire.chatId = record.chatId
|
|
617
939
|
if (record.groupName !== undefined) wire.groupName = record.groupName
|
|
618
|
-
const requester = record.requesterName ?? record.
|
|
940
|
+
const requester = record.requesterName ?? record.requesterUserId
|
|
619
941
|
if (requester !== undefined) wire.requester = requester
|
|
620
942
|
return wire
|
|
621
943
|
}),
|
|
@@ -626,8 +948,8 @@ export class FeishuService {
|
|
|
626
948
|
async addAccessGroup(chatId: string, name?: string): Promise<AccessMutationResponse> {
|
|
627
949
|
const id = this.cleanAccessTargetId(chatId)
|
|
628
950
|
if (id === undefined) return { ok: false, error: 'chat_id 不合法(应为 oc_ 开头的群 ID)' }
|
|
629
|
-
const admin = this.
|
|
630
|
-
if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员
|
|
951
|
+
const admin = this.effectiveAdminUserId()
|
|
952
|
+
if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 user_id' }
|
|
631
953
|
try {
|
|
632
954
|
if (!this.accessStore.hasGroup(id)) {
|
|
633
955
|
await this.accessStore.addGroup({
|
|
@@ -657,15 +979,15 @@ export class FeishuService {
|
|
|
657
979
|
}
|
|
658
980
|
|
|
659
981
|
/** Panel: authorize one user (global across authorized groups). */
|
|
660
|
-
async addAccessUser(
|
|
661
|
-
const id = this.cleanAccessUserId(
|
|
662
|
-
if (id === undefined) return { ok: false, error: '
|
|
663
|
-
const admin = this.
|
|
664
|
-
if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员
|
|
982
|
+
async addAccessUser(userId: string, name?: string): Promise<AccessMutationResponse> {
|
|
983
|
+
const id = this.cleanAccessUserId(userId)
|
|
984
|
+
if (id === undefined) return { ok: false, error: 'user_id 不合法(只能包含字母、数字、_ 和 -,不是 ou_ 开头的 open_id)' }
|
|
985
|
+
const admin = this.effectiveAdminUserId()
|
|
986
|
+
if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 user_id' }
|
|
665
987
|
try {
|
|
666
988
|
if (!this.accessStore.hasUser(id)) {
|
|
667
989
|
await this.accessStore.addUser({
|
|
668
|
-
|
|
990
|
+
userId: id,
|
|
669
991
|
...(name === undefined || name.trim() === '' ? {} : { name: name.trim() }),
|
|
670
992
|
source: 'panel',
|
|
671
993
|
addedBy: admin,
|
|
@@ -679,9 +1001,9 @@ export class FeishuService {
|
|
|
679
1001
|
}
|
|
680
1002
|
|
|
681
1003
|
/** Panel: revoke one user. */
|
|
682
|
-
async removeAccessUser(
|
|
683
|
-
const id = this.cleanAccessUserId(
|
|
684
|
-
if (id === undefined) return { ok: false, error: '
|
|
1004
|
+
async removeAccessUser(userId: string): Promise<AccessMutationResponse> {
|
|
1005
|
+
const id = this.cleanAccessUserId(userId)
|
|
1006
|
+
if (id === undefined) return { ok: false, error: 'user_id 不合法' }
|
|
685
1007
|
try {
|
|
686
1008
|
await this.accessStore.removeUser(id)
|
|
687
1009
|
return { ok: true, access: this.accessSnapshot() }
|
|
@@ -694,8 +1016,8 @@ export class FeishuService {
|
|
|
694
1016
|
async resolveAccessRequest(token: string, decision: 'allow' | 'deny'): Promise<AccessMutationResponse> {
|
|
695
1017
|
const requests = this.accessRequests
|
|
696
1018
|
if (requests === undefined) return { ok: false, error: '授权服务尚未启动' }
|
|
697
|
-
const actor = this.
|
|
698
|
-
if (actor === undefined) return { ok: false, error: '请先在「访问授权」标签页配置管理员
|
|
1019
|
+
const actor = this.effectiveAdminUserId()
|
|
1020
|
+
if (actor === undefined) return { ok: false, error: '请先在「访问授权」标签页配置管理员 user_id' }
|
|
699
1021
|
try {
|
|
700
1022
|
const result = await requests.resolve(token, decision, actor)
|
|
701
1023
|
if (!result.ok) return { ok: false, error: result.error }
|
|
@@ -705,275 +1027,191 @@ export class FeishuService {
|
|
|
705
1027
|
}
|
|
706
1028
|
}
|
|
707
1029
|
|
|
708
|
-
/**
|
|
709
|
-
private
|
|
710
|
-
const
|
|
711
|
-
if (
|
|
712
|
-
|
|
713
|
-
if (/[\u0000-\u001f\u007f]/u.test(value)) return undefined
|
|
714
|
-
return value
|
|
1030
|
+
/** `/auth status` administrator line: id plus where it came from. */
|
|
1031
|
+
private adminStatusLine(): string {
|
|
1032
|
+
const admin = this.effectiveAdminUserId()
|
|
1033
|
+
if (admin === undefined) return '未配置'
|
|
1034
|
+
return this.configuredAdminUserId() === undefined ? `\`${admin}\`(应用所有者,自动)` : `\`${admin}\``
|
|
715
1035
|
}
|
|
716
1036
|
|
|
717
|
-
/**
|
|
718
|
-
private
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
return value
|
|
1037
|
+
/** The explicitly configured administrator, trimmed; empty counts as unset. */
|
|
1038
|
+
private configuredAdminUserId(): string | undefined {
|
|
1039
|
+
const admin = this.config.adminUserId?.trim()
|
|
1040
|
+
return admin === undefined || admin === '' ? undefined : admin
|
|
722
1041
|
}
|
|
723
1042
|
|
|
724
|
-
|
|
1043
|
+
/** Configured administrator, else the app owner looked up at `start()`. */
|
|
1044
|
+
private effectiveAdminUserId(): string | undefined {
|
|
1045
|
+
const configured = this.configuredAdminUserId()
|
|
1046
|
+
if (configured !== undefined) return configured
|
|
1047
|
+
const owner = this.appOwner
|
|
1048
|
+
return owner !== undefined && 'userId' in owner ? owner.userId : undefined
|
|
1049
|
+
}
|
|
725
1050
|
|
|
726
|
-
/**
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
* footprint. Never deletes anything.
|
|
730
|
-
*/
|
|
731
|
-
async cleanupPreview(): Promise<{
|
|
732
|
-
retention: string
|
|
733
|
-
thresholdMs?: number
|
|
734
|
-
auto: boolean
|
|
735
|
-
workspaces: number
|
|
736
|
-
candidates: number
|
|
737
|
-
candidateBytes: number
|
|
738
|
-
error?: string
|
|
739
|
-
}> {
|
|
740
|
-
const retention = this.config.workspaceRetention
|
|
741
|
-
const window = retentionMs(retention)
|
|
742
|
-
const base = {
|
|
743
|
-
retention,
|
|
744
|
-
...(window === undefined ? {} : { thresholdMs: window }),
|
|
745
|
-
auto: this.config.workspaceRetentionAuto,
|
|
746
|
-
}
|
|
747
|
-
if (this.domain === undefined) {
|
|
748
|
-
// No connection: routes live in the closed storage domain, so a preview
|
|
749
|
-
// would mistake every directory for a candidate. Report why instead.
|
|
750
|
-
return {
|
|
751
|
-
...base,
|
|
752
|
-
workspaces: 0,
|
|
753
|
-
candidates: 0,
|
|
754
|
-
candidateBytes: 0,
|
|
755
|
-
error: '飞书连接建立后即可预览和清理工作区',
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
try {
|
|
759
|
-
const workspaces = this.ctx.workspaceRegistry.list().filter(workspace => this.isPluginWorkspace(workspace))
|
|
760
|
-
const plan = await planWorkspaceCleanup(this.cleanupDeps())
|
|
761
|
-
return {
|
|
762
|
-
...base,
|
|
763
|
-
workspaces: workspaces.length,
|
|
764
|
-
candidates: plan?.candidates.length ?? 0,
|
|
765
|
-
candidateBytes: plan?.candidates.reduce((total, candidate) => total + candidate.bytes, 0) ?? 0,
|
|
766
|
-
}
|
|
767
|
-
} catch (error) {
|
|
768
|
-
return { ...base, workspaces: 0, candidates: 0, candidateBytes: 0, error: messageOf(error) }
|
|
769
|
-
}
|
|
1051
|
+
/** Live config with `adminUserId` replaced by the effective administrator. */
|
|
1052
|
+
private accessConfig(): FeishuConfig {
|
|
1053
|
+
return { ...this.config, adminUserId: this.effectiveAdminUserId() }
|
|
770
1054
|
}
|
|
771
1055
|
|
|
772
1056
|
/**
|
|
773
|
-
*
|
|
774
|
-
*
|
|
775
|
-
*
|
|
776
|
-
* never deleted.
|
|
777
|
-
*
|
|
778
|
-
* Active routes bound to a removed directory are retired first (thread
|
|
779
|
-
* routes leave a tombstone), so an old topic fails with the explicit expired
|
|
780
|
-
* card instead of resuming into a missing directory. DSH Session logs are not
|
|
781
|
-
* touched.
|
|
1057
|
+
* Look up the app owner for the administrator fallback. Never throws: a
|
|
1058
|
+
* failure is recorded for the panel and only warned about when no
|
|
1059
|
+
* administrator is configured (the fallback is then what admission needs).
|
|
782
1060
|
*/
|
|
783
|
-
async
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
freedBytes: number
|
|
789
|
-
failures: string[]
|
|
790
|
-
error?: string
|
|
791
|
-
}> {
|
|
792
|
-
const empty = {
|
|
793
|
-
ok: false,
|
|
794
|
-
deletedWorkspaces: 0,
|
|
795
|
-
deletedSessionDirs: 0,
|
|
796
|
-
retiredRoutes: 0,
|
|
797
|
-
freedBytes: 0,
|
|
798
|
-
failures: [] as string[],
|
|
799
|
-
}
|
|
800
|
-
if (this.domain === undefined) {
|
|
801
|
-
// Without the storage domain the route table is unreadable, so a cleanup
|
|
802
|
-
// could mistake live directories for candidates and fail to retire their
|
|
803
|
-
// routes. Refuse rather than rely on the panel disabling the button.
|
|
804
|
-
return { ...empty, error: '飞书连接建立后才能清理工作区' }
|
|
805
|
-
}
|
|
806
|
-
if (retentionMs(this.config.workspaceRetention) === undefined) {
|
|
807
|
-
return { ...empty, error: '未启用目录清理阈值(workspaceRetention 为 off)' }
|
|
808
|
-
}
|
|
809
|
-
if (this.cleanupRunning) return { ...empty, error: '目录清理正在进行中,请稍候' }
|
|
810
|
-
this.cleanupRunning = true
|
|
1061
|
+
private async resolveAppOwner(channel: FeishuChannel): Promise<void> {
|
|
1062
|
+
this.appOwner = undefined
|
|
1063
|
+
if (channel.appOwnerUserId === undefined) return
|
|
1064
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1065
|
+
let state: AppOwnerState
|
|
811
1066
|
try {
|
|
812
|
-
const
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
ok: true,
|
|
822
|
-
deletedWorkspaces: outcome.deletedWorkspaces,
|
|
823
|
-
deletedSessionDirs: outcome.deletedSessionDirs,
|
|
824
|
-
retiredRoutes: outcome.retiredRoutes,
|
|
825
|
-
freedBytes: outcome.freedBytes,
|
|
826
|
-
failures: outcome.failures.map(failure => `${failure.path}: ${failure.message}`),
|
|
827
|
-
}
|
|
1067
|
+
const userId = await Promise.race([
|
|
1068
|
+
channel.appOwnerUserId(),
|
|
1069
|
+
new Promise<never>((_, reject) => {
|
|
1070
|
+
timer = setTimeout(() => reject(new Error(`超时(${APP_OWNER_LOOKUP_TIMEOUT_MS} ms)`)), APP_OWNER_LOOKUP_TIMEOUT_MS)
|
|
1071
|
+
}),
|
|
1072
|
+
])
|
|
1073
|
+
state = userId !== undefined && FEISHU_USER_ID_PATTERN.test(userId)
|
|
1074
|
+
? { userId }
|
|
1075
|
+
: { error: '应用所有者不是企业内成员,或接口未返回其 user_id', apiRejected: false }
|
|
828
1076
|
} catch (error) {
|
|
829
|
-
|
|
1077
|
+
const detail = error instanceof AppOwnerLookupError ? error.detail : messageOf(error)
|
|
1078
|
+
state = {
|
|
1079
|
+
error: `获取应用信息失败:${detail}。请在「访问授权」手动填写管理员 user_id(私聊机器人发 /userid 获取,私聊模式关闭时可在飞书管理后台的成员详情里查看用户 ID);或为应用开通「获取应用信息」权限并发布版本后重新连接`,
|
|
1080
|
+
apiRejected: error instanceof AppOwnerLookupError && error.code !== undefined,
|
|
1081
|
+
}
|
|
830
1082
|
} finally {
|
|
831
|
-
|
|
1083
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
1084
|
+
}
|
|
1085
|
+
// A background lookup can outlive its connection (shutdown/reconnect).
|
|
1086
|
+
if (this.channel !== channel) return
|
|
1087
|
+
this.appOwner = state
|
|
1088
|
+
if ('error' in state && this.configuredAdminUserId() === undefined) {
|
|
1089
|
+
this.ctx.logger?.warn?.('dsh-channels: app owner fallback unavailable: %s', state.error)
|
|
832
1090
|
}
|
|
833
1091
|
}
|
|
834
1092
|
|
|
835
|
-
/**
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
return pluginTitle && isPathInside(path.resolve(this.config.baseDir), workspace.path)
|
|
1093
|
+
/** Latest post-connect scope self-check, when one finished for this connection. */
|
|
1094
|
+
scopeCheck(): ScopeCheckWire | undefined {
|
|
1095
|
+
return this.scopeCheckState
|
|
839
1096
|
}
|
|
840
1097
|
|
|
841
|
-
/**
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
return {
|
|
845
|
-
baseDir,
|
|
846
|
-
retention: this.config.workspaceRetention,
|
|
847
|
-
listWorkspaces: () => this.ctx.workspaceRegistry.list(),
|
|
848
|
-
listSessionDirs: () => this.listCleanableSessionDirs(baseDir),
|
|
849
|
-
activeRoutes: () => [...(this.domain?.routes.entries() ?? [])].map(([key, record]): CleanupRoute => ({
|
|
850
|
-
key,
|
|
851
|
-
kind: record.kind,
|
|
852
|
-
workspacePath: record.workspacePath,
|
|
853
|
-
...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
|
|
854
|
-
sessionId: record.sessionId,
|
|
855
|
-
lastActivityAt: record.lastActivityAt,
|
|
856
|
-
})),
|
|
857
|
-
tombstones: () => [...(this.domain?.expiredRoutes.entries() ?? [])].map(([key, record]): CleanupTombstone => ({
|
|
858
|
-
key,
|
|
859
|
-
...(record.workspacePath === undefined ? {} : { workspacePath: record.workspacePath }),
|
|
860
|
-
...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
|
|
861
|
-
expiredAt: record.expiredAt,
|
|
862
|
-
...(record.lastActivityAt === undefined ? {} : { lastActivityAt: record.lastActivityAt }),
|
|
863
|
-
})),
|
|
864
|
-
isLive: sessionId => this.sessions.get(sessionId) !== undefined
|
|
865
|
-
|| this.ctx.agents.get(sessionId as SessionId) !== undefined,
|
|
866
|
-
statWorkspace: directory => measureWorkspace(directory),
|
|
867
|
-
removeWorkspace: async workspace => {
|
|
868
|
-
await this.deleteDirectoryInsideBase(baseDir, workspace.path)
|
|
869
|
-
try {
|
|
870
|
-
await this.ctx.workspaceRegistry.delete(workspace.id)
|
|
871
|
-
} catch (error) {
|
|
872
|
-
this.ctx.logger?.warn?.(
|
|
873
|
-
'dsh-channels: unregistering cleaned workspace %s failed: %s', workspace.path, String(error),
|
|
874
|
-
)
|
|
875
|
-
}
|
|
876
|
-
},
|
|
877
|
-
removeSessionDir: async directory => {
|
|
878
|
-
await this.deleteDirectoryInsideBase(baseDir, directory)
|
|
879
|
-
},
|
|
880
|
-
retireRoute: async route => {
|
|
881
|
-
if (route.kind === 'thread') await this.routes.retire(route.key)
|
|
882
|
-
else await this.routes.remove(route.key)
|
|
883
|
-
},
|
|
884
|
-
warn: message => this.ctx.logger?.warn?.(message),
|
|
885
|
-
}
|
|
1098
|
+
/** Latest native slash-command panel sync, when one finished for this connection. */
|
|
1099
|
+
slashCommandSync(): SlashCommandSyncWire | undefined {
|
|
1100
|
+
return this.slashCommandSyncState
|
|
886
1101
|
}
|
|
887
1102
|
|
|
888
1103
|
/**
|
|
889
|
-
*
|
|
890
|
-
*
|
|
891
|
-
*
|
|
892
|
-
* wins for age; the scan mtime only covers true orphans.
|
|
1104
|
+
* Reconcile Feishu's native `/` panel with the command registry and log the
|
|
1105
|
+
* outcome. Never throws; a failure only loses the panel listing, typed
|
|
1106
|
+
* commands keep working.
|
|
893
1107
|
*/
|
|
894
|
-
private async
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1108
|
+
private async runSlashCommandSync(channel: FeishuChannel): Promise<void> {
|
|
1109
|
+
this.slashCommandSyncState = undefined
|
|
1110
|
+
this.slashCommandSyncAbort?.abort()
|
|
1111
|
+
this.slashCommandSyncAbort = undefined
|
|
1112
|
+
const api = channel.slashCommands
|
|
1113
|
+
if (api === undefined) return
|
|
1114
|
+
const abort = new AbortController()
|
|
1115
|
+
this.slashCommandSyncAbort = abort
|
|
1116
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1117
|
+
let state: SlashCommandSyncWire
|
|
1118
|
+
try {
|
|
1119
|
+
const result = await Promise.race([
|
|
1120
|
+
syncSlashCommands(api, desiredSlashCommands(), abort.signal),
|
|
1121
|
+
new Promise<never>((_, reject) => {
|
|
1122
|
+
timer = setTimeout(() => reject(new Error(`超时(${SLASH_COMMAND_SYNC_TIMEOUT_MS} ms)`)), SLASH_COMMAND_SYNC_TIMEOUT_MS)
|
|
1123
|
+
}),
|
|
1124
|
+
])
|
|
1125
|
+
state = { state: 'ok', checkedAt: Date.now(), ...result }
|
|
1126
|
+
} catch (error) {
|
|
1127
|
+
const detail = error instanceof FeishuApiError ? error.detail : messageOf(error)
|
|
1128
|
+
state = { state: 'error', checkedAt: Date.now(), error: detail }
|
|
1129
|
+
} finally {
|
|
1130
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
1131
|
+
// A timed-out sync must not keep writing behind a reported failure.
|
|
1132
|
+
abort.abort()
|
|
1133
|
+
if (this.slashCommandSyncAbort === abort) this.slashCommandSyncAbort = undefined
|
|
1134
|
+
}
|
|
1135
|
+
// A background sync can outlive its connection (shutdown/reconnect).
|
|
1136
|
+
if (this.channel !== channel) return
|
|
1137
|
+
this.slashCommandSyncState = state
|
|
1138
|
+
if (state.state === 'ok') {
|
|
1139
|
+
this.ctx.logger?.info?.('dsh-channels: slash command panel synced (%d commands, %d created, %d updated, %d removed)', state.total, state.created, state.updated, state.removed)
|
|
1140
|
+
} else {
|
|
1141
|
+
this.ctx.logger?.warn?.('dsh-channels: 飞书原生斜杠指令面板同步失败(直接输入命令不受影响):%s', state.error)
|
|
925
1142
|
}
|
|
926
|
-
return scanned
|
|
927
1143
|
}
|
|
928
1144
|
|
|
929
1145
|
/**
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
* refuses a symlinked ancestor that would let the recursive delete escape
|
|
934
|
-
* the plugin root. A missing directory is already effectively removed.
|
|
1146
|
+
* Compare the app's granted scopes with what the plugin uses and log the
|
|
1147
|
+
* missing ones. Never throws; waits for the owner lookup so the app-info
|
|
1148
|
+
* need reflects its outcome.
|
|
935
1149
|
*/
|
|
936
|
-
private async
|
|
937
|
-
|
|
938
|
-
if (
|
|
939
|
-
|
|
940
|
-
|
|
1150
|
+
private async runScopeCheck(channel: FeishuChannel, appId: string, ownerLookup: Promise<void>): Promise<void> {
|
|
1151
|
+
this.scopeCheckState = undefined
|
|
1152
|
+
if (channel.grantedScopes === undefined) return
|
|
1153
|
+
await ownerLookup
|
|
1154
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1155
|
+
let state: ScopeCheckWire
|
|
941
1156
|
try {
|
|
942
|
-
const
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1157
|
+
const grants = await Promise.race([
|
|
1158
|
+
channel.grantedScopes(),
|
|
1159
|
+
new Promise<never>((_, reject) => {
|
|
1160
|
+
timer = setTimeout(() => reject(new Error(`超时(${SCOPE_CHECK_TIMEOUT_MS} ms)`)), SCOPE_CHECK_TIMEOUT_MS)
|
|
1161
|
+
}),
|
|
1162
|
+
])
|
|
1163
|
+
const result = checkScopes(grants, appId, {
|
|
1164
|
+
dmEnabled: this.config.dmMode === 'open',
|
|
1165
|
+
requireMention: this.config.requireMention,
|
|
1166
|
+
ownerLookupFailed: this.config.accessMode === 'guarded'
|
|
1167
|
+
&& this.configuredAdminUserId() === undefined
|
|
1168
|
+
&& this.appOwner !== undefined && 'error' in this.appOwner && this.appOwner.apiRejected,
|
|
1169
|
+
slashCommandPanel: this.config.slashCommandPanel,
|
|
1170
|
+
sendFile: this.config.feishuTools.sendFile,
|
|
1171
|
+
})
|
|
1172
|
+
state = result.consoleUrl === undefined
|
|
1173
|
+
? { state: 'ok', checkedAt: Date.now() }
|
|
1174
|
+
: {
|
|
1175
|
+
state: 'missing',
|
|
1176
|
+
checkedAt: Date.now(),
|
|
1177
|
+
missing: result.missing.map(item => ({
|
|
1178
|
+
scopes: [...item.anyOf], level: item.level, purpose: item.purpose, consoleUrl: item.consoleUrl,
|
|
1179
|
+
})),
|
|
1180
|
+
consoleUrl: result.consoleUrl,
|
|
1181
|
+
}
|
|
946
1182
|
} catch (error) {
|
|
947
|
-
|
|
948
|
-
|
|
1183
|
+
const detail = error instanceof FeishuApiError ? error.detail : messageOf(error)
|
|
1184
|
+
state = { state: 'error', checkedAt: Date.now(), error: detail }
|
|
1185
|
+
} finally {
|
|
1186
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
1187
|
+
}
|
|
1188
|
+
// A background check can outlive its connection (shutdown/reconnect).
|
|
1189
|
+
if (this.channel !== channel) return
|
|
1190
|
+
this.scopeCheckState = state
|
|
1191
|
+
if (state.state === 'ok') {
|
|
1192
|
+
this.ctx.logger?.info?.('dsh-channels: scope self-check passed')
|
|
1193
|
+
} else if (state.state === 'missing') {
|
|
1194
|
+
const lines = state.missing.map(item => `${item.level === 'required' ? '必需' : '可选'} ${item.scopes.join(' 或 ')}:${item.purpose}`)
|
|
1195
|
+
this.ctx.logger?.warn?.('dsh-channels: 飞书权限缺失 %d 项,开通后需发布新版本(%s):\n %s', state.missing.length, state.consoleUrl, lines.join('\n '))
|
|
1196
|
+
} else {
|
|
1197
|
+
this.ctx.logger?.warn?.('dsh-channels: 飞书权限自检失败:%s', state.error)
|
|
949
1198
|
}
|
|
950
|
-
await rm(resolved, { recursive: true, force: true })
|
|
951
1199
|
}
|
|
952
1200
|
|
|
953
|
-
/**
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
void this.runWorkspaceCleanup().then(result => {
|
|
968
|
-
if (!result.ok && result.error !== '目录清理正在进行中,请稍候') {
|
|
969
|
-
this.ctx.logger?.warn?.('dsh-channels: automatic workspace cleanup failed: %s', result.error)
|
|
970
|
-
}
|
|
971
|
-
})
|
|
972
|
-
}
|
|
973
|
-
if (runNow) sweep()
|
|
974
|
-
const timer = setInterval(sweep, RETENTION_SWEEP_INTERVAL_MS)
|
|
975
|
-
timer.unref?.()
|
|
976
|
-
this.retentionTimer = timer
|
|
1201
|
+
/** Trim + bound one panel-supplied identifier; rejects control characters. */
|
|
1202
|
+
private cleanAccessTargetId(raw: string): string | undefined {
|
|
1203
|
+
const value = raw.trim()
|
|
1204
|
+
if (value === '' || value.length > 128) return undefined
|
|
1205
|
+
// eslint-disable-next-line no-control-regex
|
|
1206
|
+
if (/[\u0000-\u001f\u007f]/u.test(value)) return undefined
|
|
1207
|
+
return value
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/** Same normalization for user ids, which must be Feishu `user_id`s. */
|
|
1211
|
+
private cleanAccessUserId(raw: string): string | undefined {
|
|
1212
|
+
const value = this.cleanAccessTargetId(raw)
|
|
1213
|
+
if (value === undefined || !FEISHU_USER_ID_PATTERN.test(value)) return undefined
|
|
1214
|
+
return value
|
|
977
1215
|
}
|
|
978
1216
|
|
|
979
1217
|
/** Aggregate health used by `/health` and the Web settings panel. */
|
|
@@ -1105,6 +1343,9 @@ export class FeishuService {
|
|
|
1105
1343
|
onError: (sessionId, error) => {
|
|
1106
1344
|
this.ctx.logger?.warn?.('dsh-channels approval error (session %s): %s', sessionId, String(error))
|
|
1107
1345
|
},
|
|
1346
|
+
onWaitingChange: (sessionId, toolNames) => {
|
|
1347
|
+
this.streamer?.setAwaitingApproval(sessionId, toolNames)
|
|
1348
|
+
},
|
|
1108
1349
|
}, this.config.approvalTimeoutMs)
|
|
1109
1350
|
this.approvalAnswerer = answerer
|
|
1110
1351
|
this.unlistenApproval = this.ctx.on('approval/request', (req, next) => answerer.onRequest(req, next), { prepend: true })
|
|
@@ -1124,7 +1365,7 @@ export class FeishuService {
|
|
|
1124
1365
|
{ prepend: true },
|
|
1125
1366
|
)
|
|
1126
1367
|
const accessRequests = new AccessRequests({
|
|
1127
|
-
config: () => this.
|
|
1368
|
+
config: () => this.accessConfig(),
|
|
1128
1369
|
store: this.accessStore,
|
|
1129
1370
|
channel: () => this.channel,
|
|
1130
1371
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
@@ -1142,10 +1383,15 @@ export class FeishuService {
|
|
|
1142
1383
|
void accessRequests.onCardAction(event)
|
|
1143
1384
|
answerer.onCardAction(event)
|
|
1144
1385
|
questions.onCardAction(event)
|
|
1386
|
+
this.onStopAction(event)
|
|
1387
|
+
void this.onPickAction(event).catch((error: unknown) => {
|
|
1388
|
+
this.ctx.logger?.warn?.('dsh-channels: picker card action failed: %s', String(error))
|
|
1389
|
+
})
|
|
1145
1390
|
})
|
|
1146
1391
|
|
|
1147
1392
|
this.unlistenHost.push(this.ctx.on('session/event', (session, event) => {
|
|
1148
1393
|
this.streamer?.onSessionEvent(session, event)
|
|
1394
|
+
this.approvalAnswerer?.onSessionEvent(session.id, event)
|
|
1149
1395
|
}))
|
|
1150
1396
|
// DSH removed the durable `assistant/chunk` event (format v2 embeds the
|
|
1151
1397
|
// stream in the settlement); the live deltas arrive here instead.
|
|
@@ -1161,7 +1407,15 @@ export class FeishuService {
|
|
|
1161
1407
|
.catch(() => {})
|
|
1162
1408
|
})
|
|
1163
1409
|
})
|
|
1410
|
+
// Without a configured administrator, resolve the owner before the
|
|
1411
|
+
// handshake so the first inbound message already sees the fallback; with
|
|
1412
|
+
// one, the lookup only backs a later runtime clear and must not delay boot.
|
|
1413
|
+
const ownerLookup = this.resolveAppOwner(channel)
|
|
1414
|
+
if (this.configuredAdminUserId() === undefined) await ownerLookup
|
|
1164
1415
|
await channel.connect()
|
|
1416
|
+
// Background: the results only feed the panel and the log.
|
|
1417
|
+
void this.runScopeCheck(channel, appId, ownerLookup)
|
|
1418
|
+
if (this.config.slashCommandPanel) void this.runSlashCommandSync(channel)
|
|
1165
1419
|
// Repair cards a previous instance left mid-flight (best-effort, background).
|
|
1166
1420
|
void this.repair.repairAll().catch((error: unknown) => {
|
|
1167
1421
|
this.ctx.logger?.warn?.('dsh-channels: inflight repair failed: %s', String(error))
|
|
@@ -1178,8 +1432,6 @@ export class FeishuService {
|
|
|
1178
1432
|
}, GC_SWEEP_INTERVAL_MS)
|
|
1179
1433
|
timer.unref?.()
|
|
1180
1434
|
this.gcTimer = timer
|
|
1181
|
-
// Workspace retention: opt-in automatic cleanup (default off).
|
|
1182
|
-
this.refreshRetentionTimer(true)
|
|
1183
1435
|
this.ctx.logger?.info?.(
|
|
1184
1436
|
'dsh-channels: feishu long connection established (bot open_id %s, %d route(s) loaded)',
|
|
1185
1437
|
channel.botOpenId() ?? 'unknown',
|
|
@@ -1195,11 +1447,15 @@ export class FeishuService {
|
|
|
1195
1447
|
})
|
|
1196
1448
|
if (this.config.accessMode === 'open') {
|
|
1197
1449
|
console.warn('[dsh-channels] 准入=open:任何应用可用范围内的用户都可以对话(不安全,仅调试)')
|
|
1198
|
-
} else if (this.
|
|
1450
|
+
} else if (this.effectiveAdminUserId() === undefined) {
|
|
1451
|
+
const ownerError = this.appOwner !== undefined && 'error' in this.appOwner ? this.appOwner.error : undefined
|
|
1199
1452
|
console.warn(
|
|
1200
|
-
'[dsh-channels] 准入=guarded 但未配置管理员
|
|
1201
|
-
+ '私聊机器人发送 /userid 取得
|
|
1453
|
+
'[dsh-channels] 准入=guarded 但未配置管理员 user_id,也无法使用应用所有者兜底:所有对话都会被拒绝;'
|
|
1454
|
+
+ '私聊机器人发送 /userid 取得 user_id 后填入 Web 设置面板'
|
|
1455
|
+
+ (ownerError === undefined ? '' : `(${ownerError})`),
|
|
1202
1456
|
)
|
|
1457
|
+
} else if (this.configuredAdminUserId() === undefined) {
|
|
1458
|
+
console.info('[dsh-channels] 准入=guarded,未配置管理员,使用应用所有者', this.effectiveAdminUserId())
|
|
1203
1459
|
} else {
|
|
1204
1460
|
console.info('[dsh-channels] 准入=guarded,管理员已配置')
|
|
1205
1461
|
}
|
|
@@ -1253,11 +1509,8 @@ export class FeishuService {
|
|
|
1253
1509
|
clearInterval(this.gcTimer)
|
|
1254
1510
|
this.gcTimer = undefined
|
|
1255
1511
|
}
|
|
1256
|
-
if (this.retentionTimer !== undefined) {
|
|
1257
|
-
clearInterval(this.retentionTimer)
|
|
1258
|
-
this.retentionTimer = undefined
|
|
1259
|
-
}
|
|
1260
1512
|
await this.sessions.disposeAll()
|
|
1513
|
+
this.attachmentFetchTools.clear()
|
|
1261
1514
|
this.routeOperations.clear()
|
|
1262
1515
|
this.inbound.clear()
|
|
1263
1516
|
this.routes.clear()
|
|
@@ -1268,10 +1521,16 @@ export class FeishuService {
|
|
|
1268
1521
|
// Already disconnected is fine.
|
|
1269
1522
|
}
|
|
1270
1523
|
this.channel = undefined
|
|
1524
|
+
this.streamer?.detachAll()
|
|
1271
1525
|
this.streamer = undefined
|
|
1272
1526
|
await this.domain?.close()
|
|
1273
1527
|
this.domain = undefined
|
|
1274
1528
|
this.runningConfig = undefined
|
|
1529
|
+
this.appOwner = undefined
|
|
1530
|
+
this.scopeCheckState = undefined
|
|
1531
|
+
this.slashCommandSyncState = undefined
|
|
1532
|
+
this.slashCommandSyncAbort?.abort()
|
|
1533
|
+
this.slashCommandSyncAbort = undefined
|
|
1275
1534
|
}
|
|
1276
1535
|
|
|
1277
1536
|
// ---- inbound routing -------------------------------------------------
|
|
@@ -1345,7 +1604,7 @@ export class FeishuService {
|
|
|
1345
1604
|
return
|
|
1346
1605
|
}
|
|
1347
1606
|
|
|
1348
|
-
if (msg.senderId !== route.
|
|
1607
|
+
if (msg.senderId !== route.senderUserId) {
|
|
1349
1608
|
await this.delivery.commandCard(msg, {
|
|
1350
1609
|
kind: 'forbidden', command: parsed.name, text: '无权操作此会话。',
|
|
1351
1610
|
})
|
|
@@ -1355,7 +1614,7 @@ export class FeishuService {
|
|
|
1355
1614
|
const operation = this.routeOperations.run(route.key, async () => {
|
|
1356
1615
|
if (!this.started) return
|
|
1357
1616
|
const current = this.routes.get(route.key) ?? route
|
|
1358
|
-
if (msg.senderId !== current.
|
|
1617
|
+
if (msg.senderId !== current.senderUserId) {
|
|
1359
1618
|
await this.delivery.commandCard(msg, {
|
|
1360
1619
|
kind: 'forbidden', command: parsed.name, text: '无权操作此会话。',
|
|
1361
1620
|
})
|
|
@@ -1388,12 +1647,12 @@ export class FeishuService {
|
|
|
1388
1647
|
const pending = this.accessStore.pendingRequests()
|
|
1389
1648
|
await this.delivery.standaloneReply(msg, standaloneInfoCard('**🔐 访问授权状态**', [
|
|
1390
1649
|
`**准入模式**:\`${this.config.accessMode}\``,
|
|
1391
|
-
`**管理员**:${this.
|
|
1650
|
+
`**管理员**:${this.adminStatusLine()}`,
|
|
1392
1651
|
...(msg.chatType === 'group'
|
|
1393
1652
|
? [`**本群**:${this.accessStore.hasGroup(msg.chatId) ? '已授权' : '未授权'}(\`${msg.chatId}\`)`]
|
|
1394
1653
|
: []),
|
|
1395
1654
|
`**白名单**:群 ${groups.length} 个 · 用户 ${users.length} 个 · 待处理申请 ${pending.length} 个`,
|
|
1396
|
-
'群授权:`/auth allow` 或 `/auth revoke`(在目标群内发);用户授权:`/auth user add <
|
|
1655
|
+
'群授权:`/auth allow` 或 `/auth revoke`(在目标群内发);用户授权:`/auth user add @用户` 或 `/auth user add <user_id>`。',
|
|
1397
1656
|
]), direct)
|
|
1398
1657
|
return
|
|
1399
1658
|
}
|
|
@@ -1440,35 +1699,39 @@ export class FeishuService {
|
|
|
1440
1699
|
}
|
|
1441
1700
|
if (action === 'user') {
|
|
1442
1701
|
const sub = words[1]
|
|
1443
|
-
const
|
|
1444
|
-
|
|
1445
|
-
|
|
1702
|
+
const target = sub === 'add' || sub === 'remove'
|
|
1703
|
+
? resolveAuthUserTarget(words.slice(2).join(' '), msg.mentions, this.channel?.botOpenId())
|
|
1704
|
+
: { ok: false as const, reason: 'usage' as const }
|
|
1705
|
+
if (!target.ok) {
|
|
1446
1706
|
await this.delivery.commandCard(msg, {
|
|
1447
|
-
kind: 'input-error', command: 'auth',
|
|
1448
|
-
text: '用法:/auth user add <open_id> 或 /auth user remove <open_id>(open_id 以 ou_ 开头,可用 /userid 查看)。',
|
|
1707
|
+
kind: 'input-error', command: 'auth', text: AUTH_USER_TARGET_ERRORS[target.reason],
|
|
1449
1708
|
}, direct)
|
|
1450
1709
|
return
|
|
1451
1710
|
}
|
|
1711
|
+
const { userId } = target
|
|
1712
|
+
const label = target.name === undefined ? `\`${userId}\`` : `${target.name}(\`${userId}\`)`
|
|
1452
1713
|
if (sub === 'add') {
|
|
1453
|
-
if (this.accessStore.hasUser(
|
|
1714
|
+
if (this.accessStore.hasUser(userId)) {
|
|
1454
1715
|
await this.delivery.commandCard(msg, {
|
|
1455
|
-
kind: 'success', command: 'auth', text:
|
|
1716
|
+
kind: 'success', command: 'auth', text: `该用户已在白名单中:${label}。`,
|
|
1456
1717
|
}, direct)
|
|
1457
1718
|
return
|
|
1458
1719
|
}
|
|
1459
1720
|
await this.accessStore.addUser({
|
|
1460
|
-
|
|
1721
|
+
userId,
|
|
1722
|
+
...(target.name === undefined ? {} : { name: target.name }),
|
|
1723
|
+
source: 'command', addedBy: msg.senderId, addedAt: now,
|
|
1461
1724
|
})
|
|
1462
1725
|
await this.delivery.commandCard(msg, {
|
|
1463
1726
|
kind: 'success', command: 'auth',
|
|
1464
|
-
text: `✅ 已授权用户
|
|
1727
|
+
text: `✅ 已授权用户 ${label}(在所有已授权群里生效)。`,
|
|
1465
1728
|
}, direct)
|
|
1466
1729
|
return
|
|
1467
1730
|
}
|
|
1468
|
-
const removed = await this.accessStore.removeUser(
|
|
1731
|
+
const removed = await this.accessStore.removeUser(userId)
|
|
1469
1732
|
await this.delivery.commandCard(msg, {
|
|
1470
1733
|
kind: removed ? 'success' : 'unavailable', command: 'auth',
|
|
1471
|
-
text: removed ? `已移除用户
|
|
1734
|
+
text: removed ? `已移除用户 ${label},立即生效。` : `用户 ${label} 不在白名单中。`,
|
|
1472
1735
|
}, direct)
|
|
1473
1736
|
return
|
|
1474
1737
|
}
|
|
@@ -1481,7 +1744,7 @@ export class FeishuService {
|
|
|
1481
1744
|
: groups.slice(0, 30).map(group => `- ${group.name ?? group.chatId} \`${group.chatId}\``)
|
|
1482
1745
|
const userLines = users.length === 0
|
|
1483
1746
|
? ['(暂无)']
|
|
1484
|
-
: users.slice(0, 30).map(user => `- ${user.name ?? user.
|
|
1747
|
+
: users.slice(0, 30).map(user => `- ${user.name ?? user.userId} \`${user.userId}\``)
|
|
1485
1748
|
await this.delivery.standaloneReply(msg, standaloneInfoCard('**🔐 访问授权清单**', [
|
|
1486
1749
|
`**已授权群(${groups.length})**`,
|
|
1487
1750
|
...groupLines,
|
|
@@ -1493,7 +1756,7 @@ export class FeishuService {
|
|
|
1493
1756
|
}
|
|
1494
1757
|
await this.delivery.commandCard(msg, {
|
|
1495
1758
|
kind: 'input-error', command: 'auth',
|
|
1496
|
-
text: '用法:/auth [status|allow|revoke|list|user add
|
|
1759
|
+
text: '用法:/auth [status|allow|revoke|list|user add <@用户|user_id>|user remove <@用户|user_id>]',
|
|
1497
1760
|
}, direct)
|
|
1498
1761
|
} catch (error) {
|
|
1499
1762
|
this.ctx.logger?.warn?.('dsh-channels: /auth failed: %s', String(error))
|
|
@@ -1602,10 +1865,7 @@ export class FeishuService {
|
|
|
1602
1865
|
return
|
|
1603
1866
|
}
|
|
1604
1867
|
if (spec.name === 'stop') {
|
|
1605
|
-
const
|
|
1606
|
-
?? this.ctx.agents.get(current.sessionId as SessionId)
|
|
1607
|
-
const running = agent?.status === 'running'
|
|
1608
|
-
agent?.cancel?.({ kind: 'user' })
|
|
1868
|
+
const running = this.cancelTurn(current.sessionId)
|
|
1609
1869
|
await this.delivery.commandCard(msg, {
|
|
1610
1870
|
kind: 'success', command: 'stop', sessionId: current.sessionId,
|
|
1611
1871
|
text: running ? '已请求停止当前回合。' : '当前没有正在运行的回合。',
|
|
@@ -1618,6 +1878,72 @@ export class FeishuService {
|
|
|
1618
1878
|
})
|
|
1619
1879
|
}
|
|
1620
1880
|
|
|
1881
|
+
/**
|
|
1882
|
+
* Cancel one Session's live turn without resuming anything. Queued
|
|
1883
|
+
* messages stay in the inbox: each already has its own streaming card and
|
|
1884
|
+
* runs next.
|
|
1885
|
+
* @param sessionId The Session to cancel.
|
|
1886
|
+
* @returns Whether a turn was running.
|
|
1887
|
+
*/
|
|
1888
|
+
private cancelTurn(sessionId: string): boolean {
|
|
1889
|
+
const agent = this.sessions.get(sessionId)?.agent
|
|
1890
|
+
?? this.ctx.agents.get(sessionId as SessionId)
|
|
1891
|
+
const running = agent?.status === 'running'
|
|
1892
|
+
agent?.cancel?.({ kind: 'user' }, { keepInbox: true })
|
|
1893
|
+
if (running) this.streamer?.markStopping(sessionId)
|
|
1894
|
+
return running
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
/**
|
|
1898
|
+
* Streaming-card stop button. Only the session owner, clicking inside the
|
|
1899
|
+
* session's own chat, cancels the turn; another operator gets the
|
|
1900
|
+
* owner-only notice on the card instead.
|
|
1901
|
+
*/
|
|
1902
|
+
private onStopAction(event: CardActionEvent): void {
|
|
1903
|
+
if (!this.started) return
|
|
1904
|
+
if ((event.action.value as { decision?: unknown } | undefined)?.decision !== STOP_ACTION) return
|
|
1905
|
+
const sessionId = this.streamer?.stopTargetOf(event.messageId)
|
|
1906
|
+
if (sessionId === undefined) return
|
|
1907
|
+
const route = this.routes.findBySession(sessionId)
|
|
1908
|
+
if (route === undefined) return
|
|
1909
|
+
if (event.operator.userId !== route.senderUserId || event.chatId !== route.chatId) {
|
|
1910
|
+
this.streamer?.markStopDenied(sessionId)
|
|
1911
|
+
return
|
|
1912
|
+
}
|
|
1913
|
+
this.cancelTurn(sessionId)
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
/**
|
|
1917
|
+
* Picker-card click: run the chosen `/<command> <arg>` exactly as if the
|
|
1918
|
+
* operator had typed it under the picker card, so access admission, route
|
|
1919
|
+
* ownership and validation are the typed command's. Stale pickers (the
|
|
1920
|
+
* topic was rebound, or the card was forwarded elsewhere) and non-owner
|
|
1921
|
+
* clicks are ignored.
|
|
1922
|
+
*/
|
|
1923
|
+
private async onPickAction(event: CardActionEvent): Promise<void> {
|
|
1924
|
+
if (!this.started) return
|
|
1925
|
+
const action = parsePickAction(event.action.value)
|
|
1926
|
+
if (action === undefined) return
|
|
1927
|
+
const route = this.routes.findBySession(action.sessionId)
|
|
1928
|
+
const userId = event.operator.userId
|
|
1929
|
+
if (route === undefined || route.chatId !== event.chatId || userId !== route.senderUserId) return
|
|
1930
|
+
const line = `/${action.command} ${action.arg}`
|
|
1931
|
+
await this.handleSlashMessage({
|
|
1932
|
+
messageId: event.messageId,
|
|
1933
|
+
chatId: route.chatId,
|
|
1934
|
+
chatType: route.chatType,
|
|
1935
|
+
senderId: userId,
|
|
1936
|
+
content: line,
|
|
1937
|
+
rawContentType: 'text',
|
|
1938
|
+
resources: [],
|
|
1939
|
+
mentions: [],
|
|
1940
|
+
mentionAll: false,
|
|
1941
|
+
mentionedBot: true,
|
|
1942
|
+
...(route.kind === 'thread' ? { threadId: route.key } : {}),
|
|
1943
|
+
createTime: Date.now(),
|
|
1944
|
+
}, line)
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1621
1947
|
/**
|
|
1622
1948
|
* `/permissions`: show this Session's effective sandbox+approval preset and
|
|
1623
1949
|
* the selectable catalog; switch only when the deployment opted in.
|
|
@@ -1638,14 +1964,27 @@ export class FeishuService {
|
|
|
1638
1964
|
const live = await this.sessions.ensureLive(current)
|
|
1639
1965
|
let active: string | undefined
|
|
1640
1966
|
let catalogLine = ''
|
|
1967
|
+
let choices: PickerOption[] = []
|
|
1641
1968
|
try {
|
|
1642
1969
|
active = presets.current(live.agent.session)
|
|
1643
1970
|
const catalog = presets.catalog()
|
|
1644
1971
|
catalogLine = catalog.options.map(option => `\`${option.value}\``).join('、')
|
|
1972
|
+
choices = catalog.options.map(option => ({ label: option.name || option.value, arg: option.value }))
|
|
1645
1973
|
} catch {
|
|
1646
1974
|
active = undefined
|
|
1647
1975
|
}
|
|
1648
1976
|
const requested = parsed.rawInput.trim()
|
|
1977
|
+
if (requested === '' && this.config.allowPermissionSwitch && choices.length > 0) {
|
|
1978
|
+
await this.delivery.standaloneReply(msg, buildPickerCard({
|
|
1979
|
+
title: '🔐 权限预设',
|
|
1980
|
+
command: 'permissions',
|
|
1981
|
+
text: `**当前权限预设**:${active === undefined ? '暂无数据' : `\`${active}\``}\n切换会同时调整沙箱与审批策略。`,
|
|
1982
|
+
sessionId: current.sessionId,
|
|
1983
|
+
options: choices,
|
|
1984
|
+
...(active === undefined ? {} : { current: active }),
|
|
1985
|
+
}))
|
|
1986
|
+
return
|
|
1987
|
+
}
|
|
1649
1988
|
if (requested === '') {
|
|
1650
1989
|
const lines = [
|
|
1651
1990
|
`**当前权限预设**:${active === undefined ? '暂无数据' : `\`${active}\``}`,
|
|
@@ -1772,6 +2111,7 @@ export class FeishuService {
|
|
|
1772
2111
|
}
|
|
1773
2112
|
await this.renderCommandResult({
|
|
1774
2113
|
spec, msg, current, live,
|
|
2114
|
+
bare: parsed.rawInput.trim() === '',
|
|
1775
2115
|
result: execution.result,
|
|
1776
2116
|
compactBefore,
|
|
1777
2117
|
renewAdmission,
|
|
@@ -1786,6 +2126,8 @@ export class FeishuService {
|
|
|
1786
2126
|
msg: NormalizedMessage
|
|
1787
2127
|
current: RouteRecord
|
|
1788
2128
|
live: LiveAgent
|
|
2129
|
+
/** The command was typed without arguments. */
|
|
2130
|
+
bare: boolean
|
|
1789
2131
|
result: CommandResult
|
|
1790
2132
|
compactBefore: ContextMeasurement | undefined
|
|
1791
2133
|
renewAdmission: RenewAdmission | undefined
|
|
@@ -1795,6 +2137,17 @@ export class FeishuService {
|
|
|
1795
2137
|
const { spec, msg, current, live, result, compactBefore, renewAdmission, effortAdmission, modelAdmission } = input
|
|
1796
2138
|
if (spec.name === 'effort') effortAdmission?.controller.sync(live.agent.session.snapshotEvents())
|
|
1797
2139
|
if (spec.name === 'model') modelAdmission?.controller.sync(live.agent.session.snapshotEvents())
|
|
2140
|
+
if (input.bare && result.kind === 'success') {
|
|
2141
|
+
const picker = spec.name === 'model' && modelAdmission !== undefined
|
|
2142
|
+
? await this.modelPicker(current, live, modelAdmission)
|
|
2143
|
+
: spec.name === 'effort' && effortAdmission !== undefined
|
|
2144
|
+
? await this.effortPicker(current, live, effortAdmission, result.text ?? '')
|
|
2145
|
+
: undefined
|
|
2146
|
+
if (picker !== undefined) {
|
|
2147
|
+
await this.delivery.standaloneReply(msg, picker)
|
|
2148
|
+
return
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
1798
2151
|
if (spec.name === 'renew' && result.kind === 'success' && renewAdmission?.commit !== undefined) {
|
|
1799
2152
|
await this.delivery.standaloneReply(msg, buildRenewCard({
|
|
1800
2153
|
previousSessionId: renewAdmission.commit.previous.sessionId,
|
|
@@ -2018,7 +2371,7 @@ export class FeishuService {
|
|
|
2018
2371
|
|
|
2019
2372
|
|
|
2020
2373
|
/**
|
|
2021
|
-
* GC pass over the routing domain. Two independent
|
|
2374
|
+
* GC pass over the routing domain. Two independent parts:
|
|
2022
2375
|
*
|
|
2023
2376
|
* 1. Dedup records (unconditional): `processed` records and in-process
|
|
2024
2377
|
* guard entries older than `PROCESSED_RETENTION_MS` are dropped —
|
|
@@ -2077,6 +2430,7 @@ export class FeishuService {
|
|
|
2077
2430
|
} else {
|
|
2078
2431
|
await this.routes.remove(key)
|
|
2079
2432
|
}
|
|
2433
|
+
this.attachmentFetchTools.forgetRoute(key)
|
|
2080
2434
|
removedRoutes++
|
|
2081
2435
|
} catch (error) {
|
|
2082
2436
|
this.ctx.logger?.warn?.('dsh-channels: GC of route %s failed: %s', key, String(error))
|
|
@@ -2084,6 +2438,19 @@ export class FeishuService {
|
|
|
2084
2438
|
}
|
|
2085
2439
|
}
|
|
2086
2440
|
|
|
2441
|
+
// Reconcile the attachment-fetch whitelist with the active route set: the
|
|
2442
|
+
// inbound TTL path retires a thread route directly (tombstone), so its
|
|
2443
|
+
// in-memory whitelist bucket would otherwise live until process exit.
|
|
2444
|
+
const droppedToolRoutes = this.attachmentFetchTools.pruneRoutes(
|
|
2445
|
+
routeKey => this.routes.get(routeKey) !== undefined,
|
|
2446
|
+
)
|
|
2447
|
+
if (droppedToolRoutes > 0) {
|
|
2448
|
+
this.ctx.logger?.info?.(
|
|
2449
|
+
'dsh-channels: record GC dropped %d stale attachment-tool route bucket(s)',
|
|
2450
|
+
droppedToolRoutes,
|
|
2451
|
+
)
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2087
2454
|
const inflight = this.domain?.inflight
|
|
2088
2455
|
if (inflight !== undefined) {
|
|
2089
2456
|
for (const [key, snapshot] of [...inflight.entries()]) {
|
|
@@ -2118,7 +2485,7 @@ export class FeishuService {
|
|
|
2118
2485
|
chatId: record.chatId,
|
|
2119
2486
|
cardMessageId: record.cardMessageId,
|
|
2120
2487
|
inThread: record.kind === 'thread',
|
|
2121
|
-
|
|
2488
|
+
senderUserId: record.senderUserId,
|
|
2122
2489
|
}
|
|
2123
2490
|
}
|
|
2124
2491
|
|
|
@@ -2138,22 +2505,10 @@ export class FeishuService {
|
|
|
2138
2505
|
if (admission.live.agent !== invocation.agent) {
|
|
2139
2506
|
return { kind: 'error', text: '/model 只能通过已授权的飞书话题执行。' }
|
|
2140
2507
|
}
|
|
2141
|
-
const
|
|
2142
|
-
const state = admission.controller.state
|
|
2143
|
-
const header = session.requestHeader?.()?.config
|
|
2144
|
-
const currentProvider = state.effectiveModel?.provider
|
|
2145
|
-
?? (typeof header?.provider === 'string' && header.provider !== '' ? header.provider : undefined)
|
|
2146
|
-
const currentModel = state.effectiveModel?.model
|
|
2147
|
-
?? (typeof header?.model === 'string' && header.model !== '' ? header.model : undefined)
|
|
2148
|
-
const source = state.modelSource === 'session'
|
|
2149
|
-
? 'Session 覆盖'
|
|
2150
|
-
: state.modelSource === 'config' ? '配置默认' : '部署默认'
|
|
2508
|
+
const { provider: currentProvider, model: currentModel } = currentModelRoute(admission.live, admission.controller)
|
|
2151
2509
|
|
|
2152
2510
|
if (rawInput === '') {
|
|
2153
|
-
const lines = [
|
|
2154
|
-
`**当前模型**:${currentModel === undefined ? '尚无模型请求记录' : `\`${currentModel}\``}`
|
|
2155
|
-
+ `${currentProvider === undefined ? '' : `(provider \`${currentProvider}\`)`} · ${source}`,
|
|
2156
|
-
]
|
|
2511
|
+
const lines = [currentModelLine(admission.live, admission.controller)]
|
|
2157
2512
|
const models = await this.listModels(currentProvider)
|
|
2158
2513
|
if (models.length > 0) {
|
|
2159
2514
|
lines.push(`**可选模型(${models.length})**:${models.slice(0, 20).map(id => `\`${id}\``).join('、')}`
|
|
@@ -2190,6 +2545,59 @@ export class FeishuService {
|
|
|
2190
2545
|
return { kind: 'success', text: `当前 Session 模型已切换为 \`${label}\`(下一条消息生效)。` }
|
|
2191
2546
|
}
|
|
2192
2547
|
|
|
2548
|
+
/** `/model` picker: the provider's advertised models, or undefined when none are listed. */
|
|
2549
|
+
private async modelPicker(
|
|
2550
|
+
current: RouteRecord,
|
|
2551
|
+
live: LiveAgent,
|
|
2552
|
+
admission: ModelAdmission,
|
|
2553
|
+
): Promise<object | undefined> {
|
|
2554
|
+
const route = currentModelRoute(live, admission.controller)
|
|
2555
|
+
const models = await this.listModels(route.provider)
|
|
2556
|
+
if (models.length === 0) return undefined
|
|
2557
|
+
return buildPickerCard({
|
|
2558
|
+
title: '🧠 模型',
|
|
2559
|
+
command: 'model',
|
|
2560
|
+
text: `${currentModelLine(live, admission.controller)}\n切换在下一条消息生效。`,
|
|
2561
|
+
sessionId: current.sessionId,
|
|
2562
|
+
options: models.map(id => ({ label: id, arg: id })),
|
|
2563
|
+
...(route.model === undefined ? {} : { current: route.model }),
|
|
2564
|
+
reset: true,
|
|
2565
|
+
})
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
/**
|
|
2569
|
+
* `/effort` picker: the efforts the current model route advertises, or
|
|
2570
|
+
* undefined when the route is unknown or advertises none.
|
|
2571
|
+
*/
|
|
2572
|
+
private async effortPicker(
|
|
2573
|
+
current: RouteRecord,
|
|
2574
|
+
live: LiveAgent,
|
|
2575
|
+
admission: EffortAdmission,
|
|
2576
|
+
text: string,
|
|
2577
|
+
): Promise<object | undefined> {
|
|
2578
|
+
const route = currentModelRoute(live, admission.controller)
|
|
2579
|
+
if (route.provider === undefined || route.model === undefined) return undefined
|
|
2580
|
+
let reasoning: LlmModelInfo['reasoning']
|
|
2581
|
+
try {
|
|
2582
|
+
const llm = this.ctx.get?.('llm') as LlmService | undefined
|
|
2583
|
+
reasoning = (await llm?.resolveModelInfo(route.provider, route.model))?.reasoning
|
|
2584
|
+
} catch (error) {
|
|
2585
|
+
this.ctx.logger?.warn?.('dsh-channels: effort catalog lookup failed: %s', String(error))
|
|
2586
|
+
return undefined
|
|
2587
|
+
}
|
|
2588
|
+
if (reasoning === undefined || reasoning.efforts.length === 0) return undefined
|
|
2589
|
+
const active = admission.controller.state.effectiveEffort ?? reasoning.defaultEffort
|
|
2590
|
+
return buildPickerCard({
|
|
2591
|
+
title: '💭 推理强度',
|
|
2592
|
+
command: 'effort',
|
|
2593
|
+
text,
|
|
2594
|
+
sessionId: current.sessionId,
|
|
2595
|
+
options: reasoning.efforts.map(effort => ({ label: effort.name || effort.id, arg: effort.id })),
|
|
2596
|
+
...(active === undefined ? {} : { current: active }),
|
|
2597
|
+
reset: true,
|
|
2598
|
+
})
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2193
2601
|
/** Model ids advertised for one provider route (empty when unavailable). */
|
|
2194
2602
|
private async listModels(provider: string | undefined): Promise<string[]> {
|
|
2195
2603
|
if (provider === undefined) return []
|
|
@@ -2330,3 +2738,28 @@ export class FeishuService {
|
|
|
2330
2738
|
}
|
|
2331
2739
|
}
|
|
2332
2740
|
}
|
|
2741
|
+
|
|
2742
|
+
/** The model route the next request of a Session uses, as far as it is known. */
|
|
2743
|
+
function currentModelRoute(
|
|
2744
|
+
live: LiveAgent,
|
|
2745
|
+
controller: RequestPolicyController,
|
|
2746
|
+
): { provider: string | undefined; model: string | undefined } {
|
|
2747
|
+
const state = controller.state
|
|
2748
|
+
const header = live.agent.session.requestHeader?.()?.config
|
|
2749
|
+
return {
|
|
2750
|
+
provider: state.effectiveModel?.provider
|
|
2751
|
+
?? (typeof header?.provider === 'string' && header.provider !== '' ? header.provider : undefined),
|
|
2752
|
+
model: state.effectiveModel?.model
|
|
2753
|
+
?? (typeof header?.model === 'string' && header.model !== '' ? header.model : undefined),
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
/** `**当前模型**:…` line shared by the `/model` text answer and its picker. */
|
|
2758
|
+
function currentModelLine(live: LiveAgent, controller: RequestPolicyController): string {
|
|
2759
|
+
const { provider, model } = currentModelRoute(live, controller)
|
|
2760
|
+
const source = controller.state.modelSource === 'session'
|
|
2761
|
+
? 'Session 覆盖'
|
|
2762
|
+
: controller.state.modelSource === 'config' ? '配置默认' : '部署默认'
|
|
2763
|
+
return `**当前模型**:${model === undefined ? '尚无模型请求记录' : `\`${model}\``}`
|
|
2764
|
+
+ `${provider === undefined ? '' : `(provider \`${provider}\`)`} · ${source}`
|
|
2765
|
+
}
|