@modesto_ma/dsh-channels 0.4.0 → 0.5.0-beta.2
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 +83 -57
- package/docs/access-control-design.md +8 -5
- package/docs/attachment-pipeline-v2.md +33 -41
- package/docs/feishu-onboarding.md +36 -33
- package/docs/persistence.md +6 -4
- package/docs/refactor-plan.md +1 -1
- package/docs/workspace-isolation-design.md +7 -8
- package/lib/client.js +444 -288
- package/lib/client.js.map +1 -1
- package/lib/index.js +9017 -6794
- 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 +43 -13
- 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 +108 -0
- package/src/cards/streaming.ts +56 -7
- package/src/cards/wiki.ts +124 -0
- 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 +96 -24
- package/src/client/panel/workspace-tab.tsx +2 -18
- package/src/client/string-list.ts +139 -0
- package/src/client/styles.ts +5 -0
- package/src/commands/registry.ts +8 -5
- package/src/config.ts +55 -54
- package/src/domain.ts +36 -123
- package/src/feishu/delivery.ts +34 -1
- package/src/feishu/inbound-attachments.ts +1 -1
- package/src/feishu/manifest.ts +8 -31
- package/src/feishu/message-read.ts +97 -24
- package/src/feishu/scopes.ts +194 -0
- package/src/feishu/send-file.ts +543 -0
- package/src/feishu/slash-commands.ts +134 -0
- package/src/feishu/tools.ts +72 -112
- package/src/feishu/wiki/api.ts +346 -0
- package/src/feishu/wiki/consent.ts +183 -0
- package/src/feishu/wiki/links.ts +122 -0
- package/src/feishu/wiki/scope.ts +66 -0
- package/src/feishu/wiki/tools.ts +984 -0
- package/src/feishu-channel.ts +280 -9
- 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 +73 -106
- package/src/service.ts +545 -440
- package/src/session/manager.ts +1 -1
- package/src/session/routes.ts +2 -3
- package/src/settings/contract.ts +110 -89
- package/src/settings/routes.ts +3 -17
- package/src/settings/surface.ts +144 -94
- package/src/streamer.ts +94 -5
- package/src/workspaces.ts +21 -29
- package/src/cleanup.ts +0 -546
- package/src/client/panel/cleanup-card.tsx +0 -106
- package/src/feishu/pending-attachments.ts +0 -562
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
|
-
|
|
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'
|
|
@@ -66,8 +62,10 @@ 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'
|
|
68
64
|
import { InboundAttachmentStore, effectiveAttachmentMode, resolveAttachmentService } from './feishu/native-attachments.ts'
|
|
69
|
-
import { PendingAttachmentStore } from './feishu/pending-attachments.ts'
|
|
70
65
|
import { AttachmentFetchToolRuntime, TopicMessagesToolRuntime } from './feishu/tools.ts'
|
|
66
|
+
import { SendFileToolRuntime } from './feishu/send-file.ts'
|
|
67
|
+
import { WikiConsent } from './feishu/wiki/consent.ts'
|
|
68
|
+
import { WikiToolRuntime } from './feishu/wiki/tools.ts'
|
|
71
69
|
import type { KnownAttachmentMessageInput, KnownAttachmentSource } from './feishu/tools.ts'
|
|
72
70
|
import {
|
|
73
71
|
MessageReader, TOPIC_MESSAGES_MAX_LIMIT, buildHistoryContextBlock,
|
|
@@ -78,7 +76,8 @@ import type { MessageReadRoute, RawMessageItem } from './feishu/message-read.ts'
|
|
|
78
76
|
import type { AttachmentStore } from './host/attachments.ts'
|
|
79
77
|
import { InboundRouter } from './routing/inbound.ts'
|
|
80
78
|
import type { RequestPolicyController } from './session/request-policy.ts'
|
|
81
|
-
import type { ChannelFactory, FeishuChannel, NormalizedMessage } from './feishu-channel.ts'
|
|
79
|
+
import type { CardActionEvent, ChannelFactory, FeishuChannel, NormalizedMessage } from './feishu-channel.ts'
|
|
80
|
+
import { AppOwnerLookupError, FeishuApiError } from './feishu-channel.ts'
|
|
82
81
|
import { buildHealthCard, foldHealthSnapshot, HEALTH_DEPENDENCY_NAMES } from './health.ts'
|
|
83
82
|
import type { HealthDependencyName, HealthSnapshot } from './health.ts'
|
|
84
83
|
import {
|
|
@@ -95,11 +94,12 @@ import { ACTIVITY_BUILD_MARKER, TurnStreamer } from './streamer.ts'
|
|
|
95
94
|
import type { ProjectedStats, StreamerHooks } from './streamer.ts'
|
|
96
95
|
import type { FeishuConfig } from './config.ts'
|
|
97
96
|
import type {
|
|
98
|
-
AccessMutationResponse, AccessRequestWire, AccessWire,
|
|
97
|
+
AccessMutationResponse, AccessRequestWire, AccessWire, ScopeCheckWire, SlashCommandSyncWire,
|
|
99
98
|
} from './settings/contract.ts'
|
|
99
|
+
import { checkScopes } from './feishu/scopes.ts'
|
|
100
|
+
import { desiredSlashCommands, syncSlashCommands } from './feishu/slash-commands.ts'
|
|
100
101
|
import { mergeOverlay } from './settings/overlay.ts'
|
|
101
102
|
import type { OverlayPatch, OverlayStore } from './settings/overlay.ts'
|
|
102
|
-
import { legacyTopicWorkspaceRoot } from './workspaces.ts'
|
|
103
103
|
|
|
104
104
|
/** Periodic route-GC sweep cadence (a long-running instance also accumulates
|
|
105
105
|
* dead records; TTL semantics make 6 h granularity more than sufficient). */
|
|
@@ -108,9 +108,18 @@ const GC_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000
|
|
|
108
108
|
/** Dedup-record retention: Feishu redelivery windows are minutes-scale, so a
|
|
109
109
|
* day is 100×+ headroom while keeping the table bounded. */
|
|
110
110
|
const PROCESSED_RETENTION_MS = 24 * 60 * 60 * 1000
|
|
111
|
+
/** Upper bound on the boot-time app-owner lookup before the connection opens. */
|
|
112
|
+
const APP_OWNER_LOOKUP_TIMEOUT_MS = 10_000
|
|
113
|
+
/** Upper bound on the post-connect scope grant query. */
|
|
114
|
+
const SCOPE_CHECK_TIMEOUT_MS = 10_000
|
|
115
|
+
/** Upper bound on the whole post-connect slash-command panel sync (one list plus per-command writes). */
|
|
116
|
+
const SLASH_COMMAND_SYNC_TIMEOUT_MS = 30_000
|
|
111
117
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
118
|
+
/** Outcome of the app-owner lookup for the current connection. */
|
|
119
|
+
type AppOwnerState =
|
|
120
|
+
| { readonly userId: string }
|
|
121
|
+
/** `apiRejected`: Feishu answered with an error code (typically a missing app-info scope). */
|
|
122
|
+
| { readonly error: string; readonly apiRejected: boolean }
|
|
114
123
|
|
|
115
124
|
|
|
116
125
|
interface RenewAdmission {
|
|
@@ -139,29 +148,7 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
|
|
|
139
148
|
const accessGroupsRaw = domain.table('access_groups')
|
|
140
149
|
const accessUsersRaw = domain.table('access_users')
|
|
141
150
|
const accessRequestsRaw = domain.table('access_requests')
|
|
142
|
-
|
|
143
|
-
// version (see `pendingAttachmentRecordSchema`); hosts that cannot accept the
|
|
144
|
-
// addition leave this undefined and the registry degrades to its memory TTL
|
|
145
|
-
// fallback without taking the whole storage domain down.
|
|
146
|
-
let pendingAttachmentsRaw: ReturnType<RawDomainHandle['table']> | undefined
|
|
147
|
-
try {
|
|
148
|
-
pendingAttachmentsRaw = domain.table('pending_attachments')
|
|
149
|
-
} catch (error) {
|
|
150
|
-
ctx.logger?.warn?.('dsh-channels: pending attachment table unavailable: %s', String(error))
|
|
151
|
-
}
|
|
152
|
-
const pendingAttachments: PendingAttachmentsTable | undefined = pendingAttachmentsRaw === undefined
|
|
153
|
-
? undefined
|
|
154
|
-
: {
|
|
155
|
-
get: key => pendingAttachmentsRaw.get(key) as PendingAttachmentRecord | undefined,
|
|
156
|
-
put: (key, value) => pendingAttachmentsRaw.put(key, value),
|
|
157
|
-
update: (key, fn) => pendingAttachmentsRaw.update(
|
|
158
|
-
key,
|
|
159
|
-
current => fn(current as PendingAttachmentRecord),
|
|
160
|
-
),
|
|
161
|
-
delete: key => pendingAttachmentsRaw.delete(key),
|
|
162
|
-
entries: () =>
|
|
163
|
-
pendingAttachmentsRaw.entries() as IterableIterator<[string, PendingAttachmentRecord]>,
|
|
164
|
-
}
|
|
151
|
+
const accessWikiUsersRaw = domain.table('access_wiki_users')
|
|
165
152
|
return {
|
|
166
153
|
routes: {
|
|
167
154
|
get: key => table.get(key) as RouteRecord | undefined,
|
|
@@ -205,13 +192,21 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
|
|
|
205
192
|
delete: key => accessRequestsRaw.delete(key),
|
|
206
193
|
entries: () => accessRequestsRaw.entries() as IterableIterator<[string, AccessRequestRecord]>,
|
|
207
194
|
},
|
|
208
|
-
|
|
195
|
+
accessWikiUsers: {
|
|
196
|
+
get: key => accessWikiUsersRaw.get(key) as AccessUserRecord | undefined,
|
|
197
|
+
put: (key, value) => accessWikiUsersRaw.put(key, value),
|
|
198
|
+
delete: key => accessWikiUsersRaw.delete(key),
|
|
199
|
+
entries: () => accessWikiUsersRaw.entries() as IterableIterator<[string, AccessUserRecord]>,
|
|
200
|
+
},
|
|
209
201
|
getGlobal: () => (domain.global.get() ?? {}) as FeishuDomainGlobal,
|
|
210
202
|
setGlobal: value => domain.global.set(value),
|
|
211
203
|
close: () => domain.close(),
|
|
212
204
|
}
|
|
213
205
|
}
|
|
214
206
|
|
|
207
|
+
/** `/auth wiki` usage line (input-error card). */
|
|
208
|
+
const AUTH_WIKI_USAGE = '用法:/auth wiki add <@用户|user_id> 或 /auth wiki remove <@用户|user_id>。知识库白名单里的用户(以及管理员)发起的话题可以使用知识库工具。'
|
|
209
|
+
|
|
215
210
|
/**
|
|
216
211
|
* Resolve one credential reference through the host credentials seam.
|
|
217
212
|
*
|
|
@@ -239,52 +234,6 @@ function messageOf(error: unknown): string {
|
|
|
239
234
|
return error instanceof Error ? error.message : String(error)
|
|
240
235
|
}
|
|
241
236
|
|
|
242
|
-
/**
|
|
243
|
-
* Measure one workspace directory for the cleanup preview: total regular-file
|
|
244
|
-
* bytes and file count. Symlinks are never followed; unreadable entries are
|
|
245
|
-
* skipped so a permission problem cannot fail the whole pass.
|
|
246
|
-
*/
|
|
247
|
-
async function measureWorkspace(dir: string): Promise<WorkspaceStats | undefined> {
|
|
248
|
-
let info
|
|
249
|
-
try {
|
|
250
|
-
info = await lstat(dir)
|
|
251
|
-
} catch {
|
|
252
|
-
return undefined
|
|
253
|
-
}
|
|
254
|
-
if (!info.isDirectory()) return undefined
|
|
255
|
-
let bytes = 0
|
|
256
|
-
let files = 0
|
|
257
|
-
const pending = [dir]
|
|
258
|
-
while (pending.length > 0) {
|
|
259
|
-
const current = pending.pop()!
|
|
260
|
-
let entries
|
|
261
|
-
try {
|
|
262
|
-
entries = await readdir(current, { withFileTypes: true })
|
|
263
|
-
} catch {
|
|
264
|
-
continue
|
|
265
|
-
}
|
|
266
|
-
for (const entry of entries) {
|
|
267
|
-
const child = path.join(current, entry.name)
|
|
268
|
-
if (entry.isSymbolicLink()) continue
|
|
269
|
-
if (entry.isDirectory()) {
|
|
270
|
-
pending.push(child)
|
|
271
|
-
continue
|
|
272
|
-
}
|
|
273
|
-
if (!entry.isFile()) continue
|
|
274
|
-
try {
|
|
275
|
-
const stat = await lstat(child)
|
|
276
|
-
if (stat.isFile()) {
|
|
277
|
-
bytes += stat.size
|
|
278
|
-
files += 1
|
|
279
|
-
}
|
|
280
|
-
} catch {
|
|
281
|
-
// The directory changed while walking it; the byte total is advisory.
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
return { mtimeMs: info.mtimeMs, bytes, files }
|
|
286
|
-
}
|
|
287
|
-
|
|
288
237
|
export class FeishuService {
|
|
289
238
|
private readonly ctx: HostContext
|
|
290
239
|
/** Live config object. Mutated in place — collaborators captured this reference. */
|
|
@@ -313,12 +262,6 @@ export class FeishuService {
|
|
|
313
262
|
* fallback when the host does not mount `ctx.attachments`.
|
|
314
263
|
*/
|
|
315
264
|
private readonly inboundAttachmentStore: InboundAttachmentStore
|
|
316
|
-
/**
|
|
317
|
-
* Silently registered attachment metadata (`pending_attachments` table):
|
|
318
|
-
* admission-layer registration, prompt manifest reads, fetch authorization
|
|
319
|
-
* whitelist, and the TTL/cap sweep wired into the periodic record GC.
|
|
320
|
-
*/
|
|
321
|
-
private readonly pendingAttachments: PendingAttachmentStore
|
|
322
265
|
/**
|
|
323
266
|
* Read-only Feishu message access (P2): the quoted-message excerpt and the
|
|
324
267
|
* first-turn topic history. Constructed before the inbound router so the
|
|
@@ -336,6 +279,9 @@ export class FeishuService {
|
|
|
336
279
|
*/
|
|
337
280
|
private readonly attachmentFetchTools: AttachmentFetchToolRuntime
|
|
338
281
|
private readonly topicMessagesTools: TopicMessagesToolRuntime
|
|
282
|
+
private readonly sendFileTools: SendFileToolRuntime
|
|
283
|
+
private readonly wikiConsent: WikiConsent
|
|
284
|
+
private readonly wikiTools: WikiToolRuntime
|
|
339
285
|
/** FIFO admission for ordinary turns and commands targeting one route. */
|
|
340
286
|
private readonly routeOperations = new RouteOperationQueue()
|
|
341
287
|
/** Active Host command executions, aborted before shutdown tears peers down. */
|
|
@@ -369,10 +315,6 @@ export class FeishuService {
|
|
|
369
315
|
private readonly unlistenHost: Array<() => boolean> = []
|
|
370
316
|
/** Periodic route-GC timer (unref'd; only armed when `routeTtlMs > 0`). */
|
|
371
317
|
private gcTimer: ReturnType<typeof setInterval> | undefined
|
|
372
|
-
/** Periodic workspace-retention timer (only armed when auto cleanup is on). */
|
|
373
|
-
private retentionTimer: ReturnType<typeof setInterval> | undefined
|
|
374
|
-
/** Guards overlapping cleanup passes (panel button / daily timer). */
|
|
375
|
-
private cleanupRunning = false
|
|
376
318
|
/** HITL approval answerer (created in `start()`). */
|
|
377
319
|
private approvalAnswerer: ApprovalAnswerer | undefined
|
|
378
320
|
/** HITL structured-question answerer (`ask_user_question`). */
|
|
@@ -389,6 +331,17 @@ export class FeishuService {
|
|
|
389
331
|
/** Administrator DM authorization cards + bot-added group info cards. */
|
|
390
332
|
private accessRequests: AccessRequests | undefined
|
|
391
333
|
private unlistenBotAdded: (() => void) | undefined
|
|
334
|
+
/**
|
|
335
|
+
* App owner resolved at `start()`; the administrator whenever `adminUserId`
|
|
336
|
+
* is unset. Cleared on shutdown so a reconnect looks it up again.
|
|
337
|
+
*/
|
|
338
|
+
private appOwner: AppOwnerState | undefined
|
|
339
|
+
/** Post-connect scope self-check; cleared on shutdown. */
|
|
340
|
+
private scopeCheckState: ScopeCheckWire | undefined
|
|
341
|
+
/** Post-connect slash-command panel sync; cleared on shutdown. */
|
|
342
|
+
private slashCommandSyncState: SlashCommandSyncWire | undefined
|
|
343
|
+
/** Aborts the running panel sync's remaining writes (timeout, shutdown, reconnect). */
|
|
344
|
+
private slashCommandSyncAbort: AbortController | undefined
|
|
392
345
|
|
|
393
346
|
constructor(
|
|
394
347
|
ctx: HostContext,
|
|
@@ -430,13 +383,16 @@ export class FeishuService {
|
|
|
430
383
|
//
|
|
431
384
|
// The topic-messages tool is backend-independent: quoted excerpts and
|
|
432
385
|
// topic history are read-only, so it is mounted for both backends and
|
|
433
|
-
// self-gates on `feishuTools.topicMessages
|
|
434
|
-
//
|
|
386
|
+
// self-gates on `feishuTools.topicMessages`; `feishu_send_file`
|
|
387
|
+
// self-gates on `feishuTools.sendFile`. The mounts start together and
|
|
388
|
+
// resolve as one promise (no serial blocking).
|
|
435
389
|
const mounts: Array<Promise<void>> = []
|
|
436
390
|
if (this.effectiveAttachmentBackend() === 'native') {
|
|
437
391
|
mounts.push(this.attachmentFetchTools.mount(agentCtx, sessionId))
|
|
438
392
|
}
|
|
439
393
|
mounts.push(this.topicMessagesTools.mount(agentCtx, sessionId))
|
|
394
|
+
mounts.push(this.sendFileTools.mount(agentCtx, sessionId))
|
|
395
|
+
mounts.push(this.wikiTools.mount(agentCtx, sessionId))
|
|
440
396
|
return Promise.all(mounts).then(() => undefined)
|
|
441
397
|
},
|
|
442
398
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
@@ -457,18 +413,9 @@ export class FeishuService {
|
|
|
457
413
|
archive: this.attachments,
|
|
458
414
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
459
415
|
})
|
|
460
|
-
this.pendingAttachments = new PendingAttachmentStore({
|
|
461
|
-
// Undefined storage domain/table → the registry's memory TTL fallback.
|
|
462
|
-
table: () => this.domain?.pendingAttachments,
|
|
463
|
-
enabled: () => this.config.acceptAttachments,
|
|
464
|
-
ttlMs: () => this.config.pendingAttachmentTtlMs,
|
|
465
|
-
maxPerScope: () => this.config.pendingAttachmentLimit,
|
|
466
|
-
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
467
|
-
})
|
|
468
416
|
this.attachmentFetchTools = new AttachmentFetchToolRuntime({
|
|
469
417
|
config: () => this.config,
|
|
470
418
|
channel: () => this.channel,
|
|
471
|
-
pending: () => this.pendingAttachments,
|
|
472
419
|
routeOfSession: sessionId => {
|
|
473
420
|
const record = this.routes.findBySession(sessionId)
|
|
474
421
|
if (record === undefined) return undefined
|
|
@@ -477,7 +424,7 @@ export class FeishuService {
|
|
|
477
424
|
kind: record.kind,
|
|
478
425
|
chatId: record.chatId,
|
|
479
426
|
chatType: record.chatType,
|
|
480
|
-
|
|
427
|
+
senderUserId: record.senderUserId,
|
|
481
428
|
}
|
|
482
429
|
},
|
|
483
430
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
@@ -493,7 +440,7 @@ export class FeishuService {
|
|
|
493
440
|
kind: record.kind,
|
|
494
441
|
chatId: record.chatId,
|
|
495
442
|
chatType: record.chatType,
|
|
496
|
-
|
|
443
|
+
senderUserId: record.senderUserId,
|
|
497
444
|
}
|
|
498
445
|
},
|
|
499
446
|
noteKnownMessage: (route, message, source) => {
|
|
@@ -505,6 +452,35 @@ export class FeishuService {
|
|
|
505
452
|
fetchAvailable: () => this.effectiveAttachmentBackend() === 'native',
|
|
506
453
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
507
454
|
})
|
|
455
|
+
this.wikiConsent = new WikiConsent({
|
|
456
|
+
channel: () => (this.started ? this.channel : undefined),
|
|
457
|
+
timeoutMs: () => this.config.approvalTimeoutMs,
|
|
458
|
+
onError: (sessionId, error) => {
|
|
459
|
+
this.ctx.logger?.warn?.('dsh-channels wiki consent error (session %s): %s', sessionId, String(error))
|
|
460
|
+
},
|
|
461
|
+
})
|
|
462
|
+
this.wikiTools = new WikiToolRuntime({
|
|
463
|
+
config: () => this.config,
|
|
464
|
+
channel: () => (this.started ? this.channel : undefined),
|
|
465
|
+
routeOfSession: sessionId => this.approvalRouteFor(sessionId),
|
|
466
|
+
mayUseWiki: userId => this.accessController.isAdmin(userId) || this.accessStore.hasWikiUser(userId),
|
|
467
|
+
consent: this.wikiConsent,
|
|
468
|
+
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
469
|
+
})
|
|
470
|
+
this.sendFileTools = new SendFileToolRuntime({
|
|
471
|
+
config: () => this.config,
|
|
472
|
+
channel: () => (this.started ? this.channel : undefined),
|
|
473
|
+
targetOfSession: sessionId => {
|
|
474
|
+
const record = this.routes.findBySession(sessionId)
|
|
475
|
+
if (record === undefined) return undefined
|
|
476
|
+
return {
|
|
477
|
+
replyTo: record.cardMessageId,
|
|
478
|
+
inThread: record.kind === 'thread',
|
|
479
|
+
rootDir: sessionDirOf(record),
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
483
|
+
})
|
|
508
484
|
this.delivery = new CardDelivery({
|
|
509
485
|
channel: () => this.channel,
|
|
510
486
|
started: () => this.started,
|
|
@@ -516,7 +492,7 @@ export class FeishuService {
|
|
|
516
492
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
517
493
|
})
|
|
518
494
|
this.accessController = new AccessController({
|
|
519
|
-
config: () => this.
|
|
495
|
+
config: () => this.accessConfig(),
|
|
520
496
|
store: this.accessStore,
|
|
521
497
|
channel: () => this.channel,
|
|
522
498
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
@@ -543,7 +519,6 @@ export class FeishuService {
|
|
|
543
519
|
attachmentMode: () => this.config.attachmentStore,
|
|
544
520
|
nativeAttachments: () => this.nativeAttachmentService(),
|
|
545
521
|
attachmentStore: this.inboundAttachmentStore,
|
|
546
|
-
pendingAttachments: this.pendingAttachments,
|
|
547
522
|
noteKnownAttachments: (record, message, source) => {
|
|
548
523
|
this.noteKnownAttachmentMessage(record, message, source ?? 'trigger')
|
|
549
524
|
},
|
|
@@ -561,6 +536,7 @@ export class FeishuService {
|
|
|
561
536
|
queue: this.routeOperations,
|
|
562
537
|
streamer: () => this.streamer,
|
|
563
538
|
questionAnswerer: () => this.questionAnswerer,
|
|
539
|
+
approvalAnswerer: () => this.approvalAnswerer,
|
|
564
540
|
commands: { handleSlash: (msg, line) => this.handleSlashMessage(msg, line) },
|
|
565
541
|
})
|
|
566
542
|
}
|
|
@@ -603,8 +579,8 @@ export class FeishuService {
|
|
|
603
579
|
* Pre-authorize one admitted Feishu message for `feishu_attachment_fetch`.
|
|
604
580
|
*
|
|
605
581
|
* The admission and quote layers call this after their access gates for the
|
|
606
|
-
* triggering / quoted message; the tool otherwise only authorizes
|
|
607
|
-
*
|
|
582
|
+
* triggering / quoted message; the tool otherwise only authorizes the route
|
|
583
|
+
* owner's own messages it reads back from Feishu. Registering is a pure
|
|
608
584
|
* in-memory operation (one LRU entry per route) and never fails the caller.
|
|
609
585
|
*/
|
|
610
586
|
noteKnownAttachmentMessage(
|
|
@@ -617,7 +593,7 @@ export class FeishuService {
|
|
|
617
593
|
kind: record.kind,
|
|
618
594
|
chatId: record.chatId,
|
|
619
595
|
chatType: record.chatType,
|
|
620
|
-
|
|
596
|
+
senderUserId: record.senderUserId,
|
|
621
597
|
}, message, source)
|
|
622
598
|
}
|
|
623
599
|
|
|
@@ -633,7 +609,7 @@ export class FeishuService {
|
|
|
633
609
|
kind: record.kind,
|
|
634
610
|
chatId: record.chatId,
|
|
635
611
|
chatType: record.chatType,
|
|
636
|
-
|
|
612
|
+
senderUserId: record.senderUserId,
|
|
637
613
|
}
|
|
638
614
|
}
|
|
639
615
|
|
|
@@ -907,7 +883,6 @@ export class FeishuService {
|
|
|
907
883
|
*/
|
|
908
884
|
applyOverlay(overlay: OverlayPatch): void {
|
|
909
885
|
this.replaceConfig(mergeOverlay(this.baseConfig, overlay))
|
|
910
|
-
this.refreshRetentionTimer()
|
|
911
886
|
}
|
|
912
887
|
|
|
913
888
|
/**
|
|
@@ -956,15 +931,17 @@ export class FeishuService {
|
|
|
956
931
|
|
|
957
932
|
/** Whitelists + pending authorization requests (read-only Web projection). */
|
|
958
933
|
accessSnapshot(): AccessWire {
|
|
959
|
-
const admin = this.
|
|
934
|
+
const admin = this.effectiveAdminUserId()
|
|
935
|
+
const owner = this.appOwner
|
|
960
936
|
return {
|
|
961
937
|
mode: this.config.accessMode,
|
|
962
938
|
// Whitelists live in the storage domain, which is only open while the
|
|
963
939
|
// channel runs; an unconnected panel must not present them as empty.
|
|
964
940
|
loaded: this.domain !== undefined,
|
|
965
941
|
admin: {
|
|
966
|
-
...(admin === undefined ? {} : {
|
|
942
|
+
...(admin === undefined ? {} : { userId: admin, source: this.configuredAdminUserId() === undefined ? 'owner' : 'config' }),
|
|
967
943
|
configured: admin !== undefined,
|
|
944
|
+
...(owner !== undefined && 'error' in owner ? { ownerLookupError: owner.error } : {}),
|
|
968
945
|
},
|
|
969
946
|
groups: this.accessStore.groupRecords().map(record => ({
|
|
970
947
|
chatId: record.chatId,
|
|
@@ -973,7 +950,7 @@ export class FeishuService {
|
|
|
973
950
|
addedAt: record.addedAt,
|
|
974
951
|
})),
|
|
975
952
|
users: this.accessStore.userRecords().map(record => ({
|
|
976
|
-
|
|
953
|
+
userId: record.userId,
|
|
977
954
|
...(record.name === undefined ? {} : { name: record.name }),
|
|
978
955
|
source: record.source,
|
|
979
956
|
addedAt: record.addedAt,
|
|
@@ -990,7 +967,7 @@ export class FeishuService {
|
|
|
990
967
|
if (name !== undefined) wire.name = name
|
|
991
968
|
if (record.chatId !== undefined) wire.chatId = record.chatId
|
|
992
969
|
if (record.groupName !== undefined) wire.groupName = record.groupName
|
|
993
|
-
const requester = record.requesterName ?? record.
|
|
970
|
+
const requester = record.requesterName ?? record.requesterUserId
|
|
994
971
|
if (requester !== undefined) wire.requester = requester
|
|
995
972
|
return wire
|
|
996
973
|
}),
|
|
@@ -1001,8 +978,8 @@ export class FeishuService {
|
|
|
1001
978
|
async addAccessGroup(chatId: string, name?: string): Promise<AccessMutationResponse> {
|
|
1002
979
|
const id = this.cleanAccessTargetId(chatId)
|
|
1003
980
|
if (id === undefined) return { ok: false, error: 'chat_id 不合法(应为 oc_ 开头的群 ID)' }
|
|
1004
|
-
const admin = this.
|
|
1005
|
-
if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员
|
|
981
|
+
const admin = this.effectiveAdminUserId()
|
|
982
|
+
if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 user_id' }
|
|
1006
983
|
try {
|
|
1007
984
|
if (!this.accessStore.hasGroup(id)) {
|
|
1008
985
|
await this.accessStore.addGroup({
|
|
@@ -1032,15 +1009,15 @@ export class FeishuService {
|
|
|
1032
1009
|
}
|
|
1033
1010
|
|
|
1034
1011
|
/** Panel: authorize one user (global across authorized groups). */
|
|
1035
|
-
async addAccessUser(
|
|
1036
|
-
const id = this.cleanAccessUserId(
|
|
1037
|
-
if (id === undefined) return { ok: false, error: '
|
|
1038
|
-
const admin = this.
|
|
1039
|
-
if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员
|
|
1012
|
+
async addAccessUser(userId: string, name?: string): Promise<AccessMutationResponse> {
|
|
1013
|
+
const id = this.cleanAccessUserId(userId)
|
|
1014
|
+
if (id === undefined) return { ok: false, error: 'user_id 不合法(只能包含字母、数字、_ 和 -,不是 ou_ 开头的 open_id)' }
|
|
1015
|
+
const admin = this.effectiveAdminUserId()
|
|
1016
|
+
if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 user_id' }
|
|
1040
1017
|
try {
|
|
1041
1018
|
if (!this.accessStore.hasUser(id)) {
|
|
1042
1019
|
await this.accessStore.addUser({
|
|
1043
|
-
|
|
1020
|
+
userId: id,
|
|
1044
1021
|
...(name === undefined || name.trim() === '' ? {} : { name: name.trim() }),
|
|
1045
1022
|
source: 'panel',
|
|
1046
1023
|
addedBy: admin,
|
|
@@ -1054,9 +1031,9 @@ export class FeishuService {
|
|
|
1054
1031
|
}
|
|
1055
1032
|
|
|
1056
1033
|
/** Panel: revoke one user. */
|
|
1057
|
-
async removeAccessUser(
|
|
1058
|
-
const id = this.cleanAccessUserId(
|
|
1059
|
-
if (id === undefined) return { ok: false, error: '
|
|
1034
|
+
async removeAccessUser(userId: string): Promise<AccessMutationResponse> {
|
|
1035
|
+
const id = this.cleanAccessUserId(userId)
|
|
1036
|
+
if (id === undefined) return { ok: false, error: 'user_id 不合法' }
|
|
1060
1037
|
try {
|
|
1061
1038
|
await this.accessStore.removeUser(id)
|
|
1062
1039
|
return { ok: true, access: this.accessSnapshot() }
|
|
@@ -1069,8 +1046,8 @@ export class FeishuService {
|
|
|
1069
1046
|
async resolveAccessRequest(token: string, decision: 'allow' | 'deny'): Promise<AccessMutationResponse> {
|
|
1070
1047
|
const requests = this.accessRequests
|
|
1071
1048
|
if (requests === undefined) return { ok: false, error: '授权服务尚未启动' }
|
|
1072
|
-
const actor = this.
|
|
1073
|
-
if (actor === undefined) return { ok: false, error: '请先在「访问授权」标签页配置管理员
|
|
1049
|
+
const actor = this.effectiveAdminUserId()
|
|
1050
|
+
if (actor === undefined) return { ok: false, error: '请先在「访问授权」标签页配置管理员 user_id' }
|
|
1074
1051
|
try {
|
|
1075
1052
|
const result = await requests.resolve(token, decision, actor)
|
|
1076
1053
|
if (!result.ok) return { ok: false, error: result.error }
|
|
@@ -1080,276 +1057,193 @@ export class FeishuService {
|
|
|
1080
1057
|
}
|
|
1081
1058
|
}
|
|
1082
1059
|
|
|
1083
|
-
/**
|
|
1084
|
-
private
|
|
1085
|
-
const
|
|
1086
|
-
if (
|
|
1087
|
-
|
|
1088
|
-
if (/[\u0000-\u001f\u007f]/u.test(value)) return undefined
|
|
1089
|
-
return value
|
|
1060
|
+
/** `/auth status` administrator line: id plus where it came from. */
|
|
1061
|
+
private adminStatusLine(): string {
|
|
1062
|
+
const admin = this.effectiveAdminUserId()
|
|
1063
|
+
if (admin === undefined) return '未配置'
|
|
1064
|
+
return this.configuredAdminUserId() === undefined ? `\`${admin}\`(应用所有者,自动)` : `\`${admin}\``
|
|
1090
1065
|
}
|
|
1091
1066
|
|
|
1092
|
-
/**
|
|
1093
|
-
private
|
|
1094
|
-
const
|
|
1095
|
-
|
|
1096
|
-
return value
|
|
1067
|
+
/** The explicitly configured administrator, trimmed; empty counts as unset. */
|
|
1068
|
+
private configuredAdminUserId(): string | undefined {
|
|
1069
|
+
const admin = this.config.adminUserId?.trim()
|
|
1070
|
+
return admin === undefined || admin === '' ? undefined : admin
|
|
1097
1071
|
}
|
|
1098
1072
|
|
|
1099
|
-
|
|
1073
|
+
/** Configured administrator, else the app owner looked up at `start()`. */
|
|
1074
|
+
private effectiveAdminUserId(): string | undefined {
|
|
1075
|
+
const configured = this.configuredAdminUserId()
|
|
1076
|
+
if (configured !== undefined) return configured
|
|
1077
|
+
const owner = this.appOwner
|
|
1078
|
+
return owner !== undefined && 'userId' in owner ? owner.userId : undefined
|
|
1079
|
+
}
|
|
1100
1080
|
|
|
1101
|
-
/**
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
* footprint. Never deletes anything.
|
|
1105
|
-
*/
|
|
1106
|
-
async cleanupPreview(): Promise<{
|
|
1107
|
-
retention: string
|
|
1108
|
-
thresholdMs?: number
|
|
1109
|
-
auto: boolean
|
|
1110
|
-
workspaces: number
|
|
1111
|
-
candidates: number
|
|
1112
|
-
candidateBytes: number
|
|
1113
|
-
error?: string
|
|
1114
|
-
}> {
|
|
1115
|
-
const retention = this.config.workspaceRetention
|
|
1116
|
-
const window = retentionMs(retention)
|
|
1117
|
-
const base = {
|
|
1118
|
-
retention,
|
|
1119
|
-
...(window === undefined ? {} : { thresholdMs: window }),
|
|
1120
|
-
auto: this.config.workspaceRetentionAuto,
|
|
1121
|
-
}
|
|
1122
|
-
if (this.domain === undefined) {
|
|
1123
|
-
// No connection: routes live in the closed storage domain, so a preview
|
|
1124
|
-
// would mistake every directory for a candidate. Report why instead.
|
|
1125
|
-
return {
|
|
1126
|
-
...base,
|
|
1127
|
-
workspaces: 0,
|
|
1128
|
-
candidates: 0,
|
|
1129
|
-
candidateBytes: 0,
|
|
1130
|
-
error: '飞书连接建立后即可预览和清理工作区',
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
try {
|
|
1134
|
-
const workspaces = this.ctx.workspaceRegistry.list().filter(workspace => this.isPluginWorkspace(workspace))
|
|
1135
|
-
const plan = await planWorkspaceCleanup(this.cleanupDeps())
|
|
1136
|
-
return {
|
|
1137
|
-
...base,
|
|
1138
|
-
workspaces: workspaces.length,
|
|
1139
|
-
candidates: plan?.candidates.length ?? 0,
|
|
1140
|
-
candidateBytes: plan?.candidates.reduce((total, candidate) => total + candidate.bytes, 0) ?? 0,
|
|
1141
|
-
}
|
|
1142
|
-
} catch (error) {
|
|
1143
|
-
return { ...base, workspaces: 0, candidates: 0, candidateBytes: 0, error: messageOf(error) }
|
|
1144
|
-
}
|
|
1081
|
+
/** Live config with `adminUserId` replaced by the effective administrator. */
|
|
1082
|
+
private accessConfig(): FeishuConfig {
|
|
1083
|
+
return { ...this.config, adminUserId: this.effectiveAdminUserId() }
|
|
1145
1084
|
}
|
|
1146
1085
|
|
|
1147
1086
|
/**
|
|
1148
|
-
*
|
|
1149
|
-
*
|
|
1150
|
-
*
|
|
1151
|
-
* never deleted.
|
|
1152
|
-
*
|
|
1153
|
-
* Active routes bound to a removed directory are retired first (thread
|
|
1154
|
-
* routes leave a tombstone), so an old topic fails with the explicit expired
|
|
1155
|
-
* card instead of resuming into a missing directory. DSH Session logs are not
|
|
1156
|
-
* touched.
|
|
1087
|
+
* Look up the app owner for the administrator fallback. Never throws: a
|
|
1088
|
+
* failure is recorded for the panel and only warned about when no
|
|
1089
|
+
* administrator is configured (the fallback is then what admission needs).
|
|
1157
1090
|
*/
|
|
1158
|
-
async
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
freedBytes: number
|
|
1164
|
-
failures: string[]
|
|
1165
|
-
error?: string
|
|
1166
|
-
}> {
|
|
1167
|
-
const empty = {
|
|
1168
|
-
ok: false,
|
|
1169
|
-
deletedWorkspaces: 0,
|
|
1170
|
-
deletedSessionDirs: 0,
|
|
1171
|
-
retiredRoutes: 0,
|
|
1172
|
-
freedBytes: 0,
|
|
1173
|
-
failures: [] as string[],
|
|
1174
|
-
}
|
|
1175
|
-
if (this.domain === undefined) {
|
|
1176
|
-
// Without the storage domain the route table is unreadable, so a cleanup
|
|
1177
|
-
// could mistake live directories for candidates and fail to retire their
|
|
1178
|
-
// routes. Refuse rather than rely on the panel disabling the button.
|
|
1179
|
-
return { ...empty, error: '飞书连接建立后才能清理工作区' }
|
|
1180
|
-
}
|
|
1181
|
-
if (retentionMs(this.config.workspaceRetention) === undefined) {
|
|
1182
|
-
return { ...empty, error: '未启用目录清理阈值(workspaceRetention 为 off)' }
|
|
1183
|
-
}
|
|
1184
|
-
if (this.cleanupRunning) return { ...empty, error: '目录清理正在进行中,请稍候' }
|
|
1185
|
-
this.cleanupRunning = true
|
|
1091
|
+
private async resolveAppOwner(channel: FeishuChannel): Promise<void> {
|
|
1092
|
+
this.appOwner = undefined
|
|
1093
|
+
if (channel.appOwnerUserId === undefined) return
|
|
1094
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1095
|
+
let state: AppOwnerState
|
|
1186
1096
|
try {
|
|
1187
|
-
const
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
ok: true,
|
|
1197
|
-
deletedWorkspaces: outcome.deletedWorkspaces,
|
|
1198
|
-
deletedSessionDirs: outcome.deletedSessionDirs,
|
|
1199
|
-
retiredRoutes: outcome.retiredRoutes,
|
|
1200
|
-
freedBytes: outcome.freedBytes,
|
|
1201
|
-
failures: outcome.failures.map(failure => `${failure.path}: ${failure.message}`),
|
|
1202
|
-
}
|
|
1097
|
+
const userId = await Promise.race([
|
|
1098
|
+
channel.appOwnerUserId(),
|
|
1099
|
+
new Promise<never>((_, reject) => {
|
|
1100
|
+
timer = setTimeout(() => reject(new Error(`超时(${APP_OWNER_LOOKUP_TIMEOUT_MS} ms)`)), APP_OWNER_LOOKUP_TIMEOUT_MS)
|
|
1101
|
+
}),
|
|
1102
|
+
])
|
|
1103
|
+
state = userId !== undefined && FEISHU_USER_ID_PATTERN.test(userId)
|
|
1104
|
+
? { userId }
|
|
1105
|
+
: { error: '应用所有者不是企业内成员,或接口未返回其 user_id', apiRejected: false }
|
|
1203
1106
|
} catch (error) {
|
|
1204
|
-
|
|
1107
|
+
const detail = error instanceof AppOwnerLookupError ? error.detail : messageOf(error)
|
|
1108
|
+
state = {
|
|
1109
|
+
error: `获取应用信息失败:${detail}。请在「访问授权」手动填写管理员 user_id(私聊机器人发 /userid 获取,私聊模式关闭时可在飞书管理后台的成员详情里查看用户 ID);或为应用开通「获取应用信息」权限并发布版本后重新连接`,
|
|
1110
|
+
apiRejected: error instanceof AppOwnerLookupError && error.code !== undefined,
|
|
1111
|
+
}
|
|
1205
1112
|
} finally {
|
|
1206
|
-
|
|
1113
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
1114
|
+
}
|
|
1115
|
+
// A background lookup can outlive its connection (shutdown/reconnect).
|
|
1116
|
+
if (this.channel !== channel) return
|
|
1117
|
+
this.appOwner = state
|
|
1118
|
+
if ('error' in state && this.configuredAdminUserId() === undefined) {
|
|
1119
|
+
this.ctx.logger?.warn?.('dsh-channels: app owner fallback unavailable: %s', state.error)
|
|
1207
1120
|
}
|
|
1208
1121
|
}
|
|
1209
1122
|
|
|
1210
|
-
/**
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
return pluginTitle && isPathInside(path.resolve(this.config.baseDir), workspace.path)
|
|
1123
|
+
/** Latest post-connect scope self-check, when one finished for this connection. */
|
|
1124
|
+
scopeCheck(): ScopeCheckWire | undefined {
|
|
1125
|
+
return this.scopeCheckState
|
|
1214
1126
|
}
|
|
1215
1127
|
|
|
1216
|
-
/**
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
return {
|
|
1220
|
-
baseDir,
|
|
1221
|
-
retention: this.config.workspaceRetention,
|
|
1222
|
-
listWorkspaces: () => this.ctx.workspaceRegistry.list(),
|
|
1223
|
-
listSessionDirs: () => this.listCleanableSessionDirs(baseDir),
|
|
1224
|
-
activeRoutes: () => [...(this.domain?.routes.entries() ?? [])].map(([key, record]): CleanupRoute => ({
|
|
1225
|
-
key,
|
|
1226
|
-
kind: record.kind,
|
|
1227
|
-
workspacePath: record.workspacePath,
|
|
1228
|
-
...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
|
|
1229
|
-
sessionId: record.sessionId,
|
|
1230
|
-
lastActivityAt: record.lastActivityAt,
|
|
1231
|
-
})),
|
|
1232
|
-
tombstones: () => [...(this.domain?.expiredRoutes.entries() ?? [])].map(([key, record]): CleanupTombstone => ({
|
|
1233
|
-
key,
|
|
1234
|
-
...(record.workspacePath === undefined ? {} : { workspacePath: record.workspacePath }),
|
|
1235
|
-
...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
|
|
1236
|
-
expiredAt: record.expiredAt,
|
|
1237
|
-
...(record.lastActivityAt === undefined ? {} : { lastActivityAt: record.lastActivityAt }),
|
|
1238
|
-
})),
|
|
1239
|
-
isLive: sessionId => this.sessions.get(sessionId) !== undefined
|
|
1240
|
-
|| this.ctx.agents.get(sessionId as SessionId) !== undefined,
|
|
1241
|
-
statWorkspace: directory => measureWorkspace(directory),
|
|
1242
|
-
removeWorkspace: async workspace => {
|
|
1243
|
-
await this.deleteDirectoryInsideBase(baseDir, workspace.path)
|
|
1244
|
-
try {
|
|
1245
|
-
await this.ctx.workspaceRegistry.delete(workspace.id)
|
|
1246
|
-
} catch (error) {
|
|
1247
|
-
this.ctx.logger?.warn?.(
|
|
1248
|
-
'dsh-channels: unregistering cleaned workspace %s failed: %s', workspace.path, String(error),
|
|
1249
|
-
)
|
|
1250
|
-
}
|
|
1251
|
-
},
|
|
1252
|
-
removeSessionDir: async directory => {
|
|
1253
|
-
await this.deleteDirectoryInsideBase(baseDir, directory)
|
|
1254
|
-
},
|
|
1255
|
-
retireRoute: async route => {
|
|
1256
|
-
if (route.kind === 'thread') await this.routes.retire(route.key)
|
|
1257
|
-
else await this.routes.remove(route.key)
|
|
1258
|
-
this.attachmentFetchTools.forgetRoute(route.key)
|
|
1259
|
-
},
|
|
1260
|
-
warn: message => this.ctx.logger?.warn?.(message),
|
|
1261
|
-
}
|
|
1128
|
+
/** Latest native slash-command panel sync, when one finished for this connection. */
|
|
1129
|
+
slashCommandSync(): SlashCommandSyncWire | undefined {
|
|
1130
|
+
return this.slashCommandSyncState
|
|
1262
1131
|
}
|
|
1263
1132
|
|
|
1264
1133
|
/**
|
|
1265
|
-
*
|
|
1266
|
-
*
|
|
1267
|
-
*
|
|
1268
|
-
* wins for age; the scan mtime only covers true orphans.
|
|
1134
|
+
* Reconcile Feishu's native `/` panel with the command registry and log the
|
|
1135
|
+
* outcome. Never throws; a failure only loses the panel listing, typed
|
|
1136
|
+
* commands keep working.
|
|
1269
1137
|
*/
|
|
1270
|
-
private async
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1138
|
+
private async runSlashCommandSync(channel: FeishuChannel): Promise<void> {
|
|
1139
|
+
this.slashCommandSyncState = undefined
|
|
1140
|
+
this.slashCommandSyncAbort?.abort()
|
|
1141
|
+
this.slashCommandSyncAbort = undefined
|
|
1142
|
+
const api = channel.slashCommands
|
|
1143
|
+
if (api === undefined) return
|
|
1144
|
+
const abort = new AbortController()
|
|
1145
|
+
this.slashCommandSyncAbort = abort
|
|
1146
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1147
|
+
let state: SlashCommandSyncWire
|
|
1148
|
+
try {
|
|
1149
|
+
const result = await Promise.race([
|
|
1150
|
+
syncSlashCommands(api, desiredSlashCommands(), abort.signal),
|
|
1151
|
+
new Promise<never>((_, reject) => {
|
|
1152
|
+
timer = setTimeout(() => reject(new Error(`超时(${SLASH_COMMAND_SYNC_TIMEOUT_MS} ms)`)), SLASH_COMMAND_SYNC_TIMEOUT_MS)
|
|
1153
|
+
}),
|
|
1154
|
+
])
|
|
1155
|
+
state = { state: 'ok', checkedAt: Date.now(), ...result }
|
|
1156
|
+
} catch (error) {
|
|
1157
|
+
const detail = error instanceof FeishuApiError ? error.detail : messageOf(error)
|
|
1158
|
+
state = { state: 'error', checkedAt: Date.now(), error: detail }
|
|
1159
|
+
} finally {
|
|
1160
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
1161
|
+
// A timed-out sync must not keep writing behind a reported failure.
|
|
1162
|
+
abort.abort()
|
|
1163
|
+
if (this.slashCommandSyncAbort === abort) this.slashCommandSyncAbort = undefined
|
|
1164
|
+
}
|
|
1165
|
+
// A background sync can outlive its connection (shutdown/reconnect).
|
|
1166
|
+
if (this.channel !== channel) return
|
|
1167
|
+
this.slashCommandSyncState = state
|
|
1168
|
+
if (state.state === 'ok') {
|
|
1169
|
+
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)
|
|
1170
|
+
} else {
|
|
1171
|
+
this.ctx.logger?.warn?.('dsh-channels: 飞书原生斜杠指令面板同步失败(直接输入命令不受影响):%s', state.error)
|
|
1301
1172
|
}
|
|
1302
|
-
return scanned
|
|
1303
1173
|
}
|
|
1304
1174
|
|
|
1305
1175
|
/**
|
|
1306
|
-
*
|
|
1307
|
-
*
|
|
1308
|
-
*
|
|
1309
|
-
* refuses a symlinked ancestor that would let the recursive delete escape
|
|
1310
|
-
* the plugin root. A missing directory is already effectively removed.
|
|
1176
|
+
* Compare the app's granted scopes with what the plugin uses and log the
|
|
1177
|
+
* missing ones. Never throws; waits for the owner lookup so the app-info
|
|
1178
|
+
* need reflects its outcome.
|
|
1311
1179
|
*/
|
|
1312
|
-
private async
|
|
1313
|
-
|
|
1314
|
-
if (
|
|
1315
|
-
|
|
1316
|
-
|
|
1180
|
+
private async runScopeCheck(channel: FeishuChannel, appId: string, ownerLookup: Promise<void>): Promise<void> {
|
|
1181
|
+
this.scopeCheckState = undefined
|
|
1182
|
+
if (channel.grantedScopes === undefined) return
|
|
1183
|
+
await ownerLookup
|
|
1184
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1185
|
+
let state: ScopeCheckWire
|
|
1317
1186
|
try {
|
|
1318
|
-
const
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1187
|
+
const grants = await Promise.race([
|
|
1188
|
+
channel.grantedScopes(),
|
|
1189
|
+
new Promise<never>((_, reject) => {
|
|
1190
|
+
timer = setTimeout(() => reject(new Error(`超时(${SCOPE_CHECK_TIMEOUT_MS} ms)`)), SCOPE_CHECK_TIMEOUT_MS)
|
|
1191
|
+
}),
|
|
1192
|
+
])
|
|
1193
|
+
const result = checkScopes(grants, appId, {
|
|
1194
|
+
dmEnabled: this.config.dmMode === 'open',
|
|
1195
|
+
requireMention: this.config.requireMention,
|
|
1196
|
+
ownerLookupFailed: this.config.accessMode === 'guarded'
|
|
1197
|
+
&& this.configuredAdminUserId() === undefined
|
|
1198
|
+
&& this.appOwner !== undefined && 'error' in this.appOwner && this.appOwner.apiRejected,
|
|
1199
|
+
slashCommandPanel: this.config.slashCommandPanel,
|
|
1200
|
+
sendFile: this.config.feishuTools.sendFile,
|
|
1201
|
+
wikiRead: this.config.feishuTools.wikiRead && this.config.wikiRoots.length > 0,
|
|
1202
|
+
wikiWrite: this.config.feishuTools.wikiWrite && this.config.wikiRoots.length > 0,
|
|
1203
|
+
})
|
|
1204
|
+
state = result.consoleUrl === undefined
|
|
1205
|
+
? { state: 'ok', checkedAt: Date.now() }
|
|
1206
|
+
: {
|
|
1207
|
+
state: 'missing',
|
|
1208
|
+
checkedAt: Date.now(),
|
|
1209
|
+
missing: result.missing.map(item => ({
|
|
1210
|
+
scopes: [...item.anyOf], level: item.level, purpose: item.purpose, consoleUrl: item.consoleUrl,
|
|
1211
|
+
})),
|
|
1212
|
+
consoleUrl: result.consoleUrl,
|
|
1213
|
+
}
|
|
1322
1214
|
} catch (error) {
|
|
1323
|
-
|
|
1324
|
-
|
|
1215
|
+
const detail = error instanceof FeishuApiError ? error.detail : messageOf(error)
|
|
1216
|
+
state = { state: 'error', checkedAt: Date.now(), error: detail }
|
|
1217
|
+
} finally {
|
|
1218
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
1219
|
+
}
|
|
1220
|
+
// A background check can outlive its connection (shutdown/reconnect).
|
|
1221
|
+
if (this.channel !== channel) return
|
|
1222
|
+
this.scopeCheckState = state
|
|
1223
|
+
if (state.state === 'ok') {
|
|
1224
|
+
this.ctx.logger?.info?.('dsh-channels: scope self-check passed')
|
|
1225
|
+
} else if (state.state === 'missing') {
|
|
1226
|
+
const lines = state.missing.map(item => `${item.level === 'required' ? '必需' : '可选'} ${item.scopes.join(' 或 ')}:${item.purpose}`)
|
|
1227
|
+
this.ctx.logger?.warn?.('dsh-channels: 飞书权限缺失 %d 项,开通后需发布新版本(%s):\n %s', state.missing.length, state.consoleUrl, lines.join('\n '))
|
|
1228
|
+
} else {
|
|
1229
|
+
this.ctx.logger?.warn?.('dsh-channels: 飞书权限自检失败:%s', state.error)
|
|
1325
1230
|
}
|
|
1326
|
-
await rm(resolved, { recursive: true, force: true })
|
|
1327
1231
|
}
|
|
1328
1232
|
|
|
1329
|
-
/**
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
void this.runWorkspaceCleanup().then(result => {
|
|
1344
|
-
if (!result.ok && result.error !== '目录清理正在进行中,请稍候') {
|
|
1345
|
-
this.ctx.logger?.warn?.('dsh-channels: automatic workspace cleanup failed: %s', result.error)
|
|
1346
|
-
}
|
|
1347
|
-
})
|
|
1348
|
-
}
|
|
1349
|
-
if (runNow) sweep()
|
|
1350
|
-
const timer = setInterval(sweep, RETENTION_SWEEP_INTERVAL_MS)
|
|
1351
|
-
timer.unref?.()
|
|
1352
|
-
this.retentionTimer = timer
|
|
1233
|
+
/** Trim + bound one panel-supplied identifier; rejects control characters. */
|
|
1234
|
+
private cleanAccessTargetId(raw: string): string | undefined {
|
|
1235
|
+
const value = raw.trim()
|
|
1236
|
+
if (value === '' || value.length > 128) return undefined
|
|
1237
|
+
// eslint-disable-next-line no-control-regex
|
|
1238
|
+
if (/[\u0000-\u001f\u007f]/u.test(value)) return undefined
|
|
1239
|
+
return value
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/** Same normalization for user ids, which must be Feishu `user_id`s. */
|
|
1243
|
+
private cleanAccessUserId(raw: string): string | undefined {
|
|
1244
|
+
const value = this.cleanAccessTargetId(raw)
|
|
1245
|
+
if (value === undefined || !FEISHU_USER_ID_PATTERN.test(value)) return undefined
|
|
1246
|
+
return value
|
|
1353
1247
|
}
|
|
1354
1248
|
|
|
1355
1249
|
/** Aggregate health used by `/health` and the Web settings panel. */
|
|
@@ -1481,6 +1375,9 @@ export class FeishuService {
|
|
|
1481
1375
|
onError: (sessionId, error) => {
|
|
1482
1376
|
this.ctx.logger?.warn?.('dsh-channels approval error (session %s): %s', sessionId, String(error))
|
|
1483
1377
|
},
|
|
1378
|
+
onWaitingChange: (sessionId, toolNames) => {
|
|
1379
|
+
this.streamer?.setAwaitingApproval(sessionId, toolNames)
|
|
1380
|
+
},
|
|
1484
1381
|
}, this.config.approvalTimeoutMs)
|
|
1485
1382
|
this.approvalAnswerer = answerer
|
|
1486
1383
|
this.unlistenApproval = this.ctx.on('approval/request', (req, next) => answerer.onRequest(req, next), { prepend: true })
|
|
@@ -1500,7 +1397,7 @@ export class FeishuService {
|
|
|
1500
1397
|
{ prepend: true },
|
|
1501
1398
|
)
|
|
1502
1399
|
const accessRequests = new AccessRequests({
|
|
1503
|
-
config: () => this.
|
|
1400
|
+
config: () => this.accessConfig(),
|
|
1504
1401
|
store: this.accessStore,
|
|
1505
1402
|
channel: () => this.channel,
|
|
1506
1403
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
@@ -1518,10 +1415,16 @@ export class FeishuService {
|
|
|
1518
1415
|
void accessRequests.onCardAction(event)
|
|
1519
1416
|
answerer.onCardAction(event)
|
|
1520
1417
|
questions.onCardAction(event)
|
|
1418
|
+
this.wikiConsent.onCardAction(event)
|
|
1419
|
+
this.onStopAction(event)
|
|
1420
|
+
void this.onPickAction(event).catch((error: unknown) => {
|
|
1421
|
+
this.ctx.logger?.warn?.('dsh-channels: picker card action failed: %s', String(error))
|
|
1422
|
+
})
|
|
1521
1423
|
})
|
|
1522
1424
|
|
|
1523
1425
|
this.unlistenHost.push(this.ctx.on('session/event', (session, event) => {
|
|
1524
1426
|
this.streamer?.onSessionEvent(session, event)
|
|
1427
|
+
this.approvalAnswerer?.onSessionEvent(session.id, event)
|
|
1525
1428
|
}))
|
|
1526
1429
|
// DSH removed the durable `assistant/chunk` event (format v2 embeds the
|
|
1527
1430
|
// stream in the settlement); the live deltas arrive here instead.
|
|
@@ -1537,7 +1440,15 @@ export class FeishuService {
|
|
|
1537
1440
|
.catch(() => {})
|
|
1538
1441
|
})
|
|
1539
1442
|
})
|
|
1443
|
+
// Without a configured administrator, resolve the owner before the
|
|
1444
|
+
// handshake so the first inbound message already sees the fallback; with
|
|
1445
|
+
// one, the lookup only backs a later runtime clear and must not delay boot.
|
|
1446
|
+
const ownerLookup = this.resolveAppOwner(channel)
|
|
1447
|
+
if (this.configuredAdminUserId() === undefined) await ownerLookup
|
|
1540
1448
|
await channel.connect()
|
|
1449
|
+
// Background: the results only feed the panel and the log.
|
|
1450
|
+
void this.runScopeCheck(channel, appId, ownerLookup)
|
|
1451
|
+
if (this.config.slashCommandPanel) void this.runSlashCommandSync(channel)
|
|
1541
1452
|
// Repair cards a previous instance left mid-flight (best-effort, background).
|
|
1542
1453
|
void this.repair.repairAll().catch((error: unknown) => {
|
|
1543
1454
|
this.ctx.logger?.warn?.('dsh-channels: inflight repair failed: %s', String(error))
|
|
@@ -1554,8 +1465,6 @@ export class FeishuService {
|
|
|
1554
1465
|
}, GC_SWEEP_INTERVAL_MS)
|
|
1555
1466
|
timer.unref?.()
|
|
1556
1467
|
this.gcTimer = timer
|
|
1557
|
-
// Workspace retention: opt-in automatic cleanup (default off).
|
|
1558
|
-
this.refreshRetentionTimer(true)
|
|
1559
1468
|
this.ctx.logger?.info?.(
|
|
1560
1469
|
'dsh-channels: feishu long connection established (bot open_id %s, %d route(s) loaded)',
|
|
1561
1470
|
channel.botOpenId() ?? 'unknown',
|
|
@@ -1571,11 +1480,15 @@ export class FeishuService {
|
|
|
1571
1480
|
})
|
|
1572
1481
|
if (this.config.accessMode === 'open') {
|
|
1573
1482
|
console.warn('[dsh-channels] 准入=open:任何应用可用范围内的用户都可以对话(不安全,仅调试)')
|
|
1574
|
-
} else if (this.
|
|
1483
|
+
} else if (this.effectiveAdminUserId() === undefined) {
|
|
1484
|
+
const ownerError = this.appOwner !== undefined && 'error' in this.appOwner ? this.appOwner.error : undefined
|
|
1575
1485
|
console.warn(
|
|
1576
|
-
'[dsh-channels] 准入=guarded 但未配置管理员
|
|
1577
|
-
+ '私聊机器人发送 /userid 取得
|
|
1486
|
+
'[dsh-channels] 准入=guarded 但未配置管理员 user_id,也无法使用应用所有者兜底:所有对话都会被拒绝;'
|
|
1487
|
+
+ '私聊机器人发送 /userid 取得 user_id 后填入 Web 设置面板'
|
|
1488
|
+
+ (ownerError === undefined ? '' : `(${ownerError})`),
|
|
1578
1489
|
)
|
|
1490
|
+
} else if (this.configuredAdminUserId() === undefined) {
|
|
1491
|
+
console.info('[dsh-channels] 准入=guarded,未配置管理员,使用应用所有者', this.effectiveAdminUserId())
|
|
1579
1492
|
} else {
|
|
1580
1493
|
console.info('[dsh-channels] 准入=guarded,管理员已配置')
|
|
1581
1494
|
}
|
|
@@ -1624,15 +1537,12 @@ export class FeishuService {
|
|
|
1624
1537
|
this.approvalAnswerer?.dispose()
|
|
1625
1538
|
this.approvalAnswerer = undefined
|
|
1626
1539
|
this.questionAnswerer?.dispose()
|
|
1540
|
+
this.wikiConsent.dispose()
|
|
1627
1541
|
this.questionAnswerer = undefined
|
|
1628
1542
|
if (this.gcTimer !== undefined) {
|
|
1629
1543
|
clearInterval(this.gcTimer)
|
|
1630
1544
|
this.gcTimer = undefined
|
|
1631
1545
|
}
|
|
1632
|
-
if (this.retentionTimer !== undefined) {
|
|
1633
|
-
clearInterval(this.retentionTimer)
|
|
1634
|
-
this.retentionTimer = undefined
|
|
1635
|
-
}
|
|
1636
1546
|
await this.sessions.disposeAll()
|
|
1637
1547
|
this.attachmentFetchTools.clear()
|
|
1638
1548
|
this.routeOperations.clear()
|
|
@@ -1645,10 +1555,16 @@ export class FeishuService {
|
|
|
1645
1555
|
// Already disconnected is fine.
|
|
1646
1556
|
}
|
|
1647
1557
|
this.channel = undefined
|
|
1558
|
+
this.streamer?.detachAll()
|
|
1648
1559
|
this.streamer = undefined
|
|
1649
1560
|
await this.domain?.close()
|
|
1650
1561
|
this.domain = undefined
|
|
1651
1562
|
this.runningConfig = undefined
|
|
1563
|
+
this.appOwner = undefined
|
|
1564
|
+
this.scopeCheckState = undefined
|
|
1565
|
+
this.slashCommandSyncState = undefined
|
|
1566
|
+
this.slashCommandSyncAbort?.abort()
|
|
1567
|
+
this.slashCommandSyncAbort = undefined
|
|
1652
1568
|
}
|
|
1653
1569
|
|
|
1654
1570
|
// ---- inbound routing -------------------------------------------------
|
|
@@ -1722,7 +1638,7 @@ export class FeishuService {
|
|
|
1722
1638
|
return
|
|
1723
1639
|
}
|
|
1724
1640
|
|
|
1725
|
-
if (msg.senderId !== route.
|
|
1641
|
+
if (msg.senderId !== route.senderUserId) {
|
|
1726
1642
|
await this.delivery.commandCard(msg, {
|
|
1727
1643
|
kind: 'forbidden', command: parsed.name, text: '无权操作此会话。',
|
|
1728
1644
|
})
|
|
@@ -1732,7 +1648,7 @@ export class FeishuService {
|
|
|
1732
1648
|
const operation = this.routeOperations.run(route.key, async () => {
|
|
1733
1649
|
if (!this.started) return
|
|
1734
1650
|
const current = this.routes.get(route.key) ?? route
|
|
1735
|
-
if (msg.senderId !== current.
|
|
1651
|
+
if (msg.senderId !== current.senderUserId) {
|
|
1736
1652
|
await this.delivery.commandCard(msg, {
|
|
1737
1653
|
kind: 'forbidden', command: parsed.name, text: '无权操作此会话。',
|
|
1738
1654
|
})
|
|
@@ -1765,12 +1681,12 @@ export class FeishuService {
|
|
|
1765
1681
|
const pending = this.accessStore.pendingRequests()
|
|
1766
1682
|
await this.delivery.standaloneReply(msg, standaloneInfoCard('**🔐 访问授权状态**', [
|
|
1767
1683
|
`**准入模式**:\`${this.config.accessMode}\``,
|
|
1768
|
-
`**管理员**:${this.
|
|
1684
|
+
`**管理员**:${this.adminStatusLine()}`,
|
|
1769
1685
|
...(msg.chatType === 'group'
|
|
1770
1686
|
? [`**本群**:${this.accessStore.hasGroup(msg.chatId) ? '已授权' : '未授权'}(\`${msg.chatId}\`)`]
|
|
1771
1687
|
: []),
|
|
1772
|
-
`**白名单**:群 ${groups.length} 个 · 用户 ${users.length} 个 · 待处理申请 ${pending.length} 个`,
|
|
1773
|
-
'群授权:`/auth allow` 或 `/auth revoke`(在目标群内发);用户授权:`/auth user add <
|
|
1688
|
+
`**白名单**:群 ${groups.length} 个 · 用户 ${users.length} 个 · 知识库 ${this.accessStore.wikiUserRecords().length} 个 · 待处理申请 ${pending.length} 个`,
|
|
1689
|
+
'群授权:`/auth allow` 或 `/auth revoke`(在目标群内发);用户授权:`/auth user add @用户` 或 `/auth user add <user_id>`;知识库授权:`/auth wiki add @用户`。',
|
|
1774
1690
|
]), direct)
|
|
1775
1691
|
return
|
|
1776
1692
|
}
|
|
@@ -1817,35 +1733,78 @@ export class FeishuService {
|
|
|
1817
1733
|
}
|
|
1818
1734
|
if (action === 'user') {
|
|
1819
1735
|
const sub = words[1]
|
|
1820
|
-
const
|
|
1821
|
-
|
|
1822
|
-
|
|
1736
|
+
const target = sub === 'add' || sub === 'remove'
|
|
1737
|
+
? resolveAuthUserTarget(words.slice(2).join(' '), msg.mentions, this.channel?.botOpenId())
|
|
1738
|
+
: { ok: false as const, reason: 'usage' as const }
|
|
1739
|
+
if (!target.ok) {
|
|
1823
1740
|
await this.delivery.commandCard(msg, {
|
|
1824
|
-
kind: 'input-error', command: 'auth',
|
|
1825
|
-
text: '用法:/auth user add <open_id> 或 /auth user remove <open_id>(open_id 以 ou_ 开头,可用 /userid 查看)。',
|
|
1741
|
+
kind: 'input-error', command: 'auth', text: AUTH_USER_TARGET_ERRORS[target.reason],
|
|
1826
1742
|
}, direct)
|
|
1827
1743
|
return
|
|
1828
1744
|
}
|
|
1745
|
+
const { userId } = target
|
|
1746
|
+
const label = target.name === undefined ? `\`${userId}\`` : `${target.name}(\`${userId}\`)`
|
|
1829
1747
|
if (sub === 'add') {
|
|
1830
|
-
if (this.accessStore.hasUser(
|
|
1748
|
+
if (this.accessStore.hasUser(userId)) {
|
|
1831
1749
|
await this.delivery.commandCard(msg, {
|
|
1832
|
-
kind: 'success', command: 'auth', text:
|
|
1750
|
+
kind: 'success', command: 'auth', text: `该用户已在白名单中:${label}。`,
|
|
1833
1751
|
}, direct)
|
|
1834
1752
|
return
|
|
1835
1753
|
}
|
|
1836
1754
|
await this.accessStore.addUser({
|
|
1837
|
-
|
|
1755
|
+
userId,
|
|
1756
|
+
...(target.name === undefined ? {} : { name: target.name }),
|
|
1757
|
+
source: 'command', addedBy: msg.senderId, addedAt: now,
|
|
1838
1758
|
})
|
|
1839
1759
|
await this.delivery.commandCard(msg, {
|
|
1840
1760
|
kind: 'success', command: 'auth',
|
|
1841
|
-
text: `✅ 已授权用户
|
|
1761
|
+
text: `✅ 已授权用户 ${label}(在所有已授权群里生效)。`,
|
|
1842
1762
|
}, direct)
|
|
1843
1763
|
return
|
|
1844
1764
|
}
|
|
1845
|
-
const removed = await this.accessStore.removeUser(
|
|
1765
|
+
const removed = await this.accessStore.removeUser(userId)
|
|
1846
1766
|
await this.delivery.commandCard(msg, {
|
|
1847
1767
|
kind: removed ? 'success' : 'unavailable', command: 'auth',
|
|
1848
|
-
text: removed ? `已移除用户
|
|
1768
|
+
text: removed ? `已移除用户 ${label},立即生效。` : `用户 ${label} 不在白名单中。`,
|
|
1769
|
+
}, direct)
|
|
1770
|
+
return
|
|
1771
|
+
}
|
|
1772
|
+
if (action === 'wiki') {
|
|
1773
|
+
const sub = words[1]
|
|
1774
|
+
const target = sub === 'add' || sub === 'remove'
|
|
1775
|
+
? resolveAuthUserTarget(words.slice(2).join(' '), msg.mentions, this.channel?.botOpenId())
|
|
1776
|
+
: { ok: false as const, reason: 'usage' as const }
|
|
1777
|
+
if (!target.ok) {
|
|
1778
|
+
await this.delivery.commandCard(msg, {
|
|
1779
|
+
kind: 'input-error', command: 'auth',
|
|
1780
|
+
text: target.reason === 'usage' ? AUTH_WIKI_USAGE : AUTH_USER_TARGET_ERRORS[target.reason],
|
|
1781
|
+
}, direct)
|
|
1782
|
+
return
|
|
1783
|
+
}
|
|
1784
|
+
const { userId } = target
|
|
1785
|
+
const label = target.name === undefined ? `\`${userId}\`` : `${target.name}(\`${userId}\`)`
|
|
1786
|
+
if (sub === 'add') {
|
|
1787
|
+
if (this.accessStore.hasWikiUser(userId)) {
|
|
1788
|
+
await this.delivery.commandCard(msg, {
|
|
1789
|
+
kind: 'success', command: 'auth', text: `该用户已在知识库白名单中:${label}。`,
|
|
1790
|
+
}, direct)
|
|
1791
|
+
return
|
|
1792
|
+
}
|
|
1793
|
+
await this.accessStore.addWikiUser({
|
|
1794
|
+
userId,
|
|
1795
|
+
...(target.name === undefined ? {} : { name: target.name }),
|
|
1796
|
+
source: 'command', addedBy: msg.senderId, addedAt: now,
|
|
1797
|
+
})
|
|
1798
|
+
await this.delivery.commandCard(msg, {
|
|
1799
|
+
kind: 'success', command: 'auth',
|
|
1800
|
+
text: `✅ 已把 ${label} 加入知识库白名单:他发起的话题里可以使用知识库工具(每次读取、新建、修改仍需他在授权卡上确认)。`,
|
|
1801
|
+
}, direct)
|
|
1802
|
+
return
|
|
1803
|
+
}
|
|
1804
|
+
const removed = await this.accessStore.removeWikiUser(userId)
|
|
1805
|
+
await this.delivery.commandCard(msg, {
|
|
1806
|
+
kind: removed ? 'success' : 'unavailable', command: 'auth',
|
|
1807
|
+
text: removed ? `已把 ${label} 移出知识库白名单,立即生效。` : `用户 ${label} 不在知识库白名单中。`,
|
|
1849
1808
|
}, direct)
|
|
1850
1809
|
return
|
|
1851
1810
|
}
|
|
@@ -1858,19 +1817,25 @@ export class FeishuService {
|
|
|
1858
1817
|
: groups.slice(0, 30).map(group => `- ${group.name ?? group.chatId} \`${group.chatId}\``)
|
|
1859
1818
|
const userLines = users.length === 0
|
|
1860
1819
|
? ['(暂无)']
|
|
1861
|
-
: users.slice(0, 30).map(user => `- ${user.name ?? user.
|
|
1820
|
+
: users.slice(0, 30).map(user => `- ${user.name ?? user.userId} \`${user.userId}\``)
|
|
1821
|
+
const wikiUsers = this.accessStore.wikiUserRecords()
|
|
1822
|
+
const wikiLines = wikiUsers.length === 0
|
|
1823
|
+
? ['(暂无;管理员始终可用)']
|
|
1824
|
+
: wikiUsers.slice(0, 30).map(user => `- ${user.name ?? user.userId} \`${user.userId}\``)
|
|
1862
1825
|
await this.delivery.standaloneReply(msg, standaloneInfoCard('**🔐 访问授权清单**', [
|
|
1863
1826
|
`**已授权群(${groups.length})**`,
|
|
1864
1827
|
...groupLines,
|
|
1865
1828
|
`**已授权用户(${users.length})**`,
|
|
1866
1829
|
...userLines,
|
|
1830
|
+
`**知识库白名单(${wikiUsers.length})**`,
|
|
1831
|
+
...wikiLines,
|
|
1867
1832
|
`**待处理申请**:${pending.length} 个(可在 Web 设置面板处理)`,
|
|
1868
1833
|
]), direct)
|
|
1869
1834
|
return
|
|
1870
1835
|
}
|
|
1871
1836
|
await this.delivery.commandCard(msg, {
|
|
1872
1837
|
kind: 'input-error', command: 'auth',
|
|
1873
|
-
text: '用法:/auth [status|allow|revoke|list|user add
|
|
1838
|
+
text: '用法:/auth [status|allow|revoke|list|user add <@用户|user_id>|user remove <@用户|user_id>|wiki add <@用户|user_id>|wiki remove <@用户|user_id>]',
|
|
1874
1839
|
}, direct)
|
|
1875
1840
|
} catch (error) {
|
|
1876
1841
|
this.ctx.logger?.warn?.('dsh-channels: /auth failed: %s', String(error))
|
|
@@ -1979,10 +1944,7 @@ export class FeishuService {
|
|
|
1979
1944
|
return
|
|
1980
1945
|
}
|
|
1981
1946
|
if (spec.name === 'stop') {
|
|
1982
|
-
const
|
|
1983
|
-
?? this.ctx.agents.get(current.sessionId as SessionId)
|
|
1984
|
-
const running = agent?.status === 'running'
|
|
1985
|
-
agent?.cancel?.({ kind: 'user' })
|
|
1947
|
+
const running = this.cancelTurn(current.sessionId)
|
|
1986
1948
|
await this.delivery.commandCard(msg, {
|
|
1987
1949
|
kind: 'success', command: 'stop', sessionId: current.sessionId,
|
|
1988
1950
|
text: running ? '已请求停止当前回合。' : '当前没有正在运行的回合。',
|
|
@@ -1995,6 +1957,73 @@ export class FeishuService {
|
|
|
1995
1957
|
})
|
|
1996
1958
|
}
|
|
1997
1959
|
|
|
1960
|
+
/**
|
|
1961
|
+
* Cancel one Session's live turn without resuming anything. Queued
|
|
1962
|
+
* messages stay in the inbox: each already has its own streaming card and
|
|
1963
|
+
* runs next.
|
|
1964
|
+
* @param sessionId The Session to cancel.
|
|
1965
|
+
* @returns Whether a turn was running.
|
|
1966
|
+
*/
|
|
1967
|
+
private cancelTurn(sessionId: string): boolean {
|
|
1968
|
+
const agent = this.sessions.get(sessionId)?.agent
|
|
1969
|
+
?? this.ctx.agents.get(sessionId as SessionId)
|
|
1970
|
+
const running = agent?.status === 'running'
|
|
1971
|
+
agent?.cancel?.({ kind: 'user' }, { keepInbox: true })
|
|
1972
|
+
if (running) this.streamer?.markStopping(sessionId)
|
|
1973
|
+
return running
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
/**
|
|
1977
|
+
* Streaming-card stop button. Only the session owner, clicking inside the
|
|
1978
|
+
* session's own chat, cancels the turn; another operator gets the
|
|
1979
|
+
* owner-only notice on the card instead.
|
|
1980
|
+
*/
|
|
1981
|
+
private onStopAction(event: CardActionEvent): void {
|
|
1982
|
+
if (!this.started) return
|
|
1983
|
+
if ((event.action.value as { decision?: unknown } | undefined)?.decision !== STOP_ACTION) return
|
|
1984
|
+
const sessionId = this.streamer?.stopTargetOf(event.messageId)
|
|
1985
|
+
if (sessionId === undefined) return
|
|
1986
|
+
const route = this.routes.findBySession(sessionId)
|
|
1987
|
+
if (route === undefined) return
|
|
1988
|
+
if (event.operator.userId !== route.senderUserId || event.chatId !== route.chatId) {
|
|
1989
|
+
this.streamer?.markStopDenied(sessionId)
|
|
1990
|
+
return
|
|
1991
|
+
}
|
|
1992
|
+
this.cancelTurn(sessionId)
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
/**
|
|
1996
|
+
* Picker-card click: run the chosen `/<command> <arg>` exactly as if the
|
|
1997
|
+
* operator had typed it under the picker card, so access admission, route
|
|
1998
|
+
* ownership and validation are the typed command's. The result replaces
|
|
1999
|
+
* the picker card in place instead of arriving as a new card. Stale pickers (the
|
|
2000
|
+
* topic was rebound, or the card was forwarded elsewhere) and non-owner
|
|
2001
|
+
* clicks are ignored.
|
|
2002
|
+
*/
|
|
2003
|
+
private async onPickAction(event: CardActionEvent): Promise<void> {
|
|
2004
|
+
if (!this.started) return
|
|
2005
|
+
const action = parsePickAction(event.action.value)
|
|
2006
|
+
if (action === undefined) return
|
|
2007
|
+
const route = this.routes.findBySession(action.sessionId)
|
|
2008
|
+
const userId = event.operator.userId
|
|
2009
|
+
if (route === undefined || route.chatId !== event.chatId || userId !== route.senderUserId) return
|
|
2010
|
+
const line = `/${action.command} ${action.arg}`
|
|
2011
|
+
await this.delivery.replacingCard(event.messageId, () => this.handleSlashMessage({
|
|
2012
|
+
messageId: event.messageId,
|
|
2013
|
+
chatId: route.chatId,
|
|
2014
|
+
chatType: route.chatType,
|
|
2015
|
+
senderId: userId,
|
|
2016
|
+
content: line,
|
|
2017
|
+
rawContentType: 'text',
|
|
2018
|
+
resources: [],
|
|
2019
|
+
mentions: [],
|
|
2020
|
+
mentionAll: false,
|
|
2021
|
+
mentionedBot: true,
|
|
2022
|
+
...(route.kind === 'thread' ? { threadId: route.key } : {}),
|
|
2023
|
+
createTime: Date.now(),
|
|
2024
|
+
}, line))
|
|
2025
|
+
}
|
|
2026
|
+
|
|
1998
2027
|
/**
|
|
1999
2028
|
* `/permissions`: show this Session's effective sandbox+approval preset and
|
|
2000
2029
|
* the selectable catalog; switch only when the deployment opted in.
|
|
@@ -2015,14 +2044,27 @@ export class FeishuService {
|
|
|
2015
2044
|
const live = await this.sessions.ensureLive(current)
|
|
2016
2045
|
let active: string | undefined
|
|
2017
2046
|
let catalogLine = ''
|
|
2047
|
+
let choices: PickerOption[] = []
|
|
2018
2048
|
try {
|
|
2019
2049
|
active = presets.current(live.agent.session)
|
|
2020
2050
|
const catalog = presets.catalog()
|
|
2021
2051
|
catalogLine = catalog.options.map(option => `\`${option.value}\``).join('、')
|
|
2052
|
+
choices = catalog.options.map(option => ({ label: option.name || option.value, arg: option.value }))
|
|
2022
2053
|
} catch {
|
|
2023
2054
|
active = undefined
|
|
2024
2055
|
}
|
|
2025
2056
|
const requested = parsed.rawInput.trim()
|
|
2057
|
+
if (requested === '' && this.config.allowPermissionSwitch && choices.length > 0) {
|
|
2058
|
+
await this.delivery.standaloneReply(msg, buildPickerCard({
|
|
2059
|
+
title: '🔐 权限预设',
|
|
2060
|
+
command: 'permissions',
|
|
2061
|
+
text: `**当前权限预设**:${active === undefined ? '暂无数据' : `\`${active}\``}\n切换会同时调整沙箱与审批策略。`,
|
|
2062
|
+
sessionId: current.sessionId,
|
|
2063
|
+
options: choices,
|
|
2064
|
+
...(active === undefined ? {} : { current: active }),
|
|
2065
|
+
}))
|
|
2066
|
+
return
|
|
2067
|
+
}
|
|
2026
2068
|
if (requested === '') {
|
|
2027
2069
|
const lines = [
|
|
2028
2070
|
`**当前权限预设**:${active === undefined ? '暂无数据' : `\`${active}\``}`,
|
|
@@ -2149,6 +2191,7 @@ export class FeishuService {
|
|
|
2149
2191
|
}
|
|
2150
2192
|
await this.renderCommandResult({
|
|
2151
2193
|
spec, msg, current, live,
|
|
2194
|
+
bare: parsed.rawInput.trim() === '',
|
|
2152
2195
|
result: execution.result,
|
|
2153
2196
|
compactBefore,
|
|
2154
2197
|
renewAdmission,
|
|
@@ -2163,6 +2206,8 @@ export class FeishuService {
|
|
|
2163
2206
|
msg: NormalizedMessage
|
|
2164
2207
|
current: RouteRecord
|
|
2165
2208
|
live: LiveAgent
|
|
2209
|
+
/** The command was typed without arguments. */
|
|
2210
|
+
bare: boolean
|
|
2166
2211
|
result: CommandResult
|
|
2167
2212
|
compactBefore: ContextMeasurement | undefined
|
|
2168
2213
|
renewAdmission: RenewAdmission | undefined
|
|
@@ -2172,6 +2217,17 @@ export class FeishuService {
|
|
|
2172
2217
|
const { spec, msg, current, live, result, compactBefore, renewAdmission, effortAdmission, modelAdmission } = input
|
|
2173
2218
|
if (spec.name === 'effort') effortAdmission?.controller.sync(live.agent.session.snapshotEvents())
|
|
2174
2219
|
if (spec.name === 'model') modelAdmission?.controller.sync(live.agent.session.snapshotEvents())
|
|
2220
|
+
if (input.bare && result.kind === 'success') {
|
|
2221
|
+
const picker = spec.name === 'model' && modelAdmission !== undefined
|
|
2222
|
+
? await this.modelPicker(current, live, modelAdmission)
|
|
2223
|
+
: spec.name === 'effort' && effortAdmission !== undefined
|
|
2224
|
+
? await this.effortPicker(current, live, effortAdmission, result.text ?? '')
|
|
2225
|
+
: undefined
|
|
2226
|
+
if (picker !== undefined) {
|
|
2227
|
+
await this.delivery.standaloneReply(msg, picker)
|
|
2228
|
+
return
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2175
2231
|
if (spec.name === 'renew' && result.kind === 'success' && renewAdmission?.commit !== undefined) {
|
|
2176
2232
|
await this.delivery.standaloneReply(msg, buildRenewCard({
|
|
2177
2233
|
previousSessionId: renewAdmission.commit.previous.sessionId,
|
|
@@ -2181,7 +2237,7 @@ export class FeishuService {
|
|
|
2181
2237
|
return
|
|
2182
2238
|
}
|
|
2183
2239
|
if (spec.name === 'health' && result.kind === 'success') {
|
|
2184
|
-
await this.delivery.standaloneReply(msg, buildHealthCard(this.healthSnapshot()))
|
|
2240
|
+
await this.delivery.standaloneReply(msg, buildHealthCard(this.healthSnapshot()), { createThread: false })
|
|
2185
2241
|
return
|
|
2186
2242
|
}
|
|
2187
2243
|
if (spec.name === 'status' && result.kind === 'success') {
|
|
@@ -2395,18 +2451,13 @@ export class FeishuService {
|
|
|
2395
2451
|
|
|
2396
2452
|
|
|
2397
2453
|
/**
|
|
2398
|
-
* GC pass over the routing domain.
|
|
2454
|
+
* GC pass over the routing domain. Two independent parts:
|
|
2399
2455
|
*
|
|
2400
2456
|
* 1. Dedup records (unconditional): `processed` records and in-process
|
|
2401
2457
|
* guard entries older than `PROCESSED_RETENTION_MS` are dropped —
|
|
2402
2458
|
* redelivery windows are minutes, so a day of retention is ample and
|
|
2403
2459
|
* keeps the table bounded regardless of the route TTL setting.
|
|
2404
|
-
* 2.
|
|
2405
|
-
* `pendingAttachmentTtlMs` and rows above `pendingAttachmentLimit` per
|
|
2406
|
-
* `(chat, thread?, sender)` scope are removed. Independent of the route
|
|
2407
|
-
* TTL: the registry keeps its own 7-day default even when route retirement
|
|
2408
|
-
* is disabled.
|
|
2409
|
-
* 3. Route/inflight GC (`routeTtlMs > 0`): thread routes idle past the TTL
|
|
2460
|
+
* 2. Route/inflight GC (`routeTtlMs > 0`): thread routes idle past the TTL
|
|
2410
2461
|
* are retired into minimal persistent tombstones before their active
|
|
2411
2462
|
* records are deleted. A later message in that visible topic is then
|
|
2412
2463
|
* rejected instead of silently creating a context-free Session. Degraded
|
|
@@ -2439,18 +2490,6 @@ export class FeishuService {
|
|
|
2439
2490
|
}
|
|
2440
2491
|
this.inbound.pruneAccepted(processedCutoff)
|
|
2441
2492
|
|
|
2442
|
-
try {
|
|
2443
|
-
const pruned = await this.pendingAttachments.prune()
|
|
2444
|
-
if (pruned.removedExpired > 0 || pruned.removedOverLimit > 0) {
|
|
2445
|
-
this.ctx.logger?.info?.(
|
|
2446
|
-
'dsh-channels: record GC removed %d expired and %d over-limit pending attachment record(s)',
|
|
2447
|
-
pruned.removedExpired, pruned.removedOverLimit,
|
|
2448
|
-
)
|
|
2449
|
-
}
|
|
2450
|
-
} catch (error) {
|
|
2451
|
-
this.ctx.logger?.warn?.('dsh-channels: pending attachment GC failed: %s', String(error))
|
|
2452
|
-
}
|
|
2453
|
-
|
|
2454
2493
|
const ttlMs = this.config.routeTtlMs
|
|
2455
2494
|
if (ttlMs === 0) {
|
|
2456
2495
|
if (removedProcessed > 0) {
|
|
@@ -2526,7 +2565,7 @@ export class FeishuService {
|
|
|
2526
2565
|
chatId: record.chatId,
|
|
2527
2566
|
cardMessageId: record.cardMessageId,
|
|
2528
2567
|
inThread: record.kind === 'thread',
|
|
2529
|
-
|
|
2568
|
+
senderUserId: record.senderUserId,
|
|
2530
2569
|
}
|
|
2531
2570
|
}
|
|
2532
2571
|
|
|
@@ -2546,22 +2585,10 @@ export class FeishuService {
|
|
|
2546
2585
|
if (admission.live.agent !== invocation.agent) {
|
|
2547
2586
|
return { kind: 'error', text: '/model 只能通过已授权的飞书话题执行。' }
|
|
2548
2587
|
}
|
|
2549
|
-
const
|
|
2550
|
-
const state = admission.controller.state
|
|
2551
|
-
const header = session.requestHeader?.()?.config
|
|
2552
|
-
const currentProvider = state.effectiveModel?.provider
|
|
2553
|
-
?? (typeof header?.provider === 'string' && header.provider !== '' ? header.provider : undefined)
|
|
2554
|
-
const currentModel = state.effectiveModel?.model
|
|
2555
|
-
?? (typeof header?.model === 'string' && header.model !== '' ? header.model : undefined)
|
|
2556
|
-
const source = state.modelSource === 'session'
|
|
2557
|
-
? 'Session 覆盖'
|
|
2558
|
-
: state.modelSource === 'config' ? '配置默认' : '部署默认'
|
|
2588
|
+
const { provider: currentProvider, model: currentModel } = currentModelRoute(admission.live, admission.controller)
|
|
2559
2589
|
|
|
2560
2590
|
if (rawInput === '') {
|
|
2561
|
-
const lines = [
|
|
2562
|
-
`**当前模型**:${currentModel === undefined ? '尚无模型请求记录' : `\`${currentModel}\``}`
|
|
2563
|
-
+ `${currentProvider === undefined ? '' : `(provider \`${currentProvider}\`)`} · ${source}`,
|
|
2564
|
-
]
|
|
2591
|
+
const lines = [currentModelLine(admission.live, admission.controller)]
|
|
2565
2592
|
const models = await this.listModels(currentProvider)
|
|
2566
2593
|
if (models.length > 0) {
|
|
2567
2594
|
lines.push(`**可选模型(${models.length})**:${models.slice(0, 20).map(id => `\`${id}\``).join('、')}`
|
|
@@ -2598,6 +2625,59 @@ export class FeishuService {
|
|
|
2598
2625
|
return { kind: 'success', text: `当前 Session 模型已切换为 \`${label}\`(下一条消息生效)。` }
|
|
2599
2626
|
}
|
|
2600
2627
|
|
|
2628
|
+
/** `/model` picker: the provider's advertised models, or undefined when none are listed. */
|
|
2629
|
+
private async modelPicker(
|
|
2630
|
+
current: RouteRecord,
|
|
2631
|
+
live: LiveAgent,
|
|
2632
|
+
admission: ModelAdmission,
|
|
2633
|
+
): Promise<object | undefined> {
|
|
2634
|
+
const route = currentModelRoute(live, admission.controller)
|
|
2635
|
+
const models = await this.listModels(route.provider)
|
|
2636
|
+
if (models.length === 0) return undefined
|
|
2637
|
+
return buildPickerCard({
|
|
2638
|
+
title: '🧠 模型',
|
|
2639
|
+
command: 'model',
|
|
2640
|
+
text: `${currentModelLine(live, admission.controller)}\n切换在下一条消息生效。`,
|
|
2641
|
+
sessionId: current.sessionId,
|
|
2642
|
+
options: models.map(id => ({ label: id, arg: id })),
|
|
2643
|
+
...(route.model === undefined ? {} : { current: route.model }),
|
|
2644
|
+
reset: true,
|
|
2645
|
+
})
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
/**
|
|
2649
|
+
* `/effort` picker: the efforts the current model route advertises, or
|
|
2650
|
+
* undefined when the route is unknown or advertises none.
|
|
2651
|
+
*/
|
|
2652
|
+
private async effortPicker(
|
|
2653
|
+
current: RouteRecord,
|
|
2654
|
+
live: LiveAgent,
|
|
2655
|
+
admission: EffortAdmission,
|
|
2656
|
+
text: string,
|
|
2657
|
+
): Promise<object | undefined> {
|
|
2658
|
+
const route = currentModelRoute(live, admission.controller)
|
|
2659
|
+
if (route.provider === undefined || route.model === undefined) return undefined
|
|
2660
|
+
let reasoning: LlmModelInfo['reasoning']
|
|
2661
|
+
try {
|
|
2662
|
+
const llm = this.ctx.get?.('llm') as LlmService | undefined
|
|
2663
|
+
reasoning = (await llm?.resolveModelInfo(route.provider, route.model))?.reasoning
|
|
2664
|
+
} catch (error) {
|
|
2665
|
+
this.ctx.logger?.warn?.('dsh-channels: effort catalog lookup failed: %s', String(error))
|
|
2666
|
+
return undefined
|
|
2667
|
+
}
|
|
2668
|
+
if (reasoning === undefined || reasoning.efforts.length === 0) return undefined
|
|
2669
|
+
const active = admission.controller.state.effectiveEffort ?? reasoning.defaultEffort
|
|
2670
|
+
return buildPickerCard({
|
|
2671
|
+
title: '💭 推理强度',
|
|
2672
|
+
command: 'effort',
|
|
2673
|
+
text,
|
|
2674
|
+
sessionId: current.sessionId,
|
|
2675
|
+
options: reasoning.efforts.map(effort => ({ label: effort.name || effort.id, arg: effort.id })),
|
|
2676
|
+
...(active === undefined ? {} : { current: active }),
|
|
2677
|
+
reset: true,
|
|
2678
|
+
})
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2601
2681
|
/** Model ids advertised for one provider route (empty when unavailable). */
|
|
2602
2682
|
private async listModels(provider: string | undefined): Promise<string[]> {
|
|
2603
2683
|
if (provider === undefined) return []
|
|
@@ -2738,3 +2818,28 @@ export class FeishuService {
|
|
|
2738
2818
|
}
|
|
2739
2819
|
}
|
|
2740
2820
|
}
|
|
2821
|
+
|
|
2822
|
+
/** The model route the next request of a Session uses, as far as it is known. */
|
|
2823
|
+
function currentModelRoute(
|
|
2824
|
+
live: LiveAgent,
|
|
2825
|
+
controller: RequestPolicyController,
|
|
2826
|
+
): { provider: string | undefined; model: string | undefined } {
|
|
2827
|
+
const state = controller.state
|
|
2828
|
+
const header = live.agent.session.requestHeader?.()?.config
|
|
2829
|
+
return {
|
|
2830
|
+
provider: state.effectiveModel?.provider
|
|
2831
|
+
?? (typeof header?.provider === 'string' && header.provider !== '' ? header.provider : undefined),
|
|
2832
|
+
model: state.effectiveModel?.model
|
|
2833
|
+
?? (typeof header?.model === 'string' && header.model !== '' ? header.model : undefined),
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
/** `**当前模型**:…` line shared by the `/model` text answer and its picker. */
|
|
2838
|
+
function currentModelLine(live: LiveAgent, controller: RequestPolicyController): string {
|
|
2839
|
+
const { provider, model } = currentModelRoute(live, controller)
|
|
2840
|
+
const source = controller.state.modelSource === 'session'
|
|
2841
|
+
? 'Session 覆盖'
|
|
2842
|
+
: controller.state.modelSource === 'config' ? '配置默认' : '部署默认'
|
|
2843
|
+
return `**当前模型**:${model === undefined ? '尚无模型请求记录' : `\`${model}\``}`
|
|
2844
|
+
+ `${provider === undefined ? '' : `(provider \`${provider}\`)`} · ${source}`
|
|
2845
|
+
}
|