@modesto_ma/dsh-channels 0.0.0-stage → 0.1.0

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 (73) hide show
  1. package/README.md +302 -2
  2. package/cordis.patch.yml +3 -0
  3. package/docs/access-control-design.md +550 -0
  4. package/docs/assets/web-settings-panel.png +0 -0
  5. package/docs/assets/web-settings-restart-pending.png +0 -0
  6. package/docs/feasibility-and-design.md +213 -0
  7. package/docs/feishu-onboarding.md +268 -0
  8. package/docs/persistence.md +199 -0
  9. package/docs/refactor-plan.md +56 -0
  10. package/docs/review-dsh-0.2.0.md +389 -0
  11. package/docs/slash-commands-implementation-plan.md +410 -0
  12. package/docs/web-display-only-surface-roadmap.md +363 -0
  13. package/lib/client.js +2226 -0
  14. package/lib/client.js.map +1 -0
  15. package/lib/index.js +10217 -0
  16. package/lib/index.js.map +1 -0
  17. package/package.json +98 -4
  18. package/src/access/cards.ts +188 -0
  19. package/src/access/controller.ts +201 -0
  20. package/src/access/index.ts +13 -0
  21. package/src/access/policy.ts +103 -0
  22. package/src/access/requests.ts +512 -0
  23. package/src/access/store.ts +220 -0
  24. package/src/approvals.ts +8 -0
  25. package/src/cards/approval.ts +114 -0
  26. package/src/cards/index.ts +11 -0
  27. package/src/cards/primitives.ts +82 -0
  28. package/src/cards/question.ts +141 -0
  29. package/src/cards/streaming.ts +563 -0
  30. package/src/cards.ts +8 -0
  31. package/src/cleanup.ts +398 -0
  32. package/src/client/api.ts +262 -0
  33. package/src/client/catalog.ts +92 -0
  34. package/src/client/index.ts +61 -0
  35. package/src/client/panel.tsx +1475 -0
  36. package/src/client/styles.ts +98 -0
  37. package/src/commands/registry.ts +155 -0
  38. package/src/commands.ts +335 -0
  39. package/src/config.ts +206 -0
  40. package/src/domain.ts +428 -0
  41. package/src/dsh-types.ts +11 -0
  42. package/src/feishu/agent-context.ts +62 -0
  43. package/src/feishu/delivery.ts +188 -0
  44. package/src/feishu/inbound-attachments.ts +436 -0
  45. package/src/feishu-channel.ts +339 -0
  46. package/src/health.ts +233 -0
  47. package/src/host/agent.ts +181 -0
  48. package/src/host/commands.ts +63 -0
  49. package/src/host/core.ts +345 -0
  50. package/src/host/index.ts +12 -0
  51. package/src/host/interaction.ts +101 -0
  52. package/src/host/services.ts +320 -0
  53. package/src/index.ts +122 -0
  54. package/src/interactions/approvals.ts +154 -0
  55. package/src/interactions/pending.ts +162 -0
  56. package/src/interactions/questions.ts +274 -0
  57. package/src/questions.ts +8 -0
  58. package/src/routing/inbound.ts +524 -0
  59. package/src/service.ts +2138 -0
  60. package/src/session/manager.ts +352 -0
  61. package/src/session/repair.ts +157 -0
  62. package/src/session/request-policy.ts +192 -0
  63. package/src/session/routes.ts +165 -0
  64. package/src/session-commands.ts +310 -0
  65. package/src/session-export.ts +112 -0
  66. package/src/settings/catalog.ts +309 -0
  67. package/src/settings/contract.ts +508 -0
  68. package/src/settings/overlay.ts +164 -0
  69. package/src/settings/routes.ts +409 -0
  70. package/src/settings/surface.ts +773 -0
  71. package/src/stats.ts +485 -0
  72. package/src/streamer.ts +1108 -0
  73. package/src/workspaces.ts +199 -0
