@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@modesto_ma/dsh-channels",
3
- "version": "0.5.0-beta.5",
4
- "description": "DeepSeek Harness (DSH) 飞书渠道插件:一个 dsh 实例对应一个飞书机器人,私聊/群聊/话题群 @ 触发会话,话题绑定 session,卡片 2.0 流式回复;Web 设置里提供「飞书渠道」状态与常用配置面板",
3
+ "version": "0.5.0-beta.7",
4
+ "description": "DeepSeek Harness (DSH) 飞书渠道插件:一个 dsh 实例对应一个飞书机器人,私聊/群聊/话题群 @ 触发会话,话题绑定 session,卡片 2.0 流式回复;Web 左侧栏提供「飞书渠道」状态与常用配置面板",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/modestoma/dsh_channels.git"
@@ -49,6 +49,8 @@ export interface UserRequestCardInput {
49
49
  requesterName?: string
50
50
  /** Opaque request token. */
51
51
  token: string
52
+ /** Whether whitelisted users may also use private chats (`dmAllowWhitelist`). */
53
+ dmAllowWhitelist?: boolean
52
54
  /** Render timestamp (defaults to now). */
53
55
  now?: number
54
56
  }
@@ -158,7 +160,9 @@ export function buildUserRequestCard(input: UserRequestCardInput): object {
158
160
  }
159
161
  elements.push(field('申请时间', formatTime(input.now)))
160
162
  elements.push(divider())
161
- elements.push(note('授权后该用户可在所有已授权群里使用;私聊仍仅管理员可用。'))
163
+ elements.push(note(input.dmAllowWhitelist === true
164
+ ? '授权后该用户可在所有已授权群里使用,也可以私聊机器人。'
165
+ : '授权后该用户可在所有已授权群里使用;私聊仍仅管理员可用。'))
162
166
  elements.push(decisionButtons(input.token, '授权用户', '忽略'))
163
167
  return standaloneCard('**🙋 用户授权申请**', elements)
164
168
  }
@@ -6,14 +6,15 @@
6
6
  * matrix lives in `policy.ts`; this module owns the user-facing behavior on
7
7
  * denial:
8
8
  *
9
- * - private chats: non-administrators get one fixed notice (10 s per-sender
9
+ * - private chats: denied senders (non-administrators, or non-whitelisted
10
+ * users with `dmAllowWhitelist`) get one fixed notice (10 s per-sender
10
11
  * cooldown); with no administrator configured they get the bootstrap
11
12
  * notice instead;
12
13
  * - group chats: fully silent, except that addressed denials call back into
13
14
  * the authorization-request lifecycle so the administrator can be asked;
14
15
  * - slash commands: `/userid` stays available to every private sender, the
15
16
  * administrator's commands always pass (including inside an unauthorized
16
- * group), everything else must pass admission.
17
+ * group), everything else must pass admission (in private chats too).
17
18
  *
18
19
  * The controller never creates a Session or a workspace.
19
20
  *
@@ -30,6 +31,9 @@ import type { AccessStore, AccessStoreSnapshot } from './store.ts'
30
31
  /** Fixed private-chat notice for non-administrators (no model call). */
31
32
  export const DM_DENY_TEXT = '非管理员禁用私聊功能哦~如需使用,请在管理员授权的群里 @ 我;或联系管理员把你的 user_id 加入白名单。'
32
33
 
34
+ /** Fixed private-chat notice for non-whitelisted users while `dmAllowWhitelist` is on. */
35
+ export const DM_DENY_WHITELIST_TEXT = '你还不在白名单里,暂时不能私聊哦~发送 /userid 查看你的 user_id,请管理员加入白名单后即可使用。'
36
+
33
37
  /** Fixed private-chat notice while no administrator is configured. */
34
38
  export const DM_UNCONFIGURED_TEXT = '机器人尚未配置管理员。发送 /userid 可查看你的 user_id,交给部署者配置后即可启用。'
35
39
 
