Compare commits

...
Author SHA1 Message Date
Peter Steinberger 862e89e683 fix: add per-channel markdown table conversion (#1495) (thanks @odysseus0) 2026-01-23 17:56:50 +00:00
George Zhang 03384e4b80 feat(telegram): convert markdown tables to bullet points
Tables render poorly in Telegram (pipes stripped, whitespace collapses).
This adds a 'tableMode' option to markdownToIR that converts tables to
nested bullet points, which render cleanly on mobile.

- Add tableMode: 'flat' | 'bullets' to MarkdownParseOptions
- Track table state during token rendering
- Render tables as bullet points with first column as row labels
- Apply bold styling to row labels for visual hierarchy
- Enable tableMode: 'bullets' for Telegram formatter

Closes #TBD
2026-01-23 12:00:26 +00:00
George Zhang bfbeea0f20 daemon: prefer symlinked paths over realpath for stable service configs (#1505)
When installing the LaunchAgent/systemd service, the CLI was using
fs.realpath() to resolve the entry.js path, which converted stable
symlinked paths (e.g. node_modules/clawdbot) into version-specific
paths (e.g. .pnpm/clawdbot@X.Y.Z/...).

This caused the service to break after pnpm updates because the old
versioned path no longer exists, even though the symlink still works.

Now we prefer the original (symlinked) path when it's valid, keeping
service configs stable across package version updates.
2026-01-23 11:52:26 +00:00
Peter Steinberger 2c85b1b409 fix: restart gateway after update by default 2026-01-23 11:50:19 +00:00
Peter Steinberger 8b7b7e154f chore: speed up tests and update opencode models 2026-01-23 11:36:32 +00:00
Peter Steinberger bb9bddebb4 fix: stabilize ci tests 2026-01-23 09:52:22 +00:00
Peter Steinberger 6e570561b6 docs: prefer fast install smoke for release 2026-01-23 09:18:15 +00:00
Peter Steinberger fb6363ae58 Merge pull request #1492 from svkozak/fix-discord-accountId
Discord: preserve accountId in message actions (refs #1489)
2026-01-23 09:15:54 +00:00
Peter Steinberger 1b77e086d4 Merge origin/main into fix-discord-accountId 2026-01-23 09:15:44 +00:00
Sergii Kozak d371a4c8c3 Discord Actions: Update tests for optional config parameter 2026-01-23 01:11:54 -08:00
Peter Steinberger 03e8b7c4ba fix: always offer TUI hatch 2026-01-23 09:07:43 +00:00
Peter Steinberger 8aadcaa1bd test: fix discord action mocks 2026-01-23 09:06:04 +00:00
Peter Steinberger 96800c27ec docs: update changelog for #1492 2026-01-23 09:06:04 +00:00
Peter Steinberger 13d1712850 fix: honor accountId in message actions 2026-01-23 09:06:04 +00:00
Sergii Kozak c5546f0d5b Discord: preserve accountId in message actions (refs #1489) 2026-01-23 09:06:04 +00:00
Peter Steinberger 3de5ea818d ci: speed up install smoke on PRs 2026-01-23 09:05:15 +00:00
Peter Steinberger dc07f1e021 fix: keep core tools when allowlist is plugin-only 2026-01-23 09:02:17 +00:00
Peter Steinberger 310a248a44 docs: add exe.dev ops note 2026-01-23 09:01:02 +00:00
Peter Steinberger 88e7684258 chore: update appcast for 2026.1.22 2026-01-23 08:59:04 +00:00
Sergii Kozak 716f901504 Discord: honor accountId across channel actions (refs #1489) 2026-01-23 00:50:50 -08:00
Peter Steinberger e817c0cee5 fix: preserve PNG alpha fallback (#1491) (thanks @robbyczgw-cla) 2026-01-23 08:45:50 +00:00
Robby e634791585 fix(media): preserve alpha channel for transparent PNGs (#1473) 2026-01-23 08:43:01 +00:00
Peter Steinberger 78071f8ec4 docs: note SPARKLE_PRIVATE_KEY_FILE in profile 2026-01-23 08:25:20 +00:00
Sergii Kozak dc89bc4004 Discord: preserve accountId in message actions (refs #1489) 2026-01-22 23:51:58 -08:00
110 changed files with 2439 additions and 444 deletions

No files matched your search

+1
View File
@@ -29,5 +29,6 @@ jobs:
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
CLAWDBOT_NO_ONBOARD: "1"
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }}
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
run: pnpm test:install:smoke
+9
View File
@@ -22,6 +22,15 @@
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
## exe.dev VM ops (general)
- Access: SSH to the VM directly: `ssh vm-name.exe.xyz` (or use exe.dev web terminal).
- Updates: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`).
- Config: use `clawdbot config set ...`; set `gateway.mode=local` if unset.
- Restart: exe.dev often lacks systemd user bus; stop old gateway and run:
`pkill -9 -f clawdbot-gateway || true; nohup clawdbot gateway run --bind loopback --port 18789 --force > /tmp/clawdbot-gateway.log 2>&1 &`
- Verify: `clawdbot --version`, `clawdbot health`, `ss -ltnp | rg 18789`.
- SSH flaky: use exe.dev web terminal or Shelley (web agent) instead of CLI SSH.
## Build, Test, and Development Commands
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
- Install deps: `pnpm install`
+11
View File
@@ -2,6 +2,16 @@
Docs: https://docs.clawd.bot
## 2026.1.23
### Changes
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
### Fixes
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
## 2026.1.22
### Changes
@@ -35,6 +45,7 @@ Docs: https://docs.clawd.bot
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
- Slack: reduce WebClient retries to avoid duplicate sends. (#1481)
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
- Discord: honor accountId across message actions and cron deliveries. (#1492) Thanks @svkozak.
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
- macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)
+48 -16
View File
@@ -2,6 +2,54 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdbot</title>
<item>
<title>2026.1.22</title>
<pubDate>Fri, 23 Jan 2026 08:58:14 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>7530</sparkle:version>
<sparkle:shortVersionString>2026.1.22</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.22</h2>
<h3>Changes</h3>
<ul>
<li>Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.</li>
<li>Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren.</li>
<li>Slack: add chat-type reply threading overrides via <code>replyToModeByChatType</code>. (#1442) Thanks @stefangalescu.</li>
<li>BlueBubbles: add <code>asVoice</code> support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.</li>
<li>Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.</li>
<li>Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.</li>
<li>Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.</li>
<li>Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.</li>
<li>Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.</li>
<li>Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.</li>
<li>Gateway: stop the service before uninstalling and fail if it remains loaded.</li>
<li>Agents: surface concrete API error details instead of generic AI service errors.</li>
<li>Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)</li>
<li>Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.</li>
<li>Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.</li>
<li>Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.</li>
<li>Agents: make tool summaries more readable and only show optional params when set.</li>
<li>Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.</li>
<li>Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436, #1486) Thanks @sibbl.</li>
<li>CLI: prefer <code>~</code> for home paths in output.</li>
<li>Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.</li>
<li>Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.</li>
<li>Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.</li>
<li>Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.</li>
<li>Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.</li>
<li>Slack: reduce WebClient retries to avoid duplicate sends. (#1481)</li>
<li>Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.</li>
<li>macOS: prefer linked channels in gateway summary to avoid false “not linked” status.</li>
<li>macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.22/Clawdbot-2026.1.22.zip" length="22302446" type="application/octet-stream" sparkle:edSignature="w/EzfwGBCRRuCg5vz8enIfYujxOZJWRw9PaunQ7gIafKwnBJSTtxcnkvMVwQsnBwB6VN5Tu2MPij7PjDFFX+CA=="/>
</item>
<item>
<title>2026.1.21</title>
<pubDate>Thu, 22 Jan 2026 12:22:35 +0000</pubDate>
@@ -266,21 +314,5 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
</item>
<item>
<title>2026.1.16-2</title>
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>6273</sparkle:version>
<sparkle:shortVersionString>2026.1.16-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.16-2</h2>
<h3>Changes</h3>
<ul>
<li>CLI: stamp build commit into dist metadata so banners show the commit in npm installs.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
</item>
</channel>
</rss>
+5 -4
View File
@@ -1,5 +1,5 @@
---
summary: "CLI reference for `clawdbot update` (safe-ish source update + optional gateway restart)"
summary: "CLI reference for `clawdbot update` (safe-ish source update + gateway auto-restart)"
read_when:
- You want to update a source checkout safely
- You need to understand `--update` shorthand behavior
@@ -20,14 +20,14 @@ clawdbot update wizard
clawdbot update --channel beta
clawdbot update --channel dev
clawdbot update --tag beta
clawdbot update --restart
clawdbot update --no-restart
clawdbot update --json
clawdbot --update
```
## Options
- `--restart`: restart the Gateway service after a successful update.
- `--no-restart`: skip restarting the Gateway service after a successful update.
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
- `--json`: print machine-readable `UpdateRunResult` JSON.
@@ -52,7 +52,8 @@ Options:
## `update wizard`
Interactive flow to pick an update channel and confirm whether to restart the Gateway
after updating. If you select `dev` without a git checkout, it offers to create one.
after updating (default is to restart). If you select `dev` without a git checkout, it
offers to create one.
## What it does
+25 -1
View File
@@ -25,6 +25,7 @@ stay consistent across channels.
1. **Parse Markdown -> IR**
- IR is plain text plus style spans (bold/italic/strike/code/spoiler) and link spans.
- Offsets are UTF-16 code units so Signal style ranges align with its API.
- Tables are parsed only when a channel opts into table conversion.
2. **Chunk IR (format-first)**
- Chunking happens on the IR text before rendering.
- Inline formatting does not split across chunks; spans are sliced per chunk.
@@ -59,7 +60,30 @@ IR (schematic):
- Slack, Telegram, and Signal outbound adapters render from the IR.
- Other channels (WhatsApp, iMessage, MS Teams, Discord) still use plain text or
their own formatting rules.
their own formatting rules, with Markdown table conversion applied before
chunking when enabled.
## Table handling
Markdown tables are not consistently supported across chat clients. Use
`markdown.tables` to control conversion per channel (and per account).
- `code`: render tables as code blocks (default for most channels).
- `bullets`: convert each row into bullet points (default for Signal + WhatsApp).
- `off`: disable table parsing and conversion; raw table text passes through.
Config keys:
```yaml
channels:
discord:
markdown:
tables: code
accounts:
work:
markdown:
tables: off
```
## Chunking rules
+3 -2
View File
@@ -7,7 +7,7 @@ read_when:
# Updating
Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart → verify.
Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart (or use `clawdbot update`, which restarts) → verify.
## Recommended: re-run the website installer (upgrade in place)
@@ -81,7 +81,7 @@ Notes:
For **source installs** (git checkout), prefer:
```bash
clawdbot update --restart
clawdbot update
```
It runs a safe-ish update flow:
@@ -89,6 +89,7 @@ It runs a safe-ish update flow:
- Switches to the selected channel (tag or branch).
- Fetches + rebases against the configured upstream (dev channel).
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
- Restarts the gateway by default (use `--no-restart` to skip).
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead.
+1 -1
View File
@@ -11,7 +11,7 @@ This app now ships Sparkle auto-updates. Release builds must be Developer ID–s
## Prereqs
- Developer ID Application cert installed (example: `Developer ID Application: <Developer Name> (<TEAMID>)`).
- Sparkle private key path set in the environment as `SPARKLE_PRIVATE_KEY_FILE` (path to your Sparkle ed25519 private key; public key baked into Info.plist).
- Sparkle private key path set in the environment as `SPARKLE_PRIVATE_KEY_FILE` (path to your Sparkle ed25519 private key; public key baked into Info.plist). If it is missing, check `~/.profile`.
- Notary credentials (keychain profile or API key) for `xcrun notarytool` if you want Gatekeeper-safe DMG/zip distribution.
- We use a Keychain profile named `clawdbot-notary`, created from App Store Connect API key env vars in your shell profile:
- `APP_STORE_CONNECT_API_KEY_P8`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`
+2
View File
@@ -82,6 +82,8 @@ Enable optional tools in `agents.list[].tools.allow` (or global `tools.allow`):
```
Other config knobs that affect tool availability:
- Allowlists that only name plugin tools are treated as plugin opt-ins; core tools remain
enabled unless you also include core tools or groups in the allowlist.
- `tools.profile` / `agents.list[].tools.profile` (base allowlist)
- `tools.byProvider` / `agents.list[].tools.byProvider` (provider‑specific allow/deny)
- `tools.sandbox.tools.*` (sandbox tool policy when sandboxed)
+3 -2
View File
@@ -13,7 +13,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
## Operator trigger
When the operator says “release”, immediately do this preflight (no extra questions unless blocked):
- Read this doc and `docs/platforms/mac/release.md`.
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set.
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set (SPARKLE_PRIVATE_KEY_FILE should live in `~/.profile`).
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
1) **Version & metadata**
@@ -39,8 +39,9 @@ When the operator says “release”, immediately do this preflight (no extra qu
- [ ] `pnpm test` (or `pnpm test:coverage` if you need coverage output)
- [ ] `pnpm run build` (last sanity check after tests)
- [ ] `pnpm release:check` (verifies npm pack contents)
- [ ] `pnpm test:install:smoke` (Docker install smoke test; required before release)
- [ ] `CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` (Docker install smoke test, fast path; required before release)
- If the immediate previous npm release is known broken, set `CLAWDBOT_INSTALL_SMOKE_PREVIOUS=<last-good-version>` or `CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS=1` for the preinstall step.
- [ ] (Optional) Full installer smoke (adds non-root + CLI coverage): `pnpm test:install:smoke`
- [ ] (Optional) Installer E2E (Docker, runs `curl -fsSL https://clawd.bot/install.sh | bash`, onboards, then runs real tool calls):
- `pnpm test:install:e2e:openai` (requires `OPENAI_API_KEY`)
- `pnpm test:install:e2e:anthropic` (requires `ANTHROPIC_API_KEY`)
+4
View File
@@ -121,6 +121,10 @@ Lobster is an **optional** plugin tool (not enabled by default). Allow it per ag
You can also allow it globally with `tools.allow` if every agent should see it.
Note: allowlists are opt-in for optional plugins. If your allowlist only names
plugin tools (like `lobster`), Clawdbot keeps core tools enabled. To restrict core
tools, include the core tools or groups you want in the allowlist too.
## Example: Email triage
Without Lobster:
@@ -1,3 +1,4 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -25,6 +26,7 @@ const bluebubblesGroupConfigSchema = z.object({
const bluebubblesAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
serverUrl: z.string().optional(),
password: z.string().optional(),
webhookPath: z.string().optional(),
@@ -99,6 +99,8 @@ function createMockRuntime(): PluginRuntime {
chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
resolveTextChunkLimit: vi.fn(() => 4000) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"],
hasControlCommand: mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"],
resolveMarkdownTableMode: vi.fn(() => "code") as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
convertMarkdownTables: vi.fn((text: string) => text) as unknown as PluginRuntime["channel"]["text"]["convertMarkdownTables"],
},
reply: {
dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
+15 -3
View File
@@ -1662,9 +1662,15 @@ async function processMessage(
? [payload.mediaUrl]
: [];
if (mediaList.length > 0) {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "bluebubbles",
accountId: account.accountId,
});
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? payload.text : undefined;
const caption = first ? text : undefined;
first = false;
const result = await sendBlueBubblesMedia({
cfg: config,
@@ -1686,8 +1692,14 @@ async function processMessage(
account.config.textChunkLimit && account.config.textChunkLimit > 0
? account.config.textChunkLimit
: DEFAULT_TEXT_LIMIT;
const chunks = core.channel.text.chunkMarkdownText(payload.text ?? "", textLimit);
if (!chunks.length && payload.text) chunks.push(payload.text);
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "bluebubbles",
accountId: account.accountId,
});
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
if (!chunks.length && text) chunks.push(text);
if (!chunks.length) return;
for (const chunk of chunks) {
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
+2
View File
@@ -1,3 +1,4 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -35,6 +36,7 @@ const matrixRoomSchema = z
export const MatrixConfigSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
homeserver: z.string().optional(),
userId: z.string().optional(),
accessToken: z.string().optional(),
@@ -548,6 +548,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
}
let didSendReply = false;
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "matrix",
accountId: route.accountId,
});
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
@@ -562,6 +567,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
textLimit,
replyToMode,
threadId: threadTarget,
accountId: route.accountId,
tableMode,
});
didSendReply = true;
},
@@ -1,6 +1,6 @@
import type { MatrixClient } from "matrix-bot-sdk";
import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
import { sendMessageMatrix } from "../send.js";
import { getMatrixRuntime } from "../../runtime.js";
@@ -12,8 +12,17 @@ export async function deliverMatrixReplies(params: {
textLimit: number;
replyToMode: "off" | "first" | "all";
threadId?: string;
accountId?: string;
tableMode?: MarkdownTableMode;
}): Promise<void> {
const core = getMatrixRuntime();
const tableMode =
params.tableMode ??
core.channel.text.resolveMarkdownTableMode({
cfg: core.config.loadConfig(),
channel: "matrix",
accountId: params.accountId,
});
const logVerbose = (message: string) => {
if (core.logging.shouldLogVerbose()) {
params.runtime.log?.(message);
@@ -33,6 +42,8 @@ export async function deliverMatrixReplies(params: {
}
const replyToIdRaw = reply.replyToId?.trim();
const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw;
const rawText = reply.text ?? "";
const text = core.channel.text.convertMarkdownTables(rawText, tableMode);
const mediaList = reply.mediaUrls?.length
? reply.mediaUrls
: reply.mediaUrl
@@ -43,13 +54,14 @@ export async function deliverMatrixReplies(params: {
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
if (mediaList.length === 0) {
for (const chunk of core.channel.text.chunkMarkdownText(reply.text ?? "", chunkLimit)) {
for (const chunk of core.channel.text.chunkMarkdownText(text, chunkLimit)) {
const trimmed = chunk.trim();
if (!trimmed) continue;
await sendMessageMatrix(params.roomId, trimmed, {
client: params.client,
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
threadId: params.threadId,
accountId: params.accountId,
});
if (shouldIncludeReply(replyToId)) {
hasReplied = true;
@@ -60,13 +72,14 @@ export async function deliverMatrixReplies(params: {
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? (reply.text ?? "") : "";
const caption = first ? text : "";
await sendMessageMatrix(params.roomId, caption, {
client: params.client,
mediaUrl,
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
threadId: params.threadId,
audioAsVoice: reply.audioAsVoice,
accountId: params.accountId,
});
if (shouldIncludeReply(replyToId)) {
hasReplied = true;
@@ -43,6 +43,8 @@ const runtimeStub = {
text: {
resolveTextChunkLimit: () => 4000,
chunkMarkdownText: (text: string) => (text ? [text] : []),
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text,
},
},
} as unknown as PluginRuntime;
+10 -1
View File
@@ -50,9 +50,18 @@ export async function sendMessageMatrix(
try {
const roomId = await resolveMatrixRoomId(client, to);
const cfg = getCore().config.loadConfig();
const tableMode = getCore().channel.text.resolveMarkdownTableMode({
cfg,
channel: "matrix",
accountId: opts.accountId,
});
const convertedMessage = getCore().channel.text.convertMarkdownTables(
trimmedMessage,
tableMode,
);
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit);
const chunks = getCore().channel.text.chunkMarkdownText(convertedMessage, chunkLimit);
const threadId = normalizeThreadId(opts.threadId);
const relation = threadId
? buildThreadRelation(threadId, opts.replyToId)
@@ -87,6 +87,7 @@ export type MatrixSendResult = {
export type MatrixSendOpts = {
client?: import("matrix-bot-sdk").MatrixClient;
mediaUrl?: string;
accountId?: string;
replyToId?: string;
threadId?: string | number | null;
timeoutMs?: number;
@@ -4,6 +4,7 @@ import {
BlockStreamingCoalesceSchema,
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
requireOpenAllowFrom,
} from "clawdbot/plugin-sdk";
@@ -11,6 +12,7 @@ const MattermostAccountSchemaBase = z
.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
botToken: z.string().optional(),
@@ -707,6 +707,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
fallbackLimit: account.textChunkLimit ?? 4000,
});
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "mattermost",
accountId: account.accountId,
});
let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(cfg, route.agentId),
@@ -720,7 +725,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => {
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? "";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
if (mediaUrls.length === 0) {
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
for (const chunk of chunks.length > 0 ? chunks : [text]) {
+10 -1
View File
@@ -181,6 +181,15 @@ export async function sendMessageMattermost(
}
}
if (message) {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "mattermost",
accountId: account.accountId,
});
message = core.channel.text.convertMarkdownTables(message, tableMode);
}
if (!message && (!fileIds || fileIds.length === 0)) {
if (uploadError) {
throw new Error(`Mattermost media upload failed: ${uploadError.message}`);
@@ -205,4 +214,4 @@ export async function sendMessageMattermost(
messageId: post.id ?? "unknown",
channelId,
};
}
}
+7 -3
View File
@@ -21,6 +21,8 @@ const runtimeStub = {
}
return chunks;
},
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text,
},
},
} as unknown as PluginRuntime;
@@ -34,6 +36,7 @@ describe("msteams messenger", () => {
it("filters silent replies", () => {
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
textChunkLimit: 4000,
tableMode: "code",
});
expect(messages).toEqual([]);
});
@@ -41,7 +44,7 @@ describe("msteams messenger", () => {
it("filters silent reply prefixes", () => {
const messages = renderReplyPayloadsToMessages(
[{ text: `${SILENT_REPLY_TOKEN} -- ignored` }],
{ textChunkLimit: 4000 },
{ textChunkLimit: 4000, tableMode: "code" },
);
expect(messages).toEqual([]);
});
@@ -49,7 +52,7 @@ describe("msteams messenger", () => {
it("splits media into separate messages by default", () => {
const messages = renderReplyPayloadsToMessages(
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
{ textChunkLimit: 4000 },
{ textChunkLimit: 4000, tableMode: "code" },
);
expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]);
});
@@ -57,7 +60,7 @@ describe("msteams messenger", () => {
it("supports inline media mode", () => {
const messages = renderReplyPayloadsToMessages(
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
{ textChunkLimit: 4000, mediaMode: "inline" },
{ textChunkLimit: 4000, mediaMode: "inline", tableMode: "code" },
);
expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]);
});
@@ -66,6 +69,7 @@ describe("msteams messenger", () => {
const long = "hello ".repeat(200);
const messages = renderReplyPayloadsToMessages([{ text: long }], {
textChunkLimit: 50,
tableMode: "code",
});
expect(messages.length).toBeGreaterThan(1);
});
+12 -1
View File
@@ -1,6 +1,7 @@
import {
isSilentReplyText,
loadWebMedia,
type MarkdownTableMode,
type MSTeamsReplyStyle,
type ReplyPayload,
SILENT_REPLY_TOKEN,
@@ -61,6 +62,7 @@ export type MSTeamsReplyRenderOptions = {
textChunkLimit: number;
chunkText?: boolean;
mediaMode?: "split" | "inline";
tableMode?: MarkdownTableMode;
};
/**
@@ -196,10 +198,19 @@ export function renderReplyPayloadsToMessages(
const chunkLimit = Math.min(options.textChunkLimit, 4000);
const chunkText = options.chunkText !== false;
const mediaMode = options.mediaMode ?? "split";
const tableMode =
options.tableMode ??
getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({
cfg: getMSTeamsRuntime().config.loadConfig(),
channel: "msteams",
});
for (const payload of replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? "";
const text = getMSTeamsRuntime().channel.text.convertMarkdownTables(
payload.text ?? "",
tableMode,
);
if (!text && mediaList.length === 0) continue;
@@ -53,10 +53,15 @@ export function createMSTeamsReplyDispatcher(params: {
).responsePrefix,
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
deliver: async (payload) => {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: params.cfg,
channel: "msteams",
});
const messages = renderReplyPayloadsToMessages([payload], {
textChunkLimit: params.textLimit,
chunkText: true,
mediaMode: "split",
tableMode,
});
const mediaMaxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg,
+16 -7
View File
@@ -16,6 +16,7 @@ import {
import { extractFilename, extractMessageId } from "./media-helpers.js";
import { buildConversationReference, sendMSTeamsMessages } from "./messenger.js";
import { buildMSTeamsPollCard } from "./polls.js";
import { getMSTeamsRuntime } from "./runtime.js";
import { resolveMSTeamsSendContext, type MSTeamsProactiveContext } from "./send-context.js";
export type SendMSTeamsMessageParams = {
@@ -93,13 +94,21 @@ export async function sendMessageMSTeams(
params: SendMSTeamsMessageParams,
): Promise<SendMSTeamsMessageResult> {
const { cfg, to, text, mediaUrl } = params;
const tableMode = getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({
cfg,
channel: "msteams",
});
const messageText = getMSTeamsRuntime().channel.text.convertMarkdownTables(
text ?? "",
tableMode,
);
const ctx = await resolveMSTeamsSendContext({ cfg, to });
const { adapter, appId, conversationId, ref, log, conversationType, tokenProvider, sharePointSiteId } = ctx;
log.debug("sending proactive message", {
conversationId,
conversationType,
textLength: text.length,
textLength: messageText.length,
hasMedia: Boolean(mediaUrl),
});
@@ -134,7 +143,7 @@ export async function sendMessageMSTeams(
const { activity, uploadId } = prepareFileConsentActivity({
media: { buffer: media.buffer, filename: fileName, contentType: media.contentType },
conversationId,
description: text || undefined,
description: messageText || undefined,
});
log.debug("sending file consent card", { uploadId, fileName, size: media.buffer.length });
@@ -172,14 +181,14 @@ export async function sendMessageMSTeams(
const base64 = media.buffer.toString("base64");
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
return sendTextWithMedia(ctx, text, finalMediaUrl);
return sendTextWithMedia(ctx, messageText, finalMediaUrl);
}
if (isImage && !sharePointSiteId) {
// Group chat/channel without SharePoint: send image inline (avoids OneDrive failures)
const base64 = media.buffer.toString("base64");
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
return sendTextWithMedia(ctx, text, finalMediaUrl);
return sendTextWithMedia(ctx, messageText, finalMediaUrl);
}
// Group chat or channel: upload to SharePoint (if siteId configured) or OneDrive
@@ -223,7 +232,7 @@ export async function sendMessageMSTeams(
const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
const activity = {
type: "message",
text: text || undefined,
text: messageText || undefined,
attachments: [fileCardAttachment],
};
@@ -264,7 +273,7 @@ export async function sendMessageMSTeams(
const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`;
const activity = {
type: "message",
text: text ? `${text}\n\n${fileLink}` : fileLink,
text: messageText ? `${messageText}\n\n${fileLink}` : fileLink,
};
const baseRef = buildConversationReference(ref);
@@ -290,7 +299,7 @@ export async function sendMessageMSTeams(
}
// No media: send text only
return sendTextWithMedia(ctx, text, undefined);
return sendTextWithMedia(ctx, messageText, undefined);
}
/**
@@ -3,6 +3,7 @@ import {
DmConfigSchema,
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
requireOpenAllowFrom,
} from "clawdbot/plugin-sdk";
import { z } from "zod";
@@ -21,6 +22,7 @@ export const NextcloudTalkAccountSchemaBase = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
baseUrl: z.string().optional(),
botSecret: z.string().optional(),
botSecretFile: z.string().optional(),
+11 -1
View File
@@ -71,8 +71,18 @@ export async function sendMessageNextcloudTalk(
throw new Error("Message must be non-empty for Nextcloud Talk sends");
}
const tableMode = getNextcloudTalkRuntime().channel.text.resolveMarkdownTableMode({
cfg,
channel: "nextcloud-talk",
accountId: account.accountId,
});
const message = getNextcloudTalkRuntime().channel.text.convertMarkdownTables(
text.trim(),
tableMode,
);
const body: Record<string, unknown> = {
message: text.trim(),
message,
};
if (opts.replyTo) {
body.replyTo = opts.replyTo;
+8 -1
View File
@@ -133,13 +133,20 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
deliveryMode: "direct",
textChunkLimit: 4000,
sendText: async ({ to, text, accountId }) => {
const core = getNostrRuntime();
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
const bus = activeBuses.get(aid);
if (!bus) {
throw new Error(`Nostr bus not running for account ${aid}`);
}
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: core.config.loadConfig(),
channel: "nostr",
accountId: aid,
});
const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode);
const normalizedTo = normalizePubkey(to);
await bus.sendDm(normalizedTo, text);
await bus.sendDm(normalizedTo, message);
return { channel: "nostr", to: normalizedTo };
},
},
+4 -1
View File
@@ -1,5 +1,5 @@
import { MarkdownConfigSchema, buildChannelConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod";
import { buildChannelConfigSchema } from "clawdbot/plugin-sdk";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -63,6 +63,9 @@ export const NostrConfigSchema = z.object({
/** Whether this channel is enabled */
enabled: z.boolean().optional(),
/** Markdown formatting overrides (tables). */
markdown: MarkdownConfigSchema,
/** Private key in hex or nsec bech32 format */
privateKey: z.string().optional(),
+2
View File
@@ -1,3 +1,4 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -5,6 +6,7 @@ const allowFromEntry = z.union([z.string(), z.number()]);
const zaloAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
botToken: z.string().optional(),
tokenFile: z.string().optional(),
webhookUrl: z.string().optional(),
+14 -4
View File
@@ -1,6 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import type { ClawdbotConfig, MarkdownTableMode } from "clawdbot/plugin-sdk";
import type { ResolvedZaloAccount } from "./accounts.js";
import {
@@ -578,6 +578,12 @@ async function processMessageWithPipeline(params: {
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
});
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "zalo",
accountId: account.accountId,
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
@@ -591,6 +597,7 @@ async function processMessageWithPipeline(params: {
core,
statusSink,
fetcher,
tableMode,
});
},
onError: (err, info) => {
@@ -608,8 +615,11 @@ async function deliverZaloReply(params: {
core: ZaloCoreRuntime;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
fetcher?: ZaloFetch;
tableMode?: MarkdownTableMode;
}): Promise<void> {
const { payload, token, chatId, runtime, core, statusSink, fetcher } = params;
const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
@@ -620,7 +630,7 @@ async function deliverZaloReply(params: {
if (mediaList.length > 0) {
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? payload.text : undefined;
const caption = first ? text : undefined;
first = false;
try {
await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
@@ -632,8 +642,8 @@ async function deliverZaloReply(params: {
return;
}
if (payload.text) {
const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALO_TEXT_LIMIT);
if (text) {
const chunks = core.channel.text.chunkMarkdownText(text, ZALO_TEXT_LIMIT);
for (const chunk of chunks) {
try {
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
+2
View File
@@ -1,3 +1,4 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -10,6 +11,7 @@ const groupConfigSchema = z.object({
const zalouserAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
profile: z.string().optional(),
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(allowFromEntry).optional(),
+12 -4
View File
@@ -1,6 +1,6 @@
import type { ChildProcess } from "node:child_process";
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
import type { ClawdbotConfig, MarkdownTableMode, RuntimeEnv } from "clawdbot/plugin-sdk";
import { mergeAllowlist, summarizeMapping } from "clawdbot/plugin-sdk";
import { sendMessageZalouser } from "./send.js";
import type {
@@ -332,6 +332,11 @@ async function processMessage(
runtime,
core,
statusSink,
tableMode: core.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "zalouser",
accountId: account.accountId,
}),
});
},
onError: (err, info) => {
@@ -351,8 +356,11 @@ async function deliverZalouserReply(params: {
runtime: RuntimeEnv;
core: ZalouserCoreRuntime;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
tableMode?: MarkdownTableMode;
}): Promise<void> {
const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params;
const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
@@ -363,7 +371,7 @@ async function deliverZalouserReply(params: {
if (mediaList.length > 0) {
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? payload.text : undefined;
const caption = first ? text : undefined;
first = false;
try {
logVerbose(core, runtime, `Sending media to ${chatId}`);
@@ -380,8 +388,8 @@ async function deliverZalouserReply(params: {
return;
}
if (payload.text) {
const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT);
if (text) {
const chunks = core.channel.text.chunkMarkdownText(text, ZALOUSER_TEXT_LIMIT);
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
for (const chunk of chunks) {
try {
+6 -1
View File
@@ -19,7 +19,12 @@ echo "==> Verify git installed"
command -v git >/dev/null
echo "==> Verify clawdbot installed"
LATEST_VERSION="$(npm view clawdbot version)"
EXPECTED_VERSION="${CLAWDBOT_INSTALL_EXPECT_VERSION:-}"
if [[ -n "$EXPECTED_VERSION" ]]; then
LATEST_VERSION="$EXPECTED_VERSION"
else
LATEST_VERSION="$(npm view clawdbot version)"
fi
CMD_PATH="$(command -v clawdbot || true)"
if [[ -z "$CMD_PATH" && -x "$HOME/.npm-global/bin/clawdbot" ]]; then
CMD_PATH="$HOME/.npm-global/bin/clawdbot"
+23 -8
View File
@@ -6,21 +6,36 @@ SMOKE_PREVIOUS_VERSION="${CLAWDBOT_INSTALL_SMOKE_PREVIOUS:-}"
SKIP_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS:-0}"
echo "==> Resolve npm versions"
LATEST_VERSION="$(npm view clawdbot version)"
if [[ -n "$SMOKE_PREVIOUS_VERSION" ]]; then
LATEST_VERSION="$(npm view clawdbot version)"
PREVIOUS_VERSION="$SMOKE_PREVIOUS_VERSION"
else
PREVIOUS_VERSION="$(node - <<'NODE'
const { execSync } = require("node:child_process");
const versions = JSON.parse(execSync("npm view clawdbot versions --json", { encoding: "utf8" }));
if (!Array.isArray(versions) || versions.length === 0) {
VERSIONS_JSON="$(npm view clawdbot versions --json)"
versions_line="$(node - <<'NODE'
const raw = process.env.VERSIONS_JSON || "[]";
let versions;
try {
versions = JSON.parse(raw);
} catch {
versions = raw ? [raw] : [];
}
if (!Array.isArray(versions)) {
versions = [versions];
}
if (versions.length === 0) {
process.exit(1);
}
const previous = versions.length >= 2 ? versions[versions.length - 2] : versions[0];
process.stdout.write(previous);
const latest = versions[versions.length - 1];
const previous = versions.length >= 2 ? versions[versions.length - 2] : latest;
process.stdout.write(`${latest} ${previous}`);
NODE
)"
LATEST_VERSION="${versions_line%% *}"
PREVIOUS_VERSION="${versions_line#* }"
fi
if [[ -n "${CLAWDBOT_INSTALL_LATEST_OUT:-}" ]]; then
printf "%s" "$LATEST_VERSION" > "$CLAWDBOT_INSTALL_LATEST_OUT"
fi
echo "latest=$LATEST_VERSION previous=$PREVIOUS_VERSION"
+31 -11
View File
@@ -6,6 +6,9 @@ SMOKE_IMAGE="${CLAWDBOT_INSTALL_SMOKE_IMAGE:-clawdbot-install-smoke:local}"
NONROOT_IMAGE="${CLAWDBOT_INSTALL_NONROOT_IMAGE:-clawdbot-install-nonroot:local}"
INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://clawd.bot/install.sh}"
CLI_INSTALL_URL="${CLAWDBOT_INSTALL_CLI_URL:-https://clawd.bot/install-cli.sh}"
SKIP_NONROOT="${CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT:-0}"
LATEST_DIR="$(mktemp -d)"
LATEST_FILE="${LATEST_DIR}/latest"
echo "==> Build smoke image (upgrade, root): $SMOKE_IMAGE"
docker build \
@@ -15,31 +18,48 @@ docker build \
echo "==> Run installer smoke test (root): $INSTALL_URL"
docker run --rm -t \
-v "${LATEST_DIR}:/out" \
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
-e CLAWDBOT_INSTALL_LATEST_OUT="/out/latest" \
-e CLAWDBOT_INSTALL_SMOKE_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_PREVIOUS:-}" \
-e CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS:-0}" \
-e CLAWDBOT_NO_ONBOARD=1 \
-e DEBIAN_FRONTEND=noninteractive \
"$SMOKE_IMAGE"
echo "==> Build non-root image: $NONROOT_IMAGE"
docker build \
-t "$NONROOT_IMAGE" \
-f "$ROOT_DIR/scripts/docker/install-sh-nonroot/Dockerfile" \
"$ROOT_DIR/scripts/docker/install-sh-nonroot"
LATEST_VERSION=""
if [[ -f "$LATEST_FILE" ]]; then
LATEST_VERSION="$(cat "$LATEST_FILE")"
fi
echo "==> Run installer non-root test: $INSTALL_URL"
docker run --rm -t \
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
-e CLAWDBOT_NO_ONBOARD=1 \
-e DEBIAN_FRONTEND=noninteractive \
"$NONROOT_IMAGE"
if [[ "$SKIP_NONROOT" == "1" ]]; then
echo "==> Skip non-root installer smoke (CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT=1)"
else
echo "==> Build non-root image: $NONROOT_IMAGE"
docker build \
-t "$NONROOT_IMAGE" \
-f "$ROOT_DIR/scripts/docker/install-sh-nonroot/Dockerfile" \
"$ROOT_DIR/scripts/docker/install-sh-nonroot"
echo "==> Run installer non-root test: $INSTALL_URL"
docker run --rm -t \
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
-e CLAWDBOT_INSTALL_EXPECT_VERSION="$LATEST_VERSION" \
-e CLAWDBOT_NO_ONBOARD=1 \
-e DEBIAN_FRONTEND=noninteractive \
"$NONROOT_IMAGE"
fi
if [[ "${CLAWDBOT_INSTALL_SMOKE_SKIP_CLI:-0}" == "1" ]]; then
echo "==> Skip CLI installer smoke (CLAWDBOT_INSTALL_SMOKE_SKIP_CLI=1)"
exit 0
fi
if [[ "$SKIP_NONROOT" == "1" ]]; then
echo "==> Skip CLI installer smoke (non-root image skipped)"
exit 0
fi
echo "==> Run CLI installer non-root test (same image)"
docker run --rm -t \
--entrypoint /bin/bash \
+31 -4
View File
@@ -1,4 +1,5 @@
import { spawn } from "node:child_process";
import os from "node:os";
const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
@@ -7,17 +8,31 @@ const runs = [
name: "unit",
args: ["vitest", "run", "--config", "vitest.unit.config.ts"],
},
{
name: "extensions",
args: ["vitest", "run", "--config", "vitest.extensions.config.ts"],
},
{
name: "gateway",
args: ["vitest", "run", "--config", "vitest.gateway.config.ts"],
},
];
const parallelRuns = runs.filter((entry) => entry.name !== "gateway");
const serialRuns = runs.filter((entry) => entry.name === "gateway");
const children = new Set();
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
const overrideWorkers = Number.parseInt(process.env.CLAWDBOT_TEST_WORKERS ?? "", 10);
const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
const maxWorkers = isCI ? null : resolvedOverride ?? perRunWorkers;
const run = (entry) =>
new Promise((resolve) => {
const child = spawn(pnpm, entry.args, {
const args = maxWorkers ? [...entry.args, "--maxWorkers", String(maxWorkers)] : entry.args;
const child = spawn(pnpm, args, {
stdio: "inherit",
env: { ...process.env, VITEST_GROUP: entry.name },
shell: process.platform === "win32",
@@ -38,6 +53,18 @@ const shutdown = (signal) => {
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
const codes = await Promise.all(runs.map(run));
const failed = codes.find((code) => code !== 0);
process.exit(failed ?? 0);
const parallelCodes = await Promise.all(parallelRuns.map(run));
const failedParallel = parallelCodes.find((code) => code !== 0);
if (failedParallel !== undefined) {
process.exit(failedParallel);
}
for (const entry of serialRuns) {
// eslint-disable-next-line no-await-in-loop
const code = await run(entry);
if (code !== 0) {
process.exit(code);
}
}
process.exit(0);
+4
View File
@@ -66,6 +66,10 @@ export function isModernModelRef(ref: ModelRef): boolean {
return matchesPrefix(id, XAI_PREFIXES);
}
if (provider === "opencode" && id.endsWith("-free")) {
return false;
}
if (provider === "openrouter" || provider === "opencode") {
return matchesAny(id, [
...ANTHROPIC_PREFIXES,
+5 -8
View File
@@ -41,12 +41,11 @@ describe("resolveOpencodeZenAlias", () => {
describe("resolveOpencodeZenModelApi", () => {
it("maps APIs by model family", () => {
expect(resolveOpencodeZenModelApi("claude-opus-4-5")).toBe("anthropic-messages");
expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe("anthropic-messages");
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai");
expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-responses");
expect(resolveOpencodeZenModelApi("alpha-gd4")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("big-pickle")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("glm-4.7-free")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("glm-4.7")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe("openai-completions");
});
});
@@ -55,10 +54,10 @@ describe("getOpencodeZenStaticFallbackModels", () => {
it("returns an array of models", () => {
const models = getOpencodeZenStaticFallbackModels();
expect(Array.isArray(models)).toBe(true);
expect(models.length).toBe(11);
expect(models.length).toBe(10);
});
it("includes Claude, GPT, Gemini, GLM, and MiniMax models", () => {
it("includes Claude, GPT, Gemini, and GLM models", () => {
const models = getOpencodeZenStaticFallbackModels();
const ids = models.map((m) => m.id);
@@ -66,8 +65,7 @@ describe("getOpencodeZenStaticFallbackModels", () => {
expect(ids).toContain("gpt-5.2");
expect(ids).toContain("gpt-5.1-codex");
expect(ids).toContain("gemini-3-pro");
expect(ids).toContain("glm-4.7-free");
expect(ids).toContain("minimax-m2.1-free");
expect(ids).toContain("glm-4.7");
});
it("returns valid ModelDefinitionConfig objects", () => {
@@ -90,8 +88,7 @@ describe("OPENCODE_ZEN_MODEL_ALIASES", () => {
expect(OPENCODE_ZEN_MODEL_ALIASES.codex).toBe("gpt-5.1-codex");
expect(OPENCODE_ZEN_MODEL_ALIASES.gpt5).toBe("gpt-5.2");
expect(OPENCODE_ZEN_MODEL_ALIASES.gemini).toBe("gemini-3-pro");
expect(OPENCODE_ZEN_MODEL_ALIASES.glm).toBe("glm-4.7-free");
expect(OPENCODE_ZEN_MODEL_ALIASES.minimax).toBe("minimax-m2.1-free");
expect(OPENCODE_ZEN_MODEL_ALIASES.glm).toBe("glm-4.7");
// Legacy aliases (kept for backward compatibility).
expect(OPENCODE_ZEN_MODEL_ALIASES.sonnet).toBe("claude-opus-4-5");
+7 -16
View File
@@ -68,13 +68,9 @@ export const OPENCODE_ZEN_MODEL_ALIASES: Record<string, string> = {
"gemini-2.5-flash": "gemini-3-flash",
// GLM (free + alpha)
glm: "glm-4.7-free",
"glm-free": "glm-4.7-free",
glm: "glm-4.7",
"glm-free": "glm-4.7",
"alpha-glm": "alpha-glm-4.7",
// MiniMax
minimax: "minimax-m2.1-free",
"minimax-free": "minimax-m2.1-free",
};
/**
@@ -134,7 +130,7 @@ const MODEL_COSTS: Record<
cacheWrite: 0,
},
"gpt-5.1": { input: 1.07, output: 8.5, cacheRead: 0.107, cacheWrite: 0 },
"glm-4.7-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"glm-4.7": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"gemini-3-flash": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 },
"gpt-5.1-codex-max": {
input: 1.25,
@@ -142,7 +138,6 @@ const MODEL_COSTS: Record<
cacheRead: 0.125,
cacheWrite: 0,
},
"minimax-m2.1-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
};
@@ -155,10 +150,9 @@ const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
"alpha-glm-4.7": 204800,
"gpt-5.1-codex-mini": 400000,
"gpt-5.1": 400000,
"glm-4.7-free": 204800,
"glm-4.7": 204800,
"gemini-3-flash": 1048576,
"gpt-5.1-codex-max": 400000,
"minimax-m2.1-free": 204800,
"gpt-5.2": 400000,
};
@@ -173,10 +167,9 @@ const MODEL_MAX_TOKENS: Record<string, number> = {
"alpha-glm-4.7": 131072,
"gpt-5.1-codex-mini": 128000,
"gpt-5.1": 128000,
"glm-4.7-free": 131072,
"glm-4.7": 131072,
"gemini-3-flash": 65536,
"gpt-5.1-codex-max": 128000,
"minimax-m2.1-free": 131072,
"gpt-5.2": 128000,
};
@@ -211,10 +204,9 @@ const MODEL_NAMES: Record<string, string> = {
"alpha-glm-4.7": "Alpha GLM-4.7",
"gpt-5.1-codex-mini": "GPT-5.1 Codex Mini",
"gpt-5.1": "GPT-5.1",
"glm-4.7-free": "GLM-4.7",
"glm-4.7": "GLM-4.7",
"gemini-3-flash": "Gemini 3 Flash",
"gpt-5.1-codex-max": "GPT-5.1 Codex Max",
"minimax-m2.1-free": "MiniMax M2.1",
"gpt-5.2": "GPT-5.2",
};
@@ -240,10 +232,9 @@ export function getOpencodeZenStaticFallbackModels(): ModelDefinitionConfig[] {
"alpha-glm-4.7",
"gpt-5.1-codex-mini",
"gpt-5.1",
"glm-4.7-free",
"glm-4.7",
"gemini-3-flash",
"gpt-5.1-codex-max",
"minimax-m2.1-free",
"gpt-5.2",
];
+25 -6
View File
@@ -44,6 +44,7 @@ import {
collectExplicitAllowlist,
expandPolicyWithPluginGroups,
resolveToolProfilePolicy,
stripPluginOnlyAllowlist,
} from "./tool-policy.js";
import { getPluginToolMeta } from "../plugins/tools.js";
@@ -298,12 +299,30 @@ export function createClawdbotCodingTools(options?: {
tools,
toolMeta: (tool) => getPluginToolMeta(tool as AnyAgentTool),
});
const profilePolicyExpanded = expandPolicyWithPluginGroups(profilePolicy, pluginGroups);
const providerProfileExpanded = expandPolicyWithPluginGroups(providerProfilePolicy, pluginGroups);
const globalPolicyExpanded = expandPolicyWithPluginGroups(globalPolicy, pluginGroups);
const globalProviderExpanded = expandPolicyWithPluginGroups(globalProviderPolicy, pluginGroups);
const agentPolicyExpanded = expandPolicyWithPluginGroups(agentPolicy, pluginGroups);
const agentProviderExpanded = expandPolicyWithPluginGroups(agentProviderPolicy, pluginGroups);
const profilePolicyExpanded = expandPolicyWithPluginGroups(
stripPluginOnlyAllowlist(profilePolicy, pluginGroups),
pluginGroups,
);
const providerProfileExpanded = expandPolicyWithPluginGroups(
stripPluginOnlyAllowlist(providerProfilePolicy, pluginGroups),
pluginGroups,
);
const globalPolicyExpanded = expandPolicyWithPluginGroups(
stripPluginOnlyAllowlist(globalPolicy, pluginGroups),
pluginGroups,
);
const globalProviderExpanded = expandPolicyWithPluginGroups(
stripPluginOnlyAllowlist(globalProviderPolicy, pluginGroups),
pluginGroups,
);
const agentPolicyExpanded = expandPolicyWithPluginGroups(
stripPluginOnlyAllowlist(agentPolicy, pluginGroups),
pluginGroups,
);
const agentProviderExpanded = expandPolicyWithPluginGroups(
stripPluginOnlyAllowlist(agentProviderPolicy, pluginGroups),
pluginGroups,
);
const sandboxPolicyExpanded = expandPolicyWithPluginGroups(sandbox?.tools, pluginGroups);
const subagentPolicyExpanded = expandPolicyWithPluginGroups(subagentPolicy, pluginGroups);
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { stripPluginOnlyAllowlist, type PluginToolGroups } from "./tool-policy.js";
const pluginGroups: PluginToolGroups = {
all: ["lobster", "workflow_tool"],
byPlugin: new Map([["lobster", ["lobster", "workflow_tool"]]]),
};
describe("stripPluginOnlyAllowlist", () => {
it("strips allowlist when it only targets plugin tools", () => {
const policy = stripPluginOnlyAllowlist({ allow: ["lobster"] }, pluginGroups);
expect(policy?.allow).toBeUndefined();
});
it("strips allowlist when it only targets plugin groups", () => {
const policy = stripPluginOnlyAllowlist({ allow: ["group:plugins"] }, pluginGroups);
expect(policy?.allow).toBeUndefined();
});
it("keeps allowlist when it mixes plugin and core entries", () => {
const policy = stripPluginOnlyAllowlist({ allow: ["lobster", "read"] }, pluginGroups);
expect(policy?.allow).toEqual(["lobster", "read"]);
});
});
+16
View File
@@ -178,6 +178,22 @@ export function expandPolicyWithPluginGroups(
};
}
export function stripPluginOnlyAllowlist(
policy: ToolPolicyLike | undefined,
groups: PluginToolGroups,
): ToolPolicyLike | undefined {
if (!policy?.allow || policy.allow.length === 0) return policy;
const normalized = normalizeToolList(policy.allow);
if (normalized.length === 0) return policy;
const pluginIds = new Set(groups.byPlugin.keys());
const pluginTools = new Set(groups.all);
const isPluginEntry = (entry: string) =>
entry === "group:plugins" || pluginIds.has(entry) || pluginTools.has(entry);
const isPluginOnly = normalized.every((entry) => isPluginEntry(entry));
if (!isPluginOnly) return policy;
return { ...policy, allow: undefined };
}
export function resolveToolProfilePolicy(profile?: string): ToolProfilePolicy | undefined {
if (!profile) return undefined;
const resolved = TOOL_PROFILES[profile as ToolProfileId];
+43
View File
@@ -5,6 +5,10 @@ vi.mock("../../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
vi.mock("../agent-scope.js", () => ({
resolveSessionAgentId: () => "agent-123",
}));
import { createCronTool } from "./cron-tool.js";
describe("cron tool", () => {
@@ -85,6 +89,23 @@ describe("cron tool", () => {
});
});
it("does not default agentId when job.agentId is null", async () => {
const tool = createCronTool({ agentSessionKey: "main" });
await tool.execute("call-null", {
action: "add",
job: {
name: "wake-up",
schedule: { atMs: 123 },
agentId: null,
},
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: { agentId?: unknown };
};
expect(call?.params?.agentId).toBeNull();
});
it("adds recent context for systemEvent reminders when contextMessages > 0", async () => {
callGatewayMock
.mockResolvedValueOnce({
@@ -188,4 +209,26 @@ describe("cron tool", () => {
const text = cronCall.params?.payload?.text ?? "";
expect(text).not.toContain("Recent context:");
});
it("preserves explicit agentId null on add", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createCronTool({ agentSessionKey: "main" });
await tool.execute("call6", {
action: "add",
job: {
name: "reminder",
schedule: { atMs: 123 },
agentId: null,
payload: { kind: "systemEvent", text: "Reminder: the thing." },
},
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
params?: { agentId?: string | null };
};
expect(call.method).toBe("cron.add");
expect(call.params?.agentId).toBeNull();
});
});
+10
View File
@@ -3,6 +3,7 @@ import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normal
import { loadConfig } from "../../config/config.js";
import { truncateUtf16Safe } from "../../utils.js";
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
import { resolveSessionAgentId } from "../agent-scope.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
@@ -158,6 +159,15 @@ export function createCronTool(opts?: CronToolOptions): AnyAgentTool {
throw new Error("job required");
}
const job = normalizeCronJobCreate(params.job) ?? params.job;
if (job && typeof job === "object" && !("agentId" in job)) {
const cfg = loadConfig();
const agentId = opts?.agentSessionKey
? resolveSessionAgentId({ sessionKey: opts.agentSessionKey, config: cfg })
: undefined;
if (agentId) {
(job as { agentId?: string }).agentId = agentId;
}
}
const contextMessages =
typeof params.contextMessages === "number" && Number.isFinite(params.contextMessages)
? params.contextMessages
+192 -68
View File
@@ -39,6 +39,7 @@ export async function handleDiscordGuildAction(
params: Record<string, unknown>,
isActionEnabled: ActionGate<DiscordActionConfig>,
): Promise<AgentToolResult<unknown>> {
const accountId = readStringParam(params, "accountId");
switch (action) {
case "memberInfo": {
if (!isActionEnabled("memberInfo")) {
@@ -50,7 +51,9 @@ export async function handleDiscordGuildAction(
const userId = readStringParam(params, "userId", {
required: true,
});
const member = await fetchMemberInfoDiscord(guildId, userId);
const member = accountId
? await fetchMemberInfoDiscord(guildId, userId, { accountId })
: await fetchMemberInfoDiscord(guildId, userId);
return jsonResult({ ok: true, member });
}
case "roleInfo": {
@@ -60,7 +63,9 @@ export async function handleDiscordGuildAction(
const guildId = readStringParam(params, "guildId", {
required: true,
});
const roles = await fetchRoleInfoDiscord(guildId);
const roles = accountId
? await fetchRoleInfoDiscord(guildId, { accountId })
: await fetchRoleInfoDiscord(guildId);
return jsonResult({ ok: true, roles });
}
case "emojiList": {
@@ -70,7 +75,9 @@ export async function handleDiscordGuildAction(
const guildId = readStringParam(params, "guildId", {
required: true,
});
const emojis = await listGuildEmojisDiscord(guildId);
const emojis = accountId
? await listGuildEmojisDiscord(guildId, { accountId })
: await listGuildEmojisDiscord(guildId);
return jsonResult({ ok: true, emojis });
}
case "emojiUpload": {
@@ -85,12 +92,22 @@ export async function handleDiscordGuildAction(
required: true,
});
const roleIds = readStringArrayParam(params, "roleIds");
const emoji = await uploadEmojiDiscord({
guildId,
name,
mediaUrl,
roleIds: roleIds?.length ? roleIds : undefined,
});
const emoji = accountId
? await uploadEmojiDiscord(
{
guildId,
name,
mediaUrl,
roleIds: roleIds?.length ? roleIds : undefined,
},
{ accountId },
)
: await uploadEmojiDiscord({
guildId,
name,
mediaUrl,
roleIds: roleIds?.length ? roleIds : undefined,
});
return jsonResult({ ok: true, emoji });
}
case "stickerUpload": {
@@ -108,13 +125,24 @@ export async function handleDiscordGuildAction(
const mediaUrl = readStringParam(params, "mediaUrl", {
required: true,
});
const sticker = await uploadStickerDiscord({
guildId,
name,
description,
tags,
mediaUrl,
});
const sticker = accountId
? await uploadStickerDiscord(
{
guildId,
name,
description,
tags,
mediaUrl,
},
{ accountId },
)
: await uploadStickerDiscord({
guildId,
name,
description,
tags,
mediaUrl,
});
return jsonResult({ ok: true, sticker });
}
case "roleAdd": {
@@ -128,7 +156,11 @@ export async function handleDiscordGuildAction(
required: true,
});
const roleId = readStringParam(params, "roleId", { required: true });
await addRoleDiscord({ guildId, userId, roleId });
if (accountId) {
await addRoleDiscord({ guildId, userId, roleId }, { accountId });
} else {
await addRoleDiscord({ guildId, userId, roleId });
}
return jsonResult({ ok: true });
}
case "roleRemove": {
@@ -142,7 +174,11 @@ export async function handleDiscordGuildAction(
required: true,
});
const roleId = readStringParam(params, "roleId", { required: true });
await removeRoleDiscord({ guildId, userId, roleId });
if (accountId) {
await removeRoleDiscord({ guildId, userId, roleId }, { accountId });
} else {
await removeRoleDiscord({ guildId, userId, roleId });
}
return jsonResult({ ok: true });
}
case "channelInfo": {
@@ -152,7 +188,9 @@ export async function handleDiscordGuildAction(
const channelId = readStringParam(params, "channelId", {
required: true,
});
const channel = await fetchChannelInfoDiscord(channelId);
const channel = accountId
? await fetchChannelInfoDiscord(channelId, { accountId })
: await fetchChannelInfoDiscord(channelId);
return jsonResult({ ok: true, channel });
}
case "channelList": {
@@ -162,7 +200,9 @@ export async function handleDiscordGuildAction(
const guildId = readStringParam(params, "guildId", {
required: true,
});
const channels = await listGuildChannelsDiscord(guildId);
const channels = accountId
? await listGuildChannelsDiscord(guildId, { accountId })
: await listGuildChannelsDiscord(guildId);
return jsonResult({ ok: true, channels });
}
case "voiceStatus": {
@@ -175,7 +215,9 @@ export async function handleDiscordGuildAction(
const userId = readStringParam(params, "userId", {
required: true,
});
const voice = await fetchVoiceStatusDiscord(guildId, userId);
const voice = accountId
? await fetchVoiceStatusDiscord(guildId, userId, { accountId })
: await fetchVoiceStatusDiscord(guildId, userId);
return jsonResult({ ok: true, voice });
}
case "eventList": {
@@ -185,7 +227,9 @@ export async function handleDiscordGuildAction(
const guildId = readStringParam(params, "guildId", {
required: true,
});
const events = await listScheduledEventsDiscord(guildId);
const events = accountId
? await listScheduledEventsDiscord(guildId, { accountId })
: await listScheduledEventsDiscord(guildId);
return jsonResult({ ok: true, events });
}
case "eventCreate": {
@@ -215,7 +259,9 @@ export async function handleDiscordGuildAction(
entity_metadata: entityType === 3 && location ? { location } : undefined,
privacy_level: 2,
};
const event = await createScheduledEventDiscord(guildId, payload);
const event = accountId
? await createScheduledEventDiscord(guildId, payload, { accountId })
: await createScheduledEventDiscord(guildId, payload);
return jsonResult({ ok: true, event });
}
case "channelCreate": {
@@ -229,15 +275,28 @@ export async function handleDiscordGuildAction(
const topic = readStringParam(params, "topic");
const position = readNumberParam(params, "position", { integer: true });
const nsfw = params.nsfw as boolean | undefined;
const channel = await createChannelDiscord({
guildId,
name,
type: type ?? undefined,
parentId: parentId ?? undefined,
topic: topic ?? undefined,
position: position ?? undefined,
nsfw,
});
const channel = accountId
? await createChannelDiscord(
{
guildId,
name,
type: type ?? undefined,
parentId: parentId ?? undefined,
topic: topic ?? undefined,
position: position ?? undefined,
nsfw,
},
{ accountId },
)
: await createChannelDiscord({
guildId,
name,
type: type ?? undefined,
parentId: parentId ?? undefined,
topic: topic ?? undefined,
position: position ?? undefined,
nsfw,
});
return jsonResult({ ok: true, channel });
}
case "channelEdit": {
@@ -255,15 +314,28 @@ export async function handleDiscordGuildAction(
const rateLimitPerUser = readNumberParam(params, "rateLimitPerUser", {
integer: true,
});
const channel = await editChannelDiscord({
channelId,
name: name ?? undefined,
topic: topic ?? undefined,
position: position ?? undefined,
parentId,
nsfw,
rateLimitPerUser: rateLimitPerUser ?? undefined,
});
const channel = accountId
? await editChannelDiscord(
{
channelId,
name: name ?? undefined,
topic: topic ?? undefined,
position: position ?? undefined,
parentId,
nsfw,
rateLimitPerUser: rateLimitPerUser ?? undefined,
},
{ accountId },
)
: await editChannelDiscord({
channelId,
name: name ?? undefined,
topic: topic ?? undefined,
position: position ?? undefined,
parentId,
nsfw,
rateLimitPerUser: rateLimitPerUser ?? undefined,
});
return jsonResult({ ok: true, channel });
}
case "channelDelete": {
@@ -273,7 +345,9 @@ export async function handleDiscordGuildAction(
const channelId = readStringParam(params, "channelId", {
required: true,
});
const result = await deleteChannelDiscord(channelId);
const result = accountId
? await deleteChannelDiscord(channelId, { accountId })
: await deleteChannelDiscord(channelId);
return jsonResult(result);
}
case "channelMove": {
@@ -286,12 +360,24 @@ export async function handleDiscordGuildAction(
});
const parentId = readParentIdParam(params);
const position = readNumberParam(params, "position", { integer: true });
await moveChannelDiscord({
guildId,
channelId,
parentId,
position: position ?? undefined,
});
if (accountId) {
await moveChannelDiscord(
{
guildId,
channelId,
parentId,
position: position ?? undefined,
},
{ accountId },
);
} else {
await moveChannelDiscord({
guildId,
channelId,
parentId,
position: position ?? undefined,
});
}
return jsonResult({ ok: true });
}
case "categoryCreate": {
@@ -301,12 +387,22 @@ export async function handleDiscordGuildAction(
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "name", { required: true });
const position = readNumberParam(params, "position", { integer: true });
const channel = await createChannelDiscord({
guildId,
name,
type: 4,
position: position ?? undefined,
});
const channel = accountId
? await createChannelDiscord(
{
guildId,
name,
type: 4,
position: position ?? undefined,
},
{ accountId },
)
: await createChannelDiscord({
guildId,
name,
type: 4,
position: position ?? undefined,
});
return jsonResult({ ok: true, category: channel });
}
case "categoryEdit": {
@@ -318,11 +414,20 @@ export async function handleDiscordGuildAction(
});
const name = readStringParam(params, "name");
const position = readNumberParam(params, "position", { integer: true });
const channel = await editChannelDiscord({
channelId: categoryId,
name: name ?? undefined,
position: position ?? undefined,
});
const channel = accountId
? await editChannelDiscord(
{
channelId: categoryId,
name: name ?? undefined,
position: position ?? undefined,
},
{ accountId },
)
: await editChannelDiscord({
channelId: categoryId,
name: name ?? undefined,
position: position ?? undefined,
});
return jsonResult({ ok: true, category: channel });
}
case "categoryDelete": {
@@ -332,7 +437,9 @@ export async function handleDiscordGuildAction(
const categoryId = readStringParam(params, "categoryId", {
required: true,
});
const result = await deleteChannelDiscord(categoryId);
const result = accountId
? await deleteChannelDiscord(categoryId, { accountId })
: await deleteChannelDiscord(categoryId);
return jsonResult(result);
}
case "channelPermissionSet": {
@@ -349,13 +456,26 @@ export async function handleDiscordGuildAction(
const targetType = targetTypeRaw === "member" ? 1 : 0;
const allow = readStringParam(params, "allow");
const deny = readStringParam(params, "deny");
await setChannelPermissionDiscord({
channelId,
targetId,
targetType,
allow: allow ?? undefined,
deny: deny ?? undefined,
});
if (accountId) {
await setChannelPermissionDiscord(
{
channelId,
targetId,
targetType,
allow: allow ?? undefined,
deny: deny ?? undefined,
},
{ accountId },
);
} else {
await setChannelPermissionDiscord({
channelId,
targetId,
targetType,
allow: allow ?? undefined,
deny: deny ?? undefined,
});
}
return jsonResult({ ok: true });
}
case "channelPermissionRemove": {
@@ -366,7 +486,11 @@ export async function handleDiscordGuildAction(
required: true,
});
const targetId = readStringParam(params, "targetId", { required: true });
await removeChannelPermissionDiscord(channelId, targetId);
if (accountId) {
await removeChannelPermissionDiscord(channelId, targetId, { accountId });
} else {
await removeChannelPermissionDiscord(channelId, targetId);
}
return jsonResult({ ok: true });
}
default:
+97 -35
View File
@@ -58,6 +58,7 @@ export async function handleDiscordMessagingAction(
required: true,
}),
);
const accountId = readStringParam(params, "accountId");
const normalizeMessage = (message: unknown) => {
if (!message || typeof message !== "object") return message;
return withNormalizedTimestamp(
@@ -78,14 +79,24 @@ export async function handleDiscordMessagingAction(
removeErrorMessage: "Emoji is required to remove a Discord reaction.",
});
if (remove) {
await removeReactionDiscord(channelId, messageId, emoji);
if (accountId) {
await removeReactionDiscord(channelId, messageId, emoji, { accountId });
} else {
await removeReactionDiscord(channelId, messageId, emoji);
}
return jsonResult({ ok: true, removed: emoji });
}
if (isEmpty) {
const removed = await removeOwnReactionsDiscord(channelId, messageId);
const removed = accountId
? await removeOwnReactionsDiscord(channelId, messageId, { accountId })
: await removeOwnReactionsDiscord(channelId, messageId);
return jsonResult({ ok: true, removed: removed.removed });
}
await reactMessageDiscord(channelId, messageId, emoji);
if (accountId) {
await reactMessageDiscord(channelId, messageId, emoji, { accountId });
} else {
await reactMessageDiscord(channelId, messageId, emoji);
}
return jsonResult({ ok: true, added: emoji });
}
case "reactions": {
@@ -100,6 +111,7 @@ export async function handleDiscordMessagingAction(
const limit =
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
const reactions = await fetchReactionsDiscord(channelId, messageId, {
...(accountId ? { accountId } : {}),
limit,
});
return jsonResult({ ok: true, reactions });
@@ -114,7 +126,10 @@ export async function handleDiscordMessagingAction(
required: true,
label: "stickerIds",
});
await sendStickerDiscord(to, stickerIds, { content });
await sendStickerDiscord(to, stickerIds, {
...(accountId ? { accountId } : {}),
content,
});
return jsonResult({ ok: true });
}
case "poll": {
@@ -140,7 +155,7 @@ export async function handleDiscordMessagingAction(
await sendPollDiscord(
to,
{ question, options: answers, maxSelections, durationHours },
{ content },
{ ...(accountId ? { accountId } : {}), content },
);
return jsonResult({ ok: true });
}
@@ -149,7 +164,9 @@ export async function handleDiscordMessagingAction(
throw new Error("Discord permissions are disabled.");
}
const channelId = resolveChannelId();
const permissions = await fetchChannelPermissionsDiscord(channelId);
const permissions = accountId
? await fetchChannelPermissionsDiscord(channelId, { accountId })
: await fetchChannelPermissionsDiscord(channelId);
return jsonResult({ ok: true, permissions });
}
case "fetchMessage": {
@@ -171,7 +188,9 @@ export async function handleDiscordMessagingAction(
"Discord message fetch requires guildId, channelId, and messageId (or a valid messageLink).",
);
}
const message = await fetchMessageDiscord(channelId, messageId);
const message = accountId
? await fetchMessageDiscord(channelId, messageId, { accountId })
: await fetchMessageDiscord(channelId, messageId);
return jsonResult({
ok: true,
message: normalizeMessage(message),
@@ -185,7 +204,7 @@ export async function handleDiscordMessagingAction(
throw new Error("Discord message reads are disabled.");
}
const channelId = resolveChannelId();
const messages = await readMessagesDiscord(channelId, {
const query = {
limit:
typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit
@@ -193,7 +212,10 @@ export async function handleDiscordMessagingAction(
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
around: readStringParam(params, "around"),
});
};
const messages = accountId
? await readMessagesDiscord(channelId, query, { accountId })
: await readMessagesDiscord(channelId, query);
return jsonResult({
ok: true,
messages: messages.map((message) => normalizeMessage(message)),
@@ -212,6 +234,7 @@ export async function handleDiscordMessagingAction(
const embeds =
Array.isArray(params.embeds) && params.embeds.length > 0 ? params.embeds : undefined;
const result = await sendMessageDiscord(to, content, {
...(accountId ? { accountId } : {}),
mediaUrl,
replyTo,
embeds,
@@ -229,9 +252,9 @@ export async function handleDiscordMessagingAction(
const content = readStringParam(params, "content", {
required: true,
});
const message = await editMessageDiscord(channelId, messageId, {
content,
});
const message = accountId
? await editMessageDiscord(channelId, messageId, { content }, { accountId })
: await editMessageDiscord(channelId, messageId, { content });
return jsonResult({ ok: true, message });
}
case "deleteMessage": {
@@ -242,7 +265,11 @@ export async function handleDiscordMessagingAction(
const messageId = readStringParam(params, "messageId", {
required: true,
});
await deleteMessageDiscord(channelId, messageId);
if (accountId) {
await deleteMessageDiscord(channelId, messageId, { accountId });
} else {
await deleteMessageDiscord(channelId, messageId);
}
return jsonResult({ ok: true });
}
case "threadCreate": {
@@ -257,11 +284,13 @@ export async function handleDiscordMessagingAction(
typeof autoArchiveMinutesRaw === "number" && Number.isFinite(autoArchiveMinutesRaw)
? autoArchiveMinutesRaw
: undefined;
const thread = await createThreadDiscord(channelId, {
name,
messageId,
autoArchiveMinutes,
});
const thread = accountId
? await createThreadDiscord(
channelId,
{ name, messageId, autoArchiveMinutes },
{ accountId },
)
: await createThreadDiscord(channelId, { name, messageId, autoArchiveMinutes });
return jsonResult({ ok: true, thread });
}
case "threadList": {
@@ -279,13 +308,24 @@ export async function handleDiscordMessagingAction(
typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit
: undefined;
const threads = await listThreadsDiscord({
guildId,
channelId,
includeArchived,
before,
limit,
});
const threads = accountId
? await listThreadsDiscord(
{
guildId,
channelId,
includeArchived,
before,
limit,
},
{ accountId },
)
: await listThreadsDiscord({
guildId,
channelId,
includeArchived,
before,
limit,
});
return jsonResult({ ok: true, threads });
}
case "threadReply": {
@@ -299,6 +339,7 @@ export async function handleDiscordMessagingAction(
const mediaUrl = readStringParam(params, "mediaUrl");
const replyTo = readStringParam(params, "replyTo");
const result = await sendMessageDiscord(`channel:${channelId}`, content, {
...(accountId ? { accountId } : {}),
mediaUrl,
replyTo,
});
@@ -312,7 +353,11 @@ export async function handleDiscordMessagingAction(
const messageId = readStringParam(params, "messageId", {
required: true,
});
await pinMessageDiscord(channelId, messageId);
if (accountId) {
await pinMessageDiscord(channelId, messageId, { accountId });
} else {
await pinMessageDiscord(channelId, messageId);
}
return jsonResult({ ok: true });
}
case "unpinMessage": {
@@ -323,7 +368,11 @@ export async function handleDiscordMessagingAction(
const messageId = readStringParam(params, "messageId", {
required: true,
});
await unpinMessageDiscord(channelId, messageId);
if (accountId) {
await unpinMessageDiscord(channelId, messageId, { accountId });
} else {
await unpinMessageDiscord(channelId, messageId);
}
return jsonResult({ ok: true });
}
case "listPins": {
@@ -331,7 +380,9 @@ export async function handleDiscordMessagingAction(
throw new Error("Discord pins are disabled.");
}
const channelId = resolveChannelId();
const pins = await listPinsDiscord(channelId);
const pins = accountId
? await listPinsDiscord(channelId, { accountId })
: await listPinsDiscord(channelId);
return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) });
}
case "searchMessages": {
@@ -354,13 +405,24 @@ export async function handleDiscordMessagingAction(
: undefined;
const channelIdList = [...(channelIds ?? []), ...(channelId ? [channelId] : [])];
const authorIdList = [...(authorIds ?? []), ...(authorId ? [authorId] : [])];
const results = await searchMessagesDiscord({
guildId,
content,
channelIds: channelIdList.length ? channelIdList : undefined,
authorIds: authorIdList.length ? authorIdList : undefined,
limit,
});
const results = accountId
? await searchMessagesDiscord(
{
guildId,
content,
channelIds: channelIdList.length ? channelIdList : undefined,
authorIds: authorIdList.length ? authorIdList : undefined,
limit,
},
{ accountId },
)
: await searchMessagesDiscord({
guildId,
content,
channelIds: channelIdList.length ? channelIdList : undefined,
authorIds: authorIdList.length ? authorIdList : undefined,
limit,
});
if (!results || typeof results !== "object") {
return jsonResult({ ok: true, results });
}
+42 -14
View File
@@ -8,6 +8,7 @@ export async function handleDiscordModerationAction(
params: Record<string, unknown>,
isActionEnabled: ActionGate<DiscordActionConfig>,
): Promise<AgentToolResult<unknown>> {
const accountId = readStringParam(params, "accountId");
switch (action) {
case "timeout": {
if (!isActionEnabled("moderation", false)) {
@@ -25,13 +26,24 @@ export async function handleDiscordModerationAction(
: undefined;
const until = readStringParam(params, "until");
const reason = readStringParam(params, "reason");
const member = await timeoutMemberDiscord({
guildId,
userId,
durationMinutes,
until,
reason,
});
const member = accountId
? await timeoutMemberDiscord(
{
guildId,
userId,
durationMinutes,
until,
reason,
},
{ accountId },
)
: await timeoutMemberDiscord({
guildId,
userId,
durationMinutes,
until,
reason,
});
return jsonResult({ ok: true, member });
}
case "kick": {
@@ -45,7 +57,11 @@ export async function handleDiscordModerationAction(
required: true,
});
const reason = readStringParam(params, "reason");
await kickMemberDiscord({ guildId, userId, reason });
if (accountId) {
await kickMemberDiscord({ guildId, userId, reason }, { accountId });
} else {
await kickMemberDiscord({ guildId, userId, reason });
}
return jsonResult({ ok: true });
}
case "ban": {
@@ -63,12 +79,24 @@ export async function handleDiscordModerationAction(
typeof params.deleteMessageDays === "number" && Number.isFinite(params.deleteMessageDays)
? params.deleteMessageDays
: undefined;
await banMemberDiscord({
guildId,
userId,
reason,
deleteMessageDays,
});
if (accountId) {
await banMemberDiscord(
{
guildId,
userId,
reason,
deleteMessageDays,
},
{ accountId },
);
} else {
await banMemberDiscord({
guildId,
userId,
reason,
deleteMessageDays,
});
}
return jsonResult({ ok: true });
}
default:
+57
View File
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import type { DiscordActionConfig } from "../../config/config.js";
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
const createChannelDiscord = vi.fn(async () => ({
id: "new-channel",
@@ -20,6 +21,7 @@ const editMessageDiscord = vi.fn(async () => ({}));
const fetchMessageDiscord = vi.fn(async () => ({}));
const fetchChannelPermissionsDiscord = vi.fn(async () => ({}));
const fetchReactionsDiscord = vi.fn(async () => ({}));
const listGuildChannelsDiscord = vi.fn(async () => []);
const listPinsDiscord = vi.fn(async () => ({}));
const listThreadsDiscord = vi.fn(async () => ({}));
const moveChannelDiscord = vi.fn(async () => ({ ok: true }));
@@ -35,8 +37,12 @@ const sendPollDiscord = vi.fn(async () => ({}));
const sendStickerDiscord = vi.fn(async () => ({}));
const setChannelPermissionDiscord = vi.fn(async () => ({ ok: true }));
const unpinMessageDiscord = vi.fn(async () => ({}));
const timeoutMemberDiscord = vi.fn(async () => ({}));
const kickMemberDiscord = vi.fn(async () => ({}));
const banMemberDiscord = vi.fn(async () => ({}));
vi.mock("../../discord/send.js", () => ({
banMemberDiscord: (...args: unknown[]) => banMemberDiscord(...args),
createChannelDiscord: (...args: unknown[]) => createChannelDiscord(...args),
createThreadDiscord: (...args: unknown[]) => createThreadDiscord(...args),
deleteChannelDiscord: (...args: unknown[]) => deleteChannelDiscord(...args),
@@ -46,6 +52,8 @@ vi.mock("../../discord/send.js", () => ({
fetchMessageDiscord: (...args: unknown[]) => fetchMessageDiscord(...args),
fetchChannelPermissionsDiscord: (...args: unknown[]) => fetchChannelPermissionsDiscord(...args),
fetchReactionsDiscord: (...args: unknown[]) => fetchReactionsDiscord(...args),
kickMemberDiscord: (...args: unknown[]) => kickMemberDiscord(...args),
listGuildChannelsDiscord: (...args: unknown[]) => listGuildChannelsDiscord(...args),
listPinsDiscord: (...args: unknown[]) => listPinsDiscord(...args),
listThreadsDiscord: (...args: unknown[]) => listThreadsDiscord(...args),
moveChannelDiscord: (...args: unknown[]) => moveChannelDiscord(...args),
@@ -60,12 +68,15 @@ vi.mock("../../discord/send.js", () => ({
sendPollDiscord: (...args: unknown[]) => sendPollDiscord(...args),
sendStickerDiscord: (...args: unknown[]) => sendStickerDiscord(...args),
setChannelPermissionDiscord: (...args: unknown[]) => setChannelPermissionDiscord(...args),
timeoutMemberDiscord: (...args: unknown[]) => timeoutMemberDiscord(...args),
unpinMessageDiscord: (...args: unknown[]) => unpinMessageDiscord(...args),
}));
const enableAllActions = () => true;
const disabledActions = (key: keyof DiscordActionConfig) => key !== "reactions";
const channelInfoEnabled = (key: keyof DiscordActionConfig) => key === "channelInfo";
const moderationEnabled = (key: keyof DiscordActionConfig) => key === "moderation";
describe("handleDiscordMessagingAction", () => {
it("adds reactions", async () => {
@@ -81,6 +92,20 @@ describe("handleDiscordMessagingAction", () => {
expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅");
});
it("forwards accountId for reactions", async () => {
await handleDiscordMessagingAction(
"react",
{
channelId: "C1",
messageId: "M1",
emoji: "✅",
accountId: "ops",
},
enableAllActions,
);
expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅", { accountId: "ops" });
});
it("removes reactions on empty emoji", async () => {
await handleDiscordMessagingAction(
"react",
@@ -245,6 +270,15 @@ describe("handleDiscordGuildAction - channel management", () => {
).rejects.toThrow(/Discord channel management is disabled/);
});
it("forwards accountId for channelList", async () => {
await handleDiscordGuildAction(
"channelList",
{ guildId: "G1", accountId: "ops" },
channelInfoEnabled,
);
expect(listGuildChannelsDiscord).toHaveBeenCalledWith("G1", { accountId: "ops" });
});
it("edits a channel", async () => {
await handleDiscordGuildAction(
"channelEdit",
@@ -448,3 +482,26 @@ describe("handleDiscordGuildAction - channel management", () => {
expect(removeChannelPermissionDiscord).toHaveBeenCalledWith("C1", "R1");
});
});
describe("handleDiscordModerationAction", () => {
it("forwards accountId for timeout", async () => {
await handleDiscordModerationAction(
"timeout",
{
guildId: "G1",
userId: "U1",
durationMinutes: 5,
accountId: "ops",
},
moderationEnabled,
);
expect(timeoutMemberDiscord).toHaveBeenCalledWith(
expect.objectContaining({
guildId: "G1",
userId: "U1",
durationMinutes: 5,
}),
{ accountId: "ops" },
);
});
});
+3
View File
@@ -342,6 +342,9 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
}) as ChannelMessageActionName;
const accountId = readStringParam(params, "accountId") ?? agentAccountId;
if (accountId) {
params.accountId = accountId;
}
const gateway = {
url: readStringParam(params, "gatewayUrl", { trim: false }),
+110 -4
View File
@@ -1,11 +1,41 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../../config/config.js";
import { discordMessageActions } from "./discord.js";
type SendMessageDiscord = typeof import("../../../discord/send.js").sendMessageDiscord;
type SendPollDiscord = typeof import("../../../discord/send.js").sendPollDiscord;
const sendMessageDiscord = vi.fn<Parameters<SendMessageDiscord>, ReturnType<SendMessageDiscord>>(
async () => ({ ok: true }) as Awaited<ReturnType<SendMessageDiscord>>,
);
const sendPollDiscord = vi.fn<Parameters<SendPollDiscord>, ReturnType<SendPollDiscord>>(
async () => ({ ok: true }) as Awaited<ReturnType<SendPollDiscord>>,
);
vi.mock("../../../discord/send.js", async () => {
const actual = await vi.importActual<typeof import("../../../discord/send.js")>(
"../../../discord/send.js",
);
return {
...actual,
sendMessageDiscord: (...args: Parameters<SendMessageDiscord>) => sendMessageDiscord(...args),
sendPollDiscord: (...args: Parameters<SendPollDiscord>) => sendPollDiscord(...args),
};
});
const loadHandleDiscordMessageAction = async () => {
const mod = await import("./discord/handle-action.js");
return mod.handleDiscordMessageAction;
};
const loadDiscordMessageActions = async () => {
const mod = await import("./discord.js");
return mod.discordMessageActions;
};
describe("discord message actions", () => {
it("lists channel and upload actions by default", () => {
it("lists channel and upload actions by default", async () => {
const cfg = { channels: { discord: { token: "d0" } } } as ClawdbotConfig;
const discordMessageActions = await loadDiscordMessageActions();
const actions = discordMessageActions.listActions?.({ cfg }) ?? [];
expect(actions).toContain("emoji-upload");
@@ -13,12 +43,88 @@ describe("discord message actions", () => {
expect(actions).toContain("channel-create");
});
it("respects disabled channel actions", () => {
it("respects disabled channel actions", async () => {
const cfg = {
channels: { discord: { token: "d0", actions: { channels: false } } },
} as ClawdbotConfig;
const discordMessageActions = await loadDiscordMessageActions();
const actions = discordMessageActions.listActions?.({ cfg }) ?? [];
expect(actions).not.toContain("channel-create");
});
});
describe("handleDiscordMessageAction", () => {
it("forwards context accountId for send", async () => {
sendMessageDiscord.mockClear();
const handleDiscordMessageAction = await loadHandleDiscordMessageAction();
await handleDiscordMessageAction({
action: "send",
params: {
to: "channel:123",
message: "hi",
},
cfg: {} as ClawdbotConfig,
accountId: "ops",
});
expect(sendMessageDiscord).toHaveBeenCalledWith(
"channel:123",
"hi",
expect.objectContaining({
accountId: "ops",
}),
);
});
it("falls back to params accountId when context missing", async () => {
sendPollDiscord.mockClear();
const handleDiscordMessageAction = await loadHandleDiscordMessageAction();
await handleDiscordMessageAction({
action: "poll",
params: {
to: "channel:123",
pollQuestion: "Ready?",
pollOption: ["Yes", "No"],
accountId: "marve",
},
cfg: {} as ClawdbotConfig,
});
expect(sendPollDiscord).toHaveBeenCalledWith(
"channel:123",
expect.objectContaining({
question: "Ready?",
options: ["Yes", "No"],
}),
expect.objectContaining({
accountId: "marve",
}),
);
});
it("forwards accountId for thread replies", async () => {
sendMessageDiscord.mockClear();
const handleDiscordMessageAction = await loadHandleDiscordMessageAction();
await handleDiscordMessageAction({
action: "thread-reply",
params: {
channelId: "123",
message: "hi",
},
cfg: {} as ClawdbotConfig,
accountId: "ops",
});
expect(sendMessageDiscord).toHaveBeenCalledWith(
"channel:123",
"hi",
expect.objectContaining({
accountId: "ops",
}),
);
});
});
+2 -2
View File
@@ -80,7 +80,7 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
}
return null;
},
handleAction: async ({ action, params, cfg }) => {
return await handleDiscordMessageAction({ action, params, cfg });
handleAction: async ({ action, params, cfg, accountId }) => {
return await handleDiscordMessageAction({ action, params, cfg, accountId });
},
};
@@ -7,7 +7,7 @@ import {
import { handleDiscordAction } from "../../../../agents/tools/discord-actions.js";
import type { ChannelMessageActionContext } from "../../types.js";
type Ctx = Pick<ChannelMessageActionContext, "action" | "params" | "cfg">;
type Ctx = Pick<ChannelMessageActionContext, "action" | "params" | "cfg" | "accountId">;
export async function tryHandleDiscordMessageActionGuildAdmin(params: {
ctx: Ctx;
@@ -16,27 +16,37 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
}): Promise<AgentToolResult<unknown> | undefined> {
const { ctx, resolveChannelId, readParentIdParam } = params;
const { action, params: actionParams, cfg } = ctx;
const accountId = ctx.accountId ?? readStringParam(actionParams, "accountId");
if (action === "member-info") {
const userId = readStringParam(actionParams, "userId", { required: true });
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
return await handleDiscordAction({ action: "memberInfo", guildId, userId }, cfg);
return await handleDiscordAction(
{ action: "memberInfo", accountId: accountId ?? undefined, guildId, userId },
cfg,
);
}
if (action === "role-info") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
return await handleDiscordAction({ action: "roleInfo", guildId }, cfg);
return await handleDiscordAction(
{ action: "roleInfo", accountId: accountId ?? undefined, guildId },
cfg,
);
}
if (action === "emoji-list") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
return await handleDiscordAction({ action: "emojiList", guildId }, cfg);
return await handleDiscordAction(
{ action: "emojiList", accountId: accountId ?? undefined, guildId },
cfg,
);
}
if (action === "emoji-upload") {
@@ -50,7 +60,14 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
});
const roleIds = readStringArrayParam(actionParams, "roleIds");
return await handleDiscordAction(
{ action: "emojiUpload", guildId, name, mediaUrl, roleIds },
{
action: "emojiUpload",
accountId: accountId ?? undefined,
guildId,
name,
mediaUrl,
roleIds,
},
cfg,
);
}
@@ -73,7 +90,15 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
trim: false,
});
return await handleDiscordAction(
{ action: "stickerUpload", guildId, name, description, tags, mediaUrl },
{
action: "stickerUpload",
accountId: accountId ?? undefined,
guildId,
name,
description,
tags,
mediaUrl,
},
cfg,
);
}
@@ -87,6 +112,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
return await handleDiscordAction(
{
action: action === "role-add" ? "roleAdd" : "roleRemove",
accountId: accountId ?? undefined,
guildId,
userId,
roleId,
@@ -99,14 +125,20 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
const channelId = readStringParam(actionParams, "channelId", {
required: true,
});
return await handleDiscordAction({ action: "channelInfo", channelId }, cfg);
return await handleDiscordAction(
{ action: "channelInfo", accountId: accountId ?? undefined, channelId },
cfg,
);
}
if (action === "channel-list") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
return await handleDiscordAction({ action: "channelList", guildId }, cfg);
return await handleDiscordAction(
{ action: "channelList", accountId: accountId ?? undefined, guildId },
cfg,
);
}
if (action === "channel-create") {
@@ -124,6 +156,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
return await handleDiscordAction(
{
action: "channelCreate",
accountId: accountId ?? undefined,
guildId,
name,
type: type ?? undefined,
@@ -153,6 +186,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
return await handleDiscordAction(
{
action: "channelEdit",
accountId: accountId ?? undefined,
channelId,
name: name ?? undefined,
topic: topic ?? undefined,
@@ -169,7 +203,10 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
const channelId = readStringParam(actionParams, "channelId", {
required: true,
});
return await handleDiscordAction({ action: "channelDelete", channelId }, cfg);
return await handleDiscordAction(
{ action: "channelDelete", accountId: accountId ?? undefined, channelId },
cfg,
);
}
if (action === "channel-move") {
@@ -186,6 +223,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
return await handleDiscordAction(
{
action: "channelMove",
accountId: accountId ?? undefined,
guildId,
channelId,
parentId: parentId === undefined ? undefined : parentId,
@@ -206,6 +244,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
return await handleDiscordAction(
{
action: "categoryCreate",
accountId: accountId ?? undefined,
guildId,
name,
position: position ?? undefined,
@@ -225,6 +264,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
return await handleDiscordAction(
{
action: "categoryEdit",
accountId: accountId ?? undefined,
categoryId,
name: name ?? undefined,
position: position ?? undefined,
@@ -237,7 +277,10 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
const categoryId = readStringParam(actionParams, "categoryId", {
required: true,
});
return await handleDiscordAction({ action: "categoryDelete", categoryId }, cfg);
return await handleDiscordAction(
{ action: "categoryDelete", accountId: accountId ?? undefined, categoryId },
cfg,
);
}
if (action === "voice-status") {
@@ -245,14 +288,20 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
required: true,
});
const userId = readStringParam(actionParams, "userId", { required: true });
return await handleDiscordAction({ action: "voiceStatus", guildId, userId }, cfg);
return await handleDiscordAction(
{ action: "voiceStatus", accountId: accountId ?? undefined, guildId, userId },
cfg,
);
}
if (action === "event-list") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
return await handleDiscordAction({ action: "eventList", guildId }, cfg);
return await handleDiscordAction(
{ action: "eventList", accountId: accountId ?? undefined, guildId },
cfg,
);
}
if (action === "event-create") {
@@ -271,6 +320,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
return await handleDiscordAction(
{
action: "eventCreate",
accountId: accountId ?? undefined,
guildId,
name,
startTime,
@@ -301,6 +351,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
return await handleDiscordAction(
{
action: discordAction,
accountId: accountId ?? undefined,
guildId,
userId,
durationMinutes,
@@ -325,6 +376,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
return await handleDiscordAction(
{
action: "threadList",
accountId: accountId ?? undefined,
guildId,
channelId,
includeArchived,
@@ -344,6 +396,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
return await handleDiscordAction(
{
action: "threadReply",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
content,
mediaUrl: mediaUrl ?? undefined,
@@ -361,6 +414,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
return await handleDiscordAction(
{
action: "searchMessages",
accountId: accountId ?? undefined,
guildId,
content: query,
channelId: readStringParam(actionParams, "channelId"),
@@ -18,9 +18,10 @@ function readParentIdParam(params: Record<string, unknown>): string | null | und
}
export async function handleDiscordMessageAction(
ctx: Pick<ChannelMessageActionContext, "action" | "params" | "cfg">,
ctx: Pick<ChannelMessageActionContext, "action" | "params" | "cfg" | "accountId">,
): Promise<AgentToolResult<unknown>> {
const { action, params, cfg } = ctx;
const accountId = ctx.accountId ?? readStringParam(params, "accountId");
const resolveChannelId = () =>
resolveDiscordChannelId(
@@ -39,6 +40,7 @@ export async function handleDiscordMessageAction(
return await handleDiscordAction(
{
action: "sendMessage",
accountId: accountId ?? undefined,
to,
content,
mediaUrl: mediaUrl ?? undefined,
@@ -62,6 +64,7 @@ export async function handleDiscordMessageAction(
return await handleDiscordAction(
{
action: "poll",
accountId: accountId ?? undefined,
to,
question,
answers,
@@ -80,6 +83,7 @@ export async function handleDiscordMessageAction(
return await handleDiscordAction(
{
action: "react",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
messageId,
emoji,
@@ -93,7 +97,13 @@ export async function handleDiscordMessageAction(
const messageId = readStringParam(params, "messageId", { required: true });
const limit = readNumberParam(params, "limit", { integer: true });
return await handleDiscordAction(
{ action: "reactions", channelId: resolveChannelId(), messageId, limit },
{
action: "reactions",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
messageId,
limit,
},
cfg,
);
}
@@ -103,6 +113,7 @@ export async function handleDiscordMessageAction(
return await handleDiscordAction(
{
action: "readMessages",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
limit,
before: readStringParam(params, "before"),
@@ -119,6 +130,7 @@ export async function handleDiscordMessageAction(
return await handleDiscordAction(
{
action: "editMessage",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
messageId,
content,
@@ -130,7 +142,12 @@ export async function handleDiscordMessageAction(
if (action === "delete") {
const messageId = readStringParam(params, "messageId", { required: true });
return await handleDiscordAction(
{ action: "deleteMessage", channelId: resolveChannelId(), messageId },
{
action: "deleteMessage",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
messageId,
},
cfg,
);
}
@@ -141,6 +158,7 @@ export async function handleDiscordMessageAction(
return await handleDiscordAction(
{
action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
messageId,
},
@@ -149,7 +167,14 @@ export async function handleDiscordMessageAction(
}
if (action === "permissions") {
return await handleDiscordAction({ action: "permissions", channelId: resolveChannelId() }, cfg);
return await handleDiscordAction(
{
action: "permissions",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
},
cfg,
);
}
if (action === "thread-create") {
@@ -161,6 +186,7 @@ export async function handleDiscordMessageAction(
return await handleDiscordAction(
{
action: "threadCreate",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
name,
messageId,
@@ -179,6 +205,7 @@ export async function handleDiscordMessageAction(
return await handleDiscordAction(
{
action: "sticker",
accountId: accountId ?? undefined,
to: readStringParam(params, "to", { required: true }),
stickerIds,
content: readStringParam(params, "message"),
+21 -2
View File
@@ -415,7 +415,7 @@ describe("update-cli", () => {
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
});
it("updateCommand restarts daemon when --restart is set", async () => {
it("updateCommand restarts daemon by default", async () => {
const { runGatewayUpdate } = await import("../infra/update-runner.js");
const { runDaemonRestart } = await import("./daemon-cli.js");
const { updateCommand } = await import("./update-cli.js");
@@ -430,11 +430,30 @@ describe("update-cli", () => {
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
vi.mocked(runDaemonRestart).mockResolvedValue(true);
await updateCommand({ restart: true });
await updateCommand({});
expect(runDaemonRestart).toHaveBeenCalled();
});
it("updateCommand skips restart when --no-restart is set", async () => {
const { runGatewayUpdate } = await import("../infra/update-runner.js");
const { runDaemonRestart } = await import("./daemon-cli.js");
const { updateCommand } = await import("./update-cli.js");
const mockResult: UpdateRunResult = {
status: "ok",
mode: "git",
steps: [],
durationMs: 100,
};
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
await updateCommand({ restart: false });
expect(runDaemonRestart).not.toHaveBeenCalled();
});
it("updateCommand skips success message when restart does not run", async () => {
const { runGatewayUpdate } = await import("../infra/update-runner.js");
const { runDaemonRestart } = await import("./daemon-cli.js");
+5 -4
View File
@@ -553,6 +553,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
process.noDeprecation = true;
process.env.NODE_NO_WARNINGS = "1";
const timeoutMs = opts.timeout ? Number.parseInt(opts.timeout, 10) * 1000 : undefined;
const shouldRestart = opts.restart !== false;
if (timeoutMs !== undefined && (Number.isNaN(timeoutMs) || timeoutMs <= 0)) {
defaultRuntime.error("--timeout must be a positive integer (seconds)");
@@ -898,7 +899,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
}
// Restart service if requested
if (opts.restart) {
if (shouldRestart) {
if (!opts.json) {
defaultRuntime.log("");
defaultRuntime.log(theme.heading("Restarting service..."));
@@ -1068,7 +1069,7 @@ export async function updateWizardCommand(opts: UpdateWizardOptions = {}): Promi
const restart = await confirm({
message: stylePromptMessage("Restart the gateway service after update?"),
initialValue: false,
initialValue: true,
});
if (isCancel(restart)) {
defaultRuntime.log(theme.muted("Update cancelled."));
@@ -1093,7 +1094,7 @@ export function registerUpdateCli(program: Command) {
.command("update")
.description("Update Clawdbot to the latest version")
.option("--json", "Output result as JSON", false)
.option("--restart", "Restart the gateway service after a successful update", false)
.option("--no-restart", "Skip restarting the gateway service after a successful update")
.option("--channel <stable|beta|dev>", "Persist update channel (git + npm)")
.option("--tag <dist-tag|version>", "Override npm dist-tag or version for this update")
.option("--timeout <seconds>", "Timeout for each update step in seconds (default: 1200)")
@@ -1104,7 +1105,7 @@ export function registerUpdateCli(program: Command) {
["clawdbot update --channel beta", "Switch to beta channel (git + npm)"],
["clawdbot update --channel dev", "Switch to dev channel (git + npm)"],
["clawdbot update --tag beta", "One-off update to a dist-tag or version"],
["clawdbot update --restart", "Update and restart the service"],
["clawdbot update --no-restart", "Update without restarting the service"],
["clawdbot update --json", "Output result as JSON"],
["clawdbot update --yes", "Non-interactive (accept downgrade prompts)"],
["clawdbot update wizard", "Interactive update wizard"],
+60
View File
@@ -0,0 +1,60 @@
import { normalizeChannelId } from "../channels/plugins/index.js";
import { normalizeAccountId } from "../routing/session-key.js";
import type { ClawdbotConfig } from "./config.js";
import type { MarkdownTableMode } from "./types.base.js";
type MarkdownConfigEntry = {
markdown?: {
tables?: MarkdownTableMode;
};
};
type MarkdownConfigSection = MarkdownConfigEntry & {
accounts?: Record<string, MarkdownConfigEntry>;
};
const DEFAULT_TABLE_MODES = new Map<string, MarkdownTableMode>([
["signal", "bullets"],
["whatsapp", "bullets"],
]);
const isMarkdownTableMode = (value: unknown): value is MarkdownTableMode =>
value === "off" || value === "bullets" || value === "code";
function resolveMarkdownModeFromSection(
section: MarkdownConfigSection | undefined,
accountId?: string | null,
): MarkdownTableMode | undefined {
if (!section) return undefined;
const normalizedAccountId = normalizeAccountId(accountId);
const accounts = section.accounts;
if (accounts && typeof accounts === "object") {
const direct = accounts[normalizedAccountId];
const directMode = direct?.markdown?.tables;
if (isMarkdownTableMode(directMode)) return directMode;
const matchKey = Object.keys(accounts).find(
(key) => key.toLowerCase() === normalizedAccountId.toLowerCase(),
);
const match = matchKey ? accounts[matchKey] : undefined;
const matchMode = match?.markdown?.tables;
if (isMarkdownTableMode(matchMode)) return matchMode;
}
const sectionMode = section.markdown?.tables;
return isMarkdownTableMode(sectionMode) ? sectionMode : undefined;
}
export function resolveMarkdownTableMode(params: {
cfg?: Partial<ClawdbotConfig>;
channel?: string | null;
accountId?: string | null;
}): MarkdownTableMode {
const channel = normalizeChannelId(params.channel);
const defaultMode = channel ? (DEFAULT_TABLE_MODES.get(channel) ?? "code") : "code";
if (!channel || !params.cfg) return defaultMode;
const channelsConfig = params.cfg.channels as Record<string, unknown> | undefined;
const section = (channelsConfig?.[channel] ??
(params.cfg as Record<string, unknown> | undefined)?.[channel]) as
| MarkdownConfigSection
| undefined;
return resolveMarkdownModeFromSection(section, params.accountId) ?? defaultMode;
}
+7
View File
@@ -31,6 +31,13 @@ export type BlockStreamingChunkConfig = {
breakPreference?: "paragraph" | "newline" | "sentence";
};
export type MarkdownTableMode = "off" | "bullets" | "code";
export type MarkdownConfig = {
/** Table rendering mode (off|bullets|code). */
tables?: MarkdownTableMode;
};
export type HumanDelayConfig = {
/** Delay style for block replies (off|natural|custom). */
mode?: "off" | "natural" | "custom";
+3
View File
@@ -2,6 +2,7 @@ import type {
BlockStreamingCoalesceConfig,
DmPolicy,
GroupPolicy,
MarkdownConfig,
OutboundRetryConfig,
ReplyToMode,
} from "./types.base.js";
@@ -70,6 +71,8 @@ export type DiscordAccountConfig = {
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** Markdown formatting overrides (tables). */
markdown?: MarkdownConfig;
/** Override native command registration for Discord (bool or "auto"). */
commands?: ProviderCommandsConfig;
/** Allow channel-initiated config writes (default: true). */
+8 -1
View File
@@ -1,4 +1,9 @@
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js";
import type {
BlockStreamingCoalesceConfig,
DmPolicy,
GroupPolicy,
MarkdownConfig,
} from "./types.base.js";
import type { DmConfig } from "./types.messages.js";
export type IMessageAccountConfig = {
@@ -6,6 +11,8 @@ export type IMessageAccountConfig = {
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** Markdown formatting overrides (tables). */
markdown?: MarkdownConfig;
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** If false, do not start this iMessage account. Default: true. */
+8 -1
View File
@@ -1,4 +1,9 @@
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js";
import type {
BlockStreamingCoalesceConfig,
DmPolicy,
GroupPolicy,
MarkdownConfig,
} from "./types.base.js";
import type { DmConfig } from "./types.messages.js";
export type MSTeamsWebhookConfig = {
@@ -34,6 +39,8 @@ export type MSTeamsConfig = {
enabled?: boolean;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** Markdown formatting overrides (tables). */
markdown?: MarkdownConfig;
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** Azure Bot App ID (from Azure Bot registration). */
+8 -1
View File
@@ -1,4 +1,9 @@
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js";
import type {
BlockStreamingCoalesceConfig,
DmPolicy,
GroupPolicy,
MarkdownConfig,
} from "./types.base.js";
import type { DmConfig } from "./types.messages.js";
export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist";
@@ -8,6 +13,8 @@ export type SignalAccountConfig = {
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** Markdown formatting overrides (tables). */
markdown?: MarkdownConfig;
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** If false, do not start this Signal account. Default: true. */
+3
View File
@@ -2,6 +2,7 @@ import type {
BlockStreamingCoalesceConfig,
DmPolicy,
GroupPolicy,
MarkdownConfig,
ReplyToMode,
} from "./types.base.js";
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
@@ -80,6 +81,8 @@ export type SlackAccountConfig = {
webhookPath?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** Markdown formatting overrides (tables). */
markdown?: MarkdownConfig;
/** Override native command registration for Slack (bool or "auto"). */
commands?: ProviderCommandsConfig;
/** Allow channel-initiated config writes (default: true). */
+3
View File
@@ -3,6 +3,7 @@ import type {
BlockStreamingCoalesceConfig,
DmPolicy,
GroupPolicy,
MarkdownConfig,
OutboundRetryConfig,
ReplyToMode,
} from "./types.base.js";
@@ -35,6 +36,8 @@ export type TelegramAccountConfig = {
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: TelegramCapabilitiesConfig;
/** Markdown formatting overrides (tables). */
markdown?: MarkdownConfig;
/** Override native command registration for Telegram (bool or "auto"). */
commands?: ProviderCommandsConfig;
/** Custom commands to register in Telegram's command menu (merged with native). */
+10 -1
View File
@@ -1,4 +1,9 @@
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js";
import type {
BlockStreamingCoalesceConfig,
DmPolicy,
GroupPolicy,
MarkdownConfig,
} from "./types.base.js";
import type { DmConfig } from "./types.messages.js";
export type WhatsAppActionConfig = {
@@ -12,6 +17,8 @@ export type WhatsAppConfig = {
accounts?: Record<string, WhatsAppAccountConfig>;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** Markdown formatting overrides (tables). */
markdown?: MarkdownConfig;
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** Send read receipts for incoming messages (default true). */
@@ -84,6 +91,8 @@ export type WhatsAppAccountConfig = {
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** Markdown formatting overrides (tables). */
markdown?: MarkdownConfig;
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** If false, do not start this WhatsApp account provider. Default: true. */
+9
View File
@@ -133,6 +133,15 @@ export const BlockStreamingChunkSchema = z
})
.strict();
export const MarkdownTableModeSchema = z.enum(["off", "bullets", "code"]);
export const MarkdownConfigSchema = z
.object({
tables: MarkdownTableModeSchema.optional(),
})
.strict()
.optional();
export const HumanDelaySchema = z
.object({
mode: z.union([z.literal("off"), z.literal("natural"), z.literal("custom")]).optional(),
+8
View File
@@ -7,6 +7,7 @@ import {
DmPolicySchema,
ExecutableTokenSchema,
GroupPolicySchema,
MarkdownConfigSchema,
MSTeamsReplyStyleSchema,
ProviderCommandsSchema,
ReplyToModeSchema,
@@ -81,6 +82,7 @@ export const TelegramAccountSchemaBase = z
.object({
name: z.string().optional(),
capabilities: TelegramCapabilitiesSchema.optional(),
markdown: MarkdownConfigSchema,
enabled: z.boolean().optional(),
commands: ProviderCommandsSchema,
customCommands: z.array(TelegramCustomCommandSchema).optional(),
@@ -193,6 +195,7 @@ export const DiscordAccountSchema = z
.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
enabled: z.boolean().optional(),
commands: ProviderCommandsSchema,
configWrites: z.boolean().optional(),
@@ -296,6 +299,7 @@ export const SlackAccountSchema = z
signingSecret: z.string().optional(),
webhookPath: z.string().optional(),
capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
enabled: z.boolean().optional(),
commands: ProviderCommandsSchema,
configWrites: z.boolean().optional(),
@@ -381,6 +385,7 @@ export const SignalAccountSchemaBase = z
.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
account: z.string().optional(),
@@ -435,6 +440,7 @@ export const IMessageAccountSchemaBase = z
.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
cliPath: ExecutableTokenSchema.optional(),
@@ -521,6 +527,7 @@ export const BlueBubblesAccountSchemaBase = z
.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
configWrites: z.boolean().optional(),
enabled: z.boolean().optional(),
serverUrl: z.string().optional(),
@@ -585,6 +592,7 @@ export const MSTeamsConfigSchema = z
.object({
enabled: z.boolean().optional(),
capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
configWrites: z.boolean().optional(),
appId: z.string().optional(),
appPassword: z.string().optional(),
@@ -5,12 +5,14 @@ import {
DmConfigSchema,
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
} from "./zod-schema.core.js";
export const WhatsAppAccountSchema = z
.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
configWrites: z.boolean().optional(),
enabled: z.boolean().optional(),
sendReadReceipts: z.boolean().optional(),
@@ -66,6 +68,7 @@ export const WhatsAppConfigSchema = z
.object({
accounts: z.record(z.string(), WhatsAppAccountSchema.optional()).optional(),
capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
configWrites: z.boolean().optional(),
sendReadReceipts: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
+1
View File
@@ -299,6 +299,7 @@ export async function runCronIsolatedAgentTurn(params: {
sessionId: cronSession.sessionEntry.sessionId,
sessionKey: agentSessionKey,
messageChannel,
agentAccountId: resolvedDelivery.accountId,
sessionFile,
workspaceDir,
config: cfgWithAgentDefaults,
+20
View File
@@ -45,6 +45,26 @@ describe("resolveGatewayProgramArguments", () => {
]);
});
it("prefers symlinked path over realpath for stable service config", async () => {
// Simulates pnpm global install where node_modules/clawdbot is a symlink
// to .pnpm/clawdbot@X.Y.Z/node_modules/clawdbot
const symlinkPath = path.resolve(
"/Users/test/Library/pnpm/global/5/node_modules/clawdbot/dist/entry.js",
);
const realpathResolved = path.resolve(
"/Users/test/Library/pnpm/global/5/node_modules/.pnpm/clawdbot@2026.1.21-2/node_modules/clawdbot/dist/entry.js",
);
process.argv = ["node", symlinkPath];
fsMocks.realpath.mockResolvedValue(realpathResolved);
fsMocks.access.mockResolvedValue(undefined); // Both paths exist
const result = await resolveGatewayProgramArguments({ port: 18789 });
// Should use the symlinked path, not the realpath-resolved versioned path
expect(result.programArguments[1]).toBe(symlinkPath);
expect(result.programArguments[1]).not.toContain("@2026.1.21-2");
});
it("falls back to node_modules package dist when .bin path is not resolved", async () => {
const argv1 = path.resolve("/tmp/.npm/_npx/63c3/node_modules/.bin/clawdbot");
const indexPath = path.resolve("/tmp/.npm/_npx/63c3/node_modules/clawdbot/dist/index.js");
+14
View File
@@ -27,6 +27,20 @@ async function resolveCliEntrypointPathForService(): Promise<string> {
const looksLikeDist = /[/\\]dist[/\\].+\.(cjs|js|mjs)$/.test(resolvedPath);
if (looksLikeDist) {
await fs.access(resolvedPath);
// Prefer the original (possibly symlinked) path over the resolved realpath.
// This keeps LaunchAgent/systemd paths stable across package version updates,
// since symlinks like node_modules/clawdbot -> .pnpm/clawdbot@X.Y.Z/...
// are automatically updated by pnpm, while the resolved path contains
// version-specific directories that break after updates.
const normalizedLooksLikeDist = /[/\\]dist[/\\].+\.(cjs|js|mjs)$/.test(normalized);
if (normalizedLooksLikeDist && normalized !== resolvedPath) {
try {
await fs.access(normalized);
return normalized;
} catch {
// Fall through to return resolvedPath
}
}
return resolvedPath;
}
@@ -27,6 +27,7 @@ import {
resolveStorePath,
updateLastRoute,
} from "../../config/sessions.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../../routing/session-key.js";
@@ -323,6 +324,11 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(cfg, route.agentId),
};
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "discord",
accountId,
});
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
@@ -340,6 +346,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
replyToId,
textLimit,
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
tableMode,
});
replyReference.markSent();
},
+6 -1
View File
@@ -1,6 +1,8 @@
import type { RequestClient } from "@buape/carbon";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { MarkdownTableMode } from "../../config/types.base.js";
import { convertMarkdownTables } from "../../markdown/tables.js";
import type { RuntimeEnv } from "../../runtime.js";
import { chunkDiscordText } from "../chunk.js";
import { sendMessageDiscord } from "../send.js";
@@ -15,11 +17,14 @@ export async function deliverDiscordReply(params: {
textLimit: number;
maxLinesPerMessage?: number;
replyToId?: string;
tableMode?: MarkdownTableMode;
}) {
const chunkLimit = Math.min(params.textLimit, 2000);
for (const payload of params.replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? "";
const rawText = payload.text ?? "";
const tableMode = params.tableMode ?? "code";
const text = convertMarkdownTables(rawText, tableMode);
if (!text && mediaList.length === 0) continue;
const replyTo = params.replyToId?.trim() || undefined;
+10 -2
View File
@@ -1,7 +1,9 @@
import type { RequestClient } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import { loadConfig } from "../config/config.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { recordChannelActivity } from "../infra/channel-activity.js";
import { convertMarkdownTables } from "../markdown/tables.js";
import type { RetryConfig } from "../infra/retry.js";
import type { PollInput } from "../polls.js";
import { resolveDiscordAccount } from "./accounts.js";
@@ -38,6 +40,12 @@ export async function sendMessageDiscord(
cfg,
accountId: opts.accountId,
});
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "discord",
accountId: accountInfo.accountId,
});
const textWithTables = convertMarkdownTables(text ?? "", tableMode);
const { token, rest, request } = createDiscordClient(opts, cfg);
const recipient = parseRecipient(to);
const { channelId } = await resolveChannelId(rest, recipient, request);
@@ -47,7 +55,7 @@ export async function sendMessageDiscord(
result = await sendDiscordMedia(
rest,
channelId,
text,
textWithTables,
opts.mediaUrl,
opts.replyTo,
request,
@@ -58,7 +66,7 @@ export async function sendMessageDiscord(
result = await sendDiscordText(
rest,
channelId,
text,
textWithTables,
opts.replyTo,
request,
accountInfo.config.maxLinesPerMessage,
@@ -40,7 +40,7 @@ const sendReq = (
);
};
describe("gateway server chat", () => {
const timeoutMs = process.platform === "win32" ? 120_000 : 60_000;
const timeoutMs = 120_000;
test(
"handles history, abort, idempotency, and ordering flows",
{ timeout: timeoutMs },
+11 -1
View File
@@ -1,4 +1,7 @@
import { chunkText } from "../../auto-reply/chunk.js";
import { loadConfig } from "../../config/config.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { convertMarkdownTables } from "../../markdown/tables.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { RuntimeEnv } from "../../runtime.js";
import type { createIMessageRpcClient } from "../client.js";
@@ -14,9 +17,16 @@ export async function deliverReplies(params: {
textLimit: number;
}) {
const { replies, target, client, runtime, maxBytes, textLimit, accountId } = params;
const cfg = loadConfig();
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "imessage",
accountId,
});
for (const payload of replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? "";
const rawText = payload.text ?? "";
const text = convertMarkdownTables(rawText, tableMode);
if (!text && mediaList.length === 0) continue;
if (mediaList.length === 0) {
for (const chunk of chunkText(text, textLimit)) {
+10
View File
@@ -1,7 +1,9 @@
import { loadConfig } from "../config/config.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { mediaKindFromMime } from "../media/constants.js";
import { saveMediaBuffer } from "../media/store.js";
import { loadWebMedia } from "../web/media.js";
import { convertMarkdownTables } from "../markdown/tables.js";
import { resolveIMessageAccount } from "./accounts.js";
import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js";
import { formatIMessageChatTarget, type IMessageService, parseIMessageTarget } from "./targets.js";
@@ -88,6 +90,14 @@ export async function sendMessageIMessage(
if (!message.trim() && !filePath) {
throw new Error("iMessage send requires text or media");
}
if (message.trim()) {
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "imessage",
accountId: account.accountId,
});
message = convertMarkdownTables(message, tableMode);
}
const params: Record<string, unknown> = {
text: message,
+11 -3
View File
@@ -4,6 +4,7 @@ import { resolveChannelMediaMaxBytes } from "../../channels/plugins/media-limits
import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import type { sendMessageDiscord } from "../../discord/send.js";
import type { sendMessageIMessage } from "../../imessage/send.js";
import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js";
@@ -192,6 +193,9 @@ export async function deliverOutboundPayloads(params: {
})
: undefined;
const isSignalChannel = channel === "signal";
const signalTableMode = isSignalChannel
? resolveMarkdownTableMode({ cfg, channel: "signal", accountId })
: "code";
const signalMaxBytes = isSignalChannel
? resolveChannelMediaMaxBytes({
cfg,
@@ -231,8 +235,10 @@ export async function deliverOutboundPayloads(params: {
throwIfAborted(abortSignal);
let signalChunks =
textLimit === undefined
? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY)
: markdownToSignalTextChunks(text, textLimit);
? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, {
tableMode: signalTableMode,
})
: markdownToSignalTextChunks(text, textLimit, { tableMode: signalTableMode });
if (signalChunks.length === 0 && text) {
signalChunks = [{ text, styles: [] }];
}
@@ -244,7 +250,9 @@ export async function deliverOutboundPayloads(params: {
const sendSignalMedia = async (caption: string, mediaUrl: string) => {
throwIfAborted(abortSignal);
const formatted = markdownToSignalTextChunks(caption, Number.POSITIVE_INFINITY)[0] ?? {
const formatted = markdownToSignalTextChunks(caption, Number.POSITIVE_INFINITY, {
tableMode: signalTableMode,
})[0] ?? {
text: caption,
styles: [],
};
@@ -410,3 +410,65 @@ describe("runMessageAction sendAttachment hydration", () => {
);
});
});
describe("runMessageAction accountId defaults", () => {
const handleAction = vi.fn(async () => jsonResult({ ok: true }));
const accountPlugin: ChannelPlugin = {
id: "discord",
meta: {
id: "discord",
label: "Discord",
selectionLabel: "Discord",
docsPath: "/channels/discord",
blurb: "Discord test plugin.",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
},
actions: {
listActions: () => ["send"],
handleAction,
},
};
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "discord",
source: "test",
plugin: accountPlugin,
},
]),
);
handleAction.mockClear();
});
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
vi.clearAllMocks();
});
it("propagates defaultAccountId into params", async () => {
await runMessageAction({
cfg: {} as ClawdbotConfig,
action: "send",
params: {
channel: "discord",
target: "channel:123",
message: "hi",
},
defaultAccountId: "ops",
});
expect(handleAction).toHaveBeenCalled();
const ctx = handleAction.mock.calls[0]?.[0] as {
accountId?: string | null;
params: Record<string, unknown>;
};
expect(ctx.accountId).toBe("ops");
expect(ctx.params.accountId).toBe("ops");
});
});
@@ -803,6 +803,9 @@ export async function runMessageAction(
const channel = await resolveChannel(cfg, params);
const accountId = readStringParam(params, "accountId") ?? input.defaultAccountId;
if (accountId) {
params.accountId = accountId;
}
const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun"));
await hydrateSendAttachmentParams({
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { markdownToIR } from "./ir.js";
describe("markdownToIR tableMode bullets", () => {
it("converts simple table to bullets", () => {
const md = `
| Name | Value |
|------|-------|
| A | 1 |
| B | 2 |
`.trim();
const ir = markdownToIR(md, { tableMode: "bullets" });
// Should contain bullet points with header:value format
expect(ir.text).toContain("• Value: 1");
expect(ir.text).toContain("• Value: 2");
// Should use first column as labels
expect(ir.text).toContain("A");
expect(ir.text).toContain("B");
});
it("handles table with multiple columns", () => {
const md = `
| Feature | SQLite | Postgres |
|---------|--------|----------|
| Speed | Fast | Medium |
| Scale | Small | Large |
`.trim();
const ir = markdownToIR(md, { tableMode: "bullets" });
// First column becomes row label
expect(ir.text).toContain("Speed");
expect(ir.text).toContain("Scale");
// Other columns become bullet points
expect(ir.text).toContain("• SQLite: Fast");
expect(ir.text).toContain("• Postgres: Medium");
expect(ir.text).toContain("• SQLite: Small");
expect(ir.text).toContain("• Postgres: Large");
});
it("leaves table syntax untouched by default", () => {
const md = `
| A | B |
|---|---|
| 1 | 2 |
`.trim();
const ir = markdownToIR(md);
// No table conversion by default
expect(ir.text).toContain("| A | B |");
expect(ir.text).toContain("| 1 | 2 |");
expect(ir.text).not.toContain("•");
expect(ir.styles.some((style) => style.style === "code_block")).toBe(false);
});
it("handles empty cells gracefully", () => {
const md = `
| Name | Value |
|------|-------|
| A | |
| B | 2 |
`.trim();
const ir = markdownToIR(md, { tableMode: "bullets" });
// Should handle empty cell without crashing
expect(ir.text).toContain("B");
expect(ir.text).toContain("• Value: 2");
});
it("bolds row labels in bullets mode", () => {
const md = `
| Name | Value |
|------|-------|
| Row1 | Data1 |
`.trim();
const ir = markdownToIR(md, { tableMode: "bullets" });
// Should have bold style for row label
const hasRowLabelBold = ir.styles.some(
(s) => s.style === "bold" && ir.text.slice(s.start, s.end) === "Row1",
);
expect(hasRowLabelBold).toBe(true);
});
it("renders tables as code blocks in code mode", () => {
const md = `
| A | B |
|---|---|
| 1 | 2 |
`.trim();
const ir = markdownToIR(md, { tableMode: "code" });
expect(ir.text).toContain("| A | B |");
expect(ir.text).toContain("| 1 | 2 |");
expect(ir.styles.some((style) => style.style === "code_block")).toBe(true);
});
it("preserves inline styles and links in bullets mode", () => {
const md = `
| Name | Value |
|------|-------|
| _Row_ | [Link](https://example.com) |
`.trim();
const ir = markdownToIR(md, { tableMode: "bullets" });
const hasItalic = ir.styles.some(
(s) => s.style === "italic" && ir.text.slice(s.start, s.end) === "Row",
);
expect(hasItalic).toBe(true);
expect(ir.links.some((link) => link.href === "https://example.com")).toBe(true);
});
});
+342 -40
View File
@@ -1,6 +1,7 @@
import MarkdownIt from "markdown-it";
import { chunkText } from "../auto-reply/chunk.js";
import type { MarkdownTableMode } from "../config/types.base.js";
type ListState = {
type: "bullet" | "ordered";
@@ -14,7 +15,6 @@ type LinkState = {
type RenderEnv = {
listStack: ListState[];
linkStack: LinkState[];
};
type MarkdownToken = {
@@ -50,15 +50,36 @@ type OpenStyle = {
start: number;
};
type RenderState = {
type RenderTarget = {
text: string;
styles: MarkdownStyleSpan[];
openStyles: OpenStyle[];
links: MarkdownLinkSpan[];
linkStack: LinkState[];
};
type TableCell = {
text: string;
styles: MarkdownStyleSpan[];
links: MarkdownLinkSpan[];
};
type TableState = {
headers: TableCell[];
rows: TableCell[][];
currentRow: TableCell[];
currentCell: RenderTarget | null;
inHeader: boolean;
};
type RenderState = RenderTarget & {
env: RenderEnv;
headingStyle: "none" | "bold";
blockquotePrefix: string;
enableSpoilers: boolean;
tableMode: MarkdownTableMode;
table: TableState | null;
hasTables: boolean;
};
export type MarkdownParseOptions = {
@@ -67,6 +88,8 @@ export type MarkdownParseOptions = {
headingStyle?: "none" | "bold";
blockquotePrefix?: string;
autolink?: boolean;
/** How to render tables (off|bullets|code). Default: off. */
tableMode?: MarkdownTableMode;
};
function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt {
@@ -77,6 +100,11 @@ function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt {
typographer: false,
});
md.enable("strikethrough");
if (options.tableMode && options.tableMode !== "off") {
md.enable("table");
} else {
md.disable("table");
}
if (options.autolink === false) {
md.disable("autolink");
}
@@ -144,23 +172,40 @@ function injectSpoilersIntoInline(tokens: MarkdownToken[]): MarkdownToken[] {
return result;
}
function initRenderTarget(): RenderTarget {
return {
text: "",
styles: [],
openStyles: [],
links: [],
linkStack: [],
};
}
function resolveRenderTarget(state: RenderState): RenderTarget {
return state.table?.currentCell ?? state;
}
function appendText(state: RenderState, value: string) {
if (!value) return;
state.text += value;
const target = resolveRenderTarget(state);
target.text += value;
}
function openStyle(state: RenderState, style: MarkdownStyle) {
state.openStyles.push({ style, start: state.text.length });
const target = resolveRenderTarget(state);
target.openStyles.push({ style, start: target.text.length });
}
function closeStyle(state: RenderState, style: MarkdownStyle) {
for (let i = state.openStyles.length - 1; i >= 0; i -= 1) {
if (state.openStyles[i]?.style === style) {
const start = state.openStyles[i].start;
state.openStyles.splice(i, 1);
const end = state.text.length;
const target = resolveRenderTarget(state);
for (let i = target.openStyles.length - 1; i >= 0; i -= 1) {
if (target.openStyles[i]?.style === style) {
const start = target.openStyles[i].start;
target.openStyles.splice(i, 1);
const end = target.text.length;
if (end > start) {
state.styles.push({ start, end, style });
target.styles.push({ start, end, style });
}
return;
}
@@ -169,7 +214,8 @@ function closeStyle(state: RenderState, style: MarkdownStyle) {
function appendParagraphSeparator(state: RenderState) {
if (state.env.listStack.length > 0) return;
appendText(state, "\n\n");
if (state.table) return; // Don't add paragraph separators inside tables
state.text += "\n\n";
}
function appendListPrefix(state: RenderState) {
@@ -179,39 +225,232 @@ function appendListPrefix(state: RenderState) {
top.index += 1;
const indent = " ".repeat(Math.max(0, stack.length - 1));
const prefix = top.type === "ordered" ? `${top.index}. ` : "• ";
appendText(state, `${indent}${prefix}`);
state.text += `${indent}${prefix}`;
}
function renderInlineCode(state: RenderState, content: string) {
if (!content) return;
const start = state.text.length;
appendText(state, content);
state.styles.push({ start, end: start + content.length, style: "code" });
const target = resolveRenderTarget(state);
const start = target.text.length;
target.text += content;
target.styles.push({ start, end: start + content.length, style: "code" });
}
function renderCodeBlock(state: RenderState, content: string) {
let code = content ?? "";
if (!code.endsWith("\n")) code = `${code}\n`;
const start = state.text.length;
appendText(state, code);
state.styles.push({ start, end: start + code.length, style: "code_block" });
const target = resolveRenderTarget(state);
const start = target.text.length;
target.text += code;
target.styles.push({ start, end: start + code.length, style: "code_block" });
if (state.env.listStack.length === 0) {
appendText(state, "\n");
target.text += "\n";
}
}
function handleLinkClose(state: RenderState) {
const link = state.env.linkStack.pop();
const target = resolveRenderTarget(state);
const link = target.linkStack.pop();
if (!link?.href) return;
const href = link.href.trim();
if (!href) return;
const start = link.labelStart;
const end = state.text.length;
const end = target.text.length;
if (end <= start) {
state.links.push({ start, end, href });
target.links.push({ start, end, href });
return;
}
state.links.push({ start, end, href });
target.links.push({ start, end, href });
}
function initTableState(): TableState {
return {
headers: [],
rows: [],
currentRow: [],
currentCell: null,
inHeader: false,
};
}
function finishTableCell(cell: RenderTarget): TableCell {
closeRemainingStyles(cell);
return {
text: cell.text,
styles: cell.styles,
links: cell.links,
};
}
function trimCell(cell: TableCell): TableCell {
const text = cell.text;
let start = 0;
let end = text.length;
while (start < end && /\s/.test(text[start] ?? "")) start += 1;
while (end > start && /\s/.test(text[end - 1] ?? "")) end -= 1;
if (start === 0 && end === text.length) return cell;
const trimmedText = text.slice(start, end);
const trimmedLength = trimmedText.length;
const trimmedStyles: MarkdownStyleSpan[] = [];
for (const span of cell.styles) {
const sliceStart = Math.max(0, span.start - start);
const sliceEnd = Math.min(trimmedLength, span.end - start);
if (sliceEnd > sliceStart) {
trimmedStyles.push({ start: sliceStart, end: sliceEnd, style: span.style });
}
}
const trimmedLinks: MarkdownLinkSpan[] = [];
for (const span of cell.links) {
const sliceStart = Math.max(0, span.start - start);
const sliceEnd = Math.min(trimmedLength, span.end - start);
if (sliceEnd > sliceStart) {
trimmedLinks.push({ start: sliceStart, end: sliceEnd, href: span.href });
}
}
return { text: trimmedText, styles: trimmedStyles, links: trimmedLinks };
}
function appendCell(state: RenderState, cell: TableCell) {
if (!cell.text) return;
const start = state.text.length;
state.text += cell.text;
for (const span of cell.styles) {
state.styles.push({
start: start + span.start,
end: start + span.end,
style: span.style,
});
}
for (const link of cell.links) {
state.links.push({
start: start + link.start,
end: start + link.end,
href: link.href,
});
}
}
function renderTableAsBullets(state: RenderState) {
if (!state.table) return;
const headers = state.table.headers.map(trimCell);
const rows = state.table.rows.map((row) => row.map(trimCell));
// If no headers or rows, skip
if (headers.length === 0 && rows.length === 0) return;
// Determine if first column should be used as row labels
// (common pattern: first column is category/feature name)
const useFirstColAsLabel = headers.length > 1 && rows.length > 0;
if (useFirstColAsLabel) {
// Format: each row becomes a section with header as row[0], then key:value pairs
for (const row of rows) {
if (row.length === 0) continue;
const rowLabel = row[0];
if (rowLabel?.text) {
const labelStart = state.text.length;
appendCell(state, rowLabel);
const labelEnd = state.text.length;
if (labelEnd > labelStart) {
state.styles.push({ start: labelStart, end: labelEnd, style: "bold" });
}
state.text += "\n";
}
// Add each column as a bullet point
for (let i = 1; i < row.length; i++) {
const header = headers[i];
const value = row[i];
if (!value?.text) continue;
state.text += "• ";
if (header?.text) {
appendCell(state, header);
state.text += ": ";
} else {
state.text += `Column ${i}: `;
}
appendCell(state, value);
state.text += "\n";
}
state.text += "\n";
}
} else {
// Simple table: just list headers and values
for (const row of rows) {
for (let i = 0; i < row.length; i++) {
const header = headers[i];
const value = row[i];
if (!value?.text) continue;
state.text += "• ";
if (header?.text) {
appendCell(state, header);
state.text += ": ";
}
appendCell(state, value);
state.text += "\n";
}
state.text += "\n";
}
}
}
function renderTableAsCode(state: RenderState) {
if (!state.table) return;
const headers = state.table.headers.map(trimCell);
const rows = state.table.rows.map((row) => row.map(trimCell));
const columnCount = Math.max(headers.length, ...rows.map((row) => row.length));
if (columnCount === 0) return;
const widths = Array.from({ length: columnCount }, () => 0);
const updateWidths = (cells: TableCell[]) => {
for (let i = 0; i < columnCount; i += 1) {
const cell = cells[i];
const width = cell?.text.length ?? 0;
if (widths[i] < width) widths[i] = width;
}
};
updateWidths(headers);
for (const row of rows) updateWidths(row);
const codeStart = state.text.length;
const appendRow = (cells: TableCell[]) => {
state.text += "|";
for (let i = 0; i < columnCount; i += 1) {
state.text += " ";
const cell = cells[i];
if (cell) appendCell(state, cell);
const pad = widths[i] - (cell?.text.length ?? 0);
if (pad > 0) state.text += " ".repeat(pad);
state.text += " |";
}
state.text += "\n";
};
const appendDivider = () => {
state.text += "|";
for (let i = 0; i < columnCount; i += 1) {
const dashCount = Math.max(3, widths[i]);
state.text += ` ${"-".repeat(dashCount)} |`;
}
state.text += "\n";
};
appendRow(headers);
appendDivider();
for (const row of rows) {
appendRow(row);
}
const codeEnd = state.text.length;
if (codeEnd > codeStart) {
state.styles.push({ start: codeStart, end: codeEnd, style: "code_block" });
}
if (state.env.listStack.length === 0) {
state.text += "\n";
}
}
function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
@@ -252,7 +491,8 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
break;
case "link_open": {
const href = getAttr(token, "href") ?? "";
state.env.linkStack.push({ href, labelStart: state.text.length });
const target = resolveRenderTarget(state);
target.linkStack.push({ href, labelStart: target.text.length });
break;
}
case "link_close":
@@ -276,10 +516,10 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
appendParagraphSeparator(state);
break;
case "blockquote_open":
if (state.blockquotePrefix) appendText(state, state.blockquotePrefix);
if (state.blockquotePrefix) state.text += state.blockquotePrefix;
break;
case "blockquote_close":
appendText(state, "\n");
state.text += "\n";
break;
case "bullet_list_open":
state.env.listStack.push({ type: "bullet", index: 0 });
@@ -299,7 +539,7 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
appendListPrefix(state);
break;
case "list_item_close":
appendText(state, "\n");
state.text += "\n";
break;
case "code_block":
case "fence":
@@ -309,22 +549,68 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
case "html_inline":
appendText(state, token.content ?? "");
break;
// Table handling
case "table_open":
if (state.tableMode !== "off") {
state.table = initTableState();
state.hasTables = true;
}
break;
case "table_close":
if (state.table) {
if (state.tableMode === "bullets") {
renderTableAsBullets(state);
} else if (state.tableMode === "code") {
renderTableAsCode(state);
}
}
state.table = null;
break;
case "thead_open":
if (state.table) {
state.table.inHeader = true;
}
break;
case "thead_close":
if (state.table) {
state.table.inHeader = false;
}
break;
case "tbody_open":
case "tbody_close":
break;
case "tr_open":
if (state.table) {
state.table.currentRow = [];
}
break;
case "tr_close":
appendText(state, "\n");
if (state.table) {
if (state.table.inHeader) {
state.table.headers = state.table.currentRow;
} else {
state.table.rows.push(state.table.currentRow);
}
state.table.currentRow = [];
}
break;
case "th_open":
case "td_open":
if (state.table) {
state.table.currentCell = initRenderTarget();
}
break;
case "th_close":
case "td_close":
appendText(state, "\t");
if (state.table?.currentCell) {
state.table.currentRow.push(finishTableCell(state.table.currentCell));
state.table.currentCell = null;
}
break;
case "hr":
appendText(state, "\n");
state.text += "\n";
break;
default:
if (token.children) renderTokens(token.children, state);
@@ -333,19 +619,19 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
}
}
function closeRemainingStyles(state: RenderState) {
for (let i = state.openStyles.length - 1; i >= 0; i -= 1) {
const open = state.openStyles[i];
const end = state.text.length;
function closeRemainingStyles(target: RenderTarget) {
for (let i = target.openStyles.length - 1; i >= 0; i -= 1) {
const open = target.openStyles[i];
const end = target.text.length;
if (end > open.start) {
state.styles.push({
target.styles.push({
start: open.start,
end,
style: open.style,
});
}
}
state.openStyles = [];
target.openStyles = [];
}
function clampStyleSpans(spans: MarkdownStyleSpan[], maxLength: number): MarkdownStyleSpan[] {
@@ -426,22 +712,35 @@ function sliceLinkSpans(spans: MarkdownLinkSpan[], start: number, end: number):
}
export function markdownToIR(markdown: string, options: MarkdownParseOptions = {}): MarkdownIR {
const env: RenderEnv = { listStack: [], linkStack: [] };
return markdownToIRWithMeta(markdown, options).ir;
}
export function markdownToIRWithMeta(
markdown: string,
options: MarkdownParseOptions = {},
): { ir: MarkdownIR; hasTables: boolean } {
const env: RenderEnv = { listStack: [] };
const md = createMarkdownIt(options);
const tokens = md.parse(markdown ?? "", env as unknown as object);
if (options.enableSpoilers) {
applySpoilerTokens(tokens as MarkdownToken[]);
}
const tableMode = options.tableMode ?? "off";
const state: RenderState = {
text: "",
styles: [],
openStyles: [],
links: [],
linkStack: [],
env,
headingStyle: options.headingStyle ?? "none",
blockquotePrefix: options.blockquotePrefix ?? "",
enableSpoilers: options.enableSpoilers ?? false,
tableMode,
table: null,
hasTables: false,
};
renderTokens(tokens as MarkdownToken[], state);
@@ -459,9 +758,12 @@ export function markdownToIR(markdown: string, options: MarkdownParseOptions = {
finalLength === state.text.length ? state.text : state.text.slice(0, finalLength);
return {
text: finalText,
styles: mergeStyleSpans(clampStyleSpans(state.styles, finalLength)),
links: clampLinkSpans(state.links, finalLength),
ir: {
text: finalText,
styles: mergeStyleSpans(clampStyleSpans(state.styles, finalLength)),
links: clampLinkSpans(state.links, finalLength),
},
hasTables: state.hasTables,
};
}
+34
View File
@@ -0,0 +1,34 @@
import type { MarkdownTableMode } from "../config/types.base.js";
import { markdownToIRWithMeta } from "./ir.js";
import { renderMarkdownWithMarkers } from "./render.js";
const MARKDOWN_STYLE_MARKERS = {
bold: { open: "**", close: "**" },
italic: { open: "_", close: "_" },
strikethrough: { open: "~~", close: "~~" },
code: { open: "`", close: "`" },
code_block: { open: "```\n", close: "```" },
} as const;
export function convertMarkdownTables(markdown: string, mode: MarkdownTableMode): string {
if (!markdown || mode === "off") return markdown;
const { ir, hasTables } = markdownToIRWithMeta(markdown, {
linkify: false,
autolink: false,
headingStyle: "none",
blockquotePrefix: "",
tableMode: mode,
});
if (!hasTables) return markdown;
return renderMarkdownWithMarkers(ir, {
styleMarkers: MARKDOWN_STYLE_MARKERS,
escapeText: (text) => text,
buildLink: (link, text) => {
const href = link.href.trim();
if (!href) return null;
const label = text.slice(link.start, link.end);
if (!label) return null;
return { start: link.start, end: link.end, open: "[", close: `](${href})` };
},
});
}
+43
View File
@@ -339,6 +339,49 @@ export async function convertHeicToJpeg(buffer: Buffer): Promise<Buffer> {
return await sharp(buffer).jpeg({ quality: 90, mozjpeg: true }).toBuffer();
}
/**
* Checks if an image has an alpha channel (transparency).
* Returns true if the image has alpha, false otherwise.
*/
export async function hasAlphaChannel(buffer: Buffer): Promise<boolean> {
try {
const sharp = await loadSharp();
const meta = await sharp(buffer).metadata();
// Check if the image has an alpha channel
// PNG color types with alpha: 4 (grayscale+alpha), 6 (RGBA)
// Sharp reports this via 'channels' (4 = RGBA) or 'hasAlpha'
return meta.hasAlpha === true || meta.channels === 4;
} catch {
return false;
}
}
/**
* Resizes an image to PNG format, preserving alpha channel (transparency).
* Falls back to sharp only (no sips fallback for PNG with alpha).
*/
export async function resizeToPng(params: {
buffer: Buffer;
maxSide: number;
compressionLevel?: number;
withoutEnlargement?: boolean;
}): Promise<Buffer> {
const sharp = await loadSharp();
// Compression level 6 is a good balance (0=fastest, 9=smallest)
const compressionLevel = params.compressionLevel ?? 6;
return await sharp(params.buffer)
.rotate() // Auto-rotate based on EXIF if present
.resize({
width: params.maxSide,
height: params.maxSide,
fit: "inside",
withoutEnlargement: params.withoutEnlargement !== false,
})
.png({ compressionLevel })
.toBuffer();
}
/**
* Internal sips-only EXIF normalization (no sharp fallback).
* Used by resizeToJpeg to normalize before sips resize.
+4
View File
@@ -73,6 +73,8 @@ export type {
DmPolicy,
DmConfig,
GroupPolicy,
MarkdownConfig,
MarkdownTableMode,
MSTeamsChannelConfig,
MSTeamsConfig,
MSTeamsReplyStyle,
@@ -92,6 +94,8 @@ export {
DmConfigSchema,
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
MarkdownTableModeSchema,
normalizeAllowFrom,
requireOpenAllowFrom,
} from "../config/zod-schema.core.js";
+4
View File
@@ -34,6 +34,7 @@ import {
resolveChannelGroupPolicy,
resolveChannelGroupRequireMention,
} from "../../config/group-policy.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { resolveStateDir } from "../../config/paths.js";
import { loadConfig, writeConfigFile } from "../../config/config.js";
import {
@@ -58,6 +59,7 @@ import { monitorIMessageProvider } from "../../imessage/monitor.js";
import { probeIMessage } from "../../imessage/probe.js";
import { sendMessageIMessage } from "../../imessage/send.js";
import { shouldLogVerbose } from "../../globals.js";
import { convertMarkdownTables } from "../../markdown/tables.js";
import { getChildLogger } from "../../logging.js";
import { normalizeLogLevel } from "../../logging/levels.js";
import { isVoiceCompatibleAudio } from "../../media/audio.js";
@@ -156,6 +158,8 @@ export function createPluginRuntime(): PluginRuntime {
chunkText,
resolveTextChunkLimit,
hasControlCommand,
resolveMarkdownTableMode,
convertMarkdownTables,
},
reply: {
dispatchReplyWithBufferedBlockDispatcher,
+5
View File
@@ -32,6 +32,9 @@ type ResolveCommandAuthorizedFromAuthorizers =
type ResolveTextChunkLimit = typeof import("../../auto-reply/chunk.js").resolveTextChunkLimit;
type ChunkMarkdownText = typeof import("../../auto-reply/chunk.js").chunkMarkdownText;
type ChunkText = typeof import("../../auto-reply/chunk.js").chunkText;
type ResolveMarkdownTableMode =
typeof import("../../config/markdown-tables.js").resolveMarkdownTableMode;
type ConvertMarkdownTables = typeof import("../../markdown/tables.js").convertMarkdownTables;
type HasControlCommand = typeof import("../../auto-reply/command-detection.js").hasControlCommand;
type IsControlCommandMessage =
typeof import("../../auto-reply/command-detection.js").isControlCommandMessage;
@@ -168,6 +171,8 @@ export type PluginRuntime = {
chunkText: ChunkText;
resolveTextChunkLimit: ResolveTextChunkLimit;
hasControlCommand: HasControlCommand;
resolveMarkdownTableMode: ResolveMarkdownTableMode;
convertMarkdownTables: ConvertMarkdownTables;
};
reply: {
dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcher;
+16 -2
View File
@@ -4,6 +4,7 @@ import {
type MarkdownIR,
type MarkdownStyle,
} from "../markdown/ir.js";
import type { MarkdownTableMode } from "../config/types.base.js";
type SignalTextStyle = "BOLD" | "ITALIC" | "STRIKETHROUGH" | "MONOSPACE" | "SPOILER";
@@ -18,6 +19,10 @@ export type SignalFormattedText = {
styles: SignalTextStyleRange[];
};
type SignalMarkdownOptions = {
tableMode?: MarkdownTableMode;
};
type SignalStyleSpan = {
start: number;
end: number;
@@ -188,22 +193,31 @@ function renderSignalText(ir: MarkdownIR): SignalFormattedText {
};
}
export function markdownToSignalText(markdown: string): SignalFormattedText {
export function markdownToSignalText(
markdown: string,
options: SignalMarkdownOptions = {},
): SignalFormattedText {
const ir = markdownToIR(markdown ?? "", {
linkify: true,
enableSpoilers: true,
headingStyle: "none",
blockquotePrefix: "",
tableMode: options.tableMode,
});
return renderSignalText(ir);
}
export function markdownToSignalTextChunks(markdown: string, limit: number): SignalFormattedText[] {
export function markdownToSignalTextChunks(
markdown: string,
limit: number,
options: SignalMarkdownOptions = {},
): SignalFormattedText[] {
const ir = markdownToIR(markdown ?? "", {
linkify: true,
enableSpoilers: true,
headingStyle: "none",
blockquotePrefix: "",
tableMode: options.tableMode,
});
const chunks = chunkMarkdownIR(ir, limit);
return chunks.map((chunk) => renderSignalText(chunk));
+7 -1
View File
@@ -1,4 +1,5 @@
import { loadConfig } from "../config/config.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { mediaKindFromMime } from "../media/constants.js";
import { saveMediaBuffer } from "../media/store.js";
import { loadWebMedia } from "../web/media.js";
@@ -164,7 +165,12 @@ export async function sendMessageSignal(
if (textMode === "plain") {
textStyles = opts.textStyles ?? [];
} else {
const formatted = markdownToSignalText(message);
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "signal",
accountId: accountInfo.accountId,
});
const formatted = markdownToSignalText(message, { tableMode });
message = formatted.text;
textStyles = formatted.styles;
}
+16 -2
View File
@@ -1,4 +1,5 @@
import { chunkMarkdownIR, markdownToIR, type MarkdownLinkSpan } from "../markdown/ir.js";
import type { MarkdownTableMode } from "../config/types.base.js";
import { renderMarkdownWithMarkers } from "../markdown/render.js";
// Escape special characters for Slack mrkdwn format.
@@ -83,12 +84,20 @@ function buildSlackLink(link: MarkdownLinkSpan, text: string) {
};
}
export function markdownToSlackMrkdwn(markdown: string): string {
type SlackMarkdownOptions = {
tableMode?: MarkdownTableMode;
};
export function markdownToSlackMrkdwn(
markdown: string,
options: SlackMarkdownOptions = {},
): string {
const ir = markdownToIR(markdown ?? "", {
linkify: false,
autolink: false,
headingStyle: "bold",
blockquotePrefix: "> ",
tableMode: options.tableMode,
});
return renderMarkdownWithMarkers(ir, {
styleMarkers: {
@@ -103,12 +112,17 @@ export function markdownToSlackMrkdwn(markdown: string): string {
});
}
export function markdownToSlackMrkdwnChunks(markdown: string, limit: number): string[] {
export function markdownToSlackMrkdwnChunks(
markdown: string,
limit: number,
options: SlackMarkdownOptions = {},
): string[] {
const ir = markdownToIR(markdown ?? "", {
linkify: false,
autolink: false,
headingStyle: "bold",
blockquotePrefix: "> ",
tableMode: options.tableMode,
});
const chunks = chunkMarkdownIR(ir, limit);
return chunks.map((chunk) =>
+5 -1
View File
@@ -1,6 +1,7 @@
import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { MarkdownTableMode } from "../../config/types.base.js";
import type { RuntimeEnv } from "../../runtime.js";
import { markdownToSlackMrkdwnChunks } from "../format.js";
import { sendMessageSlack } from "../send.js";
@@ -116,6 +117,7 @@ export async function deliverSlackSlashReplies(params: {
respond: SlackRespondFn;
ephemeral: boolean;
textLimit: number;
tableMode?: MarkdownTableMode;
}) {
const messages: string[] = [];
const chunkLimit = Math.min(params.textLimit, 4000);
@@ -127,7 +129,9 @@ export async function deliverSlackSlashReplies(params: {
.filter(Boolean)
.join("\n");
if (!combined) continue;
for (const chunk of markdownToSlackMrkdwnChunks(combined, chunkLimit)) {
for (const chunk of markdownToSlackMrkdwnChunks(combined, chunkLimit, {
tableMode: params.tableMode,
})) {
messages.push(chunk);
}
}
+11
View File
@@ -12,6 +12,7 @@ import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js";
import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { danger, logVerbose } from "../../globals.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import {
@@ -424,6 +425,11 @@ export function registerSlackMonitorSlashCommands(params: {
respond,
ephemeral: slashCommand.ephemeral,
textLimit: ctx.textLimit,
tableMode: resolveMarkdownTableMode({
cfg,
channel: "slack",
accountId: route.accountId,
}),
});
},
onError: (err, info) => {
@@ -438,6 +444,11 @@ export function registerSlackMonitorSlashCommands(params: {
respond,
ephemeral: slashCommand.ephemeral,
textLimit: ctx.textLimit,
tableMode: resolveMarkdownTableMode({
cfg,
channel: "slack",
accountId: route.accountId,
}),
});
}
} catch (err) {
+7 -1
View File
@@ -8,6 +8,7 @@ import type { SlackTokenSource } from "./accounts.js";
import { resolveSlackAccount } from "./accounts.js";
import { createSlackWebClient } from "./client.js";
import { markdownToSlackMrkdwnChunks } from "./format.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { parseSlackTarget } from "./targets.js";
import { resolveSlackBotToken } from "./token.js";
@@ -143,7 +144,12 @@ export async function sendMessageSlack(
const { channelId } = await resolveChannelId(client, recipient);
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT);
const chunks = markdownToSlackMrkdwnChunks(trimmedMessage, chunkLimit);
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "slack",
accountId: account.accountId,
});
const chunks = markdownToSlackMrkdwnChunks(trimmedMessage, chunkLimit, { tableMode });
const mediaMaxBytes =
typeof account.config.mediaMaxMb === "number"
? account.config.mediaMaxMb * 1024 * 1024
+7
View File
@@ -8,6 +8,7 @@ import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js";
import { clearHistoryEntries } from "../auto-reply/reply/history.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
import { danger, logVerbose } from "../globals.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { deliverReplies } from "./bot/delivery.js";
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
import { createTelegramDraftStream } from "./draft-stream.js";
@@ -123,6 +124,11 @@ export const dispatchTelegramMessage = async ({
let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(cfg, route.agentId),
};
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "telegram",
accountId: route.accountId,
});
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
@@ -144,6 +150,7 @@ export const dispatchTelegramMessage = async ({
replyToMode,
textLimit,
messageThreadId: resolvedThreadId,
tableMode,
onVoiceRecording: sendRecordVoice,
});
},
+7
View File
@@ -15,6 +15,7 @@ import { resolveTelegramCustomCommands } from "../config/telegram-custom-command
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { danger, logVerbose } from "../globals.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js";
import type { ChannelGroupPolicy } from "../config/group-policy.js";
@@ -269,6 +270,11 @@ export const registerTelegramNativeCommands = ({
id: isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId),
},
});
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "telegram",
accountId: route.accountId,
});
const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills);
const systemPromptParts = [
groupConfig?.systemPrompt?.trim() || null,
@@ -327,6 +333,7 @@ export const registerTelegramNativeCommands = ({
replyToMode,
textLimit,
messageThreadId: resolvedThreadId,
tableMode,
});
},
onError: (err, info) => {
+8 -2
View File
@@ -3,6 +3,7 @@ import { markdownToTelegramChunks, markdownToTelegramHtml } from "../format.js";
import { splitTelegramCaption } from "../caption.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { ReplyToMode } from "../../config/config.js";
import type { MarkdownTableMode } from "../../config/types.base.js";
import { danger, logVerbose } from "../../globals.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { mediaKindFromMime } from "../../media/constants.js";
@@ -26,6 +27,7 @@ export async function deliverReplies(params: {
replyToMode: ReplyToMode;
textLimit: number;
messageThreadId?: number;
tableMode?: MarkdownTableMode;
/** Callback invoked before sending a voice message to switch typing indicator. */
onVoiceRecording?: () => Promise<void> | void;
}) {
@@ -49,7 +51,9 @@ export async function deliverReplies(params: {
? [reply.mediaUrl]
: [];
if (mediaList.length === 0) {
const chunks = markdownToTelegramChunks(reply.text || "", textLimit);
const chunks = markdownToTelegramChunks(reply.text || "", textLimit, {
tableMode: params.tableMode,
});
for (const chunk of chunks) {
await sendTelegramText(bot, chatId, chunk.html, runtime, {
replyToMessageId:
@@ -139,7 +143,9 @@ export async function deliverReplies(params: {
// Send deferred follow-up text right after the first media item.
// Chunk it in case it's extremely long (same logic as text-only replies).
if (pendingFollowUpText && isFirstMedia) {
const chunks = markdownToTelegramChunks(pendingFollowUpText, textLimit);
const chunks = markdownToTelegramChunks(pendingFollowUpText, textLimit, {
tableMode: params.tableMode,
});
for (const chunk of chunks) {
const replyToMessageIdFollowup =
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;
+8 -1
View File
@@ -5,6 +5,7 @@ import {
type MarkdownIR,
} from "../markdown/ir.js";
import { renderMarkdownWithMarkers } from "../markdown/render.js";
import type { MarkdownTableMode } from "../config/types.base.js";
export type TelegramFormattedChunk = {
html: string;
@@ -46,11 +47,15 @@ function renderTelegramHtml(ir: MarkdownIR): string {
});
}
export function markdownToTelegramHtml(markdown: string): string {
export function markdownToTelegramHtml(
markdown: string,
options: { tableMode?: MarkdownTableMode } = {},
): string {
const ir = markdownToIR(markdown ?? "", {
linkify: true,
headingStyle: "none",
blockquotePrefix: "",
tableMode: options.tableMode,
});
return renderTelegramHtml(ir);
}
@@ -58,11 +63,13 @@ export function markdownToTelegramHtml(markdown: string): string {
export function markdownToTelegramChunks(
markdown: string,
limit: number,
options: { tableMode?: MarkdownTableMode } = {},
): TelegramFormattedChunk[] {
const ir = markdownToIR(markdown ?? "", {
linkify: true,
headingStyle: "none",
blockquotePrefix: "",
tableMode: options.tableMode,
});
const chunks = chunkMarkdownIR(ir, limit);
return chunks.map((chunk) => ({
+7 -1
View File
@@ -17,6 +17,7 @@ import { loadWebMedia } from "../web/media.js";
import { resolveTelegramAccount } from "./accounts.js";
import { resolveTelegramFetch } from "./fetch.js";
import { markdownToTelegramHtml } from "./format.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { splitTelegramCaption } from "./caption.js";
import { recordSentMessage } from "./sent-message-cache.js";
import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js";
@@ -310,7 +311,12 @@ export async function sendMessageTelegram(
throw new Error("Message must be non-empty for Telegram sends");
}
const textMode = opts.textMode ?? "markdown";
const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text);
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "telegram",
accountId: account.accountId,
});
const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text, { tableMode });
const textParams = hasThreadParams
? {
parse_mode: "HTML" as const,
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more