@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.
@@ -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
+ }