@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.
- package/README.md +302 -2
- package/cordis.patch.yml +3 -0
- package/docs/access-control-design.md +550 -0
- package/docs/assets/web-settings-panel.png +0 -0
- package/docs/assets/web-settings-restart-pending.png +0 -0
- package/docs/feasibility-and-design.md +213 -0
- package/docs/feishu-onboarding.md +268 -0
- package/docs/persistence.md +199 -0
- package/docs/refactor-plan.md +56 -0
- package/docs/review-dsh-0.2.0.md +389 -0
- package/docs/slash-commands-implementation-plan.md +410 -0
- package/docs/web-display-only-surface-roadmap.md +363 -0
- package/lib/client.js +2226 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +10217 -0
- package/lib/index.js.map +1 -0
- package/package.json +98 -4
- package/src/access/cards.ts +188 -0
- package/src/access/controller.ts +201 -0
- package/src/access/index.ts +13 -0
- package/src/access/policy.ts +103 -0
- package/src/access/requests.ts +512 -0
- package/src/access/store.ts +220 -0
- package/src/approvals.ts +8 -0
- package/src/cards/approval.ts +114 -0
- package/src/cards/index.ts +11 -0
- package/src/cards/primitives.ts +82 -0
- package/src/cards/question.ts +141 -0
- package/src/cards/streaming.ts +563 -0
- package/src/cards.ts +8 -0
- package/src/cleanup.ts +398 -0
- package/src/client/api.ts +262 -0
- package/src/client/catalog.ts +92 -0
- package/src/client/index.ts +61 -0
- package/src/client/panel.tsx +1475 -0
- package/src/client/styles.ts +98 -0
- package/src/commands/registry.ts +155 -0
- package/src/commands.ts +335 -0
- package/src/config.ts +206 -0
- package/src/domain.ts +428 -0
- package/src/dsh-types.ts +11 -0
- package/src/feishu/agent-context.ts +62 -0
- package/src/feishu/delivery.ts +188 -0
- package/src/feishu/inbound-attachments.ts +436 -0
- package/src/feishu-channel.ts +339 -0
- package/src/health.ts +233 -0
- package/src/host/agent.ts +181 -0
- package/src/host/commands.ts +63 -0
- package/src/host/core.ts +345 -0
- package/src/host/index.ts +12 -0
- package/src/host/interaction.ts +101 -0
- package/src/host/services.ts +320 -0
- package/src/index.ts +122 -0
- package/src/interactions/approvals.ts +154 -0
- package/src/interactions/pending.ts +162 -0
- package/src/interactions/questions.ts +274 -0
- package/src/questions.ts +8 -0
- package/src/routing/inbound.ts +524 -0
- package/src/service.ts +2138 -0
- package/src/session/manager.ts +352 -0
- package/src/session/repair.ts +157 -0
- package/src/session/request-policy.ts +192 -0
- package/src/session/routes.ts +165 -0
- package/src/session-commands.ts +310 -0
- package/src/session-export.ts +112 -0
- package/src/settings/catalog.ts +309 -0
- package/src/settings/contract.ts +508 -0
- package/src/settings/overlay.ts +164 -0
- package/src/settings/routes.ts +409 -0
- package/src/settings/surface.ts +773 -0
- package/src/stats.ts +485 -0
- package/src/streamer.ts +1108 -0
- 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
|
+
}
|