From 17f774b442141a3da20c377dc4da1b33f5f465f8 Mon Sep 17 00:00:00 2001 From: jay <1569941651@qq.com> Date: Mon, 12 Oct 2026 20:34:42 +0800 Subject: [PATCH] fix(dingtalk): preserve Markdown table cell boundaries (#6741) * fix(dingtalk): preserve Markdown table cell boundaries DingTalk's sampleMarkdown fallback re-splits table rows on every pipe, so an escaped pipe (\|) inside a cell is read as a column separator and every value after it shifts one column to the left. The strip("|") wrapper also removes an empty leading or trailing cell, dropping that value's column label. Split rows on unescaped pipes only, and drop at most one outer border cell per side, so escaped pipes and empty edge cells stay under the right label. Adds six parameterized regressions plus one full fallback-adapter regression; all seven fail on the base commit and pass after the fix. * docs(dingtalk): document escaped-backslash boundary in table row splitter Document the residual boundary case flagged in review: the single-character lookbehind in _split_markdown_table_row cannot distinguish an escaped pipe (\|) from an escaped backslash followed by a real separator (\\|), so a cell ending in a literal backslash absorbs the next cell. Full GFM backslash counting is out of scope for this helper. --- README.md | 2 ++ backend/app/channels/AGENTS.md | 2 +- backend/app/channels/dingtalk.py | 26 ++++++++++++++++++++++++-- backend/tests/test_dingtalk_channel.py | 20 ++++++++++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 28cc1778f4..9fe5213e7b 100644 --- a/README.md +++ b/README.md @@ -1110,6 +1110,8 @@ Scheduled-task result and lifecycle notices can also be pushed to a bound Feishu 3. Copy the `Client ID` and `Client Secret`, set `DINGTALK_CLIENT_ID` and `DINGTALK_CLIENT_SECRET` in `.env`, and enable the channel in `config.yaml`. 4. *(Optional)* To enable streaming AI Card replies (typewriter effect), create an **AI Card** template on the [DingTalk Card Platform](https://open.dingtalk.com/document/dingstart/typewriter-effect-streaming-ai-card), then set `card_template_id` in `config.yaml` to the template ID. You also need to apply for the `Card.Streaming.Write` and `Card.Instance.Write` permissions. +DingTalk's Markdown fallback displays tables as labeled blockquotes, preserving escaped pipes (`\|`) within cells and empty leading or trailing cells so values stay under the correct column labels. + When DeerFlow runs in Docker Compose, IM channels execute inside the `gateway` container. In that case, do not point `channels.langgraph_url` or `channels.gateway_url` at `localhost`; use container service names such as `http://gateway:8001/api` and `http://gateway:8001`, or set `DEER_FLOW_CHANNELS_LANGGRAPH_URL` and `DEER_FLOW_CHANNELS_GATEWAY_URL`. diff --git a/backend/app/channels/AGENTS.md b/backend/app/channels/AGENTS.md index b0689649cf..6b53c985ed 100644 --- a/backend/app/channels/AGENTS.md +++ b/backend/app/channels/AGENTS.md @@ -38,7 +38,7 @@ Cursor writes drain before cancellation; token expiry drains cursor/auth clearin 6b. GitHub chat (`ChannelRunPolicy.fire_and_forget=True`): `runs.create()` returns once the run is `pending`; the manager does not wait for the final state and does not publish an outbound; the agent posts its own reply via sandbox `gh`. `ConflictError` on a busy thread still trips the standard `THREAD_BUSY_MESSAGE` path (log-only on GitHub); with `buffer_followups_on_busy=True` (GitHub's default) the triggering message is captured into a per-thread buffer so a concurrent comment is not silently dropped (see "Follow-up buffering while busy"). 7. Feishu patches one card per source (`config.update_multi=true`), with topic previews and queued → running → final states. Opt-in cards show context/question; bounded tokens preserve request ID/source. Originator/option checks consume once. Card replies keep bound aliases in fields/metadata; ordinary replies preserve `/new`. Accepted intake retires only that card's buttons/text hint before terminal PATCH; rejected intake keeps text fallback. 8. Telegram streaming: the "Working on it..." placeholder message is registered as the stream target; non-final updates `editMessageText` it in place (channel-side throttle: 1s in private chats, 3s in groups due to Telegram's 20 msg/min group cap; 4096-char truncation; rate-limited updates dropped); the final update performs the last edit and splits >4096 texts into follow-up messages -9. DingTalk AI Card mode (when `card_template_id` configured): `runs.stream()` → create card with initial text → stream updates via `PUT /v1.0/card/streaming` → finalize on `is_final=True`. Falls back to `sampleMarkdown` if card creation or streaming fails +9. DingTalk AI Cards (`card_template_id`): `runs.stream()` → create card with initial text → `PUT /v1.0/card/streaming` → finalize (`is_final=True`). Creation/streaming failure uses `sampleMarkdown`; tables preserve escaped pipes and empty edge cells. 10. For commands (`/new`, `/status`, `/models`, `/model`, `/memory`, `/goal`, `/agent`, `/help`): handle locally or query Gateway API. `/agent list` reads the effective owner's Custom Agents; `/agent use ` validates in the same owner bucket, creates a new thread, and persists `channel_agent_name` in its metadata. A Custom Agent writes canonical `metadata.agent_name` so thread-search routes Web continuation to `/workspace/agents//chats/`; `lead_agent` omits it and stays on the ordinary chat route. `/model ` persists `channel_model_name` in the *current* thread's metadata (in place, no fresh thread); `/model` reports the effective model, `/model default` clears it (stale pins self-clear). The `channel_agent_name` cache reloads after restart before a resumed turn routes. An explicit selection also normalizes `agent_name` across the top-level run context and both RunnableConfig carriers (`context`, `configurable`) — cleared for `lead_agent` — before Gateway's `setdefault` merge, so stale channel defaults cannot silently win. diff --git a/backend/app/channels/dingtalk.py b/backend/app/channels/dingtalk.py index 8373e8e302..0814035e1c 100644 --- a/backend/app/channels/dingtalk.py +++ b/backend/app/channels/dingtalk.py @@ -104,6 +104,28 @@ _HORIZONTAL_RULE_RE = re.compile(r"^-{3,}$", re.MULTILINE) _TABLE_SEPARATOR_RE = re.compile(r"^\|[-:| ]+\|$", re.MULTILINE) +def _split_markdown_table_row(row: str) -> list[str]: + r"""Keep escaped pipes and empty cells when removing the outer borders. + + Known boundary: the single-character lookbehind in the split pattern + cannot distinguish an escaped pipe (``\|``) from an escaped backslash + immediately followed by a real separator (``\\|``). A cell ending in a + literal backslash therefore absorbs the next cell: ``| foo\\ | bar |`` + splits as one cell instead of two. GFM counts consecutive backslashes + for this reason; doing so is out of scope here — this helper is + intentionally not a general Markdown parser. + """ + # ``(? str: # DingTalk sampleMarkdown does not render pipe-delimited tables. lines = text.split("\n") @@ -113,10 +135,10 @@ def _convert_markdown_table(text: str) -> str: line = lines[i] # Detect table: header row followed by separator row if i + 1 < len(lines) and line.strip().startswith("|") and _TABLE_SEPARATOR_RE.match(lines[i + 1].strip()): - headers = [h.strip() for h in line.strip().strip("|").split("|")] + headers = _split_markdown_table_row(line) i += 2 # skip header + separator while i < len(lines) and lines[i].strip().startswith("|"): - cells = [c.strip() for c in lines[i].strip().strip("|").split("|")] + cells = _split_markdown_table_row(lines[i]) for h, c in zip(headers, cells): result.append(f"> **{h}**: {c}") result.append("") diff --git a/backend/tests/test_dingtalk_channel.py b/backend/tests/test_dingtalk_channel.py index 7c64bf30ff..c9fdeddcfc 100644 --- a/backend/tests/test_dingtalk_channel.py +++ b/backend/tests/test_dingtalk_channel.py @@ -1196,6 +1196,26 @@ class TestAdaptMarkdownForDingtalk: class TestConvertMarkdownTable: + @pytest.mark.parametrize( + ("header", "row", "expected"), + [ + ("| Rule | Meaning |", r"| foo\|bar | alternative |", "> **Rule**: foo\\|bar\n> **Meaning**: alternative\n"), + (r"| Rule\|Pattern | Meaning |", "| regex | alternative |", "> **Rule\\|Pattern**: regex\n> **Meaning**: alternative\n"), + ("| Rule | Meaning |", "|| alternative |", "> **Rule**: \n> **Meaning**: alternative\n"), + ("| Rule | Meaning |", "| regex ||", "> **Rule**: regex\n> **Meaning**: \n"), + ("| Rule | Meaning |", r"| regex | value\|", "> **Rule**: regex\n> **Meaning**: value\\|\n"), + ("| Rule | Meaning |", r"| foo\|bar\|baz | alternative |", "> **Rule**: foo\\|bar\\|baz\n> **Meaning**: alternative\n"), + ], + ids=["escaped-body-pipe", "escaped-header-pipe", "leading-empty-cell", "trailing-empty-cell", "escaped-final-pipe", "multiple-escaped-pipes"], + ) + def test_cell_boundaries_preserved(self, header, row, expected): + text = f"{header}\n|---|---|\n{row}" + assert _convert_markdown_table(text) == expected + + def test_fallback_adapter_preserves_table_values(self): + text = "| Rule | Meaning |\n|---|---|\n| `foo\\|bar` | alternative |" + assert _adapt_markdown_for_dingtalk(text) == "> **Rule**: **foo\\|bar**\n> **Meaning**: alternative\n" + def test_simple_table(self): text = "| Name | Age |\n|------|-----|\n| Alice | 30 |\n| Bob | 25 |" result = _convert_markdown_table(text)