@@ -0,0 +1,512 @@
1
+ /**
2
+ * Authorization request lifecycle.
3
+ *
4
+ * Two fixed events can ask the administrator to grant access: the bot being
5
+ * added to a group, and an unauthorized user mentioning the bot inside an
6
+ * already-authorized group. Both create one durable
7
+ * `AccessRequestRecord` (opaque token, TTL from `authRequestTtlDays`), deliver
8
+ * a DM card carrying that token, and settle exactly once from the card or the
9
+ * Web panel through the same `resolve()` path. Every network/persistence
10
+ * failure degrades to a warning: admission must never throw because a card
11
+ * could not be delivered.
12
+ *
13
+ * @module dsh-channels/access/requests
14
+ */
15
+
16
+ import { randomUUID } from 'node:crypto'
17
+ import type { FeishuConfig } from '../config.ts'
18
+ import type { AccessRequestRecord } from '../domain.ts'
19
+ import type {
20
+ BotAddedEvent,
21
+ CardActionEvent,
22
+ ChatInfo,
23
+ FeishuChannel,
24
+ NormalizedMessage,
25
+ } from '../feishu-channel.ts'
26
+ import { buildGroupInfoCard, buildRequestSettledCard, buildUserRequestCard } from './cards.ts'
27
+ import type { SettledRequestStatus } from './cards.ts'
28
+ import { isAdminOpenId } from './policy.ts'
29
+ import type { AccessStore } from './store.ts'
30
+
31
+ /** Same-target re-notification cooldown after a request was denied (24 h). */
32
+ const DENIED_COOLDOWN_MS = 24 * 60 * 60 * 1000
33
+
34
+ const MS_PER_DAY = 86_400_000
35
+
36
+ /** Collaborators the request lifecycle needs, injected so tests stay offline. */
37
+ export interface AccessRequestsDeps {
38
+ /** Current plugin configuration (administrator id + request TTL). */
39
+ config(): FeishuConfig
40
+ /** Durable whitelist/request store. */
41
+ store: AccessStore
42
+ /** Live channel, undefined before startup / after shutdown. */
43
+ channel(): FeishuChannel | undefined
44
+ /** Best-effort diagnostics; never rethrown. */
45
+ warn(message: string, ...args: unknown[]): void
46
+ /** Clock seam for tests. */
47
+ now?(): number
48
+ }
49
+
50
+ /** One decoded card callback value (the token is the only trusted input). */
51
+ interface CardDecisionValue {
52
+ token: string
53
+ action: 'allow' | 'deny'
54
+ }
55
+
56
+ /** Decode one button callback value; anything malformed is silently ignored. */
57
+ function decodeCardValue(value: unknown): CardDecisionValue | undefined {
58
+ if (typeof value !== 'object' || value === null) return undefined
59
+ const record = value as Record<string, unknown>
60
+ const token = record.token
61
+ const action = record.action
62
+ if (typeof token !== 'string' || token === '') return undefined
63
+ if (action !== 'allow' && action !== 'deny') return undefined
64
+ return { token, action }
65
+ }
66
+
67
+ /** Human label for a terminal request status (error messages and cards). */
68
+ function statusLabel(status: AccessRequestRecord['status']): string {
69
+ switch (status) {
70
+ case 'approved':
71
+ return '已授权'
72
+ case 'denied':
73
+ return '已忽略'
74
+ case 'expired':
75
+ return '已过期'
76
+ default:
77
+ return '处理中'
78
+ }
79
+ }
80
+
81
+ /** Map a settled record status to the card vocabulary (`pending` has no card). */
82
+ function settledStatus(status: AccessRequestRecord['status']): SettledRequestStatus | undefined {
83
+ return status === 'pending' ? undefined : status
84
+ }
85
+
86
+ /**
87
+ * Late-trigger condition: only messages addressed to the bot may create a
88
+ * request. A plain group message in an unauthorized group stays silent.
89
+ */
90
+ function addressedToBot(msg: NormalizedMessage): boolean {
91
+ return msg.mentionedBot === true || msg.threadId !== undefined
92
+ }
93
+
94
+ export class AccessRequests {
95
+ /** Targets with an in-flight create/deliver pass (avoids duplicate cards). */
96
+ private readonly creating = new Set<string>()
97
+ /** `${messageId}:${openId}` of non-administrator card clicks already answered. */
98
+ private readonly unauthorizedNotified = new Set<string>()
99
+
100
+ constructor(private readonly deps: AccessRequestsDeps) {}
101
+
102
+ private now(): number {
103
+ return this.deps.now?.() ?? Date.now()
104
+ }
105
+
106
+ private config(): FeishuConfig {
107
+ return this.deps.config()
108
+ }
109
+
110
+ private adminOpenId(): string | undefined {
111
+ const admin = this.config().adminOpenId?.trim()
112
+ return admin === undefined || admin === '' ? undefined : admin
113
+ }
114
+
115
+ private ttlMs(): number {
116
+ return this.config().authRequestTtlDays * MS_PER_DAY
117
+ }
118
+
119
+ /**
120
+ * Bot added to a group (`im.chat.member.bot.added_v1`): DM the group
121
+ * information card to the administrator. An authorized group only gets the
122
+ * informational card; an unauthorized one gets an「授权本群 / 忽略」card
123
+ * backed by a fresh pending request (deduplicated, 24 h deny cooldown).
124
+ */
125
+ async handleBotAdded(event: BotAddedEvent): Promise<void> {
126
+ try {
127
+ const admin = this.adminOpenId()
128
+ if (admin === undefined) {
129
+ this.deps.warn(
130
+ 'dsh-channels: bot added to %s but no administrator open_id is configured; group card skipped',
131
+ event.chatId,
132
+ )
133
+ return
134
+ }
135
+ const now = this.now()
136
+ if (this.deps.store.hasGroup(event.chatId)) {
137
+ const info = await this.chatInfo(event.chatId)
138
+ await this.sendCard(admin, buildGroupInfoCard({
139
+ chatId: event.chatId,
140
+ ...(info?.name === undefined ? {} : { name: info.name }),
141
+ ...(info?.memberCount === undefined ? {} : { memberCount: info.memberCount }),
142
+ ...(info?.ownerId === undefined ? {} : { ownerId: info.ownerId }),
143
+ inviterOpenId: event.operator.openId,
144
+ authorized: true,
145
+ now,
146
+ }))
147
+ return
148
+ }
149
+ if (!(await this.beginGroupRequest(event.chatId, event.operator.openId, now))) return
150
+ } catch (error) {
151
+ this.deps.warn('dsh-channels: bot-added group notice failed: %s', String(error))
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Fallback trigger for an unauthorized group: the first *addressed* message
157
+ * in it re-sends the group information/authorization card to the
158
+ * administrator (used when the bot-added event is not subscribed).
159
+ */
160
+ async ensureGroupRequest(msg: NormalizedMessage): Promise<void> {
161
+ if (!addressedToBot(msg) || msg.chatType !== 'group') return
162
+ try {
163
+ const admin = this.adminOpenId()
164
+ if (admin === undefined) {
165
+ this.deps.warn(
166
+ 'dsh-channels: group %s needs authorization but no administrator open_id is configured',
167
+ msg.chatId,
168
+ )
169
+ return
170
+ }
171
+ if (this.deps.store.hasGroup(msg.chatId)) return
172
+ if (!(await this.beginGroupRequest(msg.chatId, msg.senderId, this.now()))) return
173
+ } catch (error) {
174
+ this.deps.warn('dsh-channels: group authorization request failed: %s', String(error))
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Lazy trigger for an unauthorized user inside an authorized group: DM the
180
+ * administrator a user-authorization request card (deduplicated, 24 h deny
181
+ * cooldown). Never runs for unauthorized groups (the group gate is first).
182
+ */
183
+ async ensureUserRequest(msg: NormalizedMessage): Promise<void> {
184
+ if (!addressedToBot(msg) || msg.chatType !== 'group') return
185
+ try {
186
+ const admin = this.adminOpenId()
187
+ if (admin === undefined) {
188
+ this.deps.warn(
189
+ 'dsh-channels: user %s needs authorization but no administrator open_id is configured',
190
+ msg.senderId,
191
+ )
192
+ return
193
+ }
194
+ const group = this.deps.store.getGroup(msg.chatId)
195
+ if (group === undefined) return
196
+ if (this.deps.store.hasUser(msg.senderId)) return
197
+ await this.beginUserRequest(msg, group.name, this.now())
198
+ } catch (error) {
199
+ this.deps.warn('dsh-channels: user authorization request failed: %s', String(error))
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Card button callback: administrator-only settlement. A stale card (already
205
+ * settled or expired) is patched to its terminal state; a request that is
206
+ * still pending goes through the same `resolve()` as the Web panel.
207
+ */
208
+ async onCardAction(event: CardActionEvent): Promise<void> {
209
+ try {
210
+ const decision = decodeCardValue(event.action?.value)
211
+ if (decision === undefined) return
212
+ const record = this.deps.store.getRequest(decision.token)
213
+ if (record === undefined) return
214
+ if (!isAdminOpenId(this.config(), event.operator.openId)) {
215
+ await this.notifyUnauthorized(event)
216
+ return
217
+ }
218
+ if (record.status !== 'pending') {
219
+ await this.patchCard(record)
220
+ return
221
+ }
222
+ if (record.expiresAt <= this.now()) {
223
+ await this.settleExpired(record)
224
+ return
225
+ }
226
+ const result = await this.resolve(decision.token, decision.action, event.operator.openId)
227
+ if (!result.ok) {
228
+ this.deps.warn('dsh-channels: settling access request %s failed: %s', decision.token, result.error)
229
+ }
230
+ } catch (error) {
231
+ this.deps.warn('dsh-channels: access card action failed: %s', String(error))
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Settle one pending request: `allow` writes the group/user grant (source
237
+ * `card`), `deny` only records the decision. Shared by the DM card and the
238
+ * Web panel; every failure is reported in the return value, never thrown.
239
+ */
240
+ async resolve(
241
+ token: string,
242
+ decision: 'allow' | 'deny',
243
+ actorOpenId: string,
244
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
245
+ if (!isAdminOpenId(this.config(), actorOpenId)) return { ok: false, error: '仅管理员可处理授权申请' }
246
+ const record = this.deps.store.getRequest(token)
247
+ if (record === undefined) return { ok: false, error: '授权申请不存在' }
248
+ if (record.status !== 'pending') {
249
+ return { ok: false, error: `该申请已处理(${statusLabel(record.status)})` }
250
+ }
251
+ const now = this.now()
252
+ if (record.expiresAt <= now) {
253
+ await this.settleExpired(record)
254
+ return { ok: false, error: '授权申请已过期' }
255
+ }
256
+ const status = decision === 'allow' ? 'approved' : 'denied'
257
+ try {
258
+ if (decision === 'allow') await this.grant(record, actorOpenId, now)
259
+ await this.deps.store.putRequest({ ...record, status })
260
+ } catch (error) {
261
+ this.deps.warn('dsh-channels: persisting access request %s settlement failed: %s', token, String(error))
262
+ return { ok: false, error: '保存授权结果失败' }
263
+ }
264
+ await this.patchCard({ ...record, status })
265
+ return { ok: true }
266
+ }
267
+
268
+ /** Mark every pending request past its deadline as expired and patch its card. */
269
+ async expirePending(): Promise<void> {
270
+ try {
271
+ const expired = await this.deps.store.expireRequests(this.now())
272
+ for (const record of expired) await this.patchCard(record)
273
+ } catch (error) {
274
+ this.deps.warn('dsh-channels: expiring access requests failed: %s', String(error))
275
+ }
276
+ }
277
+
278
+ /** Pending, not-yet-expired requests (the administrator's queue). */
279
+ pending(): AccessRequestRecord[] {
280
+ return this.deps.store.pendingRequests(this.now())
281
+ }
282
+
283
+ /** Drop in-memory dedupe guards (shutdown). Durable state lives in the store. */
284
+ clear(): void {
285
+ this.creating.clear()
286
+ this.unauthorizedNotified.clear()
287
+ }
288
+
289
+ // ---- internals -------------------------------------------------------
290
+
291
+ /** 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()
294
+ if (admin === undefined) return false
295
+ if (await this.hasLivePending('group', chatId, now)) return false
296
+ if (this.deps.store.recentlyDenied('group', chatId, DENIED_COOLDOWN_MS, now)) return false
297
+ const key = `group:${chatId}`
298
+ if (this.creating.has(key)) return false
299
+ this.creating.add(key)
300
+ try {
301
+ const info = await this.chatInfo(chatId)
302
+ const record = this.newRecord({
303
+ kind: 'group',
304
+ targetId: chatId,
305
+ chatId,
306
+ ...(info?.name === undefined ? {} : { groupName: info.name }),
307
+ requesterOpenId: inviterOpenId,
308
+ })
309
+ if (!(await this.persist(record))) return false
310
+ await this.sendCard(admin, buildGroupInfoCard({
311
+ chatId,
312
+ ...(info?.name === undefined ? {} : { name: info.name }),
313
+ ...(info?.memberCount === undefined ? {} : { memberCount: info.memberCount }),
314
+ ...(info?.ownerId === undefined ? {} : { ownerId: info.ownerId }),
315
+ inviterOpenId,
316
+ authorized: false,
317
+ token: record.token,
318
+ now,
319
+ }), record)
320
+ return true
321
+ } finally {
322
+ this.creating.delete(key)
323
+ }
324
+ }
325
+
326
+ /** Create + DM one user request unless deduplicated/cooling down. */
327
+ private async beginUserRequest(msg: NormalizedMessage, groupName: string | undefined, now: number): Promise<boolean> {
328
+ const admin = this.adminOpenId()
329
+ if (admin === undefined) return false
330
+ if (await this.hasLivePending('user', msg.senderId, now)) return false
331
+ if (this.deps.store.recentlyDenied('user', msg.senderId, DENIED_COOLDOWN_MS, now)) return false
332
+ const key = `user:${msg.senderId}`
333
+ if (this.creating.has(key)) return false
334
+ this.creating.add(key)
335
+ try {
336
+ const record = this.newRecord({
337
+ kind: 'user',
338
+ targetId: msg.senderId,
339
+ chatId: msg.chatId,
340
+ ...(groupName === undefined ? {} : { groupName }),
341
+ requesterOpenId: msg.senderId,
342
+ ...(msg.senderName === undefined ? {} : { requesterName: msg.senderName }),
343
+ })
344
+ if (!(await this.persist(record))) return false
345
+ await this.sendCard(admin, buildUserRequestCard({
346
+ openId: msg.senderId,
347
+ ...(msg.senderName === undefined ? {} : { name: msg.senderName }),
348
+ chatId: msg.chatId,
349
+ ...(groupName === undefined ? {} : { groupName }),
350
+ ...(msg.senderName === undefined ? {} : { requesterName: msg.senderName }),
351
+ token: record.token,
352
+ now,
353
+ }), record)
354
+ return true
355
+ } finally {
356
+ this.creating.delete(key)
357
+ }
358
+ }
359
+
360
+ /**
361
+ * True when an unexpired request for this target is pending. An expired but
362
+ * not-yet-swept record is settled as expired first so the new request is not
363
+ * silently blocked by the stale one.
364
+ */
365
+ private async hasLivePending(
366
+ kind: AccessRequestRecord['kind'],
367
+ targetId: string,
368
+ now: number,
369
+ ): Promise<boolean> {
370
+ const pending = this.deps.store.pendingRequestFor(kind, targetId)
371
+ if (pending === undefined) return false
372
+ if (pending.expiresAt > now) return true
373
+ await this.settleExpired(pending)
374
+ return false
375
+ }
376
+
377
+ /** Build one pending request record (token + TTL, status `pending`). */
378
+ private newRecord(input: {
379
+ kind: AccessRequestRecord['kind']
380
+ targetId: string
381
+ chatId?: string
382
+ groupName?: string
383
+ requesterOpenId?: string
384
+ requesterName?: string
385
+ }): AccessRequestRecord {
386
+ const now = this.now()
387
+ return {
388
+ token: randomUUID(),
389
+ kind: input.kind,
390
+ targetId: input.targetId,
391
+ ...(input.chatId === undefined ? {} : { chatId: input.chatId }),
392
+ ...(input.groupName === undefined || input.groupName === '' ? {} : { groupName: input.groupName }),
393
+ ...(input.requesterOpenId === undefined ? {} : { requesterOpenId: input.requesterOpenId }),
394
+ ...(input.requesterName === undefined || input.requesterName === '' ? {} : { requesterName: input.requesterName }),
395
+ status: 'pending',
396
+ createdAt: now,
397
+ expiresAt: now + this.ttlMs(),
398
+ }
399
+ }
400
+
401
+ /** Persist a request; returns false (and warns) on failure. */
402
+ private async persist(record: AccessRequestRecord): Promise<boolean> {
403
+ try {
404
+ await this.deps.store.putRequest(record)
405
+ return true
406
+ } catch (error) {
407
+ this.deps.warn('dsh-channels: persisting access request %s failed: %s', record.token, String(error))
408
+ return false
409
+ }
410
+ }
411
+
412
+ /** DM one card to the administrator and remember its message id. */
413
+ private async sendCard(to: string, card: object, record?: AccessRequestRecord): Promise<void> {
414
+ if (to === '') return
415
+ const channel = this.deps.channel()
416
+ if (channel === undefined) {
417
+ this.deps.warn('dsh-channels: channel unavailable; access card %s not delivered', record?.token ?? '(group info)')
418
+ return
419
+ }
420
+ try {
421
+ const result = await channel.send(to, { card })
422
+ if (record === undefined) return
423
+ const messageId = result?.messageId
424
+ if (typeof messageId !== 'string' || messageId === '') return
425
+ try {
426
+ await this.deps.store.putRequest({ ...record, messageId })
427
+ } catch (error) {
428
+ this.deps.warn('dsh-channels: persisting card message id for %s failed: %s', record.token, String(error))
429
+ }
430
+ } catch (error) {
431
+ this.deps.warn('dsh-channels: delivering access card %s failed: %s', record?.token ?? '(group info)', String(error))
432
+ }
433
+ }
434
+
435
+ /** Group name/owner/member count, best-effort (missing permission → warn). */
436
+ private async chatInfo(chatId: string): Promise<ChatInfo | undefined> {
437
+ const channel = this.deps.channel()
438
+ if (channel === undefined) return undefined
439
+ try {
440
+ return await channel.getChatInfo(chatId)
441
+ } catch (error) {
442
+ this.deps.warn('dsh-channels: reading chat info for %s failed: %s', chatId, String(error))
443
+ return undefined
444
+ }
445
+ }
446
+
447
+ /** Write the actual grant; throws so `resolve()` can report the failure. */
448
+ private async grant(record: AccessRequestRecord, actorOpenId: string, now: number): Promise<void> {
449
+ if (record.kind === 'group') {
450
+ await this.deps.store.addGroup({
451
+ chatId: record.targetId,
452
+ ...(record.groupName === undefined ? {} : { name: record.groupName }),
453
+ source: 'card',
454
+ addedBy: actorOpenId,
455
+ addedAt: now,
456
+ })
457
+ return
458
+ }
459
+ await this.deps.store.addUser({
460
+ openId: record.targetId,
461
+ ...(record.requesterName === undefined ? {} : { name: record.requesterName }),
462
+ source: 'card',
463
+ addedBy: actorOpenId,
464
+ addedAt: now,
465
+ })
466
+ }
467
+
468
+ /** Persist the expired status (best-effort) then patch the DM card. */
469
+ private async settleExpired(record: AccessRequestRecord): Promise<void> {
470
+ const expired: AccessRequestRecord = { ...record, status: 'expired' }
471
+ try {
472
+ await this.deps.store.putRequest(expired)
473
+ } catch (error) {
474
+ this.deps.warn('dsh-channels: persisting expired access request %s failed: %s', record.token, String(error))
475
+ }
476
+ await this.patchCard(expired)
477
+ }
478
+
479
+ /** Replace the DM card with its terminal state; delivery failures only warn. */
480
+ private async patchCard(record: AccessRequestRecord): Promise<void> {
481
+ const status = settledStatus(record.status)
482
+ if (status === undefined || record.messageId === undefined) return
483
+ const channel = this.deps.channel()
484
+ if (channel === undefined) return
485
+ const targetName = record.kind === 'group' ? record.groupName : record.requesterName
486
+ try {
487
+ await channel.updateCard(record.messageId, buildRequestSettledCard({
488
+ kind: record.kind,
489
+ targetId: record.targetId,
490
+ ...(targetName === undefined ? {} : { targetName }),
491
+ status,
492
+ now: this.now(),
493
+ }))
494
+ } catch (error) {
495
+ this.deps.warn('dsh-channels: patching access card %s failed: %s', record.messageId, String(error))
496
+ }
497
+ }
498
+
499
+ /** One control card is delivered once; non-administrator clicks get one notice. */
500
+ private async notifyUnauthorized(event: CardActionEvent): Promise<void> {
501
+ const key = `${event.messageId}:${event.operator.openId}`
502
+ if (this.unauthorizedNotified.has(key)) return
503
+ this.unauthorizedNotified.add(key)
504
+ const channel = this.deps.channel()
505
+ if (channel === undefined) return
506
+ try {
507
+ await channel.send(event.chatId, { text: '仅管理员可操作该授权卡片。' }, { replyTo: event.messageId })
508
+ } catch (error) {
509
+ this.deps.warn('dsh-channels: notifying a non-administrator card click failed: %s', String(error))
510
+ }
511
+ }
512
+ }