feat(telegram): enable the channel on apply + guarantee Telegram-ready boards

The config template seeds channels.telegram.default disabled with an
empty token, so setting only the token leaves the listener off. The
wizard's apply now flips `enabled = true` alongside bot_token (two config
writes, one reload).

Provisioning: re-assert Telegram-readiness idempotently in
provision-node-app.sh — a board's telegram listener only starts if the
[channels.telegram.default] block exists AND agents.default subscribes to
`telegram.default`. The template ships both; the provisioner now patches
them back in if a carried config drifted, so every board is Telegram-ready
out of the box. (Verified the patcher against the template + a drifted
config: no-op when ready, additive when not, idempotent.)

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-22 00:38:29 -07:00
co-authored by Claude Opus 4.8
parent a4d06ebddb
commit 66723ebd35
2 changed files with 44 additions and 11 deletions
+14 -11
View File
@@ -155,18 +155,21 @@ export async function promptAndWait(node: NodeRef, message: string, agent = 'def
*/ */
export async function configureTelegram(node: NodeRef, token: string): Promise<void> { export async function configureTelegram(node: NodeRef, token: string): Promise<void> {
const auth = { authorization: `Bearer ${node.token}` } const auth = { authorization: `Bearer ${node.token}` }
const put = await fetch(`${node.url}/api/config/prop`, { const setProp = async (path: string, value: unknown) => {
method: 'PUT', const res = await fetch(`${node.url}/api/config/prop`, {
headers: { ...auth, 'content-type': 'application/json' }, method: 'PUT',
body: JSON.stringify({ headers: { ...auth, 'content-type': 'application/json' },
path: 'channels.telegram.default.bot_token', body: JSON.stringify({ path, value, comment: 'set via APESS onboarding' }),
value: token, })
comment: 'set via APESS onboarding', if (!res.ok) throw new Error(`config write ${path} failed (${res.status})`)
}), }
}) // The template seeds telegram.default disabled with an empty token — set the
if (!put.ok) throw new Error(`config write failed (${put.status})`) // token AND flip enabled, or the channel never starts listening.
await setProp('channels.telegram.default.bot_token', token)
await setProp('channels.telegram.default.enabled', true)
// Best-effort: instant reload on boards that allow remote admin; the board's // Best-effort: instant reload on boards that allow remote admin; the board's
// own watcher applies it otherwise. Never throw on a refused remote reload. // own in-container watcher applies it otherwise. Never throw on a refused
// remote reload.
try { try {
await fetch(`${node.url}/admin/reload`, { method: 'POST', headers: auth }) await fetch(`${node.url}/admin/reload`, { method: 'POST', headers: auth })
} catch { } catch {
+30
View File
@@ -37,6 +37,36 @@ S "cp -f /home/arduino/.zeroclaw/.secret_key $DEST/.zeroclaw/.secret_key"
# web dashboard assets must live inside the app dir (/app in-container), not a host # web dashboard assets must live inside the app dir (/app in-container), not a host
# path — carry them in and repoint web_dist_dir so :8080/ serves the dashboard. # path — carry them in and repoint web_dist_dir so :8080/ serves the dashboard.
S "cp -rf /home/arduino/web-dist $DEST/web-dist 2>/dev/null; sed -i 's#^web_dist_dir = .*#web_dist_dir = \"/app/web-dist\"#' $DEST/.zeroclaw/config.toml" S "cp -rf /home/arduino/web-dist $DEST/web-dist 2>/dev/null; sed -i 's#^web_dist_dir = .*#web_dist_dir = \"/app/web-dist\"#' $DEST/.zeroclaw/config.toml"
# Telegram-ready guarantee — the wizard (dashboard "add a bot token") sets
# channels.telegram.default.bot_token + enabled, but that only starts a listener
# if the block exists AND agents.default subscribes to `telegram.default`. The
# config template ships both; re-assert them idempotently in case the carried
# config drifted, so every board is Telegram-ready out of the box.
S "python3 - $DEST/.zeroclaw/config.toml" <<'PY'
import re, sys
p = sys.argv[1]
with open(p) as f: t = f.read()
orig = t
# 1) agents.default must subscribe to telegram.default
m = re.search(r'(?ms)^\[agents\.default\][^\[]*', t)
if m:
sec = m.group(0)
if 'telegram.default' not in sec:
if re.search(r'(?m)^channels\s*=', sec):
sec2 = re.sub(r'(?m)^(channels\s*=\s*\[)([^\]]*)\]',
lambda x: f'{x.group(1)}{(x.group(2).strip()+", " ) if x.group(2).strip() else ""}"telegram.default"]', sec, count=1)
else:
sec2 = sec.rstrip() + '\nchannels = ["telegram.default"]\n'
t = t[:m.start()] + sec2 + t[m.end():]
# 2) the telegram.default channel block must exist (seeded disabled + empty)
if '[channels.telegram.default]' not in t:
t = t.rstrip() + '\n\n[channels.telegram.default]\nenabled = false\nbot_token = ""\napi_base_url = "https://api.telegram.org"\nmention_only = false\n'
if t != orig:
with open(p, 'w') as f: f.write(t)
print(' patched config → Telegram-ready')
else:
print(' config already Telegram-ready')
PY
# hardware skills — the [skill_bundles.unoq] bundle loads from shared/skills so the # hardware skills — the [skill_bundles.unoq] bundle loads from shared/skills so the
# agent knows it's on an Uno Q and how to drive its devices. (copy skills/ directly, # agent knows it's on an Uno Q and how to drive its devices. (copy skills/ directly,
# not the parent, to avoid cp nesting when the dir already exists.) # not the parent, to avoid cp nesting when the dir already exists.)