@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.
- package/README.md +31 -13
- package/docs/attachment-pipeline-v2.md +1 -1
- package/docs/feishu-onboarding.md +5 -4
- package/lib/client.js +605 -55
- package/lib/client.js.map +1 -1
- package/lib/index.js +379 -21
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/access/cards.ts +5 -1
- package/src/access/controller.ts +14 -6
- package/src/access/policy.ts +7 -2
- package/src/access/requests.ts +1 -0
- package/src/client/api.ts +45 -2
- package/src/client/avatar.tsx +36 -0
- package/src/client/icon.tsx +25 -0
- package/src/client/index.ts +78 -23
- package/src/client/origin-cache.ts +45 -0
- package/src/client/origin.tsx +201 -0
- package/src/client/panel/access-tab.tsx +41 -13
- package/src/client/panel/index.tsx +23 -9
- package/src/client/panel/profiles.ts +73 -0
- package/src/client/panel/whitelist.tsx +17 -11
- package/src/client/panel/wiki-tab.tsx +10 -2
- package/src/client/styles.ts +26 -3
- package/src/config.ts +9 -2
- package/src/domain.ts +44 -2
- package/src/feishu/scopes.ts +2 -1
- package/src/feishu-channel.ts +87 -0
- package/src/routing/inbound.ts +13 -5
- package/src/service.ts +147 -3
- package/src/session/routes.ts +45 -4
- package/src/settings/contract.ts +70 -1
- package/src/settings/routes.ts +47 -0
- package/src/settings/surface.ts +30 -1
|
@@ -10,12 +10,16 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { fillCopy, PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
|
|
13
|
-
import {
|
|
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
|
|
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 => ({
|
|
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 => ({
|
|
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
|
-
*
|
|
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 与 `
|
|
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
|
-
/**
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
/**
|
|
78
|
-
|
|
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
|
-
<
|
|
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
|
|
158
|
-
const
|
|
159
|
-
|
|
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 => ({
|
|
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}
|
package/src/client/styles.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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:
|
|
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-
|
|
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
|
|
388
|
-
*
|
|
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
|
|
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>
|
package/src/feishu/scopes.ts
CHANGED
|
@@ -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. */
|
package/src/feishu-channel.ts
CHANGED
|
@@ -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
|
}
|