@hedeqiang/dsh-nl2sql 0.11.1 → 0.12.0

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/channels/lark.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Lark / Feishu channel adapter (see docs/CHANNELS.md).
2
+ * Lark / Feishu channel adapter (see docs/channels.md).
3
3
  *
4
4
  * Kinds:
5
5
  * - lark_webhook: a group custom bot (webhook URL + optional signing secret).
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { createHash, createHmac } from 'node:crypto'
18
18
  import { ChannelError, request } from './http.js'
19
- import { MD_TABLE, cellText, chartOption, chartable, dualAxis, kpiText, isKpi, num, parseMdTable, renderChartPng, resultMeta } from './message.js'
19
+ import { MD_TABLE, cellText, chartOption, chartable, dualAxis, kpiText, isKpi, needsAttachment, num, omittedNote, parseMdTable, renderChartPng, resultMeta } from './message.js'
20
20
 
21
21
  export { ChannelError, ChannelError as ImError, chartOption, chartable, dualAxis, renderChartPng }
22
22
 
@@ -677,6 +677,18 @@ async function uploadImage(channel, secrets, png) {
677
677
  return json.data.image_key
678
678
  }
679
679
 
680
+ /** Upload a file (≤ 30 MB) for a file message; returns file_key. https://open.feishu.cn/document/server-docs/im-v1/file/create */
681
+ async function uploadFile(channel, secrets, buf, fileName, fileType = 'xls') {
682
+ const token = await pushToken(channel, secrets)
683
+ const form = new FormData()
684
+ form.set('file_type', fileType)
685
+ form.set('file_name', fileName)
686
+ form.set('file', new Blob([buf], { type: 'application/octet-stream' }), fileName)
687
+ const { status, json } = await request(`${hostFor(channel)}/open-apis/im/v1/files`, { method: 'POST', headers: { authorization: `Bearer ${token}` }, body: form, timeoutMs: 60000, label: '飞书' })
688
+ if (json.code !== 0) throw larkPushError(json, status)
689
+ return json.data.file_key
690
+ }
691
+
680
692
  // ===========================================================================
681
693
  // Cards (JSON 2.0)
682
694
  // ===========================================================================
