@modesto_ma/dsh-channels 0.5.0-beta.2 → 0.5.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +72 -15
  2. package/docs/attachment-pipeline-v2.md +2 -2
  3. package/docs/feishu-onboarding.md +4 -3
  4. package/lib/client.js +369 -94
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +7847 -5293
  7. package/lib/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/access/controller.ts +32 -1
  10. package/src/analyze/contract.ts +121 -0
  11. package/src/analyze/launcher.ts +353 -0
  12. package/src/analyze/server.ts +201 -0
  13. package/src/cards/analyze.ts +116 -0
  14. package/src/cards/index.ts +1 -0
  15. package/src/cards/streaming.ts +58 -0
  16. package/src/client/api.ts +15 -0
  17. package/src/client/panel/access-card.tsx +96 -70
  18. package/src/client/panel/analyze-token-card.tsx +70 -0
  19. package/src/client/panel/index.tsx +72 -10
  20. package/src/client/panel/sessions-tab.tsx +7 -4
  21. package/src/client/panel/shared.tsx +12 -0
  22. package/src/client/panel/wiki-tab.tsx +62 -0
  23. package/src/config.ts +52 -14
  24. package/src/domain.ts +55 -0
  25. package/src/feishu/scopes.ts +19 -1
  26. package/src/feishu/tools.ts +3 -1
  27. package/src/feishu/wiki/a1.ts +97 -0
  28. package/src/feishu/wiki/api.ts +21 -6
  29. package/src/feishu/wiki/base-api.ts +267 -0
  30. package/src/feishu/wiki/base-tools.ts +423 -0
  31. package/src/feishu/wiki/shared.ts +284 -0
  32. package/src/feishu/wiki/sheet-api.ts +250 -0
  33. package/src/feishu/wiki/sheet-tools.ts +493 -0
  34. package/src/feishu/wiki/tools.ts +102 -189
  35. package/src/feishu-channel.ts +14 -1
  36. package/src/health.ts +12 -0
  37. package/src/host/agent.ts +5 -1
  38. package/src/index.ts +1 -1
  39. package/src/interactions/pending.ts +11 -2
  40. package/src/routing/inbound.ts +57 -14
  41. package/src/service.ts +161 -28
  42. package/src/settings/contract.ts +37 -6
  43. package/src/settings/routes.ts +66 -14
  44. package/src/settings/surface.ts +69 -20
  45. package/src/streamer.ts +156 -7
  46. package/src/workspaces.ts +31 -0
@@ -0,0 +1,70 @@
1
+ /**
2
+ * 日志分析接口令牌编辑器:值经 Host 的 DSH 凭据服务写入本机凭据库,面板只显示
3
+ * 「已配置 / 未配置」,不回显已存值;保存或移除后下一次接口请求即生效,不触发重连。
4
+ *
5
+ * @module dsh-channels/client/panel/analyze-token-card
6
+ */
7
+
8
+ import { PANEL_COPY } from '../../settings/contract.ts'
9
+ import type { PanelWire } from '../../settings/contract.ts'
10
+ import type { AnalyzeTokenView } from './shared.tsx'
11
+
12
+ export function AnalyzeTokenCard(props: {
13
+ panel: PanelWire
14
+ token: AnalyzeTokenView
15
+ }): JSX.Element {
16
+ const { panel, token } = props
17
+ const state = panel.config.credentials.analyzeToken
18
+ const disabled = token.busy !== null || token.locked || !state.writable
19
+ const issue = token.issues.find(item => item.key === 'analyzeToken')
20
+ const inputClass = `dsh-channels-cfg__input${issue === undefined ? '' : ' dsh-channels-cfg__input--invalid'}`
21
+ return (
22
+ <section className="dsh-channels-cfg__card">
23
+ <div className="dsh-channels-cfg__cardHead">
24
+ <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.analyzeTokenCard}</span>
25
+ <span className={`dsh-channels-cfg__badge dsh-channels-cfg__badge--${state.configured ? 'ok' : 'muted'}`}>
26
+ {state.configured ? PANEL_COPY.credentialConfigured : PANEL_COPY.credentialMissing}
27
+ </span>
28
+ </div>
29
+ <span className="dsh-channels-cfg__help">{PANEL_COPY.analyzeTokenCardHelp}</span>
30
+ <div className="dsh-channels-cfg__field">
31
+ <span className="dsh-channels-cfg__label">{PANEL_COPY.analyzeTokenLabel}</span>
32
+ <div className="dsh-channels-cfg__cred">
33
+ <input
34
+ type="password"
35
+ className={inputClass}
36
+ value={token.value}
37
+ placeholder={PANEL_COPY.analyzeTokenPlaceholder}
38
+ autoComplete="new-password"
39
+ aria-label={PANEL_COPY.analyzeTokenLabel}
40
+ disabled={disabled}
41
+ onChange={event => token.onChange(event.target.value)}
42
+ />
43
+ {state.configured
44
+ ? (
45
+ <button type="button" className="dsh-channels-cfg__btn" disabled={disabled} onClick={token.onClear}>
46
+ {token.busy === 'credentials-clear' ? PANEL_COPY.removingCredential : PANEL_COPY.removeCredential}
47
+ </button>
48
+ )
49
+ : null}
50
+ </div>
51
+ <span className="dsh-channels-cfg__help">
52
+ {PANEL_COPY.credentialsRef}:<span className="dsh-channels-cfg__mono">{state.ref}</span>
53
+ {state.source === undefined ? '' : ` · ${state.source}`}
54
+ </span>
55
+ {issue === undefined ? null : <span className="dsh-channels-cfg__fieldError">{issue.message}</span>}
56
+ </div>
57
+ {state.writable ? null : <span className="dsh-channels-cfg__fieldError">{PANEL_COPY.credentialReadOnly}</span>}
58
+ <div className="dsh-channels-cfg__footer">
59
+ <button
60
+ type="button"
61
+ className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
62
+ onClick={token.onSave}
63
+ disabled={disabled || token.value.trim() === ''}
64
+ >
65
+ {token.busy === 'credentials' ? PANEL_COPY.savingCredentials : PANEL_COPY.saveAnalyzeToken}
66
+ </button>
67
+ </div>
68
+ </section>
69
+ )
70
+ }
@@ -14,6 +14,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
14
14
  import type { KeyboardEvent } from 'react'
