@modesto_ma/dsh-channels 0.4.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 +73 -55
- package/docs/access-control-design.md +7 -5
- package/docs/attachment-pipeline-v2.md +33 -41
- package/docs/feishu-onboarding.md +33 -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 +173 -287
- package/lib/client.js.map +1 -1
- package/lib/index.js +3001 -2627
- 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 +25 -56
- package/src/domain.ts +28 -125
- package/src/feishu/inbound-attachments.ts +1 -1
- package/src/feishu/manifest.ts +8 -31
- package/src/feishu/message-read.ts +19 -22
- 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 +72 -112
- package/src/feishu-channel.ts +270 -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 +463 -438
- package/src/session/manager.ts +1 -1
- package/src/session/routes.ts +2 -3
- package/src/settings/contract.ts +68 -85
- package/src/settings/routes.ts +3 -17
- package/src/settings/surface.ts +37 -88
- 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,8 @@ 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'
|
|
71
67
|
import type { KnownAttachmentMessageInput, KnownAttachmentSource } from './feishu/tools.ts'
|
|
72
68
|
import {
|
|
73
69
|
MessageReader, TOPIC_MESSAGES_MAX_LIMIT, buildHistoryContextBlock,
|
|
@@ -78,7 +74,8 @@ import type { MessageReadRoute, RawMessageItem } from './feishu/message-read.ts'
|
|
|
78
74
|
import type { AttachmentStore } from './host/attachments.ts'
|
|
79
75
|
import { InboundRouter } from './routing/inbound.ts'
|
|
80
76
|
import type { RequestPolicyController } from './session/request-policy.ts'
|
|
81
|
-
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'
|
|
82
79
|
import { buildHealthCard, foldHealthSnapshot, HEALTH_DEPENDENCY_NAMES } from './health.ts'
|
|
83
80
|
import type { HealthDependencyName, HealthSnapshot } from './health.ts'
|
|
84
81
|
import {
|
|
@@ -95,11 +92,12 @@ import { ACTIVITY_BUILD_MARKER, TurnStreamer } from './streamer.ts'
|
|
|
95
92
|
import type { ProjectedStats, StreamerHooks } from './streamer.ts'
|
|
96
93
|
import type { FeishuConfig } from './config.ts'
|
|
97
94
|
import type {
|
|
98
|
-
AccessMutationResponse, AccessRequestWire, AccessWire,
|
|
95
|
+
AccessMutationResponse, AccessRequestWire, AccessWire, ScopeCheckWire, SlashCommandSyncWire,
|
|
99
96
|
} from './settings/contract.ts'
|
|
97
|
+
import { checkScopes } from './feishu/scopes.ts'
|
|
98
|
+
import { desiredSlashCommands, syncSlashCommands } from './feishu/slash-commands.ts'
|
|
100
99
|
import { mergeOverlay } from './settings/overlay.ts'
|
|
101
100
|
import type { OverlayPatch, OverlayStore } from './settings/overlay.ts'
|
|
102
|
-
import { legacyTopicWorkspaceRoot } from './workspaces.ts'
|
|
103
101
|
|
|
104
102
|
/** Periodic route-GC sweep cadence (a long-running instance also accumulates
|
|
105
103
|
* dead records; TTL semantics make 6 h granularity more than sufficient). */
|
|
@@ -108,9 +106,18 @@ const GC_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000
|
|
|
108
106
|
/** Dedup-record retention: Feishu redelivery windows are minutes-scale, so a
|
|
109
107
|
* day is 100×+ headroom while keeping the table bounded. */
|
|
110
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
|
|
111
115
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
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 }
|
|
114
121
|
|
|
115
122
|
|
|
116
123
|
interface RenewAdmission {
|
|
@@ -139,29 +146,6 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
|
|
|
139
146
|
const accessGroupsRaw = domain.table('access_groups')
|
|
140
147
|
const accessUsersRaw = domain.table('access_users')
|
|
141
148
|
const accessRequestsRaw = domain.table('access_requests')
|
|
142
|
-
// Pending attachment metadata is a table ADDED to the domain at the same
|
|
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
|
-
}
|
|
165
149
|
return {
|
|
166
150
|
routes: {
|
|
167
151
|
get: key => table.get(key) as RouteRecord | undefined,
|
|
@@ -205,7 +189,6 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
|
|
|
205
189
|
delete: key => accessRequestsRaw.delete(key),
|
|
206
190
|
entries: () => accessRequestsRaw.entries() as IterableIterator<[string, AccessRequestRecord]>,
|
|
207
191
|
},
|
|
208
|
-
pendingAttachments,
|
|
209
192
|
getGlobal: () => (domain.global.get() ?? {}) as FeishuDomainGlobal,
|
|
210
193
|
setGlobal: value => domain.global.set(value),
|
|
211
194
|
close: () => domain.close(),
|
|
@@ -239,52 +222,6 @@ function messageOf(error: unknown): string {
|
|
|
239
222
|
return error instanceof Error ? error.message : String(error)
|
|
240
223
|
}
|
|
241
224
|
|
|
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
225
|
export class FeishuService {
|
|
289
226
|
private readonly ctx: HostContext
|
|
290
227
|
/** Live config object. Mutated in place — collaborators captured this reference. */
|
|
@@ -313,12 +250,6 @@ export class FeishuService {
|
|
|
313
250
|
* fallback when the host does not mount `ctx.attachments`.
|
|
314
251
|
*/
|
|
315
252
|
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
253
|
/**
|
|
323
254
|
* Read-only Feishu message access (P2): the quoted-message excerpt and the
|
|
324
255
|
* first-turn topic history. Constructed before the inbound router so the
|
|
@@ -336,6 +267,7 @@ export class FeishuService {
|
|
|
336
267
|
*/
|
|
337
268
|
private readonly attachmentFetchTools: AttachmentFetchToolRuntime
|
|
338
269
|
private readonly topicMessagesTools: TopicMessagesToolRuntime
|
|
270
|
+
private readonly sendFileTools: SendFileToolRuntime
|
|
339
271
|
/** FIFO admission for ordinary turns and commands targeting one route. */
|
|
340
272
|
private readonly routeOperations = new RouteOperationQueue()
|
|
341
273
|
/** Active Host command executions, aborted before shutdown tears peers down. */
|
|
@@ -369,10 +301,6 @@ export class FeishuService {
|
|
|
369
301
|
private readonly unlistenHost: Array<() => boolean> = []
|
|
370
302
|
/** Periodic route-GC timer (unref'd; only armed when `routeTtlMs > 0`). */
|
|
371
303
|
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
304
|
/** HITL approval answerer (created in `start()`). */
|
|
377
305
|
private approvalAnswerer: ApprovalAnswerer | undefined
|
|
378
306
|
/** HITL structured-question answerer (`ask_user_question`). */
|
|
@@ -389,6 +317,17 @@ export class FeishuService {
|
|
|
389
317
|
/** Administrator DM authorization cards + bot-added group info cards. */
|
|
390
318
|
private accessRequests: AccessRequests | undefined
|
|
391
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
|
|
392
331
|
|
|
393
332
|
constructor(
|
|
394
333
|
ctx: HostContext,
|
|
@@ -430,13 +369,15 @@ export class FeishuService {
|
|
|
430
369
|
//
|
|
431
370
|
// The topic-messages tool is backend-independent: quoted excerpts and
|
|
432
371
|
// topic history are read-only, so it is mounted for both backends and
|
|
433
|
-
// self-gates on `feishuTools.topicMessages
|
|
434
|
-
//
|
|
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).
|
|
435
375
|
const mounts: Array<Promise<void>> = []
|
|
436
376
|
if (this.effectiveAttachmentBackend() === 'native') {
|
|
437
377
|
mounts.push(this.attachmentFetchTools.mount(agentCtx, sessionId))
|
|
438
378
|
}
|
|
439
379
|
mounts.push(this.topicMessagesTools.mount(agentCtx, sessionId))
|
|
380
|
+
mounts.push(this.sendFileTools.mount(agentCtx, sessionId))
|
|
440
381
|
return Promise.all(mounts).then(() => undefined)
|
|
441
382
|
},
|
|
442
383
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
@@ -457,18 +398,9 @@ export class FeishuService {
|
|
|
457
398
|
archive: this.attachments,
|
|
458
399
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
459
400
|
})
|
|
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
401
|
this.attachmentFetchTools = new AttachmentFetchToolRuntime({
|
|
469
402
|
config: () => this.config,
|
|
470
403
|
channel: () => this.channel,
|
|
471
|
-
pending: () => this.pendingAttachments,
|
|
472
404
|
routeOfSession: sessionId => {
|
|
473
405
|
const record = this.routes.findBySession(sessionId)
|
|
474
406
|
if (record === undefined) return undefined
|
|
@@ -477,7 +409,7 @@ export class FeishuService {
|
|
|
477
409
|
kind: record.kind,
|
|
478
410
|
chatId: record.chatId,
|
|
479
411
|
chatType: record.chatType,
|
|
480
|
-
|
|
412
|
+
senderUserId: record.senderUserId,
|
|
481
413
|
}
|
|
482
414
|
},
|
|
483
415
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
@@ -493,7 +425,7 @@ export class FeishuService {
|
|
|
493
425
|
kind: record.kind,
|
|
494
426
|
chatId: record.chatId,
|
|
495
427
|
chatType: record.chatType,
|
|
496
|
-
|
|
428
|
+
senderUserId: record.senderUserId,
|
|
497
429
|
}
|
|
498
430
|
},
|
|
499
431
|
noteKnownMessage: (route, message, source) => {
|
|
@@ -505,6 +437,20 @@ export class FeishuService {
|
|
|
505
437
|
fetchAvailable: () => this.effectiveAttachmentBackend() === 'native',
|
|
506
438
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
507
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
|
+
})
|
|
508
454
|
this.delivery = new CardDelivery({
|
|
509
455
|
channel: () => this.channel,
|
|
510
456
|
started: () => this.started,
|
|
@@ -516,7 +462,7 @@ export class FeishuService {
|
|
|
516
462
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
517
463
|
})
|
|
518
464
|
this.accessController = new AccessController({
|
|
519
|
-
config: () => this.
|
|
465
|
+
config: () => this.accessConfig(),
|
|
520
466
|
store: this.accessStore,
|
|
521
467
|
channel: () => this.channel,
|
|
522
468
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
@@ -543,7 +489,6 @@ export class FeishuService {
|
|
|
543
489
|
attachmentMode: () => this.config.attachmentStore,
|
|
544
490
|
nativeAttachments: () => this.nativeAttachmentService(),
|
|
545
491
|
attachmentStore: this.inboundAttachmentStore,
|
|
546
|
-
pendingAttachments: this.pendingAttachments,
|
|
547
492
|
noteKnownAttachments: (record, message, source) => {
|
|
548
493
|
this.noteKnownAttachmentMessage(record, message, source ?? 'trigger')
|
|
549
494
|
},
|
|
@@ -561,6 +506,7 @@ export class FeishuService {
|
|
|
561
506
|
queue: this.routeOperations,
|
|
562
507
|
streamer: () => this.streamer,
|
|
563
508
|
questionAnswerer: () => this.questionAnswerer,
|
|
509
|
+
approvalAnswerer: () => this.approvalAnswerer,
|
|
564
510
|
commands: { handleSlash: (msg, line) => this.handleSlashMessage(msg, line) },
|
|
565
511
|
})
|
|
566
512
|
}
|
|
@@ -603,8 +549,8 @@ export class FeishuService {
|
|
|
603
549
|
* Pre-authorize one admitted Feishu message for `feishu_attachment_fetch`.
|
|
604
550
|
*
|
|
605
551
|
* The admission and quote layers call this after their access gates for the
|
|
606
|
-
* triggering / quoted message; the tool otherwise only authorizes
|
|
607
|
-
*
|
|
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
|
|
608
554
|
* in-memory operation (one LRU entry per route) and never fails the caller.
|
|
609
555
|
*/
|
|
610
556
|
noteKnownAttachmentMessage(
|
|
@@ -617,7 +563,7 @@ export class FeishuService {
|
|
|
617
563
|
kind: record.kind,
|
|
618
564
|
chatId: record.chatId,
|
|
619
565
|
chatType: record.chatType,
|
|
620
|
-
|
|
566
|
+
senderUserId: record.senderUserId,
|
|
621
567
|
}, message, source)
|
|
622
568
|
}
|
|
623
569
|
|
|
@@ -633,7 +579,7 @@ export class FeishuService {
|
|
|
633
579
|
kind: record.kind,
|
|
634
580
|
chatId: record.chatId,
|
|
635
581
|
chatType: record.chatType,
|
|
636
|
-
|
|
582
|
+
senderUserId: record.senderUserId,
|
|
637
583
|
}
|
|
638
584
|
}
|
|
639
585
|
|
|
@@ -907,7 +853,6 @@ export class FeishuService {
|
|
|
907
853
|
*/
|
|
908
854
|
applyOverlay(overlay: OverlayPatch): void {
|
|
909
855
|
this.replaceConfig(mergeOverlay(this.baseConfig, overlay))
|
|
910
|
-
this.refreshRetentionTimer()
|
|
911
856
|
}
|
|
912
857
|
|
|
913
858
|
/**
|
|
@@ -956,15 +901,17 @@ export class FeishuService {
|
|
|
956
901
|
|
|
957
902
|
/** Whitelists + pending authorization requests (read-only Web projection). */
|
|
958
903
|
accessSnapshot(): AccessWire {
|
|
959
|
-
const admin = this.
|
|
904
|
+
const admin = this.effectiveAdminUserId()
|
|
905
|
+
const owner = this.appOwner
|
|
960
906
|
return {
|
|
961
907
|
mode: this.config.accessMode,
|
|
962
908
|
// Whitelists live in the storage domain, which is only open while the
|
|
963
909
|
// channel runs; an unconnected panel must not present them as empty.
|
|
964
910
|
loaded: this.domain !== undefined,
|
|
965
911
|
admin: {
|
|
966
|
-
...(admin === undefined ? {} : {
|
|
912
|
+
...(admin === undefined ? {} : { userId: admin, source: this.configuredAdminUserId() === undefined ? 'owner' : 'config' }),
|
|
967
913
|
configured: admin !== undefined,
|
|
914
|
+
...(owner !== undefined && 'error' in owner ? { ownerLookupError: owner.error } : {}),
|
|
968
915
|
},
|
|
969
916
|
groups: this.accessStore.groupRecords().map(record => ({
|
|
970
917
|
chatId: record.chatId,
|
|
@@ -973,7 +920,7 @@ export class FeishuService {
|
|
|
973
920
|
addedAt: record.addedAt,
|
|
974
921
|
})),
|
|
975
922
|
users: this.accessStore.userRecords().map(record => ({
|
|
976
|
-
|
|
923
|
+
userId: record.userId,
|
|
977
924
|
...(record.name === undefined ? {} : { name: record.name }),
|
|
978
925
|
source: record.source,
|
|
979
926
|
addedAt: record.addedAt,
|
|
@@ -990,7 +937,7 @@ export class FeishuService {
|
|
|
990
937
|
if (name !== undefined) wire.name = name
|
|
991
938
|
if (record.chatId !== undefined) wire.chatId = record.chatId
|
|
992
939
|
if (record.groupName !== undefined) wire.groupName = record.groupName
|
|
993
|
-
const requester = record.requesterName ?? record.
|
|
940
|
+
const requester = record.requesterName ?? record.requesterUserId
|
|
994
941
|
if (requester !== undefined) wire.requester = requester
|
|
995
942
|
return wire
|
|
996
943
|
}),
|
|
@@ -1001,8 +948,8 @@ export class FeishuService {
|
|
|
1001
948
|
async addAccessGroup(chatId: string, name?: string): Promise<AccessMutationResponse> {
|
|
1002
949
|
const id = this.cleanAccessTargetId(chatId)
|
|
1003
950
|
if (id === undefined) return { ok: false, error: 'chat_id 不合法(应为 oc_ 开头的群 ID)' }
|
|
1004
|
-
const admin = this.
|
|
1005
|
-
if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员
|
|
951
|
+
const admin = this.effectiveAdminUserId()
|
|
952
|
+
if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 user_id' }
|
|
1006
953
|
try {
|
|
1007
954
|
if (!this.accessStore.hasGroup(id)) {
|
|
1008
955
|
await this.accessStore.addGroup({
|
|
@@ -1032,15 +979,15 @@ export class FeishuService {
|
|
|
1032
979
|
}
|
|
1033
980
|
|
|
1034
981
|
/** 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: '请先在「访问授权」标签页设置管理员
|
|
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' }
|
|
1040
987
|
try {
|
|
1041
988
|
if (!this.accessStore.hasUser(id)) {
|
|
1042
989
|
await this.accessStore.addUser({
|
|
1043
|
-
|
|
990
|
+
userId: id,
|
|
1044
991
|
...(name === undefined || name.trim() === '' ? {} : { name: name.trim() }),
|
|
1045
992
|
source: 'panel',
|
|
1046
993
|
addedBy: admin,
|
|
@@ -1054,9 +1001,9 @@ export class FeishuService {
|
|
|
1054
1001
|
}
|
|
1055
1002
|
|
|
1056
1003
|
/** Panel: revoke one user. */
|
|
1057
|
-
async removeAccessUser(
|
|
1058
|
-
const id = this.cleanAccessUserId(
|
|
1059
|
-
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 不合法' }
|
|
1060
1007
|
try {
|
|
1061
1008
|
await this.accessStore.removeUser(id)
|
|
1062
1009
|
return { ok: true, access: this.accessSnapshot() }
|
|
@@ -1069,8 +1016,8 @@ export class FeishuService {
|
|
|
1069
1016
|
async resolveAccessRequest(token: string, decision: 'allow' | 'deny'): Promise<AccessMutationResponse> {
|
|
1070
1017
|
const requests = this.accessRequests
|
|
1071
1018
|
if (requests === undefined) return { ok: false, error: '授权服务尚未启动' }
|
|
1072
|
-
const actor = this.
|
|
1073
|
-
if (actor === undefined) return { ok: false, error: '请先在「访问授权」标签页配置管理员
|
|
1019
|
+
const actor = this.effectiveAdminUserId()
|
|
1020
|
+
if (actor === undefined) return { ok: false, error: '请先在「访问授权」标签页配置管理员 user_id' }
|
|
1074
1021
|
try {
|
|
1075
1022
|
const result = await requests.resolve(token, decision, actor)
|
|
1076
1023
|
if (!result.ok) return { ok: false, error: result.error }
|
|
@@ -1080,276 +1027,191 @@ export class FeishuService {
|
|
|
1080
1027
|
}
|
|
1081
1028
|
}
|
|
1082
1029
|
|
|
1083
|
-
/**
|
|
1084
|
-
private
|
|
1085
|
-
const
|
|
1086
|
-
if (
|
|
1087
|
-
|
|
1088
|
-
if (/[\u0000-\u001f\u007f]/u.test(value)) return undefined
|
|
1089
|
-
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}\``
|
|
1090
1035
|
}
|
|
1091
1036
|
|
|
1092
|
-
/**
|
|
1093
|
-
private
|
|
1094
|
-
const
|
|
1095
|
-
|
|
1096
|
-
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
|
|
1097
1041
|
}
|
|
1098
1042
|
|
|
1099
|
-
|
|
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
|
+
}
|
|
1100
1050
|
|
|
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
|
-
}
|
|
1051
|
+
/** Live config with `adminUserId` replaced by the effective administrator. */
|
|
1052
|
+
private accessConfig(): FeishuConfig {
|
|
1053
|
+
return { ...this.config, adminUserId: this.effectiveAdminUserId() }
|
|
1145
1054
|
}
|
|
1146
1055
|
|
|
1147
1056
|
/**
|
|
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.
|
|
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).
|
|
1157
1060
|
*/
|
|
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
|
|
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
|
|
1186
1066
|
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
|
-
}
|
|
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 }
|
|
1203
1076
|
} catch (error) {
|
|
1204
|
-
|
|
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
|
+
}
|
|
1205
1082
|
} finally {
|
|
1206
|
-
|
|
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)
|
|
1207
1090
|
}
|
|
1208
1091
|
}
|
|
1209
1092
|
|
|
1210
|
-
/**
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
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
|
|
1214
1096
|
}
|
|
1215
1097
|
|
|
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
|
-
}
|
|
1098
|
+
/** Latest native slash-command panel sync, when one finished for this connection. */
|
|
1099
|
+
slashCommandSync(): SlashCommandSyncWire | undefined {
|
|
1100
|
+
return this.slashCommandSyncState
|
|
1262
1101
|
}
|
|
1263
1102
|
|
|
1264
1103
|
/**
|
|
1265
|
-
*
|
|
1266
|
-
*
|
|
1267
|
-
*
|
|
1268
|
-
* 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.
|
|
1269
1107
|
*/
|
|
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
|
-
|
|
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)
|
|
1301
1142
|
}
|
|
1302
|
-
return scanned
|
|
1303
1143
|
}
|
|
1304
1144
|
|
|
1305
1145
|
/**
|
|
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.
|
|
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.
|
|
1311
1149
|
*/
|
|
1312
|
-
private async
|
|
1313
|
-
|
|
1314
|
-
if (
|
|
1315
|
-
|
|
1316
|
-
|
|
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
|
|
1317
1156
|
try {
|
|
1318
|
-
const
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
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
|
+
}
|
|
1322
1182
|
} catch (error) {
|
|
1323
|
-
|
|
1324
|
-
|
|
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)
|
|
1325
1198
|
}
|
|
1326
|
-
await rm(resolved, { recursive: true, force: true })
|
|
1327
1199
|
}
|
|
1328
1200
|
|
|
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
|
|
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
|
|
1353
1215
|
}
|
|
1354
1216
|
|
|
1355
1217
|
/** Aggregate health used by `/health` and the Web settings panel. */
|
|
@@ -1481,6 +1343,9 @@ export class FeishuService {
|
|
|
1481
1343
|
onError: (sessionId, error) => {
|
|
1482
1344
|
this.ctx.logger?.warn?.('dsh-channels approval error (session %s): %s', sessionId, String(error))
|
|
1483
1345
|
},
|
|
1346
|
+
onWaitingChange: (sessionId, toolNames) => {
|
|
1347
|
+
this.streamer?.setAwaitingApproval(sessionId, toolNames)
|
|
1348
|
+
},
|
|
1484
1349
|
}, this.config.approvalTimeoutMs)
|
|
1485
1350
|
this.approvalAnswerer = answerer
|
|
1486
1351
|
this.unlistenApproval = this.ctx.on('approval/request', (req, next) => answerer.onRequest(req, next), { prepend: true })
|
|
@@ -1500,7 +1365,7 @@ export class FeishuService {
|
|
|
1500
1365
|
{ prepend: true },
|
|
1501
1366
|
)
|
|
1502
1367
|
const accessRequests = new AccessRequests({
|
|
1503
|
-
config: () => this.
|
|
1368
|
+
config: () => this.accessConfig(),
|
|
1504
1369
|
store: this.accessStore,
|
|
1505
1370
|
channel: () => this.channel,
|
|
1506
1371
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
@@ -1518,10 +1383,15 @@ export class FeishuService {
|
|
|
1518
1383
|
void accessRequests.onCardAction(event)
|
|
1519
1384
|
answerer.onCardAction(event)
|
|
1520
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
|
+
})
|
|
1521
1390
|
})
|
|
1522
1391
|
|
|
1523
1392
|
this.unlistenHost.push(this.ctx.on('session/event', (session, event) => {
|
|
1524
1393
|
this.streamer?.onSessionEvent(session, event)
|
|
1394
|
+
this.approvalAnswerer?.onSessionEvent(session.id, event)
|
|
1525
1395
|
}))
|
|
1526
1396
|
// DSH removed the durable `assistant/chunk` event (format v2 embeds the
|
|
1527
1397
|
// stream in the settlement); the live deltas arrive here instead.
|
|
@@ -1537,7 +1407,15 @@ export class FeishuService {
|
|
|
1537
1407
|
.catch(() => {})
|
|
1538
1408
|
})
|
|
1539
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
|
|
1540
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)
|
|
1541
1419
|
// Repair cards a previous instance left mid-flight (best-effort, background).
|
|
1542
1420
|
void this.repair.repairAll().catch((error: unknown) => {
|
|
1543
1421
|
this.ctx.logger?.warn?.('dsh-channels: inflight repair failed: %s', String(error))
|
|
@@ -1554,8 +1432,6 @@ export class FeishuService {
|
|
|
1554
1432
|
}, GC_SWEEP_INTERVAL_MS)
|
|
1555
1433
|
timer.unref?.()
|
|
1556
1434
|
this.gcTimer = timer
|
|
1557
|
-
// Workspace retention: opt-in automatic cleanup (default off).
|
|
1558
|
-
this.refreshRetentionTimer(true)
|
|
1559
1435
|
this.ctx.logger?.info?.(
|
|
1560
1436
|
'dsh-channels: feishu long connection established (bot open_id %s, %d route(s) loaded)',
|
|
1561
1437
|
channel.botOpenId() ?? 'unknown',
|
|
@@ -1571,11 +1447,15 @@ export class FeishuService {
|
|
|
1571
1447
|
})
|
|
1572
1448
|
if (this.config.accessMode === 'open') {
|
|
1573
1449
|
console.warn('[dsh-channels] 准入=open:任何应用可用范围内的用户都可以对话(不安全,仅调试)')
|
|
1574
|
-
} else if (this.
|
|
1450
|
+
} else if (this.effectiveAdminUserId() === undefined) {
|
|
1451
|
+
const ownerError = this.appOwner !== undefined && 'error' in this.appOwner ? this.appOwner.error : undefined
|
|
1575
1452
|
console.warn(
|
|
1576
|
-
'[dsh-channels] 准入=guarded 但未配置管理员
|
|
1577
|
-
+ '私聊机器人发送 /userid 取得
|
|
1453
|
+
'[dsh-channels] 准入=guarded 但未配置管理员 user_id,也无法使用应用所有者兜底:所有对话都会被拒绝;'
|
|
1454
|
+
+ '私聊机器人发送 /userid 取得 user_id 后填入 Web 设置面板'
|
|
1455
|
+
+ (ownerError === undefined ? '' : `(${ownerError})`),
|
|
1578
1456
|
)
|
|
1457
|
+
} else if (this.configuredAdminUserId() === undefined) {
|
|
1458
|
+
console.info('[dsh-channels] 准入=guarded,未配置管理员,使用应用所有者', this.effectiveAdminUserId())
|
|
1579
1459
|
} else {
|
|
1580
1460
|
console.info('[dsh-channels] 准入=guarded,管理员已配置')
|
|
1581
1461
|
}
|
|
@@ -1629,10 +1509,6 @@ export class FeishuService {
|
|
|
1629
1509
|
clearInterval(this.gcTimer)
|
|
1630
1510
|
this.gcTimer = undefined
|
|
1631
1511
|
}
|
|
1632
|
-
if (this.retentionTimer !== undefined) {
|
|
1633
|
-
clearInterval(this.retentionTimer)
|
|
1634
|
-
this.retentionTimer = undefined
|
|
1635
|
-
}
|
|
1636
1512
|
await this.sessions.disposeAll()
|
|
1637
1513
|
this.attachmentFetchTools.clear()
|
|
1638
1514
|
this.routeOperations.clear()
|
|
@@ -1645,10 +1521,16 @@ export class FeishuService {
|
|
|
1645
1521
|
// Already disconnected is fine.
|
|
1646
1522
|
}
|
|
1647
1523
|
this.channel = undefined
|
|
1524
|
+
this.streamer?.detachAll()
|
|
1648
1525
|
this.streamer = undefined
|
|
1649
1526
|
await this.domain?.close()
|
|
1650
1527
|
this.domain = undefined
|
|
1651
1528
|
this.runningConfig = undefined
|
|
1529
|
+
this.appOwner = undefined
|
|
1530
|
+
this.scopeCheckState = undefined
|
|
1531
|
+
this.slashCommandSyncState = undefined
|
|
1532
|
+
this.slashCommandSyncAbort?.abort()
|
|
1533
|
+
this.slashCommandSyncAbort = undefined
|
|
1652
1534
|
}
|
|
1653
1535
|
|
|
1654
1536
|
// ---- inbound routing -------------------------------------------------
|
|
@@ -1722,7 +1604,7 @@ export class FeishuService {
|
|
|
1722
1604
|
return
|
|
1723
1605
|
}
|
|
1724
1606
|
|
|
1725
|
-
if (msg.senderId !== route.
|
|
1607
|
+
if (msg.senderId !== route.senderUserId) {
|
|
1726
1608
|
await this.delivery.commandCard(msg, {
|
|
1727
1609
|
kind: 'forbidden', command: parsed.name, text: '无权操作此会话。',
|
|
1728
1610
|
})
|
|
@@ -1732,7 +1614,7 @@ export class FeishuService {
|
|
|
1732
1614
|
const operation = this.routeOperations.run(route.key, async () => {
|
|
1733
1615
|
if (!this.started) return
|
|
1734
1616
|
const current = this.routes.get(route.key) ?? route
|
|
1735
|
-
if (msg.senderId !== current.
|
|
1617
|
+
if (msg.senderId !== current.senderUserId) {
|
|
1736
1618
|
await this.delivery.commandCard(msg, {
|
|
1737
1619
|
kind: 'forbidden', command: parsed.name, text: '无权操作此会话。',
|
|
1738
1620
|
})
|
|
@@ -1765,12 +1647,12 @@ export class FeishuService {
|
|
|
1765
1647
|
const pending = this.accessStore.pendingRequests()
|
|
1766
1648
|
await this.delivery.standaloneReply(msg, standaloneInfoCard('**🔐 访问授权状态**', [
|
|
1767
1649
|
`**准入模式**:\`${this.config.accessMode}\``,
|
|
1768
|
-
`**管理员**:${this.
|
|
1650
|
+
`**管理员**:${this.adminStatusLine()}`,
|
|
1769
1651
|
...(msg.chatType === 'group'
|
|
1770
1652
|
? [`**本群**:${this.accessStore.hasGroup(msg.chatId) ? '已授权' : '未授权'}(\`${msg.chatId}\`)`]
|
|
1771
1653
|
: []),
|
|
1772
1654
|
`**白名单**:群 ${groups.length} 个 · 用户 ${users.length} 个 · 待处理申请 ${pending.length} 个`,
|
|
1773
|
-
'群授权:`/auth allow` 或 `/auth revoke`(在目标群内发);用户授权:`/auth user add <
|
|
1655
|
+
'群授权:`/auth allow` 或 `/auth revoke`(在目标群内发);用户授权:`/auth user add @用户` 或 `/auth user add <user_id>`。',
|
|
1774
1656
|
]), direct)
|
|
1775
1657
|
return
|
|
1776
1658
|
}
|
|
@@ -1817,35 +1699,39 @@ export class FeishuService {
|
|
|
1817
1699
|
}
|
|
1818
1700
|
if (action === 'user') {
|
|
1819
1701
|
const sub = words[1]
|
|
1820
|
-
const
|
|
1821
|
-
|
|
1822
|
-
|
|
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) {
|
|
1823
1706
|
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 查看)。',
|
|
1707
|
+
kind: 'input-error', command: 'auth', text: AUTH_USER_TARGET_ERRORS[target.reason],
|
|
1826
1708
|
}, direct)
|
|
1827
1709
|
return
|
|
1828
1710
|
}
|
|
1711
|
+
const { userId } = target
|
|
1712
|
+
const label = target.name === undefined ? `\`${userId}\`` : `${target.name}(\`${userId}\`)`
|
|
1829
1713
|
if (sub === 'add') {
|
|
1830
|
-
if (this.accessStore.hasUser(
|
|
1714
|
+
if (this.accessStore.hasUser(userId)) {
|
|
1831
1715
|
await this.delivery.commandCard(msg, {
|
|
1832
|
-
kind: 'success', command: 'auth', text:
|
|
1716
|
+
kind: 'success', command: 'auth', text: `该用户已在白名单中:${label}。`,
|
|
1833
1717
|
}, direct)
|
|
1834
1718
|
return
|
|
1835
1719
|
}
|
|
1836
1720
|
await this.accessStore.addUser({
|
|
1837
|
-
|
|
1721
|
+
userId,
|
|
1722
|
+
...(target.name === undefined ? {} : { name: target.name }),
|
|
1723
|
+
source: 'command', addedBy: msg.senderId, addedAt: now,
|
|
1838
1724
|
})
|
|
1839
1725
|
await this.delivery.commandCard(msg, {
|
|
1840
1726
|
kind: 'success', command: 'auth',
|
|
1841
|
-
text: `✅ 已授权用户
|
|
1727
|
+
text: `✅ 已授权用户 ${label}(在所有已授权群里生效)。`,
|
|
1842
1728
|
}, direct)
|
|
1843
1729
|
return
|
|
1844
1730
|
}
|
|
1845
|
-
const removed = await this.accessStore.removeUser(
|
|
1731
|
+
const removed = await this.accessStore.removeUser(userId)
|
|
1846
1732
|
await this.delivery.commandCard(msg, {
|
|
1847
1733
|
kind: removed ? 'success' : 'unavailable', command: 'auth',
|
|
1848
|
-
text: removed ? `已移除用户
|
|
1734
|
+
text: removed ? `已移除用户 ${label},立即生效。` : `用户 ${label} 不在白名单中。`,
|
|
1849
1735
|
}, direct)
|
|
1850
1736
|
return
|
|
1851
1737
|
}
|
|
@@ -1858,7 +1744,7 @@ export class FeishuService {
|
|
|
1858
1744
|
: groups.slice(0, 30).map(group => `- ${group.name ?? group.chatId} \`${group.chatId}\``)
|
|
1859
1745
|
const userLines = users.length === 0
|
|
1860
1746
|
? ['(暂无)']
|
|
1861
|
-
: users.slice(0, 30).map(user => `- ${user.name ?? user.
|
|
1747
|
+
: users.slice(0, 30).map(user => `- ${user.name ?? user.userId} \`${user.userId}\``)
|
|
1862
1748
|
await this.delivery.standaloneReply(msg, standaloneInfoCard('**🔐 访问授权清单**', [
|
|
1863
1749
|
`**已授权群(${groups.length})**`,
|
|
1864
1750
|
...groupLines,
|
|
@@ -1870,7 +1756,7 @@ export class FeishuService {
|
|
|
1870
1756
|
}
|
|
1871
1757
|
await this.delivery.commandCard(msg, {
|
|
1872
1758
|
kind: 'input-error', command: 'auth',
|
|
1873
|
-
text: '用法:/auth [status|allow|revoke|list|user add
|
|
1759
|
+
text: '用法:/auth [status|allow|revoke|list|user add <@用户|user_id>|user remove <@用户|user_id>]',
|
|
1874
1760
|
}, direct)
|
|
1875
1761
|
} catch (error) {
|
|
1876
1762
|
this.ctx.logger?.warn?.('dsh-channels: /auth failed: %s', String(error))
|
|
@@ -1979,10 +1865,7 @@ export class FeishuService {
|
|
|
1979
1865
|
return
|
|
1980
1866
|
}
|
|
1981
1867
|
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' })
|
|
1868
|
+
const running = this.cancelTurn(current.sessionId)
|
|
1986
1869
|
await this.delivery.commandCard(msg, {
|
|
1987
1870
|
kind: 'success', command: 'stop', sessionId: current.sessionId,
|
|
1988
1871
|
text: running ? '已请求停止当前回合。' : '当前没有正在运行的回合。',
|
|
@@ -1995,6 +1878,72 @@ export class FeishuService {
|
|
|
1995
1878
|
})
|
|
1996
1879
|
}
|
|
1997
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
|
+
|
|
1998
1947
|
/**
|
|
1999
1948
|
* `/permissions`: show this Session's effective sandbox+approval preset and
|
|
2000
1949
|
* the selectable catalog; switch only when the deployment opted in.
|
|
@@ -2015,14 +1964,27 @@ export class FeishuService {
|
|
|
2015
1964
|
const live = await this.sessions.ensureLive(current)
|
|
2016
1965
|
let active: string | undefined
|
|
2017
1966
|
let catalogLine = ''
|
|
1967
|
+
let choices: PickerOption[] = []
|
|
2018
1968
|
try {
|
|
2019
1969
|
active = presets.current(live.agent.session)
|
|
2020
1970
|
const catalog = presets.catalog()
|
|
2021
1971
|
catalogLine = catalog.options.map(option => `\`${option.value}\``).join('、')
|
|
1972
|
+
choices = catalog.options.map(option => ({ label: option.name || option.value, arg: option.value }))
|
|
2022
1973
|
} catch {
|
|
2023
1974
|
active = undefined
|
|
2024
1975
|
}
|
|
2025
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
|
+
}
|
|
2026
1988
|
if (requested === '') {
|
|
2027
1989
|
const lines = [
|
|
2028
1990
|
`**当前权限预设**:${active === undefined ? '暂无数据' : `\`${active}\``}`,
|
|
@@ -2149,6 +2111,7 @@ export class FeishuService {
|
|
|
2149
2111
|
}
|
|
2150
2112
|
await this.renderCommandResult({
|
|
2151
2113
|
spec, msg, current, live,
|
|
2114
|
+
bare: parsed.rawInput.trim() === '',
|
|
2152
2115
|
result: execution.result,
|
|
2153
2116
|
compactBefore,
|
|
2154
2117
|
renewAdmission,
|
|
@@ -2163,6 +2126,8 @@ export class FeishuService {
|
|
|
2163
2126
|
msg: NormalizedMessage
|
|
2164
2127
|
current: RouteRecord
|
|
2165
2128
|
live: LiveAgent
|
|
2129
|
+
/** The command was typed without arguments. */
|
|
2130
|
+
bare: boolean
|
|
2166
2131
|
result: CommandResult
|
|
2167
2132
|
compactBefore: ContextMeasurement | undefined
|
|
2168
2133
|
renewAdmission: RenewAdmission | undefined
|
|
@@ -2172,6 +2137,17 @@ export class FeishuService {
|
|
|
2172
2137
|
const { spec, msg, current, live, result, compactBefore, renewAdmission, effortAdmission, modelAdmission } = input
|
|
2173
2138
|
if (spec.name === 'effort') effortAdmission?.controller.sync(live.agent.session.snapshotEvents())
|
|
2174
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
|
+
}
|
|
2175
2151
|
if (spec.name === 'renew' && result.kind === 'success' && renewAdmission?.commit !== undefined) {
|
|
2176
2152
|
await this.delivery.standaloneReply(msg, buildRenewCard({
|
|
2177
2153
|
previousSessionId: renewAdmission.commit.previous.sessionId,
|
|
@@ -2395,18 +2371,13 @@ export class FeishuService {
|
|
|
2395
2371
|
|
|
2396
2372
|
|
|
2397
2373
|
/**
|
|
2398
|
-
* GC pass over the routing domain.
|
|
2374
|
+
* GC pass over the routing domain. Two independent parts:
|
|
2399
2375
|
*
|
|
2400
2376
|
* 1. Dedup records (unconditional): `processed` records and in-process
|
|
2401
2377
|
* guard entries older than `PROCESSED_RETENTION_MS` are dropped —
|
|
2402
2378
|
* redelivery windows are minutes, so a day of retention is ample and
|
|
2403
2379
|
* 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
|
|
2380
|
+
* 2. Route/inflight GC (`routeTtlMs > 0`): thread routes idle past the TTL
|
|
2410
2381
|
* are retired into minimal persistent tombstones before their active
|
|
2411
2382
|
* records are deleted. A later message in that visible topic is then
|
|
2412
2383
|
* rejected instead of silently creating a context-free Session. Degraded
|
|
@@ -2439,18 +2410,6 @@ export class FeishuService {
|
|
|
2439
2410
|
}
|
|
2440
2411
|
this.inbound.pruneAccepted(processedCutoff)
|
|
2441
2412
|
|
|
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
2413
|
const ttlMs = this.config.routeTtlMs
|
|
2455
2414
|
if (ttlMs === 0) {
|
|
2456
2415
|
if (removedProcessed > 0) {
|
|
@@ -2526,7 +2485,7 @@ export class FeishuService {
|
|
|
2526
2485
|
chatId: record.chatId,
|
|
2527
2486
|
cardMessageId: record.cardMessageId,
|
|
2528
2487
|
inThread: record.kind === 'thread',
|
|
2529
|
-
|
|
2488
|
+
senderUserId: record.senderUserId,
|
|
2530
2489
|
}
|
|
2531
2490
|
}
|
|
2532
2491
|
|
|
@@ -2546,22 +2505,10 @@ export class FeishuService {
|
|
|
2546
2505
|
if (admission.live.agent !== invocation.agent) {
|
|
2547
2506
|
return { kind: 'error', text: '/model 只能通过已授权的飞书话题执行。' }
|
|
2548
2507
|
}
|
|
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' ? '配置默认' : '部署默认'
|
|
2508
|
+
const { provider: currentProvider, model: currentModel } = currentModelRoute(admission.live, admission.controller)
|
|
2559
2509
|
|
|
2560
2510
|
if (rawInput === '') {
|
|
2561
|
-
const lines = [
|
|
2562
|
-
`**当前模型**:${currentModel === undefined ? '尚无模型请求记录' : `\`${currentModel}\``}`
|
|
2563
|
-
+ `${currentProvider === undefined ? '' : `(provider \`${currentProvider}\`)`} · ${source}`,
|
|
2564
|
-
]
|
|
2511
|
+
const lines = [currentModelLine(admission.live, admission.controller)]
|
|
2565
2512
|
const models = await this.listModels(currentProvider)
|
|
2566
2513
|
if (models.length > 0) {
|
|
2567
2514
|
lines.push(`**可选模型(${models.length})**:${models.slice(0, 20).map(id => `\`${id}\``).join('、')}`
|
|
@@ -2598,6 +2545,59 @@ export class FeishuService {
|
|
|
2598
2545
|
return { kind: 'success', text: `当前 Session 模型已切换为 \`${label}\`(下一条消息生效)。` }
|
|
2599
2546
|
}
|
|
2600
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
|
+
|
|
2601
2601
|
/** Model ids advertised for one provider route (empty when unavailable). */
|
|
2602
2602
|
private async listModels(provider: string | undefined): Promise<string[]> {
|
|
2603
2603
|
if (provider === undefined) return []
|
|
@@ -2738,3 +2738,28 @@ export class FeishuService {
|
|
|
2738
2738
|
}
|
|
2739
2739
|
}
|
|
2740
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
|
+
}
|