@modesto_ma/dsh-channels 0.5.0-beta.2 → 0.5.0-beta.4
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 +72 -15
- package/docs/attachment-pipeline-v2.md +2 -2
- package/docs/feishu-onboarding.md +4 -3
- package/lib/client.js +369 -94
- package/lib/client.js.map +1 -1
- package/lib/index.js +7847 -5293
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/controller.ts +32 -1
- package/src/analyze/contract.ts +121 -0
- package/src/analyze/launcher.ts +353 -0
- package/src/analyze/server.ts +201 -0
- package/src/cards/analyze.ts +116 -0
- package/src/cards/index.ts +1 -0
- package/src/cards/streaming.ts +58 -0
- package/src/client/api.ts +15 -0
- package/src/client/panel/access-card.tsx +96 -70
- package/src/client/panel/analyze-token-card.tsx +70 -0
- package/src/client/panel/index.tsx +72 -10
- package/src/client/panel/sessions-tab.tsx +7 -4
- package/src/client/panel/shared.tsx +12 -0
- package/src/client/panel/wiki-tab.tsx +62 -0
- package/src/config.ts +52 -14
- package/src/domain.ts +55 -0
- package/src/feishu/scopes.ts +19 -1
- package/src/feishu/tools.ts +3 -1
- package/src/feishu/wiki/a1.ts +97 -0
- package/src/feishu/wiki/api.ts +21 -6
- package/src/feishu/wiki/base-api.ts +267 -0
- package/src/feishu/wiki/base-tools.ts +423 -0
- package/src/feishu/wiki/shared.ts +284 -0
- package/src/feishu/wiki/sheet-api.ts +250 -0
- package/src/feishu/wiki/sheet-tools.ts +493 -0
- package/src/feishu/wiki/tools.ts +102 -189
- package/src/feishu-channel.ts +14 -1
- package/src/health.ts +12 -0
- package/src/host/agent.ts +5 -1
- package/src/index.ts +1 -1
- package/src/interactions/pending.ts +11 -2
- package/src/routing/inbound.ts +57 -14
- package/src/service.ts +161 -28
- package/src/settings/contract.ts +37 -6
- package/src/settings/routes.ts +66 -14
- package/src/settings/surface.ts +69 -20
- package/src/streamer.ts +156 -7
- package/src/workspaces.ts +31 -0
package/src/routing/inbound.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type {
|
|
|
14
14
|
AgentHandle, AttachmentStore, ContentBlock, HostContext, SessionId, UserMessage, Workspace,
|
|
15
15
|
} from '../dsh-types.ts'
|
|
16
16
|
import type { AttachmentStoreMode, FeishuConfig } from '../config.ts'
|
|
17
|
-
import { buildCard, buildOnboardingCard, initialStreamingCard } from '../cards.ts'
|
|
17
|
+
import { buildCard, buildOnboardingCard, initialStreamingCard, queuedStreamingCard } from '../cards.ts'
|
|
18
18
|
import { RouteOperationQueue } from '../commands.ts'
|
|
19
19
|
import { chatRouteKey, sessionDirOf } from '../domain.ts'
|
|
20
20
|
import type { FeishuDomainHandle, RouteRecord } from '../domain.ts'
|
|
@@ -228,6 +228,21 @@ interface TurnDeliveryOptions {
|
|
|
228
228
|
excludeMessageIds?: readonly string[]
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Register one release gate under `key` in `gates`. The returned closer
|
|
233
|
+
* resolves the gate and removes it only while it is still the registered one.
|
|
234
|
+
*/
|
|
235
|
+
function openGate(gates: Map<string, { promise: Promise<void>; release(): void }>, key: string): () => void {
|
|
236
|
+
let settle!: () => void
|
|
237
|
+
const promise = new Promise<void>((resolve) => { settle = resolve })
|
|
238
|
+
const release = (): void => {
|
|
239
|
+
if (gates.get(key)?.promise === promise) gates.delete(key)
|
|
240
|
+
settle()
|
|
241
|
+
}
|
|
242
|
+
gates.set(key, { promise, release })
|
|
243
|
+
return release
|
|
244
|
+
}
|
|
245
|
+
|
|
231
246
|
export class InboundRouter {
|
|
232
247
|
/**
|
|
233
248
|
* In-process dedup guard: message id → accept timestamp. Set synchronously
|
|
@@ -251,6 +266,13 @@ export class InboundRouter {
|
|
|
251
266
|
* racing it, otherwise the topic's turns reach the model out of order.
|
|
252
267
|
*/
|
|
253
268
|
private readonly firstDeliveries = new Map<string, { promise: Promise<void>; release(): void }>()
|
|
269
|
+
/**
|
|
270
|
+
* topic id → reservation by an external starter (the analyze launcher) that
|
|
271
|
+
* opened the topic but has not published its route yet. A reply in the topic
|
|
272
|
+
* waits for the release and then routes against the published record, or
|
|
273
|
+
* falls through as an unbound topic when the start failed.
|
|
274
|
+
*/
|
|
275
|
+
private readonly reservedTopics = new Map<string, { promise: Promise<void>; release(): void }>()
|
|
254
276
|
|
|
255
277
|
constructor(private readonly deps: InboundDeps) {}
|
|
256
278
|
|
|
@@ -282,14 +304,7 @@ export class InboundRouter {
|
|
|
282
304
|
* observes its gate. The returned closer must be called exactly once.
|
|
283
305
|
*/
|
|
284
306
|
private openDeliveryGate(key: string): () => void {
|
|
285
|
-
|
|
286
|
-
const promise = new Promise<void>((resolve) => { settle = resolve })
|
|
287
|
-
const release = (): void => {
|
|
288
|
-
if (this.firstDeliveries.get(key)?.promise === promise) this.firstDeliveries.delete(key)
|
|
289
|
-
settle()
|
|
290
|
-
}
|
|
291
|
-
this.firstDeliveries.set(key, { promise, release })
|
|
292
|
-
return release
|
|
307
|
+
return openGate(this.firstDeliveries, key)
|
|
293
308
|
}
|
|
294
309
|
|
|
295
310
|
/** Wait for the in-flight first followup of a route, when one is pending. */
|
|
@@ -298,7 +313,19 @@ export class InboundRouter {
|
|
|
298
313
|
if (pending !== undefined) await pending.promise.catch(() => undefined)
|
|
299
314
|
}
|
|
300
315
|
|
|
301
|
-
/**
|
|
316
|
+
/**
|
|
317
|
+
* Reserve one topic whose route is about to be published by an external
|
|
318
|
+
* starter. Replies in the topic wait until the returned closer runs; the
|
|
319
|
+
* closer must be called exactly once, after the route (and its first turn)
|
|
320
|
+
* is published or the start was abandoned.
|
|
321
|
+
* @param threadId - the topic id.
|
|
322
|
+
* @returns the closer releasing waiting replies.
|
|
323
|
+
*/
|
|
324
|
+
reserveTopic(threadId: string): () => void {
|
|
325
|
+
return openGate(this.reservedTopics, threadId)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
302
329
|
* Send the one-time onboarding card for a private chat, then remember the
|
|
303
330
|
* chat in the domain global. Best-effort: a storage failure only risks a
|
|
304
331
|
* repeated card, never a blocked session.
|
|
@@ -327,6 +354,8 @@ export class InboundRouter {
|
|
|
327
354
|
// first-turn delivery never stays suspended across a teardown.
|
|
328
355
|
for (const entry of [...this.firstDeliveries.values()]) entry.release()
|
|
329
356
|
this.firstDeliveries.clear()
|
|
357
|
+
for (const entry of [...this.reservedTopics.values()]) entry.release()
|
|
358
|
+
this.reservedTopics.clear()
|
|
330
359
|
}
|
|
331
360
|
|
|
332
361
|
/** Entry point: one normalized inbound message. */
|
|
@@ -370,6 +399,14 @@ export class InboundRouter {
|
|
|
370
399
|
// TTL tombstone, attachment archive, workspace, Session, or model call.
|
|
371
400
|
if (!(await this.access.admit(msg))) return
|
|
372
401
|
|
|
402
|
+
// A reserved topic has no route yet: wait for its starter so the TTL,
|
|
403
|
+
// attachment, mention, and route decisions below see the published record.
|
|
404
|
+
const reservation = msg.threadId === undefined ? undefined : this.reservedTopics.get(msg.threadId)
|
|
405
|
+
if (reservation !== undefined) {
|
|
406
|
+
await reservation.promise
|
|
407
|
+
if (!this.started) return
|
|
408
|
+
}
|
|
409
|
+
|
|
373
410
|
// Enforce TTL at admission as well as in the periodic sweep. A message that
|
|
374
411
|
// arrives after the deadline must not get one chance to revive the route
|
|
375
412
|
// merely because the background GC has not run yet.
|
|
@@ -1112,7 +1149,12 @@ export class InboundRouter {
|
|
|
1112
1149
|
// deployment follow its next turn (one-time legacy adoption, D12).
|
|
1113
1150
|
await this.adoptLegacyStagedAttachments(msg, placement.sessionDir, warn)
|
|
1114
1151
|
|
|
1115
|
-
|
|
1152
|
+
// A message behind a running turn opens as a queued card whose button can
|
|
1153
|
+
// move it into that turn instead (see `TurnStreamer.mergeQueued`).
|
|
1154
|
+
const firstCard = this.streamer?.hasLiveTurn(current.sessionId) === true
|
|
1155
|
+
? queuedStreamingCard(current.sessionId)
|
|
1156
|
+
: initialStreamingCard(current.sessionId)
|
|
1157
|
+
const sent = await this.delivery.replyFirstCard(msg, firstCard)
|
|
1116
1158
|
const typingId = await typingPromise
|
|
1117
1159
|
|
|
1118
1160
|
// Repair snapshot first — same crash-window reasoning as `startSession`.
|
|
@@ -1132,9 +1174,10 @@ export class InboundRouter {
|
|
|
1132
1174
|
cardMessageId: sent.messageId,
|
|
1133
1175
|
lastActivityAt: Date.now(),
|
|
1134
1176
|
})
|
|
1135
|
-
this.
|
|
1136
|
-
live.agent.followup(await this.deliverTurnMessage(msg, current, content, {
|
|
1177
|
+
const message = await this.deliverTurnMessage(msg, current, content, {
|
|
1137
1178
|
excludeMessageIds: [msg.messageId, sent.messageId],
|
|
1138
|
-
})
|
|
1179
|
+
})
|
|
1180
|
+
this.streamer?.attach(live.agent.session, sent.messageId, msg.messageId, typingId, { message })
|
|
1181
|
+
live.agent.followup(message)
|
|
1139
1182
|
}
|
|
1140
1183
|
}
|
package/src/service.ts
CHANGED
|
@@ -39,7 +39,7 @@ import type {
|
|
|
39
39
|
SessionProjections,
|
|
40
40
|
TokenMeter,
|
|
41
41
|
} from './dsh-types.ts'
|
|
42
|
-
import { buildPickerCard, parsePickAction, standaloneInfoCard, STOP_ACTION } from './cards.ts'
|
|
42
|
+
import { buildPickerCard, parsePickAction, standaloneInfoCard, STEER_ACTION, STOP_ACTION } from './cards.ts'
|
|
43
43
|
import type { PickerOption } from './cards.ts'
|
|
44
44
|
import {
|
|
45
45
|
armEffortCommand, armModelCommand, armRenewCommand, registerModelCommand, commandResultCardKind, parseSlashCommand,
|
|
@@ -52,10 +52,13 @@ import type { PermissionPresets, SessionQuery } from './dsh-types.ts'
|
|
|
52
52
|
import { FEISHU_USER_ID_PATTERN, feishuDomain, sessionDirOf } from './domain.ts'
|
|
53
53
|
import type {
|
|
54
54
|
AccessGroupRecord, AccessRequestRecord, AccessUserRecord,
|
|
55
|
-
ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot,
|
|
55
|
+
AnalyzeRequestRecord, ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot,
|
|
56
56
|
ProcessedRecord, RawDomainHandle, RouteRecord,
|
|
57
57
|
} from './domain.ts'
|
|
58
58
|
import { RouteStore } from './session/routes.ts'
|
|
59
|
+
import { AnalyzeLauncher } from './analyze/launcher.ts'
|
|
60
|
+
import { startAnalyzeServer } from './analyze/server.ts'
|
|
61
|
+
import type { AnalyzeServer } from './analyze/server.ts'
|
|
59
62
|
import { InflightRepair } from './session/repair.ts'
|
|
60
63
|
import { SessionManager } from './session/manager.ts'
|
|
61
64
|
import type { LiveAgent, RenewCommit } from './session/manager.ts'
|
|
@@ -94,7 +97,7 @@ import { ACTIVITY_BUILD_MARKER, TurnStreamer } from './streamer.ts'
|
|
|
94
97
|
import type { ProjectedStats, StreamerHooks } from './streamer.ts'
|
|
95
98
|
import type { FeishuConfig } from './config.ts'
|
|
96
99
|
import type {
|
|
97
|
-
AccessMutationResponse, AccessRequestWire, AccessWire, ScopeCheckWire, SlashCommandSyncWire,
|
|
100
|
+
AccessMutationResponse, AccessRequestWire, AccessUserWire, AccessWire, CredentialField, ScopeCheckWire, SlashCommandSyncWire,
|
|
98
101
|
} from './settings/contract.ts'
|
|
99
102
|
import { checkScopes } from './feishu/scopes.ts'
|
|
100
103
|
import { desiredSlashCommands, syncSlashCommands } from './feishu/slash-commands.ts'
|
|
@@ -149,6 +152,7 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
|
|
|
149
152
|
const accessUsersRaw = domain.table('access_users')
|
|
150
153
|
const accessRequestsRaw = domain.table('access_requests')
|
|
151
154
|
const accessWikiUsersRaw = domain.table('access_wiki_users')
|
|
155
|
+
const analyzeRequestsRaw = domain.table('analyze_requests')
|
|
152
156
|
return {
|
|
153
157
|
routes: {
|
|
154
158
|
get: key => table.get(key) as RouteRecord | undefined,
|
|
@@ -198,6 +202,11 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
|
|
|
198
202
|
delete: key => accessWikiUsersRaw.delete(key),
|
|
199
203
|
entries: () => accessWikiUsersRaw.entries() as IterableIterator<[string, AccessUserRecord]>,
|
|
200
204
|
},
|
|
205
|
+
analyzeRequests: {
|
|
206
|
+
get: key => analyzeRequestsRaw.get(key) as AnalyzeRequestRecord | undefined,
|
|
207
|
+
put: (key, value) => analyzeRequestsRaw.put(key, value),
|
|
208
|
+
entries: () => analyzeRequestsRaw.entries() as IterableIterator<[string, AnalyzeRequestRecord]>,
|
|
209
|
+
},
|
|
201
210
|
getGlobal: () => (domain.global.get() ?? {}) as FeishuDomainGlobal,
|
|
202
211
|
setGlobal: value => domain.global.set(value),
|
|
203
212
|
close: () => domain.close(),
|
|
@@ -230,6 +239,16 @@ async function resolveCredentialRef(ctx: HostContext, ref: string): Promise<stri
|
|
|
230
239
|
}
|
|
231
240
|
|
|
232
241
|
/** Best-effort human-readable error text for the settings panel. */
|
|
242
|
+
/** One whitelisted user as the panel shows it. */
|
|
243
|
+
function userWire(record: AccessUserRecord): AccessUserWire {
|
|
244
|
+
return {
|
|
245
|
+
userId: record.userId,
|
|
246
|
+
...(record.name === undefined ? {} : { name: record.name }),
|
|
247
|
+
source: record.source,
|
|
248
|
+
addedAt: record.addedAt,
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
233
252
|
function messageOf(error: unknown): string {
|
|
234
253
|
return error instanceof Error ? error.message : String(error)
|
|
235
254
|
}
|
|
@@ -270,6 +289,12 @@ export class FeishuService {
|
|
|
270
289
|
private readonly messageReader = new MessageReader({ channel: () => this.channel })
|
|
271
290
|
/** Inbound routing: dedup, admission, decision tree, session establishment. */
|
|
272
291
|
private readonly inbound: InboundRouter
|
|
292
|
+
/** Log-platform analyze API business side (requests → shared analysis topics). */
|
|
293
|
+
private readonly analyzeLauncher: AnalyzeLauncher
|
|
294
|
+
/** Bound analyze listener while connected with `analyzeListen` set. */
|
|
295
|
+
private analyzeServer: AnalyzeServer | undefined
|
|
296
|
+
/** Why the analyze listener could not bind at the last connect. */
|
|
297
|
+
private analyzeListenError: string | undefined
|
|
273
298
|
/** Agent/session lifecycle: live registry, resume, presets, renew. */
|
|
274
299
|
private readonly sessions: SessionManager
|
|
275
300
|
/**
|
|
@@ -425,6 +450,7 @@ export class FeishuService {
|
|
|
425
450
|
chatId: record.chatId,
|
|
426
451
|
chatType: record.chatType,
|
|
427
452
|
senderUserId: record.senderUserId,
|
|
453
|
+
...(record.shared === true ? { shared: true } : {}),
|
|
428
454
|
}
|
|
429
455
|
},
|
|
430
456
|
warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
@@ -441,6 +467,7 @@ export class FeishuService {
|
|
|
441
467
|
chatId: record.chatId,
|
|
442
468
|
chatType: record.chatType,
|
|
443
469
|
senderUserId: record.senderUserId,
|
|
470
|
+
...(record.shared === true ? { shared: true } : {}),
|
|
444
471
|
}
|
|
445
472
|
},
|
|
446
473
|
noteKnownMessage: (route, message, source) => {
|
|
@@ -539,6 +566,21 @@ export class FeishuService {
|
|
|
539
566
|
approvalAnswerer: () => this.approvalAnswerer,
|
|
540
567
|
commands: { handleSlash: (msg, line) => this.handleSlashMessage(msg, line) },
|
|
541
568
|
})
|
|
569
|
+
this.analyzeLauncher = new AnalyzeLauncher({
|
|
570
|
+
ctx: this.ctx,
|
|
571
|
+
config: this.config,
|
|
572
|
+
started: () => this.started,
|
|
573
|
+
channel: () => this.channel,
|
|
574
|
+
domain: () => this.domain,
|
|
575
|
+
routes: this.routes,
|
|
576
|
+
repair: this.repair,
|
|
577
|
+
sessions: this.sessions,
|
|
578
|
+
streamer: () => this.streamer,
|
|
579
|
+
queue: this.routeOperations,
|
|
580
|
+
reserveTopic: threadId => this.inbound.reserveTopic(threadId),
|
|
581
|
+
mayAnalyzeIn: chatId => this.accessController.mayAnalyzeIn(chatId),
|
|
582
|
+
resolveToken: () => resolveCredentialRef(this.ctx, this.config.analyzeToken).catch(() => undefined),
|
|
583
|
+
})
|
|
542
584
|
}
|
|
543
585
|
|
|
544
586
|
/** Effective inbound attachment backend (matches the inbound router). */
|
|
@@ -949,12 +991,8 @@ export class FeishuService {
|
|
|
949
991
|
source: record.source,
|
|
950
992
|
addedAt: record.addedAt,
|
|
951
993
|
})),
|
|
952
|
-
users: this.accessStore.userRecords().map(
|
|
953
|
-
|
|
954
|
-
...(record.name === undefined ? {} : { name: record.name }),
|
|
955
|
-
source: record.source,
|
|
956
|
-
addedAt: record.addedAt,
|
|
957
|
-
})),
|
|
994
|
+
users: this.accessStore.userRecords().map(userWire),
|
|
995
|
+
wikiUsers: this.accessStore.wikiUserRecords().map(userWire),
|
|
958
996
|
requests: this.accessStore.pendingRequests().map(record => {
|
|
959
997
|
const wire: AccessRequestWire = {
|
|
960
998
|
token: record.token,
|
|
@@ -1010,13 +1048,37 @@ export class FeishuService {
|
|
|
1010
1048
|
|
|
1011
1049
|
/** Panel: authorize one user (global across authorized groups). */
|
|
1012
1050
|
async addAccessUser(userId: string, name?: string): Promise<AccessMutationResponse> {
|
|
1051
|
+
return this.addPanelUser(userId, name, id => this.accessStore.hasUser(id), record => this.accessStore.addUser(record))
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/** Panel: revoke one user. */
|
|
1055
|
+
async removeAccessUser(userId: string): Promise<AccessMutationResponse> {
|
|
1056
|
+
return this.removePanelUser(userId, id => this.accessStore.removeUser(id))
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/** Panel: add one user to the wiki whitelist. */
|
|
1060
|
+
async addWikiUser(userId: string, name?: string): Promise<AccessMutationResponse> {
|
|
1061
|
+
return this.addPanelUser(userId, name, id => this.accessStore.hasWikiUser(id), record => this.accessStore.addWikiUser(record))
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/** Panel: remove one user from the wiki whitelist. */
|
|
1065
|
+
async removeWikiUser(userId: string): Promise<AccessMutationResponse> {
|
|
1066
|
+
return this.removePanelUser(userId, id => this.accessStore.removeWikiUser(id))
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
private async addPanelUser(
|
|
1070
|
+
userId: string,
|
|
1071
|
+
name: string | undefined,
|
|
1072
|
+
has: (id: string) => boolean,
|
|
1073
|
+
add: (record: AccessUserRecord) => Promise<AccessUserRecord>,
|
|
1074
|
+
): Promise<AccessMutationResponse> {
|
|
1013
1075
|
const id = this.cleanAccessUserId(userId)
|
|
1014
1076
|
if (id === undefined) return { ok: false, error: 'user_id 不合法(只能包含字母、数字、_ 和 -,不是 ou_ 开头的 open_id)' }
|
|
1015
1077
|
const admin = this.effectiveAdminUserId()
|
|
1016
1078
|
if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 user_id' }
|
|
1017
1079
|
try {
|
|
1018
|
-
if (!
|
|
1019
|
-
await
|
|
1080
|
+
if (!has(id)) {
|
|
1081
|
+
await add({
|
|
1020
1082
|
userId: id,
|
|
1021
1083
|
...(name === undefined || name.trim() === '' ? {} : { name: name.trim() }),
|
|
1022
1084
|
source: 'panel',
|
|
@@ -1030,12 +1092,11 @@ export class FeishuService {
|
|
|
1030
1092
|
}
|
|
1031
1093
|
}
|
|
1032
1094
|
|
|
1033
|
-
|
|
1034
|
-
async removeAccessUser(userId: string): Promise<AccessMutationResponse> {
|
|
1095
|
+
private async removePanelUser(userId: string, remove: (id: string) => Promise<boolean>): Promise<AccessMutationResponse> {
|
|
1035
1096
|
const id = this.cleanAccessUserId(userId)
|
|
1036
1097
|
if (id === undefined) return { ok: false, error: 'user_id 不合法' }
|
|
1037
1098
|
try {
|
|
1038
|
-
await
|
|
1099
|
+
await remove(id)
|
|
1039
1100
|
return { ok: true, access: this.accessSnapshot() }
|
|
1040
1101
|
} catch (error) {
|
|
1041
1102
|
return { ok: false, error: `撤销授权失败:${messageOf(error)}` }
|
|
@@ -1250,10 +1311,10 @@ export class FeishuService {
|
|
|
1250
1311
|
health(): HealthSnapshot { return this.healthSnapshot() }
|
|
1251
1312
|
|
|
1252
1313
|
/**
|
|
1253
|
-
* Credential facts for the
|
|
1314
|
+
* Credential facts for the panel's credential refs. Values are never returned:
|
|
1254
1315
|
* `describe()` reports presence/source/writability only.
|
|
1255
1316
|
*/
|
|
1256
|
-
async credentialStates(): Promise<
|
|
1317
|
+
async credentialStates(): Promise<Record<CredentialField, CredentialInfo>> {
|
|
1257
1318
|
const provider = this.credentialProvider()
|
|
1258
1319
|
const read = async (ref: string): Promise<CredentialInfo> => {
|
|
1259
1320
|
try {
|
|
@@ -1274,8 +1335,10 @@ export class FeishuService {
|
|
|
1274
1335
|
return { configured: false, writable: false }
|
|
1275
1336
|
}
|
|
1276
1337
|
}
|
|
1277
|
-
const [appId, appSecret] = await Promise.all([
|
|
1278
|
-
|
|
1338
|
+
const [appId, appSecret, analyzeToken] = await Promise.all([
|
|
1339
|
+
read(this.config.appId), read(this.config.appSecret), read(this.config.analyzeToken),
|
|
1340
|
+
])
|
|
1341
|
+
return { appId, appSecret, analyzeToken }
|
|
1279
1342
|
}
|
|
1280
1343
|
|
|
1281
1344
|
/** The host credentials seam, when mounted. */
|
|
@@ -1283,8 +1346,8 @@ export class FeishuService {
|
|
|
1283
1346
|
return this.ctx.get?.('credentials') as CredentialProvider | undefined
|
|
1284
1347
|
}
|
|
1285
1348
|
|
|
1286
|
-
/** Store one
|
|
1287
|
-
async setCredential(field:
|
|
1349
|
+
/** Store one credential value through the host seam; the value is never logged. */
|
|
1350
|
+
async setCredential(field: CredentialField, value: string): Promise<void> {
|
|
1288
1351
|
const provider = this.credentialProvider()
|
|
1289
1352
|
if (provider?.set === undefined) {
|
|
1290
1353
|
throw new Error('主机的凭据服务不可写(未挂载 credentials.set,或当前提供方只读)')
|
|
@@ -1298,8 +1361,8 @@ export class FeishuService {
|
|
|
1298
1361
|
await this.refreshReadiness()
|
|
1299
1362
|
}
|
|
1300
1363
|
|
|
1301
|
-
/** Remove the stored value of one
|
|
1302
|
-
async clearCredential(field:
|
|
1364
|
+
/** Remove the stored value of one credential reference. */
|
|
1365
|
+
async clearCredential(field: CredentialField): Promise<void> {
|
|
1303
1366
|
const provider = this.credentialProvider()
|
|
1304
1367
|
if (provider?.unset === undefined) {
|
|
1305
1368
|
throw new Error('主机的凭据服务不支持移除凭据')
|
|
@@ -1314,8 +1377,12 @@ export class FeishuService {
|
|
|
1314
1377
|
}
|
|
1315
1378
|
|
|
1316
1379
|
/** The overlay-independent ref name backing one editable credential slot. */
|
|
1317
|
-
private credentialRef(field:
|
|
1318
|
-
|
|
1380
|
+
private credentialRef(field: CredentialField): string {
|
|
1381
|
+
switch (field) {
|
|
1382
|
+
case 'appId': return this.config.appId
|
|
1383
|
+
case 'appSecret': return this.config.appSecret
|
|
1384
|
+
case 'analyzeToken': return this.config.analyzeToken
|
|
1385
|
+
}
|
|
1319
1386
|
}
|
|
1320
1387
|
|
|
1321
1388
|
/** Wire events and open the long connection. */
|
|
@@ -1417,6 +1484,7 @@ export class FeishuService {
|
|
|
1417
1484
|
questions.onCardAction(event)
|
|
1418
1485
|
this.wikiConsent.onCardAction(event)
|
|
1419
1486
|
this.onStopAction(event)
|
|
1487
|
+
this.onSteerAction(event)
|
|
1420
1488
|
void this.onPickAction(event).catch((error: unknown) => {
|
|
1421
1489
|
this.ctx.logger?.warn?.('dsh-channels: picker card action failed: %s', String(error))
|
|
1422
1490
|
})
|
|
@@ -1449,6 +1517,9 @@ export class FeishuService {
|
|
|
1449
1517
|
// Background: the results only feed the panel and the log.
|
|
1450
1518
|
void this.runScopeCheck(channel, appId, ownerLookup)
|
|
1451
1519
|
if (this.config.slashCommandPanel) void this.runSlashCommandSync(channel)
|
|
1520
|
+
// Before the listener opens, so every `running` record left is a previous instance's.
|
|
1521
|
+
await this.analyzeLauncher.markInterrupted()
|
|
1522
|
+
await this.openAnalyzeListener()
|
|
1452
1523
|
// Repair cards a previous instance left mid-flight (best-effort, background).
|
|
1453
1524
|
void this.repair.repairAll().catch((error: unknown) => {
|
|
1454
1525
|
this.ctx.logger?.warn?.('dsh-channels: inflight repair failed: %s', String(error))
|
|
@@ -1506,12 +1577,38 @@ export class FeishuService {
|
|
|
1506
1577
|
)
|
|
1507
1578
|
}
|
|
1508
1579
|
|
|
1580
|
+
/**
|
|
1581
|
+
* Bind the analyze listener when `analyzeListen` is set. A bind failure is
|
|
1582
|
+
* logged and folded into the health snapshot (panel reasons, `/health`); the Feishu connection stays up.
|
|
1583
|
+
*/
|
|
1584
|
+
private async openAnalyzeListener(): Promise<void> {
|
|
1585
|
+
this.analyzeListenError = undefined
|
|
1586
|
+
const listen = this.config.analyzeListen
|
|
1587
|
+
if (listen === undefined) return
|
|
1588
|
+
try {
|
|
1589
|
+
this.analyzeServer = await startAnalyzeServer(
|
|
1590
|
+
listen,
|
|
1591
|
+
this.analyzeLauncher,
|
|
1592
|
+
(message, ...args) => this.ctx.logger?.warn?.(message, ...args),
|
|
1593
|
+
)
|
|
1594
|
+
console.info('[dsh-channels] 日志分析接口已监听', listen)
|
|
1595
|
+
} catch (error) {
|
|
1596
|
+
this.analyzeListenError = messageOf(error)
|
|
1597
|
+
this.ctx.logger?.error?.('dsh-channels: analyze listener on %s failed: %s', listen, this.analyzeListenError)
|
|
1598
|
+
console.error('[dsh-channels] 日志分析接口监听失败:', listen, error)
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1509
1602
|
/** Tear down: close the connection and dispose all live agents. */
|
|
1510
1603
|
async shutdown(): Promise<void> {
|
|
1511
1604
|
// Close admission first. In-flight Host commands receive cancellation
|
|
1512
1605
|
// before their Agent/channel/storage dependencies are disposed, while
|
|
1513
1606
|
// already queued route operations observe `started === false` and retire.
|
|
1514
1607
|
this.started = false
|
|
1608
|
+
const analyzeServer = this.analyzeServer
|
|
1609
|
+
this.analyzeServer = undefined
|
|
1610
|
+
await analyzeServer?.close()
|
|
1611
|
+
await this.analyzeLauncher.drain()
|
|
1515
1612
|
for (const controller of this.commandControllers) controller.abort()
|
|
1516
1613
|
await Promise.allSettled([...this.commandOperations])
|
|
1517
1614
|
this.commandControllers.clear()
|
|
@@ -1638,7 +1735,7 @@ export class FeishuService {
|
|
|
1638
1735
|
return
|
|
1639
1736
|
}
|
|
1640
1737
|
|
|
1641
|
-
if (msg
|
|
1738
|
+
if (!this.accessController.ownsRoute(msg, route)) {
|
|
1642
1739
|
await this.delivery.commandCard(msg, {
|
|
1643
1740
|
kind: 'forbidden', command: parsed.name, text: '无权操作此会话。',
|
|
1644
1741
|
})
|
|
@@ -1648,7 +1745,7 @@ export class FeishuService {
|
|
|
1648
1745
|
const operation = this.routeOperations.run(route.key, async () => {
|
|
1649
1746
|
if (!this.started) return
|
|
1650
1747
|
const current = this.routes.get(route.key) ?? route
|
|
1651
|
-
if (msg
|
|
1748
|
+
if (!this.accessController.ownsRoute(msg, current)) {
|
|
1652
1749
|
await this.delivery.commandCard(msg, {
|
|
1653
1750
|
kind: 'forbidden', command: parsed.name, text: '无权操作此会话。',
|
|
1654
1751
|
})
|
|
@@ -1985,13 +2082,44 @@ export class FeishuService {
|
|
|
1985
2082
|
if (sessionId === undefined) return
|
|
1986
2083
|
const route = this.routes.findBySession(sessionId)
|
|
1987
2084
|
if (route === undefined) return
|
|
1988
|
-
if (event.operator.userId
|
|
2085
|
+
if (!this.accessController.mayActOnRoute(event.operator.userId, event.chatId, route)) {
|
|
1989
2086
|
this.streamer?.markStopDenied(sessionId)
|
|
1990
2087
|
return
|
|
1991
2088
|
}
|
|
1992
2089
|
this.cancelTurn(sessionId)
|
|
1993
2090
|
}
|
|
1994
2091
|
|
|
2092
|
+
/**
|
|
2093
|
+
* Queued-card steer button: a user who may act on the route (the owner, or
|
|
2094
|
+
* any admitted member of a group-shared route), clicking inside the
|
|
2095
|
+
* session's own chat, moves the queued message out of the next-turn queue
|
|
2096
|
+
* and into the running turn's next step; another operator gets the
|
|
2097
|
+
* owner-only notice instead. A message the driver already claimed (its turn
|
|
2098
|
+
* started) or a turn that is stopping leaves the card to its own turn.
|
|
2099
|
+
*/
|
|
2100
|
+
private onSteerAction(event: CardActionEvent): void {
|
|
2101
|
+
if (!this.started) return
|
|
2102
|
+
if ((event.action.value as { decision?: unknown } | undefined)?.decision !== STEER_ACTION) return
|
|
2103
|
+
const target = this.streamer?.steerTargetOf(event.messageId)
|
|
2104
|
+
if (target === undefined) return
|
|
2105
|
+
const route = this.routes.findBySession(target.sessionId)
|
|
2106
|
+
if (route === undefined) return
|
|
2107
|
+
if (!this.accessController.mayActOnRoute(event.operator.userId, event.chatId, route)) {
|
|
2108
|
+
this.streamer?.markQueuedDenied(event.messageId)
|
|
2109
|
+
return
|
|
2110
|
+
}
|
|
2111
|
+
const agent = this.sessions.get(target.sessionId)?.agent
|
|
2112
|
+
?? this.ctx.agents.get(target.sessionId as SessionId)
|
|
2113
|
+
if (agent?.status !== 'running') return
|
|
2114
|
+
const merged = this.streamer?.mergeQueued(event.messageId, (_sessionId, message) => {
|
|
2115
|
+
if (!agent.inbox.remove(message.id)) return false
|
|
2116
|
+
agent.steer(message)
|
|
2117
|
+
return true
|
|
2118
|
+
}) === true
|
|
2119
|
+
// The queued card no longer owns a turn: retire its restart-repair snapshot.
|
|
2120
|
+
if (merged) void this.repair.clear(event.messageId)
|
|
2121
|
+
}
|
|
2122
|
+
|
|
1995
2123
|
/**
|
|
1996
2124
|
* Picker-card click: run the chosen `/<command> <arg>` exactly as if the
|
|
1997
2125
|
* operator had typed it under the picker card, so access admission, route
|
|
@@ -2006,7 +2134,8 @@ export class FeishuService {
|
|
|
2006
2134
|
if (action === undefined) return
|
|
2007
2135
|
const route = this.routes.findBySession(action.sessionId)
|
|
2008
2136
|
const userId = event.operator.userId
|
|
2009
|
-
if (route === undefined ||
|
|
2137
|
+
if (route === undefined || userId === undefined
|
|
2138
|
+
|| !this.accessController.mayActOnRoute(userId, event.chatId, route)) return
|
|
2010
2139
|
const line = `/${action.command} ${action.arg}`
|
|
2011
2140
|
await this.delivery.replacingCard(event.messageId, () => this.handleSlashMessage({
|
|
2012
2141
|
messageId: event.messageId,
|
|
@@ -2437,6 +2566,7 @@ export class FeishuService {
|
|
|
2437
2566
|
routeCount: this.routes.size(),
|
|
2438
2567
|
liveAgentCount: this.sessions.liveCount(),
|
|
2439
2568
|
inflightTurnCount,
|
|
2569
|
+
...(this.analyzeListenError === undefined ? {} : { analyzeListenError: this.analyzeListenError }),
|
|
2440
2570
|
})
|
|
2441
2571
|
}
|
|
2442
2572
|
|
|
@@ -2566,6 +2696,9 @@ export class FeishuService {
|
|
|
2566
2696
|
cardMessageId: record.cardMessageId,
|
|
2567
2697
|
inThread: record.kind === 'thread',
|
|
2568
2698
|
senderUserId: record.senderUserId,
|
|
2699
|
+
...(record.shared === true
|
|
2700
|
+
? { mayDecide: (userId: string) => this.accessController.mayActOnRoute(userId, record.chatId, record) }
|
|
2701
|
+
: {}),
|
|
2569
2702
|
}
|
|
2570
2703
|
}
|
|
2571
2704
|
|