15
15
  import { PANEL_COPY, PANEL_TABS, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
16
16
  import type {
17
+ AccessMutationResponse,
17
18
  AccessWire,
18
19
  CatalogWire,
19
20
  ChannelStatusWire,
@@ -28,6 +29,7 @@ import type {
28
29
  import {
29
30
  addAccessGroup,
30
31
  addAccessUser,
32
+ addWikiUser,
31
33
  clearConfig,
32
34
  clearCredential,
33
35
  fetchCatalog,
@@ -37,6 +39,7 @@ import {
37
39
  reconnect,
38
40
  removeAccessGroup,
39
41
  removeAccessUser,
42
+ removeWikiUser,
40
43
  resolveAccessRequest,
41
44
  saveConfig,
42
45
  saveCredentials,
@@ -56,12 +59,14 @@ import {
56
59
  validateDraft,
57
60
  } from './shared.tsx'
58
61
  import type {
62
+ AnalyzeTokenView,
59
63
  BusyAction,
60
64
  CredentialsView,
61
65
  FormView,
62
66
  LoadState,
63
67
  Notice,
64
68
  } from './shared.tsx'
69
+ import { WikiTab } from './wiki-tab.tsx'
65
70
  import { WorkspaceTab } from './workspace-tab.tsx'
66
71
 
67
72
  /** Owner props handed to a `settings.section` contribution (unused beyond parity). */
@@ -80,6 +85,7 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
80
85
  const [pollError, setPollError] = useState<string | null>(null)
81
86
  const [appIdInput, setAppIdInput] = useState('')
82
87
  const [appSecretInput, setAppSecretInput] = useState('')
88
+ const [analyzeTokenInput, setAnalyzeTokenInput] = useState('')
83
89
  const [catalog, setCatalog] = useState<CatalogWire | null>(null)
84
90
  const [catalogBusy, setCatalogBusy] = useState(false)
85
91
  const [accessNotice, setAccessNotice] = useState<string | null>(null)
@@ -106,6 +112,7 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
106
112
  setDraft({})
107
113
  setAppIdInput('')
108
114
  setAppSecretInput('')
115
+ setAnalyzeTokenInput('')
109
116
  } catch (error) {
110
117
  setLoad({ phase: 'error', message: messageOf(error) })
111
118
  }
@@ -453,18 +460,24 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
453
460
  }
454
461
  }, [applyAccess])
455
462
 
456
- const onAddAccessUser = useCallback(async (userId: string, name: string): Promise<boolean> => {
463
+ /** Add one user to a whitelist; resolves true when the input should clear. */
464
+ const addUser = useCallback(async (
465
+ add: (userId: string, name: string) => Promise<AccessMutationResponse>,
466
+ userId: string,
467
+ name: string,
468
+ notice: string,
469
+ ): Promise<boolean> => {
457
470
  setBusy('access')
458
471
  setAccessError(null)
459
472
  setAccessNotice(null)
460
473
  try {
461
- const result = await addAccessUser(userId, name)
474
+ const result = await add(userId, name)
462
475
  if (!result.ok) {
463
476
  setAccessError(result.error)
464
477
  return false
465
478
  }
466
479
  applyAccess(result.access)
467
- setAccessNotice(PANEL_COPY.accessUserAdded)
480
+ setAccessNotice(notice)
468
481
  return true
469
482
  } catch (error) {
470
483
  setAccessError(messageOf(error))
@@ -474,18 +487,22 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
474
487
  }
475
488
  }, [applyAccess])
476
489
 
477
- const onRemoveAccessUser = useCallback(async (userId: string): Promise<void> => {
490
+ const removeUser = useCallback(async (
491
+ remove: (userId: string) => Promise<AccessMutationResponse>,
492
+ userId: string,
493
+ notice: string,
494
+ ): Promise<void> => {
478
495
  setBusy('access')
479
496
  setAccessError(null)
480
497
  setAccessNotice(null)
481
498
  try {
482
- const result = await removeAccessUser(userId)
499
+ const result = await remove(userId)
483
500
  if (!result.ok) {
484
501
  setAccessError(result.error)
485
502
  return
486
503
  }
487
504
  applyAccess(result.access)
488
- setAccessNotice(PANEL_COPY.accessUserRemoved)
505
+ setAccessNotice(notice)
489
506
  } catch (error) {
490
507
  setAccessError(messageOf(error))
491
508
  } finally {
@@ -552,6 +569,28 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
552
569
  }
553
570
  }, [appIdInput, appSecretInput])
554
571
 
572
+ /** Save the analyze API token; it applies to the next request, no reconnect. */
573
+ const onSaveAnalyzeToken = useCallback(async (): Promise<void> => {
574
+ if (analyzeTokenInput.trim() === '') return
575
+ setBusy('credentials')
576
+ setNotice(null)
577
+ try {
578
+ const result = await saveCredentials({ analyzeToken: analyzeTokenInput })
579
+ if (!result.ok) {
580
+ setNotice({ kind: 'error', text: result.error, issues: result.issues })
581
+ return
582
+ }
583
+ setLoad({ phase: 'ready', panel: result.panel })
584
+ setStatus(result.panel.status)
585
+ setAnalyzeTokenInput('')
586
+ setNotice({ kind: 'ok', text: PANEL_COPY.analyzeTokenSaved })
587
+ } catch (error) {
588
+ setNotice({ kind: 'error', text: messageOf(error) })
589
+ } finally {
590
+ setBusy(null)
591
+ }
592
+ }, [analyzeTokenInput])
593
+
555
594
  const onClearCredential = useCallback(async (field: CredentialField): Promise<void> => {
556
595
  const confirmed = typeof window.confirm === 'function'
557
596
  ? window.confirm(PANEL_COPY.credentialRemoveConfirm)
@@ -569,7 +608,9 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
569
608
  setStatus(result.panel.status)
570
609
  setNotice({
571
610
  kind: 'ok',
572
- text: result.reconnect === 'pending' ? PANEL_COPY.credentialRemovedPending : PANEL_COPY.credentialRemoved,
611
+ text: field === 'analyzeToken'
612
+ ? PANEL_COPY.analyzeTokenRemoved
613
+ : result.reconnect === 'pending' ? PANEL_COPY.credentialRemovedPending : PANEL_COPY.credentialRemoved,
573
614
  })
574
615
  } catch (error) {
575
616
  setNotice({ kind: 'error', text: messageOf(error) })
@@ -628,6 +669,16 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
628
669
  onClear: (field: CredentialField) => { void onClearCredential(field) },
629
670
  }), [appIdInput, appSecretInput, busy, hostBusy, issues, onClearCredential, onSaveCredentials])
630
671
 
672
+ const analyzeToken = useMemo<AnalyzeTokenView>(() => ({
673
+ value: analyzeTokenInput,
674
+ issues,
675
+ busy,
676
+ locked: hostBusy,
677
+ onChange: setAnalyzeTokenInput,
678
+ onSave: () => { void onSaveAnalyzeToken() },
679
+ onClear: () => { void onClearCredential('analyzeToken') },
680
+ }), [analyzeTokenInput, busy, hostBusy, issues, onClearCredential, onSaveAnalyzeToken])
681
+
631
682
  if (load.phase === 'loading') {
632
683
  return (
633
684
  <div className="dsh-channels-cfg">
@@ -680,7 +731,7 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
680
731
  />
681
732
  )
682
733
  : null}
683
- {activeTab === 'sessions' ? <SessionsTab form={form} /> : null}
734
+ {activeTab === 'sessions' ? <SessionsTab form={form} analyzeToken={analyzeToken} /> : null}
684
735
  {activeTab === 'model' ? <ModelTab form={form} catalogBusy={catalogBusy} /> : null}
685
736
  {activeTab === 'workspace'
686
737
  ? (
@@ -695,12 +746,23 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
695
746
  accessError={accessError}
696
747
  onAddGroup={onAddAccessGroup}
697
748
  onRemoveGroup={chatId => { void onRemoveAccessGroup(chatId) }}
698
- onAddUser={onAddAccessUser}
699
- onRemoveUser={userId => { void onRemoveAccessUser(userId) }}
749
+ onAddUser={(userId, name) => addUser(addAccessUser, userId, name, PANEL_COPY.accessUserAdded)}
750
+ onRemoveUser={userId => { void removeUser(removeAccessUser, userId, PANEL_COPY.accessUserRemoved) }}
700
751
  onResolveRequest={(token, decision) => { void onResolveAccessRequest(token, decision) }}
701
752
  />
702
753
  )
703
754
  : null}
755
+ {activeTab === 'wiki'
756
+ ? (
757
+ <WikiTab
758
+ form={form}
759
+ accessNotice={accessNotice}
760
+ accessError={accessError}
761
+ onAddUser={(userId, name) => addUser(addWikiUser, userId, name, PANEL_COPY.wikiUserAdded)}
762
+ onRemoveUser={userId => { void removeUser(removeWikiUser, userId, PANEL_COPY.wikiUserRemoved) }}
763
+ />
764
+ )
765
+ : null}
704
766
  {activeTab === 'advanced'
705
767
  ? <AdvancedTab form={form} onClear={() => { void onClear() }} />
706
768
  : null}
@@ -1,21 +1,24 @@
1
1
  /**
2
2
  * 「会话与行为」tab:行为 + 内容与附件两组字段(私聊/群聊触发方式、
3
- * 首次引导、权限切换、附件接收)。表单字段完全由 Host 描述驱动。
3
+ * 首次引导、权限切换、附件接收、日志分析接口)。表单字段完全由 Host 描述驱动;
4
+ * 日志分析接口令牌是凭据,单独一张卡片。
4
5
  *
5
6
  * @module dsh-channels/client/panel/sessions-tab
6
7
  */
7
8
 
8
9
  import { PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
10
+ import { AnalyzeTokenCard } from './analyze-token-card.tsx'
9
11
  import { FieldsCard, SaveBar, TabHelp } from './shared.tsx'
10
- import type { FormView } from './shared.tsx'
12
+ import type { AnalyzeTokenView, FormView } from './shared.tsx'
11
13
 
12
- export function SessionsTab(props: { form: FormView }): JSX.Element {
13
- const { form } = props
14
+ export function SessionsTab(props: { form: FormView; analyzeToken: AnalyzeTokenView }): JSX.Element {
15
+ const { form, analyzeToken } = props
14
16
  return (
15
17
  <div className="dsh-channels-cfg__tabBody">
16
18
  <TabHelp text={PANEL_COPY.sessionsTabHelp} />
17
19
  <FieldsCard form={form} groups={TAB_FIELD_GROUPS.sessions} />
18
20
  <SaveBar form={form} tab="sessions" />
21
+ <AnalyzeTokenCard panel={form.panel} token={analyzeToken} />
19
22
  </div>
20
23
  )
21
24
  }
@@ -182,6 +182,18 @@ export interface CredentialsView {
182
182
  onClear: (field: CredentialField) => void
183
183
  }
184
184
 
185
+ /** Analyze API token editor data owned by the page shell, rendered by the sessions tab. */
186
+ export interface AnalyzeTokenView {
187
+ value: string
188
+ issues: FieldIssueWire[]
189
+ busy: BusyAction | null
190
+ /** Host-side lifecycle busy (connecting/restarting): block writes. */
191
+ locked: boolean
192
+ onChange: (value: string) => void
193
+ onSave: () => void
194
+ onClear: () => void
195
+ }
196
+
185
197
  /** Everything a field-bearing tab needs from the page shell's single draft. */
186
198
  export interface FormView {
187
199
  panel: PanelWire
@@ -0,0 +1,62 @@
1
+ /**
2
+ * 「知识库」tab:知识库工具字段表单(读取/写入开关、知识库范围)+ 知识库
3
+ * 白名单卡。表单保存走全局 draft,白名单增删是独立即时 mutation。
4
+ *
5
+ * @module dsh-channels/client/panel/wiki-tab
6
+ */
7
+
8
+ import { PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
9
+ import { UserWhitelist } from './access-card.tsx'
10
+ import { EmptyState, FieldsCard, SaveBar, TabHelp } from './shared.tsx'
11
+ import type { FormView } from './shared.tsx'
12
+
13
+ export interface WikiTabProps {
14
+ form: FormView
15
+ accessNotice: string | null
16
+ accessError: string | null
17
+ onAddUser: (userId: string, name: string) => Promise<boolean>
18
+ onRemoveUser: (userId: string) => void
19
+ }
20
+
21
+ export function WikiTab(props: WikiTabProps): JSX.Element {
22
+ const { form, accessNotice, accessError, onAddUser, onRemoveUser } = props
23
+ const { access } = form.panel
24
+ const disabled = form.busy !== null || form.locked || !access.loaded
25
+ return (
26
+ <div className="dsh-channels-cfg__tabBody">
27
+ <TabHelp text={PANEL_COPY.wikiTabHelp} />
28
+ <FieldsCard form={form} groups={TAB_FIELD_GROUPS.wiki} />
29
+ <SaveBar form={form} tab="wiki" />
30
+ <section className="dsh-channels-cfg__card">
31
+ <div className="dsh-channels-cfg__cardHead">
32
+ <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.wikiUsersCard}</span>
33
+ </div>
34
+ <span className="dsh-channels-cfg__help">{PANEL_COPY.wikiUsersHelp}</span>
35
+ {!access.loaded
36
+ ? <EmptyState text={PANEL_COPY.accessNotLoaded} />
37
+ : (
38
+ <UserWhitelist
39
+ title={PANEL_COPY.wikiUsersCard}
40
+ empty={PANEL_COPY.wikiUsersEmpty}
41
+ addLabel={PANEL_COPY.accessAddUser}
42
+ users={access.wikiUsers}
43
+ disabled={disabled}
44
+ working={form.busy === 'access'}
45
+ onAdd={onAddUser}
46
+ onRemove={onRemoveUser}
47
+ />
48
+ )}
49
+ {accessNotice === null
50
+ ? null
51
+ : (
52
+ <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--ok" role="status" aria-live="polite">
53
+ <span>{accessNotice}</span>
54
+ </div>
55
+ )}
56
+ {accessError === null
57
+ ? null
58
+ : <span className="dsh-channels-cfg__fieldError" role="alert">{accessError}</span>}
59
+ </section>
60
+ </div>
61
+ )
62
+ }
package/src/config.ts CHANGED
@@ -48,6 +48,9 @@ export const DEFAULT_ATTACHMENT_FETCH_MAX_BYTES = 100 * 1024 * 1024
48
48
  /** Default whole-call timeout of `feishu_attachment_fetch` (120 s). */
49
49
  export const DEFAULT_ATTACHMENT_FETCH_TIMEOUT_MS = 120_000
50
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
+
51
54
  /** Default per-image byte cap for trigger-message prefetch (20 MiB). */
52
55
  export const DEFAULT_PREFETCH_IMAGE_MAX_BYTES = 20 * 1024 * 1024
53
56
 
@@ -77,6 +80,12 @@ export const DEFAULT_TOPIC_MESSAGES_TIMEOUT_MS = 20_000
77
80
  */
78
81
  export const DEFAULT_PROMPT_CONTEXT_TIMEOUT_MS = 3_000
79
82
 
83
+ /** Conventional credential reference holding the log-platform analyze API bearer token. */
84
+ export const DEFAULT_ANALYZE_TOKEN_REF = 'DSH_CHANNELS_ANALYZE_TOKEN'
85
+
86
+ /** `host:port` listen address of the analyze API (`[v6]:port` for IPv6 literals). */
87
+ export const ANALYZE_LISTEN_PATTERN = /^(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9.-]+):(?:[1-9][0-9]{0,4})$/u
88
+
80
89
  /** A credential reference: `FEISHU_APP_ID`-style name resolved through ctx.credentials. */
81
90
  const credentialRefSchema = z.string().regex(
82
91
  CREDENTIAL_REF_PATTERN,
@@ -265,10 +274,12 @@ export const configSchema = z.object({
265
274
  * fetch (1–50). A message with more attachments is refused with a readable
266
275
  * reason so the model can retry per `file_key`.
267
276
  * - `attachmentFetchMaxBytes`: total download budget of one tool call
268
- * (default 100 MiB). Files stream to the native attachment library, so the
269
- * limit is enforced while streaming rather than buffered first.
270
- * - `attachmentFetchTimeoutMs`: whole-call timeout (default 120 s). On expiry
271
- * the in-flight download is cancelled and reported per attachment.
277
+ * (default 100 MiB, no upper bound). Files stream to the native attachment
278
+ * library, so the limit is enforced while streaming rather than buffered
279
+ * first, and multi-GiB files need only disk space.
280
+ * - `attachmentFetchTimeoutMs`: whole-call timeout (default 120 s, at most
281
+ * {@link MAX_TIMER_DELAY_MS}). On expiry the in-flight download is
282
+ * cancelled and reported per attachment.
272
283
  * - `topicMessages` (default on): register `feishu_topic_messages` in
273
284
  * channel sessions so the model can read the quoted message / earlier
274
285
  * topic messages on demand and authorize their attachments for fetch.
@@ -288,14 +299,23 @@ export const configSchema = z.object({
288
299
  * `im:resource` (or `im:resource:upload`) scope; without it a call returns
289
300
  * a readable failure and the turn continues.
290
301
  *
291
- * - `wikiRead` (default off): register `feishu_wiki_list` and
292
- * `feishu_wiki_read` so the model can browse the wiki nodes under
293
- * `wikiRoots` and read docx documents there. Needs `wiki:wiki` (or
294
- * `wiki:wiki:readonly`) and `docx:document` (or `docx:document:readonly`).
295
- * - `wikiWrite` (default off): register `feishu_wiki_create` and
296
- * `feishu_wiki_edit` so the model can create docx documents under
297
- * `wikiRoots` and edit documents this app owns. Needs `wiki:wiki` and
298
- * `docx:document`.
302
+ * - `wikiRead` (default off): register `feishu_wiki_list`,
303
+ * `feishu_wiki_read`, `feishu_wiki_sheet_read`, and
304
+ * `feishu_wiki_base_read` so the model can browse the wiki nodes under
305
+ * `wikiRoots` and read docx documents, spreadsheets, and Bases there.
306
+ * Needs `wiki:wiki` (or `wiki:wiki:readonly`), `docx:document` (or
307
+ * `docx:document:readonly`), `sheets:spreadsheet:read`, and
308
+ * `base:table:read` + `base:field:read` + `base:record:read` (the
309
+ * classic `sheets:spreadsheet` / `bitable:app` grants do not apply).
310
+ * - `wikiWrite` (default off): register `feishu_wiki_create`,
311
+ * `feishu_wiki_edit`, `feishu_wiki_sheet_edit`, and
312
+ * `feishu_wiki_base_edit` so the model can create docx documents,
313
+ * spreadsheets, and Bases under `wikiRoots`, edit documents and
314
+ * spreadsheets this app owns, and change records of Bases this app owns
315
+ * (other Bases accept new records only). Needs `wiki:wiki`,
316
+ * `docx:document`, `sheets:spreadsheet:write_only`, and
317
+ * `base:record:create` / `base:record:update` / `base:record:delete`,
318
+ * plus the read scopes above.
299
319
  *
300
320
  * The wiki tools act with the app identity and are mounted only while
301
321
  * `wikiRoots` is non-empty. Each call is limited to topics whose owner is
@@ -309,9 +329,9 @@ export const configSchema = z.object({
309
329
  attachmentFetch: z.boolean().default(true),
310
330
  attachmentFetchMaxResources: z.number().int().min(1).max(50)
311
331
  .default(DEFAULT_ATTACHMENT_FETCH_MAX_RESOURCES),
312
- attachmentFetchMaxBytes: z.number().int().min(1).max(1024 * 1024 * 1024)
332
+ attachmentFetchMaxBytes: z.number().int().min(1)
313
333
  .default(DEFAULT_ATTACHMENT_FETCH_MAX_BYTES),
314
- attachmentFetchTimeoutMs: z.number().int().min(1_000).max(600_000)
334
+ attachmentFetchTimeoutMs: z.number().int().min(1_000).max(MAX_TIMER_DELAY_MS)
315
335
  .default(DEFAULT_ATTACHMENT_FETCH_TIMEOUT_MS),
316
336
  topicMessages: z.boolean().default(true),
317
337
  topicMessagesTimeoutMs: z.number().int().min(1_000).max(120_000)
@@ -397,6 +417,24 @@ export const configSchema = z.object({
397
417
  * its own deadline and is left to the host signal instead.
398
418
  */
399
419
  questionTimeoutMs: z.number().int().min(10_000).max(3_600_000).default(DEFAULT_QUESTION_TIMEOUT_MS),
420
+ /**
421
+ * Listen address (`host:port`, e.g. `0.0.0.0:3090`) of the log-platform
422
+ * analyze API: a dedicated HTTP listener serving only
423
+ * `POST /dsh-channels/analyze`, separate from the DSH Web port. Unset
424
+ * (default) keeps the API off. The listener runs while the Feishu connection
425
+ * is up and is rebound on reconnect.
426
+ */
427
+ analyzeListen: z.string().trim().regex(
428
+ ANALYZE_LISTEN_PATTERN,
429
+ 'must be host:port, e.g. 0.0.0.0:3090 or 127.0.0.1:3090',
430
+ ).refine(value => Number(value.slice(value.lastIndexOf(':') + 1)) <= 65_535, 'port must be 1-65535')
431
+ .optional(),
432
+ /**
433
+ * Credential reference holding the analyze API bearer token (defaults to
434
+ * `DSH_CHANNELS_ANALYZE_TOKEN`). Resolved per request, so rotating the value
435
+ * applies to the next request; an unresolvable token refuses every request.
436
+ */
437
+ analyzeToken: credentialRefSchema.default(DEFAULT_ANALYZE_TOKEN_REF),
400
438
  })
401
439
 
402
440
  export type FeishuConfig = z.infer<typeof configSchema>
package/src/domain.ts CHANGED
@@ -61,6 +61,12 @@ export const routeRecordSchema = z.object({
61
61
  lastActivityAt: z.number().int().nonnegative(),
62
62
  /** Sessions this topic has pointed at, oldest first. Optional for v1 compatibility. */
63
63
  sessionHistory: z.array(sessionHistoryEntrySchema).optional(),
64
+ /**
65
+ * Group-shared route (log-platform analysis topics): every member admitted
66
+ * in `chatId` may continue the Session and decide its cards, not only
67
+ * `senderUserId` (who then names the requester). Absent = owner-only.
68
+ */
69
+ shared: z.literal(true).optional(),
64
70
  })
65
71
 
66
72
  export type RouteRecord = z.infer<typeof routeRecordSchema>
@@ -281,6 +287,40 @@ export const processedRecordSchema = z.object({
281
287
 
282
288
  export type ProcessedRecord = z.infer<typeof processedRecordSchema>
283
289
 
290
+ /**
291
+ * One accepted analyze API request, keyed by its `Idempotency-Key`. Kept
292
+ * forever: a retried key returns the stored response instead of posting a
293
+ * second analysis, and a reused key with a different body is refused.
294
+ */
295
+ export const analyzeRequestRecordSchema = z.object({
296
+ /** The request's `Idempotency-Key` (also the table key). */
297
+ key: z.string().min(1),
298
+ /** SHA-256 (hex) of the canonical request body. */
299
+ bodyHash: z.string().min(1),
300
+ /** Target group chat. */
301
+ chatId: z.string().min(1),
302
+ /** Root card message id (the analysis topic's root). */
303
+ messageId: z.string().min(1),
304
+ /** Epoch ms when the request was accepted. */
305
+ acceptedAt: z.number().int().nonnegative(),
306
+ /** What the root card shows, kept so the card can be redrawn after a restart. */
307
+ subject: z.object({
308
+ requesterUserId: z.string(),
309
+ triggeredAt: z.string(),
310
+ app: z.string(),
311
+ errorCode: z.string(),
312
+ logDetails: z.array(z.object({ name: z.string(), url: z.string() })),
313
+ }),
314
+ /**
315
+ * Root card status. `running` until the first turn settles; a record still
316
+ * `running` when the channel starts belongs to an analysis the previous
317
+ * instance never finished, and becomes `interrupted`.
318
+ */
319
+ status: z.enum(['running', 'done', 'stopped', 'error', 'failed', 'interrupted']),
320
+ })
321
+
322
+ export type AnalyzeRequestRecord = z.infer<typeof analyzeRequestRecordSchema>
323
+
284
324
  export interface FeishuDomainSpecShape {
285
325
  name: 'channels_routes_v2'
286
326
  version: 1
@@ -321,6 +361,10 @@ export interface FeishuDomainSpecShape {
321
361
  valueSchema: typeof accessUserRecordSchema
322
362
  __key?: string
323
363
  }
364
+ analyze_requests: {
365
+ valueSchema: typeof analyzeRequestRecordSchema
366
+ __key?: string
367
+ }
324
368
  }
325
369
  }
326
370
 
@@ -361,6 +405,8 @@ export const feishuDomain: FeishuDomainSpecShape = {
361
405
  // independent grant. Added without a version bump like the other access
362
406
  // tables: a missing table loads empty.
363
407
  access_wiki_users: { valueSchema: accessUserRecordSchema },
408
+ // Analyze API idempotency records; a missing table loads empty.
409
+ analyze_requests: { valueSchema: analyzeRequestRecordSchema },
364
410
  },
365
411
  }
366
412
 
@@ -427,6 +473,13 @@ export interface AccessRequestsTable {
427
473
  entries(): IterableIterator<[string, AccessRequestRecord]>
428
474
  }
429
475
 
476
+ /** Normalized table handle for analyze API requests (key = `Idempotency-Key`). */
477
+ export interface AnalyzeRequestsTable {
478
+ get(key: string): AnalyzeRequestRecord | undefined
479
+ put(key: string, value: AnalyzeRequestRecord): Promise<unknown> | void
480
+ entries(): IterableIterator<[string, AnalyzeRequestRecord]>
481
+ }
482
+
430
483
  /** Normalized domain handle. */
431
484
  export interface FeishuDomainHandle {
432
485
  routes: RoutesTable
@@ -438,6 +491,8 @@ export interface FeishuDomainHandle {
438
491
  accessRequests: AccessRequestsTable
439
492
  /** Wiki whitelist (key = `user_id`); same records as {@link AccessUsersTable}. */
440
493
  accessWikiUsers: AccessUsersTable
494
+ /** Analyze API idempotency records. */
495
+ analyzeRequests: AnalyzeRequestsTable
441
496
  getGlobal(): FeishuDomainGlobal
442
497
  setGlobal(value: FeishuDomainGlobal): Promise<unknown> | void
443
498
  close(): Promise<void>
@@ -91,16 +91,34 @@ const SEND_FILE_REQUIREMENT: ScopeRequirement = {
91
91
  anyOf: ['im:resource', 'im:resource:upload'], level: 'optional', purpose: '上传图片/文件,让模型用 feishu_send_file 把生成的文件发给用户',
92
92
  }
93
93
 
94
- /** Wiki read needs (`feishu_wiki_list` / `feishu_wiki_read`). */
94
+ /**
95
+ * Spreadsheet and Base read needs, shared by the wiki read and write sets. The
96
+ * `sheet_ai/v2` and `base/v3` APIs accept only their fine-grained scopes; the
97
+ * classic `sheets:spreadsheet` / `bitable:app` grants do not satisfy them.
98
+ */
99
+ const SHEET_BASE_READ_REQUIREMENTS: readonly ScopeRequirement[] = [
100
+ { anyOf: ['sheets:spreadsheet:read'], level: 'optional', purpose: '读取知识库电子表格(feishu_wiki_sheet_read)' },
101
+ { anyOf: ['base:table:read'], level: 'optional', purpose: '列出知识库多维表格的数据表(feishu_wiki_base_read)' },
102
+ { anyOf: ['base:field:read'], level: 'optional', purpose: '读取知识库多维表格的字段(feishu_wiki_base_read)' },
103
+ { anyOf: ['base:record:read'], level: 'optional', purpose: '读取知识库多维表格的记录(feishu_wiki_base_read)' },
104
+ ]
105
+
106
+ /** Wiki read needs (`feishu_wiki_list` / `feishu_wiki_read` and the sheet/base read tools). */
95
107
  const WIKI_READ_REQUIREMENTS: readonly ScopeRequirement[] = [
96
108
  { anyOf: ['wiki:wiki', 'wiki:wiki:readonly'], level: 'optional', purpose: '浏览知识库节点(feishu_wiki_list / feishu_wiki_read)' },
97
109
  { anyOf: ['docx:document', 'docx:document:readonly'], level: 'optional', purpose: '读取知识库中的文档内容(feishu_wiki_read)' },
110
+ ...SHEET_BASE_READ_REQUIREMENTS,
98
111
  ]
99
112
 
100
113
  /** Wiki write needs (`feishu_wiki_create` / `feishu_wiki_edit`). */
101
114
  const WIKI_WRITE_REQUIREMENTS: readonly ScopeRequirement[] = [
102
115
  { anyOf: ['wiki:wiki'], level: 'optional', purpose: '在知识库中新建节点(feishu_wiki_create)' },
103
116
  { anyOf: ['docx:document'], level: 'optional', purpose: '写入与修改知识库文档内容(feishu_wiki_create / feishu_wiki_edit)' },
117
+ { anyOf: ['sheets:spreadsheet:write_only'], level: 'optional', purpose: '写入知识库电子表格(feishu_wiki_sheet_edit)' },
118
+ { anyOf: ['base:record:create'], level: 'optional', purpose: '新增知识库多维表格记录(feishu_wiki_base_edit)' },
119
+ { anyOf: ['base:record:update'], level: 'optional', purpose: '修改本应用创建的多维表格中的记录(feishu_wiki_base_edit)' },
120
+ { anyOf: ['base:record:delete'], level: 'optional', purpose: '删除本应用创建的多维表格中的记录(feishu_wiki_base_edit)' },
121
+ ...SHEET_BASE_READ_REQUIREMENTS,
104
122
  ]
105
123
 
106
124
  /** Group receive needs while ordinary group messages require an @ mention. */
@@ -105,6 +105,8 @@ export interface AttachmentFetchRoute {
105
105
  readonly chatId: string
106
106
  readonly chatType: 'p2p' | 'group'
107
107
  readonly senderUserId: string
108
+ /** Group-shared route: messages of any sender in the route's topic count as its own. */
109
+ readonly shared?: boolean
108
110
  }
109
111
 
110
112
  /** Why a message became known to one route. */
@@ -811,7 +813,7 @@ export class AttachmentFetchToolRuntime {
811
813
  if (!read.ok) return undefined
812
814
  const container = quoteItemsOf(read.value)
813
815
  if (container === undefined || container.deleted) return undefined
814
- if (container.senderUserId !== call.route.senderUserId) return undefined
816
+ if (call.route.shared !== true && container.senderUserId !== call.route.senderUserId) return undefined
815
817
  if (!messageBelongsToRoute(call.route, container)) return undefined
816
818
  return knownMessageOfItems(read.value)
817
819
  }