@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
package/src/domain.ts
CHANGED
|
@@ -317,6 +317,10 @@ export interface FeishuDomainSpecShape {
|
|
|
317
317
|
valueSchema: typeof accessRequestRecordSchema
|
|
318
318
|
__key?: string
|
|
319
319
|
}
|
|
320
|
+
access_wiki_users: {
|
|
321
|
+
valueSchema: typeof accessUserRecordSchema
|
|
322
|
+
__key?: string
|
|
323
|
+
}
|
|
320
324
|
}
|
|
321
325
|
}
|
|
322
326
|
|
|
@@ -353,6 +357,10 @@ export const feishuDomain: FeishuDomainSpecShape = {
|
|
|
353
357
|
access_groups: { valueSchema: accessGroupRecordSchema },
|
|
354
358
|
access_users: { valueSchema: accessUserRecordSchema },
|
|
355
359
|
access_requests: { valueSchema: accessRequestRecordSchema },
|
|
360
|
+
// Wiki whitelist (`/auth wiki add`): same record as `access_users`, an
|
|
361
|
+
// independent grant. Added without a version bump like the other access
|
|
362
|
+
// tables: a missing table loads empty.
|
|
363
|
+
access_wiki_users: { valueSchema: accessUserRecordSchema },
|
|
356
364
|
},
|
|
357
365
|
}
|
|
358
366
|
|
|
@@ -428,6 +436,8 @@ export interface FeishuDomainHandle {
|
|
|
428
436
|
accessGroups: AccessGroupsTable
|
|
429
437
|
accessUsers: AccessUsersTable
|
|
430
438
|
accessRequests: AccessRequestsTable
|
|
439
|
+
/** Wiki whitelist (key = `user_id`); same records as {@link AccessUsersTable}. */
|
|
440
|
+
accessWikiUsers: AccessUsersTable
|
|
431
441
|
getGlobal(): FeishuDomainGlobal
|
|
432
442
|
setGlobal(value: FeishuDomainGlobal): Promise<unknown> | void
|
|
433
443
|
close(): Promise<void>
|
package/src/feishu/delivery.ts
CHANGED
|
@@ -28,8 +28,27 @@ export interface CardDeliveryDeps {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export class CardDelivery {
|
|
31
|
+
/** Open `replacingCard` scopes per message id; overlapping clicks nest. */
|
|
32
|
+
private readonly inPlace = new Map<string, number>()
|
|
33
|
+
|
|
31
34
|
constructor(private readonly deps: CardDeliveryDeps) {}
|
|
32
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Run `operation` with every `standaloneReply` anchored to `messageId`
|
|
38
|
+
* patched into that (bot-sent card) message instead of replying to it.
|
|
39
|
+
* Used for picker clicks, whose synthetic message id is the picker card.
|
|
40
|
+
*/
|
|
41
|
+
async replacingCard(messageId: string, operation: () => Promise<void>): Promise<void> {
|
|
42
|
+
this.inPlace.set(messageId, (this.inPlace.get(messageId) ?? 0) + 1)
|
|
43
|
+
try {
|
|
44
|
+
await operation()
|
|
45
|
+
} finally {
|
|
46
|
+
const remaining = (this.inPlace.get(messageId) ?? 1) - 1
|
|
47
|
+
if (remaining === 0) this.inPlace.delete(messageId)
|
|
48
|
+
else this.inPlace.set(messageId, remaining)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
33
52
|
/** The live channel; throws instead of silently dropping a delivery. */
|
|
34
53
|
requireChannel(): FeishuChannel {
|
|
35
54
|
const channel = this.deps.channel()
|
|
@@ -73,6 +92,9 @@ export class CardDelivery {
|
|
|
73
92
|
/**
|
|
74
93
|
* Shared delivery policy for command-plane cards: reply in a new topic by
|
|
75
94
|
* default, then degrade to a plain reply when Feishu refuses the topic.
|
|
95
|
+
* `createThread: false` replies in the chat timeline, or inside the topic
|
|
96
|
+
* the message already belongs to. Inside `replacingCard` the card patches
|
|
97
|
+
* the anchored card instead, and falls back to a reply if the patch fails.
|
|
76
98
|
*/
|
|
77
99
|
async standaloneReply(
|
|
78
100
|
msg: NormalizedMessage,
|
|
@@ -81,9 +103,20 @@ export class CardDelivery {
|
|
|
81
103
|
): Promise<void> {
|
|
82
104
|
const channel = this.deps.channel()
|
|
83
105
|
if (!this.deps.started() || channel === undefined) return
|
|
106
|
+
if (this.inPlace.has(msg.messageId)) {
|
|
107
|
+
try {
|
|
108
|
+
await channel.updateCard(msg.messageId, card)
|
|
109
|
+
return
|
|
110
|
+
} catch (error) {
|
|
111
|
+
this.deps.warn(
|
|
112
|
+
'dsh-channels: in-place update of card %s failed, replying instead: %s',
|
|
113
|
+
msg.messageId, String(error),
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
84
117
|
const createThread = options.createThread ?? true
|
|
85
118
|
try {
|
|
86
|
-
await channel.send(msg.chatId, { card }, createThread
|
|
119
|
+
await channel.send(msg.chatId, { card }, createThread || msg.threadId !== undefined
|
|
87
120
|
? { replyTo: msg.messageId, replyInThread: true }
|
|
88
121
|
: { replyTo: msg.messageId })
|
|
89
122
|
} catch (error) {
|
|
@@ -548,8 +548,10 @@ export function messageTextOf(item: RawMessageItem): string {
|
|
|
548
548
|
}
|
|
549
549
|
case 'merge_forward':
|
|
550
550
|
return '[合并转发]'
|
|
551
|
-
case 'interactive':
|
|
552
|
-
|
|
551
|
+
case 'interactive': {
|
|
552
|
+
const text = content === undefined ? '' : cardText(content)
|
|
553
|
+
return text === '' ? '[卡片消息]' : `[卡片] ${text}`
|
|
554
|
+
}
|
|
553
555
|
case 'audio':
|
|
554
556
|
return '[语音]'
|
|
555
557
|
case 'video':
|
|
@@ -565,6 +567,80 @@ export function messageTextOf(item: RawMessageItem): string {
|
|
|
565
567
|
}
|
|
566
568
|
}
|
|
567
569
|
|
|
570
|
+
/** Deepest card node visited by {@link cardText}; real cards nest far less. */
|
|
571
|
+
const MAX_CARD_DEPTH = 12
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Card keys whose values are never reader-visible text: callback payloads,
|
|
575
|
+
* link targets, and layout/style settings.
|
|
576
|
+
*/
|
|
577
|
+
const CARD_SKIPPED_KEYS = new Set([
|
|
578
|
+
'value', 'behaviors', 'url', 'href', 'multi_url', 'image_key', 'img_key',
|
|
579
|
+
'icon', 'style', 'config', 'confirm', 'i18n_elements', 'i18n_header', 'user_id', 'open_id',
|
|
580
|
+
])
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Flatten one card body into text. Handles both the simplified structure the
|
|
584
|
+
* message read APIs return by default (`{ title, elements: [[...]] }`) and a
|
|
585
|
+
* sent card's JSON 1.0/2.0 (`header.title`, `elements` or `body.elements`):
|
|
586
|
+
* every `text`/`content` string and `plain_text`/`lark_md` object in reading
|
|
587
|
+
* order, images as `[图片]`. A Card 1.0 localized only through
|
|
588
|
+
* `i18n_header`/`i18n_elements` renders one locale. Returns `''` when no text
|
|
589
|
+
* is found.
|
|
590
|
+
*/
|
|
591
|
+
function cardText(content: Record<string, unknown>): string {
|
|
592
|
+
const parts: string[] = []
|
|
593
|
+
const title = stringField(content, 'title')
|
|
594
|
+
if (title !== undefined) parts.push(title)
|
|
595
|
+
collectCardText(asRecord(content.header) ?? localizedBranch(content.i18n_header), parts, 0)
|
|
596
|
+
collectCardText(
|
|
597
|
+
content.elements ?? asRecord(content.body)?.elements ?? localizedBranch(content.i18n_elements),
|
|
598
|
+
parts,
|
|
599
|
+
0,
|
|
600
|
+
)
|
|
601
|
+
return parts.map(part => part.trim()).filter(part => part !== '').join(' ')
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* One locale branch of a Card 1.0 `i18n_*` map: `zh_cn` when present,
|
|
606
|
+
* otherwise the first branch, so a card localized only through `i18n_*`
|
|
607
|
+
* still renders once instead of once per locale.
|
|
608
|
+
*/
|
|
609
|
+
function localizedBranch(value: unknown): unknown {
|
|
610
|
+
const branches = asRecord(value)
|
|
611
|
+
if (branches === undefined) return undefined
|
|
612
|
+
return branches.zh_cn ?? Object.values(branches)[0]
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Append the reader-visible text of one card node to `parts`. */
|
|
616
|
+
function collectCardText(node: unknown, parts: string[], depth: number): void {
|
|
617
|
+
if (depth > MAX_CARD_DEPTH) return
|
|
618
|
+
if (Array.isArray(node)) {
|
|
619
|
+
for (const child of node) collectCardText(child, parts, depth + 1)
|
|
620
|
+
return
|
|
621
|
+
}
|
|
622
|
+
const record = asRecord(node)
|
|
623
|
+
if (record === undefined) return
|
|
624
|
+
const tag = stringField(record, 'tag')
|
|
625
|
+
if (tag === 'img' || tag === 'image') {
|
|
626
|
+
parts.push('[图片]')
|
|
627
|
+
return
|
|
628
|
+
}
|
|
629
|
+
if (tag === 'at') {
|
|
630
|
+
const name = stringField(record, 'user_name')
|
|
631
|
+
parts.push(name === undefined ? '@' : `@${name}`)
|
|
632
|
+
return
|
|
633
|
+
}
|
|
634
|
+
for (const [key, value] of Object.entries(record)) {
|
|
635
|
+
if (CARD_SKIPPED_KEYS.has(key)) continue
|
|
636
|
+
if (typeof value === 'string') {
|
|
637
|
+
if (key === 'text' || key === 'content') parts.push(value)
|
|
638
|
+
continue
|
|
639
|
+
}
|
|
640
|
+
collectCardText(value, parts, depth + 1)
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
568
644
|
/** Flatten one post body into text; inline images/files become placeholders. */
|
|
569
645
|
function postText(content: Record<string, unknown>): string {
|
|
570
646
|
const lines: string[] = []
|
package/src/feishu/scopes.ts
CHANGED
|
@@ -44,6 +44,10 @@ export interface ScopeCheckContext {
|
|
|
44
44
|
readonly slashCommandPanel: boolean
|
|
45
45
|
/** The `feishu_send_file` tool is enabled (`feishuTools.sendFile`). */
|
|
46
46
|
readonly sendFile: boolean
|
|
47
|
+
/** The wiki read tools are mounted (`feishuTools.wikiRead` with non-empty `wikiRoots`). */
|
|
48
|
+
readonly wikiRead: boolean
|
|
49
|
+
/** The wiki write tools are mounted (`feishuTools.wikiWrite` with non-empty `wikiRoots`). */
|
|
50
|
+
readonly wikiWrite: boolean
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
/** A requirement none of whose scopes is granted. */
|
|
@@ -87,6 +91,30 @@ const SEND_FILE_REQUIREMENT: ScopeRequirement = {
|
|
|
87
91
|
anyOf: ['im:resource', 'im:resource:upload'], level: 'optional', purpose: '上传图片/文件,让模型用 feishu_send_file 把生成的文件发给用户',
|
|
88
92
|
}
|
|
89
93
|
|
|
94
|
+
/** Spreadsheet and Base read needs, shared by the wiki read and write sets. */
|
|
95
|
+
const SHEET_BASE_READ_REQUIREMENTS: readonly ScopeRequirement[] = [
|
|
96
|
+
{ anyOf: ['sheets:spreadsheet', 'sheets:spreadsheet:readonly', 'sheets:spreadsheet:read'], level: 'optional', purpose: '读取知识库电子表格(feishu_wiki_sheet_read)' },
|
|
97
|
+
{ anyOf: ['bitable:app', 'bitable:app:readonly', 'base:record:read'], level: 'optional', purpose: '读取知识库多维表格(feishu_wiki_base_read)' },
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
/** Wiki read needs (`feishu_wiki_list` / `feishu_wiki_read` and the sheet/base read tools). */
|
|
101
|
+
const WIKI_READ_REQUIREMENTS: readonly ScopeRequirement[] = [
|
|
102
|
+
{ anyOf: ['wiki:wiki', 'wiki:wiki:readonly'], level: 'optional', purpose: '浏览知识库节点(feishu_wiki_list / feishu_wiki_read)' },
|
|
103
|
+
{ anyOf: ['docx:document', 'docx:document:readonly'], level: 'optional', purpose: '读取知识库中的文档内容(feishu_wiki_read)' },
|
|
104
|
+
...SHEET_BASE_READ_REQUIREMENTS,
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
/** Wiki write needs (`feishu_wiki_create` / `feishu_wiki_edit`). */
|
|
108
|
+
const WIKI_WRITE_REQUIREMENTS: readonly ScopeRequirement[] = [
|
|
109
|
+
{ anyOf: ['wiki:wiki'], level: 'optional', purpose: '在知识库中新建节点(feishu_wiki_create)' },
|
|
110
|
+
{ anyOf: ['docx:document'], level: 'optional', purpose: '写入与修改知识库文档内容(feishu_wiki_create / feishu_wiki_edit)' },
|
|
111
|
+
{ anyOf: ['sheets:spreadsheet', 'sheets:spreadsheet:write_only'], level: 'optional', purpose: '写入知识库电子表格(feishu_wiki_sheet_edit)' },
|
|
112
|
+
{ anyOf: ['bitable:app', 'base:record:create'], level: 'optional', purpose: '新增知识库多维表格记录(feishu_wiki_base_edit)' },
|
|
113
|
+
{ anyOf: ['bitable:app', 'base:record:update'], level: 'optional', purpose: '修改本应用创建的多维表格中的记录(feishu_wiki_base_edit)' },
|
|
114
|
+
{ anyOf: ['bitable:app', 'base:record:delete'], level: 'optional', purpose: '删除本应用创建的多维表格中的记录(feishu_wiki_base_edit)' },
|
|
115
|
+
...SHEET_BASE_READ_REQUIREMENTS,
|
|
116
|
+
]
|
|
117
|
+
|
|
90
118
|
/** Group receive needs while ordinary group messages require an @ mention. */
|
|
91
119
|
const MENTION_GROUP_REQUIREMENTS: readonly ScopeRequirement[] = [
|
|
92
120
|
{ anyOf: ['im:message.group_at_msg:readonly', 'im:message.group_msg'], level: 'required', purpose: '接收群聊中 @ 机器人的消息' },
|
|
@@ -139,10 +167,17 @@ export function scopeRequirements(context: ScopeCheckContext): readonly ScopeReq
|
|
|
139
167
|
...(context.requireMention ? MENTION_GROUP_REQUIREMENTS : [ALL_GROUP_REQUIREMENT]),
|
|
140
168
|
...BASE_REQUIREMENTS,
|
|
141
169
|
...(context.sendFile ? [SEND_FILE_REQUIREMENT] : []),
|
|
170
|
+
...wikiRequirements(context),
|
|
142
171
|
...(context.slashCommandPanel ? SLASH_COMMAND_REQUIREMENTS : []),
|
|
143
172
|
]
|
|
144
173
|
}
|
|
145
174
|
|
|
175
|
+
/** Wiki needs; the write set subsumes the read set's preferred scopes. */
|
|
176
|
+
function wikiRequirements(context: ScopeCheckContext): readonly ScopeRequirement[] {
|
|
177
|
+
if (context.wikiWrite) return WIKI_WRITE_REQUIREMENTS
|
|
178
|
+
return context.wikiRead ? WIKI_READ_REQUIREMENTS : []
|
|
179
|
+
}
|
|
180
|
+
|
|
146
181
|
/**
|
|
147
182
|
* Compare `grants` with the applicable requirements. Only tenant (or untyped)
|
|
148
183
|
* grants count: the plugin always calls with the app identity.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A1 notation helpers for the wiki sheet tools: column letters, single cells,
|
|
3
|
+
* and rectangle end rows. Ranges never carry a sheet prefix (`Sheet1!`); the
|
|
4
|
+
* sub-sheet is selected separately.
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-channels/feishu/wiki/a1
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Largest column the helpers accept (`ZZZ`). */
|
|
10
|
+
const MAX_COLUMN = 18_278
|
|
11
|
+
|
|
12
|
+
/** Largest row the helpers accept. */
|
|
13
|
+
const MAX_ROW = 10_000_000
|
|
14
|
+
|
|
15
|
+
/** One cell position, 1-based. */
|
|
16
|
+
export interface A1Cell {
|
|
17
|
+
readonly column: number
|
|
18
|
+
readonly row: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Column letters to a 1-based index (`A` → 1, `AA` → 27).
|
|
23
|
+
*
|
|
24
|
+
* @param letters Column letters, any case.
|
|
25
|
+
* @returns The index, or undefined when invalid or beyond `ZZZ`.
|
|
26
|
+
*/
|
|
27
|
+
export function columnIndex(letters: string): number | undefined {
|
|
28
|
+
if (!/^[A-Za-z]{1,3}$/u.test(letters)) return undefined
|
|
29
|
+
let index = 0
|
|
30
|
+
for (const char of letters.toUpperCase()) index = index * 26 + (char.charCodeAt(0) - 64)
|
|
31
|
+
return index <= MAX_COLUMN ? index : undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A 1-based column index to letters (1 → `A`, 27 → `AA`).
|
|
36
|
+
*
|
|
37
|
+
* @param index Column index, at least 1.
|
|
38
|
+
* @returns The letters.
|
|
39
|
+
*/
|
|
40
|
+
export function columnLetters(index: number): string {
|
|
41
|
+
let letters = ''
|
|
42
|
+
let rest = index
|
|
43
|
+
while (rest > 0) {
|
|
44
|
+
const digit = (rest - 1) % 26
|
|
45
|
+
letters = String.fromCharCode(65 + digit) + letters
|
|
46
|
+
rest = Math.floor((rest - 1) / 26)
|
|
47
|
+
}
|
|
48
|
+
return letters
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse one cell (`B3`).
|
|
53
|
+
*
|
|
54
|
+
* @param value Cell reference.
|
|
55
|
+
* @returns The position, or undefined when it is not a single valid cell.
|
|
56
|
+
*/
|
|
57
|
+
export function parseCell(value: string): A1Cell | undefined {
|
|
58
|
+
const match = /^([A-Za-z]{1,3})([1-9]\d{0,7})$/u.exec(value.trim())
|
|
59
|
+
if (match === null) return undefined
|
|
60
|
+
const column = columnIndex(match[1] ?? '')
|
|
61
|
+
const row = Number(match[2])
|
|
62
|
+
return column === undefined || row > MAX_ROW ? undefined : { column, row }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The rectangle that starts at `start` and spans `rows` × `columns` cells.
|
|
67
|
+
*
|
|
68
|
+
* @param start Top-left cell.
|
|
69
|
+
* @param rows Row count, at least 1.
|
|
70
|
+
* @param columns Column count, at least 1.
|
|
71
|
+
* @returns The range (`B3:D10`).
|
|
72
|
+
*/
|
|
73
|
+
export function rectangle(start: A1Cell, rows: number, columns: number): string {
|
|
74
|
+
return `${columnLetters(start.column)}${start.row}:${columnLetters(start.column + columns - 1)}${start.row + rows - 1}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Last row of a range (`A1:B5` → 5, `C7` → 7); undefined for whole-column ranges (`A:C`).
|
|
79
|
+
*
|
|
80
|
+
* @param range A1 range.
|
|
81
|
+
* @returns The row, or undefined.
|
|
82
|
+
*/
|
|
83
|
+
export function rangeEndRow(range: string): number | undefined {
|
|
84
|
+
const end = range.split(':').at(-1) ?? ''
|
|
85
|
+
const match = /(\d+)$/u.exec(end.trim())
|
|
86
|
+
return match === null ? undefined : Number(match[1])
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether a range is plain A1 (`A1`, `A1:F30`, `A:C`, `3:5`) without a sheet prefix.
|
|
91
|
+
*
|
|
92
|
+
* @param range Candidate range.
|
|
93
|
+
* @returns True when acceptable.
|
|
94
|
+
*/
|
|
95
|
+
export function isA1Range(range: string): boolean {
|
|
96
|
+
return /^(?:[A-Za-z]{1,3}\d{0,8}|\d{1,8})(?::(?:[A-Za-z]{1,3}\d{0,8}|\d{1,8}))?$/u.test(range)
|
|
97
|
+
}
|