@@ -123,8 +127,9 @@ export class AccessController {
123
127
  /**
124
128
  * Slash-command admission. Runs before any command card/topic/Session side
125
129
  * effect. `open` mode keeps the legacy behavior; otherwise private chats
126
- * stay administrator-only (with `/userid` as the bootstrap exception) and
127
- * group chats must pass the full admission matrix; the administrator is
130
+ * admit whoever the admission matrix admits (with `/userid` as the
131
+ * bootstrap exception) and group chats must pass the full admission
132
+ * matrix; the administrator is
128
133
  * never constrained by the whitelists, so all of their commands pass even
129
134
  * inside an unauthorized group (`/auth` is the in-conversation grant
130
135
  * entry).
@@ -136,7 +141,7 @@ export class AccessController {
136
141
  // `/userid` is the bootstrap entry for everyone: it only echoes the
137
142
  // sender's own user_id and has no storage/model side effect.
138
143
  if (commandName === 'userid') return true
139
- if (this.isAdmin(msg.senderId)) return true
144
+ if (this.decide(msg).kind === 'allow') return true
140
145
  await this.noticeDeniedDm(msg)
141
146
  return false
142
147
  }
@@ -207,7 +212,10 @@ export class AccessController {
207
212
  const last = this.dmNoticeAt.get(msg.senderId)
208
213
  if (last !== undefined && now - last < DM_NOTICE_COOLDOWN_MS) return
209
214
  this.dmNoticeAt.set(msg.senderId, now)
210
- const text = this.config().adminUserId === undefined ? DM_UNCONFIGURED_TEXT : DM_DENY_TEXT
215
+ const config = this.config()
216
+ const text = config.adminUserId === undefined
217
+ ? DM_UNCONFIGURED_TEXT
218
+ : config.dmAllowWhitelist ? DM_DENY_WHITELIST_TEXT : DM_DENY_TEXT
211
219
  await this.sendText(msg.chatId, text, msg.messageId)
212
220
  }
213
221
 
@@ -11,7 +11,9 @@
11
11
  * Access model:
12
12
  * - the administrator may start/continue a conversation in any private chat
13
13
  * or any group, regardless of the whitelists;
14
- * - private chats: non-administrators are refused;
14
+ * - private chats: non-administrators are refused, unless
15
+ * `dmAllowWhitelist` is on and their user_id is on the global user
16
+ * whitelist;
15
17
  * - group chats: non-administrators need the group to be authorized AND
16
18
  * their user_id to be on the global user whitelist.
17
19
  *
@@ -56,7 +58,7 @@ export type AccessDecision =
56
58
  }
57
59
 
58
60
  /** The configuration slice admission reads (allows easy fakes in tests). */
59
- export type AccessPolicyConfig = Pick<FeishuConfig, 'accessMode' | 'adminUserId'>
61
+ export type AccessPolicyConfig = Pick<FeishuConfig, 'accessMode' | 'adminUserId' | 'dmAllowWhitelist'>
60
62
 
61
63
  /** True when `userId` is exactly the configured administrator. */
62
64
  export function isAdminUserId(
@@ -90,6 +92,9 @@ export function decideAccess(
90
92
  if (subject.senderId.trim() === admin) return { kind: 'allow', reason: 'admin' }
91
93
 
92
94
  if (subject.chatType === 'p2p') {
95
+ if (config.dmAllowWhitelist && snapshot.users.has(subject.senderId)) {
96
+ return { kind: 'allow', reason: 'authorized-user' }
97
+ }
93
98
  return { kind: 'deny', reason: 'dm-non-admin' }
94
99
  }
95
100
 
@@ -350,6 +350,7 @@ export class AccessRequests {
350
350
  ...(groupName === undefined ? {} : { groupName }),
351
351
  ...(msg.senderName === undefined ? {} : { requesterName: msg.senderName }),
352
352
  token: record.token,
353
+ dmAllowWhitelist: this.config().dmAllowWhitelist,
353
354
  now,
354
355
  }), record)
355
356
  return true
package/src/client/api.ts CHANGED
@@ -16,7 +16,10 @@ import {
16
16
  CREDENTIALS_ROUTE,
17
17
  PANEL_ROUTE,
18
18
  PICK_DIRECTORY_ROUTE,
19
+ PROFILES_MAX_IDS,
20
+ PROFILES_ROUTE,
19
21
  RECONNECT_ROUTE,
22
+ SESSION_ORIGIN_ROUTE,
20
23
  STATUS_ROUTE,
21
24
  } from '../settings/contract.ts'
22
25
  import type {
@@ -28,12 +31,14 @@ import type {
28
31
  ClearCredentialResponse,
29
32
  CredentialField,
30
33
  EditableValues,
34
+ FeishuProfilesWire,
31
35
  PanelWire,
32
36
  PickDirectoryResponse,
33
37
  ReconnectResponse,
34
38
  SaveConfigResponse,
35
39
  SaveCredentialsRequest,
36
40
  SaveCredentialsResponse,
41
+ SessionOriginResponse,
37
42
  } from '../settings/contract.ts'
38
43
 
39
44
  function messageOf(error: unknown): string {
@@ -51,9 +56,15 @@ function errorFromBody(status: number, text: string): string {
51
56
  return `HTTP ${status}`
52
57
  }
53
58
 
54
- async function request(path: string, init?: RequestInit): Promise<Response> {
59
+ /**
60
+ * Fetch one Host route. Routes are registered as absolute pathnames, but the
61
+ * browser addresses them relative to the DSH document (`<base href="./">`), so
62
+ * a reverse proxy that mounts DSH under a prefix such as `/dsh/` still routes
63
+ * them to DSH with the login cookie it scoped to that prefix.
64
+ */
65
+ async function request(route: string, init?: RequestInit): Promise<Response> {
55
66
  try {
56
- return await fetch(path, {
67
+ return await fetch(route.slice(1), {
57
68
  cache: 'no-store',
58
69
  credentials: 'same-origin',
59
70
  ...init,
@@ -80,6 +91,38 @@ export async function fetchStatus(): Promise<ChannelStatusWire> {
80
91
  return readJson<ChannelStatusWire>(await request(STATUS_ROUTE))
81
92
  }
82
93
 
94
+ /** Feishu origin of one Session (`origin: null` when Feishu never routed it). */
95
+ export async function fetchSessionOrigin(sessionId: string): Promise<SessionOriginResponse> {
96
+ const query = new URLSearchParams({ sessionId }).toString()
97
+ return readJson<SessionOriginResponse>(await request(`${SESSION_ORIGIN_ROUTE}?${query}`))
98
+ }
99
+
100
+ /**
101
+ * Live Feishu names and avatars of the given users and chats, split into
102
+ * requests of at most {@link PROFILES_MAX_IDS} ids per list and merged.
103
+ * @param userIds - user `user_id`s.
104
+ * @param chatIds - chat `chat_id`s.
105
+ * @returns the profiles Feishu resolved, by id, with every request's warning.
106
+ */
107
+ export async function fetchFeishuProfiles(userIds: readonly string[], chatIds: readonly string[]): Promise<FeishuProfilesWire> {
108
+ const requests: Array<Promise<FeishuProfilesWire>> = []
109
+ const rounds = Math.max(1, Math.ceil(userIds.length / PROFILES_MAX_IDS), Math.ceil(chatIds.length / PROFILES_MAX_IDS))
110
+ for (let round = 0; round < rounds; round += 1) {
111
+ const window = (ids: readonly string[]): string => ids.slice(round * PROFILES_MAX_IDS, (round + 1) * PROFILES_MAX_IDS).join(',')
112
+ const query = new URLSearchParams({ users: window(userIds), chats: window(chatIds) }).toString()
113
+ requests.push(request(`${PROFILES_ROUTE}?${query}`).then(response => readJson<FeishuProfilesWire>(response)))
114
+ }
115
+ const merged: FeishuProfilesWire = { users: {}, chats: {} }
116
+ const warnings = new Set<string>()
117
+ for (const part of await Promise.all(requests)) {
118
+ Object.assign(merged.users, part.users)
119
+ Object.assign(merged.chats, part.chats)
120
+ if (part.warning !== undefined) warnings.add(part.warning)
121
+ }
122
+ if (warnings.size > 0) merged.warning = [...warnings].join(';')
123
+ return merged
124
+ }
125
+
83
126
  /**
84
127
  * Model-selector catalog for one route: providers, the provider's models, the
85
128
  * model's reasoning efforts, and the preset roster. The client re-queries it
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Round Feishu avatar: the user or chat image when Feishu returned one,
3
+ * otherwise (and when the image fails to load) the first character of the
4
+ * display text.
5
+ *
6
+ * @module dsh-channels/client/avatar
7
+ */
8
+
9
+ import { useEffect, useState } from 'react'
10
+
11
+ /**
12
+ * One avatar badge. Decorative: the adjacent label names the user or chat.
13
+ * @param props - `text` supplies the fallback initial, `url` the image, `size` the diameter in px (default 26).
14
+ * @returns the badge.
15
+ */
16
+ export function Avatar(props: { text: string; url?: string; size?: number }): JSX.Element {
17
+ const { text, url, size = 26 } = props
18
+ const [failed, setFailed] = useState(false)
19
+ useEffect(() => { setFailed(false) }, [url])
20
+ const style = { width: size, height: size, fontSize: Math.round(size * 0.46) }
21
+ if (url !== undefined && !failed) {
22
+ return (
23
+ <img
24
+ className="dsh-channels-avatar"
25
+ style={style}
26
+ src={url}
27
+ alt=""
28
+ aria-hidden="true"
29
+ referrerPolicy="no-referrer"
30
+ loading="lazy"
31
+ onError={() => setFailed(true)}
32
+ />
33
+ )
34
+ }
35
+ return <span className="dsh-channels-avatar" style={style} aria-hidden="true">{Array.from(text)[0] ?? '?'}</span>
36
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 左侧栏「飞书渠道」行的装饰图标;行本身负责可访问名称(列表注册的 label)。
3
+ *
4
+ * @module dsh-channels/client/icon
5
+ */
6
+
7
+ /** Owner share of `sidebar.panellist`: requested square edge and selection state. */
8
+ export interface FeishuChannelsIconProps {
9
+ size: number
10
+ active: boolean
11
+ }
12
+
13
+ /**
14
+ * Paper-plane glyph drawn in `currentColor`, so it follows the row's text color.
15
+ * @param props - the sidebar's icon share.
16
+ * @returns decorative icon.
17
+ */
18
+ export function FeishuChannelsIcon({ size }: FeishuChannelsIconProps): JSX.Element {
19
+ return (
20
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
21
+ <path d="M21 3 3.5 10.2a.6.6 0 0 0 0 1.1l6.6 2.6 2.6 6.6a.6.6 0 0 0 1.1 0L21 3Z" />
22
+ <path d="m10.1 13.9 4.4-4.4" />
23
+ </svg>
24
+ )
25
+ }
@@ -1,35 +1,52 @@
1
1
  /**
2
- * dsh-channels client half: 设置 →「飞书渠道」分区。
2
+ * dsh-channels client half: 左侧栏全局入口「飞书渠道」+ 主区整页设置面板,
3
+ * 以及对话内的「飞书来源」(会话头部入口 + 右侧栏标签页)。
3
4
  *
4
5
  * 只依赖平台核心的 `slots` 服务与 react(bundle 里 react 走平台模块表,
5
- * 其余全部内联)。页面数据来自 Host 半侧的 `/dsh-channels/*` 路由,因此
6
- * 不需要 typert 契约,也不 import 任何 DSH 客户端包。
6
+ * 其余全部内联);右侧栏服务存在时才注册来源标签页。页面数据来自 Host
7
+ * 半侧的 `/dsh-channels/*` 路由,因此不需要 typert 契约,也不 import 任何
8
+ * DSH 客户端包。
7
9
  *
8
10
  * @module dsh-channels/client
9
11
  */
10
12
 
11
13
  import { PANEL_COPY } from '../settings/contract.ts'
12
- import { FeishuChannelsSection } from './panel/index.tsx'
14
+ import { FeishuChannelsIcon } from './icon.tsx'
15
+ import { ORIGIN_TAB_ID, ORIGIN_TAB_KIND, ORIGIN_TAB_TITLE, OriginHeaderAction, OriginTab } from './origin.tsx'
16
+ import { FeishuChannelsPage } from './panel/index.tsx'
13
17
  import { installStyles } from './styles.ts'
14
18
 
15
- // 平台 client runtime 的最小结构声明:只声明本 bundle 用到的两个能力。
16
- // 与安装的 DSH Web 版本(0.2.x)的 slots 运行时 API 一致。
19
+ // 平台 client runtime 的最小结构声明:只声明本 bundle 用到的能力。
20
+ // 与安装的 DSH Web 版本(0.2.x)的 slots 运行时 API 一致:list 槽按 `id`
21
+ // 注册,keyed 槽按 `key` 注册;`inject` 回调按会话作用域槽收到 sessionId。
17
22
  export interface ClientSlots {
18
23
  inject(slot: string, contribution: () => unknown): void
19
24
  register(
20
- declaration: {
21
- name: string
22
- id: string
23
- order?: number
24
- label: () => string
25
- },
26
- component: (props: { close?: () => void }) => unknown,
25
+ declaration:
26
+ | { name: string; id: string; order?: number; label?: () => string; inject?: (sessionId: string) => object }
27
+ | { name: string; key: string },
28
+ component: (props: never) => unknown,
27
29
  ): () => void
28
30
  }
29
31
 
32
+ /** Static right-sidebar tab type (`ctx.sidebarRightTabs.register`). */
33
+ export interface SidebarRightTabDefinition {
34
+ id: string
35
+ kind: string
36
+ title: (address: string) => string
37
+ }
38
+
39
+ /** Right-sidebar services the origin tab needs. */
40
+ export interface SidebarRightScope extends ClientContext {
41
+ sidebarRightTabs: { register(definition: SidebarRightTabDefinition): () => void }
42
+ sidebarRight: { openTab(kind: string): void }
43
+ }
44
+
30
45
  export interface ClientContext {
31
46
  slots: ClientSlots
32
47
  effect?(callback: () => void | (() => void), label?: string): void
48
+ /** Run `callback` in a child scope while every named service is available. */
49
+ inject?(deps: string[], callback: (scope: SidebarRightScope) => void): void
33
50
  }
34
51
 
35
52
  /** Cordis client plugin name. */
@@ -38,24 +55,62 @@ export const name = 'dsh-channels'
38
55
  /** Required client services: the Slots registry is part of the web shell core. */
39
56
  export const inject = ['slots']
40
57
 
41
- /** Section id and nav order (built-ins: account -10, general 0, models 10, plugins 15, agent-presets 20). */
42
- export const SETTINGS_SECTION_ID = 'feishu-channels'
43
- export const SETTINGS_SECTION_ORDER = 40
58
+ /**
59
+ * Main panel id: the `sidebar.panellist` row id and the matching `main` slot key.
60
+ * Must not change once released: DSH keys navigation state by it.
61
+ */
62
+ export const PANEL_ID = 'feishu-channels'
63
+ /** Left-sidebar row order (built-ins: tasks 10; Plugins is pinned last by the sidebar). */
64
+ export const PANEL_ORDER = 40
65
+ /** Session header action order (after the preset label -10 and subagents 10). */
66
+ export const ORIGIN_ACTION_ORDER = 30
44
67
 
45
68
  /**
46
- * Mount the settings section and its stylesheet.
69
+ * Mount the left-sidebar entry, its main-column page, the stylesheet, and —
70
+ * while the right sidebar is present — the Feishu origin header entry and tab.
47
71
  * @param ctx - client plugin context carrying the slots registry.
48
72
  */
49
73
  export function apply(ctx: ClientContext): void {
50
74
  if (typeof ctx.effect === 'function') {
51
- ctx.effect(() => installStyles(), 'dsh-channels: settings styles')
75
+ ctx.effect(() => installStyles(), 'dsh-channels: panel styles')
52
76
  } else {
53
77
  installStyles()
54
78
  }
55
- ctx.slots.inject('settings.section', () => ctx.slots.register({
56
- name: 'settings.section',
57
- id: SETTINGS_SECTION_ID,
58
- order: SETTINGS_SECTION_ORDER,
79
+ ctx.slots.inject('main', () => ctx.slots.register({
80
+ name: 'main',
81
+ key: PANEL_ID,
82
+ }, FeishuChannelsPage))
83
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
84
+ name: 'sidebar.panellist',
85
+ id: PANEL_ID,
86
+ order: PANEL_ORDER,
59
87
  label: () => PANEL_COPY.title,
60
- }, FeishuChannelsSection))
88
+ }, FeishuChannelsIcon))
89
+ ctx.inject?.(['sidebarRightTabs', 'sidebarRight'], mountOrigin)
90
+ }
91
+
92
+ /**
93
+ * Register the origin tab type, its body and title, and the header entry that
94
+ * opens it. Everything lives as long as the right-sidebar services do.
95
+ * @param scope - child context holding the right-sidebar services.
96
+ */
97
+ export function mountOrigin(scope: SidebarRightScope): void {
98
+ const definition: SidebarRightTabDefinition = {
99
+ id: ORIGIN_TAB_ID,
100
+ kind: ORIGIN_TAB_KIND,
101
+ title: () => ORIGIN_TAB_TITLE,
102
+ }
103
+ scope.effect?.(() => scope.sidebarRightTabs.register(definition), 'dsh-channels: origin tab type')
104
+ scope.slots.inject('sidebar.right.pane.tab', () => scope.slots.register({
105
+ name: 'sidebar.right.pane.tab',
106
+ key: ORIGIN_TAB_ID,
107
+ }, OriginTab))
108
+ scope.slots.inject('conversation.session.header.actions', () => scope.slots.register({
109
+ name: 'conversation.session.header.actions',
110
+ id: 'dsh-channels-origin',
111
+ order: ORIGIN_ACTION_ORDER,
112
+ inject: () => ({
113
+ openOrigin: () => { scope.sidebarRight.openTab(ORIGIN_TAB_KIND) },
114
+ }),
115
+ }, OriginHeaderAction))
61
116
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * 按会话缓存的飞书来源查询,会话头部入口与右侧栏标签页共用。
3
+ *
4
+ * @module dsh-channels/client/origin-cache
5
+ */
6
+
7
+ import type { SessionOriginWire } from '../settings/contract.ts'
8
+ import { fetchSessionOrigin } from './api.ts'
9
+
10
+ /**
11
+ * How long a "not from Feishu" answer is reused. A Session shows up in the
12
+ * Web list before its Feishu route is written, so an early null must expire.
13
+ */
14
+ const NULL_ORIGIN_TTL_MS = 15_000
15
+
16
+ interface Lookup {
17
+ pending: Promise<SessionOriginWire | null>
18
+ /** Epoch ms when the Host answered null; absent while pending or for an origin. */
19
+ nullAt?: number
20
+ }
21
+
22
+ /** One settled or pending origin lookup per Session; failures are not kept. */
23
+ const lookups = new Map<string, Lookup>()
24
+
25
+ /**
26
+ * Origin of one Session through the shared cache. An origin is kept for the
27
+ * page's lifetime; a null answer only for {@link NULL_ORIGIN_TTL_MS}.
28
+ * @param sessionId - DSH session id.
29
+ * @param refresh - drop the cached answer and ask the Host again.
30
+ * @returns the origin, or null for a Session Feishu never routed.
31
+ */
32
+ export function loadSessionOrigin(sessionId: string, refresh = false): Promise<SessionOriginWire | null> {
33
+ const cached = refresh ? undefined : lookups.get(sessionId)
34
+ if (cached !== undefined && (cached.nullAt === undefined || Date.now() - cached.nullAt < NULL_ORIGIN_TTL_MS)) {
35
+ return cached.pending
36
+ }
37
+ const pending = fetchSessionOrigin(sessionId).then(response => response.origin)
38
+ const lookup: Lookup = { pending }
39
+ lookups.set(sessionId, lookup)
40
+ pending.then(
41
+ (origin) => { if (origin === null) lookup.nullAt = Date.now() },
42
+ () => { if (lookups.get(sessionId) === lookup) lookups.delete(sessionId) },
43
+ )
44
+ return pending
45
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * 对话里的「飞书来源」:会话头部的入口按钮(只在飞书发起的会话里出现)与
3
+ * 右侧栏的来源标签页。两者共用一份按会话缓存的来源查询。
4
+ *
5
+ * @module dsh-channels/client/origin
6
+ */
7
+
8
+ import { useCallback, useEffect, useState } from 'react'
9
+ import type { SessionOriginWire } from '../settings/contract.ts'
10
+ import { Avatar } from './avatar.tsx'
11
+ import { loadSessionOrigin } from './origin-cache.ts'
12
+ import { FeishuChannelsIcon } from './icon.tsx'
13
+
14
+ /** Right-sidebar tab kind opened by the header button. */
15
+ export const ORIGIN_TAB_KIND = 'feishu-origin'
16
+ /** Tab-type implementation id and keyed body/title slot key. */
17
+ export const ORIGIN_TAB_ID = '@modesto_ma/dsh-channels/origin'
18
+ /** Tab chip text. */
19
+ export const ORIGIN_TAB_TITLE = '飞书来源'
20
+
21
+ type OriginState =
22
+ | { phase: 'loading' }
23
+ | { phase: 'ready'; origin: SessionOriginWire | null }
24
+ | { phase: 'error'; message: string }
25
+
26
+ /**
27
+ * Load one Session's origin; `reload` asks the Host again. `fresh` skips the
28
+ * shared cache on the first load too, so names and avatars are current.
29
+ */
30
+ function useSessionOrigin(sessionId: string, fresh = false): [OriginState, () => void] {
31
+ const [state, setState] = useState<OriginState>({ phase: 'loading' })
32
+ // A reload is tied to the Session it was asked for, so switching Sessions
33
+ // never shows the previous Session's origin while the new one loads.
34
+ const [reloadOf, setReloadOf] = useState<{ sessionId: string; ticket: number }>({ sessionId, ticket: 0 })
35
+ const ticket = reloadOf.sessionId === sessionId ? reloadOf.ticket : 0
36
+ useEffect(() => {
37
+ let live = true
38
+ setState(current => (current.phase === 'ready' && ticket > 0 ? current : { phase: 'loading' }))
39
+ loadSessionOrigin(sessionId, fresh || ticket > 0).then(
40
+ origin => { if (live) setState({ phase: 'ready', origin }) },
41
+ (error: unknown) => {
42
+ if (live) setState({ phase: 'error', message: error instanceof Error ? error.message : String(error) })
43
+ },
44
+ )
45
+ return () => { live = false }
46
+ }, [fresh, sessionId, ticket])
47
+ const reload = useCallback(() => {
48
+ setReloadOf(current => ({ sessionId, ticket: current.sessionId === sessionId ? current.ticket + 1 : 1 }))
49
+ }, [sessionId])
50
+ return [state, reload]
51
+ }
52
+
53
+ /** Header chip text: group name, or 私聊 with the peer's name. */
54
+ function sourceLabel(origin: SessionOriginWire): string {
55
+ if (origin.chatType === 'p2p') return origin.senderName === undefined ? '飞书私聊' : `私聊 · ${origin.senderName}`
56
+ return origin.chatName === undefined ? '飞书群聊' : origin.chatName
57
+ }
58
+
59
+ /** Tab heading: the group, or the private-chat peer, with its avatar. */
60
+ function originHeading(origin: SessionOriginWire): { title: string; avatarUrl?: string } {
61
+ if (origin.chatType === 'p2p') {
62
+ return { title: origin.senderName ?? '飞书私聊', ...(origin.senderAvatarUrl === undefined ? {} : { avatarUrl: origin.senderAvatarUrl }) }
63
+ }
64
+ return { title: origin.chatName ?? '飞书群聊', ...(origin.chatAvatarUrl === undefined ? {} : { avatarUrl: origin.chatAvatarUrl }) }
65
+ }
66
+
67
+ /** Times a mounted header re-asks after a null answer, and the delay between asks. */
68
+ const HEADER_NULL_RETRIES = 3
69
+ const HEADER_NULL_RETRY_MS = 5_000
70
+
71
+ /** Props of the header entry: the session standard kit's `sessionId`. */
72
+ export interface OriginHeaderActionProps {
73
+ sessionId: string
74
+ /** Open the origin tab in this Session's right sidebar. */
75
+ openOrigin: () => void
76
+ }
77
+
78
+ /**
79
+ * Header button shown only for Sessions Feishu routed; renders nothing for
80
+ * every other Session and while the lookup is pending or failed.
81
+ * @param props - session id and the tab opener.
82
+ * @returns the button or null.
83
+ */
84
+ export function OriginHeaderAction({ sessionId, openOrigin }: OriginHeaderActionProps): JSX.Element | null {
85
+ const [state, reload] = useSessionOrigin(sessionId)
86
+ const [retries, setRetries] = useState(0)
87
+ useEffect(() => { setRetries(0) }, [sessionId])
88
+ // A Session opened while Feishu is still creating it answers null until its
89
+ // route is written; look again a few times while the header stays mounted.
90
+ const waiting = state.phase === 'ready' && state.origin === null && retries < HEADER_NULL_RETRIES
91
+ useEffect(() => {
92
+ if (!waiting) return
93
+ const timer = setTimeout(() => {
94
+ setRetries(value => value + 1)
95
+ reload()
96
+ }, HEADER_NULL_RETRY_MS)
97
+ return () => { clearTimeout(timer) }
98
+ }, [waiting, reload])
99
+ if (state.phase !== 'ready' || state.origin === null) return null
100
+ const label = sourceLabel(state.origin)
101
+ return (
102
+ <button
103
+ type="button"
104
+ className="dsh-channels-origin-chip"
105
+ title={`来自${label},点击查看飞书来源`}
106
+ aria-label={`飞书来源:${label}`}
107
+ onClick={openOrigin}
108
+ >
109
+ <FeishuChannelsIcon size={13} active={false} />
110
+ <span className="dsh-channels-origin-chip__text">{label}</span>
111
+ </button>
112
+ )
113
+ }
114
+
115
+ /** Props of the tab body: the session standard kit's `sessionId`. */
116
+ export interface OriginTabProps {
117
+ sessionId: string
118
+ }
119
+
120
+ function formatTime(ms: number): string {
121
+ const date = new Date(ms)
122
+ const pad = (value: number): string => String(value).padStart(2, '0')
123
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
124
+ }
125
+
126
+ /**
127
+ * Right-sidebar body: where this Session came from in Feishu.
128
+ * @param props - the tab's Session.
129
+ * @returns the origin page.
130
+ */
131
+ export function OriginTab({ sessionId }: OriginTabProps): JSX.Element {
132
+ const [state, reload] = useSessionOrigin(sessionId, true)
133
+ return (
134
+ <div className="dsh-channels-origin">
135
+ {state.phase === 'loading' ? <div className="dsh-channels-origin__muted">正在读取飞书来源…</div> : null}
136
+ {state.phase === 'error'
137
+ ? (
138
+ <div className="dsh-channels-origin__error">
139
+ <span>读取飞书来源失败:{state.message}</span>
140
+ <button type="button" className="dsh-channels-origin__link" onClick={reload}>重试</button>
141
+ </div>
142
+ )
143
+ : null}
144
+ {state.phase === 'ready' && state.origin === null
145
+ ? (
146
+ <div className="dsh-channels-origin__muted">
147
+ 这个会话不是从飞书发起的。
148
+ <button type="button" className="dsh-channels-origin__link" onClick={reload}>刷新</button>
149
+ </div>
150
+ )
151
+ : null}
152
+ {state.phase === 'ready' && state.origin !== null ? <OriginDetails origin={state.origin} onRefresh={reload} /> : null}
153
+ </div>
154
+ )
155
+ }
156
+
157
+ function OriginDetails({ origin, onRefresh }: { origin: SessionOriginWire; onRefresh: () => void }): JSX.Element {
158
+ const p2p = origin.chatType === 'p2p'
159
+ const rows: Array<[string, JSX.Element | string]> = [
160
+ ['来源', p2p ? '私聊' : (origin.kind === 'thread' ? '群聊 · 话题' : '群聊')],
161
+ ]
162
+ // The heading already names a resolved group; an unnamed one shows its id.
163
+ if (!p2p && origin.chatName === undefined) {
164
+ rows.push(['群 ID', <span className="dsh-channels-origin__mono">{origin.chatId}</span>])
165
+ }
166
+ rows.push([origin.shared ? '请求人' : '发起人', (
167
+ <span className="dsh-channels-origin__who">
168
+ <Avatar text={origin.senderName ?? origin.senderUserId} url={origin.senderAvatarUrl} size={20} />
169
+ {origin.senderName === undefined ? null : <span>{origin.senderName}</span>}
170
+ <span className="dsh-channels-origin__mono dsh-channels-origin__muted">{origin.senderUserId}</span>
171
+ </span>
172
+ )])
173
+ const heading = originHeading(origin)
174
+ if (origin.shared) rows.push(['说明', '日志平台分析话题,群内成员都可以继续'])
175
+ rows.push(['开始于', formatTime(origin.createdAt)])
176
+ rows.push(origin.bound && origin.lastActivityAt !== undefined
177
+ ? ['最近活跃', formatTime(origin.lastActivityAt)]
178
+ : ['状态', '飞书侧已切换到新会话或已过期'])
179
+ return (
180
+ <>
181
+ <div className="dsh-channels-origin__head">
182
+ {heading.avatarUrl === undefined
183
+ ? <FeishuChannelsIcon size={16} active={false} />
184
+ : <Avatar text={heading.title} url={heading.avatarUrl} size={28} />}
185
+ <span className="dsh-channels-origin__title">{heading.title}</span>
186
+ </div>
187
+ <dl className="dsh-channels-origin__list">
188
+ {rows.map(([label, value]) => (
189
+ <div key={label} className="dsh-channels-origin__row">
190
+ <dt>{label}</dt>
191
+ <dd>{value}</dd>
192
+ </div>
193
+ ))}
194
+ </dl>
195
+ <div className="dsh-channels-origin__actions">
196
+ <a className="dsh-channels-origin__button" href={origin.chatLink} target="_blank" rel="noreferrer">在飞书中打开</a>
197
+ <button type="button" className="dsh-channels-origin__link" onClick={onRefresh}>刷新</button>
198
+ </div>
199
+ </>
200
+ )
201
+ }