Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
945b6ce
docs: add feat/matrix-channel proposal
theredspoon Mar 7, 2026
6be7cee
feat(matrix): add Matrix channel integration
theredspoon Mar 7, 2026
e030c95
fix(backup): include matrix/ in per-claw backup
theredspoon Mar 7, 2026
4e295ec
fix(matrix): address code review issues on feat/matrix-channel
theredspoon Mar 7, 2026
40cae9b
fix(matrix): correct plugin model — @openclaw/matrix is bundled, not …
theredspoon Mar 7, 2026
e831726
docs(proposal): correct @openclaw/matrix plugin model
theredspoon Mar 7, 2026
614a31c
fix(matrix): restore plugin install — @openclaw/matrix is external, n…
theredspoon Mar 7, 2026
07cee17
feat(matrix): add deviceName to channel config
theredspoon Mar 7, 2026
91d9346
fix(docs): correct MATRIX.md room config and token rotation
theredspoon Mar 8, 2026
89b1141
Update .env.example with Matrix access token key
theredspoon Mar 8, 2026
2949d7a
fix(matrix): revert 614a31c — @openclaw/matrix is bundled, not external
theredspoon Mar 8, 2026
8c38c67
Rename Matrix bot token variable
theredspoon Mar 8, 2026
387adfd
feat(matrix): add entrypoint patches, config var coercion, and defaul…
theredspoon Mar 8, 2026
bc48fb5
feat(telegram): add telegram.enabled toggle mirroring Matrix pattern
theredspoon Mar 8, 2026
6af086c
fix(entrypoint): reinstall matrix deps when bot-sdk dir is missing
theredspoon Mar 9, 2026
313a4f2
fix(resolve-config): strip blank IDs from allowFrom on env substitution
theredspoon Mar 9, 2026
522b475
fix(docs): update MATRIX.md to reflect groupPolicy: open default
theredspoon Mar 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ ROOT_DOMAIN=example.com # Used in stack.yml.example for configuring e
PERSONAL_CLAW_TELEGRAM_BOT_TOKEN=
WORK_CLAW_TELEGRAM_BOT_TOKEN=

# ── Per-Claw Matrix Bots ─────────────────────────────────────
# Convention: <CLAW_NAME>_MATRIX_BOT_TOKEN
# *Each* claw MUST use a unique bot token to avoid polling conflicts.

CLAW_NAME_MATRIX_ACCESS_TOKEN=

# ── Local Browser Node (docker/local-browser-node) ──────────────
# Claw name in stack.yml for the local browser node to connect to
# Also requires Cloudflare Access Token.
Expand Down
27 changes: 23 additions & 4 deletions build/pre-deploy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -294,8 +294,18 @@ function validateClaw(name, claw) {
}

const telegram = claw.telegram;
if (!telegram?.bot_token) {
warn(`Claw '${name}' has no telegram.bot_token — Telegram will be disabled`);
if (telegram?.enabled && !telegram?.bot_token) {
warn(`Claw '${name}' has telegram.enabled: true but no bot_token — Telegram may not work without a token (env var fallback allowed)`);
}

const matrix = claw.matrix;
if (matrix?.enabled) {
if (!matrix.homeserver) {
fatal(`Claw '${name}' has matrix.enabled: true but matrix.homeserver is not set`);
}
if (!matrix.access_token) {
fatal(`Claw '${name}' has matrix.enabled: true but matrix.access_token is empty — set the access token env var in .env`);
}
}
}

Expand Down Expand Up @@ -356,6 +366,10 @@ function computeDerivedValues(claws, stack, host, previousDeploy) {
claw.llemtry_url = logUrl ? logUrl + "/llemtry" : "";
claw.enable_events_logging = stack.logging?.events || false;
claw.enable_llemtry_logging = stack.logging?.llemtry || false;
// telegram_enabled / matrix_enabled are flat booleans for Handlebars — avoids
// empty-string output when the section is absent from stack.yml.
claw.telegram_enabled = claw.telegram?.enabled === true;
claw.matrix_enabled = claw.matrix?.enabled === true;
}

