@modesto_ma/dsh-channels 0.5.0-beta.5 → 0.5.0-beta.7

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.
@@ -10,12 +10,16 @@
10
10
  */
11
11
 
12
12
  import { fillCopy, PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
13
- import { FieldSections, NoticeBar } from './shared.tsx'
13
+ import type { AccessWire } from '../../settings/contract.ts'
14
+ import { Avatar } from '../avatar.tsx'
15
+ import type { ProfileView } from './profiles.ts'
16
+ import { FieldSections, NoticeBar, Row } from './shared.tsx'
14
17
  import type { FormView } from './shared.tsx'
15
18
  import { RequestList, Whitelist } from './whitelist.tsx'
16
19
 
17
20
  export interface AccessTabProps {
18
21
  form: FormView
22
+ profiles: ProfileView
19
23
  accessNotice: string | null
20
24
  accessError: string | null
21
25
  onAddGroup: (chatId: string, name: string) => Promise<boolean>
@@ -26,17 +30,10 @@ export interface AccessTabProps {
26
30
  }
27
31
 
28
32
  export function AccessTab(props: AccessTabProps): JSX.Element {
29
- const { form, accessNotice, accessError, onAddGroup, onRemoveGroup, onAddUser, onRemoveUser, onResolveRequest } = props
33
+ const { form, profiles, accessNotice, accessError, onAddGroup, onRemoveGroup, onAddUser, onRemoveUser, onResolveRequest } = props
30
34
  const { access } = form.panel
31
35
  const working = form.busy === 'access'
32
36
  const disabled = form.busy !== null || form.locked || !access.loaded
33
- // The administrator row says who is in charge right now when the owner fallback applies.
34
- const ownerAdmin = access.mode === 'guarded' && access.admin.source === 'owner' ? access.admin.userId : undefined
35
- const fields = ownerAdmin === undefined
36
- ? form.fields
37
- : form.fields.map(field => field.key === 'adminUserId'
38
- ? { ...field, summary: fillCopy(PANEL_COPY.accessAdminOwnerSummary, { id: ownerAdmin }) }
39
- : field)
40
37
  return (
41
38
  <div className="dsh-channels-cfg__tabBody">
42
39
  {accessNotice === null ? null : <NoticeBar kind="ok" text={accessNotice} />}
@@ -51,13 +48,23 @@ export function AccessTab(props: AccessTabProps): JSX.Element {
51
48
  />
52
49
  )
53
50
  : null}
51
+ {profiles.warning === undefined ? null : <p className="dsh-channels-cfg__secFoot">{fillCopy(PANEL_COPY.profilesWarning, { reason: profiles.warning })}</p>}
54
52
  {access.loaded && access.requests.length > 0
55
- ? <RequestList requests={access.requests} disabled={disabled} working={working} onResolve={onResolveRequest} />
53
+ ? <RequestList requests={access.requests} profiles={profiles} disabled={disabled} working={working} onResolve={onResolveRequest} />
56
54
  : null}
57
- <FieldSections form={{ ...form, fields }} groups={TAB_FIELD_GROUPS.access} />
55
+ <FieldSections
56
+ form={form}
57
+ groups={TAB_FIELD_GROUPS.access}
58
+ extra={{ access: <AdminRow admin={access.admin} profiles={profiles} /> }}
59
+ />
58
60
  <Whitelist
59
61
  title={PANEL_COPY.accessGroupsTitle}
60
- entries={access.groups.map(group => ({ id: group.chatId, name: group.name, source: group.source }))}
62
+ entries={access.groups.map(group => ({
63
+ id: group.chatId,
64
+ name: profiles.chat(group.chatId)?.name ?? group.name,
65
+ avatarUrl: profiles.chat(group.chatId)?.avatarUrl,
66
+ source: group.source,
67
+ }))}
61
68
  avatar="群"
62
69
  empty={PANEL_COPY.accessGroupsEmpty}
63
70
  loaded={access.loaded}
@@ -71,7 +78,12 @@ export function AccessTab(props: AccessTabProps): JSX.Element {
71
78
  />
72
79
  <Whitelist
73
80
  title={PANEL_COPY.accessUsersTitle}
74
- entries={access.users.map(user => ({ id: user.userId, name: user.name, source: user.source }))}
81
+ entries={access.users.map(user => ({
82
+ id: user.userId,
83
+ name: profiles.user(user.userId)?.name ?? user.name,
84
+ avatarUrl: profiles.user(user.userId)?.avatarUrl,
85
+ source: user.source,
86
+ }))}
75
87
  empty={PANEL_COPY.accessUsersEmpty}
76
88
  loaded={access.loaded}
77
89
  addLabel={PANEL_COPY.accessAddUser}
@@ -85,3 +97,19 @@ export function AccessTab(props: AccessTabProps): JSX.Element {
85
97
  </div>
86
98
  )
87
99
  }
100
+
101
+ /** The effective administrator with their Feishu name and avatar; nothing while there is none. */
102
+ function AdminRow(props: { admin: AccessWire['admin']; profiles: ProfileView }): JSX.Element | null {
103
+ const { admin, profiles } = props
104
+ if (admin.userId === undefined) return null
105
+ const profile = profiles.user(admin.userId)
106
+ const name = profile?.name ?? admin.userId
107
+ const source = admin.source === 'owner' ? PANEL_COPY.accessAdminSourceOwner : PANEL_COPY.accessAdminSourceConfig
108
+ return (
109
+ <Row
110
+ leading={<Avatar text={name} url={profile?.avatarUrl} />}
111
+ label={name}
112
+ summary={<>{PANEL_COPY.accessAdminCurrent} · <span className="dsh-channels-cfg__mono">{admin.userId}</span> · {source}</>}
113
+ />
114
+ )
115
+ }
@@ -1,11 +1,10 @@
1
1
  /**
2
- * 设置 →「飞书渠道」页面:数据加载、5 秒状态轮询、全局 draft、顶部状态头 /
2
+ * 左侧栏「飞书渠道」主区页面:数据加载、5 秒状态轮询、全局 draft、顶部状态头 /
3
3
  * tab 导航与底部保存条的外壳。
4
4
  *
5
5
  * 所有 Host 交互(panel / status / catalog / config / credentials / access /
6
6
  * pick-directory)都集中在这里;tab 组件只接收渲染所需的数据与
7
- * 回调。页面不 import 任何 DSH 客户端包,只依赖 react 与 `settings.section`
8
- * 槽位。
7
+ * 回调。页面不 import 任何 DSH 客户端包,只依赖 react 与 `main` 槽位。
9
8
  *
10
9
  * @module dsh-channels/client/panel
11
10
  */
@@ -49,6 +48,7 @@ import { AccessTab } from './access-tab.tsx'
49
48
  import { AdvancedTab } from './advanced-tab.tsx'
50
49
  import { ConnectionTab } from './connection-tab.tsx'
51
50
  import { FieldsTab } from './fields-tab.tsx'
51
+ import { useFeishuProfiles } from './profiles.ts'
52
52
  import {
53
53
  fieldLabel,
54
54
  formatUptime,
@@ -69,13 +69,23 @@ import type {
69
69
  } from './shared.tsx'
70
70
  import { WikiTab } from './wiki-tab.tsx'
71
71
 
72
- /** Owner props handed to a `settings.section` contribution (unused beyond parity). */
73
- export interface FeishuChannelsSectionProps {
74
- close?: () => void
72
+ /**
73
+ * Main-column page for the `main` slot: a full-height scroll region with the
74
+ * centered panel column. The slot passes no owner props.
75
+ * @returns the page.
76
+ */
77
+ export function FeishuChannelsPage(): JSX.Element {
78
+ return (
79
+ <section className="dsh-channels-page" aria-label={PANEL_COPY.title}>
80
+ <div className="dsh-channels-page__content">
81
+ <FeishuChannelsPanel />
82
+ </div>
83
+ </section>
84
+ )
75
85
  }
76
86
 
77
- /** Page shell: one panel payload, one status poll, one global config draft. */
78
- export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.Element | null {
87
+ /** Panel shell: one panel payload, one status poll, one global config draft. */
88
+ function FeishuChannelsPanel(): JSX.Element | null {
79
89
  const [load, setLoad] = useState<LoadState>({ phase: 'loading' })
80
90
  const [activeTab, setActiveTab] = useState<PanelTabId>('connection')
81
91
  const [draft, setDraft] = useState<EditableValues>({})
@@ -652,6 +662,8 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
652
662
  }
653
663
  }, [busy, draft, hostBusy, issues, onPickDirectory, panel, renderedFields, updateDraft])
654
664
 
665
+ const profiles = useFeishuProfiles(panel?.access ?? null)
666
+
655
667
  const credentials = useMemo<CredentialsView>(() => ({
656
668
  appId: appIdInput,
657
669
  appSecret: appSecretInput,
@@ -741,6 +753,7 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
741
753
  ? (
742
754
  <AccessTab
743
755
  form={form}
756
+ profiles={profiles}
744
757
  accessNotice={accessNotice}
745
758
  accessError={accessError}
746
759
  onAddGroup={onAddAccessGroup}
@@ -755,6 +768,7 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
755
768
  ? (
756
769
  <WikiTab
757
770
  form={form}
771
+ profiles={profiles}
758
772
  accessNotice={accessNotice}
759
773
  accessError={accessError}
760
774
  onAddUser={(userId, name) => addUser(addWikiUser, userId, name, PANEL_COPY.wikiUserAdded)}
@@ -791,7 +805,7 @@ function PanelHeader(props: {
791
805
  return (
792
806
  <header className="dsh-channels-cfg__panelHead">
793
807
  <div className="dsh-channels-cfg__headRow">
794
- <h2 className="dsh-channels-cfg__title">{PANEL_COPY.title}</h2>
808
+ <h1 className="dsh-channels-cfg__title">{PANEL_COPY.title}</h1>
795
809
  <Pill tone={badge.tone} dot>{badge.text}</Pill>
796
810
  <span className="dsh-channels-cfg__meta dsh-channels-cfg__mono">v{status.version}</span>
797
811
  {status.uptimeMs === undefined
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Live Feishu names and avatars for every user and chat the access and wiki
3
+ * tabs show. The page asks the Host once on entry and again whenever the set
4
+ * of shown ids changes; a failed request keeps the previous answer.
5
+ *
6
+ * @module dsh-channels/client/panel/profiles
7
+ */
8
+
9
+ import { useEffect, useMemo, useState } from 'react'
10
+ import type { AccessWire, FeishuProfilesWire, FeishuProfileWire } from '../../settings/contract.ts'
11
+ import { fetchFeishuProfiles } from '../api.ts'
12
+
13
+ /** Resolved profiles plus the reason some could not be read. */
14
+ export interface ProfileView {
15
+ user: (userId: string) => FeishuProfileWire | undefined
16
+ chat: (chatId: string) => FeishuProfileWire | undefined
17
+ /** Feishu or request failure reason, when one lookup failed. */
18
+ warning?: string
19
+ }
20
+
21
+ /**
22
+ * The `user_id`s and `chat_id`s one access snapshot shows.
23
+ * @param access - the panel's access snapshot.
24
+ * @returns sorted, distinct ids per kind.
25
+ */
26
+ export function profileIds(access: AccessWire): { users: string[]; chats: string[] } {
27
+ const users = new Set<string>()
28
+ const chats = new Set<string>()
29
+ if (access.admin.userId !== undefined) users.add(access.admin.userId)
30
+ for (const user of access.users) users.add(user.userId)
31
+ for (const user of access.wikiUsers) users.add(user.userId)
32
+ for (const group of access.groups) chats.add(group.chatId)
33
+ for (const request of access.requests) {
34
+ if (request.kind === 'user') users.add(request.targetId)
35
+ else chats.add(request.targetId)
36
+ if (request.chatId !== undefined) chats.add(request.chatId)
37
+ }
38
+ return { users: [...users].sort(), chats: [...chats].sort() }
39
+ }
40
+
41
+ /**
42
+ * Profiles for one access snapshot; empty until the panel has loaded.
43
+ * @param access - the panel's access snapshot, null while loading.
44
+ * @returns lookups by id and the latest failure reason.
45
+ */
46
+ export function useFeishuProfiles(access: AccessWire | null): ProfileView {
47
+ const ids = useMemo(() => (access === null ? { users: [], chats: [] } : profileIds(access)), [access])
48
+ const key = `${ids.users.join(',')}|${ids.chats.join(',')}`
49
+ const [answer, setAnswer] = useState<FeishuProfilesWire>({ users: {}, chats: {} })
50
+ const [requestError, setRequestError] = useState<string | undefined>(undefined)
51
+ useEffect(() => {
52
+ if (ids.users.length === 0 && ids.chats.length === 0) return
53
+ let current = true
54
+ fetchFeishuProfiles(ids.users, ids.chats).then(
55
+ (next) => {
56
+ if (!current) return
57
+ setAnswer(next)
58
+ setRequestError(undefined)
59
+ },
60
+ (error: unknown) => { if (current) setRequestError(error instanceof Error ? error.message : String(error)) },
61
+ )
62
+ return () => { current = false }
63
+ // `key` carries `ids`: a new snapshot with the same ids does not re-ask.
64
+ }, [key])
65
+ return useMemo(() => {
66
+ const warning = requestError ?? answer.warning
67
+ return {
68
+ user: (userId: string) => answer.users[userId],
69
+ chat: (chatId: string) => answer.chats[chatId],
70
+ ...(warning === undefined ? {} : { warning }),
71
+ }
72
+ }, [answer, requestError])
73
+ }
@@ -8,6 +8,8 @@
8
8
  import { useState } from 'react'
9
9
  import { fillCopy, PANEL_COPY } from '../../settings/contract.ts'
10
10
  import type { AccessGroupWire, AccessRequestWire } from '../../settings/contract.ts'
11
+ import { Avatar } from '../avatar.tsx'
12
+ import type { ProfileView } from './profiles.ts'
11
13
  import { EmptyRow, Row, Section } from './shared.tsx'
12
14
 
13
15
  /** Local `MM-DD HH:mm` rendering for authorization timestamps. */
@@ -27,15 +29,13 @@ function sourceLabel(source: AccessGroupWire['source']): string {
27
29
  }
28
30
  }
29
31
 
30
- /** Round initial badge in front of a whitelist entry. */
31
- function Avatar(props: { text: string }): JSX.Element {
32
- return <span className="dsh-channels-cfg__avatar" aria-hidden="true">{Array.from(props.text)[0] ?? '?'}</span>
33
- }
34
-
35
32
  /** One whitelist entry: a group `chat_id` or a user `user_id`. */
36
33
  export interface WhitelistEntry {
37
34
  id: string
35
+ /** Live Feishu name, else the name captured at authorization time. */
38
36
  name?: string
37
+ /** Live Feishu avatar. */
38
+ avatarUrl?: string
39
39
  source: AccessGroupWire['source']
40
40
  }
41
41
 
@@ -48,6 +48,7 @@ export function Whitelist(props: {
48
48
  /** Shown after the title once loaded; defaults to the entry count. */
49
49
  countLabel?: string
50
50
  entries: readonly WhitelistEntry[]
51
+ /** Fallback avatar text for entries without an image; defaults to the entry name. */
51
52
  avatar?: string
52
53
  empty: string
53
54
  loaded: boolean
@@ -81,7 +82,7 @@ export function Whitelist(props: {
81
82
  {entries.map(entry => (
82
83
  <Row
83
84
  key={entry.id}
84
- leading={<Avatar text={avatar ?? entry.name ?? entry.id} />}
85
+ leading={<Avatar text={avatar ?? entry.name ?? entry.id} url={entry.avatarUrl} />}
85
86
  label={entry.name ?? entry.id}
86
87
  summary={<><span className="dsh-channels-cfg__mono">{entry.id}</span> · {sourceLabel(entry.source)}</>}
87
88
  control={(
@@ -146,22 +147,27 @@ export function Whitelist(props: {
146
147
  /** Pending authorization requests, settled with the same resolver as the DM card. */
147
148
  export function RequestList(props: {
148
149
  requests: readonly AccessRequestWire[]
150
+ profiles: ProfileView
149
151
  disabled: boolean
150
152
  working: boolean
151
153
  onResolve: (token: string, decision: 'allow' | 'deny') => void
152
154
  }): JSX.Element {
153
- const { requests, disabled, working, onResolve } = props
155
+ const { requests, profiles, disabled, working, onResolve } = props
154
156
  return (
155
157
  <Section title={`${PANEL_COPY.accessRequestsTitle} · ${requests.length}`} tone="warn">
156
158
  {requests.map((request) => {
157
- const target = request.name ?? request.targetId
158
- const where = request.kind === 'user' && (request.groupName ?? request.chatId) !== undefined
159
- ? fillCopy(PANEL_COPY.accessRequestIn, { group: request.groupName ?? request.chatId ?? '' })
159
+ const profile = request.kind === 'user' ? profiles.user(request.targetId) : profiles.chat(request.targetId)
160
+ const target = profile?.name ?? request.name ?? request.targetId
161
+ const group = request.chatId === undefined
162
+ ? request.groupName
163
+ : profiles.chat(request.chatId)?.name ?? request.groupName ?? request.chatId
164
+ const where = request.kind === 'user' && group !== undefined
165
+ ? fillCopy(PANEL_COPY.accessRequestIn, { group })
160
166
  : ''
161
167
  return (
162
168
  <Row
163
169
  key={request.token}
164
- leading={<Avatar text={target} />}
170
+ leading={<Avatar text={target} url={profile?.avatarUrl} />}
165
171
  label={`${target} ${where}${request.kind === 'group' ? PANEL_COPY.accessRequestGroup : PANEL_COPY.accessRequestUser}`}
166
172
  summary={(
167
173
  <>
@@ -7,12 +7,14 @@
7
7
  */
8
8
 
9
9
  import { fillCopy, PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
10
+ import type { ProfileView } from './profiles.ts'
10
11
  import { FieldSections, NoticeBar } from './shared.tsx'
11
12
  import type { FormView } from './shared.tsx'
12
13
  import { Whitelist } from './whitelist.tsx'
13
14
 
14
15
  export interface WikiTabProps {
15
16
  form: FormView
17
+ profiles: ProfileView
16
18
  accessNotice: string | null
17
19
  accessError: string | null
18
20
  onAddUser: (userId: string, name: string) => Promise<boolean>
@@ -20,18 +22,24 @@ export interface WikiTabProps {
20
22
  }
21
23
 
22
24
  export function WikiTab(props: WikiTabProps): JSX.Element {
23
- const { form, accessNotice, accessError, onAddUser, onRemoveUser } = props
25
+ const { form, profiles, accessNotice, accessError, onAddUser, onRemoveUser } = props
24
26
  const { access } = form.panel
25
27
  const disabled = form.busy !== null || form.locked || !access.loaded
26
28
  return (
27
29
  <div className="dsh-channels-cfg__tabBody">
28
30
  {accessNotice === null ? null : <NoticeBar kind="ok" text={accessNotice} />}
29
31
  {accessError === null ? null : <NoticeBar kind="error" text={accessError} />}
32
+ {profiles.warning === undefined ? null : <p className="dsh-channels-cfg__secFoot">{fillCopy(PANEL_COPY.profilesWarning, { reason: profiles.warning })}</p>}
30
33
  <FieldSections form={form} groups={TAB_FIELD_GROUPS.wiki} />
31
34
  <Whitelist
32
35
  title={PANEL_COPY.wikiUsersTitle}
33
36
  countLabel={fillCopy(PANEL_COPY.wikiUsersAdminPlus, { n: access.wikiUsers.length })}
34
- entries={access.wikiUsers.map(user => ({ id: user.userId, name: user.name, source: user.source }))}
37
+ entries={access.wikiUsers.map(user => ({
38
+ id: user.userId,
39
+ name: profiles.user(user.userId)?.name ?? user.name,
40
+ avatarUrl: profiles.user(user.userId)?.avatarUrl,
41
+ source: user.source,
42
+ }))}
35
43
  empty={PANEL_COPY.wikiUsersEmpty}
36
44
  loaded={access.loaded}
37
45
  addLabel={PANEL_COPY.accessAddUser}
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Settings panel styling. DSH semantic tokens (`--dsw-alias-*`) with neutral
2
+ * Feishu channel page styling. DSH semantic tokens (`--dsw-alias-*`) with neutral
3
3
  * fallbacks, aliased once to local `--dc-*` variables, so the page follows
4
4
  * the active theme.
5
5
  *
@@ -9,10 +9,13 @@
9
9
  const STYLE_ATTRIBUTE = 'data-dsh-channels-css'
10
10
 
11
11
  const CSS = `
12
+ .dsh-channels-page{width:100%;height:100%;min-width:0;min-height:0;overflow:auto;scrollbar-gutter:stable;background:var(--dsw-alias-bg-base,#fff)}
13
+ .dsh-channels-page__content{box-sizing:border-box;max-width:816px;margin:0 auto;padding:18px clamp(24px,4vw,48px) 48px}
14
+ [data-platform='darwin'] .dsh-channels-page__content{padding-top:calc(18px + var(--dsh-frame-top-clearance,0px))}
12
15
  .dsh-channels-cfg{--dc-fg:var(--dsw-alias-label-primary,#1f2328);--dc-fg2:var(--dsw-alias-label-secondary,#5c6169);--dc-fg3:var(--dsw-alias-label-tertiary,#8a8f98);--dc-dim:var(--dsw-alias-label-dimmed,#b0b4ba);--dc-bg:var(--dsw-alias-bg-base,#fff);--dc-line:var(--dsw-alias-border-l2,rgba(0,0,0,.12));--dc-hover:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05));--dc-accent:var(--dsw-alias-state-business-primary,#0a67d3);--dc-ok:var(--dsw-alias-state-success-primary,#0a7d33);--dc-warn:var(--dsw-alias-state-warn-primary,#a15c00);--dc-bad:var(--dsw-alias-state-error-primary,#c62828);--dc-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;container-type:inline-size;display:flex;flex-direction:column;gap:14px;width:100%;max-width:720px;color:var(--dc-fg);font-size:13px;line-height:20px}
13
16
  .dsh-channels-cfg__panelHead{position:sticky;top:0;z-index:2;display:flex;flex-direction:column;gap:4px;padding:10px 0 6px;background:var(--dc-bg)}
14
17
  .dsh-channels-cfg__headRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
15
- .dsh-channels-cfg__title{margin:0 2px 0 0;font-size:17px;font-weight:600;line-height:26px}
18
+ .dsh-channels-cfg__title{margin:0 2px 0 0;font-size:20px;font-weight:500;line-height:28px}
16
19
  .dsh-channels-cfg__meta{color:var(--dc-fg3);font-size:12px}
17
20
  .dsh-channels-cfg__spacer{flex:1}
18
21
  .dsh-channels-cfg__mono{font-family:var(--dc-mono);font-size:12px}
@@ -103,7 +106,7 @@ const CSS = `
103
106
  .dsh-channels-cfg__scopeList li{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
104
107
  .dsh-channels-cfg__scopeList .dsh-channels-cfg__link{font-size:12px}
105
108
  .dsh-channels-cfg__list>.dsh-channels-cfg__scopeList{border-top-width:0}
106
- .dsh-channels-cfg__avatar{display:grid;place-items:center;flex:none;width:26px;height:26px;border-radius:50%;background:var(--dc-hover);color:var(--dc-fg2);font-size:12px}
109
+ .dsh-channels-avatar{box-sizing:border-box;display:grid;place-items:center;flex:none;border-radius:50%;object-fit:cover;background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05));color:var(--dsw-alias-label-secondary,#5c6169);line-height:1}
107
110
  .dsh-channels-cfg__addRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 14px}
108
111
  .dsh-channels-cfg__addRow .dsh-channels-cfg__input{flex:1 1 160px;width:auto}
109
112
  .dsh-channels-cfg__emptyRow{padding:11px 14px;color:var(--dc-fg3);font-size:12px}
@@ -116,6 +119,26 @@ const CSS = `
116
119
  .dsh-channels-cfg__stepTitle{font-weight:500}
117
120
  .dsh-channels-cfg__error{padding:10px 12px;border:1px solid var(--dc-bad);border-radius:10px;color:var(--dc-bad);font-size:12px;line-height:18px;display:flex;flex-direction:column;gap:8px}
118
121
  .dsh-channels-cfg__loading{padding:16px 0;color:var(--dc-fg3);font-size:12px}
122
+ .dsh-channels-origin-chip{display:inline-flex;align-items:center;gap:4px;max-width:180px;height:24px;padding:0 8px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary,#5c6169);font:inherit;font-size:12px;line-height:24px;cursor:pointer;white-space:nowrap}
123
+ .dsh-channels-origin-chip:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05));color:var(--dsw-alias-label-primary,#1f2328)}
124
+ .dsh-channels-origin-chip>svg{flex:none}
125
+ .dsh-channels-origin__who{display:inline-flex;align-items:center;gap:6px;vertical-align:middle}
126
+ .dsh-channels-origin-chip__text{overflow:hidden;text-overflow:ellipsis}
127
+ .dsh-channels-origin{box-sizing:border-box;height:100%;overflow:auto;padding:16px;color:var(--dsw-alias-label-primary,#1f2328);font-size:13px;line-height:20px}
128
+ .dsh-channels-origin__head{display:flex;align-items:center;gap:8px;margin-bottom:12px}
129
+ .dsh-channels-origin__title{font-size:15px;font-weight:600;line-height:22px;overflow-wrap:anywhere}
130
+ .dsh-channels-origin__list{display:flex;flex-direction:column;margin:0;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:10px}
131
+ .dsh-channels-origin__row{display:flex;gap:12px;padding:9px 12px}
132
+ .dsh-channels-origin__row+.dsh-channels-origin__row{border-top:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12))}
133
+ .dsh-channels-origin__row dt{flex:none;width:64px;color:var(--dsw-alias-label-tertiary,#8a8f98)}
134
+ .dsh-channels-origin__row dd{flex:1;min-width:0;margin:0;overflow-wrap:anywhere}
135
+ .dsh-channels-origin__mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px}
136
+ .dsh-channels-origin__muted{color:var(--dsw-alias-label-tertiary,#8a8f98)}
137
+ .dsh-channels-origin__error{display:flex;flex-direction:column;gap:6px;color:var(--dsw-alias-state-error-primary,#c62828);font-size:12px}
138
+ .dsh-channels-origin__actions{display:flex;align-items:center;gap:14px;margin-top:14px}
139
+ .dsh-channels-origin__button{display:inline-flex;align-items:center;height:28px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:7px;color:inherit;font-size:13px;text-decoration:none}
140
+ .dsh-channels-origin__button:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05))}
141
+ .dsh-channels-origin__link{padding:0;border:0;background:none;color:var(--dsw-alias-state-business-primary,#0a67d3);font:inherit;font-size:13px;cursor:pointer}
119
142
  @media (prefers-reduced-motion:reduce){.dsh-channels-cfg__switch,.dsh-channels-cfg__switch::after{transition:none}}
120
143
  @container (max-width:560px){
121
144
  .dsh-channels-cfg__row:not(.dsh-channels-cfg__row--wide){flex-wrap:wrap}
package/src/config.ts CHANGED
@@ -384,13 +384,20 @@ export const configSchema = z.object({
384
384
  * Admission policy for inbound Feishu traffic.
385
385
  *
386
386
  * - `guarded` (default): only the configured administrator can talk in
387
- * private chats, and a group conversation additionally requires the group
388
- * to be authorized and the sender to be the administrator or an explicitly
387
+ * private chats (plus whitelisted users when `dmAllowWhitelist` is on),
388
+ * and a group conversation additionally requires the group to be
389
+ * authorized and the sender to be the administrator or an explicitly
389
390
  * authorized user.
390
391
  * - `open`: legacy behavior — any user inside the app's availability scope
391
392
  * can start a conversation. Debug/compatibility only; unsafe.
392
393
  */
393
394
  accessMode: z.enum(['open', 'guarded']).default('guarded'),
395
+ /**
396
+ * In `guarded` mode, also admit users on the global user whitelist
397
+ * (`/auth user`) in private chats. Off by default: private chats stay
398
+ * administrator-only. Each private-chat user already has an own workspace.
399
+ */
400
+ dmAllowWhitelist: z.boolean().default(false),
394
401
  /**
395
402
  * The unique administrator's Feishu `user_id`. Set it in the Web
396
403
  * settings panel (or the profile config). Get it by sending `/userid` to the
package/src/domain.ts CHANGED
@@ -217,8 +217,8 @@ export type AccessGroupRecord = z.infer<typeof accessGroupRecordSchema>
217
217
 
218
218
  /**
219
219
  * One authorized user (`user_id`), global across every authorized group.
220
- * Private chats are administrator-only, so this grant only affects group
221
- * conversations.
220
+ * Private chats are administrator-only unless `dmAllowWhitelist` is on, so
221
+ * by default this grant only affects group conversations.
222
222
  */
223
223
  export const accessUserRecordSchema = z.object({
224
224
  /** Feishu `user_id` — also the table key. */
@@ -321,6 +321,34 @@ export const analyzeRequestRecordSchema = z.object({
321
321
 
322
322
  export type AnalyzeRequestRecord = z.infer<typeof analyzeRequestRecordSchema>
323
323
 
324
+ /**
325
+ * Where one Feishu-routed Session came from, keyed by its DSH session id.
326
+ * Written the first time a route record names the Session (new topic,
327
+ * `/renew`, analyze API) and kept after the route retires, so the Web
328
+ * client can still show the origin of an old Session. Names are resolved
329
+ * live when read, never stored.
330
+ */
331
+ export const sessionOriginRecordSchema = z.object({
332
+ /** DSH session id (also the table key). */
333
+ key: z.string().min(1),
334
+ /** Route key the Session was bound under (thread_id or chat:<chatId>). */
335
+ routeKey: z.string().min(1),
336
+ /** Topic-scoped (`thread`) or chat-scoped (`chat`) route. */
337
+ kind: z.enum(['thread', 'chat']),
338
+ /** Feishu chat the Session was started from. */
339
+ chatId: z.string().min(1),
340
+ /** `p2p` or `group`. */
341
+ chatType: z.enum(['p2p', 'group']),
342
+ /** `user_id` of the user who started the Session (the requester for analyze topics). */
343
+ senderUserId: z.string().min(1),
344
+ /** Epoch ms when the Session was bound to the route. */
345
+ createdAt: z.number().int().nonnegative(),
346
+ /** Group-shared route (log-platform analysis topic). */
347
+ shared: z.literal(true).optional(),
348
+ })
349
+
350
+ export type SessionOriginRecord = z.infer<typeof sessionOriginRecordSchema>
351
+
324
352
  export interface FeishuDomainSpecShape {
325
353
  name: 'channels_routes_v2'
326
354
  version: 1
@@ -365,6 +393,10 @@ export interface FeishuDomainSpecShape {
365
393
  valueSchema: typeof analyzeRequestRecordSchema
366
394
  __key?: string
367
395
  }
396
+ session_origins: {
397
+ valueSchema: typeof sessionOriginRecordSchema
398
+ __key?: string
399
+ }
368
400
  }
369
401
  }
370
402
 
@@ -407,6 +439,8 @@ export const feishuDomain: FeishuDomainSpecShape = {
407
439
  access_wiki_users: { valueSchema: accessUserRecordSchema },
408
440
  // Analyze API idempotency records; a missing table loads empty.
409
441
  analyze_requests: { valueSchema: analyzeRequestRecordSchema },
442
+ // Session id → Feishu origin; a missing table loads empty.
443
+ session_origins: { valueSchema: sessionOriginRecordSchema },
410
444
  },
411
445
  }
412
446
 
@@ -480,6 +514,12 @@ export interface AnalyzeRequestsTable {
480
514
  entries(): IterableIterator<[string, AnalyzeRequestRecord]>
481
515
  }
482
516
 
517
+ /** Normalized table handle for the session-origin table (key = DSH session id). */
518
+ export interface SessionOriginsTable {
519
+ get(key: string): SessionOriginRecord | undefined
520
+ put(key: string, value: SessionOriginRecord): Promise<unknown> | void
521
+ }
522
+
483
523
  /** Normalized domain handle. */
484
524
  export interface FeishuDomainHandle {
485
525
  routes: RoutesTable
@@ -493,6 +533,8 @@ export interface FeishuDomainHandle {
493
533
  accessWikiUsers: AccessUsersTable
494
534
  /** Analyze API idempotency records. */
495
535
  analyzeRequests: AnalyzeRequestsTable
536
+ /** Feishu origin of every Session a route ever named. */
537
+ sessionOrigins: SessionOriginsTable
496
538
  getGlobal(): FeishuDomainGlobal
497
539
  setGlobal(value: FeishuDomainGlobal): Promise<unknown> | void
498
540
  close(): Promise<void>
@@ -77,7 +77,8 @@ const BASE_REQUIREMENTS: readonly ScopeRequirement[] = [
77
77
  { anyOf: ['im:message:update', 'im:message'], level: 'required', purpose: '流式更新回复卡片' },
78
78
  { anyOf: ['im:message.reactions:write_only', 'im:message'], level: 'optional', purpose: '给消息添加/移除处理中表情' },
79
79
  { anyOf: ['im:message:readonly', 'im:message'], level: 'optional', purpose: '读取引用消息、话题历史与 feishu_topic_messages' },
80
- { anyOf: ['im:chat:readonly', 'im:chat'], level: 'optional', purpose: '机器人入群后读取群信息、发送群信息卡' },
80
+ { anyOf: ['im:chat:readonly', 'im:chat'], level: 'optional', purpose: '机器人入群后读取群信息、发送群信息卡;Web 页面显示群名和群头像' },
81
+ { anyOf: ['contact:user.base:readonly'], level: 'optional', purpose: 'Web 页面显示用户真实姓名和头像' },
81
82
  ]
82
83
 
83
84
  /** Panel sync needs, applicable while `slashCommandPanel` is on. */
@@ -71,6 +71,27 @@ export interface FeishuChannel {
71
71
  onBotAdded(handler: (event: BotAddedEvent) => void | Promise<void>): () => void
72
72
  /** Chat name/owner/member count (needs chat-info read permission). */
73
73
  getChatInfo(chatId: string): Promise<ChatInfo>
74
+ /**
75
+ * Contact profiles (name and avatar) of tenant users by `user_id`
76
+ * (`contact.v3.user.batch`, `contact:user.base:readonly`). Users outside the
77
+ * app's contact range are absent from the map. Rejects with a
78
+ * {@link FeishuApiError} when the lookup fails as a whole. Optional so stubs
79
+ * can omit it.
80
+ */
81
+ userProfiles?(userIds: readonly string[]): Promise<ReadonlyMap<string, FeishuProfile>>
82
+ /**
83
+ * Name and avatar of one chat (`im.v1.chat.get`, `im:chat:readonly`); a bot
84
+ * outside the chat still gets both. Rejects with a {@link FeishuApiError}
85
+ * when the call fails. Optional so stubs can omit it.
86
+ */
87
+ chatProfile?(chatId: string): Promise<FeishuProfile>
88
+ /**
89
+ * Display name of one user from the member list of `chatId`
90
+ * (`im:chat:readonly`), for users the contact profile cannot name. Resolves
91
+ * `undefined` when the list does not name the user; never rejects. Optional
92
+ * so stubs can omit it.
93
+ */
94
+ memberName?(userId: string, chatId: string): Promise<string | undefined>
74
95
  /** Bot open_id, available after `connect()` resolves. */
75
96
  botOpenId(): string | undefined
76
97
  /**
@@ -278,6 +299,29 @@ function assertFeishuOk(response: { code?: number; msg?: string }): void {
278
299
  }
279
300
  }
280
301
 
302
+ /** Member-list pages `memberName()` scans before giving up (100 members each). */
303
+ const MEMBER_NAME_PAGES = 5
304
+
305
+ /** `user_ids` per `contact.v3.user.batch` call (the API maximum). */
306
+ const USER_BATCH_SIZE = 50
307
+
308
+ /** Display name and avatar of one Feishu user or chat, as far as Feishu reports them. */
309
+ export interface FeishuProfile {
310
+ name?: string
311
+ /** `https:` image URL on the Feishu CDN. */
312
+ avatarUrl?: string
313
+ }
314
+
315
+ /** Keep a non-blank name and an `https:` avatar URL from one Feishu response. */
316
+ function feishuProfile(name: string | undefined, avatar: string | undefined): FeishuProfile {
317
+ const trimmed = name?.trim()
318
+ const avatarUrl = avatar !== undefined && /^https:\/\/[^\s"'<>]+$/u.test(avatar) ? avatar : undefined
319
+ return {
320
+ ...(trimmed === undefined || trimmed === '' ? {} : { name: trimmed }),
321
+ ...(avatarUrl === undefined ? {} : { avatarUrl }),
322
+ }
323
+ }
324
+
281
325
  /** Run one raw request, mapping thrown and non-zero-code failures to {@link FeishuApiError}. */
282
326
  async function feishuCall<T extends { code?: number; msg?: string }>(call: () => Promise<T>): Promise<T> {
283
327
  let response: T
@@ -450,6 +494,49 @@ class LarkChannelAdapter implements FeishuChannel {
450
494
  return this.channel.getChatInfo(chatId)
451
495
  }
452
496
 
497
+ async userProfiles(userIds: readonly string[]): Promise<ReadonlyMap<string, FeishuProfile>> {
498
+ const client = this.channel.rawClient
499
+ const profiles = new Map<string, FeishuProfile>()
500
+ for (let offset = 0; offset < userIds.length; offset += USER_BATCH_SIZE) {
501
+ const response = await feishuCall(() => client.contact.v3.user.batch({
502
+ params: { user_ids: userIds.slice(offset, offset + USER_BATCH_SIZE), user_id_type: 'user_id' },
503
+ }))
504
+ for (const item of response.data?.items ?? []) {
505
+ if (item.user_id === undefined) continue
506
+ profiles.set(item.user_id, feishuProfile(item.name, item.avatar?.avatar_72))
507
+ }
508
+ }
509
+ return profiles
510
+ }
511
+
512
+ async chatProfile(chatId: string): Promise<FeishuProfile> {
513
+ const client = this.channel.rawClient
514
+ const response = await feishuCall(() => client.im.v1.chat.get({ path: { chat_id: chatId } }))
515
+ return feishuProfile(response.data?.name, response.data?.avatar)
516
+ }
517
+
518
+ async memberName(userId: string, chatId: string): Promise<string | undefined> {
519
+ const client = this.channel.rawClient
520
+ let pageToken: string | undefined
521
+ for (let page = 0; page < MEMBER_NAME_PAGES; page += 1) {
522
+ let response
523
+ try {
524
+ response = await feishuCall(() => client.im.v1.chatMembers.get({
525
+ path: { chat_id: chatId },
526
+ params: { member_id_type: 'user_id', page_size: 100, ...(pageToken === undefined ? {} : { page_token: pageToken }) },
527
+ }))
528
+ } catch (_membersUnavailable) {
529
+ // No chat-read scope, or a chat the bot left: the caller shows the id.
530
+ return undefined
531
+ }
532
+ const hit = response.data?.items?.find(item => item.member_id === userId)?.name?.trim()
533
+ if (hit !== undefined && hit !== '') return hit
534
+ pageToken = response.data?.page_token
535
+ if (response.data?.has_more !== true || pageToken === undefined) return undefined
536
+ }
537
+ return undefined
538
+ }
539
+
453
540
  botOpenId(): string | undefined {
454
541
  return this.channel.botIdentity?.openId
455
542
  }