Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9c3cb23
fix(messaging): activate OpenClaw channels in generated config
ericksoa May 28, 2026
4560c7d
chore(messaging): keep config generator net-neutral
ericksoa May 28, 2026
4a4dcdd
test(messaging): move OpenClaw channel checks to scenario e2e
ericksoa May 28, 2026
3089bde
fix(messaging): enable Telegram/Discord channels and verify bridge af…
yimoj May 28, 2026
61729d4
test(messaging): restore live nightly channel checks
ericksoa May 28, 2026
1213935
Merge remote-tracking branch 'origin/main' into fix/openclaw-messagin…
ericksoa May 28, 2026
2b34bee
fix(scripts): compact channels enabled fix to satisfy growth guardrail
yimoj May 28, 2026
235ca30
test(messaging): sanitize OpenClaw channel diagnostics
ericksoa May 28, 2026
9c65a5b
chore(messaging): keep generator net-neutral
ericksoa May 28, 2026
079ba61
fix(messaging): pin OpenClaw channel plugins
ericksoa May 28, 2026
48211a6
ci(messaging): pass live e2e secrets
ericksoa May 28, 2026
ad39f2c
test(messaging): mock OpenClaw provider sends
ericksoa May 28, 2026
0644e42
test(messaging): avoid regex in fake discord auth
ericksoa May 28, 2026
83f9eca
test(messaging): fix OpenClaw mock send proofs
ericksoa May 28, 2026
1616d48
test(messaging): import Slack proof helpers directly
ericksoa May 28, 2026
637ad25
Merge remote-tracking branch 'origin/main' into fix/openclaw-messagin…
ericksoa May 28, 2026
3158872
Merge remote-tracking branch 'origin/main' into unify-4403-pr4400
ericksoa May 28, 2026
e3a2e72
Merge remote-tracking branch 'pr/4400' into unify-4403-pr4400
ericksoa May 28, 2026
0fb1829
test: bound Windows bootstrap harness timeout
ericksoa May 28, 2026
db2cdfb
test: address messaging E2E review findings
ericksoa May 28, 2026
83ae969
Merge branch 'main' into fix/4390-telegram-channel-runtime
ericksoa May 28, 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
21 changes: 21 additions & 0 deletions .github/workflows/e2e-script.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ on:
required: false
BRAVE_API_KEY:
required: false
TELEGRAM_BOT_TOKEN_REAL:
required: false
TELEGRAM_CHAT_ID_E2E:
required: false
DISCORD_BOT_TOKEN_REAL:
required: false
DISCORD_CHANNEL_ID_E2E:
required: false
SLACK_BOT_TOKEN_REAL:
required: false
SLACK_APP_TOKEN_REAL:
required: false
SLACK_CHANNEL_ID_E2E:
required: false

