@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
package/package.json
CHANGED
|
@@ -1,6 +1,100 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modesto_ma/dsh-channels",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|