@modesto_ma/dsh-channels 0.3.0 → 0.5.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -53
- package/docs/access-control-design.md +7 -5
- package/docs/attachment-pipeline-v2.md +310 -0
- package/docs/feasibility-and-design.md +1 -1
- package/docs/feishu-onboarding.md +127 -21
- package/docs/persistence.md +6 -4
- package/docs/refactor-plan.md +2 -2
- package/docs/workspace-isolation-design.md +13 -11
- package/lib/client.js +179 -288
- package/lib/client.js.map +1 -1
- package/lib/index.js +12125 -6382
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/cards.ts +9 -9
- package/src/access/controller.ts +10 -10
- package/src/access/index.ts +1 -0
- package/src/access/policy.ts +10 -10
- package/src/access/requests.ts +32 -31
- package/src/access/store.ts +12 -12
- package/src/access/targets.ts +56 -0
- package/src/cards/approval.ts +96 -26
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +1 -0
- package/src/cards/picker.ts +115 -0
- package/src/cards/streaming.ts +56 -7
- package/src/client/api.ts +5 -25
- package/src/client/panel/access-card.tsx +24 -15
- package/src/client/panel/access-tab.tsx +3 -3
- package/src/client/panel/connection-tab.tsx +70 -1
- package/src/client/panel/index.tsx +11 -62
- package/src/client/panel/shared.tsx +0 -23
- package/src/client/panel/workspace-tab.tsx +2 -18
- package/src/client/styles.ts +2 -0
- package/src/commands/registry.ts +2 -2
- package/src/config.ts +190 -32
- package/src/domain.ts +28 -18
- package/src/feishu/agent-context.ts +65 -19
- package/src/feishu/inbound-attachments.ts +440 -3
- package/src/feishu/manifest.ts +247 -0
- package/src/feishu/message-read.ts +1330 -0
- package/src/feishu/native-attachments.ts +998 -0
- package/src/feishu/scopes.ts +171 -0
- package/src/feishu/send-file.ts +543 -0
- package/src/feishu/slash-commands.ts +134 -0
- package/src/feishu/tools.ts +2305 -0
- package/src/feishu-channel.ts +322 -6
- package/src/host/agent.ts +55 -0
- package/src/host/attachments.ts +210 -0
- package/src/host/index.ts +1 -0
- package/src/index.ts +5 -4
- package/src/interactions/approvals.ts +187 -7
- package/src/interactions/pending.ts +3 -3
- package/src/routing/inbound.ts +620 -49
- package/src/service.ts +808 -375
- package/src/session/manager.ts +67 -13
- package/src/session/routes.ts +2 -3
- package/src/settings/contract.ts +90 -89
- package/src/settings/overlay.ts +124 -13
- package/src/settings/routes.ts +3 -17
- package/src/settings/surface.ts +156 -82
- package/src/streamer.ts +94 -5
- package/src/workspaces.ts +39 -26
- package/src/cleanup.ts +0 -535
- package/src/client/panel/cleanup-card.tsx +0 -106
package/package.json
CHANGED
package/src/access/cards.ts
CHANGED
|
@@ -25,8 +25,8 @@ export interface GroupInfoCardInput {
|
|
|
25
25
|
memberCount?: number
|
|
26
26
|
/** Group owner `open_id`, when available. */
|
|
27
27
|
ownerId?: string
|
|
28
|
-
/** `
|
|
29
|
-
|
|
28
|
+
/** `user_id` of whoever invited/triggered the bot. */
|
|
29
|
+
inviterUserId?: string
|
|
30
30
|
/** Whether the group already passed the first admission gate. */
|
|
31
31
|
authorized: boolean
|
|
32
32
|
/** Opaque request token; only present for a *pending* group request. */
|
|
@@ -37,8 +37,8 @@ export interface GroupInfoCardInput {
|
|
|
37
37
|
|
|
38
38
|
/** Inputs of the user-authorization request card sent to the administrator. */
|
|
39
39
|
export interface UserRequestCardInput {
|
|
40
|
-
/** `
|
|
41
|
-
|
|
40
|
+
/** `user_id` of the user asking for access. */
|
|
41
|
+
userId: string
|
|
42
42
|
/** Display name of that user, when known. */
|
|
43
43
|
name?: string
|
|
44
44
|
/** Chat the request originated from. */
|
|
@@ -57,7 +57,7 @@ export interface UserRequestCardInput {
|
|
|
57
57
|
export interface RequestSettledCardInput {
|
|
58
58
|
/** What kind of grant the request was about. */
|
|
59
59
|
kind: 'group' | 'user'
|
|
60
|
-
/** `chat_id` (group) or `
|
|
60
|
+
/** `chat_id` (group) or `user_id` (user) the request targeted. */
|
|
61
61
|
targetId: string
|
|
62
62
|
/** Human-readable target name, when known. */
|
|
63
63
|
targetName?: string
|
|
@@ -121,8 +121,8 @@ export function buildGroupInfoCard(input: GroupInfoCardInput): object {
|
|
|
121
121
|
if (input.ownerId !== undefined && input.ownerId !== '') {
|
|
122
122
|
elements.push(field('群主', `\`${input.ownerId}\``))
|
|
123
123
|
}
|
|
124
|
-
if (input.
|
|
125
|
-
elements.push(field('邀请人', `\`${input.
|
|
124
|
+
if (input.inviterUserId !== undefined && input.inviterUserId !== '') {
|
|
125
|
+
elements.push(field('邀请人', `\`${input.inviterUserId}\``))
|
|
126
126
|
}
|
|
127
127
|
elements.push(field('加入时间', formatTime(input.now)))
|
|
128
128
|
elements.push(divider())
|
|
@@ -140,7 +140,7 @@ export function buildGroupInfoCard(input: GroupInfoCardInput): object {
|
|
|
140
140
|
|
|
141
141
|
/**
|
|
142
142
|
* The user-authorization request card: an unauthorized user mentioned the bot
|
|
143
|
-
* inside an authorized group, so the administrator can grant that `
|
|
143
|
+
* inside an authorized group, so the administrator can grant that `user_id`
|
|
144
144
|
* from the DM card.
|
|
145
145
|
*/
|
|
146
146
|
export function buildUserRequestCard(input: UserRequestCardInput): object {
|
|
@@ -148,7 +148,7 @@ export function buildUserRequestCard(input: UserRequestCardInput): object {
|
|
|
148
148
|
const groupName = input.groupName === undefined ? undefined : clip(input.groupName)
|
|
149
149
|
const elements: object[] = [
|
|
150
150
|
field('用户', display === '' ? '(未知昵称)' : display),
|
|
151
|
-
field('
|
|
151
|
+
field('user_id', `\`${input.userId}\``),
|
|
152
152
|
]
|
|
153
153
|
if (groupName !== undefined && groupName !== '') {
|
|
154
154
|
const group = input.chatId === undefined ? groupName : `${groupName}(\`${input.chatId}\`)`
|
package/src/access/controller.ts
CHANGED
|
@@ -23,15 +23,15 @@
|
|
|
23
23
|
import type { FeishuConfig } from '../config.ts'
|
|
24
24
|
import type { RouteRecord } from '../domain.ts'
|
|
25
25
|
import type { FeishuChannel, NormalizedMessage } from '../feishu-channel.ts'
|
|
26
|
-
import { decideAccess,
|
|
26
|
+
import { decideAccess, isAdminUserId } from './policy.ts'
|
|
27
27
|
import type { AccessDecision } from './policy.ts'
|
|
28
28
|
import type { AccessStore, AccessStoreSnapshot } from './store.ts'
|
|
29
29
|
|
|
30
30
|
/** Fixed private-chat notice for non-administrators (no model call). */
|
|
31
|
-
export const DM_DENY_TEXT = '非管理员禁用私聊功能哦~如需使用,请在管理员授权的群里 @ 我;或联系管理员把你的
|
|
31
|
+
export const DM_DENY_TEXT = '非管理员禁用私聊功能哦~如需使用,请在管理员授权的群里 @ 我;或联系管理员把你的 user_id 加入白名单。'
|
|
32
32
|
|
|
33
33
|
/** Fixed private-chat notice while no administrator is configured. */
|
|
34
|
-
export const DM_UNCONFIGURED_TEXT = '机器人尚未配置管理员。发送 /userid 可查看你的
|
|
34
|
+
export const DM_UNCONFIGURED_TEXT = '机器人尚未配置管理员。发送 /userid 可查看你的 user_id,交给部署者配置后即可启用。'
|
|
35
35
|
|
|
36
36
|
/** Per-sender cooldown for the repeated private-chat notice (10 s). */
|
|
37
37
|
const DM_NOTICE_COOLDOWN_MS = 10_000
|
|
@@ -55,7 +55,7 @@ export interface AccessControllerDeps {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
export class AccessController {
|
|
58
|
-
/** sender
|
|
58
|
+
/** sender user_id → last fixed-notice delivery instant (DM spam guard). */
|
|
59
59
|
private readonly dmNoticeAt = new Map<string, number>()
|
|
60
60
|
|
|
61
61
|
constructor(private readonly deps: AccessControllerDeps) {}
|
|
@@ -68,9 +68,9 @@ export class AccessController {
|
|
|
68
68
|
return this.deps.now?.() ?? Date.now()
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/** True when `
|
|
72
|
-
isAdmin(
|
|
73
|
-
return
|
|
71
|
+
/** True when `userId` is exactly the configured administrator. */
|
|
72
|
+
isAdmin(userId?: string): boolean {
|
|
73
|
+
return isAdminUserId(this.config(), userId)
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/** Current whitelist snapshot used by the pure policy. */
|
|
@@ -134,7 +134,7 @@ export class AccessController {
|
|
|
134
134
|
try {
|
|
135
135
|
if (msg.chatType === 'p2p') {
|
|
136
136
|
// `/userid` is the bootstrap entry for everyone: it only echoes the
|
|
137
|
-
// sender's own
|
|
137
|
+
// sender's own user_id and has no storage/model side effect.
|
|
138
138
|
if (commandName === 'userid') return true
|
|
139
139
|
if (this.isAdmin(msg.senderId)) return true
|
|
140
140
|
await this.noticeDeniedDm(msg)
|
|
@@ -156,7 +156,7 @@ export class AccessController {
|
|
|
156
156
|
* execution inside someone else's topic.
|
|
157
157
|
*/
|
|
158
158
|
ownsRoute(msg: NormalizedMessage, record: RouteRecord): boolean {
|
|
159
|
-
return record.
|
|
159
|
+
return record.senderUserId === msg.senderId && record.chatId === msg.chatId
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
/** Drop in-memory notices (shutdown; durable state lives in the store). */
|
|
@@ -176,7 +176,7 @@ export class AccessController {
|
|
|
176
176
|
const last = this.dmNoticeAt.get(msg.senderId)
|
|
177
177
|
if (last !== undefined && now - last < DM_NOTICE_COOLDOWN_MS) return
|
|
178
178
|
this.dmNoticeAt.set(msg.senderId, now)
|
|
179
|
-
const text = this.config().
|
|
179
|
+
const text = this.config().adminUserId === undefined ? DM_UNCONFIGURED_TEXT : DM_DENY_TEXT
|
|
180
180
|
await this.sendText(msg.chatId, text, msg.messageId)
|
|
181
181
|
}
|
|
182
182
|
|
package/src/access/index.ts
CHANGED
package/src/access/policy.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* or any group, regardless of the whitelists;
|
|
14
14
|
* - private chats: non-administrators are refused;
|
|
15
15
|
* - group chats: non-administrators need the group to be authorized AND
|
|
16
|
-
* their
|
|
16
|
+
* their user_id to be on the global user whitelist.
|
|
17
17
|
*
|
|
18
18
|
* @module dsh-channels/access/policy
|
|
19
19
|
*/
|
|
@@ -34,7 +34,7 @@ export interface AccessSubject {
|
|
|
34
34
|
export interface AccessSnapshot {
|
|
35
35
|
/** Authorized group `chat_id`s. */
|
|
36
36
|
groups: ReadonlySet<string>
|
|
37
|
-
/** Authorized user `
|
|
37
|
+
/** Authorized user `user_id`s (global, across authorized groups). */
|
|
38
38
|
users: ReadonlySet<string>
|
|
39
39
|
}
|
|
40
40
|
|
|
@@ -56,16 +56,16 @@ export type AccessDecision =
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/** The configuration slice admission reads (allows easy fakes in tests). */
|
|
59
|
-
export type AccessPolicyConfig = Pick<FeishuConfig, 'accessMode' | '
|
|
59
|
+
export type AccessPolicyConfig = Pick<FeishuConfig, 'accessMode' | 'adminUserId'>
|
|
60
60
|
|
|
61
|
-
/** True when `
|
|
62
|
-
export function
|
|
63
|
-
config: Pick<FeishuConfig, '
|
|
64
|
-
|
|
61
|
+
/** True when `userId` is exactly the configured administrator. */
|
|
62
|
+
export function isAdminUserId(
|
|
63
|
+
config: Pick<FeishuConfig, 'adminUserId'>,
|
|
64
|
+
userId: string | undefined,
|
|
65
65
|
): boolean {
|
|
66
|
-
const admin = config.
|
|
66
|
+
const admin = config.adminUserId?.trim()
|
|
67
67
|
if (admin === undefined || admin === '') return false
|
|
68
|
-
return
|
|
68
|
+
return userId !== undefined && userId.trim() === admin
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/**
|
|
@@ -81,7 +81,7 @@ export function decideAccess(
|
|
|
81
81
|
): AccessDecision {
|
|
82
82
|
if (config.accessMode === 'open') return { kind: 'allow', reason: 'open' }
|
|
83
83
|
|
|
84
|
-
const admin = config.
|
|
84
|
+
const admin = config.adminUserId?.trim()
|
|
85
85
|
if (admin === undefined || admin === '') return { kind: 'deny', reason: 'admin-missing' }
|
|
86
86
|
|
|
87
87
|
// The administrator is never constrained by the whitelists: they may start
|
package/src/access/requests.ts
CHANGED
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
} from '../feishu-channel.ts'
|
|
26
26
|
import { buildGroupInfoCard, buildRequestSettledCard, buildUserRequestCard } from './cards.ts'
|
|
27
27
|
import type { SettledRequestStatus } from './cards.ts'
|
|
28
|
-
import {
|
|
28
|
+
import { isAdminUserId } from './policy.ts'
|
|
29
29
|
import type { AccessStore } from './store.ts'
|
|
30
30
|
|
|
31
31
|
/** Same-target re-notification cooldown after a request was denied (24 h). */
|
|
@@ -94,7 +94,7 @@ function addressedToBot(msg: NormalizedMessage): boolean {
|
|
|
94
94
|
export class AccessRequests {
|
|
95
95
|
/** Targets with an in-flight create/deliver pass (avoids duplicate cards). */
|
|
96
96
|
private readonly creating = new Set<string>()
|
|
97
|
-
/** `${messageId}:${
|
|
97
|
+
/** `${messageId}:${userId}` of non-administrator card clicks already answered. */
|
|
98
98
|
private readonly unauthorizedNotified = new Set<string>()
|
|
99
99
|
|
|
100
100
|
constructor(private readonly deps: AccessRequestsDeps) {}
|
|
@@ -107,8 +107,8 @@ export class AccessRequests {
|
|
|
107
107
|
return this.deps.config()
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
private
|
|
111
|
-
const admin = this.config().
|
|
110
|
+
private adminUserId(): string | undefined {
|
|
111
|
+
const admin = this.config().adminUserId?.trim()
|
|
112
112
|
return admin === undefined || admin === '' ? undefined : admin
|
|
113
113
|
}
|
|
114
114
|
|
|
@@ -124,10 +124,10 @@ export class AccessRequests {
|
|
|
124
124
|
*/
|
|
125
125
|
async handleBotAdded(event: BotAddedEvent): Promise<void> {
|
|
126
126
|
try {
|
|
127
|
-
const admin = this.
|
|
127
|
+
const admin = this.adminUserId()
|
|
128
128
|
if (admin === undefined) {
|
|
129
129
|
this.deps.warn(
|
|
130
|
-
'dsh-channels: bot added to %s but no administrator
|
|
130
|
+
'dsh-channels: bot added to %s but no administrator user_id is configured; group card skipped',
|
|
131
131
|
event.chatId,
|
|
132
132
|
)
|
|
133
133
|
return
|
|
@@ -140,13 +140,13 @@ export class AccessRequests {
|
|
|
140
140
|
...(info?.name === undefined ? {} : { name: info.name }),
|
|
141
141
|
...(info?.memberCount === undefined ? {} : { memberCount: info.memberCount }),
|
|
142
142
|
...(info?.ownerId === undefined ? {} : { ownerId: info.ownerId }),
|
|
143
|
-
|
|
143
|
+
...(event.operator.userId === undefined ? {} : { inviterUserId: event.operator.userId }),
|
|
144
144
|
authorized: true,
|
|
145
145
|
now,
|
|
146
146
|
}))
|
|
147
147
|
return
|
|
148
148
|
}
|
|
149
|
-
if (!(await this.beginGroupRequest(event.chatId, event.operator.
|
|
149
|
+
if (!(await this.beginGroupRequest(event.chatId, event.operator.userId, now))) return
|
|
150
150
|
} catch (error) {
|
|
151
151
|
this.deps.warn('dsh-channels: bot-added group notice failed: %s', String(error))
|
|
152
152
|
}
|
|
@@ -160,10 +160,10 @@ export class AccessRequests {
|
|
|
160
160
|
async ensureGroupRequest(msg: NormalizedMessage): Promise<void> {
|
|
161
161
|
if (!addressedToBot(msg) || msg.chatType !== 'group') return
|
|
162
162
|
try {
|
|
163
|
-
const admin = this.
|
|
163
|
+
const admin = this.adminUserId()
|
|
164
164
|
if (admin === undefined) {
|
|
165
165
|
this.deps.warn(
|
|
166
|
-
'dsh-channels: group %s needs authorization but no administrator
|
|
166
|
+
'dsh-channels: group %s needs authorization but no administrator user_id is configured',
|
|
167
167
|
msg.chatId,
|
|
168
168
|
)
|
|
169
169
|
return
|
|
@@ -183,10 +183,10 @@ export class AccessRequests {
|
|
|
183
183
|
async ensureUserRequest(msg: NormalizedMessage): Promise<void> {
|
|
184
184
|
if (!addressedToBot(msg) || msg.chatType !== 'group') return
|
|
185
185
|
try {
|
|
186
|
-
const admin = this.
|
|
186
|
+
const admin = this.adminUserId()
|
|
187
187
|
if (admin === undefined) {
|
|
188
188
|
this.deps.warn(
|
|
189
|
-
'dsh-channels: user %s needs authorization but no administrator
|
|
189
|
+
'dsh-channels: user %s needs authorization but no administrator user_id is configured',
|
|
190
190
|
msg.senderId,
|
|
191
191
|
)
|
|
192
192
|
return
|
|
@@ -211,7 +211,8 @@ export class AccessRequests {
|
|
|
211
211
|
if (decision === undefined) return
|
|
212
212
|
const record = this.deps.store.getRequest(decision.token)
|
|
213
213
|
if (record === undefined) return
|
|
214
|
-
|
|
214
|
+
const actor = event.operator.userId
|
|
215
|
+
if (actor === undefined || !isAdminUserId(this.config(), actor)) {
|
|
215
216
|
await this.notifyUnauthorized(event)
|
|
216
217
|
return
|
|
217
218
|
}
|
|
@@ -223,7 +224,7 @@ export class AccessRequests {
|
|
|
223
224
|
await this.settleExpired(record)
|
|
224
225
|
return
|
|
225
226
|
}
|
|
226
|
-
const result = await this.resolve(decision.token, decision.action,
|
|
227
|
+
const result = await this.resolve(decision.token, decision.action, actor)
|
|
227
228
|
if (!result.ok) {
|
|
228
229
|
this.deps.warn('dsh-channels: settling access request %s failed: %s', decision.token, result.error)
|
|
229
230
|
}
|
|
@@ -240,9 +241,9 @@ export class AccessRequests {
|
|
|
240
241
|
async resolve(
|
|
241
242
|
token: string,
|
|
242
243
|
decision: 'allow' | 'deny',
|
|
243
|
-
|
|
244
|
+
actorUserId: string,
|
|
244
245
|
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
245
|
-
if (!
|
|
246
|
+
if (!isAdminUserId(this.config(), actorUserId)) return { ok: false, error: '仅管理员可处理授权申请' }
|
|
246
247
|
const record = this.deps.store.getRequest(token)
|
|
247
248
|
if (record === undefined) return { ok: false, error: '授权申请不存在' }
|
|
248
249
|
if (record.status !== 'pending') {
|
|
@@ -255,7 +256,7 @@ export class AccessRequests {
|
|
|
255
256
|
}
|
|
256
257
|
const status = decision === 'allow' ? 'approved' : 'denied'
|
|
257
258
|
try {
|
|
258
|
-
if (decision === 'allow') await this.grant(record,
|
|
259
|
+
if (decision === 'allow') await this.grant(record, actorUserId, now)
|
|
259
260
|
await this.deps.store.putRequest({ ...record, status })
|
|
260
261
|
} catch (error) {
|
|
261
262
|
this.deps.warn('dsh-channels: persisting access request %s settlement failed: %s', token, String(error))
|
|
@@ -289,8 +290,8 @@ export class AccessRequests {
|
|
|
289
290
|
// ---- internals -------------------------------------------------------
|
|
290
291
|
|
|
291
292
|
/** Create + DM one group request unless deduplicated/cooling down. */
|
|
292
|
-
private async beginGroupRequest(chatId: string,
|
|
293
|
-
const admin = this.
|
|
293
|
+
private async beginGroupRequest(chatId: string, inviterUserId: string | undefined, now: number): Promise<boolean> {
|
|
294
|
+
const admin = this.adminUserId()
|
|
294
295
|
if (admin === undefined) return false
|
|
295
296
|
if (await this.hasLivePending('group', chatId, now)) return false
|
|
296
297
|
if (this.deps.store.recentlyDenied('group', chatId, DENIED_COOLDOWN_MS, now)) return false
|
|
@@ -304,7 +305,7 @@ export class AccessRequests {
|
|
|
304
305
|
targetId: chatId,
|
|
305
306
|
chatId,
|
|
306
307
|
...(info?.name === undefined ? {} : { groupName: info.name }),
|
|
307
|
-
|
|
308
|
+
...(inviterUserId === undefined ? {} : { requesterUserId: inviterUserId }),
|
|
308
309
|
})
|
|
309
310
|
if (!(await this.persist(record))) return false
|
|
310
311
|
await this.sendCard(admin, buildGroupInfoCard({
|
|
@@ -312,7 +313,7 @@ export class AccessRequests {
|
|
|
312
313
|
...(info?.name === undefined ? {} : { name: info.name }),
|
|
313
314
|
...(info?.memberCount === undefined ? {} : { memberCount: info.memberCount }),
|
|
314
315
|
...(info?.ownerId === undefined ? {} : { ownerId: info.ownerId }),
|
|
315
|
-
|
|
316
|
+
...(inviterUserId === undefined ? {} : { inviterUserId }),
|
|
316
317
|
authorized: false,
|
|
317
318
|
token: record.token,
|
|
318
319
|
now,
|
|
@@ -325,7 +326,7 @@ export class AccessRequests {
|
|
|
325
326
|
|
|
326
327
|
/** Create + DM one user request unless deduplicated/cooling down. */
|
|
327
328
|
private async beginUserRequest(msg: NormalizedMessage, groupName: string | undefined, now: number): Promise<boolean> {
|
|
328
|
-
const admin = this.
|
|
329
|
+
const admin = this.adminUserId()
|
|
329
330
|
if (admin === undefined) return false
|
|
330
331
|
if (await this.hasLivePending('user', msg.senderId, now)) return false
|
|
331
332
|
if (this.deps.store.recentlyDenied('user', msg.senderId, DENIED_COOLDOWN_MS, now)) return false
|
|
@@ -338,12 +339,12 @@ export class AccessRequests {
|
|
|
338
339
|
targetId: msg.senderId,
|
|
339
340
|
chatId: msg.chatId,
|
|
340
341
|
...(groupName === undefined ? {} : { groupName }),
|
|
341
|
-
|
|
342
|
+
requesterUserId: msg.senderId,
|
|
342
343
|
...(msg.senderName === undefined ? {} : { requesterName: msg.senderName }),
|
|
343
344
|
})
|
|
344
345
|
if (!(await this.persist(record))) return false
|
|
345
346
|
await this.sendCard(admin, buildUserRequestCard({
|
|
346
|
-
|
|
347
|
+
userId: msg.senderId,
|
|
347
348
|
...(msg.senderName === undefined ? {} : { name: msg.senderName }),
|
|
348
349
|
chatId: msg.chatId,
|
|
349
350
|
...(groupName === undefined ? {} : { groupName }),
|
|
@@ -380,7 +381,7 @@ export class AccessRequests {
|
|
|
380
381
|
targetId: string
|
|
381
382
|
chatId?: string
|
|
382
383
|
groupName?: string
|
|
383
|
-
|
|
384
|
+
requesterUserId?: string
|
|
384
385
|
requesterName?: string
|
|
385
386
|
}): AccessRequestRecord {
|
|
386
387
|
const now = this.now()
|
|
@@ -390,7 +391,7 @@ export class AccessRequests {
|
|
|
390
391
|
targetId: input.targetId,
|
|
391
392
|
...(input.chatId === undefined ? {} : { chatId: input.chatId }),
|
|
392
393
|
...(input.groupName === undefined || input.groupName === '' ? {} : { groupName: input.groupName }),
|
|
393
|
-
...(input.
|
|
394
|
+
...(input.requesterUserId === undefined ? {} : { requesterUserId: input.requesterUserId }),
|
|
394
395
|
...(input.requesterName === undefined || input.requesterName === '' ? {} : { requesterName: input.requesterName }),
|
|
395
396
|
status: 'pending',
|
|
396
397
|
createdAt: now,
|
|
@@ -445,22 +446,22 @@ export class AccessRequests {
|
|
|
445
446
|
}
|
|
446
447
|
|
|
447
448
|
/** Write the actual grant; throws so `resolve()` can report the failure. */
|
|
448
|
-
private async grant(record: AccessRequestRecord,
|
|
449
|
+
private async grant(record: AccessRequestRecord, actorUserId: string, now: number): Promise<void> {
|
|
449
450
|
if (record.kind === 'group') {
|
|
450
451
|
await this.deps.store.addGroup({
|
|
451
452
|
chatId: record.targetId,
|
|
452
453
|
...(record.groupName === undefined ? {} : { name: record.groupName }),
|
|
453
454
|
source: 'card',
|
|
454
|
-
addedBy:
|
|
455
|
+
addedBy: actorUserId,
|
|
455
456
|
addedAt: now,
|
|
456
457
|
})
|
|
457
458
|
return
|
|
458
459
|
}
|
|
459
460
|
await this.deps.store.addUser({
|
|
460
|
-
|
|
461
|
+
userId: record.targetId,
|
|
461
462
|
...(record.requesterName === undefined ? {} : { name: record.requesterName }),
|
|
462
463
|
source: 'card',
|
|
463
|
-
addedBy:
|
|
464
|
+
addedBy: actorUserId,
|
|
464
465
|
addedAt: now,
|
|
465
466
|
})
|
|
466
467
|
}
|
|
@@ -498,7 +499,7 @@ export class AccessRequests {
|
|
|
498
499
|
|
|
499
500
|
/** One control card is delivered once; non-administrator clicks get one notice. */
|
|
500
501
|
private async notifyUnauthorized(event: CardActionEvent): Promise<void> {
|
|
501
|
-
const key = `${event.messageId}:${event.operator.openId}`
|
|
502
|
+
const key = `${event.messageId}:${event.operator.userId ?? event.operator.openId}`
|
|
502
503
|
if (this.unauthorizedNotified.has(key)) return
|
|
503
504
|
this.unauthorizedNotified.add(key)
|
|
504
505
|
const channel = this.deps.channel()
|
package/src/access/store.ts
CHANGED
|
@@ -84,16 +84,16 @@ export class AccessStore {
|
|
|
84
84
|
return this.groupKeys.has(chatId)
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
hasUser(
|
|
88
|
-
return this.userKeys.has(
|
|
87
|
+
hasUser(userId: string): boolean {
|
|
88
|
+
return this.userKeys.has(userId)
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
getGroup(chatId: string): AccessGroupRecord | undefined {
|
|
92
92
|
return this.groups.get(chatId)
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
getUser(
|
|
96
|
-
return this.users.get(
|
|
95
|
+
getUser(userId: string): AccessUserRecord | undefined {
|
|
96
|
+
return this.users.get(userId)
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
/** Every authorized group, oldest first. */
|
|
@@ -122,17 +122,17 @@ export class AccessStore {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
async addUser(record: AccessUserRecord): Promise<AccessUserRecord> {
|
|
125
|
-
await this.requireDomain('add access user').accessUsers.put(record.
|
|
126
|
-
this.users.set(record.
|
|
127
|
-
this.userKeys.add(record.
|
|
125
|
+
await this.requireDomain('add access user').accessUsers.put(record.userId, record)
|
|
126
|
+
this.users.set(record.userId, record)
|
|
127
|
+
this.userKeys.add(record.userId)
|
|
128
128
|
return record
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
async removeUser(
|
|
132
|
-
if (!this.users.has(
|
|
133
|
-
await this.requireDomain('remove access user').accessUsers.delete(
|
|
134
|
-
this.users.delete(
|
|
135
|
-
this.userKeys.delete(
|
|
131
|
+
async removeUser(userId: string): Promise<boolean> {
|
|
132
|
+
if (!this.users.has(userId)) return false
|
|
133
|
+
await this.requireDomain('remove access user').accessUsers.delete(userId)
|
|
134
|
+
this.users.delete(userId)
|
|
135
|
+
this.userKeys.delete(userId)
|
|
136
136
|
return true
|
|
137
137
|
}
|
|
138
138
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target resolution for `/auth user add|remove`: the administrator names the
|
|
3
|
+
* user either by a pasted `user_id` or by @-mentioning them in the command.
|
|
4
|
+
*
|
|
5
|
+
* Feishu replaces each mention in the text with `@<name>`, so the mentioned
|
|
6
|
+
* user's `user_id` comes from the message's mention list, never from the text.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-channels/access/targets
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { NormalizedMessage } from '@larksuiteoapi/node-sdk'
|
|
12
|
+
import { FEISHU_USER_ID_PATTERN } from '../domain.ts'
|
|
13
|
+
|
|
14
|
+
/** Outcome of resolving one `/auth user` target. */
|
|
15
|
+
export type AuthUserTarget =
|
|
16
|
+
| { ok: true; userId: string; name?: string }
|
|
17
|
+
| { ok: false; reason: 'usage' | 'mention-missing' | 'mention-multiple' | 'mention-no-user-id' }
|
|
18
|
+
|
|
19
|
+
/** Input-error card text for each unresolved `/auth user` target. */
|
|
20
|
+
export const AUTH_USER_TARGET_ERRORS: Record<Extract<AuthUserTarget, { ok: false }>['reason'], string> = {
|
|
21
|
+
'usage': '用法:/auth user add <@用户|user_id> 或 /auth user remove <@用户|user_id>(可用 /userid 查看 user_id)。',
|
|
22
|
+
'mention-missing': '没有识别到被 @ 的用户。请在群里用 @ 选择成员,或改用 /auth user add <user_id>。',
|
|
23
|
+
'mention-multiple': '一次只能 @ 一个用户,请分开发送。',
|
|
24
|
+
'mention-no-user-id': '被 @ 的用户没有 user_id(外部用户、机器人,或应用缺少 contact:user.employee_id:readonly 权限),无法授权。',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the user an `/auth user add|remove` command addresses.
|
|
29
|
+
*
|
|
30
|
+
* An argument starting with `@` selects the message's single non-bot mention
|
|
31
|
+
* (the bot's own mention and `@所有人` never count); otherwise the first word
|
|
32
|
+
* must be a valid `user_id`.
|
|
33
|
+
*
|
|
34
|
+
* @param argument Command text after `user add` / `user remove`.
|
|
35
|
+
* @param mentions The message's mentions (`NormalizedMessage.mentions`).
|
|
36
|
+
* @param botOpenId The bot's open_id, when known, to exclude its mention.
|
|
37
|
+
* @returns the resolved user, or the reason it could not be resolved.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveAuthUserTarget(
|
|
40
|
+
argument: string,
|
|
41
|
+
mentions: NormalizedMessage['mentions'],
|
|
42
|
+
botOpenId?: string,
|
|
43
|
+
): AuthUserTarget {
|
|
44
|
+
const text = argument.trim()
|
|
45
|
+
if (text.startsWith('@')) {
|
|
46
|
+
const users = mentions.filter(mention => mention.isBot !== true
|
|
47
|
+
&& (botOpenId === undefined || mention.openId !== botOpenId))
|
|
48
|
+
if (users.length === 0) return { ok: false, reason: 'mention-missing' }
|
|
49
|
+
if (users.length > 1) return { ok: false, reason: 'mention-multiple' }
|
|
50
|
+
const [user] = users as [NormalizedMessage['mentions'][number]]
|
|
51
|
+
if (user.userId === undefined || user.userId === '') return { ok: false, reason: 'mention-no-user-id' }
|
|
52
|
+
return { ok: true, userId: user.userId, ...(user.name === undefined || user.name === '' ? {} : { name: user.name }) }
|
|
53
|
+
}
|
|
54
|
+
const userId = text.split(/\s+/u)[0] ?? ''
|
|
55
|
+
return FEISHU_USER_ID_PATTERN.test(userId) ? { ok: true, userId } : { ok: false, reason: 'usage' }
|
|
56
|
+
}
|