@modesto_ma/dsh-channels 0.5.0-beta.1 → 0.5.0-beta.3
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/attachment-pipeline-v2.md +2 -2
- package/docs/feishu-onboarding.md +6 -2
- package/lib/client.js +271 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +3374 -167
- 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 +50 -6
- 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 +35 -0
- package/src/feishu/wiki/a1.ts +97 -0
- package/src/feishu/wiki/api.ts +361 -0
- package/src/feishu/wiki/base-api.ts +267 -0
- package/src/feishu/wiki/base-tools.ts +423 -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/shared.ts +284 -0
- package/src/feishu/wiki/sheet-api.ts +250 -0
- package/src/feishu/wiki/sheet-tools.ts +493 -0
- package/src/feishu/wiki/tools.ts +897 -0
- package/src/feishu-channel.ts +23 -0
- package/src/service.ts +87 -7
- package/src/settings/contract.ts +42 -4
- package/src/settings/surface.ts +111 -10
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failure values and helpers shared by the wiki tool families (docx, sheet,
|
|
3
|
+
* base): stable failure codes with readable reasons, argument parsing,
|
|
4
|
+
* Feishu error mapping, ownership, links, and consent-card previews.
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-channels/feishu/wiki/shared
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { z } from 'zod'
|
|
10
|
+
import { FeishuApiError } from '../../feishu-channel.ts'
|
|
11
|
+
import type { WikiConsentAction } from '../../cards/wiki.ts'
|
|
12
|
+
import type { InteractionRoute } from '../../interactions/pending.ts'
|
|
13
|
+
import type { WikiApi, WikiNode } from './api.ts'
|
|
14
|
+
import type { BaseApi } from './base-api.ts'
|
|
15
|
+
import type { WikiConsentOutcome } from './consent.ts'
|
|
16
|
+
import type { WikiRef, WikiRoot } from './links.ts'
|
|
17
|
+
import type { SheetApi } from './sheet-api.ts'
|
|
18
|
+
|
|
19
|
+
/** What every gated call works with. */
|
|
20
|
+
export interface WikiCallContext {
|
|
21
|
+
/** Topic and owner the consent cards go to. */
|
|
22
|
+
readonly route: InteractionRoute
|
|
23
|
+
readonly api: WikiApi
|
|
24
|
+
/** Absent on channel stubs without spreadsheet support. */
|
|
25
|
+
readonly sheets?: SheetApi
|
|
26
|
+
/** Absent on channel stubs without base support. */
|
|
27
|
+
readonly bases?: BaseApi
|
|
28
|
+
readonly roots: readonly WikiRoot[]
|
|
29
|
+
readonly botOpenId: string | undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Outcome of the per-call gates. */
|
|
33
|
+
export type WikiGate = { ok: true; context: WikiCallContext } | WikiFailure
|
|
34
|
+
|
|
35
|
+
/** Consent card summary fields. */
|
|
36
|
+
export interface WikiConsentFields {
|
|
37
|
+
readonly title: string
|
|
38
|
+
readonly url?: string
|
|
39
|
+
readonly location: string
|
|
40
|
+
readonly details: readonly string[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The gates and consent steps every wiki tool family runs through (implemented by the wiki tool runtime). */
|
|
44
|
+
export interface WikiToolKit {
|
|
45
|
+
/**
|
|
46
|
+
* Switch, route, whitelist, and channel gates of one call.
|
|
47
|
+
* @param sessionId The calling Session.
|
|
48
|
+
* @param tool Tool name (its switch decides).
|
|
49
|
+
* @returns The call context, or the failure.
|
|
50
|
+
*/
|
|
51
|
+
gate(sessionId: string, tool: string): WikiGate
|
|
52
|
+
/**
|
|
53
|
+
* Resolve a node reference and check `wikiRoots` containment.
|
|
54
|
+
* @param context The call context.
|
|
55
|
+
* @param ref Parsed reference (a space is rejected).
|
|
56
|
+
* @returns The node, or the failure.
|
|
57
|
+
*/
|
|
58
|
+
containedNode(context: WikiCallContext, ref: WikiRef): Promise<WikiNode | WikiFailure>
|
|
59
|
+
/**
|
|
60
|
+
* Ask the topic owner on a consent card and wait.
|
|
61
|
+
* @param sessionId The calling Session.
|
|
62
|
+
* @param context The call context.
|
|
63
|
+
* @param action What is asked for.
|
|
64
|
+
* @param fields Card summary.
|
|
65
|
+
* @param signal The tool call's abort signal.
|
|
66
|
+
* @returns Undefined when allowed, else the failure.
|
|
67
|
+
*/
|
|
68
|
+
consent(
|
|
69
|
+
sessionId: string,
|
|
70
|
+
context: WikiCallContext,
|
|
71
|
+
action: WikiConsentAction,
|
|
72
|
+
fields: WikiConsentFields,
|
|
73
|
+
signal: AbortSignal,
|
|
74
|
+
): Promise<WikiFailure | undefined>
|
|
75
|
+
/**
|
|
76
|
+
* Read consent for one node: reuses the Session's standing grant, otherwise asks and records it.
|
|
77
|
+
* @param sessionId The calling Session.
|
|
78
|
+
* @param context The call context.
|
|
79
|
+
* @param node The node to open.
|
|
80
|
+
* @param signal The tool call's abort signal.
|
|
81
|
+
* @returns Undefined when reading may proceed, else the failure.
|
|
82
|
+
*/
|
|
83
|
+
readConsent(sessionId: string, context: WikiCallContext, node: WikiNode, signal: AbortSignal): Promise<WikiFailure | undefined>
|
|
84
|
+
/**
|
|
85
|
+
* Record a standing read grant (for a document the owner just approved creating).
|
|
86
|
+
* @param sessionId The calling Session.
|
|
87
|
+
* @param documentId The node's `objToken`.
|
|
88
|
+
*/
|
|
89
|
+
grantRead(sessionId: string, documentId: string): void
|
|
90
|
+
/**
|
|
91
|
+
* `空间 / 父节点` label of a node.
|
|
92
|
+
* @param api Wiki endpoints.
|
|
93
|
+
* @param node The node.
|
|
94
|
+
* @returns The label.
|
|
95
|
+
*/
|
|
96
|
+
locationOf(api: WikiApi, node: WikiNode): Promise<string>
|
|
97
|
+
/** Whether writes are switched on (`feishuTools.wikiWrite`). */
|
|
98
|
+
writeEnabled(): boolean
|
|
99
|
+
/**
|
|
100
|
+
* Structured warning sink.
|
|
101
|
+
* @param message Format string.
|
|
102
|
+
* @param args Format arguments.
|
|
103
|
+
*/
|
|
104
|
+
warn(message: string, ...args: unknown[]): void
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** One model-facing tool of a wiki tool family (the runtime adds output rendering and mounting). */
|
|
108
|
+
export interface WikiToolSpec {
|
|
109
|
+
readonly name: string
|
|
110
|
+
/** Switch that mounts it: `wikiRead` or `wikiWrite`. */
|
|
111
|
+
readonly access: 'read' | 'write'
|
|
112
|
+
readonly description: string
|
|
113
|
+
readonly parameters: Record<string, unknown>
|
|
114
|
+
/**
|
|
115
|
+
* Run one call; never rejects for Feishu failures (they become failure values).
|
|
116
|
+
* @param kit Gates and consent.
|
|
117
|
+
* @param sessionId The calling Session.
|
|
118
|
+
* @param rawArgs Model arguments (validated here).
|
|
119
|
+
* @param signal The tool call's abort signal.
|
|
120
|
+
* @returns The tool value.
|
|
121
|
+
*/
|
|
122
|
+
execute(kit: WikiToolKit, sessionId: string, rawArgs: Record<string, unknown>, signal: AbortSignal): Promise<object>
|
|
123
|
+
/**
|
|
124
|
+
* One-line call presentation.
|
|
125
|
+
* @param args Model arguments.
|
|
126
|
+
* @returns Display text.
|
|
127
|
+
*/
|
|
128
|
+
presentCall(args: Record<string, unknown>): string
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Characters of each content shown on a consent card. */
|
|
132
|
+
export const PREVIEW_CHARS = 300
|
|
133
|
+
|
|
134
|
+
// ---- Results ------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
/** Stable failure codes; the readable `reason` carries the details. */
|
|
137
|
+
export type WikiFailureCode =
|
|
138
|
+
| 'TOOL_DISABLED'
|
|
139
|
+
| 'ROUTE_NOT_FOUND'
|
|
140
|
+
| 'NOT_AUTHORIZED'
|
|
141
|
+
| 'CHANNEL_UNAVAILABLE'
|
|
142
|
+
| 'INVALID_REFERENCE'
|
|
143
|
+
| 'INVALID_OPERATION'
|
|
144
|
+
| 'OUTSIDE_ROOTS'
|
|
145
|
+
| 'NOT_FOUND'
|
|
146
|
+
| 'NO_ACCESS'
|
|
147
|
+
| 'UNSUPPORTED_TYPE'
|
|
148
|
+
| 'NOT_OWNED'
|
|
149
|
+
| 'REVISION_CONFLICT'
|
|
150
|
+
| 'CONSENT_DENIED'
|
|
151
|
+
| 'CONSENT_EXPIRED'
|
|
152
|
+
| 'CONSENT_UNAVAILABLE'
|
|
153
|
+
| 'PERMISSION_DENIED'
|
|
154
|
+
| 'CONTENT_FAILED'
|
|
155
|
+
| 'EDIT_FAILED'
|
|
156
|
+
| 'EDIT_PARTIAL'
|
|
157
|
+
| 'API_FAILED'
|
|
158
|
+
| 'ABORTED'
|
|
159
|
+
|
|
160
|
+
export const FAILURE_REASONS: Readonly<Record<WikiFailureCode, string>> = {
|
|
161
|
+
TOOL_DISABLED: '知识库工具已在插件配置中关闭',
|
|
162
|
+
ROUTE_NOT_FOUND: '当前会话没有绑定飞书话题,无法确定授权人',
|
|
163
|
+
NOT_AUTHORIZED: '当前话题的发起人不在知识库白名单中;请管理员发送 /auth wiki add @该用户 后重试',
|
|
164
|
+
CHANNEL_UNAVAILABLE: '飞书连接当前不可用,请稍后重试',
|
|
165
|
+
INVALID_REFERENCE: '无法识别的知识库引用:请传知识库节点链接 https://<租户>.feishu.cn/wiki/<node_token>、node_token,或知识空间链接 https://<租户>.feishu.cn/wiki/space/<space_id>',
|
|
166
|
+
INVALID_OPERATION: '操作参数不正确',
|
|
167
|
+
OUTSIDE_ROOTS: '该位置不在插件配置的知识库范围(wikiRoots)内,不能访问',
|
|
168
|
+
NOT_FOUND: '知识库节点不存在或已被删除',
|
|
169
|
+
NO_ACCESS: '机器人对该知识库节点没有访问权限;请知识库管理员把应用(或包含机器人的群)加为该空间成员',
|
|
170
|
+
UNSUPPORTED_TYPE: '这个工具不支持该类型的节点:飞书文档(docx)用 feishu_wiki_read / feishu_wiki_edit,电子表格(sheet)用 feishu_wiki_sheet_read / feishu_wiki_sheet_edit,多维表格(bitable)用 feishu_wiki_base_read / feishu_wiki_base_edit;其他类型暂不支持',
|
|
171
|
+
NOT_OWNED: '只能修改由本应用创建(所有者是本应用)的文档',
|
|
172
|
+
REVISION_CONFLICT: '文档在你读取之后被修改过;请重新用 feishu_wiki_read 读取最新内容,基于新的 revision_id 再修改',
|
|
173
|
+
CONSENT_DENIED: '话题发起人拒绝了这次知识库操作',
|
|
174
|
+
CONSENT_EXPIRED: '话题发起人没有在授权卡有效期内确认,已自动拒绝',
|
|
175
|
+
CONSENT_UNAVAILABLE: '授权卡发送失败,无法征得话题发起人同意,已取消操作',
|
|
176
|
+
PERMISSION_DENIED: '飞书拒绝调用:应用缺少知识库或文档权限(wiki:wiki、docx:document),请管理员在开发者后台开通并发布版本',
|
|
177
|
+
CONTENT_FAILED: '文档已创建,但写入内容失败',
|
|
178
|
+
EDIT_FAILED: '修改失败',
|
|
179
|
+
EDIT_PARTIAL: '飞书报告操作只部分生效;已停止后续操作,请用 feishu_wiki_read 读取最新内容核验后再继续',
|
|
180
|
+
API_FAILED: '飞书接口调用失败',
|
|
181
|
+
ABORTED: '已取消',
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** One failed call. */
|
|
185
|
+
export interface WikiFailure {
|
|
186
|
+
readonly ok: false
|
|
187
|
+
readonly code: WikiFailureCode
|
|
188
|
+
readonly reason: string
|
|
189
|
+
/** Set by `CONTENT_FAILED`: the document that was created anyway. */
|
|
190
|
+
readonly node_token?: string
|
|
191
|
+
readonly url?: string
|
|
192
|
+
/** Set by `EDIT_FAILED`/`EDIT_PARTIAL`: operations fully applied before the stop. */
|
|
193
|
+
readonly applied?: number
|
|
194
|
+
/** Set by `REVISION_CONFLICT`: the current revision. */
|
|
195
|
+
readonly revision_id?: number
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function failure(code: WikiFailureCode, reason?: string): WikiFailure {
|
|
199
|
+
return { ok: false, code, reason: reason ?? FAILURE_REASONS[code] }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function parseArgs<T>(tool: string, schema: z.ZodType<T>, rawArgs: Record<string, unknown>): T {
|
|
203
|
+
const parsed = schema.safeParse(rawArgs)
|
|
204
|
+
if (parsed.success) return parsed.data
|
|
205
|
+
const issue = parsed.error.issues[0]
|
|
206
|
+
const where = issue === undefined || issue.path.length === 0 ? 'arguments' : issue.path.join('.')
|
|
207
|
+
throw new Error(`${tool}: invalid ${where}: ${issue?.message ?? 'invalid arguments'}`)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Run one gated call body, mapping thrown Feishu errors to failures. */
|
|
211
|
+
export async function guarded<T>(
|
|
212
|
+
deps: { warn(message: string, ...args: unknown[]): void },
|
|
213
|
+
tool: string,
|
|
214
|
+
body: () => Promise<T | WikiFailure>,
|
|
215
|
+
): Promise<T | WikiFailure> {
|
|
216
|
+
try {
|
|
217
|
+
return await body()
|
|
218
|
+
} catch (error) {
|
|
219
|
+
const mapped = failureOf(error)
|
|
220
|
+
if (mapped.code === 'API_FAILED') deps.warn('dsh-channels: %s failed: %s', tool, mapped.reason)
|
|
221
|
+
return mapped
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Feishu codes: node missing/deleted, no resource access, missing app scope. */
|
|
226
|
+
const NOT_FOUND_CODES: ReadonlySet<number> = new Set([131005, 131012, 131013, 131014, 131016])
|
|
227
|
+
const NO_ACCESS_CODES: ReadonlySet<number> = new Set([131006, 1770032, 1770002])
|
|
228
|
+
const PERMISSION_CODES: ReadonlySet<number> = new Set([99991672, 99991679])
|
|
229
|
+
|
|
230
|
+
export function failureOf(error: unknown): WikiFailure {
|
|
231
|
+
const detail = describeError(error)
|
|
232
|
+
if (error instanceof FeishuApiError && error.code !== undefined) {
|
|
233
|
+
if (NOT_FOUND_CODES.has(error.code)) return failure('NOT_FOUND', `${FAILURE_REASONS.NOT_FOUND}(${detail})`)
|
|
234
|
+
if (NO_ACCESS_CODES.has(error.code)) return failure('NO_ACCESS', `${FAILURE_REASONS.NO_ACCESS}(${detail})`)
|
|
235
|
+
if (PERMISSION_CODES.has(error.code)) return failure('PERMISSION_DENIED', `${FAILURE_REASONS.PERMISSION_DENIED}(${detail})`)
|
|
236
|
+
}
|
|
237
|
+
return failure('API_FAILED', `${FAILURE_REASONS.API_FAILED}:${detail}`)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function consentFailure(outcome: WikiConsentOutcome): WikiFailure | undefined {
|
|
241
|
+
switch (outcome) {
|
|
242
|
+
case 'allowed': return undefined
|
|
243
|
+
case 'denied': return failure('CONSENT_DENIED')
|
|
244
|
+
case 'expired': return failure('CONSENT_EXPIRED')
|
|
245
|
+
case 'cancelled': return failure('ABORTED')
|
|
246
|
+
case 'unavailable': return failure('CONSENT_UNAVAILABLE')
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Whether the app owns a node: Feishu reports the bot open_id as the owner. */
|
|
251
|
+
export function ownedByApp(node: WikiNode, botOpenId: string | undefined): boolean {
|
|
252
|
+
return botOpenId !== undefined && botOpenId !== '' && node.owner === botOpenId
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** A node's web link; falls back to the first root's origin. */
|
|
256
|
+
export function nodeUrl(roots: readonly WikiRoot[], node: WikiNode): string {
|
|
257
|
+
if (node.url !== undefined) return node.url
|
|
258
|
+
const first = roots[0]
|
|
259
|
+
const origin = first === undefined ? 'https://feishu.cn' : new URL(first.link).origin
|
|
260
|
+
return `${origin}/wiki/${node.nodeToken}`
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** One-line clipped preview with Markdown control characters neutralized. */
|
|
264
|
+
export function preview(value: string, max = 80): string {
|
|
265
|
+
const flat = value.replace(/\s+/gu, ' ').trim().replace(/([[\]()*_`~<>])/gu, '\\$1')
|
|
266
|
+
return flat.length <= max ? flat : `${flat.slice(0, max)}…`
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** A fenced preview of the first characters of a content. */
|
|
270
|
+
export function previewBlock(content: string): string {
|
|
271
|
+
const head = content.length <= PREVIEW_CHARS ? content : `${content.slice(0, PREVIEW_CHARS)}\n…`
|
|
272
|
+
return `\`\`\`\n${head.replace(/```/gu, '```')}\n\`\`\``
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function display(value: unknown): string {
|
|
276
|
+
return typeof value === 'string' ? preview(value, 120) : '?'
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Readable, bounded text for a thrown value. */
|
|
280
|
+
export function describeError(error: unknown): string {
|
|
281
|
+
const text = error instanceof FeishuApiError ? error.detail : error instanceof Error ? error.message : String(error)
|
|
282
|
+
const cleaned = text.replace(/[\u0000-\u001f\u007f]+/gu, ' ').trim()
|
|
283
|
+
return cleaned.length <= 300 ? cleaned : `${cleaned.slice(0, 300)}…`
|
|
284
|
+
}
|