@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.
Files changed (64) hide show
  1. package/README.md +123 -53
  2. package/docs/access-control-design.md +7 -5
  3. package/docs/attachment-pipeline-v2.md +310 -0
  4. package/docs/feasibility-and-design.md +1 -1
  5. package/docs/feishu-onboarding.md +127 -21
  6. package/docs/persistence.md +6 -4
  7. package/docs/refactor-plan.md +2 -2
  8. package/docs/workspace-isolation-design.md +13 -11
  9. package/lib/client.js +179 -288
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +12125 -6382
  12. package/lib/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/access/cards.ts +9 -9
  15. package/src/access/controller.ts +10 -10
  16. package/src/access/index.ts +1 -0
  17. package/src/access/policy.ts +10 -10
  18. package/src/access/requests.ts +32 -31
  19. package/src/access/store.ts +12 -12
  20. package/src/access/targets.ts +56 -0
  21. package/src/cards/approval.ts +96 -26
  22. package/src/cards/index.ts +1 -0
  23. package/src/cards/onboarding.ts +1 -0
  24. package/src/cards/picker.ts +115 -0
  25. package/src/cards/streaming.ts +56 -7
  26. package/src/client/api.ts +5 -25
  27. package/src/client/panel/access-card.tsx +24 -15
  28. package/src/client/panel/access-tab.tsx +3 -3
  29. package/src/client/panel/connection-tab.tsx +70 -1
  30. package/src/client/panel/index.tsx +11 -62
  31. package/src/client/panel/shared.tsx +0 -23
  32. package/src/client/panel/workspace-tab.tsx +2 -18
  33. package/src/client/styles.ts +2 -0
  34. package/src/commands/registry.ts +2 -2
  35. package/src/config.ts +190 -32
  36. package/src/domain.ts +28 -18
  37. package/src/feishu/agent-context.ts +65 -19
  38. package/src/feishu/inbound-attachments.ts +440 -3
  39. package/src/feishu/manifest.ts +247 -0
  40. package/src/feishu/message-read.ts +1330 -0
  41. package/src/feishu/native-attachments.ts +998 -0
  42. package/src/feishu/scopes.ts +171 -0
  43. package/src/feishu/send-file.ts +543 -0
  44. package/src/feishu/slash-commands.ts +134 -0
  45. package/src/feishu/tools.ts +2305 -0
  46. package/src/feishu-channel.ts +322 -6
  47. package/src/host/agent.ts +55 -0
  48. package/src/host/attachments.ts +210 -0
  49. package/src/host/index.ts +1 -0
  50. package/src/index.ts +5 -4
  51. package/src/interactions/approvals.ts +187 -7
  52. package/src/interactions/pending.ts +3 -3
  53. package/src/routing/inbound.ts +620 -49
  54. package/src/service.ts +808 -375
  55. package/src/session/manager.ts +67 -13
  56. package/src/session/routes.ts +2 -3
  57. package/src/settings/contract.ts +90 -89
  58. package/src/settings/overlay.ts +124 -13
  59. package/src/settings/routes.ts +3 -17
  60. package/src/settings/surface.ts +156 -82
  61. package/src/streamer.ts +94 -5
  62. package/src/workspaces.ts +39 -26
  63. package/src/cleanup.ts +0 -535
  64. package/src/client/panel/cleanup-card.tsx +0 -106
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modesto_ma/dsh-channels",
3
- "version": "0.3.0",
3
+ "version": "0.5.0-beta.1",
4
4
  "description": "DeepSeek Harness (DSH) 飞书渠道插件:一个 dsh 实例对应一个飞书机器人,私聊/群聊/话题群 @ 触发会话,话题绑定 session,卡片 2.0 流式回复;Web 设置里提供「飞书渠道」状态与常用配置面板",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,8 +25,8 @@ export interface GroupInfoCardInput {
25
25
  memberCount?: number
26
26
  /** Group owner `open_id`, when available. */
27
27
  ownerId?: string
28
- /** `open_id` of whoever invited/triggered the bot. */
29
- inviterOpenId?: string
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
- /** `open_id` of the user asking for access. */
41
- openId: string
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 `open_id` (user) the request targeted. */
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.inviterOpenId !== undefined && input.inviterOpenId !== '') {
125
- elements.push(field('邀请人', `\`${input.inviterOpenId}\``))
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 `open_id`
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('open_id', `\`${input.openId}\``),
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}\`)`
@@ -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, isAdminOpenId } from './policy.ts'
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 = '非管理员禁用私聊功能哦~如需使用,请在管理员授权的群里 @ 我;或联系管理员把你的 open_id 加入白名单。'
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 可查看你的 open_id,交给部署者配置后即可启用。'
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 open_id → last fixed-notice delivery instant (DM spam guard). */
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 `openId` is exactly the configured administrator. */
72
- isAdmin(openId?: string): boolean {
73
- return isAdminOpenId(this.config(), openId)
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 open_id and has no storage/model side effect.
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.senderOpenId === msg.senderId && record.chatId === msg.chatId
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().adminOpenId === undefined ? DM_UNCONFIGURED_TEXT : DM_DENY_TEXT
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
 
@@ -11,3 +11,4 @@ export * from './store.ts'
11
11
  export * from './controller.ts'
12
12
  export * from './requests.ts'
13
13
  export * from './cards.ts'
14
+ export * from './targets.ts'
@@ -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 open_id to be on the global user whitelist.
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 `open_id`s (global, across authorized groups). */
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' | 'adminOpenId'>
59
+ export type AccessPolicyConfig = Pick<FeishuConfig, 'accessMode' | 'adminUserId'>
60
60
 
61
- /** True when `openId` is exactly the configured administrator. */
62
- export function isAdminOpenId(
63
- config: Pick<FeishuConfig, 'adminOpenId'>,
64
- openId: string | undefined,
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.adminOpenId?.trim()
66
+ const admin = config.adminUserId?.trim()
67
67
  if (admin === undefined || admin === '') return false
68
- return openId !== undefined && openId.trim() === admin
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.adminOpenId?.trim()
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
@@ -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 { isAdminOpenId } from './policy.ts'
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}:${openId}` of non-administrator card clicks already answered. */
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 adminOpenId(): string | undefined {
111
- const admin = this.config().adminOpenId?.trim()
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.adminOpenId()
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 open_id is configured; group card skipped',
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
- inviterOpenId: event.operator.openId,
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.openId, now))) return
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.adminOpenId()
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 open_id is configured',
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.adminOpenId()
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 open_id is configured',
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
- if (!isAdminOpenId(this.config(), event.operator.openId)) {
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, event.operator.openId)
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
- actorOpenId: string,
244
+ actorUserId: string,
244
245
  ): Promise<{ ok: true } | { ok: false; error: string }> {
245
- if (!isAdminOpenId(this.config(), actorOpenId)) return { ok: false, error: '仅管理员可处理授权申请' }
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, actorOpenId, now)
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, inviterOpenId: string, now: number): Promise<boolean> {
293
- const admin = this.adminOpenId()
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
- requesterOpenId: inviterOpenId,
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
- inviterOpenId,
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.adminOpenId()
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
- requesterOpenId: msg.senderId,
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
- openId: msg.senderId,
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
- requesterOpenId?: string
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.requesterOpenId === undefined ? {} : { requesterOpenId: input.requesterOpenId }),
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, actorOpenId: string, now: number): Promise<void> {
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: actorOpenId,
455
+ addedBy: actorUserId,
455
456
  addedAt: now,
456
457
  })
457
458
  return
458
459
  }
459
460
  await this.deps.store.addUser({
460
- openId: record.targetId,
461
+ userId: record.targetId,
461
462
  ...(record.requesterName === undefined ? {} : { name: record.requesterName }),
462
463
  source: 'card',
463
- addedBy: actorOpenId,
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()
@@ -84,16 +84,16 @@ export class AccessStore {
84
84
  return this.groupKeys.has(chatId)
85
85
  }
86
86
 
87
- hasUser(openId: string): boolean {
88
- return this.userKeys.has(openId)
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(openId: string): AccessUserRecord | undefined {
96
- return this.users.get(openId)
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.openId, record)
126
- this.users.set(record.openId, record)
127
- this.userKeys.add(record.openId)
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(openId: string): Promise<boolean> {
132
- if (!this.users.has(openId)) return false
133
- await this.requireDomain('remove access user').accessUsers.delete(openId)
134
- this.users.delete(openId)
135
- this.userKeys.delete(openId)
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
+ }