return autoTokens;
Expand Down Expand Up @@ -640,7 +654,6 @@ async function main() {
LOG_WORKER_TOKEN: "log_worker_token",
EVENTS_URL: "events_url",
LLEMTRY_URL: "llemtry_url",
ADMIN_TELEGRAM_ID: "telegram.allow_from",
};
// Check against first claw (these vars are stack-wide, same for all claws)
const firstClaw = Object.values(claws)[0];
Expand All @@ -650,7 +663,13 @@ async function main() {
return !val && val !== 0 && val !== false;
})
.map(([envVar]) => envVar);
writeFileSync(join(DEPLOY_DIR, "openclaw-stack", "empty-env-vars"), emptyVars.join("\n") + "\n");
// Channel vars are per-claw: only emitted in docker-compose.yml when the channel is enabled.
// Always pre-resolve them regardless of any claw's config, so openclaw.jsonc ${VAR}
// substitution succeeds on claws where the channel is disabled and these env vars are absent
// from the container environment. Checking only the first claw would break multi-claw
// stacks where claws have heterogeneous channel config.
const alwaysResolveVars = ["ADMIN_TELEGRAM_ID", "MATRIX_HOMESERVER", "MATRIX_ACCESS_TOKEN"];
writeFileSync(join(DEPLOY_DIR, "openclaw-stack", "empty-env-vars"), [...emptyVars, ...alwaysResolveVars].join("\n") + "\n");

// 7d-post. Resolve {{INSTALL_DIR}} in host/ files (cron configs, logrotate)
const installDir = String(stack.install_dir || "/home/openclaw");
Expand Down
3 changes: 3 additions & 0 deletions deploy/host/backup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,16 @@ while IFS= read -r install_dir; do

# Create backup of this claw's config and data.
# Include all workspace dirs (main=workspace, agents=workspace-<id>).
# Include matrix/ when present — contains sync state and E2EE crypto keys.
# || true: continue to other instances if one fails (error still printed)
workspace_dirs=$(cd "${inst_dir}" && ls -d .openclaw/workspace .openclaw/workspace-* 2>/dev/null || true)
matrix_dir=$(cd "${inst_dir}" && ls -d .openclaw/matrix 2>/dev/null || true)
tar -czf "${BACKUP_FILE}" \
-C "${inst_dir}" \
.openclaw/openclaw.json \
.openclaw/credentials \
${workspace_dirs} \
${matrix_dir} \
|| true

# Set ownership so container can also access backups if needed
Expand Down
37 changes: 36 additions & 1 deletion deploy/openclaw-stack/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,42 @@ echo "prefix=$npm_global" > /home/node/.npmrc
export PATH="$npm_global/bin:$PATH"
echo "[entrypoint] npm global prefix set to $npm_global"

# ── 1h. Auto-generate gateway shims from sandbox-toolkit.yaml ──────

# ── 1h. Patch matrix plugin: keyed-async-queue subpath regression ──
# TODO: remove once openclaw/openclaw#32772 is fixed
# OpenClaw 2026.3.2 broke the matrix plugin — send-queue.ts imports
# "openclaw/plugin-sdk/keyed-async-queue" but jiti doesn't resolve the
# subpath. Patch to use the barrel export. Version-gated so it no-ops
# once upstream ships a fix.
SEND_QUEUE="/app/extensions/matrix/src/matrix/send-queue.ts"
OPENCLAW_VERSION=$(node -e "console.log(require('/app/package.json').version)" 2>/dev/null || echo "unknown")
if [ "$OPENCLAW_VERSION" = "2026.3.2" ] && [ -f "$SEND_QUEUE" ] && grep -q '"openclaw/plugin-sdk/keyed-async-queue"' "$SEND_QUEUE"; then
sed -i 's|"openclaw/plugin-sdk/keyed-async-queue"|"openclaw/plugin-sdk"|' "$SEND_QUEUE"
echo "[entrypoint] Patched matrix plugin keyed-async-queue import (openclaw/openclaw#32772)"
elif [ "$OPENCLAW_VERSION" != "2026.3.2" ] && [ -f "$SEND_QUEUE" ]; then
echo "[entrypoint] OpenClaw $OPENCLAW_VERSION — matrix keyed-async-queue patch no longer needed, consider removing"
fi

# ── 1i. Install matrix plugin dependencies ─────────────────────────
# TODO: remove once openclaw/openclaw#16031 is fixed
# The bundled matrix plugin at /app/extensions/matrix declares deps in
# package.json but they aren't installed in the Docker image. Install
# them on first boot if the node_modules dir is missing.
MATRIX_EXT="/app/extensions/matrix"
if [ "${MATRIX_ENABLED:-false}" = "true" ] && [ -f "$MATRIX_EXT/package.json" ] && \
{ [ ! -d "$MATRIX_EXT/node_modules" ] || [ ! -d "$MATRIX_EXT/node_modules/@vector-im/matrix-bot-sdk" ]; }; then
echo "[entrypoint] Installing matrix plugin dependencies..."
cd "$MATRIX_EXT"
if npm install --omit=dev 2>&1; then
echo "[entrypoint] Matrix plugin dependencies installed"
else
echo "[entrypoint] WARNING: matrix plugin dependency install failed" >&2
fi
cd /app
fi


