@hedeqiang/dsh-nl2sql 0.12.1 → 0.13.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/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.13.0] - 2026-11-12
9
+
10
+ ### Added
11
+ - **Data health:** per-table freshness (latest timestamp, lag, thresholds), the `nl2sql_freshness` tool and stale-data warnings under results and in the schema view, notifications when a table stops updating and when it recovers; a daily data-quality patrol (null rates, duplicate keys, outliers, future timestamps, negative amounts) with a health score and a push when it drops.
12
+ - **Reconciliation:** the `nl2sql_reconcile` tool and saved daily jobs that compare two queries on two data sources by key (tolerances, normalisation), with match rate, rows missing on either side, value differences, run history and pushes with the full differences as Excel.
13
+ - **Chat subscriptions:** `nl2sql_subscribe`, `nl2sql_alert_create` and `nl2sql_subscriptions` create, list, pause and cancel scheduled results and alerts delivered to the chat they were created in, running with the creator's permissions at delivery time.
14
+ - **Data finds people:** daily digests of notable metric changes with their main contributors, automatic attribution in alert messages, metric goals with progress and projection on dashboards, and time-series forecasts (`nl2sql_forecast`, result menu).
15
+ - **Presentation:** PowerPoint and long-image export for dashboards and results, sending the long image to Lark / Telegram, presentation mode, and scheduled sync of results to Lark Bitable.
16
+ - **MCP server:** a local Model Context Protocol endpoint with per-client tokens mapped to owner, member or role scopes, plus the `nl2sql-mcp` stdio bridge for Claude Desktop ([docs/mcp.md](docs/mcp.md)).
17
+ - The **Manage** navigation groups sections into core, *Insight & automation* and *Integrations*.
18
+
19
+ ### Changed
20
+ - Internal: feature modules plug in through `service/features.js` (RPCs, tools, scheduler jobs) and client extension points (`extend.*`); `runQuery` accepts a per-call `maxRows` up to 200,000.
21
+
22
+ These features are tested with local databases and fake IM / Lark servers; real Lark, Telegram, WeCom, DingTalk and MCP clients have not been exercised yet.
23
+
24
+ ## [0.12.2] - 2026-11-12
25
+
26
+ ### Security
27
+ - Row-level rules could be bypassed with MySQL `--` arithmetic (e.g. `WHERE 1--1) OR (1`): the filter injector now uses the dialect-aware lexer and refuses any rewrite where the injected rule is not a top-level conjunct.
28
+ - The SQL guard followed JavaScript's notion of whitespace after `--`; it now follows MySQL exactly, and SQL containing control characters or non-ASCII whitespace outside literals is refused (closed table-policy and system-catalog bypasses).
29
+ - `nl2sql_overview` ran its queries without the caller's row-level rules; it now runs as the caller.
30
+ - A result filtered for one member could be reused (re-charted, drilled, sent) by a member with different row-level rules; results now carry a signature of the rules applied.
31
+ - The bot's data source catalog listed tables hidden by the access policy.
32
+ - Data values could inject mentions (`<at id=all>`) or links into Lark cards and other IM messages; data-derived text is now escaped per platform.
33
+
34
+ ### Fixed
35
+ - Previewing an alert while another alert was saved could delete saved alerts; an alert edited during a check lost its state and re-fired every minute.
36
+ - Bot commands matched any question starting with words such as 自动, 数据源, 切换 or help; commands must now be the whole message. English aliases: `help`, `sources`, `switch <name>`, `auto`, `new`.
37
+ - Bots no longer change the page's current data source when they switch sources.
38
+ - Deleting a channel left alerts, the schema-change notice, scheduled reports and bot conversations pointing at it.
39
+ - Sending failed for titles longer than 100 characters; NULL numbers showed as 0; line breaks inside cells broke tables; changing a channel's type asked for unchanged secrets again; Excel sheet names with emoji could exceed Excel's limit; daily alerts handled daylight-saving transitions wrongly.
40
+ - New file data sources had the description "上传的文件:undefined".
41
+ - Page: the knowledge editor could save into another data source after switching; the Ask page briefly offered the previous data source's questions; dialogs and toasts did not appear inside conversation tool views; free-text dashboard filters could not take a comma; double clicks created duplicate widgets, alerts and knowledge items; unsaved settings were lost without warning; several actions failed silently; number formatting showed 0 for empty values and `10000万` / `1000K`; history labels for Telegram / WeCom / DingTalk bots; dates follow the UI language.
42
+ - Accessibility: focus stays inside dialogs; policy rows work with the keyboard; Chinese punctuation no longer appears in the English UI.
43
+
8
44
  ## [0.12.1] - 2026-11-12
9
45
 
10
46
  ### Fixed
@@ -86,7 +122,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
86
122
 
87
123
  - Redesigned UI; Lark push (Markdown and native charts); access control, feedback loop, dashboard global filters; PostgreSQL / Elasticsearch, SSH tunnels, privacy settings, automatic overviews, scheduled reports; semantic layer, ER graph, scan guard, history and audit, dashboards; first version.
88
124
 
89
- [Unreleased]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.12.1...HEAD
125
+ [Unreleased]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.13.0...HEAD
126
+ [0.13.0]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.12.2...v0.13.0
127
+ [0.12.2]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.12.1...v0.12.2
90
128
  [0.12.1]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.12.0...v0.12.1
91
129
  [0.12.0]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.11.2...v0.12.0
92
130
  [0.11.2]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.11.1...v0.11.2
