@modesto_ma/dsh-channels 0.5.0-beta.5 → 0.5.0-beta.7
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 +31 -13
- package/docs/attachment-pipeline-v2.md +1 -1
- package/docs/feishu-onboarding.md +5 -4
- package/lib/client.js +605 -55
- package/lib/client.js.map +1 -1
- package/lib/index.js +379 -21
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/access/cards.ts +5 -1
- package/src/access/controller.ts +14 -6
- package/src/access/policy.ts +7 -2
- package/src/access/requests.ts +1 -0
- package/src/client/api.ts +45 -2
- package/src/client/avatar.tsx +36 -0
- package/src/client/icon.tsx +25 -0
- package/src/client/index.ts +78 -23
- package/src/client/origin-cache.ts +45 -0
- package/src/client/origin.tsx +201 -0
- package/src/client/panel/access-tab.tsx +41 -13
- package/src/client/panel/index.tsx +23 -9
- package/src/client/panel/profiles.ts +73 -0
- package/src/client/panel/whitelist.tsx +17 -11
- package/src/client/panel/wiki-tab.tsx +10 -2
- package/src/client/styles.ts +26 -3
- package/src/config.ts +9 -2
- package/src/domain.ts +44 -2
- package/src/feishu/scopes.ts +2 -1
- package/src/feishu-channel.ts +87 -0
- package/src/routing/inbound.ts +13 -5
- package/src/service.ts +147 -3
- package/src/session/routes.ts +45 -4
- package/src/settings/contract.ts +70 -1
- package/src/settings/routes.ts +47 -0
- package/src/settings/surface.ts +30 -1
package/src/routing/inbound.ts
CHANGED
|
@@ -47,7 +47,13 @@ import { ensureWorkspace, stagingPathFor, topicTokenFor } from '../workspaces.ts
|
|
|
47
47
|
|
|
48
48
|
/** The command plane the inbound router delegates admission-time work to. */
|
|
49
49
|
export interface CommandPlanePort {
|
|
50
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Whether a `/`-prefixed line names a channel command (including aliases).
|
|
52
|
+
* Other slash lines are ordinary prompts, so DSH can expand user-invocable
|
|
53
|
+
* skills such as `/guanxiang`.
|
|
54
|
+
*/
|
|
55
|
+
owns(line: string): boolean
|
|
56
|
+
/** Slash-command admission + dispatch for a line {@link owns} accepted. */
|
|
51
57
|
handleSlash(msg: NormalizedMessage, line: string): Promise<void>
|
|
52
58
|
}
|
|
53
59
|
|
|
@@ -386,10 +392,12 @@ export class InboundRouter {
|
|
|
386
392
|
this.ctx.logger?.warn?.('dsh-channels: recording processed message %s failed: %s', msg.messageId, String(error))
|
|
387
393
|
}
|
|
388
394
|
|
|
389
|
-
//
|
|
390
|
-
// mention, workspace, Session, card-stream, and model side
|
|
391
|
-
// command plane applies its own administrator/bootstrap rules.
|
|
392
|
-
|
|
395
|
+
// Channel-command admission stays behind message-id dedup but ahead of
|
|
396
|
+
// every route, mention, workspace, Session, card-stream, and model side
|
|
397
|
+
// effect. The command plane applies its own administrator/bootstrap rules.
|
|
398
|
+
// A slash line that names no channel command continues as an ordinary
|
|
399
|
+
// prompt through the channel-plane admission below.
|
|
400
|
+
if (content.startsWith('/') && this.deps.commands.owns(content)) {
|
|
393
401
|
await this.deps.commands.handleSlash(msg, content)
|
|
394
402
|
return
|
|
395
403
|
}
|
package/src/service.ts
CHANGED
|
@@ -53,7 +53,7 @@ import { FEISHU_USER_ID_PATTERN, feishuDomain, sessionDirOf } from './domain.ts'
|
|
|
53
53
|
import type {
|
|
54
54
|
AccessGroupRecord, AccessRequestRecord, AccessUserRecord,
|
|
55
55
|
AnalyzeRequestRecord, ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot,
|
|
56
|
-
ProcessedRecord, RawDomainHandle, RouteRecord,
|
|
56
|
+
ProcessedRecord, RawDomainHandle, RouteRecord, SessionOriginRecord,
|
|
57
57
|
} from './domain.ts'
|
|
58
58
|
import { RouteStore } from './session/routes.ts'
|
|
59
59
|
import { AnalyzeLauncher } from './analyze/launcher.ts'
|
|
@@ -97,7 +97,9 @@ import { ACTIVITY_BUILD_MARKER, TurnStreamer } from './streamer.ts'
|
|
|
97
97
|
import type { ProjectedStats, StreamerHooks } from './streamer.ts'
|
|
98
98
|
import type { FeishuConfig } from './config.ts'
|
|
99
99
|
import type {
|
|
100
|
-
AccessMutationResponse, AccessRequestWire, AccessUserWire, AccessWire, CredentialField,
|
|
100
|
+
AccessMutationResponse, AccessRequestWire, AccessUserWire, AccessWire, CredentialField, FeishuProfilesWire, FeishuProfileWire,
|
|
101
|
+
ScopeCheckWire, SessionOriginWire,
|
|
102
|
+
SlashCommandSyncWire,
|
|
101
103
|
} from './settings/contract.ts'
|
|
102
104
|
import { checkScopes } from './feishu/scopes.ts'
|
|
103
105
|
import { desiredSlashCommands, syncSlashCommands } from './feishu/slash-commands.ts'
|
|
@@ -141,6 +143,21 @@ interface ModelAdmission {
|
|
|
141
143
|
controller: RequestPolicyController
|
|
142
144
|
}
|
|
143
145
|
|
|
146
|
+
/**
|
|
147
|
+
* How long one resolved user or chat profile is reused. Short enough that
|
|
148
|
+
* every page entry shows current Feishu data; long enough that the session
|
|
149
|
+
* header and its sidebar tab opening together cost one lookup.
|
|
150
|
+
*/
|
|
151
|
+
const PROFILE_TTL_MS = 30 * 1000
|
|
152
|
+
|
|
153
|
+
/** The Feishu `code=<n> <msg>` of a failed API call, else the error text. */
|
|
154
|
+
function feishuReason(error: unknown): string {
|
|
155
|
+
return error instanceof FeishuApiError ? error.detail : messageOf(error)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Feishu `chat_id`s the profile lookup accepts. */
|
|
159
|
+
const FEISHU_CHAT_ID_PATTERN = /^oc_[A-Za-z0-9_-]+$/u
|
|
160
|
+
|
|
144
161
|
/** Wrap the host's storage facility open behind a stable local type. */
|
|
145
162
|
async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
|
|
146
163
|
const domain = await ctx.storageDomain.open(feishuDomain) as unknown as RawDomainHandle
|
|
@@ -153,6 +170,7 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
|
|
|
153
170
|
const accessRequestsRaw = domain.table('access_requests')
|
|
154
171
|
const accessWikiUsersRaw = domain.table('access_wiki_users')
|
|
155
172
|
const analyzeRequestsRaw = domain.table('analyze_requests')
|
|
173
|
+
const sessionOriginsRaw = domain.table('session_origins')
|
|
156
174
|
return {
|
|
157
175
|
routes: {
|
|
158
176
|
get: key => table.get(key) as RouteRecord | undefined,
|
|
@@ -207,6 +225,10 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
|
|
|
207
225
|
put: (key, value) => analyzeRequestsRaw.put(key, value),
|
|
208
226
|
entries: () => analyzeRequestsRaw.entries() as IterableIterator<[string, AnalyzeRequestRecord]>,
|
|
209
227
|
},
|
|
228
|
+
sessionOrigins: {
|
|
229
|
+
get: key => sessionOriginsRaw.get(key) as SessionOriginRecord | undefined,
|
|
230
|
+
put: (key, value) => sessionOriginsRaw.put(key, value),
|
|
231
|
+
},
|
|
210
232
|
getGlobal: () => (domain.global.get() ?? {}) as FeishuDomainGlobal,
|
|
211
233
|
setGlobal: value => domain.global.set(value),
|
|
212
234
|
close: () => domain.close(),
|
|
@@ -361,6 +383,8 @@ export class FeishuService {
|
|
|
361
383
|
* is unset. Cleared on shutdown so a reconnect looks it up again.
|
|
362
384
|
*/
|
|
363
385
|
private appOwner: AppOwnerState | undefined
|
|
386
|
+
/** Live-resolved user and chat profiles, by `user:`/`chat:` key; misses are kept as `{}`. */
|
|
387
|
+
private readonly profileCache = new Map<string, { profile: FeishuProfileWire; at: number }>()
|
|
364
388
|
/** Post-connect scope self-check; cleared on shutdown. */
|
|
365
389
|
private scopeCheckState: ScopeCheckWire | undefined
|
|
366
390
|
/** Post-connect slash-command panel sync; cleared on shutdown. */
|
|
@@ -564,7 +588,13 @@ export class FeishuService {
|
|
|
564
588
|
streamer: () => this.streamer,
|
|
565
589
|
questionAnswerer: () => this.questionAnswerer,
|
|
566
590
|
approvalAnswerer: () => this.approvalAnswerer,
|
|
567
|
-
commands: {
|
|
591
|
+
commands: {
|
|
592
|
+
owns: (line) => {
|
|
593
|
+
const parsed = parseSlashCommand(line)
|
|
594
|
+
return parsed !== undefined && commandSpec(parsed.name) !== undefined
|
|
595
|
+
},
|
|
596
|
+
handleSlash: (msg, line) => this.handleSlashMessage(msg, line),
|
|
597
|
+
},
|
|
568
598
|
})
|
|
569
599
|
this.analyzeLauncher = new AnalyzeLauncher({
|
|
570
600
|
ctx: this.ctx,
|
|
@@ -1103,6 +1133,119 @@ export class FeishuService {
|
|
|
1103
1133
|
}
|
|
1104
1134
|
}
|
|
1105
1135
|
|
|
1136
|
+
/** Panel: Feishu origin of one Session, or null when Feishu never routed it. */
|
|
1137
|
+
async sessionOrigin(sessionId: string): Promise<SessionOriginWire | null> {
|
|
1138
|
+
const origin = this.routes.origin(sessionId)
|
|
1139
|
+
if (origin === undefined) return null
|
|
1140
|
+
const route = this.routes.findBySession(sessionId)
|
|
1141
|
+
const bound = route !== undefined
|
|
1142
|
+
const group = origin.chatType === 'group'
|
|
1143
|
+
const profiles = await this.feishuProfiles([origin.senderUserId], group ? [origin.chatId] : [])
|
|
1144
|
+
const chat = profiles.chats[origin.chatId] ?? {}
|
|
1145
|
+
const sender = profiles.users[origin.senderUserId] ?? {}
|
|
1146
|
+
const senderName = sender.name ?? (group ? await this.memberName(origin.senderUserId, origin.chatId) : undefined)
|
|
1147
|
+
return {
|
|
1148
|
+
sessionId,
|
|
1149
|
+
chatType: origin.chatType,
|
|
1150
|
+
kind: origin.kind,
|
|
1151
|
+
chatId: origin.chatId,
|
|
1152
|
+
...(chat.name === undefined ? {} : { chatName: chat.name }),
|
|
1153
|
+
...(chat.avatarUrl === undefined ? {} : { chatAvatarUrl: chat.avatarUrl }),
|
|
1154
|
+
senderUserId: origin.senderUserId,
|
|
1155
|
+
...(senderName === undefined ? {} : { senderName }),
|
|
1156
|
+
...(sender.avatarUrl === undefined ? {} : { senderAvatarUrl: sender.avatarUrl }),
|
|
1157
|
+
shared: origin.shared === true,
|
|
1158
|
+
createdAt: origin.createdAt,
|
|
1159
|
+
bound,
|
|
1160
|
+
...(bound ? { lastActivityAt: route.lastActivityAt } : {}),
|
|
1161
|
+
chatLink: `https://applink.feishu.cn/client/chat/open?openChatId=${encodeURIComponent(origin.chatId)}`,
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* Live Feishu names and avatars for the panel and the session-origin view,
|
|
1167
|
+
* through a {@link PROFILE_TTL_MS} cache. Ids that are not Feishu
|
|
1168
|
+
* `user_id`s / `chat_id`s are skipped; a disconnected channel and failed
|
|
1169
|
+
* lookups leave ids absent, and failures are reported in `warning` and not
|
|
1170
|
+
* cached.
|
|
1171
|
+
* @param userIds - user `user_id`s to resolve.
|
|
1172
|
+
* @param chatIds - chat `chat_id`s to resolve.
|
|
1173
|
+
* @returns the resolved profiles by id.
|
|
1174
|
+
*/
|
|
1175
|
+
async feishuProfiles(userIds: readonly string[], chatIds: readonly string[]): Promise<FeishuProfilesWire> {
|
|
1176
|
+
const result: FeishuProfilesWire = { users: {}, chats: {} }
|
|
1177
|
+
const warnings: string[] = []
|
|
1178
|
+
const now = Date.now()
|
|
1179
|
+
for (const [key, cached] of this.profileCache) {
|
|
1180
|
+
if (now - cached.at >= PROFILE_TTL_MS) this.profileCache.delete(key)
|
|
1181
|
+
}
|
|
1182
|
+
const fresh = (key: string): FeishuProfileWire | undefined => this.profileCache.get(key)?.profile
|
|
1183
|
+
const keep = (target: Record<string, FeishuProfileWire>, id: string, profile: FeishuProfileWire): void => {
|
|
1184
|
+
if (profile.name !== undefined || profile.avatarUrl !== undefined) target[id] = profile
|
|
1185
|
+
}
|
|
1186
|
+
const users = [...new Set(userIds)].filter(id => FEISHU_USER_ID_PATTERN.test(id))
|
|
1187
|
+
const chats = [...new Set(chatIds)].filter(id => FEISHU_CHAT_ID_PATTERN.test(id))
|
|
1188
|
+
const missingUsers: string[] = []
|
|
1189
|
+
for (const id of users) {
|
|
1190
|
+
const cached = fresh(`user:${id}`)
|
|
1191
|
+
if (cached === undefined) missingUsers.push(id)
|
|
1192
|
+
else keep(result.users, id, cached)
|
|
1193
|
+
}
|
|
1194
|
+
const missingChats: string[] = []
|
|
1195
|
+
for (const id of chats) {
|
|
1196
|
+
const cached = fresh(`chat:${id}`)
|
|
1197
|
+
if (cached === undefined) missingChats.push(id)
|
|
1198
|
+
else keep(result.chats, id, cached)
|
|
1199
|
+
}
|
|
1200
|
+
const channel = this.channel
|
|
1201
|
+
if (channel === undefined) return result
|
|
1202
|
+
const lookups: Array<Promise<void>> = []
|
|
1203
|
+
if (missingUsers.length > 0 && channel.userProfiles !== undefined) {
|
|
1204
|
+
lookups.push(channel.userProfiles(missingUsers).then(
|
|
1205
|
+
(found) => {
|
|
1206
|
+
for (const id of missingUsers) {
|
|
1207
|
+
const profile = found.get(id) ?? {}
|
|
1208
|
+
this.profileCache.set(`user:${id}`, { profile, at: now })
|
|
1209
|
+
keep(result.users, id, profile)
|
|
1210
|
+
}
|
|
1211
|
+
},
|
|
1212
|
+
(error: unknown) => { warnings.push(`读取用户资料失败:${feishuReason(error)}`) },
|
|
1213
|
+
))
|
|
1214
|
+
}
|
|
1215
|
+
const chatProfile = channel.chatProfile?.bind(channel)
|
|
1216
|
+
if (chatProfile !== undefined) {
|
|
1217
|
+
let chatError: string | undefined
|
|
1218
|
+
for (const id of missingChats) {
|
|
1219
|
+
lookups.push(chatProfile(id).then(
|
|
1220
|
+
(profile) => {
|
|
1221
|
+
this.profileCache.set(`chat:${id}`, { profile, at: now })
|
|
1222
|
+
keep(result.chats, id, profile)
|
|
1223
|
+
},
|
|
1224
|
+
(error: unknown) => {
|
|
1225
|
+
if (chatError !== undefined) return
|
|
1226
|
+
chatError = `读取群信息失败:${feishuReason(error)}`
|
|
1227
|
+
warnings.push(chatError)
|
|
1228
|
+
},
|
|
1229
|
+
))
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
await Promise.all(lookups)
|
|
1233
|
+
if (warnings.length > 0) {
|
|
1234
|
+
result.warning = warnings.join(';')
|
|
1235
|
+
this.ctx.logger?.debug?.('dsh-channels: Feishu profile lookup: %s', result.warning)
|
|
1236
|
+
}
|
|
1237
|
+
return result
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/** Chat-member-list name for a user the contact profile cannot name. */
|
|
1241
|
+
private async memberName(userId: string, chatId: string): Promise<string | undefined> {
|
|
1242
|
+
const cached = this.profileCache.get(`member:${chatId}:${userId}`)
|
|
1243
|
+
if (cached !== undefined && Date.now() - cached.at < PROFILE_TTL_MS) return cached.profile.name
|
|
1244
|
+
const name = await this.channel?.memberName?.(userId, chatId)
|
|
1245
|
+
this.profileCache.set(`member:${chatId}:${userId}`, { profile: name === undefined ? {} : { name }, at: Date.now() })
|
|
1246
|
+
return name
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1106
1249
|
/** Panel: decide one pending authorization request (same path as the card). */
|
|
1107
1250
|
async resolveAccessRequest(token: string, decision: 'allow' | 'deny'): Promise<AccessMutationResponse> {
|
|
1108
1251
|
const requests = this.accessRequests
|
|
@@ -1779,6 +1922,7 @@ export class FeishuService {
|
|
|
1779
1922
|
await this.delivery.standaloneReply(msg, standaloneInfoCard('**🔐 访问授权状态**', [
|
|
1780
1923
|
`**准入模式**:\`${this.config.accessMode}\``,
|
|
1781
1924
|
`**管理员**:${this.adminStatusLine()}`,
|
|
1925
|
+
`**私聊**:${this.config.dmAllowWhitelist ? '管理员与白名单用户' : '仅管理员'}`,
|
|
1782
1926
|
...(msg.chatType === 'group'
|
|
1783
1927
|
? [`**本群**:${this.accessStore.hasGroup(msg.chatId) ? '已授权' : '未授权'}(\`${msg.chatId}\`)`]
|
|
1784
1928
|
: []),
|
package/src/session/routes.ts
CHANGED
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
* Durable route store: thread/chat keys → bound DSH session records.
|
|
3
3
|
*
|
|
4
4
|
* Owns the hot cache over the `channels_routes` domain tables, the TTL
|
|
5
|
-
* retirement that leaves a persistent tombstone,
|
|
5
|
+
* retirement that leaves a persistent tombstone, session → route lookup, and
|
|
6
|
+
* the durable session → Feishu origin records every route write feeds.
|
|
6
7
|
* The domain handle is late-bound (`domain()`), because the service opens it
|
|
7
8
|
* during `start()` while the store is constructed earlier.
|
|
8
9
|
*
|
|
9
10
|
* @module dsh-channels/session/routes
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
|
-
import type { ExpiredRouteRecord, FeishuDomainHandle, RouteRecord } from '../domain.ts'
|
|
13
|
-
import { normalizeRouteRecord } from '../domain.ts'
|
|
13
|
+
import type { ExpiredRouteRecord, FeishuDomainHandle, RouteRecord, SessionOriginRecord } from '../domain.ts'
|
|
14
|
+
import { normalizeRouteRecord, normalizeSessionHistory } from '../domain.ts'
|
|
14
15
|
|
|
15
16
|
export interface RouteStoreDeps {
|
|
16
17
|
/** The opened domain handle, or undefined before `start()` / after shutdown. */
|
|
@@ -44,6 +45,11 @@ export class RouteStore {
|
|
|
44
45
|
this.expired.set(key, record)
|
|
45
46
|
this.active.delete(key)
|
|
46
47
|
}
|
|
48
|
+
// Routes written before origins existed get theirs now; a failure only
|
|
49
|
+
// leaves those Sessions without a Web origin view.
|
|
50
|
+
for (const record of this.active.values()) {
|
|
51
|
+
void this.recordOrigins(record)
|
|
52
|
+
}
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
/** One active route by key (cache-first, normalized). */
|
|
@@ -70,7 +76,14 @@ export class RouteStore {
|
|
|
70
76
|
/** Persist one active route and refresh the cache. */
|
|
71
77
|
async put(key: string, record: RouteRecord): Promise<void> {
|
|
72
78
|
await this.deps.domain()?.routes.put(key, record)
|
|
73
|
-
|
|
79
|
+
const normalized = normalizeRouteRecord(record)
|
|
80
|
+
this.active.set(key, normalized)
|
|
81
|
+
await this.recordOrigins(normalized)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Feishu origin of one Session any route ever named, retired routes included. */
|
|
85
|
+
origin(sessionId: string): SessionOriginRecord | undefined {
|
|
86
|
+
return this.deps.domain()?.sessionOrigins.get(sessionId)
|
|
74
87
|
}
|
|
75
88
|
|
|
76
89
|
/** Delete one active route from the medium and the cache. */
|
|
@@ -104,6 +117,34 @@ export class RouteStore {
|
|
|
104
117
|
this.expired.clear()
|
|
105
118
|
}
|
|
106
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Write an origin for every Session in the route's history that has none.
|
|
122
|
+
* Existing origins are never rewritten: the first route naming a Session
|
|
123
|
+
* is where it started. Failures are logged and never fail the route write.
|
|
124
|
+
*/
|
|
125
|
+
private async recordOrigins(record: RouteRecord): Promise<void> {
|
|
126
|
+
const table = this.deps.domain()?.sessionOrigins
|
|
127
|
+
if (table === undefined) return
|
|
128
|
+
for (const entry of normalizeSessionHistory(record)) {
|
|
129
|
+
if (table.get(entry.sessionId) !== undefined) continue
|
|
130
|
+
const origin: SessionOriginRecord = {
|
|
131
|
+
key: entry.sessionId,
|
|
132
|
+
routeKey: record.key,
|
|
133
|
+
kind: record.kind,
|
|
134
|
+
chatId: record.chatId,
|
|
135
|
+
chatType: record.chatType,
|
|
136
|
+
senderUserId: record.senderUserId,
|
|
137
|
+
createdAt: entry.createdAt,
|
|
138
|
+
...(record.shared === true ? { shared: true as const } : {}),
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
await table.put(entry.sessionId, origin)
|
|
142
|
+
} catch (error) {
|
|
143
|
+
this.deps.warn('dsh-channels: recording the origin of session %s failed: %s', entry.sessionId, String(error))
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
107
148
|
/** Whether one active route has crossed the configured inactivity deadline. */
|
|
108
149
|
private hasExpired(record: RouteRecord, now: number): boolean {
|
|
109
150
|
const ttlMs = this.deps.ttlMs()
|
package/src/settings/contract.ts
CHANGED
|
@@ -38,6 +38,71 @@ export const ACCESS_REQUESTS_ROUTE = '/dsh-channels/access/requests'
|
|
|
38
38
|
* [`EditableFieldWire.browse`],保留手写输入。
|
|
39
39
|
*/
|
|
40
40
|
export const PICK_DIRECTORY_ROUTE = '/dsh-channels/pick-directory'
|
|
41
|
+
/** 会话的飞书来源(GET/HEAD,`?sessionId=`):对话右侧栏「飞书来源」标签与会话头部入口使用。 */
|
|
42
|
+
export const SESSION_ORIGIN_ROUTE = '/dsh-channels/session-origin'
|
|
43
|
+
|
|
44
|
+
/** Where one Feishu-routed Session came from; names are resolved live and may be absent. */
|
|
45
|
+
export interface SessionOriginWire {
|
|
46
|
+
sessionId: string
|
|
47
|
+
/** `p2p` = 私聊,`group` = 群聊。 */
|
|
48
|
+
chatType: 'p2p' | 'group'
|
|
49
|
+
/** `thread` = 话题内会话,`chat` = 整个聊天一个会话(话题不可用时的降级)。 */
|
|
50
|
+
kind: 'thread' | 'chat'
|
|
51
|
+
chatId: string
|
|
52
|
+
/** 群名(群聊且能读取群信息时)。 */
|
|
53
|
+
chatName?: string
|
|
54
|
+
/** 群头像 URL(群聊且能读取群信息时)。 */
|
|
55
|
+
chatAvatarUrl?: string
|
|
56
|
+
/** 发起人 user_id(日志平台分析话题为请求人)。 */
|
|
57
|
+
senderUserId: string
|
|
58
|
+
/** 发起人姓名(通讯录或群成员列表能查到时)。 */
|
|
59
|
+
senderName?: string
|
|
60
|
+
/** 发起人头像 URL(通讯录能查到时)。 */
|
|
61
|
+
senderAvatarUrl?: string
|
|
62
|
+
/** 日志平台分析话题:群内成员都可以继续。 */
|
|
63
|
+
shared: boolean
|
|
64
|
+
/** Epoch ms when the Session was bound to its Feishu route. */
|
|
65
|
+
createdAt: number
|
|
66
|
+
/** Whether the Feishu route still points at this Session (false after `/renew`, `/new` or route expiry). */
|
|
67
|
+
bound: boolean
|
|
68
|
+
/** Epoch ms of the route's last activity, while bound. */
|
|
69
|
+
lastActivityAt?: number
|
|
70
|
+
/** 在飞书客户端打开该聊天的链接。 */
|
|
71
|
+
chatLink: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `GET PROFILES_ROUTE?users=<user_id,...>&chats=<chat_id,...>`: live Feishu
|
|
76
|
+
* names and avatars for the users and chats the panel shows. At most
|
|
77
|
+
* {@link PROFILES_MAX_IDS} ids per list.
|
|
78
|
+
*/
|
|
79
|
+
export const PROFILES_ROUTE = '/dsh-channels/profiles'
|
|
80
|
+
|
|
81
|
+
/** Most `user_id`s, and separately most `chat_id`s, one profiles request may name. */
|
|
82
|
+
export const PROFILES_MAX_IDS = 200
|
|
83
|
+
|
|
84
|
+
/** Display name and avatar of one Feishu user or chat. */
|
|
85
|
+
export interface FeishuProfileWire {
|
|
86
|
+
name?: string
|
|
87
|
+
/** `https:` image URL on the Feishu CDN. */
|
|
88
|
+
avatarUrl?: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* `GET PROFILES_ROUTE` response. Ids Feishu could not resolve, and ids that
|
|
93
|
+
* are not valid Feishu ids, are absent; `warning` carries the Feishu reason
|
|
94
|
+
* when a lookup failed (typically a missing scope).
|
|
95
|
+
*/
|
|
96
|
+
export interface FeishuProfilesWire {
|
|
97
|
+
users: Record<string, FeishuProfileWire>
|
|
98
|
+
chats: Record<string, FeishuProfileWire>
|
|
99
|
+
warning?: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `GET SESSION_ORIGIN_ROUTE` response: `origin` is null for a Session not started from Feishu. */
|
|
103
|
+
export interface SessionOriginResponse {
|
|
104
|
+
origin: SessionOriginWire | null
|
|
105
|
+
}
|
|
41
106
|
|
|
42
107
|
/**
|
|
43
108
|
* 可由 Web 面板覆盖的配置项。顶层键对应 `src/config.ts` 的字段;`feishuTools.*`
|
|
@@ -99,6 +164,7 @@ export const EDITABLE_KEYS = [
|
|
|
99
164
|
'promptContextTimeoutMs',
|
|
100
165
|
// 准入与授权(白名单由「访问授权」tab 的列表管理)
|
|
101
166
|
'accessMode',
|
|
167
|
+
'dmAllowWhitelist',
|
|
102
168
|
'adminUserId',
|
|
103
169
|
'authRequestTtlDays',
|
|
104
170
|
// 知识库(顶层 `wikiRoots` 字符串列表;为空时不挂载任何知识库工具)
|
|
@@ -744,7 +810,10 @@ export const PANEL_COPY = {
|
|
|
744
810
|
// ---- access -------------------------------------------------------------
|
|
745
811
|
debugModeWarning: '调试模式已打开:应用可用范围内的任何人都能和机器人对话。用完请在「访问授权」关闭。',
|
|
746
812
|
accessAdminMissingWarning: '没有可用的管理员,所有对话都会被拒绝。请在下方填写管理员 user_id(私聊机器人发送 /userid 获取)。',
|
|
747
|
-
|
|
813
|
+
accessAdminCurrent: '当前管理员',
|
|
814
|
+
accessAdminSourceConfig: '已配置',
|
|
815
|
+
accessAdminSourceOwner: '应用所有者',
|
|
816
|
+
profilesWarning: '部分飞书名字和头像未能读取:{reason}',
|
|
748
817
|
accessNotLoaded: '飞书连接建立后加载授权列表',
|
|
749
818
|
accessRequestsTitle: '待处理申请',
|
|
750
819
|
accessGroupsTitle: '已授权群',
|
package/src/settings/routes.ts
CHANGED
|
@@ -27,7 +27,10 @@ import {
|
|
|
27
27
|
CREDENTIALS_ROUTE,
|
|
28
28
|
PANEL_ROUTE,
|
|
29
29
|
PICK_DIRECTORY_ROUTE,
|
|
30
|
+
PROFILES_MAX_IDS,
|
|
31
|
+
PROFILES_ROUTE,
|
|
30
32
|
RECONNECT_ROUTE,
|
|
33
|
+
SESSION_ORIGIN_ROUTE,
|
|
31
34
|
STATUS_ROUTE,
|
|
32
35
|
} from './contract.ts'
|
|
33
36
|
import type {
|
|
@@ -141,6 +144,12 @@ async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
|
141
144
|
}
|
|
142
145
|
}
|
|
143
146
|
|
|
147
|
+
/** Distinct non-empty entries of one comma-separated id query parameter. */
|
|
148
|
+
function idList(raw: string | null): string[] {
|
|
149
|
+
if (raw === null) return []
|
|
150
|
+
return [...new Set(raw.split(',').map(id => id.trim()).filter(id => id !== ''))]
|
|
151
|
+
}
|
|
152
|
+
|
|
144
153
|
/**
|
|
145
154
|
* Parse one JSON-object body for an access write. HttpErrors are answered
|
|
146
155
|
* here (415/413/400); `undefined` means "already responded, stop".
|
|
@@ -251,6 +260,44 @@ export function registerSettingsRoutes(
|
|
|
251
260
|
))
|
|
252
261
|
}),
|
|
253
262
|
}),
|
|
263
|
+
server.register({
|
|
264
|
+
kind: 'exact',
|
|
265
|
+
path: SESSION_ORIGIN_ROUTE,
|
|
266
|
+
handler: guarded(async (req, res) => {
|
|
267
|
+
if (!allowOnly(req, res, ['GET', 'HEAD'])) return
|
|
268
|
+
if (methodOf(req) === 'HEAD') {
|
|
269
|
+
res.writeHead(200, { 'cache-control': 'no-store' })
|
|
270
|
+
res.end()
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
const sessionId = new URL(req.url ?? '/', 'http://dsh.local').searchParams.get('sessionId')?.trim()
|
|
274
|
+
if (sessionId === undefined || sessionId === '') {
|
|
275
|
+
sendJson(res, 400, { ok: false, error: '缺少 sessionId 参数' })
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
sendJson(res, 200, await surface.sessionOrigin(sessionId))
|
|
279
|
+
}),
|
|
280
|
+
}),
|
|
281
|
+
server.register({
|
|
282
|
+
kind: 'exact',
|
|
283
|
+
path: PROFILES_ROUTE,
|
|
284
|
+
handler: guarded(async (req, res) => {
|
|
285
|
+
if (!allowOnly(req, res, ['GET', 'HEAD'])) return
|
|
286
|
+
if (methodOf(req) === 'HEAD') {
|
|
287
|
+
res.writeHead(200, { 'cache-control': 'no-store' })
|
|
288
|
+
res.end()
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
const params = new URL(req.url ?? '/', 'http://dsh.local').searchParams
|
|
292
|
+
const userIds = idList(params.get('users'))
|
|
293
|
+
const chatIds = idList(params.get('chats'))
|
|
294
|
+
if (userIds.length > PROFILES_MAX_IDS || chatIds.length > PROFILES_MAX_IDS) {
|
|
295
|
+
sendJson(res, 400, { ok: false, error: `每次最多查询 ${PROFILES_MAX_IDS} 个用户和 ${PROFILES_MAX_IDS} 个群` })
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
sendJson(res, 200, await surface.feishuProfiles(userIds, chatIds))
|
|
299
|
+
}),
|
|
300
|
+
}),
|
|
254
301
|
server.register({
|
|
255
302
|
kind: 'exact',
|
|
256
303
|
path: CONFIG_ROUTE,
|
package/src/settings/surface.ts
CHANGED
|
@@ -37,6 +37,7 @@ import type {
|
|
|
37
37
|
EditableFieldWire,
|
|
38
38
|
EditableKey,
|
|
39
39
|
EditableValues,
|
|
40
|
+
FeishuProfilesWire,
|
|
40
41
|
FieldIssueWire,
|
|
41
42
|
FieldGroup,
|
|
42
43
|
FieldOptionWire,
|
|
@@ -48,6 +49,8 @@ import type {
|
|
|
48
49
|
SaveCredentialsRequest,
|
|
49
50
|
SaveCredentialsResponse,
|
|
50
51
|
ScopeCheckWire,
|
|
52
|
+
SessionOriginResponse,
|
|
53
|
+
SessionOriginWire,
|
|
51
54
|
SlashCommandSyncWire,
|
|
52
55
|
StringListItemFormat,
|
|
53
56
|
} from './contract.ts'
|
|
@@ -116,6 +119,10 @@ export interface SettingsTarget {
|
|
|
116
119
|
removeWikiUser(userId: string): Promise<AccessMutationResponse>
|
|
117
120
|
/** Settle one pending request (panel shares this path with the Feishu DM card). */
|
|
118
121
|
resolveAccessRequest(token: string, decision: 'allow' | 'deny'): Promise<AccessMutationResponse>
|
|
122
|
+
/** Feishu origin of one Session, or null when Feishu never routed it. */
|
|
123
|
+
sessionOrigin(sessionId: string): Promise<SessionOriginWire | null>
|
|
124
|
+
/** Live Feishu names and avatars of the given users and chats. */
|
|
125
|
+
feishuProfiles(userIds: readonly string[], chatIds: readonly string[]): Promise<FeishuProfilesWire>
|
|
119
126
|
}
|
|
120
127
|
|
|
121
128
|
/** Presence/source/writability facts returned by the credentials seam. */
|
|
@@ -146,6 +153,7 @@ const BOOLEAN_KEYS = new Set<EditableKey>([
|
|
|
146
153
|
'welcomeOnboarding',
|
|
147
154
|
'slashCommandPanel',
|
|
148
155
|
'allowPermissionSwitch',
|
|
156
|
+
'dmAllowWhitelist',
|
|
149
157
|
'acceptAttachments',
|
|
150
158
|
'prefetchImages',
|
|
151
159
|
'feishuTools.attachmentFetch',
|
|
@@ -394,7 +402,13 @@ const FIELD_META: Record<EditableKey, FieldMeta> = {
|
|
|
394
402
|
label: '调试模式',
|
|
395
403
|
group: 'access',
|
|
396
404
|
summary: '打开后所有人都能聊,不安全,只在调试时用',
|
|
397
|
-
help: '
|
|
405
|
+
help: '关闭时(默认):管理员随时可用;非管理员私聊会被拒绝(打开「白名单用户可私聊」后白名单用户也可以);群聊要同时满足群已授权、发言人在用户白名单里。授权入口有三个:群里 /auth 命令、授权卡、本页。撤销立即生效。',
|
|
406
|
+
},
|
|
407
|
+
dmAllowWhitelist: {
|
|
408
|
+
label: '白名单用户可私聊',
|
|
409
|
+
group: 'access',
|
|
410
|
+
summary: '关闭时私聊仅管理员可用',
|
|
411
|
+
help: '打开后「已授权用户」里的人也可以私聊机器人,各自使用独立的私聊工作区,能力与群聊相同;不在白名单的人仍收到固定提示。撤销用户立即生效。',
|
|
398
412
|
},
|
|
399
413
|
adminUserId: {
|
|
400
414
|
label: '管理员 user_id',
|
|
@@ -720,6 +734,21 @@ export class SettingsSurface {
|
|
|
720
734
|
* the draft provider/model changes). A broken lookup degrades to the
|
|
721
735
|
* free-text form instead of failing the panel.
|
|
722
736
|
*/
|
|
737
|
+
/** Feishu origin of one Session for the conversation sidebar. */
|
|
738
|
+
async sessionOrigin(sessionId: string): Promise<SessionOriginResponse> {
|
|
739
|
+
return { origin: await this.target.sessionOrigin(sessionId) }
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Live Feishu names and avatars for the panel's users and chats.
|
|
744
|
+
* @param userIds - user `user_id`s.
|
|
745
|
+
* @param chatIds - chat `chat_id`s.
|
|
746
|
+
* @returns the profiles Feishu resolved.
|
|
747
|
+
*/
|
|
748
|
+
async feishuProfiles(userIds: readonly string[], chatIds: readonly string[]): Promise<FeishuProfilesWire> {
|
|
749
|
+
return this.target.feishuProfiles(userIds, chatIds)
|
|
750
|
+
}
|
|
751
|
+
|
|
723
752
|
async catalogFor(provider?: string, model?: string): Promise<CatalogWire> {
|
|
724
753
|
if (this.catalog === undefined) return emptyCatalog()
|
|
725
754
|
try {
|