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)