@modesto_ma/dsh-channels 0.5.0-beta.1 → 0.5.0-beta.2
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 +15 -7
- package/docs/access-control-design.md +1 -0
- package/docs/feishu-onboarding.md +3 -0
- package/lib/client.js +271 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +2061 -212
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/store.ts +31 -1
- package/src/cards/picker.ts +9 -16
- package/src/cards/wiki.ts +124 -0
- package/src/client/panel/shared.tsx +96 -1
- package/src/client/string-list.ts +139 -0
- package/src/client/styles.ts +3 -0
- package/src/commands/registry.ts +6 -3
- package/src/config.ts +32 -0
- package/src/domain.ts +10 -0
- package/src/feishu/delivery.ts +34 -1
- package/src/feishu/message-read.ts +78 -2
- package/src/feishu/scopes.ts +23 -0
- package/src/feishu/wiki/api.ts +346 -0
- package/src/feishu/wiki/consent.ts +183 -0
- package/src/feishu/wiki/links.ts +122 -0
- package/src/feishu/wiki/scope.ts +66 -0
- package/src/feishu/wiki/tools.ts +984 -0
- package/src/feishu-channel.ts +10 -0
- package/src/service.ts +87 -7
- package/src/settings/contract.ts +42 -4
- package/src/settings/surface.ts +107 -6
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Topic-owner consent for wiki tool calls.
|
|
3
|
+
*
|
|
4
|
+
* A wiki tool that opens, creates, or edits a document asks the topic owner
|
|
5
|
+
* on a consent card in the topic and waits for the answer. Only the topic
|
|
6
|
+
* owner (`route.senderUserId`) can decide; a click by anyone else adds the
|
|
7
|
+
* owner-only notice once. An unanswered card is denied after the configured
|
|
8
|
+
* window (`approvalTimeoutMs`). A read consent is remembered per Session and
|
|
9
|
+
* document, in memory only: it ends with the Session (`/renew`) or the
|
|
10
|
+
* process. Create and edit consents are never remembered.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-channels/feishu/wiki/consent
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomUUID } from 'node:crypto'
|
|
16
|
+
import { buildWikiConsentCard, buildWikiConsentSettledCard } from '../../cards/wiki.ts'
|
|
17
|
+
import type { WikiConsentAction, WikiConsentCardState, WikiConsentQuestion } from '../../cards/wiki.ts'
|
|
18
|
+
import type { CardActionEvent, FeishuChannel } from '../../feishu-channel.ts'
|
|
19
|
+
import { PendingCardTable } from '../../interactions/pending.ts'
|
|
20
|
+
import type { InteractionRoute, PendingCardEntry } from '../../interactions/pending.ts'
|
|
21
|
+
|
|
22
|
+
/** How one consent request ended. */
|
|
23
|
+
export type WikiConsentOutcome = 'allowed' | 'denied' | 'expired' | 'cancelled' | 'unavailable'
|
|
24
|
+
|
|
25
|
+
/** One consent to ask for. */
|
|
26
|
+
export interface WikiConsentRequest {
|
|
27
|
+
readonly sessionId: string
|
|
28
|
+
readonly route: InteractionRoute
|
|
29
|
+
readonly action: WikiConsentAction
|
|
30
|
+
readonly title: string
|
|
31
|
+
readonly url?: string
|
|
32
|
+
readonly location: string
|
|
33
|
+
readonly details: readonly string[]
|
|
34
|
+
/** The tool call's abort signal; aborting withdraws the card. */
|
|
35
|
+
readonly signal: AbortSignal
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Pending extends PendingCardEntry {
|
|
39
|
+
readonly question: WikiConsentQuestion
|
|
40
|
+
resolve(outcome: WikiConsentOutcome): void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface WikiConsentDeps {
|
|
44
|
+
/** Live Feishu channel; undefined while disconnected. */
|
|
45
|
+
channel(): FeishuChannel | undefined
|
|
46
|
+
/** Decision window per card, read per request (`approvalTimeoutMs`). */
|
|
47
|
+
timeoutMs(): number
|
|
48
|
+
/** Log a non-fatal delivery/update failure. */
|
|
49
|
+
onError(sessionId: string, error: unknown): void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Consent cards for the wiki tools. */
|
|
53
|
+
export class WikiConsent {
|
|
54
|
+
/** One table per channel instance (cards are patched through their channel). */
|
|
55
|
+
private table: { channel: FeishuChannel; entries: PendingCardTable<Pending> } | undefined
|
|
56
|
+
/** sessionId → document ids with a standing read consent. */
|
|
57
|
+
private readonly readGrants = new Map<string, Set<string>>()
|
|
58
|
+
|
|
59
|
+
constructor(private readonly deps: WikiConsentDeps) {}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether the Session already holds a read consent for one document.
|
|
63
|
+
* @param sessionId The Session.
|
|
64
|
+
* @param documentId Docx document id.
|
|
65
|
+
* @returns True when reading needs no new card.
|
|
66
|
+
*/
|
|
67
|
+
hasReadGrant(sessionId: string, documentId: string): boolean {
|
|
68
|
+
return this.readGrants.get(sessionId)?.has(documentId) === true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Record a standing read consent (after an allowed read, or for a document
|
|
73
|
+
* the topic owner just approved creating).
|
|
74
|
+
* @param sessionId The Session.
|
|
75
|
+
* @param documentId Docx document id.
|
|
76
|
+
*/
|
|
77
|
+
grantRead(sessionId: string, documentId: string): void {
|
|
78
|
+
let grants = this.readGrants.get(sessionId)
|
|
79
|
+
if (grants === undefined) {
|
|
80
|
+
grants = new Set()
|
|
81
|
+
this.readGrants.set(sessionId, grants)
|
|
82
|
+
}
|
|
83
|
+
grants.add(documentId)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Ask the topic owner and wait for the decision. Never rejects.
|
|
88
|
+
* @param request What to ask.
|
|
89
|
+
* @returns The outcome; `unavailable` when no card could be delivered.
|
|
90
|
+
*/
|
|
91
|
+
request(request: WikiConsentRequest): Promise<WikiConsentOutcome> {
|
|
92
|
+
if (request.signal.aborted) return Promise.resolve('cancelled')
|
|
93
|
+
const channel = this.deps.channel()
|
|
94
|
+
if (channel === undefined) return Promise.resolve('unavailable')
|
|
95
|
+
const table = this.tableFor(channel)
|
|
96
|
+
const timeoutMs = this.deps.timeoutMs()
|
|
97
|
+
const question: WikiConsentQuestion = {
|
|
98
|
+
token: randomUUID(),
|
|
99
|
+
action: request.action,
|
|
100
|
+
title: request.title,
|
|
101
|
+
...(request.url === undefined ? {} : { url: request.url }),
|
|
102
|
+
location: request.location,
|
|
103
|
+
details: request.details,
|
|
104
|
+
timeoutMs,
|
|
105
|
+
}
|
|
106
|
+
return new Promise<WikiConsentOutcome>((resolve) => {
|
|
107
|
+
const entry: Pending = {
|
|
108
|
+
token: question.token,
|
|
109
|
+
sessionId: request.sessionId,
|
|
110
|
+
route: request.route,
|
|
111
|
+
messageId: undefined,
|
|
112
|
+
settled: false,
|
|
113
|
+
timer: undefined,
|
|
114
|
+
signal: request.signal,
|
|
115
|
+
onAbort: undefined,
|
|
116
|
+
unauthorizedNoted: false,
|
|
117
|
+
question,
|
|
118
|
+
resolve,
|
|
119
|
+
}
|
|
120
|
+
const onAbort = (): void => this.settle(table, entry, 'cancelled', 'cancelled')
|
|
121
|
+
entry.onAbort = onAbort
|
|
122
|
+
request.signal.addEventListener('abort', onAbort, { once: true })
|
|
123
|
+
table.add(entry)
|
|
124
|
+
void table.deliver(entry, {
|
|
125
|
+
card: buildWikiConsentCard(question),
|
|
126
|
+
onWithdrawn: e => table.patch(e, buildWikiConsentSettledCard(e.question, 'cancelled')),
|
|
127
|
+
onDelivered: e => table.armTimeout(e, timeoutMs, () => this.settle(table, e, 'expired', 'expired')),
|
|
128
|
+
onFailure: e => this.settle(table, e, 'unavailable'),
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Route one card button callback to its pending consent; unknown cards are
|
|
135
|
+
* ignored.
|
|
136
|
+
* @param event The card action.
|
|
137
|
+
*/
|
|
138
|
+
onCardAction(event: CardActionEvent): void {
|
|
139
|
+
const table = this.table?.entries
|
|
140
|
+
if (table === undefined) return
|
|
141
|
+
const entry = table.resolveAction(event, (candidate) => {
|
|
142
|
+
if (candidate.unauthorizedNoted) return
|
|
143
|
+
candidate.unauthorizedNoted = true
|
|
144
|
+
table.patch(candidate, buildWikiConsentCard(candidate.question, { unauthorizedNote: true }))
|
|
145
|
+
})
|
|
146
|
+
if (entry === undefined) return
|
|
147
|
+
const decision = (event.action.value as { decision?: unknown } | undefined)?.decision
|
|
148
|
+
if (decision === 'allow') this.settle(table, entry, 'allowed', 'allowed')
|
|
149
|
+
else if (decision === 'deny') this.settle(table, entry, 'denied', 'denied')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Withdraw every pending card and forget every read consent (shutdown). */
|
|
153
|
+
dispose(): void {
|
|
154
|
+
const table = this.table?.entries
|
|
155
|
+
table?.withdrawAll(entry => this.settle(table, entry, 'cancelled', 'cancelled'))
|
|
156
|
+
this.table = undefined
|
|
157
|
+
this.readGrants.clear()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Pending consent count (diagnostics/tests). */
|
|
161
|
+
pendingCount(): number {
|
|
162
|
+
return this.table?.entries.count() ?? 0
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private tableFor(channel: FeishuChannel): PendingCardTable<Pending> {
|
|
166
|
+
if (this.table?.channel !== channel) {
|
|
167
|
+
this.table = { channel, entries: new PendingCardTable<Pending>(channel, this.deps.onError) }
|
|
168
|
+
}
|
|
169
|
+
return this.table.entries
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private settle(
|
|
173
|
+
table: PendingCardTable<Pending>,
|
|
174
|
+
entry: Pending,
|
|
175
|
+
outcome: WikiConsentOutcome,
|
|
176
|
+
cardState?: WikiConsentCardState,
|
|
177
|
+
): void {
|
|
178
|
+
const closed = table.close(entry, () => {
|
|
179
|
+
if (cardState !== undefined) table.patch(entry, buildWikiConsentSettledCard(entry.question, cardState))
|
|
180
|
+
})
|
|
181
|
+
if (closed) entry.resolve(outcome)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu wiki (知识库) link parsing: the `wikiRoots` allow-list entries and
|
|
3
|
+
* the document references the model passes to the wiki tools.
|
|
4
|
+
*
|
|
5
|
+
* Pure module: no I/O. Hosts are not checked because tenants use their own
|
|
6
|
+
* subdomains (`<tenant>.feishu.cn`) and the API never uses the link host.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-channels/feishu/wiki/links
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Feishu wiki space id: decimal digits. */
|
|
12
|
+
const SPACE_ID_PATTERN = /^\d{1,32}$/u
|
|
13
|
+
|
|
14
|
+
/** Feishu wiki node / docx token: letters and digits. */
|
|
15
|
+
const TOKEN_PATTERN = /^[A-Za-z0-9]{8,64}$/u
|
|
16
|
+
|
|
17
|
+
/** One configured `wikiRoots` entry. */
|
|
18
|
+
export type WikiRoot =
|
|
19
|
+
| {
|
|
20
|
+
/** A whole wiki space (`/wiki/space/<space_id>`). */
|
|
21
|
+
readonly kind: 'space'
|
|
22
|
+
readonly spaceId: string
|
|
23
|
+
/** The link as configured. */
|
|
24
|
+
readonly link: string
|
|
25
|
+
}
|
|
26
|
+
| {
|
|
27
|
+
/** One wiki node and every node below it (`/wiki/<node_token>`). */
|
|
28
|
+
readonly kind: 'node'
|
|
29
|
+
readonly nodeToken: string
|
|
30
|
+
/** The link as configured. */
|
|
31
|
+
readonly link: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Why a `wikiRoots` entry was rejected. */
|
|
35
|
+
export type WikiRootLinkError =
|
|
36
|
+
| 'not-a-url'
|
|
37
|
+
| 'not-https'
|
|
38
|
+
| 'not-a-wiki-link'
|
|
39
|
+
|
|
40
|
+
/** Readable reasons for {@link WikiRootLinkError}. */
|
|
41
|
+
export const WIKI_ROOT_LINK_ERRORS: Readonly<Record<WikiRootLinkError, string>> = {
|
|
42
|
+
'not-a-url': '不是有效的链接',
|
|
43
|
+
'not-https': '必须是 https 链接',
|
|
44
|
+
'not-a-wiki-link': '只支持知识空间链接 https://<租户>.feishu.cn/wiki/space/<space_id> 或节点链接 https://<租户>.feishu.cn/wiki/<node_token>',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse one `wikiRoots` entry.
|
|
49
|
+
*
|
|
50
|
+
* @param link Link as configured (surrounding whitespace is ignored).
|
|
51
|
+
* @returns The root, or the reason it is invalid.
|
|
52
|
+
*/
|
|
53
|
+
export function parseWikiRootLink(link: string): { ok: true; root: WikiRoot } | { ok: false; error: WikiRootLinkError } {
|
|
54
|
+
const trimmed = link.trim()
|
|
55
|
+
let url: URL
|
|
56
|
+
try {
|
|
57
|
+
url = new URL(trimmed)
|
|
58
|
+
} catch {
|
|
59
|
+
// Unparseable input: reported as a configuration error below.
|
|
60
|
+
return { ok: false, error: 'not-a-url' }
|
|
61
|
+
}
|
|
62
|
+
if (url.protocol !== 'https:') return { ok: false, error: 'not-https' }
|
|
63
|
+
const segments = url.pathname.split('/').filter(segment => segment !== '')
|
|
64
|
+
if (segments[0] !== 'wiki') return { ok: false, error: 'not-a-wiki-link' }
|
|
65
|
+
if (segments.length === 3 && segments[1] === 'space' && SPACE_ID_PATTERN.test(segments[2]!)) {
|
|
66
|
+
return { ok: true, root: { kind: 'space', spaceId: segments[2]!, link: trimmed } }
|
|
67
|
+
}
|
|
68
|
+
if (segments.length === 2 && segments[1] !== 'space' && TOKEN_PATTERN.test(segments[1]!)) {
|
|
69
|
+
return { ok: true, root: { kind: 'node', nodeToken: segments[1]!, link: trimmed } }
|
|
70
|
+
}
|
|
71
|
+
return { ok: false, error: 'not-a-wiki-link' }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse every configured root. Callers validate the config first, so an
|
|
76
|
+
* invalid entry here is a programming error.
|
|
77
|
+
*
|
|
78
|
+
* @param links Validated `wikiRoots` entries.
|
|
79
|
+
* @returns The parsed roots, in configuration order.
|
|
80
|
+
*/
|
|
81
|
+
export function parseWikiRoots(links: readonly string[]): WikiRoot[] {
|
|
82
|
+
return links.map((link) => {
|
|
83
|
+
const parsed = parseWikiRootLink(link)
|
|
84
|
+
if (!parsed.ok) throw new Error(`dsh-channels: invalid wikiRoots entry ${link}: ${WIKI_ROOT_LINK_ERRORS[parsed.error]}`)
|
|
85
|
+
return parsed.root
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A document or node the model names: a wiki node, a docx, or a whole space. */
|
|
90
|
+
export type WikiRef =
|
|
91
|
+
| { readonly kind: 'node'; readonly token: string }
|
|
92
|
+
| { readonly kind: 'docx'; readonly token: string }
|
|
93
|
+
| { readonly kind: 'space'; readonly spaceId: string }
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse a model-supplied reference: a wiki node link, a docx link, a space
|
|
97
|
+
* link, or a bare node token.
|
|
98
|
+
*
|
|
99
|
+
* @param input Raw tool argument.
|
|
100
|
+
* @returns The reference, or `undefined` when it names nothing recognizable.
|
|
101
|
+
*/
|
|
102
|
+
export function parseWikiRef(input: string): WikiRef | undefined {
|
|
103
|
+
const trimmed = input.trim()
|
|
104
|
+
if (TOKEN_PATTERN.test(trimmed)) return { kind: 'node', token: trimmed }
|
|
105
|
+
let url: URL
|
|
106
|
+
try {
|
|
107
|
+
url = new URL(trimmed)
|
|
108
|
+
} catch {
|
|
109
|
+
// Neither a token nor a URL.
|
|
110
|
+
return undefined
|
|
111
|
+
}
|
|
112
|
+
if (url.protocol !== 'https:') return undefined
|
|
113
|
+
const segments = url.pathname.split('/').filter(segment => segment !== '')
|
|
114
|
+
if (segments[0] === 'wiki' && segments.length === 3 && segments[1] === 'space' && SPACE_ID_PATTERN.test(segments[2]!)) {
|
|
115
|
+
return { kind: 'space', spaceId: segments[2]! }
|
|
116
|
+
}
|
|
117
|
+
if (segments.length === 2 && TOKEN_PATTERN.test(segments[1]!)) {
|
|
118
|
+
if (segments[0] === 'wiki' && segments[1] !== 'space') return { kind: 'node', token: segments[1]! }
|
|
119
|
+
if (segments[0] === 'docx') return { kind: 'docx', token: segments[1]! }
|
|
120
|
+
}
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `wikiRoots` containment: decides whether a wiki node lies inside one of the
|
|
3
|
+
* configured roots before any wiki tool touches it.
|
|
4
|
+
*
|
|
5
|
+
* A space root contains every node of that space. A node root contains
|
|
6
|
+
* itself and its descendants; descent is checked by walking the node's
|
|
7
|
+
* parent chain with `spaces/get_node`, bounded by {@link MAX_PARENT_DEPTH}.
|
|
8
|
+
* A node whose chain cannot be read is treated as outside (fail closed).
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-channels/feishu/wiki/scope
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { WikiApi, WikiNode } from './api.ts'
|
|
14
|
+
import type { WikiRoot } from './links.ts'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Longest parent chain walked for a node root. Feishu limits wiki depth well
|
|
18
|
+
* below this; a longer chain is treated as outside.
|
|
19
|
+
*/
|
|
20
|
+
const MAX_PARENT_DEPTH = 64
|
|
21
|
+
|
|
22
|
+
/** The root a node was found under. */
|
|
23
|
+
export interface WikiContainment {
|
|
24
|
+
readonly root: WikiRoot
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Find the configured root containing `node`.
|
|
29
|
+
*
|
|
30
|
+
* @param api Wiki endpoints (for parent lookups).
|
|
31
|
+
* @param roots Configured roots.
|
|
32
|
+
* @param node The node to check.
|
|
33
|
+
* @returns The containing root, or `undefined` when none contains it.
|
|
34
|
+
*/
|
|
35
|
+
export async function containingRoot(
|
|
36
|
+
api: WikiApi,
|
|
37
|
+
roots: readonly WikiRoot[],
|
|
38
|
+
node: WikiNode,
|
|
39
|
+
): Promise<WikiContainment | undefined> {
|
|
40
|
+
for (const root of roots) {
|
|
41
|
+
if (root.kind === 'space' && root.spaceId === node.spaceId) return { root }
|
|
42
|
+
}
|
|
43
|
+
const nodeRoots = roots.filter((root): root is Extract<WikiRoot, { kind: 'node' }> => root.kind === 'node')
|
|
44
|
+
if (nodeRoots.length === 0) return undefined
|
|
45
|
+
const byToken = new Map(nodeRoots.map(root => [root.nodeToken, root]))
|
|
46
|
+
let current: WikiNode = node
|
|
47
|
+
for (let depth = 0; depth <= MAX_PARENT_DEPTH; depth += 1) {
|
|
48
|
+
const match = byToken.get(current.nodeToken)
|
|
49
|
+
if (match !== undefined) return { root: match }
|
|
50
|
+
if (current.parentNodeToken === undefined) return undefined
|
|
51
|
+
current = await api.getNode(current.parentNodeToken, 'wiki')
|
|
52
|
+
}
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether a whole space (its top level) is inside the roots: only a space
|
|
58
|
+
* root covers a space's top level.
|
|
59
|
+
*
|
|
60
|
+
* @param roots Configured roots.
|
|
61
|
+
* @param spaceId The space.
|
|
62
|
+
* @returns True when a space root names it.
|
|
63
|
+
*/
|
|
64
|
+
export function spaceIsRoot(roots: readonly WikiRoot[], spaceId: string): boolean {
|
|
65
|
+
return roots.some(root => root.kind === 'space' && root.spaceId === spaceId)
|
|
66
|
+
}
|