@modesto_ma/dsh-channels 0.5.0-beta.1 → 0.5.0-beta.3
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 +15 -7
- package/docs/access-control-design.md +1 -0
- package/docs/attachment-pipeline-v2.md +2 -2
- package/docs/feishu-onboarding.md +6 -2
- package/lib/client.js +271 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +3374 -167
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/store.ts +31 -1
- package/src/cards/picker.ts +9 -16
- package/src/cards/wiki.ts +124 -0
- package/src/client/panel/shared.tsx +96 -1
- package/src/client/string-list.ts +139 -0
- package/src/client/styles.ts +3 -0
- package/src/commands/registry.ts +6 -3
- package/src/config.ts +50 -6
- package/src/domain.ts +10 -0
- package/src/feishu/delivery.ts +34 -1
- package/src/feishu/message-read.ts +78 -2
- package/src/feishu/scopes.ts +35 -0
- package/src/feishu/wiki/a1.ts +97 -0
- package/src/feishu/wiki/api.ts +361 -0
- package/src/feishu/wiki/base-api.ts +267 -0
- package/src/feishu/wiki/base-tools.ts +423 -0
- package/src/feishu/wiki/consent.ts +183 -0
- package/src/feishu/wiki/links.ts +122 -0
- package/src/feishu/wiki/scope.ts +66 -0
- package/src/feishu/wiki/shared.ts +284 -0
- package/src/feishu/wiki/sheet-api.ts +250 -0
- package/src/feishu/wiki/sheet-tools.ts +493 -0
- package/src/feishu/wiki/tools.ts +897 -0
- package/src/feishu-channel.ts +23 -0
- package/src/service.ts +87 -7
- package/src/settings/contract.ts +42 -4
- package/src/settings/surface.ts +111 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modesto_ma/dsh-channels",
|
|
3
|
-
"version": "0.5.0-beta.
|
|
3
|
+
"version": "0.5.0-beta.3",
|
|
4
4
|
"description": "DeepSeek Harness (DSH) 飞书渠道插件:一个 dsh 实例对应一个飞书机器人,私聊/群聊/话题群 @ 触发会话,话题绑定 session,卡片 2.0 流式回复;Web 设置里提供「飞书渠道」状态与常用配置面板",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
package/src/access/store.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* every mutation writes through to the domain first and then updates memory,
|
|
7
7
|
* matching the store patterns used for routes and inflight snapshots.
|
|
8
8
|
*
|
|
9
|
-
* The tables (`access_groups`, `access_users`, `access_requests
|
|
9
|
+
* The tables (`access_groups`, `access_users`, `access_requests`,
|
|
10
|
+
* `access_wiki_users`) were added
|
|
10
11
|
* without bumping the domain version on purpose: the JSON backend's `single`
|
|
11
12
|
* layout refuses a stored-version mismatch, while unknown/missing tables are
|
|
12
13
|
* simply empty on load (see `docs/access-control-design.md` §4.2).
|
|
@@ -40,6 +41,8 @@ export class AccessStore {
|
|
|
40
41
|
private readonly groups = new Map<string, AccessGroupRecord>()
|
|
41
42
|
private readonly users = new Map<string, AccessUserRecord>()
|
|
42
43
|
private readonly requests = new Map<string, AccessRequestRecord>()
|
|
44
|
+
/** Wiki whitelist (`/auth wiki add`), independent of the user whitelist. */
|
|
45
|
+
private readonly wikiUsers = new Map<string, AccessUserRecord>()
|
|
43
46
|
/** Cached key sets so `snapshot()` can stay allocation-free per message. */
|
|
44
47
|
private readonly groupKeys = new Set<string>()
|
|
45
48
|
private readonly userKeys = new Set<string>()
|
|
@@ -65,6 +68,9 @@ export class AccessStore {
|
|
|
65
68
|
for (const [key, record] of domain.accessRequests.entries()) {
|
|
66
69
|
this.requests.set(key, record)
|
|
67
70
|
}
|
|
71
|
+
for (const [key, record] of domain.accessWikiUsers.entries()) {
|
|
72
|
+
this.wikiUsers.set(key, record)
|
|
73
|
+
}
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
/** Drop every in-memory record (shutdown). The domain owns durability. */
|
|
@@ -72,6 +78,7 @@ export class AccessStore {
|
|
|
72
78
|
this.groups.clear()
|
|
73
79
|
this.users.clear()
|
|
74
80
|
this.requests.clear()
|
|
81
|
+
this.wikiUsers.clear()
|
|
75
82
|
this.groupKeys.clear()
|
|
76
83
|
this.userKeys.clear()
|
|
77
84
|
}
|
|
@@ -136,6 +143,29 @@ export class AccessStore {
|
|
|
136
143
|
return true
|
|
137
144
|
}
|
|
138
145
|
|
|
146
|
+
/** Whether `userId` is on the wiki whitelist (the administrator is not implied). */
|
|
147
|
+
hasWikiUser(userId: string): boolean {
|
|
148
|
+
return this.wikiUsers.has(userId)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Every wiki-whitelisted user, oldest first. */
|
|
152
|
+
wikiUserRecords(): AccessUserRecord[] {
|
|
153
|
+
return [...this.wikiUsers.values()].sort((left, right) => left.addedAt - right.addedAt)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async addWikiUser(record: AccessUserRecord): Promise<AccessUserRecord> {
|
|
157
|
+
await this.requireDomain('add wiki user').accessWikiUsers.put(record.userId, record)
|
|
158
|
+
this.wikiUsers.set(record.userId, record)
|
|
159
|
+
return record
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async removeWikiUser(userId: string): Promise<boolean> {
|
|
163
|
+
if (!this.wikiUsers.has(userId)) return false
|
|
164
|
+
await this.requireDomain('remove wiki user').accessWikiUsers.delete(userId)
|
|
165
|
+
this.wikiUsers.delete(userId)
|
|
166
|
+
return true
|
|
167
|
+
}
|
|
168
|
+
|
|
139
169
|
/** Every authorization request, including settled ones (audit). */
|
|
140
170
|
requestRecords(): AccessRequestRecord[] {
|
|
141
171
|
return [...this.requests.values()].sort((left, right) => left.createdAt - right.createdAt)
|
package/src/cards/picker.ts
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
*
|
|
5
5
|
* A click carries the command and its argument back to the service, which
|
|
6
6
|
* runs it exactly like the typed `/<command> <arg>` line, so admission,
|
|
7
|
-
* ownership and validation stay in one place
|
|
7
|
+
* ownership and validation stay in one place; the result replaces the picker
|
|
8
|
+
* card in place.
|
|
8
9
|
*
|
|
9
10
|
* @module dsh-channels/cards/picker
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
|
-
import {
|
|
13
|
+
import { divider, markdown, note, standaloneCard, textButton } from './primitives.ts'
|
|
13
14
|
|
|
14
15
|
/** `decision` carried by every picker button. */
|
|
15
16
|
export const PICK_ACTION = 'command-pick'
|
|
@@ -58,36 +59,28 @@ function pickButton(input: PickerCardInput, label: string, arg: string): object
|
|
|
58
59
|
return textButton({
|
|
59
60
|
label: active ? `✅ ${label}` : label,
|
|
60
61
|
type: active ? 'primary' : 'default',
|
|
61
|
-
width: 'fill',
|
|
62
|
-
margin: '0px',
|
|
63
62
|
value,
|
|
64
63
|
})
|
|
65
64
|
}
|
|
66
65
|
|
|
67
|
-
/** Choices in two-column rows. */
|
|
68
|
-
function optionRows(input: PickerCardInput, options: readonly PickerOption[]): object[] {
|
|
69
|
-
const rows: object[] = []
|
|
70
|
-
for (let index = 0; index < options.length; index += 2) {
|
|
71
|
-
const pair = options.slice(index, index + 2).map(option => pickButton(input, option.label, option.arg))
|
|
72
|
-
rows.push(columnSet(pair))
|
|
73
|
-
}
|
|
74
|
-
return rows
|
|
75
|
-
}
|
|
76
|
-
|
|
77
66
|
/** Whether a value can be appended to a command line as one argument. */
|
|
78
67
|
export function isPickerArg(value: string): boolean {
|
|
79
68
|
return value !== '' && !/\s/u.test(value)
|
|
80
69
|
}
|
|
81
70
|
|
|
82
71
|
/**
|
|
83
|
-
* The picker card
|
|
72
|
+
* The picker card: one full-width button per choice, in a single column.
|
|
73
|
+
* Options whose argument cannot form one command-line token
|
|
84
74
|
* are skipped; when more than `MAX_PICKER_OPTIONS` remain the rest are
|
|
85
75
|
* reported as typed-only.
|
|
86
76
|
*/
|
|
87
77
|
export function buildPickerCard(input: PickerCardInput): object {
|
|
88
78
|
const usable = input.options.filter(option => isPickerArg(option.arg))
|
|
89
79
|
const shown = usable.slice(0, MAX_PICKER_OPTIONS)
|
|
90
|
-
const elements: object[] = [
|
|
80
|
+
const elements: object[] = [
|
|
81
|
+
markdown(input.text),
|
|
82
|
+
...shown.map(option => pickButton(input, option.label, option.arg)),
|
|
83
|
+
]
|
|
91
84
|
if (usable.length > shown.length) {
|
|
92
85
|
elements.push(note(`另有 ${usable.length - shown.length} 项未展示,可直接输入 /${input.command} <id>。`))
|
|
93
86
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wiki (知识库) authorization cards: the topic owner's consent before the
|
|
3
|
+
* model opens, creates, or edits a wiki document.
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-channels/cards/wiki
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { columnSet, divider, markdown, note, standaloneCard, textButton } from './primitives.ts'
|
|
9
|
+
|
|
10
|
+
/** What one wiki consent covers. */
|
|
11
|
+
export type WikiConsentAction = 'read' | 'create' | 'edit'
|
|
12
|
+
|
|
13
|
+
/** Terminal states of a wiki consent card. */
|
|
14
|
+
export type WikiConsentCardState = 'allowed' | 'denied' | 'expired' | 'cancelled'
|
|
15
|
+
|
|
16
|
+
/** The question one wiki consent card presents. */
|
|
17
|
+
export interface WikiConsentQuestion {
|
|
18
|
+
/** Opaque token binding the card to one pending consent (button value). */
|
|
19
|
+
readonly token: string
|
|
20
|
+
readonly action: WikiConsentAction
|
|
21
|
+
/** Document title (create: the new title). */
|
|
22
|
+
readonly title: string
|
|
23
|
+
/** Document link (read/edit) or parent link (create), when known. */
|
|
24
|
+
readonly url?: string
|
|
25
|
+
/** Where the document lives or will live, e.g. `空间名 / 父节点`. */
|
|
26
|
+
readonly location: string
|
|
27
|
+
/** Extra lines: operations of an edit, the content preview of a create. */
|
|
28
|
+
readonly details: readonly string[]
|
|
29
|
+
/** Timeout window shown on the card (milliseconds). */
|
|
30
|
+
readonly timeoutMs: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Card rendering options. */
|
|
34
|
+
export interface WikiConsentCardOptions {
|
|
35
|
+
/** Add the owner-only notice (someone else clicked and was ignored). */
|
|
36
|
+
readonly unauthorizedNote?: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Cap on one rendered detail block so the card stays readable on a phone. */
|
|
40
|
+
const DETAIL_MAX_CHARS = 800
|
|
41
|
+
|
|
42
|
+
const ACTION_LABELS: Readonly<Record<WikiConsentAction, string>> = {
|
|
43
|
+
read: '读取文档',
|
|
44
|
+
create: '新建文档',
|
|
45
|
+
edit: '修改文档',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const SETTLED_LINES: Readonly<Record<WikiConsentCardState, string>> = {
|
|
49
|
+
allowed: '✅ 已允许',
|
|
50
|
+
denied: '❌ 已拒绝',
|
|
51
|
+
expired: '⏱ 已超时未处理,自动拒绝',
|
|
52
|
+
cancelled: '↩️ 请求已取消',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Clip one value to `max` characters, marking the cut. */
|
|
56
|
+
function clip(value: string, max: number): string {
|
|
57
|
+
return value.length <= max ? value : `${value.slice(0, max)}…(已截断 ${value.length - max} 个字符)`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One line of Markdown text with link/markup characters neutralized. */
|
|
61
|
+
function plain(value: string): string {
|
|
62
|
+
return value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/([[\]()*_`~<>])/gu, '\\$1').trim()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The question summary shared by the pending and settled variants. */
|
|
66
|
+
function questionElements(question: WikiConsentQuestion): object[] {
|
|
67
|
+
const title = question.title === '' ? '(无标题)' : plain(question.title)
|
|
68
|
+
const titleLine = question.url === undefined || question.action === 'create'
|
|
69
|
+
? title
|
|
70
|
+
: `[${title}](${question.url})`
|
|
71
|
+
const elements: object[] = [
|
|
72
|
+
markdown([
|
|
73
|
+
`机器人请求以应用身份**${ACTION_LABELS[question.action]}**。`,
|
|
74
|
+
`**文档**:${titleLine}`,
|
|
75
|
+
`**位置**:${plain(question.location)}`,
|
|
76
|
+
].join('\n')),
|
|
77
|
+
]
|
|
78
|
+
for (const detail of question.details) {
|
|
79
|
+
elements.push(markdown(clip(detail, DETAIL_MAX_CHARS)))
|
|
80
|
+
}
|
|
81
|
+
return elements
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function consentButton(question: WikiConsentQuestion, decision: 'allow' | 'deny'): object {
|
|
85
|
+
return textButton({
|
|
86
|
+
label: decision === 'allow' ? '✅ 允许' : '❌ 拒绝',
|
|
87
|
+
type: decision === 'allow' ? 'primary' : 'danger',
|
|
88
|
+
width: 'fill',
|
|
89
|
+
value: { decision, token: question.token },
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The pending consent card, sent as a reply in the session's topic.
|
|
95
|
+
*
|
|
96
|
+
* @param question The consent asked for.
|
|
97
|
+
* @param options Rendering options.
|
|
98
|
+
* @returns Card JSON 2.0.
|
|
99
|
+
*/
|
|
100
|
+
export function buildWikiConsentCard(question: WikiConsentQuestion, options: WikiConsentCardOptions = {}): object {
|
|
101
|
+
const minutes = Math.max(1, Math.round(question.timeoutMs / 60_000))
|
|
102
|
+
const scope = question.action === 'read'
|
|
103
|
+
? '允许后,本话题内再次读取这篇文档不再询问'
|
|
104
|
+
: '仅对这一次操作生效'
|
|
105
|
+
const elements: object[] = [
|
|
106
|
+
...questionElements(question),
|
|
107
|
+
divider(),
|
|
108
|
+
note(`⏱ ${minutes} 分钟内有效,超时自动拒绝 · ${scope}`),
|
|
109
|
+
]
|
|
110
|
+
if (options.unauthorizedNote === true) elements.push(note('ℹ️ 仅发起该话题的用户可操作此卡片'))
|
|
111
|
+
elements.push(columnSet([consentButton(question, 'allow'), consentButton(question, 'deny')]))
|
|
112
|
+
return standaloneCard('**📚 知识库授权**', elements)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The terminal replacement after the consent settles; no controls.
|
|
117
|
+
*
|
|
118
|
+
* @param question The consent that was asked for.
|
|
119
|
+
* @param state How it settled.
|
|
120
|
+
* @returns Card JSON 2.0.
|
|
121
|
+
*/
|
|
122
|
+
export function buildWikiConsentSettledCard(question: WikiConsentQuestion, state: WikiConsentCardState): object {
|
|
123
|
+
return standaloneCard(`**${SETTLED_LINES[state]}**`, [...questionElements(question), divider(), note(`📚 知识库${ACTION_LABELS[question.action]}`)])
|
|
124
|
+
}
|
|
@@ -23,6 +23,15 @@ import type {
|
|
|
23
23
|
PanelTabId,
|
|
24
24
|
PanelWire,
|
|
25
25
|
} from '../../settings/contract.ts'
|
|
26
|
+
import {
|
|
27
|
+
listIsFull,
|
|
28
|
+
listItems,
|
|
29
|
+
listRows,
|
|
30
|
+
stringListDraftValue,
|
|
31
|
+
withBlankListEntry,
|
|
32
|
+
withListEntry,
|
|
33
|
+
withoutListEntry,
|
|
34
|
+
} from '../string-list.ts'
|
|
26
35
|
|
|
27
36
|
/** Initial panel load: one request, retried from the error card. */
|
|
28
37
|
export type LoadState =
|
|
@@ -126,6 +135,12 @@ export function validateDraft(
|
|
|
126
135
|
values[field.key] = raw === true
|
|
127
136
|
continue
|
|
128
137
|
}
|
|
138
|
+
if (field.kind === 'string-list') {
|
|
139
|
+
const list = stringListDraftValue(field, raw)
|
|
140
|
+
if (list.ok) values[field.key] = list.value
|
|
141
|
+
else issues.push(...list.issues)
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
129
144
|
values[field.key] = String(raw ?? '')
|
|
130
145
|
}
|
|
131
146
|
return { values, issues }
|
|
@@ -236,6 +251,73 @@ function DirectoryTextInput(props: {
|
|
|
236
251
|
)
|
|
237
252
|
}
|
|
238
253
|
|
|
254
|
+
/**
|
|
255
|
+
* `string-list` control: one single-line input per entry, each with a remove
|
|
256
|
+
* button, and an add button below (disabled at `maxItems` inputs). An empty
|
|
257
|
+
* list renders one blank input so the first entry can be typed directly.
|
|
258
|
+
* Issues carrying an `index` mark that input and show the reason under it.
|
|
259
|
+
*/
|
|
260
|
+
function StringListInput(props: {
|
|
261
|
+
field: EditableFieldWire
|
|
262
|
+
value: ConfigValue | undefined
|
|
263
|
+
issues: readonly FieldIssueWire[]
|
|
264
|
+
disabled: boolean
|
|
265
|
+
onChange: (key: EditableKey, value: ConfigValue) => void
|
|
266
|
+
}): JSX.Element {
|
|
267
|
+
const { field, value, issues, disabled, onChange } = props
|
|
268
|
+
const empty = listItems(value).length === 0
|
|
269
|
+
const rows = listRows(value)
|
|
270
|
+
return (
|
|
271
|
+
<div className="dsh-channels-cfg__list">
|
|
272
|
+
{rows.map((item, index) => {
|
|
273
|
+
const issue = issues.find(entry => entry.index === index)
|
|
274
|
+
const position = PANEL_COPY.listEntry.replace('{n}', String(index + 1))
|
|
275
|
+
// Entries have no identity beyond their position, so the index is the key.
|
|
276
|
+
return (
|
|
277
|
+
<div className="dsh-channels-cfg__listItem" key={index}>
|
|
278
|
+
<div className="dsh-channels-cfg__inputRow">
|
|
279
|
+
<input
|
|
280
|
+
type="text"
|
|
281
|
+
className={`dsh-channels-cfg__input${issue === undefined ? '' : ' dsh-channels-cfg__input--invalid'}`}
|
|
282
|
+
value={item}
|
|
283
|
+
placeholder={field.placeholder}
|
|
284
|
+
aria-label={`${field.label} ${position}`}
|
|
285
|
+
aria-invalid={issue === undefined ? undefined : true}
|
|
286
|
+
disabled={disabled}
|
|
287
|
+
onChange={event => onChange(field.key, withListEntry(rows, index, event.target.value))}
|
|
288
|
+
/>
|
|
289
|
+
{empty
|
|
290
|
+
? null
|
|
291
|
+
: (
|
|
292
|
+
<button
|
|
293
|
+
type="button"
|
|
294
|
+
className="dsh-channels-cfg__btn dsh-channels-cfg__btn--listRemove"
|
|
295
|
+
aria-label={`${PANEL_COPY.listRemove} ${position}`}
|
|
296
|
+
disabled={disabled}
|
|
297
|
+
onClick={() => onChange(field.key, withoutListEntry(rows, index))}
|
|
298
|
+
>
|
|
299
|
+
{PANEL_COPY.listRemove}
|
|
300
|
+
</button>
|
|
301
|
+
)}
|
|
302
|
+
</div>
|
|
303
|
+
{issue === undefined ? null : <span className="dsh-channels-cfg__fieldError">{issue.message}</span>}
|
|
304
|
+
</div>
|
|
305
|
+
)
|
|
306
|
+
})}
|
|
307
|
+
<div>
|
|
308
|
+
<button
|
|
309
|
+
type="button"
|
|
310
|
+
className="dsh-channels-cfg__btn"
|
|
311
|
+
disabled={disabled || listIsFull(field, rows)}
|
|
312
|
+
onClick={() => onChange(field.key, withBlankListEntry(rows))}
|
|
313
|
+
>
|
|
314
|
+
{PANEL_COPY.listAdd}
|
|
315
|
+
</button>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
239
321
|
/** One generic form control driven by the Host field descriptor. */
|
|
240
322
|
export function FieldRow(props: {
|
|
241
323
|
field: EditableFieldWire
|
|
@@ -247,7 +329,9 @@ export function FieldRow(props: {
|
|
|
247
329
|
onPickDirectory?: () => Promise<void>
|
|
248
330
|
}): JSX.Element {
|
|
249
331
|
const { field, value, issues, disabled = false, onChange, onPickDirectory } = props
|
|
250
|
-
|
|
332
|
+
// Entry issues (`index` set) are rendered next to their input by StringListInput.
|
|
333
|
+
const issue = issues.find(item => item.key === field.key && item.index === undefined)
|
|
334
|
+
const entryIssues = issues.filter(item => item.key === field.key && item.index !== undefined)
|
|
251
335
|
const inputClass = `dsh-channels-cfg__input${issue === undefined ? '' : ' dsh-channels-cfg__input--invalid'}`
|
|
252
336
|
const fieldClass = `dsh-channels-cfg__field${field.wide === true ? ' dsh-channels-cfg__field--wide' : ''}`
|
|
253
337
|
return (
|
|
@@ -327,6 +411,17 @@ export function FieldRow(props: {
|
|
|
327
411
|
/>
|
|
328
412
|
))
|
|
329
413
|
: null}
|
|
414
|
+
{field.kind === 'string-list'
|
|
415
|
+
? (
|
|
416
|
+
<StringListInput
|
|
417
|
+
field={field}
|
|
418
|
+
value={value}
|
|
419
|
+
issues={entryIssues}
|
|
420
|
+
disabled={disabled}
|
|
421
|
+
onChange={onChange}
|
|
422
|
+
/>
|
|
423
|
+
)
|
|
424
|
+
: null}
|
|
330
425
|
{field.help === undefined ? null : <span className="dsh-channels-cfg__help">{field.help}</span>}
|
|
331
426
|
{issue === undefined ? null : <span className="dsh-channels-cfg__fieldError">{issue.message}</span>}
|
|
332
427
|
</div>
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 浏览器半区的 `string-list` 字段辅助:把草稿值读成条目数组,并在保存前
|
|
3
|
+
* 逐项校验,供面板即时标红。Host 保存时按同一规则复核(见
|
|
4
|
+
* `settings/surface.ts` 的 `normalizeStringList`)。
|
|
5
|
+
*
|
|
6
|
+
* 不含 JSX/React,便于单测。条目格式校验直接复用纯函数模块
|
|
7
|
+
* `feishu/wiki/links.ts`(无运行时依赖,可内联进 Client bundle)。
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-channels/client/string-list
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { parseWikiRootLink, WIKI_ROOT_LINK_ERRORS } from '../feishu/wiki/links.ts'
|
|
13
|
+
import { PANEL_COPY } from '../settings/contract.ts'
|
|
14
|
+
import type {
|
|
15
|
+
ConfigValue,
|
|
16
|
+
EditableFieldWire,
|
|
17
|
+
FieldIssueWire,
|
|
18
|
+
StringListItemFormat,
|
|
19
|
+
} from '../settings/contract.ts'
|
|
20
|
+
|
|
21
|
+
/** Entry checks per item format: the readable reason, or `undefined` when valid. */
|
|
22
|
+
const ITEM_FORMAT_CHECKS: Readonly<Record<StringListItemFormat, (entry: string) => string | undefined>> = {
|
|
23
|
+
'wiki-root-link': (entry) => {
|
|
24
|
+
const parsed = parseWikiRootLink(entry)
|
|
25
|
+
return parsed.ok ? undefined : WIKI_ROOT_LINK_ERRORS[parsed.error]
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Entries of a `string-list` value; any other value reads as no entries.
|
|
31
|
+
*
|
|
32
|
+
* @param value - Draft or effective value of a `string-list` field.
|
|
33
|
+
* @returns The entries as stored (untrimmed).
|
|
34
|
+
*/
|
|
35
|
+
export function listItems(value: ConfigValue | undefined): readonly string[] {
|
|
36
|
+
return typeof value === 'object' ? value : []
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Validate the entries of one `string-list` draft the way the Host will:
|
|
41
|
+
* empty inputs are ignored, every other entry is trimmed and checked against
|
|
42
|
+
* the field's `itemFormat`, and the non-empty count is bounded by `maxItems`.
|
|
43
|
+
*
|
|
44
|
+
* @param field - The Host descriptor (`kind: 'string-list'`).
|
|
45
|
+
* @param items - The draft entries, empty inputs included.
|
|
46
|
+
* @returns Per-entry issues (with `index` into `items`) and a whole-field issue when over `maxItems`.
|
|
47
|
+
*/
|
|
48
|
+
export function stringListIssues(field: EditableFieldWire, items: readonly string[]): FieldIssueWire[] {
|
|
49
|
+
const issues: FieldIssueWire[] = []
|
|
50
|
+
const check = field.itemFormat === undefined ? undefined : ITEM_FORMAT_CHECKS[field.itemFormat]
|
|
51
|
+
let count = 0
|
|
52
|
+
items.forEach((item, index) => {
|
|
53
|
+
const entry = item.trim()
|
|
54
|
+
if (entry === '') return
|
|
55
|
+
count += 1
|
|
56
|
+
const reason = check?.(entry)
|
|
57
|
+
if (reason === undefined) return
|
|
58
|
+
const position = PANEL_COPY.listEntry.replace('{n}', String(index + 1))
|
|
59
|
+
issues.push({ key: field.key, index, message: `${position}「${entry}」:${reason}` })
|
|
60
|
+
})
|
|
61
|
+
if (field.maxItems !== undefined && count > field.maxItems) {
|
|
62
|
+
issues.push({ key: field.key, message: PANEL_COPY.listTooMany.replace('{max}', String(field.maxItems)) })
|
|
63
|
+
}
|
|
64
|
+
return issues
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Turn one `string-list` draft into its save payload. Entries are submitted as
|
|
69
|
+
* typed (untrimmed, empty inputs included) so Host issue indexes point at the
|
|
70
|
+
* same rendered inputs; the Host trims and drops empty inputs.
|
|
71
|
+
*
|
|
72
|
+
* @param field - The Host descriptor (`kind: 'string-list'`).
|
|
73
|
+
* @param raw - The draft value.
|
|
74
|
+
* @returns The payload entries, or the issues that block the save.
|
|
75
|
+
*/
|
|
76
|
+
export function stringListDraftValue(
|
|
77
|
+
field: EditableFieldWire,
|
|
78
|
+
raw: ConfigValue | undefined,
|
|
79
|
+
): { ok: true; value: string[] } | { ok: false; issues: FieldIssueWire[] } {
|
|
80
|
+
const items = listItems(raw)
|
|
81
|
+
const issues = stringListIssues(field, items)
|
|
82
|
+
return issues.length > 0 ? { ok: false, issues } : { ok: true, value: [...items] }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Inputs the list control renders: one per entry, or one blank input for an
|
|
87
|
+
* empty list so the first entry can be typed directly.
|
|
88
|
+
*
|
|
89
|
+
* @param value - Draft or effective value.
|
|
90
|
+
* @returns The input values, in order.
|
|
91
|
+
*/
|
|
92
|
+
export function listRows(value: ConfigValue | undefined): readonly string[] {
|
|
93
|
+
const items = listItems(value)
|
|
94
|
+
return items.length === 0 ? [''] : items
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Replace the entry at one input position.
|
|
99
|
+
*
|
|
100
|
+
* @param rows - Rendered inputs ({@link listRows}).
|
|
101
|
+
* @param index - Position of the edited input.
|
|
102
|
+
* @param entry - The typed value.
|
|
103
|
+
* @returns The next draft entries.
|
|
104
|
+
*/
|
|
105
|
+
export function withListEntry(rows: readonly string[], index: number, entry: string): string[] {
|
|
106
|
+
return rows.map((item, position) => (position === index ? entry : item))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Drop the entry at one input position.
|
|
111
|
+
*
|
|
112
|
+
* @param rows - Rendered inputs ({@link listRows}).
|
|
113
|
+
* @param index - Position of the removed input.
|
|
114
|
+
* @returns The next draft entries.
|
|
115
|
+
*/
|
|
116
|
+
export function withoutListEntry(rows: readonly string[], index: number): string[] {
|
|
117
|
+
return rows.filter((_, position) => position !== index)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Append one blank input.
|
|
122
|
+
*
|
|
123
|
+
* @param rows - Rendered inputs ({@link listRows}).
|
|
124
|
+
* @returns The next draft entries.
|
|
125
|
+
*/
|
|
126
|
+
export function withBlankListEntry(rows: readonly string[]): string[] {
|
|
127
|
+
return [...rows, '']
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Whether the add button is disabled: the inputs already reach `maxItems`.
|
|
132
|
+
*
|
|
133
|
+
* @param field - The Host descriptor.
|
|
134
|
+
* @param rows - Rendered inputs ({@link listRows}).
|
|
135
|
+
* @returns `true` when no further input may be added.
|
|
136
|
+
*/
|
|
137
|
+
export function listIsFull(field: EditableFieldWire, rows: readonly string[]): boolean {
|
|
138
|
+
return field.maxItems !== undefined && rows.length >= field.maxItems
|
|
139
|
+
}
|
package/src/client/styles.ts
CHANGED
|
@@ -59,6 +59,9 @@ const CSS = `
|
|
|
59
59
|
.dsh-channels-cfg__inputRow{display:flex;align-items:center;gap:8px;min-width:0}
|
|
60
60
|
.dsh-channels-cfg__inputRow .dsh-channels-cfg__input{flex:1 1 auto;width:auto;min-width:0}
|
|
61
61
|
.dsh-channels-cfg__btn--browse{flex:none}
|
|
62
|
+
.dsh-channels-cfg__list{display:flex;flex-direction:column;gap:6px;min-width:0}
|
|
63
|
+
.dsh-channels-cfg__listItem{display:flex;flex-direction:column;gap:2px;min-width:0}
|
|
64
|
+
.dsh-channels-cfg__btn--listRemove{flex:none}
|
|
62
65
|
.dsh-channels-cfg__input:focus{border-color:var(--dsw-alias-state-business-primary,#0a67d3)}
|
|
63
66
|
.dsh-channels-cfg__input--invalid{border-color:var(--dsw-alias-state-danger-primary,#c62828)}
|
|
64
67
|
select.dsh-channels-cfg__input{appearance:auto;cursor:pointer}
|
package/src/commands/registry.ts
CHANGED
|
@@ -28,7 +28,10 @@ export interface ChannelCommandSpec {
|
|
|
28
28
|
readonly mentionGate?: boolean
|
|
29
29
|
/** Only usable in this chat type. */
|
|
30
30
|
readonly chatType?: 'p2p' | 'group'
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Answer with a direct reply instead of creating a topic; inside an existing
|
|
33
|
+
* topic the reply still lands in that topic.
|
|
34
|
+
*/
|
|
32
35
|
readonly directReply?: boolean
|
|
33
36
|
/**
|
|
34
37
|
* Answered by the channel itself (`/stop`), never through the agent command
|
|
@@ -47,7 +50,7 @@ export interface ChannelCommandSpec {
|
|
|
47
50
|
export const CHANNEL_COMMANDS = [
|
|
48
51
|
{
|
|
49
52
|
name: 'help', group: '基础', description: '显示当前支持的斜杠命令',
|
|
50
|
-
routeFree: true, directReply:
|
|
53
|
+
routeFree: true, directReply: true,
|
|
51
54
|
},
|
|
52
55
|
{
|
|
53
56
|
name: 'userid', group: '基础', description: '在私聊中显示当前用户 ID',
|
|
@@ -59,7 +62,7 @@ export const CHANNEL_COMMANDS = [
|
|
|
59
62
|
},
|
|
60
63
|
{
|
|
61
64
|
name: 'health', group: '基础', description: '显示 Harness 与飞书通道健康状态',
|
|
62
|
-
sessionOptional: true,
|
|
65
|
+
sessionOptional: true, directReply: true,
|
|
63
66
|
},
|
|
64
67
|
{
|
|
65
68
|
name: 'status', group: '会话', description: '显示当前 Session 状态与性能指标',
|
package/src/config.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { z } from 'zod'
|
|
16
16
|
import { FEISHU_USER_ID_PATTERN } from './domain.ts'
|
|
17
|
+
import { parseWikiRootLink, WIKI_ROOT_LINK_ERRORS } from './feishu/wiki/links.ts'
|
|
17
18
|
|
|
18
19
|
export const FEISHU_LOGGER_LEVELS = ['debug', 'info', 'warn', 'error'] as const
|
|
19
20
|
export type FeishuLoggerLevel = (typeof FEISHU_LOGGER_LEVELS)[number]
|
|
@@ -47,6 +48,9 @@ export const DEFAULT_ATTACHMENT_FETCH_MAX_BYTES = 100 * 1024 * 1024
|
|
|
47
48
|
/** Default whole-call timeout of `feishu_attachment_fetch` (120 s). */
|
|
48
49
|
export const DEFAULT_ATTACHMENT_FETCH_TIMEOUT_MS = 120_000
|
|
49
50
|
|
|
51
|
+
/** Largest delay Node's `setTimeout` honors (2^31 − 1 ms, about 24.8 days); longer delays fire at once. */
|
|
52
|
+
export const MAX_TIMER_DELAY_MS = 2_147_483_647
|
|
53
|
+
|
|
50
54
|
/** Default per-image byte cap for trigger-message prefetch (20 MiB). */
|
|
51
55
|
export const DEFAULT_PREFETCH_IMAGE_MAX_BYTES = 20 * 1024 * 1024
|
|
52
56
|
|
|
@@ -264,10 +268,12 @@ export const configSchema = z.object({
|
|
|
264
268
|
* fetch (1–50). A message with more attachments is refused with a readable
|
|
265
269
|
* reason so the model can retry per `file_key`.
|
|
266
270
|
* - `attachmentFetchMaxBytes`: total download budget of one tool call
|
|
267
|
-
* (default 100 MiB). Files stream to the native attachment
|
|
268
|
-
* limit is enforced while streaming rather than buffered
|
|
269
|
-
* -
|
|
270
|
-
*
|
|
271
|
+
* (default 100 MiB, no upper bound). Files stream to the native attachment
|
|
272
|
+
* library, so the limit is enforced while streaming rather than buffered
|
|
273
|
+
* first, and multi-GiB files need only disk space.
|
|
274
|
+
* - `attachmentFetchTimeoutMs`: whole-call timeout (default 120 s, at most
|
|
275
|
+
* {@link MAX_TIMER_DELAY_MS}). On expiry the in-flight download is
|
|
276
|
+
* cancelled and reported per attachment.
|
|
271
277
|
* - `topicMessages` (default on): register `feishu_topic_messages` in
|
|
272
278
|
* channel sessions so the model can read the quoted message / earlier
|
|
273
279
|
* topic messages on demand and authorize their attachments for fetch.
|
|
@@ -287,20 +293,44 @@ export const configSchema = z.object({
|
|
|
287
293
|
* `im:resource` (or `im:resource:upload`) scope; without it a call returns
|
|
288
294
|
* a readable failure and the turn continues.
|
|
289
295
|
*
|
|
296
|
+
* - `wikiRead` (default off): register `feishu_wiki_list`,
|
|
297
|
+
* `feishu_wiki_read`, `feishu_wiki_sheet_read`, and
|
|
298
|
+
* `feishu_wiki_base_read` so the model can browse the wiki nodes under
|
|
299
|
+
* `wikiRoots` and read docx documents, spreadsheets, and Bases there.
|
|
300
|
+
* Needs `wiki:wiki` (or `wiki:wiki:readonly`), `docx:document` (or
|
|
301
|
+
* `docx:document:readonly`), `sheets:spreadsheet` (or a read-only
|
|
302
|
+
* spreadsheet scope), and `bitable:app` (or a read-only Base scope).
|
|
303
|
+
* - `wikiWrite` (default off): register `feishu_wiki_create`,
|
|
304
|
+
* `feishu_wiki_edit`, `feishu_wiki_sheet_edit`, and
|
|
305
|
+
* `feishu_wiki_base_edit` so the model can create docx documents,
|
|
306
|
+
* spreadsheets, and Bases under `wikiRoots`, edit documents and
|
|
307
|
+
* spreadsheets this app owns, and change records of Bases this app owns
|
|
308
|
+
* (other Bases accept new records only). Needs `wiki:wiki`,
|
|
309
|
+
* `docx:document`, `sheets:spreadsheet`, and `bitable:app` (or the
|
|
310
|
+
* matching write-only scopes).
|
|
311
|
+
*
|
|
312
|
+
* The wiki tools act with the app identity and are mounted only while
|
|
313
|
+
* `wikiRoots` is non-empty. Each call is limited to topics whose owner is
|
|
314
|
+
* the administrator or on the wiki whitelist (`/auth wiki add`); opening a
|
|
315
|
+
* document, creating one, and editing one each need the topic owner's
|
|
316
|
+
* consent on an authorization card (a read consent lasts for the topic).
|
|
317
|
+
*
|
|
290
318
|
* The Web panel/metrics ship with `panel_docs_release`.
|
|
291
319
|
*/
|
|
292
320
|
feishuTools: z.object({
|
|
293
321
|
attachmentFetch: z.boolean().default(true),
|
|
294
322
|
attachmentFetchMaxResources: z.number().int().min(1).max(50)
|
|
295
323
|
.default(DEFAULT_ATTACHMENT_FETCH_MAX_RESOURCES),
|
|
296
|
-
attachmentFetchMaxBytes: z.number().int().min(1)
|
|
324
|
+
attachmentFetchMaxBytes: z.number().int().min(1)
|
|
297
325
|
.default(DEFAULT_ATTACHMENT_FETCH_MAX_BYTES),
|
|
298
|
-
attachmentFetchTimeoutMs: z.number().int().min(1_000).max(
|
|
326
|
+
attachmentFetchTimeoutMs: z.number().int().min(1_000).max(MAX_TIMER_DELAY_MS)
|
|
299
327
|
.default(DEFAULT_ATTACHMENT_FETCH_TIMEOUT_MS),
|
|
300
328
|
topicMessages: z.boolean().default(true),
|
|
301
329
|
topicMessagesTimeoutMs: z.number().int().min(1_000).max(120_000)
|
|
302
330
|
.default(DEFAULT_TOPIC_MESSAGES_TIMEOUT_MS),
|
|
303
331
|
sendFile: z.boolean().default(true),
|
|
332
|
+
wikiRead: z.boolean().default(false),
|
|
333
|
+
wikiWrite: z.boolean().default(false),
|
|
304
334
|
}).default({
|
|
305
335
|
attachmentFetch: true,
|
|
306
336
|
attachmentFetchMaxResources: DEFAULT_ATTACHMENT_FETCH_MAX_RESOURCES,
|
|
@@ -309,7 +339,21 @@ export const configSchema = z.object({
|
|
|
309
339
|
topicMessages: true,
|
|
310
340
|
topicMessagesTimeoutMs: DEFAULT_TOPIC_MESSAGES_TIMEOUT_MS,
|
|
311
341
|
sendFile: true,
|
|
342
|
+
wikiRead: false,
|
|
343
|
+
wikiWrite: false,
|
|
312
344
|
}),
|
|
345
|
+
/**
|
|
346
|
+
* Wiki (知识库) locations the wiki tools may touch: whole spaces
|
|
347
|
+
* (`https://<tenant>.feishu.cn/wiki/space/<space_id>`) or single nodes with
|
|
348
|
+
* everything below them (`https://<tenant>.feishu.cn/wiki/<node_token>`).
|
|
349
|
+
* Browsing, reading, creating, and editing outside these roots fail. Empty
|
|
350
|
+
* (default) mounts no wiki tool even when `feishuTools.wikiRead`/`wikiWrite`
|
|
351
|
+
* is on.
|
|
352
|
+
*/
|
|
353
|
+
wikiRoots: z.array(z.string().trim().superRefine((link, issue) => {
|
|
354
|
+
const parsed = parseWikiRootLink(link)
|
|
355
|
+
if (!parsed.ok) issue.addIssue({ code: 'custom', message: WIKI_ROOT_LINK_ERRORS[parsed.error] })
|
|
356
|
+
})).max(20).default([]),
|
|
313
357
|
/**
|
|
314
358
|
* Send a one-time onboarding card on the first private message of a chat
|
|
315
359
|
* (facts + command cheat sheet). Persisted per chat; never repeats.
|