# ── 1j. Auto-generate gateway shims from sandbox-toolkit.yaml ──────
# Shims satisfy the gateway's load-time skill binary preflight checks.
# Real binaries live in sandbox images — shims are gateway-only (not bind-mounted).
SKILL_BINS="/opt/skill-bins"
Expand Down
44 changes: 43 additions & 1 deletion deploy/openclaw-stack/resolve-config-vars.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { readFileSync, existsSync } from "fs";
import { join, dirname } from "path";
import yaml from "js-yaml";
import { parse as parseJsonc } from "jsonc-parser";

const [configFile, clawName] = process.argv.slice(2);
if (!configFile || !clawName) {
Expand Down Expand Up @@ -53,14 +54,55 @@ for (const entry of service[1]?.environment || []) {

// Resolve ${VAR} and ${VAR:-default} in the raw text (preserves comments, formatting)
let content = readFileSync(configFile, "utf-8");
content = content.replace(/\$\{([^}]+)\}/g, (_match, expr) => {

function resolveExpr(expr) {
const defaultMatch = expr.match(/^([^:]+):-(.*)$/);
if (defaultMatch) {
const key = defaultMatch[1];
const defaultVal = defaultMatch[2];
return (key in env && env[key] !== "") ? env[key] : defaultVal;
}
return (expr in env) ? env[expr] : "";
}

// When a "${VAR}" is the entire JSON value (quoted), coerce booleans and numbers
// so "enabled": "${MATRIX_ENABLED}" becomes "enabled": true, not "enabled": "true".
content = content.replace(/"(\$\{([^}]+)\})"/g, (_match, _fullRef, expr) => {
const val = resolveExpr(expr);
if (val === "true" || val === "false") return val;
if (val !== "" && !isNaN(val) && !isNaN(parseFloat(val))) return val;
return `"${val}"`;
});

// Resolve remaining ${VAR} refs (inside longer strings, unquoted positions)
content = content.replace(/\$\{([^}]+)\}/g, (_match, expr) => resolveExpr(expr));

// Strip disabled channel blocks from the resolved config.
// Removing the block entirely prevents the channel from appearing in the Control UI
// (same as unconfigured channels like WhatsApp, iMessage, etc.).
// The local .jsonc source of truth retains the full config with comments.
const stripTelegram = env.TELEGRAM_ENABLED === "false";
const stripMatrix = env.MATRIX_ENABLED === "false";

if (stripTelegram || stripMatrix) {
const config = parseJsonc(content, [], { allowTrailingComma: true });
if (config?.channels) {
if (stripTelegram) delete config.channels.telegram;
if (stripMatrix) delete config.channels.matrix;
}
content = JSON.stringify(config, null, 2) + "\n";
}

// Drop blank IDs introduced by env substitution, e.g. [""] when
// ADMIN_TELEGRAM_ID is unset. The live config UI normalizes these to [].
const config = parseJsonc(content, [], { allowTrailingComma: true });
const allowFrom = config?.tools?.elevated?.allowFrom;
if (allowFrom && typeof allowFrom === "object") {
for (const [channel, ids] of Object.entries(allowFrom)) {
if (!Array.isArray(ids)) continue;
allowFrom[channel] = ids.filter((id) => typeof id !== "string" || id.trim() !== "");
}
content = JSON.stringify(config, null, 2) + "\n";
}

process.stdout.write(content);
11 changes: 11 additions & 0 deletions docker-compose.yml.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,19 @@ services:
- OPENAI_BASE_URL={{this.openai_base_url}}
- OPENAI_CODEX_BASE_URL={{this.openai_codex_base_url}}
# ── Telegram ──
- TELEGRAM_ENABLED={{this.telegram_enabled}}
{{#if this.telegram_enabled}}
- TELEGRAM_BOT_TOKEN={{this.telegram.bot_token}}
- ADMIN_TELEGRAM_ID={{this.telegram.allow_from}}
{{/if}}
# ── Matrix ──
# MATRIX_ENABLED is always emitted so openclaw.jsonc ${MATRIX_ENABLED} resolves cleanly.
# Sensitive vars (homeserver, token) are only emitted when Matrix is enabled.
- MATRIX_ENABLED={{this.matrix_enabled}}
{{#if this.matrix_enabled}}
- MATRIX_HOMESERVER={{this.matrix.homeserver}}
- MATRIX_ACCESS_TOKEN={{this.matrix.access_token}}
{{/if}}
# ── Domain & UI ──
- OPENCLAW_DOMAIN={{this.domain}}
- OPENCLAW_DOMAIN_PATH={{this.domain_path}}
Expand Down
Loading