@@ -687,6 +699,7 @@ function tableElement(result, maxRows) {
687
699
  const cols = result.columns.slice(0, 8)
688
700
  return {
689
701
  tag: 'table',
702
+ // page_size is 1..10 per the card spec; more rows are paged in the client.
690
703
  page_size: Math.min(10, Math.max(1, Math.min(maxRows, result.rows.length))),
691
704
  row_height: 'low',
692
705
  header_style: { background_style: 'grey', bold: true, text_size: 'normal' },
@@ -820,7 +833,7 @@ export function actionElement(spec) {
820
833
  * Build a JSON 2.0 card from an OutboundMessage.
821
834
  * Results may carry `imageKey` (uploaded PNG) and `actions` (ActionSpec; legacy name `cardActions`).
822
835
  */
823
- export function buildCard(message, { maxRows = 10, charts = true, actions = true } = {}) {
836
+ export function buildCard(message, { maxRows = 50, charts = true, actions = true } = {}) {
824
837
  const elements = [...markdownElements(message.text)]
825
838
  let tables = elements.filter(e => e.tag === 'table').length
826
839
  let chartCount = 0
@@ -844,8 +857,12 @@ export function buildCard(message, { maxRows = 10, charts = true, actions = true
844
857
  } else if (tables < 5) {
845
858
  elements.push(tableElement(r, maxRows))
846
859
  tables++
860
+ const note = omittedNote(r, Math.min(maxRows, r.rows.length), { attached: Boolean(r.attached) })
861
+ if (note) elements.push({ tag: 'markdown', content: `<font color='grey'>${note}</font>`, text_size: 'notation' })
847
862
  } else {
848
863
  elements.push({ tag: 'markdown', content: r.rows.slice(0, 5).map(row => row.slice(0, 6).map(v => cellText(v)).join(' | ')).join('\n') })
864
+ const note = omittedNote(r, Math.min(5, r.rows.length), { attached: Boolean(r.attached) })
865
+ if (note) elements.push({ tag: 'markdown', content: `<font color='grey'>${note}</font>`, text_size: 'notation' })
849
866
  }
850
867
  const spec = r.actions ?? r.cardActions
851
868
  if (spec && actions) {
@@ -863,7 +880,7 @@ export function buildCard(message, { maxRows = 10, charts = true, actions = true
863
880
  }
864
881
  // Card bodies are limited to ~30 KB: shrink tables/charts until it fits.
865
882
  const size = Buffer.byteLength(JSON.stringify(card))
866
- if (size > CARD_LIMIT && maxRows > 3) return buildCard(message, { maxRows: Math.max(3, Math.floor(maxRows / 2)), charts, actions })
883
+ if (size > CARD_LIMIT && maxRows > 5) return buildCard(message, { maxRows: Math.max(5, Math.floor(maxRows / 2)), charts, actions })
867
884
  if (size > CARD_LIMIT && charts) return buildCard(message, { maxRows, charts: false, actions })
868
885
  if (size > CARD_LIMIT) {
869
886
  card.body.elements = elements.filter(e => e.tag !== 'table')
@@ -915,6 +932,7 @@ export async function sendMessage(channel, secrets, message) {
915
932
  }
916
933
  results.push(r)
917
934
  }
935
+ if (channel.type === 'lark_app' && message.attachment?.buffer) for (let i = 0; i < results.length; i++) if (message.attachment.resultIndexes?.includes(i)) results[i] = { ...results[i], attached: true }
918
936
  const card = buildCard({ ...message, results }, { charts: message.chartMode !== 'image' && message.chartMode !== 'table' })
919
937
  if (channel.type === 'lark_webhook') {
920
938
  if (!secrets.webhookUrl) throw new ChannelError('im/config', '缺少 Webhook 地址')
@@ -944,7 +962,17 @@ export async function sendMessage(channel, secrets, message) {
944
962
  if (json.code !== 0 && !fatal.includes(json.code) && card.body.elements.some(e => e.tag === 'chart')) ({ status, json } = await send(buildCard({ ...message, results }, { charts: false })))
945
963
  if (json.code !== 0 && !fatal.includes(json.code)) ({ status, json } = await send(legacyCard(card)))
946
964
  if (json.code !== 0) throw larkPushError(json, status)
947
- return { messageId: json.data?.message_id, images }
965
+ // Full data as an Excel file message after the card (app bots only; webhooks cannot send files).
966
+ let files = 0
967
+ if (message.attachment?.buffer) {
968
+ try {
969
+ const fileKey = await uploadFile(channel, secrets, message.attachment.buffer, message.attachment.fileName)
970
+ const fr = await postJson(url, { receive_id: channel.receiveId, msg_type: 'file', content: JSON.stringify({ file_key: fileKey }) }, { authorization: `Bearer ${token}` })
971
+ if (fr.json.code === 0) files++
972
+ else message.attachmentError = larkPushError(fr.json, fr.status).message
973
+ } catch (error) { message.attachmentError = error.message }
974
+ }
975
+ return { messageId: json.data?.message_id, images, files, ...(message.attachmentError ? { warning: `完整数据附件发送失败:${message.attachmentError}` } : {}) }
948
976
  }
949
977
  throw new ChannelError('im/config', `不支持的渠道类型:${channel.type}`)
950
978
  }
@@ -1104,7 +1132,7 @@ export const kinds = [
1104
1132
  { key: 'webhookUrl', label: { zh: 'Webhook 地址', en: 'Webhook URL' }, type: 'secret', secret: true, required: true, placeholder: 'https://open.feishu.cn/open-apis/bot/v2/hook/…', pattern: WEBHOOK_RE.source },
1105
1133
  { key: 'signSecret', label: { zh: '签名密钥', en: 'Signing secret' }, type: 'secret', secret: true, help: { zh: '机器人开启「签名校验」时填写', en: 'Only when signature verification is enabled' } },
1106
1134
  ],
1107
- capabilities: { push: true, markdown: 'full', charts: 'native', chartModes: ['native', 'table'], tables: 'native', buttons: false, bot: false, listTargets: false, mentionAll: true, maxMessageBytes: 28000 },
1135
+ capabilities: { push: true, markdown: 'full', charts: 'native', chartModes: ['native', 'table'], tables: 'native', tableRows: 50, files: false, buttons: false, bot: false, listTargets: false, mentionAll: true, maxMessageBytes: 28000 },
1108
1136
  setupSteps: WEBHOOK_SETUP,
1109
1137
  validate(input, existing) {
1110
1138
  const config = {}
@@ -1141,7 +1169,7 @@ export const kinds = [
1141
1169
  ] },
1142
1170
  { key: 'receiveId', label: { zh: '接收者 ID', en: 'Receiver ID' }, type: 'text', required: true, placeholder: 'oc_…', target: true, help: { zh: '推送目标;选择群时可点「列出群聊」', en: 'Push target; use "List chats" to pick a chat' } },
1143
1171
  ],
1144
- capabilities: { push: true, markdown: 'full', charts: 'native', chartImages: true, chartModes: ['native', 'image', 'table'], tables: 'native', buttons: true, bot: true, listTargets: true, mentionAll: true, maxMessageBytes: 28000 },
1172
+ capabilities: { push: true, markdown: 'full', charts: 'native', chartImages: true, chartModes: ['native', 'image', 'table'], tables: 'native', tableRows: 50, files: true, buttons: true, bot: true, listTargets: true, mentionAll: true, maxMessageBytes: 28000 },
1145
1173
  setupSteps: APP_SETUP,
1146
1174
  validate(input, existing) {
1147
1175
  const c = input.config ?? {}
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * OutboundMessage = { title, text?, results?, footer?, includeSql?, chartMode?, mentionAll?, template? }
5
5
  * ResultBlock = { title, columns:[{name,kind}], rows, rowCount, truncated, chart, sql?, filters?, error?, actions? }
6
- * (see docs/CHANNELS.md)
6
+ * (see docs/channels.md)
7
7
  */
8
8
  import * as echarts from 'echarts'
9
9
  import { Resvg } from '@resvg/resvg-js'
@@ -42,6 +42,23 @@ export function isKpi(result) {
42
42
  }
43
43
 
44
44
  /** "12 行(已截断) · 已筛选:…" */
45
+ /**
46
+ * One line telling the reader how much of a result the message shows, or ''
47
+ * when everything is shown. `attached` = a full-data file follows.
48
+ */
49
+ export function omittedNote(result, shown, { attached = false } = {}) {
50
+ const total = result.rowCount ?? result.rows?.length ?? 0
51
+ if (shown >= total && !result.truncated) return ''
52
+ const base = shown < total ? `共 ${total} 行,消息中显示前 ${shown} 行` : `查询结果已按行数上限截断(${total} 行)`
53
+ return attached ? `${base},完整数据见附件 Excel` : `${base};完整数据可在数据问答页面查看或导出 Excel`
54
+ }
55
+
56
+ /** Results whose rows do not all fit in a message (candidates for a full-data attachment). */
57
+ export function needsAttachment(result, shown) {
58
+ if (!result || result.error || !result.rows?.length) return false
59
+ return (result.rowCount ?? result.rows.length) > shown
60
+ }
61
+
45
62
  export function resultMeta(result) {
46
63
  return `${result.rowCount ?? result.rows?.length ?? 0} 行${result.truncated ? '(已截断)' : ''}${result.filters?.length ? ` · 已筛选:${result.filters.map(f => f.description).join(';')}` : ''}`
47
64
  }
@@ -204,7 +221,7 @@ export function resultAsMarkdownTable(result, maxRows = 10, { maxCols = 8 } = {}
204
221
  ...rows.map(r => `| ${cols.map((c, i) => mdCell(c.kind === 'number' && Number.isFinite(Number(r[i])) && r[i] !== null && r[i] !== '' ? Number(r[i]).toLocaleString('en-US', { maximumFractionDigits: 4 }) : r[i])).join(' | ')} |`),
205
222
  ]
206
223
  const total = result.rowCount ?? result.rows?.length ?? 0
207
- if (total > rows.length) lines.push('', `…共 ${total} 行,仅显示前 ${rows.length} 行`)
224
+ if (total > rows.length) lines.push('', `…共 ${total} 行,仅显示前 ${rows.length} 行${result.attached ? ',完整数据见附件 Excel' : ''}`)
208
225
  return lines.join('\n')
209
226
  }
210
227
 
@@ -280,7 +280,7 @@ export function inlineKeyboard(spec) {
280
280
 
281
281
  // ------------------------------------------------------------ rendering
282
282
 
283
- function resultMarkdown(r, { includeSql, maxRows = 10 } = {}) {
283
+ function resultMarkdown(r, { includeSql, maxRows = 20 } = {}) {
284
284
  const head = `**${r.title || '查询结果'}** ${resultMeta(r)}`
285
285
  if (r.error) return `${head}\n${r.error}`
286
286
  const lines = [head]
@@ -353,6 +353,16 @@ function createSender(api) {
353
353
  return msg?.message_id
354
354
  }
355
355
 
356
+ async function sendDocument(chatId, buf, fileName, { caption, threadId } = {}) {
357
+ const form = new FormData()
358
+ form.append('chat_id', String(chatId))
359
+ form.append('document', new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), fileName)
360
+ if (caption) form.append('caption', caption.slice(0, 1024))
361
+ if (threadId) form.append('message_thread_id', String(threadId))
362
+ const msg = await api.call('sendDocument', undefined, { form, timeoutMs: 60000 })
363
+ return msg?.message_id
364
+ }
365
+
356
366
  /** Deliver an OutboundMessage. Returns { messageId, images }. */
357
367
  async function deliver(chatId, message, { replyTo, threadId, renderChartPng = defaultRenderChartPng, logger, buttons = true } = {}) {
358
368
  let images = 0
@@ -376,10 +386,16 @@ function createSender(api) {
376
386
  messageId ??= id
377
387
  reply = undefined
378
388
  }
379
- return { messageId: messageId === undefined ? undefined : String(messageId), images }
389
+ // Full data as an Excel document (Bot API sendDocument, ≤ 50 MB).
390
+ let files = 0
391
+ let warning
392
+ if (message.attachment?.buffer) {
393
+ try { await sendDocument(chatId, message.attachment.buffer, message.attachment.fileName, { caption: '完整数据', threadId }); files++ } catch (error) { warning = `完整数据附件发送失败:${error.message}` }
394
+ }
395
+ return { messageId: messageId === undefined ? undefined : String(messageId), images, files, ...(warning ? { warning } : {}) }
380
396
  }
381
397
 
382
- return { sendText, sendPhoto, deliver }
398
+ return { sendText, sendPhoto, sendDocument, deliver }
383
399
  }
384
400
 
385
401
  // ------------------------------------------------------------ validate
@@ -627,7 +643,7 @@ const telegramBot = {
627
643
  { key: 'chatId', type: 'text', required: false, target: true, label: { zh: 'Chat ID(推送目标)', en: 'Chat ID (push target)' }, placeholder: '-1001234567890 / @mychannel', pattern: CHAT_RE.source, help: { zh: '群组/超级群为负数(通常以 -100 开头),公开频道可填 @用户名;仅作机器人使用时可留空', en: 'Negative number for groups (usually -100…), or @username for public channels; may be empty for bot-only use' } },
628
644
  { key: 'apiBase', type: 'url', required: false, label: { zh: 'API 地址(可选)', en: 'API base URL (optional)' }, placeholder: DEFAULT_API_BASE, help: { zh: '自建 Bot API 服务器或反向代理地址;留空使用官方 api.telegram.org', en: 'Self-hosted Bot API server or proxy; empty = api.telegram.org' } },
629
645
  ],
630
- capabilities: { push: true, markdown: 'basic', charts: 'image', chartModes: ['image', 'table'], tables: 'text', buttons: true, bot: true, listTargets: false, mentionAll: false, maxMessageBytes: TEXT_LIMIT },
646
+ capabilities: { push: true, markdown: 'basic', charts: 'image', chartModes: ['image', 'table'], tables: 'text', tableRows: 20, files: true, buttons: true, bot: true, listTargets: false, mentionAll: false, maxMessageBytes: TEXT_LIMIT },
631
647
  setupSteps: {
632
648
  zh: [
633
649
  '在 Telegram 中打开 @BotFather,发送 /newbot 按提示创建机器人,复制 Bot Token。',
package/channels/wecom.js CHANGED
@@ -522,7 +522,7 @@ const webhookKind = {
522
522
  fields: [
523
523
  { key: 'webhookUrl', type: 'secret', secret: true, required: true, label: { zh: 'Webhook 地址', en: 'Webhook URL' }, placeholder: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=…', pattern: WEBHOOK_RE.source, help: { zh: '群聊 → 右上角 … → 消息推送 → 添加,复制 Webhook 地址', en: 'Group chat → … → Message push → Add, copy the webhook URL' } },
524
524
  ],
525
- capabilities: { push: true, markdown: 'basic', charts: 'image', chartModes: ['image', 'table'], tables: 'markdown', buttons: false, bot: false, listTargets: false, mentionAll: true, maxMessageBytes: WEBHOOK_LIMIT },
525
+ capabilities: { push: true, markdown: 'basic', charts: 'image', chartModes: ['image', 'table'], tables: 'markdown', tableRows: 10, files: false, buttons: false, bot: false, listTargets: false, mentionAll: true, maxMessageBytes: WEBHOOK_LIMIT },
526
526
  setupSteps: {
527
527
  zh: [
528
528
  '在企业微信群聊中点击右上角「…」→「消息推送」(旧版为「群机器人」)→「添加」,创建后复制 Webhook 地址。',
@@ -553,7 +553,7 @@ const aibotKind = {
553
553
  { key: 'chatId', type: 'text', required: false, label: { zh: '推送目标会话 ID(可选)', en: 'Push target chat id (optional)' }, help: { zh: '群聊 chatid(来自机器人收到的消息)或成员 userid;会话需先给机器人发过消息', en: 'Group chatid (from a received message) or member userid; the chat must have messaged the bot first' } },
554
554
  { key: 'chatType', type: 'select', required: false, label: { zh: '会话类型', en: 'Chat type' }, options: [{ value: 'group', label: { zh: '群聊', en: 'Group' } }, { value: 'single', label: { zh: '单聊', en: 'Single' } }] },
555
555
  ],
556
- capabilities: { push: true, markdown: 'full', charts: 'none', chartModes: ['table'], tables: 'markdown', buttons: false, bot: true, listTargets: false, mentionAll: false, maxMessageBytes: AIBOT_LIMIT },
556
+ capabilities: { push: true, markdown: 'full', charts: 'none', chartModes: ['table'], tables: 'markdown', tableRows: 10, files: false, buttons: false, bot: true, listTargets: false, mentionAll: false, maxMessageBytes: AIBOT_LIMIT },
557
557
  setupSteps: {
558
558
  zh: [
559
559
  '企业微信管理后台 → 应用管理 → 智能机器人 → 创建(或选择)机器人 → 开启「API 模式」并选择「长连接」。',
package/im.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Compatibility shim: IM delivery moved to channels/ (see docs/CHANNELS.md).
2
+ * Compatibility shim: IM delivery moved to channels/ (see docs/channels.md).
3
3
  * Platform-neutral helpers live in channels/message.js, Lark cards and push in
4
4
  * channels/lark.js. `ImError` is the generic ChannelError.
5
5
  */
package/index.js CHANGED
@@ -310,6 +310,7 @@ function registerSendTool(ctx, service) {
310
310
  chartMode: { type: 'string', enum: ['native', 'image', 'table'], description: 'How attached results render: native card charts, PNG images, or tables. Omit to use the channel default; unsupported modes fall back to it.' },
311
311
  resultIds: { type: 'array', items: { type: 'string' }, description: 'resultIds from nl2sql_query / nl2sql_chart / nl2sql_overview to include.' },
312
312
  includeSql: { type: 'boolean', description: 'Also include each result\'s SQL.' },
313
+ attachFull: { type: 'boolean', description: 'Where the channel can send files (Lark app bot, Telegram), results with more rows than the message shows get the full data attached as an Excel file. Default true.' },
313
314
  },
314
315
  additionalProperties: false,
315
316
  },
@@ -318,7 +319,7 @@ function registerSendTool(ctx, service) {
318
319
  execute: async (args, exec) => {
319
320
  try {
320
321
  if (args.action === 'list') return { channels: service.listChannels().map(c => ({ ...pick(c, ['id', 'name', 'type', 'platform', 'description', 'isDefault']), ...(c.capabilities ? { capabilities: pick(c.capabilities, ['markdown', 'charts', 'tables', 'chartModes']) } : {}) })) }
321
- return await service.sendToChannel({ channelId: args.channel, title: args.title, text: args.text, resultIds: args.resultIds ?? [], includeSql: args.includeSql, chartMode: args.chartMode, source: 'agent', sessionId: exec.agent?.id })
322
+ return await service.sendToChannel({ channelId: args.channel, title: args.title, text: args.text, resultIds: args.resultIds ?? [], includeSql: args.includeSql, attachFull: args.attachFull, chartMode: args.chartMode, source: 'agent', sessionId: exec.agent?.id })
322
323
  } catch (error) {
323
324
  throw error instanceof UserError ? new Error(error.message) : error
324
325
  }
@@ -495,6 +496,7 @@ function registerRpc(ctx, service) {
495
496
  ackSchemaChange: (p) => service.ackSchemaChange(p),
496
497
  checkAllSchemas: () => service.checkAllSchemas(),
497
498
  listUsers: () => service.listUsers(),
499
+ listSeenIdentities: (p) => service.listSeenIdentities(p ?? {}),
498
500
  saveUser: (p) => service.saveUser(p),
499
501
  deleteUser: (p) => service.deleteUser(p.id),
500
502
  usageByActor: (p) => service.usageByActor(p?.days ?? 30),
package/lark-bot.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Compatibility shim: the Lark long connection and OpenAPI helpers moved to
3
- * channels/lark.js (see docs/CHANNELS.md).
3
+ * channels/lark.js (see docs/channels.md).
4
4
  */
5
5
  export {
6
6
  addReaction, clearTenantTokenCache, createDedupe, createLarkBot, getBotInfo, getChatInfo, getTenantToken, getUserInfo,