permissions:
contents: read
Expand Down Expand Up @@ -157,3 +171,10 @@ jobs:
BRAVE_API_KEY: ${{ inputs.brave_api_key && secrets.BRAVE_API_KEY || '' }}
GITHUB_TOKEN: ${{ inputs.github_token && github.token || '' }}
NVIDIA_API_KEY: ${{ inputs.nvidia_api_key && secrets.NVIDIA_API_KEY || '' }}
TELEGRAM_BOT_TOKEN_REAL: ${{ secrets.TELEGRAM_BOT_TOKEN_REAL }}
TELEGRAM_CHAT_ID_E2E: ${{ secrets.TELEGRAM_CHAT_ID_E2E }}
DISCORD_BOT_TOKEN_REAL: ${{ secrets.DISCORD_BOT_TOKEN_REAL }}
DISCORD_CHANNEL_ID_E2E: ${{ secrets.DISCORD_CHANNEL_ID_E2E }}
SLACK_BOT_TOKEN_REAL: ${{ secrets.SLACK_BOT_TOKEN_REAL }}
SLACK_APP_TOKEN_REAL: ${{ secrets.SLACK_APP_TOKEN_REAL }}
SLACK_CHANNEL_ID_E2E: ${{ secrets.SLACK_CHANNEL_ID_E2E }}
7 changes: 7 additions & 0 deletions .github/workflows/nightly-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,13 @@ jobs:
secrets:
NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}
BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }}
TELEGRAM_BOT_TOKEN_REAL: ${{ secrets.TELEGRAM_BOT_TOKEN_REAL }}
TELEGRAM_CHAT_ID_E2E: ${{ secrets.TELEGRAM_CHAT_ID_E2E }}
DISCORD_BOT_TOKEN_REAL: ${{ secrets.DISCORD_BOT_TOKEN_REAL }}
DISCORD_CHANNEL_ID_E2E: ${{ secrets.DISCORD_CHANNEL_ID_E2E }}
SLACK_BOT_TOKEN_REAL: ${{ secrets.SLACK_BOT_TOKEN_REAL }}
SLACK_APP_TOKEN_REAL: ${{ secrets.SLACK_APP_TOKEN_REAL }}
SLACK_CHANNEL_ID_E2E: ${{ secrets.SLACK_CHANNEL_ID_E2E }}
openclaw-slack-pairing-e2e:
if: >-
github.repository == 'NVIDIA/NemoClaw' && (github.event_name != 'workflow_dispatch' ||
Expand Down
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -395,11 +395,13 @@ COPY scripts/nemoclaw-start.sh /usr/local/bin/nemoclaw-start
COPY nemoclaw-blueprint/scripts/*.js /usr/local/lib/nemoclaw/preloads/
COPY scripts/codex-acp-wrapper.sh /usr/local/bin/nemoclaw-codex-acp
COPY scripts/generate-openclaw-config.py /usr/local/lib/nemoclaw/generate-openclaw-config.py
COPY scripts/openclaw-build-messaging-plugins.py /usr/local/lib/nemoclaw/openclaw-build-messaging-plugins.py
COPY scripts/seed-wechat-accounts.py /usr/local/lib/nemoclaw/seed-wechat-accounts.py
COPY nemoclaw-blueprint/openclaw-plugins/ /usr/local/share/nemoclaw/openclaw-plugins/
RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-codex-acp \
/usr/local/lib/nemoclaw/sandbox-init.sh \
/usr/local/lib/nemoclaw/generate-openclaw-config.py \
/usr/local/lib/nemoclaw/openclaw-build-messaging-plugins.py \
/usr/local/lib/nemoclaw/seed-wechat-accounts.py \
&& if [ -d /usr/local/lib/nemoclaw/preloads ]; then find /usr/local/lib/nemoclaw/preloads -type f -name '*.js' -exec chmod 644 {} +; fi \
&& chmod 755 /usr/local/share/nemoclaw \
Expand Down Expand Up @@ -543,7 +545,7 @@ USER sandbox
RUN NEMOCLAW_OPENCLAW_MANAGED_PROXY=0 python3 /usr/local/lib/nemoclaw/generate-openclaw-config.py

# hadolint ignore=DL3059,DL4006
RUN openclaw doctor --fix --non-interactive
RUN python3 /usr/local/lib/nemoclaw/openclaw-build-messaging-plugins.py

# Lock down npm: no further registry traffic in this image. Everything past
# this point must resolve from local sources only.
Expand Down
45 changes: 45 additions & 0 deletions nemoclaw-blueprint/scripts/telegram-diagnostics.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,4 +191,49 @@
wrapHttp(https, 'request');
wrapHttp(https, 'get');
process.nextTick(maybeLogCredentialPlaceholderDiagnostics);

// Defense in depth for #4314/#4390: if Telegram is configured but the
// bridge module never logs "starting provider" and never hits the Bot
// API within the startup window, surface a single actionable breadcrumb
// so the channel is observably broken instead of silently invisible.
//
// Gate to the OpenClaw gateway process flavors only. The preload is
// exported via NODE_OPTIONS, so every short-lived Node child the user
// spawns inside the sandbox (CLI tools, shells, npm scripts) also requires
// this file; without the gate the timer would emit a false "bridge did
// not start" line from every Node command even while the real gateway
// bridge is healthy. Mirrors sandbox-safety-net.js's gatewayProcessFlavor.
function basename(value) {
return String(value || '').split(/[\\/]/).pop();
}
function gatewayProcessFlavor() {
if (basename(process.argv0) === 'openclaw-gateway') return 'openclaw-gateway';
if (basename(process.title) === 'openclaw-gateway') return 'openclaw-gateway';
if (process.argv[2] === 'gateway') return 'launcher';
if (basename(process.argv[1]) === 'openclaw-gateway') return 'openclaw-gateway';
if (basename(process.argv[0]) === 'openclaw-gateway') return 'openclaw-gateway';
return '';
}
if (!gatewayProcessFlavor()) return;
var STARTUP_GRACE_MS = Number(process.env.NEMOCLAW_TELEGRAM_STARTUP_GRACE_MS || '') || 15000;
var noStartupTimer = setTimeout(function () {
if (providerStarted || startupProbeLogged) return;
var configPath = process.env.OPENCLAW_CONFIG_PATH || '/sandbox/.openclaw/openclaw.json';
try {
var fs = require('fs');
var cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
var telegram = cfg && cfg.channels && cfg.channels.telegram;
if (!telegram || telegram.enabled === false) return;
var accounts = telegram.accounts || {};
if (!Object.keys(accounts).length) return;
} catch (_e) {
return;
}
emit(
'[telegram] [default] bridge did not start within ' +
Math.round(STARTUP_GRACE_MS / 1000) +
's; check channels.telegram.enabled, plugin entries, and gateway log'
);
}, STARTUP_GRACE_MS);
if (typeof noStartupTimer.unref === 'function') noStartupTimer.unref();
})();
14 changes: 7 additions & 7 deletions scripts/generate-openclaw-config.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,11 +566,9 @@ def _placeholder(channel: str, env_key: str) -> str:
for ch in msg_channels:
if ch == "whatsapp":
_ch_cfg[ch] = {
"enabled": True,
"accounts": {
"default": {
"enabled": True,
"healthMonitor": {"enabled": False},
}
"default": {"enabled": True, "healthMonitor": {"enabled": False}}
}
}
continue
Expand All @@ -585,7 +583,6 @@ def _placeholder(channel: str, env_key: str) -> str:
account["appToken"] = _placeholder(ch, "SLACK_APP_TOKEN")
if ch == "telegram":
account["proxy"] = proxy_url
if ch == "telegram":
account["groupPolicy"] = "open"
if ch in _allowed_ids and _allowed_ids[ch]:
account["dmPolicy"] = "allowlist"
Expand All @@ -611,7 +608,8 @@ def _placeholder(channel: str, env_key: str) -> str:
channel_id: dict(slack_channel_config)
for channel_id in _slack_allowed_channels
}
_ch_cfg[ch] = {**({"enabled": True} if ch == "slack" else {}), "accounts": {"default": account}}
# Top-level enabled is required by OpenClaw 2026.5.22+ (#4189/#4314/#4390).
_ch_cfg[ch] = {"enabled": True, "accounts": {"default": account}}

# WeChat (openclaw-weixin) is NOT added to channels.* here in build
# contexts where the plugin has not been installed yet — writing it upfront
Expand Down Expand Up @@ -716,7 +714,9 @@ def _placeholder(channel: str, env_key: str) -> str:
# registered an accountId under channels.openclaw-weixin.accounts.
"openclaw-weixin": {"enabled": True},
}
plugin_entries.update({"slack": {"enabled": True}} if "slack" in _ch_cfg else {})
plugin_entries.update(
{ch: {"enabled": True} for ch in ("discord", "slack", "telegram", "whatsapp") if ch in _ch_cfg}
)
_bundled_provider_plugins = {
"amazon-bedrock": {"amazon-bedrock", "bedrock"},
"amazon-bedrock-mantle": {"amazon-bedrock-mantle"},
Expand Down
153 changes: 153 additions & 0 deletions scripts/openclaw-build-messaging-plugins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Install OpenClaw messaging plugins that match the bundled OpenClaw version.

OpenClaw's doctor repair uses the official catalog's unversioned plugin specs.
That can drift to a newer external messaging plugin than the host OpenClaw
runtime. NemoClaw pins the runtime with OPENCLAW_VERSION, so build-time channel
activation must pin external messaging plugins to that same version.
"""

from __future__ import annotations

import argparse
import base64
import json
import os
import subprocess
import sys
from typing import Iterable


DEFAULT_CHANNELS_B64 = "W10="

EXTERNAL_CHANNEL_PACKAGES = {
"discord": "@openclaw/discord",
"slack": "@openclaw/slack",
"whatsapp": "@openclaw/whatsapp",
}

DOCTOR_ENV_BY_CHANNEL = {
"telegram": {
"TELEGRAM_BOT_TOKEN": "openshell:resolve:env:TELEGRAM_BOT_TOKEN",
},
"discord": {
"DISCORD_BOT_TOKEN": "openshell:resolve:env:DISCORD_BOT_TOKEN",
},
"slack": {
"SLACK_BOT_TOKEN": "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN",
"SLACK_APP_TOKEN": "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN",
},
}


class BuildMessagingPluginError(RuntimeError):
"""Raised for configuration errors that should fail the image build."""


def decode_channels(raw: str) -> list[str]:
try:
decoded = base64.b64decode(raw, validate=True)
parsed = json.loads(decoded.decode("utf-8"))
except Exception as exc: # noqa: BLE001 - keep the build error actionable.
raise BuildMessagingPluginError(
"NEMOCLAW_MESSAGING_CHANNELS_B64 must be base64-encoded JSON array"
) from exc

if not isinstance(parsed, list):
raise BuildMessagingPluginError(
"NEMOCLAW_MESSAGING_CHANNELS_B64 must decode to a JSON array"
)

channels: list[str] = []
seen: set[str] = set()
for item in parsed:
if not isinstance(item, str):
raise BuildMessagingPluginError(
"NEMOCLAW_MESSAGING_CHANNELS_B64 may contain only string channel names"
)
channel = item.strip().lower()
if not channel or channel in seen:
continue
seen.add(channel)
channels.append(channel)
return channels


def require_openclaw_version(channels: Iterable[str], env: dict[str, str]) -> str:
needs_external_install = any(channel in EXTERNAL_CHANNEL_PACKAGES for channel in channels)
version = (env.get("OPENCLAW_VERSION") or "").strip()
if needs_external_install and not version:
raise BuildMessagingPluginError(
"OPENCLAW_VERSION is required when external messaging channels are enabled"
)
return version


def plugin_specs(channels: Iterable[str], openclaw_version: str) -> list[str]:
specs: list[str] = []
for channel in channels:
package_name = EXTERNAL_CHANNEL_PACKAGES.get(channel)
if package_name:
specs.append(f"{package_name}@{openclaw_version}")
return specs


def doctor_env_overrides(channels: Iterable[str]) -> dict[str, str]:
overrides: dict[str, str] = {}
for channel in channels:
overrides.update(DOCTOR_ENV_BY_CHANNEL.get(channel, {}))
return overrides


def run_command(args: list[str], *, env: dict[str, str] | None = None) -> None:
print("+ " + " ".join(args), flush=True)
subprocess.run(args, check=True, env=env)


def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--dry-run",
action="store_true",
help="Print the derived plugin specs and doctor env overrides as JSON.",
)
args = parser.parse_args(argv)

raw_channels = os.environ.get("NEMOCLAW_MESSAGING_CHANNELS_B64", DEFAULT_CHANNELS_B64)
channels = decode_channels(raw_channels or DEFAULT_CHANNELS_B64)
openclaw_version = require_openclaw_version(channels, os.environ)
specs = plugin_specs(channels, openclaw_version)
env_overrides = doctor_env_overrides(channels)

if args.dry_run:
print(
json.dumps(
{
"channels": channels,
"doctorEnv": env_overrides,
"installSpecs": specs,
"openclawVersion": openclaw_version,
},
indent=2,
sort_keys=True,
)
)
return 0

for spec in specs:
run_command(["openclaw", "plugins", "install", spec])

doctor_env = os.environ.copy()
doctor_env.update(env_overrides)
run_command(["openclaw", "doctor", "--fix", "--non-interactive"], env=doctor_env)
return 0


if __name__ == "__main__":
try:
raise SystemExit(main(sys.argv[1:]))
except BuildMessagingPluginError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(2)
Loading
Loading