package/README.md CHANGED
@@ -24,6 +24,7 @@ English · [简体中文](README.zh-CN.md)
24
24
  - [Installation](#installation)
25
25
  - [Quick start](#quick-start)
26
26
  - [IM channels and bots](#im-channels-and-bots)
27
+ - [MCP server](#mcp-server)
27
28
  - [Security model](#security-model)
28
29
  - [Data and privacy](#data-and-privacy)
29
30
  - [Limitations](#limitations)
@@ -47,6 +48,17 @@ English · [简体中文](README.zh-CN.md)
47
48
  - Push to Lark / Feishu, Telegram, WeCom and DingTalk; charts are sent as native card charts, images or tables depending on the platform, and large results come with the full data as an Excel attachment where the platform allows files.
48
49
  - Chat bots that answer @-mentions in group chats without a public URL (Lark long connection, Telegram long polling, WeCom AI bot long connection, DingTalk Stream mode).
49
50
 
51
+ **Data finds people**
52
+ - Data freshness: the latest timestamp of every table, stale-data warnings in answers and alerts when a table stops updating; a daily data-quality patrol with a health score.
53
+ - Cross-source reconciliation: compare two queries on two data sources by key, list rows missing on either side and value differences; save as a daily job that pushes differences with an Excel attachment.
54
+ - Chat subscriptions: "@bot send this to the group every day at 9" or "tell me when withdrawals exceed 500k" creates a subscription or alert delivered to that chat.
55
+ - Daily digest with the notable changes and their main contributors, automatic attribution in alert messages, metric goals with progress and projection, and time-series forecasts.
56
+
57
+ **Present and connect**
58
+ - Export a dashboard or results to PowerPoint or one long image (also sendable to Lark / Telegram); presentation mode for wall screens with auto refresh and rotation.
59
+ - Sync query results to Lark Bitable on a schedule.
60
+ - MCP server: Claude Desktop, Cursor, VS Code and other MCP clients can use the same governed access (see [docs/mcp.md](docs/mcp.md)).
61
+
50
62
  **Govern**
51
63
  - Read-only by construction, table / column access policies with masking, and a full-scan guard.
52
64
  - Members with roles (admin / analyst / viewer), data source scopes and row-level rules for people and group chats on every IM platform.
@@ -126,6 +138,16 @@ In a group chat, members @-mention the bot and ask. The bot knows which data sou
126
138
 
127
139
  > The Telegram, WeCom and DingTalk adapters are tested against local fakes of their APIs and have not yet been verified with real accounts; Lark has been used with real workspaces. Reports from real deployments are welcome.
128
140
 
141
+ ## MCP server
142
+
143
+ Other AI tools — Claude Desktop, Cursor, Cline, VS Code — can query your data sources through the [Model Context Protocol](https://modelcontextprotocol.io/), with the same read-only admission, access policies, member scopes, row-level rules, privacy setting and audit. Enable it under **Admin → MCP access** (off by default; listens on `127.0.0.1:47860` only), create a scoped token (shown once), and paste the generated snippet into your client. HTTP clients use `http://127.0.0.1:47860/mcp` with `Authorization: Bearer <token>`; stdio clients such as Claude Desktop use the bundled bridge:
144
+
145
+ ```bash
146
+ NL2SQL_MCP_TOKEN=<token> npx -y -p @hedeqiang/dsh-nl2sql nl2sql-mcp
147
+ ```
148
+
149
+ Tools: `list_datasources`, `get_schema`, `query`, `query_metric`, `search_knowledge`, `drill`, `explain_change` (plus `reconcile` / `forecast` when available); resources: the schema of every visible data source. See [docs/mcp.md](docs/mcp.md).
150
+
129
151
  ## Security model
130
152
 
131
153
  - **Read-only queries.** Every statement passes a per-dialect lexer (MySQL, PostgreSQL, SQLite, Elasticsearch SQL) that admits only `SELECT` / `SHOW` / `DESCRIBE` / `EXPLAIN`; queries run in read-only transactions with server-side timeouts, PostgreSQL uses the extended protocol (no multi-statement strings), and large results are streamed and truncated.
package/README.zh-CN.md CHANGED
@@ -24,6 +24,7 @@ Agent 编写只读 SQL、执行并在对话中画图。<br>
24
24
  - [安装](#安装)
25
25
  - [快速开始](#快速开始)
26
26
  - [IM 渠道与机器人](#im-渠道与机器人)
27
+ - [MCP 服务](#mcp-服务)
27
28
  - [安全模型](#安全模型)
28
29
  - [数据与隐私](#数据与隐私)
29
30
  - [已知限制](#已知限制)
@@ -47,6 +48,17 @@ Agent 编写只读 SQL、执行并在对话中画图。<br>
47
48
  - 推送到飞书、Telegram、企业微信、钉钉:图表按平台能力用原生卡片图表、图片或表格发送;结果过大时,在能发文件的平台附带完整数据 Excel。
48
49
  - 群聊机器人回答 @提问,不需要公网地址(飞书长连接、Telegram 长轮询、企业微信智能机器人长连接、钉钉 Stream 模式)。
49
50
 
51
+ **让数据找人**
52
+ - 数据新鲜度:记录每张表的最新数据时间,回答近期数据时提示是否滞后,数据断更时告警;每日数据质量巡检并给出健康分。
53
+ - 跨数据源对账:按主键对比两个数据源的两条查询,列出单边缺失和数值差异;可保存为每日任务,有差异时推送并附 Excel。
54
+ - 群里订阅:「@机器人 每天 9 点把这个发到群里」「提现超过 50 万提醒我」直接创建订阅或告警,推送到当前群。
55
+ - 每日数据简报(异常变化及主要来源)、告警消息自动归因、指标目标进度与达成预测、时间序列预测。
56
+
57
+ **展示与集成**
58
+ - 看板或结果导出 PPT、长图(可发送到飞书 / Telegram);大屏模式(全屏、自动刷新、轮播)。
59
+ - 定时把查询结果同步到飞书多维表格。
60
+ - MCP 服务:Claude Desktop、Cursor、VS Code 等 MCP 客户端可以使用同样受控的数据访问(见 [docs/mcp.md](docs/mcp.md))。
61
+
50
62
  **治理**
51
63
  - 只读执行;表 / 字段级访问控制与脱敏;大表全表扫描保护。
52
64
  - 成员:各 IM 平台的个人和群都可以设置角色(管理员 / 分析师 / 只读)、数据源范围和行级权限。
@@ -126,6 +138,16 @@ dsh plugin --profile desktop remove @hedeqiang/dsh-nl2sql
126
138
 
127
139
  > Telegram、企业微信、钉钉适配器目前只在本地模拟的平台接口上测试过,尚未在真实账号上验证;飞书已在真实环境中使用。欢迎反馈实际部署情况。
128
140
 
141
+ ## MCP 服务
142
+
143
+ Claude Desktop、Cursor、Cline、VS Code 等 AI 工具可以通过 [Model Context Protocol](https://modelcontextprotocol.io/) 查询这里的数据源,只读准入、访问控制、成员范围、行级权限、隐私设置与审计同样生效。在 **管理 → MCP 接入** 中启用(默认关闭,只监听 `127.0.0.1:47860`),创建带权限范围的令牌(只显示一次),把页面生成的配置粘贴到客户端即可。HTTP 客户端使用 `http://127.0.0.1:47860/mcp` 并带上 `Authorization: Bearer <令牌>`;Claude Desktop 等 stdio 客户端使用内置的桥接命令:
144
+
145
+ ```bash
146
+ NL2SQL_MCP_TOKEN=<令牌> npx -y -p @hedeqiang/dsh-nl2sql nl2sql-mcp
147
+ ```
148
+
149
+ 工具:`list_datasources`、`get_schema`、`query`、`query_metric`、`search_knowledge`、`drill`、`explain_change`(安装了对账 / 预测功能时还有 `reconcile` / `forecast`);资源:每个可见数据源的表结构。详见 [docs/mcp.md](docs/mcp.md)(英文)。
150
+
129
151
  ## 安全模型
130
152
 
131
153
  - **只读查询。** 每条语句都经过按方言的词法检查(MySQL、PostgreSQL、SQLite、Elasticsearch SQL),只允许 `SELECT` / `SHOW` / `DESCRIBE` / `EXPLAIN`;查询在只读事务中执行并有服务端超时;PostgreSQL 使用扩展协议(不接受多语句);大结果流式读取并截断。
package/analysis.js CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { timeBounds, TIME_RANGES } from './filters.js'
12
+ import { dashDashCommentFollower } from './sql-guard.js'
12
13
 
13
14
  // ------------------------------------------------------------ quoting
14
15
 
@@ -83,7 +84,9 @@ function tokenize(sql, { dialect } = {}) {
83
84
  const c = sql[i]
84
85
  const n = sql[i + 1]
85
86
  if (/\s/.test(c)) { i++; continue }
86
- if ((c === '-' && n === '-') || (c === '#' && dialect !== 'postgres')) { while (i < sql.length && sql[i] !== '\n') i++; continue }
87
+ // MySQL: `--` starts a comment only when followed by a control/space character (or end); other dialects: always.
88
+ const dashComment = c === '-' && n === '-' && (dialect === 'postgres' || dialect === 'sqlite' || dialect === 'elasticsearch' || dashDashCommentFollower(sql[i + 2]))
89
+ if (dashComment || (c === '#' && dialect !== 'postgres' && dialect !== 'sqlite' && dialect !== 'elasticsearch')) { while (i < sql.length && sql[i] !== '\n') i++; continue }
87
90
  if (c === '/' && n === '*') { const e = sql.indexOf('*/', i + 2); i = e < 0 ? sql.length : e + 2; continue }
88
91
  if (c === '\'' || c === '"' || c === '`') {
89
92
  const start = i
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * nl2sql-mcp — stdio bridge to the DeepSeek Harness Data Q&A MCP server.
4
+ *
5
+ * Speaks MCP over stdio (newline-delimited JSON-RPC 2.0) and forwards every
6
+ * message to the plugin's Streamable HTTP endpoint, so stdio-only clients
7
+ * (e.g. Claude Desktop) can use it. Plain ESM, Node ≥ 18, no dependencies.
8
+ *
9
+ * NL2SQL_MCP_URL default http://127.0.0.1:47860/mcp
10
+ * NL2SQL_MCP_TOKEN bearer token created in Data Q&A → MCP access (required)
11
+ *
12
+ * npx -y -p @hedeqiang/dsh-nl2sql nl2sql-mcp
13
+ */
14
+ const URL_ = process.env.NL2SQL_MCP_URL || 'http://127.0.0.1:47860/mcp'
15
+ const TOKEN = process.env.NL2SQL_MCP_TOKEN || ''
16
+ const TIMEOUT_MS = Number(process.env.NL2SQL_MCP_TIMEOUT_MS) || 11 * 60 * 1000
17
+
18
+ const log = (...a) => process.stderr.write(`[nl2sql-mcp] ${a.join(' ')}\n`)
19
+ const write = (obj) => process.stdout.write(`${JSON.stringify(obj)}\n`)
20
+ const errorReply = (id, code, message, data) => write({ jsonrpc: '2.0', id: id ?? null, error: { code, message, ...(data !== undefined ? { data } : {}) } })
21
+
22
+ if (typeof fetch !== 'function') { log('Node.js 18 or newer is required (global fetch).'); process.exit(1) }
23
+ if (!TOKEN) log('NL2SQL_MCP_TOKEN is not set; requests will be rejected (401). Create a token in DeepSeek Harness → Data Q&A → MCP access.')
24
+
25
+ let sessionId
26
+ let protocolVersion
27
+ let pending = 0
28
+ let ended = false
29
+
30
+ /** Header-safe value (Base64 sentinel for non-ASCII), per the Streamable HTTP binding. */
31
+ function headerValue(v) {
32
+ const s = String(v)
33
+ return /^[\x20-\x7e]*$/.test(s) && s.trim() === s && !/^=\?base64\?.*\?=$/.test(s) ? s : `=?base64?${Buffer.from(s, 'utf8').toString('base64')}?=`
34
+ }
35
+
36
+ async function forward(msg) {
37
+ const isRequest = msg && typeof msg === 'object' && 'id' in msg && typeof msg.method === 'string'
38
+ const headers = { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }
39
+ if (TOKEN) headers.authorization = `Bearer ${TOKEN}`
40
+ if (sessionId) headers['mcp-session-id'] = sessionId
41
+ const metaVersion = msg?.params?._meta?.['io.modelcontextprotocol/protocolVersion']
42
+ const version = metaVersion || protocolVersion
43
+ if (version && msg?.method !== 'initialize') headers['mcp-protocol-version'] = version
44
+ if (typeof msg?.method === 'string') headers['mcp-method'] = msg.method
45
+ const name = msg?.params?.name ?? msg?.params?.uri
46
+ if (name !== undefined && metaVersion) headers['mcp-name'] = headerValue(name)
47
+ let res
48
+ try {
49
+ res = await fetch(URL_, { method: 'POST', headers, body: JSON.stringify(msg), signal: AbortSignal.timeout(TIMEOUT_MS) })
50
+ } catch (e) {
51
+ if (isRequest) errorReply(msg.id, -32000, `Cannot reach the nl2sql MCP server at ${URL_} (${e.cause?.code ?? e.message}). Enable it in DeepSeek Harness → Data Q&A → MCP access and keep DeepSeek Harness running.`)
52
+ return
53
+ }
54
+ const sid = res.headers.get('mcp-session-id')
55
+ if (sid) sessionId = sid
56
+ const type = res.headers.get('content-type') ?? ''
57
+ const text = await res.text()
58
+ if (res.status === 202 || !text) {
59
+ if (isRequest) errorReply(msg.id, -32000, `HTTP ${res.status} without a response body`)
60
+ return
61
+ }
62
+ const emit = (body) => {
63
+ if (body?.result?.protocolVersion && msg?.method === 'initialize') protocolVersion = body.result.protocolVersion
64
+ write(body)
65
+ }
66
+ if (type.includes('text/event-stream')) {
67
+ // Request-scoped SSE stream: forward every `data:` event.
68
+ for (const block of text.split(/\r?\n\r?\n/)) {
69
+ const data = block.split(/\r?\n/).filter(l => l.startsWith('data:')).map(l => l.slice(5).replace(/^ /, '')).join('\n')
70
+ if (data) { try { emit(JSON.parse(data)) } catch {} }
71
+ }
72
+ return
73
+ }
74
+ let body
75
+ try { body = JSON.parse(text) } catch {
76
+ if (isRequest) errorReply(msg.id, -32000, `HTTP ${res.status}: ${text.slice(0, 200)}`)
77
+ return
78
+ }
79
+ if (Array.isArray(body)) { for (const b of body) emit(b); return }
80
+ if (body && typeof body === 'object' && body.jsonrpc === '2.0') {
81
+ // Errors without an id (401 / 403) belong to this request.
82
+ if (isRequest && (body.id === null || body.id === undefined) && body.error) body.id = msg.id
83
+ if (res.status === 404 && sessionId && msg?.method !== 'initialize') sessionId = undefined
84
+ emit(body)
85
+ } else if (isRequest) errorReply(msg.id, -32000, `HTTP ${res.status}`)
86
+ }
87
+
88
+ let buffer = ''
89
+ process.stdin.setEncoding('utf8')
90
+ process.stdin.on('data', (chunk) => {
91
+ buffer += chunk
92
+ let i
93
+ while ((i = buffer.indexOf('\n')) >= 0) {
94
+ const line = buffer.slice(0, i).trim()
95
+ buffer = buffer.slice(i + 1)
96
+ if (!line) continue
97
+ let msg
98
+ try { msg = JSON.parse(line) } catch { errorReply(null, -32700, 'Parse error'); continue }
99
+ pending++
100
+ forward(msg).catch(e => log('forward failed:', e.message)).finally(() => { pending--; if (ended && !pending) process.exit(0) })
101
+ }
102
+ })
103
+ process.stdin.on('end', () => { ended = true; if (!pending) process.exit(0) })
@@ -48,7 +48,7 @@
48
48
  */
49
49
  import { createHmac } from 'node:crypto'
50
50
  import { ChannelError, postJson, request } from './http.js'
51
- import { resultMeta, isKpi, kpiText, fmtNum, cellText, toPlainMarkdown, mentionsAll, splitText, MD_TABLE, parseMdTable } from './message.js'
51
+ import { resultMeta, isKpi, kpiText, fmtNum, cellText, defangMd, toPlainMarkdown, mentionsAll, splitText, MD_TABLE, parseMdTable } from './message.js'
52
52
 
53
53
  export const OAPI = 'https://oapi.dingtalk.com'
54
54
  export const API = 'https://api.dingtalk.com'
@@ -153,11 +153,12 @@ export function dingApiError(json, status) {
153
153
  // ------------------------------------------------------------- rendering
154
154
 
155
155
  /** A result as text lines (DingTalk markdown has no tables). */
156
- export function resultAsDingText(r, maxRows = 10, maxCols = 6) {
156
+ export function resultAsDingText(r, maxRows = 10, maxCols = 6, { escape = defangMd } = {}) {
157
157
  const cols = r.columns.slice(0, maxCols)
158
158
  const rows = r.rows.slice(0, maxRows)
159
- const cell = (c, v) => (c.kind === 'number' ? fmtNum(v) : cellText(v, 30)).replace(/\|/g, '|')
160
- const out = [`**${cols.map(c => c.name).join(' | ')}**`, '', ...rows.map((row, i) => `${i + 1}. ${cols.map((c, j) => cell(c, row[j])).join(' | ')}`)]
159
+ // NULL → '—' (not 0); line breaks would split a numbered row; data must not form links / tags.
160
+ const cell = (c, v) => escape((c.kind === 'number' ? fmtNum(v) : cellText(v, 30)) || '—').replace(/\|/g, '|')
161
+ const out = [`**${cols.map(c => escape(c.name)).join(' | ')}**`, '', ...rows.map((row, i) => `${i + 1}. ${cols.map((c, j) => cell(c, row[j])).join(' | ')}`)]
161
162
  const total = r.rowCount ?? r.rows.length
162
163
  if (total > rows.length) out.push('', `> 共 ${total} 行,仅显示前 ${rows.length} 行;完整数据可在数据问答页面查看或导出 Excel`)
163
164
  return out.join('\n')
@@ -167,7 +168,8 @@ export function resultAsDingText(r, maxRows = 10, maxCols = 6) {
167
168
  export function stripTables(md) {
168
169
  return String(md ?? '').replace(MD_TABLE, (m, lead, block) => {
169
170
  const t = parseMdTable(block)
170
- return `${lead}${resultAsDingText({ ...t, rowCount: t.rows.length }, 50, 8)}\n`
171
+ // Tables authored in the message text keep their Markdown (links etc.).
172
+ return `${lead}${resultAsDingText({ ...t, rowCount: t.rows.length }, 50, 8, { escape: (x) => String(x ?? '') })}\n`
171
173
  })
172
174
  }
173
175
 
@@ -179,18 +181,18 @@ export function toDingMarkdown(md) {
179
181
  /** OutboundMessage → { title, text } for msgtype markdown / sampleMarkdown. */
180
182
  export function renderMarkdown(message, { maxRows = 10 } = {}) {
181
183
  const parts = []
182
- if (message.title) parts.push(`### ${message.title}`)
184
+ if (message.title) parts.push(`### ${defangMd(message.title)}`)
183
185
  if (message.text) parts.push(toDingMarkdown(message.text))
184
186
  for (const r of (message.results ?? []).slice(0, 10)) {
185
- const lines = [`#### ${r.title || '查询结果'}`, `> ${resultMeta(r)}`, '']
186
- if (r.error) lines.push(r.error)
187
+ const lines = [`#### ${defangMd(r.title || '查询结果')}`, `> ${resultMeta(r)}`, '']
188
+ if (r.error) lines.push(defangMd(r.error))
187
189
  else if (!r.rows?.length) lines.push('无数据')
188
190
  else if (isKpi(r)) lines.push(kpiText(r, { separator: '\n\n' }))
189
191
  else lines.push(resultAsDingText(r, maxRows))
190
192
  if (r.sql && message.includeSql) lines.push('', '```sql\n' + r.sql.slice(0, 1500) + '\n```')
191
193
  parts.push(lines.join('\n'))
192
194
  }
193
- if (message.footer) parts.push(toDingMarkdown(message.footer))
195
+ if (message.footer) parts.push(defangMd(toDingMarkdown(message.footer)))
194
196
  const all = message.mentionAll || mentionsAll(message.text) || mentionsAll(message.footer)
195
197
  if (all && !parts.some(p => p.includes('@所有人'))) parts.push('@所有人')
196
198
  const title = String(message.title || '数据问答').slice(0, 60)
package/channels/lark.js CHANGED
@@ -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, needsAttachment, num, omittedNote, parseMdTable, renderChartPng, resultMeta } from './message.js'
19
+ import { MD_TABLE, cellText, chartOption, chartable, dualAxis, escapeLarkMd, kpiText, isKpi, needsAttachment, num, oneLine, omittedNote, parseMdTable, renderChartPng, resultMeta } from './message.js'
20
20
 
21
21
  export { ChannelError, ChannelError as ImError, chartOption, chartable, dualAxis, renderChartPng }
22
22
 
@@ -704,7 +704,13 @@ function tableElement(result, maxRows) {
704
704
  row_height: 'low',
705
705
  header_style: { background_style: 'grey', bold: true, text_size: 'normal' },
706
706
  columns: cols.map((c, i) => ({ name: `c${i}`, display_name: c.name, data_type: c.kind === 'number' ? 'number' : 'text', ...(c.kind === 'number' ? { format: { separator: true } } : {}), width: 'auto' })),
707
- rows: result.rows.slice(0, maxRows).map(r => Object.fromEntries(cols.map((c, i) => [`c${i}`, c.kind === 'number' && Number.isFinite(Number(r[i])) ? Number(r[i]) : cellText(r[i])]))),
707
+ // NULL / '' numbers stay empty (Number(null) would show 0); line breaks would break rows.
708
+ rows: result.rows.slice(0, maxRows).map(r => Object.fromEntries(cols.flatMap((c, i) => {
709
+ const v = r[i]
710
+ if (c.kind !== 'number') return [[`c${i}`, oneLine(cellText(v))]]
711
+ if (v === null || v === undefined || v === '') return [] // empty cell, not 0
712
+ return [[`c${i}`, Number.isFinite(Number(v)) ? Number(v) : oneLine(cellText(v))]]
713
+ }))),
708
714
  }
709
715
  }
710
716
 
@@ -840,15 +846,17 @@ export function buildCard(message, { maxRows = 50, charts = true, actions = true
840
846
  for (const r of message.results ?? []) {
841
847
  if (elements.length) elements.push({ tag: 'hr' })
842
848
  if (r.error) {
843
- elements.push({ tag: 'markdown', content: `**${r.title || '查询'}**\n<font color='red'>${r.error}</font>` })
849
+ elements.push({ tag: 'markdown', content: `**${escapeLarkMd(r.title || '查询')}**\n<font color='red'>${escapeLarkMd(r.error)}</font>` })
844
850
  continue
845
851
  }
846
- elements.push({ tag: 'markdown', content: [`**${r.title || '查询结果'}**`, `<font color='grey'>${resultMeta(r)}</font>`].join(' ') })
852
+ // Everything data-derived (titles, values, errors, footer) is escaped: a cell like
853
+ // `<at id=all></at>` or `[x](https://…)` must show as text, not mention or link.
854
+ elements.push({ tag: 'markdown', content: [`**${escapeLarkMd(r.title || '查询结果')}**`, `<font color='grey'>${resultMeta(r, { escape: escapeLarkMd })}</font>`].join(' ') })
847
855
  const nativeChart = charts && chartCount < 5 && chartable(r) ? chartElement(r) : undefined
848
856
  if (r.rows.length === 0) {
849
857
  elements.push({ tag: 'markdown', content: '<font color=\'grey\'>无数据</font>' })
850
858
  } else if (isKpi(r)) {
851
- elements.push({ tag: 'markdown', content: kpiText(r) })
859
+ elements.push({ tag: 'markdown', content: kpiText(r, { escape: escapeLarkMd }) })
852
860
  } else if (nativeChart) {
853
861
  elements.push(nativeChart)
854
862
  chartCount++
@@ -860,7 +868,7 @@ export function buildCard(message, { maxRows = 50, charts = true, actions = true
860
868
  const note = omittedNote(r, Math.min(maxRows, r.rows.length), { attached: Boolean(r.attached) })
861
869
  if (note) elements.push({ tag: 'markdown', content: `<font color='grey'>${note}</font>`, text_size: 'notation' })
862
870
  } else {
863
- elements.push({ tag: 'markdown', content: r.rows.slice(0, 5).map(row => row.slice(0, 6).map(v => cellText(v)).join(' | ')).join('\n') })
871
+ elements.push({ tag: 'markdown', content: r.rows.slice(0, 5).map(row => row.slice(0, 6).map(v => escapeLarkMd(cellText(v))).join(' | ')).join('\n') })
864
872
  const note = omittedNote(r, Math.min(5, r.rows.length), { attached: Boolean(r.attached) })
865
873
  if (note) elements.push({ tag: 'markdown', content: `<font color='grey'>${note}</font>`, text_size: 'notation' })
866
874
  }
@@ -871,7 +879,7 @@ export function buildCard(message, { maxRows = 50, charts = true, actions = true
871
879
  }
872
880
  if (r.sql && message.includeSql) elements.push({ tag: 'markdown', content: `\`\`\`sql\n${r.sql.slice(0, 1500)}\n\`\`\`` })
873
881
  }
874
- if (message.footer) elements.push({ tag: 'markdown', content: `<font color='grey'>${message.footer}</font>`, text_size: 'notation' })
882
+ if (message.footer) elements.push({ tag: 'markdown', content: `<font color='grey'>${escapeLarkMd(message.footer)}</font>`, text_size: 'notation' })
875
883
  const card = {
876
884
  schema: '2.0',
877
885
  config: { wide_screen_mode: true, summary: { content: String(message.title).slice(0, 60) } },
@@ -894,10 +902,10 @@ export function legacyCard(card) {
894
902
  const md = card.body.elements.map(e => {
895
903
  if (e.tag === 'markdown') return e.content
896
904
  if (e.tag === 'hr') return '---'
897
- if (e.tag === 'table') return [e.columns.map(c => c.display_name).join(' | '), ...e.rows.slice(0, 10).map(r => e.columns.map(c => cellText(r[c.name])).join(' | '))].join('\n')
905
+ if (e.tag === 'table') return [e.columns.map(c => escapeLarkMd(c.display_name)).join(' | '), ...e.rows.slice(0, 10).map(r => e.columns.map(c => escapeLarkMd(cellText(r[c.name]))).join(' | '))].join('\n')
898
906
  if (e.tag === 'chart') {
899
907
  const v = e.chart_spec.data?.values ?? e.chart_spec.data?.[0]?.values ?? []
900
- return v.slice(0, 10).map(x => Object.values(x).map(y => cellText(y)).join(' | ')).join('\n')
908
+ return v.slice(0, 10).map(x => Object.values(x).map(y => escapeLarkMd(cellText(y))).join(' | ')).join('\n')
901
909
  }
902
910
  return ''
903
911
  }).filter(Boolean).join('\n\n')
@@ -14,8 +14,9 @@ export function num(v) {
14
14
  return v === null || v === undefined || v === '' ? null : Number(v)
15
15
  }
16
16
 
17
- /** Compact number: 1.23亿 / 4.56万 / integers as-is / two decimals. */
17
+ /** Compact number: 1.23亿 / 4.56万 / integers as-is / two decimals. NULL / '' → ''. */
18
18
  export function fmtNum(v) {
19
+ if (v === null || v === undefined || v === '') return ''
19
20
  const n = Number(v)
20
21
  if (!Number.isFinite(n)) return String(v ?? '')
21
22
  if (Math.abs(n) >= 1e8) return `${(n / 1e8).toFixed(2)}亿`
@@ -30,10 +31,44 @@ export function cellText(v, max = 80) {
30
31
  return s.length > max ? `${s.slice(0, max - 1)}…` : s
31
32
  }
32
33
 
33
- /** A KPI-style result (one row) as `**col**:value | …` Markdown. */
34
- export function kpiText(result, { separator = ' | ', bold = true } = {}) {
34
+ /** Line breaks (CR, LF, CRLF) → one space: keeps table rows and inline Markdown on one line. */
35
+ export function oneLine(s) {
36
+ return String(s ?? '').replace(/\r\n?|\n/g, ' ')
37
+ }
38
+
39
+ /**
40
+ * Lark card Markdown: data-derived text (cell values, column names, result titles,
41
+ * errors, footers) as literal text. Lark documents HTML numeric entities for
42
+ * characters that would trigger Markdown / tags (`<at>`, `<font>`, links, emphasis).
43
+ * https://open.larksuite.com/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-components/content-components/rich-text
44
+ */
45
+ export function escapeLarkMd(s) {
46
+ return oneLine(s)
47
+ .replace(/&(?=#?\w+;)/g, '&#38;')
48
+ .replace(/[<>[\]*_~`\\]/g, ch => `&#${ch.charCodeAt(0)};`)
49
+ }
50
+
51
+ /**
52
+ * Neutralize link / tag syntax in data-derived text for platforms whose Markdown
53
+ * has no reliable escape (WeCom, DingTalk): `<` `>` `[` `]` become full-width look-alikes,
54
+ * so values cannot create links, images, `<@user>` mentions or `<font>` tags.
55
+ */
56
+ export function defangMd(s) {
57
+ return oneLine(s).replace(/[<>[\]]/g, ch => ({ '<': '<', '>': '>', '[': '[', ']': ']' })[ch])
58
+ }
59
+
60
+ /**
61
+ * A KPI-style result (one row) as `**col**:value | …` Markdown. NULL shows as '—'.
62
+ * `escape` is applied to data-derived text (column names and values).
63
+ */
64
+ export function kpiText(result, { separator = ' | ', bold = true, escape = defangMd } = {}) {
35
65
  const row = result.rows?.[0] ?? []
36
- return result.columns.map((c, i) => `${bold ? `**${c.name}**` : c.name}:${c.kind === 'number' ? fmtNum(row[i]) : cellText(row[i])}`).join(separator)
66
+ return result.columns.map((c, i) => {
67
+ const raw = c.kind === 'number' ? fmtNum(row[i]) : cellText(row[i])
68
+ const value = raw === '' ? '—' : escape(oneLine(raw))
69
+ const name = escape(oneLine(c.name))
70
+ return `${bold ? `**${name}**` : name}:${value}`
71
+ }).join(separator)
37
72
  }
38
73
 
39
74
  /** True when the result should be shown as KPI text rather than a table/chart. */
@@ -59,8 +94,8 @@ export function needsAttachment(result, shown) {
59
94
  return (result.rowCount ?? result.rows.length) > shown
60
95
  }
61
96
 
62
- export function resultMeta(result) {
63
- return `${result.rowCount ?? result.rows?.length ?? 0} 行${result.truncated ? '(已截断)' : ''}${result.filters?.length ? ` · 已筛选:${result.filters.map(f => f.description).join(';')}` : ''}`
97
+ export function resultMeta(result, { escape = defangMd } = {}) {
98
+ return `${result.rowCount ?? result.rows?.length ?? 0} 行${result.truncated ? '(已截断)' : ''}${result.filters?.length ? ` · 已筛选:${result.filters.map(f => escape(f.description)).join(';')}` : ''}`
64
99
  }
65
100
 
66
101
  // ------------------------------------------------------- chart rendering
@@ -207,7 +242,7 @@ export function toPlainText(md) {
207
242
  }
208
243
 
209
244
  function mdCell(v, max = 40) {
210
- return cellText(v, max).replace(/\|/g, '\\|').replace(/\n/g, ' ')
245
+ return defangMd(cellText(v, max)).replace(/\|/g, '\\|')
211
246
  }
212
247
 
213
248
  /** A result as a Markdown table (≤ maxRows rows, ≤ maxCols columns), with a "… N more" note when cut. */
@@ -229,7 +264,7 @@ export function resultAsMarkdownTable(result, maxRows = 10, { maxCols = 8 } = {}
229
264
  export function resultAsText(result, maxRows = 10, { maxCols = 6 } = {}) {
230
265
  const cols = (result.columns ?? []).slice(0, maxCols)
231
266
  const rows = (result.rows ?? []).slice(0, maxRows)
232
- const lines = [cols.map(c => c.name).join(' | '), ...rows.map(r => cols.map((c, i) => (c.kind === 'number' ? fmtNum(r[i]) : cellText(r[i], 30))).join(' | '))]
267
+ const lines = [cols.map(c => c.name).join(' | '), ...rows.map(r => cols.map((c, i) => oneLine(c.kind === 'number' ? fmtNum(r[i]) : cellText(r[i], 30))).join(' | '))]
233
268
  const total = result.rowCount ?? result.rows?.length ?? 0
234
269
  if (total > rows.length) lines.push(`…共 ${total} 行`)
235
270
  return lines.join('\n')
@@ -243,11 +278,12 @@ export function resultAsText(result, maxRows = 10, { maxCols = 6 } = {}) {
243
278
  export function messageAsMarkdown(message, { markdown = 'basic', maxRows = 10, sqlMax = 1500 } = {}) {
244
279
  const clean = markdown === 'full' ? normalizeMarkdown : markdown === 'none' ? toPlainText : toPlainMarkdown
245
280
  const parts = []
246
- if (message.title) parts.push(markdown === 'none' ? String(message.title) : `**${message.title}**`)
281
+ if (message.title) parts.push(markdown === 'none' ? oneLine(message.title) : `**${defangMd(message.title)}**`)
247
282
  if (message.text) parts.push(clean(message.text))
248
283
  for (const r of message.results ?? []) {
249
- const head = markdown === 'none' ? `${r.title || '查询结果'}` : `**${r.title || '查询结果'}**`
250
- if (r.error) { parts.push(`${head}\n${r.error}`); continue }
284
+ // Result titles, errors and cells are data-derived: no Markdown / tags from them.
285
+ const head = markdown === 'none' ? oneLine(r.title || '查询结果') : `**${defangMd(r.title || '查询结果')}**`
286
+ if (r.error) { parts.push(`${head}\n${markdown === 'none' ? r.error : defangMd(r.error)}`); continue }
251
287
  const lines = [`${head} ${resultMeta(r)}`]
252
288
  if (!r.rows?.length) lines.push('无数据')
253
289
  else if (isKpi(r)) lines.push(kpiText(r, { bold: markdown !== 'none' }))
@@ -255,7 +291,7 @@ export function messageAsMarkdown(message, { markdown = 'basic', maxRows = 10, s
255
291
  if (r.sql && message.includeSql) lines.push(markdown === 'none' ? r.sql.slice(0, sqlMax) : `\`\`\`sql\n${r.sql.slice(0, sqlMax)}\n\`\`\``)
256
292
  parts.push(lines.join('\n'))
257
293
  }
258
- if (message.footer) parts.push(clean(message.footer))
294
+ if (message.footer) parts.push(markdown === 'none' ? toPlainText(message.footer) : defangMd(toPlainMarkdown(message.footer)))
259
295
  return parts.join('\n\n')
260
296
  }
261
297
 
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { randomBytes } from 'node:crypto'
20
20
  import { ChannelError, request } from './http.js'
21
- import { renderChartPng as defaultRenderChartPng, chartable, resultMeta, isKpi, kpiText, resultAsMarkdownTable, toPlainMarkdown } from './message.js'
21
+ import { renderChartPng as defaultRenderChartPng, chartable, oneLine, resultMeta, isKpi, kpiText, resultAsMarkdownTable, toPlainMarkdown } from './message.js'
22
22
 
23
23
  export const DEFAULT_API_BASE = 'https://api.telegram.org'
24
24
  export const TEXT_LIMIT = 4096
@@ -280,12 +280,19 @@ export function inlineKeyboard(spec) {
280
280
 
281
281
  // ------------------------------------------------------------ rendering
282
282
 
283
+ /**
284
+ * Data-derived text in Telegram Markdown: HTML is escaped by the converter anyway, so only
285
+ * link syntax and line breaks are neutralized (`[x](url)` must not become a link).
286
+ */
287
+ const defangMd = (s) => oneLine(s).replace(/[[\]]/g, ch => (ch === '[' ? '[' : ']'))
288
+
283
289
  function resultMarkdown(r, { includeSql, maxRows = 20 } = {}) {
284
- const head = `**${r.title || '查询结果'}** ${resultMeta(r)}`
285
- if (r.error) return `${head}\n${r.error}`
290
+ // Data-derived text (title, error, values) is defanged: no links / tags / mentions from data.
291
+ const head = `**${defangMd(r.title || '查询结果')}** ${resultMeta(r)}`
292
+ if (r.error) return `${head}\n${defangMd(r.error)}`
286
293
  const lines = [head]
287
294
  if (!r.rows?.length) lines.push('无数据')
288
- else if (isKpi(r)) lines.push(kpiText(r, { separator: '\n' }))
295
+ else if (isKpi(r)) lines.push(kpiText(r, { separator: '\n', escape: defangMd }))
289
296
  else lines.push(resultAsMarkdownTable(r, maxRows, { maxCols: 6 }))
290
297
  if (r.sql && includeSql) lines.push('```sql\n' + r.sql.slice(0, 1500) + '\n```')
291
298
  return lines.join('\n')
@@ -297,7 +304,7 @@ function resultMarkdown(r, { includeSql, maxRows = 20 } = {}) {
297
304
  */
298
305
  export function planMessage(message, { buttons = true } = {}) {
299
306
  const segs = []
300
- const head = [message.title ? `**${message.title}**` : '', message.text ?? ''].filter(Boolean).join('\n\n')
307
+ const head = [message.title ? `**${defangMd(message.title)}**` : '', message.text ?? ''].filter(Boolean).join('\n\n')
301
308
  let text = head
302
309
  const flush = (keyboard) => { if (text.trim() || keyboard) segs.push({ kind: 'text', md: text, ...(keyboard ? { keyboard } : {}) }); text = '' }
303
310
  for (const r of (message.results ?? []).slice(0, 10)) {
@@ -310,7 +317,7 @@ export function planMessage(message, { buttons = true } = {}) {
310
317
  text = [text, resultMarkdown(r, { includeSql: message.includeSql })].filter(s => s.trim()).join('\n\n')
311
318
  if (keyboard) flush(keyboard)
312
319
  }
313
- if (message.footer) text = [text, message.footer].filter(s => s.trim()).join('\n\n')
320
+ if (message.footer) text = [text, defangMd(toPlainMarkdown(message.footer, { mentionAll: '' }))].filter(s => s.trim()).join('\n\n')
314
321
  flush()
315
322
  return segs
316
323
  }