@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
package/package.json CHANGED
@@ -1,6 +1,100 @@
1
1
  {
2
2
  "name": "@modesto_ma/dsh-channels",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "DeepSeek Harness (DSH) 飞书渠道插件:一个 dsh 实例对应一个飞书机器人,私聊/群聊/话题群 @ 触发会话,话题绑定 session,卡片 2.0 流式回复;Web 设置里提供「飞书渠道」状态与常用配置面板",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/modestoma/dsh_channels.git"
8
+ },
9
+ "packageManager": "pnpm@12.14.0",
10
+ "type": "module",
11
+ "main": "lib/index.js",
12
+ "types": "src/index.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./src/index.ts",
16
+ "default": "./lib/index.js"
17
+ },
18
+ "./client": {
19
+ "types": "./src/client/index.ts",
20
+ "default": "./lib/client.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "src",
26
+ "lib",
27
+ "docs",
28
+ "README.md",
29
+ "cordis.patch.yml"
30
+ ],
31
+ "dsh": {
32
+ "bundle": {
33
+ "patch": "./cordis.patch.yml"
34
+ },
35
+ "client": {
36
+ "platform": "web"
37
+ }
38
+ },
39
+ "scripts": {
40
+ "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json",
41
+ "test": "vitest run",
42
+ "test:watch": "vitest",
43
+ "build:host": "tsdown --config tsdown.config.ts",
44
+ "build:client": "tsdown --config tsdown.client.config.ts",
45
+ "build": "pnpm build:host && pnpm build:client",
46
+ "verify:host": "node scripts/verify-host-loader.mjs",
47
+ "verify:client": "node scripts/verify-client-loader.mjs",
48
+ "prepack": "pnpm build",
49
+ "check": "pnpm typecheck && pnpm test && pnpm build && pnpm verify:host && pnpm verify:client"
50
+ },
51
+ "keywords": [
52
+ "deepseek-harness",
53
+ "dsh",
54
+ "dsh-plugin",
55
+ "feishu",
56
+ "lark",
57
+ "im-bridge",
58
+ "web-ui",
59
+ "settings-panel"
60
+ ],
61
+ "license": "MIT",
62
+ "engines": {
63
+ "node": "^22.19.0 || >=24.0.0"
64
+ },
65
+ "publishConfig": {
66
+ "access": "public",
67
+ "provenance": true
68
+ },
69
+ "dependencies": {
70
+ "@larksuiteoapi/node-sdk": "^1.76.0",
71
+ "zod": "^4.6.7"
72
+ },
73
+ "peerDependencies": {
74
+ "@deepseek-ai/cordis": "^4.0.1",
75
+ "react": "^18.2.0"
76
+ },
77
+ "peerDependenciesMeta": {
78
+ "@deepseek-ai/cordis": {
79
+ "optional": false
80
+ },
81
+ "react": {
82
+ "optional": true
83
+ }
84
+ },
85
+ "devDependencies": {
86
+ "@deepseek-ai/cordis": "^4.0.5",
87
+ "@semantic-release/commit-analyzer": "^13.0.1",
88
+ "@semantic-release/github": "^12.0.10",
89
+ "@semantic-release/npm": "^13.2.0",
90
+ "@semantic-release/release-notes-generator": "^14.1.1",
91
+ "@types/node": "^26.6.5",
92
+ "@types/react": "~18.3.31",
93
+ "conventional-changelog-conventionalcommits": "10.3.0",
94
+ "react": "18.3.1",
95
+ "semantic-release": "^25.0.9",
96
+ "tsdown": "0.22.2",
97
+ "typescript": "^7.0.2",
98
+ "vitest": "^5.0.5"
99
+ }
100
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Access-control cards: the bot-added group information card, the
3
+ * administrator's authorization request cards, and their terminal state.
4
+ *
5
+ * The request cards carry an opaque token in the button callback value; the
6
+ * token is resolved server-side against the durable request table, so a card
7
+ * can never grant anything by itself (see `docs/access-control-design.md`
8
+ * §5.7/§5.8).
9
+ *
10
+ * @module dsh-channels/access/cards
11
+ */
12
+
13
+ import { columnSet, divider, markdown, note, standaloneCard, textButton } from '../cards/primitives.ts'
14
+
15
+ /** Terminal status of one settled authorization request. */
16
+ export type SettledRequestStatus = 'approved' | 'denied' | 'expired'
17
+
18
+ /** Inputs of the group-information card sent to the administrator. */
19
+ export interface GroupInfoCardInput {
20
+ /** Feishu `chat_id` of the group the bot was added to (or was seen in). */
21
+ chatId: string
22
+ /** Group name, when `getChatInfo()` could read it. */
23
+ name?: string
24
+ /** Member count, when available. */
25
+ memberCount?: number
26
+ /** Group owner `open_id`, when available. */
27
+ ownerId?: string
28
+ /** `open_id` of whoever invited/triggered the bot. */
29
+ inviterOpenId?: string
30
+ /** Whether the group already passed the first admission gate. */
31
+ authorized: boolean
32
+ /** Opaque request token; only present for a *pending* group request. */
33
+ token?: string
34
+ /** Render timestamp (defaults to now). */
35
+ now?: number
36
+ }
37
+
38
+ /** Inputs of the user-authorization request card sent to the administrator. */
39
+ export interface UserRequestCardInput {
40
+ /** `open_id` of the user asking for access. */
41
+ openId: string
42
+ /** Display name of that user, when known. */
43
+ name?: string
44
+ /** Chat the request originated from. */
45
+ chatId?: string
46
+ /** Name of the authorized group the user was seen in. */
47
+ groupName?: string
48
+ /** Display name of the requester (the inbound sender). */
49
+ requesterName?: string
50
+ /** Opaque request token. */
51
+ token: string
52
+ /** Render timestamp (defaults to now). */
53
+ now?: number
54
+ }
55
+
56
+ /** Inputs of the terminal replacement card. */
57
+ export interface RequestSettledCardInput {
58
+ /** What kind of grant the request was about. */
59
+ kind: 'group' | 'user'
60
+ /** `chat_id` (group) or `open_id` (user) the request targeted. */
61
+ targetId: string
62
+ /** Human-readable target name, when known. */
63
+ targetName?: string
64
+ /** Settled state; pending requests never use this card. */
65
+ status: SettledRequestStatus
66
+ /** Render timestamp (defaults to now). */
67
+ now?: number
68
+ }
69
+
70
+ /** Truncate one display string so the card stays far below the size budget. */
71
+ function clip(value: string, max = 120): string {
72
+ const trimmed = value.trim()
73
+ return trimmed.length <= max ? trimmed : `${trimmed.slice(0, max)}…`
74
+ }
75
+
76
+ /** Render one epoch-ms instant as a stable UTC `YYYY-MM-DD HH:mm` string. */
77
+ function formatTime(timestamp?: number): string {
78
+ const value = timestamp ?? Date.now()
79
+ if (!Number.isFinite(value)) return '—'
80
+ const date = new Date(value)
81
+ if (Number.isNaN(date.getTime())) return '—'
82
+ return `${date.toISOString().replace('T', ' ').slice(0, 16)} UTC`
83
+ }
84
+
85
+ /** Two side-by-side decision buttons carrying the opaque request token. */
86
+ function decisionButtons(token: string, allowLabel: string, denyLabel: string): object {
87
+ return columnSet([
88
+ textButton({
89
+ label: allowLabel,
90
+ type: 'primary',
91
+ width: 'fill',
92
+ margin: '0px',
93
+ value: { token, action: 'allow' },
94
+ }),
95
+ textButton({
96
+ label: denyLabel,
97
+ type: 'danger',
98
+ width: 'fill',
99
+ margin: '0px',
100
+ value: { token, action: 'deny' },
101
+ }),
102
+ ])
103
+ }
104
+
105
+ /** `**字段**:值` line used across both request cards. */
106
+ function field(label: string, value: string): object {
107
+ return markdown(`**${label}**:${value}`)
108
+ }
109
+
110
+ /**
111
+ * The group-information card the administrator receives when the bot joins a
112
+ * group (and, as a fallback, on the first denied access in an unauthorized
113
+ * group). Unauthorized groups with a pending request carry「授权本群 / 忽略」.
114
+ */
115
+ export function buildGroupInfoCard(input: GroupInfoCardInput): object {
116
+ const name = input.name === undefined ? undefined : clip(input.name)
117
+ const elements: object[] = []
118
+ elements.push(field('群名', name === undefined || name === '' ? '(未知)' : name))
119
+ elements.push(field('群 ID', `\`${input.chatId}\``))
120
+ if (input.memberCount !== undefined) elements.push(field('成员数', String(input.memberCount)))
121
+ if (input.ownerId !== undefined && input.ownerId !== '') {
122
+ elements.push(field('群主', `\`${input.ownerId}\``))
123
+ }
124
+ if (input.inviterOpenId !== undefined && input.inviterOpenId !== '') {
125
+ elements.push(field('邀请人', `\`${input.inviterOpenId}\``))
126
+ }
127
+ elements.push(field('加入时间', formatTime(input.now)))
128
+ elements.push(divider())
129
+
130
+ if (!input.authorized && input.token !== undefined && input.token !== '') {
131
+ elements.push(note('该群尚未授权:群内目前只有管理员可以与我对话;授权后群内白名单用户也可使用。'))
132
+ elements.push(decisionButtons(input.token, '授权本群', '忽略'))
133
+ } else if (input.authorized) {
134
+ elements.push(note('✅ 该群已授权,群内白名单用户可正常使用(管理员不受白名单限制)。'))
135
+ } else {
136
+ elements.push(note('该群当前未授权(仅管理员可用),且没有可用的授权令牌;可在 Web 设置面板的「访问与授权」中授权。'))
137
+ }
138
+ return standaloneCard('**📋 群信息**', elements)
139
+ }
140
+
141
+ /**
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`
144
+ * from the DM card.
145
+ */
146
+ export function buildUserRequestCard(input: UserRequestCardInput): object {
147
+ const display = clip(input.name ?? input.requesterName ?? '')
148
+ const groupName = input.groupName === undefined ? undefined : clip(input.groupName)
149
+ const elements: object[] = [
150
+ field('用户', display === '' ? '(未知昵称)' : display),
151
+ field('open_id', `\`${input.openId}\``),
152
+ ]
153
+ if (groupName !== undefined && groupName !== '') {
154
+ const group = input.chatId === undefined ? groupName : `${groupName}(\`${input.chatId}\`)`
155
+ elements.push(field('所在群', group))
156
+ } else if (input.chatId !== undefined && input.chatId !== '') {
157
+ elements.push(field('所在群', `\`${input.chatId}\``))
158
+ }
159
+ elements.push(field('申请时间', formatTime(input.now)))
160
+ elements.push(divider())
161
+ elements.push(note('授权后该用户可在所有已授权群里使用;私聊仍仅管理员可用。'))
162
+ elements.push(decisionButtons(input.token, '授权用户', '忽略'))
163
+ return standaloneCard('**🙋 用户授权申请**', elements)
164
+ }
165
+
166
+ /** Title/description pairs for the three settled states. */
167
+ const SETTLED_PRESENTATION: Record<SettledRequestStatus, { title: string; line: string }> = {
168
+ approved: { title: '✅ 已授权', line: '授权已生效。' },
169
+ denied: { title: '❌ 已忽略', line: '本次申请已被忽略。' },
170
+ expired: { title: '⏱ 已过期', line: '申请超过有效期,已自动失效。' },
171
+ }
172
+
173
+ /**
174
+ * The terminal replacement for a request card (decision or expiry). Carries
175
+ * no buttons: the request token can settle exactly once.
176
+ */
177
+ export function buildRequestSettledCard(input: RequestSettledCardInput): object {
178
+ const presentation = SETTLED_PRESENTATION[input.status]
179
+ const kindLabel = input.kind === 'group' ? '群' : '用户'
180
+ const name = input.targetName === undefined ? undefined : clip(input.targetName)
181
+ const target = name === undefined || name === '' ? `\`${input.targetId}\`` : `${name}(\`${input.targetId}\`)`
182
+ const elements: object[] = [
183
+ field(kindLabel, target),
184
+ field('处理时间', formatTime(input.now)),
185
+ note(presentation.line),
186
+ ]
187
+ return standaloneCard(`**${presentation.title}**`, elements)
188
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Inbound admission controller.
3
+ *
4
+ * Every inbound Feishu message passes this gate *before* any side effect
5
+ * (Session creation, workspace, attachment archive, model call). The pure
6
+ * matrix lives in `policy.ts`; this module owns the user-facing behavior on
7
+ * denial:
8
+ *
9
+ * - private chats: non-administrators get one fixed notice (10 s per-sender
10
+ * cooldown); with no administrator configured they get the bootstrap
11
+ * notice instead;
12
+ * - group chats: fully silent, except that addressed denials call back into
13
+ * the authorization-request lifecycle so the administrator can be asked;
14
+ * - slash commands: `/userid` stays available to every private sender, the
15
+ * administrator's commands always pass (including inside an unauthorized
16
+ * group), everything else must pass admission.
17
+ *
18
+ * The controller never creates a Session or a workspace.
19
+ *
20
+ * @module dsh-channels/access/controller
21
+ */
22
+
23
+ import type { FeishuConfig } from '../config.ts'
24
+ import type { RouteRecord } from '../domain.ts'
25
+ import type { FeishuChannel, NormalizedMessage } from '../feishu-channel.ts'
26
+ import { decideAccess, isAdminOpenId } from './policy.ts'
27
+ import type { AccessDecision } from './policy.ts'
28
+ import type { AccessStore, AccessStoreSnapshot } from './store.ts'
29
+
30
+ /** Fixed private-chat notice for non-administrators (no model call). */
31
+ export const DM_DENY_TEXT = '非管理员禁用私聊功能哦~如需使用,请在管理员授权的群里 @ 我;或联系管理员把你的 open_id 加入白名单。'
32
+
33
+ /** Fixed private-chat notice while no administrator is configured. */
34
+ export const DM_UNCONFIGURED_TEXT = '机器人尚未配置管理员。发送 /userid 可查看你的 open_id,交给部署者配置后即可启用。'
35
+
36
+ /** Per-sender cooldown for the repeated private-chat notice (10 s). */
37
+ const DM_NOTICE_COOLDOWN_MS = 10_000
38
+
39
+ /** Collaborators of the admission gate. */
40
+ export interface AccessControllerDeps {
41
+ /** Current plugin configuration (administrator id + access mode). */
42
+ config(): FeishuConfig
43
+ /** Durable whitelists (read through the in-memory snapshot). */
44
+ store: AccessStore
45
+ /** Live channel, undefined before startup / after shutdown. */
46
+ channel(): FeishuChannel | undefined
47
+ /** Best-effort diagnostics; never rethrown. */
48
+ warn(message: string, ...args: unknown[]): void
49
+ /** Addressed message in an unauthorized group (request lifecycle hook). */
50
+ onDeniedGroup?(msg: NormalizedMessage): void
51
+ /** Addressed message from an unauthorized user in an authorized group. */
52
+ onDeniedUser?(msg: NormalizedMessage): void
53
+ /** Clock seam for tests. */
54
+ now?(): number
55
+ }
56
+
57
+ export class AccessController {
58
+ /** sender open_id → last fixed-notice delivery instant (DM spam guard). */
59
+ private readonly dmNoticeAt = new Map<string, number>()
60
+
61
+ constructor(private readonly deps: AccessControllerDeps) {}
62
+
63
+ private config(): FeishuConfig {
64
+ return this.deps.config()
65
+ }
66
+
67
+ private now(): number {
68
+ return this.deps.now?.() ?? Date.now()
69
+ }
70
+
71
+ /** True when `openId` is exactly the configured administrator. */
72
+ isAdmin(openId?: string): boolean {
73
+ return isAdminOpenId(this.config(), openId)
74
+ }
75
+
76
+ /** Current whitelist snapshot used by the pure policy. */
77
+ snapshot(): AccessStoreSnapshot {
78
+ return this.deps.store.snapshot()
79
+ }
80
+
81
+ /** Evaluate the admission matrix for one message (pure; no side effects). */
82
+ decide(msg: NormalizedMessage): AccessDecision {
83
+ return decideAccess(
84
+ { chatType: msg.chatType, chatId: msg.chatId, senderId: msg.senderId },
85
+ this.config(),
86
+ this.deps.store.snapshot(),
87
+ )
88
+ }
89
+
90
+ /**
91
+ * Full channel-plane admission for one non-slash message. `false` means the
92
+ * message has been handled (denied silently or with the fixed notice) and
93
+ * the caller must stop before any side effect.
94
+ */
95
+ async admit(msg: NormalizedMessage): Promise<boolean> {
96
+ const decision = this.decide(msg)
97
+ if (decision.kind === 'allow') return true
98
+ try {
99
+ if (decision.reason === 'dm-non-admin') {
100
+ if (msg.chatType === 'p2p') await this.noticeDeniedDm(msg)
101
+ return false
102
+ }
103
+ if (decision.reason === 'admin-missing') {
104
+ // Private chats get the bootstrap instructions; groups stay silent.
105
+ // Goes through the same per-sender cooldown as the regular notice so a
106
+ // single sender cannot make the bot answer every message while the
107
+ // deployment is unconfigured.
108
+ if (msg.chatType === 'p2p') await this.noticeDeniedDm(msg)
109
+ return false
110
+ }
111
+ // Group denials are silent. Only messages actually addressed to the bot
112
+ // may trigger the (administrator-facing) authorization request.
113
+ if (msg.chatType === 'group' && addressedToBot(msg)) {
114
+ if (decision.reason === 'group-not-authorized') this.deps.onDeniedGroup?.(msg)
115
+ else if (decision.reason === 'user-not-authorized') this.deps.onDeniedUser?.(msg)
116
+ }
117
+ } catch (error) {
118
+ this.deps.warn('dsh-channels: admission side effect failed: %s', String(error))
119
+ }
120
+ return false
121
+ }
122
+
123
+ /**
124
+ * Slash-command admission. Runs before any command card/topic/Session side
125
+ * effect. `open` mode keeps the legacy behavior; otherwise private chats
126
+ * stay administrator-only (with `/userid` as the bootstrap exception) and
127
+ * group chats must pass the full admission matrix; the administrator is
128
+ * never constrained by the whitelists, so all of their commands pass even
129
+ * inside an unauthorized group (`/auth` is the in-conversation grant
130
+ * entry).
131
+ */
132
+ async admitCommand(msg: NormalizedMessage, commandName: string | undefined): Promise<boolean> {
133
+ if (this.config().accessMode === 'open') return true
134
+ try {
135
+ if (msg.chatType === 'p2p') {
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.
138
+ if (commandName === 'userid') return true
139
+ if (this.isAdmin(msg.senderId)) return true
140
+ await this.noticeDeniedDm(msg)
141
+ return false
142
+ }
143
+ if (this.isAdmin(msg.senderId) && (commandName === 'auth' || commandName === 'groupid')) {
144
+ return true
145
+ }
146
+ return this.decide(msg).kind === 'allow'
147
+ } catch (error) {
148
+ this.deps.warn('dsh-channels: command admission failed: %s', String(error))
149
+ return false
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Topic/route ownership: a route belongs to the sender (and chat) that
155
+ * created it. Used to reject foreign replies, attachments, and command
156
+ * execution inside someone else's topic.
157
+ */
158
+ ownsRoute(msg: NormalizedMessage, record: RouteRecord): boolean {
159
+ return record.senderOpenId === msg.senderId && record.chatId === msg.chatId
160
+ }
161
+
162
+ /** Drop in-memory notices (shutdown; durable state lives in the store). */
163
+ clear(): void {
164
+ this.dmNoticeAt.clear()
165
+ }
166
+
167
+ // ---- internals -------------------------------------------------------
168
+
169
+ /**
170
+ * Deliver the fixed private-chat notice (cooled down). While no
171
+ * administrator is configured the bootstrap text is used instead, so users
172
+ * are never pointed at an administrator that does not exist yet.
173
+ */
174
+ private async noticeDeniedDm(msg: NormalizedMessage): Promise<void> {
175
+ const now = this.now()
176
+ const last = this.dmNoticeAt.get(msg.senderId)
177
+ if (last !== undefined && now - last < DM_NOTICE_COOLDOWN_MS) return
178
+ this.dmNoticeAt.set(msg.senderId, now)
179
+ const text = this.config().adminOpenId === undefined ? DM_UNCONFIGURED_TEXT : DM_DENY_TEXT
180
+ await this.sendText(msg.chatId, text, msg.messageId)
181
+ }
182
+
183
+ /** Best-effort fixed-text send; unavailable channel/failures only warn. */
184
+ private async sendText(chatId: string, text: string, replyTo?: string): Promise<void> {
185
+ const channel = this.deps.channel()
186
+ if (channel === undefined) {
187
+ this.deps.warn('dsh-channels: channel unavailable; admission notice not delivered')
188
+ return
189
+ }
190
+ try {
191
+ await channel.send(chatId, { text }, replyTo === undefined ? undefined : { replyTo })
192
+ } catch (error) {
193
+ this.deps.warn('dsh-channels: delivering the admission notice failed: %s', String(error))
194
+ }
195
+ }
196
+ }
197
+
198
+ /** Late-trigger condition: only messages addressed to the bot count. */
199
+ function addressedToBot(msg: NormalizedMessage): boolean {
200
+ return msg.mentionedBot === true || msg.threadId !== undefined
201
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Access-control barrel: one import specifier for the admission policy, the
3
+ * durable whitelist/request store, the admission controller, the request
4
+ * lifecycle, and the authorization cards.
5
+ *
6
+ * @module dsh-channels/access
7
+ */
8
+
9
+ export * from './policy.ts'
10
+ export * from './store.ts'
11
+ export * from './controller.ts'
12
+ export * from './requests.ts'
13
+ export * from './cards.ts'
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Pure admission policy for inbound Feishu traffic.
3
+ *
4
+ * The policy deliberately has no I/O: it takes one normalized subject, the
5
+ * relevant configuration slice, and a snapshot of the durable authorization
6
+ * tables, and returns an allow/deny decision. The controller turns a denial
7
+ * into the user-facing behavior (fixed private-chat notice, silent drop,
8
+ * authorization request card), which keeps the security-critical matrix
9
+ * cheap to unit test.
10
+ *
11
+ * Access model:
12
+ * - the administrator may start/continue a conversation in any private chat
13
+ * or any group, regardless of the whitelists;
14
+ * - private chats: non-administrators are refused;
15
+ * - group chats: non-administrators need the group to be authorized AND
16
+ * their open_id to be on the global user whitelist.
17
+ *
18
+ * @module dsh-channels/access/policy
19
+ */
20
+
21
+ import type { FeishuConfig } from '../config.ts'
22
+
23
+ /** Chat kinds this policy decides about (matches the channel normalizer). */
24
+ export type AccessChatType = 'p2p' | 'group'
25
+
26
+ /** The security-relevant facts of one inbound message. */
27
+ export interface AccessSubject {
28
+ chatType: AccessChatType
29
+ chatId: string
30
+ senderId: string
31
+ }
32
+
33
+ /** Read-only view of the durable authorization tables. */
34
+ export interface AccessSnapshot {
35
+ /** Authorized group `chat_id`s. */
36
+ groups: ReadonlySet<string>
37
+ /** Authorized user `open_id`s (global, across authorized groups). */
38
+ users: ReadonlySet<string>
39
+ }
40
+
41
+ /** Why access was denied; drives the controller's response. */
42
+ export type AccessDenyReason =
43
+ | 'admin-missing'
44
+ | 'dm-non-admin'
45
+ | 'group-not-authorized'
46
+ | 'user-not-authorized'
47
+
48
+ /** Result of evaluating one inbound message. */
49
+ export type AccessDecision =
50
+ | { readonly kind: 'allow'; readonly reason: 'open' | 'admin' | 'authorized-user' }
51
+ | {
52
+ readonly kind: 'deny'
53
+ readonly reason: AccessDenyReason
54
+ /** Which authorization request a denial may trigger, when applicable. */
55
+ readonly request?: 'group' | 'user'
56
+ }
57
+
58
+ /** The configuration slice admission reads (allows easy fakes in tests). */
59
+ export type AccessPolicyConfig = Pick<FeishuConfig, 'accessMode' | 'adminOpenId'>
60
+
61
+ /** True when `openId` is exactly the configured administrator. */
62
+ export function isAdminOpenId(
63
+ config: Pick<FeishuConfig, 'adminOpenId'>,
64
+ openId: string | undefined,
65
+ ): boolean {
66
+ const admin = config.adminOpenId?.trim()
67
+ if (admin === undefined || admin === '') return false
68
+ return openId !== undefined && openId.trim() === admin
69
+ }
70
+
71
+ /**
72
+ * Evaluate the admission matrix for one message.
73
+ *
74
+ * `open` mode reproduces the legacy behavior (the SDK's app-availability scope
75
+ * is then the only boundary) and exists for debugging/compatibility only.
76
+ */
77
+ export function decideAccess(
78
+ subject: AccessSubject,
79
+ config: AccessPolicyConfig,
80
+ snapshot: AccessSnapshot,
81
+ ): AccessDecision {
82
+ if (config.accessMode === 'open') return { kind: 'allow', reason: 'open' }
83
+
84
+ const admin = config.adminOpenId?.trim()
85
+ if (admin === undefined || admin === '') return { kind: 'deny', reason: 'admin-missing' }
86
+
87
+ // The administrator is never constrained by the whitelists: they may start
88
+ // a conversation in any private chat or any group, even one that has not
89
+ // been authorized yet. The group/user gates only restrict non-admins.
90
+ if (subject.senderId.trim() === admin) return { kind: 'allow', reason: 'admin' }
91
+
92
+ if (subject.chatType === 'p2p') {
93
+ return { kind: 'deny', reason: 'dm-non-admin' }
94
+ }
95
+
96
+ // Non-admin group access needs both gates: the group must be authorized and
97
+ // the sender must be on the global user whitelist.
98
+ if (!snapshot.groups.has(subject.chatId)) {
99
+ return { kind: 'deny', reason: 'group-not-authorized', request: 'group' }
100
+ }
101
+ if (snapshot.users.has(subject.senderId)) return { kind: 'allow', reason: 'authorized-user' }
102
+ return { kind: 'deny', reason: 'user-not-authorized', request: 'user' }
103
+ }