@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/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>
@@ -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
- return '[卡片消息]'
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[] = []
@@ -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
+ }