@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 +39 -1
- package/README.md +22 -0
- package/README.zh-CN.md +22 -0
- package/analysis.js +4 -1
- package/bin/nl2sql-mcp.mjs +103 -0
- package/channels/dingtalk.js +11 -9
- package/channels/lark.js +17 -9
- package/channels/message.js +48 -12
- package/channels/telegram.js +13 -6
- package/channels/wecom.js +10 -9
- package/cordis.patch.yml +4 -0
- package/docs/mcp.md +119 -0
- package/filters.js +87 -33
- package/index.js +21 -3
- package/lib/client.js +2122 -153
- package/package.json +6 -1
- package/service/alerts.js +60 -26
- package/service/bots.js +38 -12
- package/service/channels.js +26 -5
- package/service/dashboards.js +5 -2
- package/service/datasources.js +16 -3
- package/service/features.js +21 -0
- package/service/forecast.js +252 -0
- package/service/freshness.js +747 -0
- package/service/mcp.js +677 -0
- package/service/pptx.js +362 -0
- package/service/present.js +516 -0
- package/service/proactive.js +531 -0
- package/service/query.js +39 -10
- package/service/reconcile.js +565 -0
- package/service/shared.js +27 -0
- package/service/subscriptions.js +563 -0
- package/service/uploads.js +4 -1
- package/sql-guard.js +33 -2
- package/xlsx.js +12 -2
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.
|
|
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
|
-
|
|
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) })
|
package/channels/dingtalk.js
CHANGED
|
@@ -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
|
-
|
|
160
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
package/channels/message.js
CHANGED
|
@@ -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
|
-
/**
|
|
34
|
-
export function
|
|
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, '&')
|
|
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) =>
|
|
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, '\\|')
|
|
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' ?
|
|
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
|
-
|
|
250
|
-
|
|
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(
|
|
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
|
|
package/channels/telegram.js
CHANGED
|
@@ -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
|
-
|
|
285
|
-
|
|
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
|
}
|