@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modesto_ma/dsh-channels",
3
- "version": "0.5.0-beta.1",
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",
@@ -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`) were added
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)
@@ -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 { columnSet, divider, markdown, note, standaloneCard, textButton } from './primitives.ts'
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. Options whose argument cannot form one command-line token
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[] = [markdown(input.text), ...optionRows(input, shown)]
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
- const issue = issues.find(item => item.key === field.key)
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
+ }
@@ -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}
@@ -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
- /** Answer with a direct reply instead of creating a topic. */
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: false,
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 library, so the
268
- * limit is enforced while streaming rather than buffered first.
269
- * - `attachmentFetchTimeoutMs`: whole-call timeout (default 120 s). On expiry
270
- * the in-flight download is cancelled and reported per attachment.
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).max(1024 * 1024 * 1024)
324
+ attachmentFetchMaxBytes: z.number().int().min(1)
297
325
  .default(DEFAULT_ATTACHMENT_FETCH_MAX_BYTES),
298
- attachmentFetchTimeoutMs: z.number().int().min(1_000).max(600_000)
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.