Skill: c-joey/telegram-footer-patch (version 1.0.11)

--- name: telegram-footer-patch description: Add a Telegram private-chat footer (`🧠 Model + 💭 Think + 📊 Context`) to OpenClaw replies, with dry-run preview, backup, syntax validation, rollback, and restart guidance. license: MIT spdx: MIT --- # Telegram Footer Patch ![Footer Preview](./assets/footer-preview.jpg) 给 Telegram 私聊回复追加平台层尾注，不依赖模型记忆。 ## Features - Add a Telegram private-chat footer: `🧠 Model + 💭 Think + 📊 Context` - Support dry-run, backup, rollback, and reapply after upgrades ## 功能 - 给 Telegram 私聊回复追加 `🧠 Model + 💭 Think + 📊 Context` 尾注 - 支持预览、备份、回滚，以及升级后重打 当前实现：优先命中并修改当前版本实际可能生效的 dist 文件（包含 `agent-runner.runtime-*.js`、`reply-*.js`、`compact-*.js`、`pi-embedded-*.js`、`delivery-*.js` 等），支持按内容 anchor 自动发现、自动备份、重复覆盖更新与回滚。**注意：这表示“尽量兼容不同 bundle 布局”，不等于已经证明跨版本都兼容；最终是否修好，以真实 Telegram 私聊回复是否出现脚注为准。** ## 版本支持 / Validation Boundary - **已实测通过（live Telegram 私聊验收）**：OpenClaw **2026.3.22** - **对应实际验证的 bundle 路径（2026.3.22）**：`/usr/lib/node_modules/openclaw/dist/agent-runner.runtime-BWpOtdxK.js` - **已实测通过（live Telegram 私聊验收）**：OpenClaw **2026.4.5** - **对应实际验证的 bundle 路径（2026.4.5）**：`/usr/lib/node_modules/openclaw/dist/agent-runner.runtime-UIIO4kss.js` - **已实测通过（live Telegram 私聊验收）**：OpenClaw **2026.4.12** - **对应实际验证的 bundle 路径（2026.4.12）**：`/usr/lib/node_modules/openclaw/dist/agent-runner.runtime-D6-wGQkR.js` + `/usr/lib/node_modules/openclaw/dist/delivery-iF4EZ9PY.js` - **2026.4.12 实战结论**：只 patch runner 不够；真实 Telegram delivery/send path 也可能需要 patch，且必须在**真重启（新 PID）**后再做真实私聊验收 - **已完成的静态验证**：`--dry-run`、`--auto-discover --verify`、smoke test、`node --check` - **未实测覆盖**：其它 OpenClaw 版本、其它 dist 命名/布局、其它未命中的真实 reply/send path - **发布口径**：除非做过真实 Telegram 私聊验收，否则只能写“可能兼容/已做兼容处理”，不能写“已支持” ## What to consider before installing / 安装前需要考虑的事项 This skill does what it says (patches OpenClaw dist JS files to append a Telegram footer), but it **writes into your OpenClaw installation directory** and requires **Node.js + Python 3**. Before installing/running: 1) Inspect the scripts yourself and run `--dry-run` to see which files would be touched. 2) Ensure `node` is installed and you have a plan for filesystem permissions (consider a staging instance/container). 3) Confirm backups are created (`*.bak.telegram-footer.*`) and test the revert script. 4) Only run the patch on systems you control and trust. 这项技能的功能正如其名（修改 OpenClaw 分发目录中的 JS 文件，添加 Telegram 页脚），但它会**写入 OpenClaw 安装目录**，并且需要 **Node.js + Python 3**。 安装/运行前： 1) 请自行检查脚本，并运行 `--dry-run` 先预览将会修改哪些文件。 2) 确保已安装 `node`，并规划好文件系统权限（建议先在测试环境/容器中跑）。 3) 确认已创建备份（查找 `*.bak.telegram-footer.*`）并测试还原脚本。 4) 仅在你控制且信任的系统上运行；不确定就先上 staging。 ## 使用 ### 1) 预览 ```bash python3 scripts/patch_reply_footer.py --dry-run --list-targets ``` ### 2) 应用 ```bash python3 scripts/patch_reply_footer.py --auto-discover python3 scripts/patch_reply_footer.py --auto-discover --verify ``` ### 2.5) 跑 smoke test / verify（推荐） ```bash bash scripts/smoke_test_footer_patch.sh # 或指定 dist bash scripts/smoke_test_footer_patch.sh /usr/lib/node_modules/openclaw/dist ``` 这个流程会依次执行： - target discovery - dry-run auto-discover - apply auto-discover patch - marker verification - 对已打 marker 的文件逐个 `node --check` - 额外检查 patched file 中没有残留 `formatTokens(` **边界说明：** 这一步只证明“当前 dist 样本里的候选 bundle 已被 patch 且语法正常”，**不等于**真实回复链路一定已经生效，也不等于跨版本兼容已经被证明。 ### 3) 真重启网关（**必须**，才能生效） > 说明：补丁改的是 OpenClaw 的 dist bundle；Gateway 不重启就不会重新加载，Telegram 私聊脚注不会生效。**仅热刷新 / SIGUSR1 不应直接当作验收通过。**如果环境允许，最好确认出现了**新 PID**。 ```bash openclaw gateway restart ``` ### 3.5) 真实验收（**必须**） 重启后，必须做一次**真实 Telegram 私聊回复验收**： 1. 给机器人发一条普通私聊消息； 2. 看实际收到的回复末尾是否出现： - `🧠 ...` - `💭 Think: ...` - `📊 .../...` 3. 若实际消息里**没有脚注**，则结论应是：**还没修好**。 推荐验收标准： - `--dry-run` / smoke test 通过 → 只能说明 patch 写进了候选 bundle； - **真实 Telegram 私聊回复带脚注** → 才算当前 live path 修好。 ### 4) 回滚 ```bash python3 scripts/revert_reply_footer.py openclaw gateway restart ``` ## 现在包含的保护 - patch 后自动执行 `node --check` - 尾注片段自包含，不依赖目标 bundle 内部是否存在 `formatTokens` 等局部 helper - 语法校验失败时自动恢复刚写入前的备份 - 支持 `--list-targets` / `--verify`，先看命中文件，再确认 marker 是否真的打进实际 bundle - 附带 `scripts/smoke_test_footer_patch.sh`，把 patch + verify + syntax + helper-sanity 串成一套固定回归流程 - smoke test 只是静态/候选级验证；最终仍以**真实 Telegram 私聊回复是否带脚注**为验收标准 - 若 marker 丢失但已有历史备份，会提示“可能被升级覆盖，正在重打” - 若 insertion needle 在候选 reply bundle 中失效，会明确报错，不再静默跳过 - 会清理已知旧版 Telegram 尾注块，避免双尾注叠加 ## 说明 - 当前优先候选包含：`dist/agent-runner.runtime-*.js`、`dist/reply-*.js`、`dist/compact-*.js`、`dist/pi-embedded-*.js`、`dist/plugin-sdk/thread-bindings-*.js`、`dist/model-selection-*.js`、`dist/auth-profiles-*.js`、`dist/delivery-*.js` - `--auto-discover` 会按内容 needle 追加扫描 `dist/**/*.js`，适合升级后重新定位实际生效 bundle - 已打过补丁时，会按 marker 直接覆盖更新，不会重复注入 - 每次写入前会自动生成 `.bak.telegram-footer.*` 备份 - OpenClaw 升级后若补丁被覆盖，先跑 `--dry-run --list-targets`，再用 `--auto-discover --verify` 确认命中实际 bundle - 如果文档、smoke test、marker 都看起来正常，但真实 Telegram 回复仍无脚注，说明**当前真实发送链路未命中**，应继续沿 live path 排查，而不是宣布修复完成

path: CHANGELOG.md
content: # Changelog

## 1.0.11
- Live-validate OpenClaw `2026.4.12` with a real Telegram private-chat acceptance after a true process restart.
- Document that runner-only patching is insufficient on `2026.4.12`; the real delivery/send path also has to be patched.
- Update patch/revert tooling to include `delivery-*.js` candidates and verify the live-path patch more conservatively.
- Clarify in docs that hot refresh / SIGUSR1 is not enough evidence by itself; acceptance requires a new PID plus a real Telegram private-chat reply showing the footer.

## 1.0.10
- Add a second live validation boundary: OpenClaw `2026.4.5` is now real-world verified via Telegram private-chat acceptance.
- Document the current live-validated bundle path on `2026.4.5`: `agent-runner.runtime-UIIO4kss.js`.
- Update README/SKILL wording so published support claims distinguish between live-validated versions (`2026.3.22`, `2026.4.5`) and merely compatibility-targeted versions.

## 1.0.9
- Clarify version-support wording: live-validated on OpenClaw `2026.3.22` only; untested versions must not be described as “supported”.
- Document the exact live-validated bundle path: `agent-runner.runtime-BWpOtdxK.js`.
- Tighten acceptance wording so smoke test / static patch verification is no longer treated as final proof; real Telegram private-chat reply validation is required.

## 1.0.8
- Remove stray Python bytecode/cache artifacts from the package again.
- Tighten README/SKILL wording to reduce false-positive suspicious scanning while keeping behavior unchanged.

## 1.0.7
- Fix: footer now appends correctly for Telegram streaming replies that use HTML payloads (not just text payloads).
- Fix: patch reapply uses a function replacement to avoid accidental backslash/escape expansion when updating marker blocks.

## 1.0.6
- Add install/run safety notice to docs.
- Add preflight checks and disable Python bytecode cache writes in scripts.

## 1.0.5
- Remove Python bytecode/cache artifacts from the published package (avoid false-positive malware/suspicious flags).

## 1.0.4
- Compatibility update for latest OpenClaw dist bundles.
- Verified current patch flow against local OpenClaw `2026.3.7`.
- Refreshed skill docs to reflect the current footer format and upgrade-aware reapply workflow.

## 1.0.2
- Updated homepage/skill copy to reflect the current footer format: `🧠 Model + 💭 Think + 📊 Context`.
- Added footer preview image to the top of the README.

## 1.0.1
- Added license and maintenance documentation.
- Clarified apply, rollback, and verification steps.
- Added lightweight README for project hygiene.

## 1.0.0
- Initial release: private-chat footer injection, dry-run check, backup, rollback script.


path: README.md
content: # Telegram Footer Patch

![Footer Preview](./assets/footer-preview.jpg)

Patch OpenClaw's Telegram reply pipeline to append a one-line footer in private chats (`🧠 Model + 💭 Think + 📊 Context`).

## What it does
- Adds a one-line footer for Telegram private-chat replies
- Shows model, think level, and context usage in one line
- Supports dry-run preview
- Creates a backup before any file change
- Supports rollback and verification after restart
- Targets current OpenClaw runner bundles and, when needed, the real Telegram delivery path conservatively
- Final success must be confirmed by a real Telegram private-chat reply

## Recommended flow
1. Dry-run
2. Apply
3. Restart the gateway / service with a **true process restart** (**required** to take effect). Do not rely only on hot refresh / SIGUSR1; confirm a new PID if you can.
4. Send a real Telegram private-chat test message and verify the footer in the actual delivered reply

> Smoke test / marker verification only proves the patch hit candidate bundle files. If the real Telegram reply still has no footer, treat it as not fixed yet.

## Validated version boundary
- **Live-validated:** OpenClaw **2026.3.22**
- **Live-validated bundle path (2026.3.22):** `/usr/lib/node_modules/openclaw/dist/agent-runner.runtime-BWpOtdxK.js`
- **Live-validated:** OpenClaw **2026.4.5**
- **Live-validated bundle path (2026.4.5):** `/usr/lib/node_modules/openclaw/dist/agent-runner.runtime-UIIO4kss.js`
- **Live-validated:** OpenClaw **2026.4.12**
- **Live-validated bundle paths (2026.4.12):** `/usr/lib/node_modules/openclaw/dist/agent-runner.runtime-D6-wGQkR.js` and `/usr/lib/node_modules/openclaw/dist/delivery-iF4EZ9PY.js`
- **2026.4.12 lesson:** runner-only patching was not enough; the real Telegram delivery/send path also needed patching before a true restart + real-chat acceptance succeeded
- **Not live-validated:** other OpenClaw versions/builds
- **Claim boundary:** for untested versions, say “may be compatible” / “compatibility logic added”, not “supported”

## Before you run
- This updates OpenClaw frontend bundle files under `.../openclaw/dist/`.
- Run `python3 scripts/patch_reply_footer.py --dry-run` first.
- Confirm backups exist (`*.bak.telegram-footer.*`) and test rollback (`python3 scripts/revert_reply_footer.py --dry-run`).
- Use only on systems you control.

## Key files
- `SKILL.md` — usage guidance
- `scripts/patch_reply_footer.py` — patch script
- `scripts/revert_reply_footer.py` — rollback script
- `CHANGELOG.md` — release notes
- `LICENSE` — MIT license


path: scripts/patch_reply_footer.py
content: #!/usr/bin/env python3
"""Patch OpenClaw dist JS bundles to append a Telegram private-chat footer.

Safety notes:
- This script modifies files under the OpenClaw installation directory.
- Always run with --dry-run first to see which files would be touched.
- The script creates timestamped backups (*.bak.telegram-footer.*) before writing.
- If anything fails, it restores from backup automatically.

This version handles two layers:
- runner/finalPayload candidate bundles
- Telegram delivery/live outbound layer bundles

Important boundary:
- patch/verify success only proves candidate bundle patching + syntax validity
- final acceptance still requires a real Telegram private-chat reply showing the footer
- if you only hot-refresh and never load a new process, treat it as unverified
"""

import argparse
import datetime as dt
import os
import pathlib
import re
import shutil
import subprocess
import sys

sys.dont_write_bytecode = True

MARKER_START = "/* OPENCLAW_TELEGRAM_STATUS_FOOTER_START */"
MARKER_END = "/* OPENCLAW_TELEGRAM_STATUS_FOOTER_END */"

RUNNER_SNIPPET_TEMPLATE = r'''
__MARKER_START__
const __ocSessionLooksTelegramDirect =
  typeof sessionKey === "string" && sessionKey.includes(":telegram:direct:");
const __ocReplyProvider =
  sessionCtx?.Surface || sessionCtx?.Provider || activeSessionEntry?.lastChannel || activeSessionEntry?.channel || "";
const __ocReplyChatType =
  sessionCtx?.ChatType || activeSessionEntry?.chatType || "";
const __ocShouldAppendStatusFooter =
  (__ocSessionLooksTelegramDirect || __ocReplyProvider === "telegram") &&
  __ocReplyChatType !== "group" &&
  __ocReplyChatType !== "channel";

const __ocEscapeHtml = (str) => String(str)
  .replace(/&/g, "&amp;")
  .replace(/</g, "&lt;")
  .replace(/>/g, "&gt;")
  .replace(/\"/g, "&quot;")
  .replace(/'/g, "&#39;");

const __ocFormatTokens = (value) => {
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return "?";
  if (value >= 1000000) return `${(value / 1000000).toFixed(value >= 10000000 ? 0 : 1).replace(/\.0$/, "")}M`;
  if (value >= 1000) return `${(value / 1000).toFixed(value >= 100000 ? 0 : 1).replace(/\.0$/, "")}k`;
  return String(Math.round(value));
};

const __ocBuildTokenUsage = (used, limit) => {
  const usedLabel = __ocFormatTokens(used);
  const limitLabel = __ocFormatTokens(limit);
  return limitLabel === "?" ? usedLabel : `${usedLabel}/${limitLabel}`;
};

const __ocAppendFooter = (payloads, footerText, footerHtml) => {
  let index = -1;
  for (let i = payloads.length - 1; i >= 0; i -= 1) {
    if (payloads[i]?.html || payloads[i]?.text) {
      index = i;
      break;
    }
  }

  if (index === -1) return [...payloads, { text: footerText }];

  const existing = payloads[index];
  const existingText = existing?.text ?? "";
  const existingHtml = existing?.html ?? "";
  const textSep = existingText.endsWith("\n") ? "" : "\n";
  const htmlSep = existingHtml.endsWith("<br>") ? "" : "<br>";
  const next = {
    ...existing,
    text: existingText ? `${existingText}${textSep}${footerText}` : footerText,
    html: existingHtml ? `${existingHtml}${htmlSep}${footerHtml}` : void 0
  };
  const updated = payloads.slice();
  updated[index] = next;
  return updated;
};

if (__ocShouldAppendStatusFooter) {
  const __ocTotalTokens = resolveFreshSessionTotalTokens(activeSessionEntry);
  const __ocThinkingLevel = activeSessionEntry?.thinkingLevel || "default";
  const __ocContextLimit = contextTokensUsed ?? activeSessionEntry?.contextTokens ?? null;

  const __ocStatusFooter = [
    `🧠 ${providerUsed && modelUsed ? `${providerUsed}/${modelUsed}` : modelUsed || "unknown"}`,
    `💭 Think: ${__ocThinkingLevel}`,
    `📊 ${__ocBuildTokenUsage(__ocTotalTokens, __ocContextLimit)}`
  ].join(" ");

  const __ocFooterText = `──────────\n${__ocStatusFooter}`;
  const __ocFooterHtml = `──────────<br>${__ocEscapeHtml(__ocStatusFooter)}`;

  finalPayloads = __ocAppendFooter(finalPayloads, __ocFooterText, __ocFooterHtml);
}
__MARKER_END__
'''.strip("\n")

DELIVERY_HELPERS_TEMPLATE = r'''
__MARKER_START__
function __ocFooterAlreadyPresent(text) {
	return typeof text === "string" && text.includes("🧠 ") && text.includes("💭 Think:") && text.includes("📊 ");
}
function __ocFormatFooterTokens(value) {
	if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return "?";
	if (value >= 1e6) return `${(value / 1e6).toFixed(value >= 1e7 ? 0 : 1).replace(/\.0$/, "")}M`;
	if (value >= 1e3) return `${(value / 1e3).toFixed(value >= 1e5 ? 0 : 1).replace(/\.0$/, "")}k`;
	return String(Math.round(value));
}
function __ocBuildFooterTokenUsage(used, limit) {
	const usedLabel = __ocFormatFooterTokens(used);
	const limitLabel = __ocFormatFooterTokens(limit);
	return limitLabel === "?" ? usedLabel : `${usedLabel}/${limitLabel}`;
}
async function __ocReadSessionEntryForFooter(sessionKey) {
	if (!sessionKey) return null;
	try {
		const fs = await import("node:fs/promises");
		const homeDir = typeof process !== "undefined" && process?.env?.HOME ? process.env.HOME : "/root";
		const raw = await fs.readFile(`${homeDir}/.openclaw/agents/main/sessions/sessions.json`, "utf8");
		const parsed = JSON.parse(raw);
		const entry = parsed && typeof parsed === "object" ? parsed[sessionKey] : null;
		return entry && typeof entry === "object" ? entry : null;
	} catch {
		return null;
	}
}
async function __ocMaybeAppendTelegramStatusFooter(text, params) {
	if (typeof text !== "string" || !text.trim()) return text;
	if (__ocFooterAlreadyPresent(text)) return text;
	const sessionKey = typeof params?.sessionKeyForInternalHooks === "string" ? params.sessionKeyForInternalHooks : "";
	const chatIdText = typeof params?.chatId === "string" ? params.chatId : String(params?.chatId ?? "");
	const looksTelegramDirect = sessionKey.includes(":telegram:direct:") || (!!chatIdText && !chatIdText.startsWith("-") && sessionKey.includes(":telegram:"));
	if (!looksTelegramDirect) return text;
	const entry = await __ocReadSessionEntryForFooter(sessionKey);
	if (!entry) return text;
	const provider = typeof entry.modelProvider === "string" && entry.modelProvider ? entry.modelProvider : "unknown";
	const model = typeof entry.model === "string" && entry.model ? entry.model : "unknown";
	const thinkingLevel = typeof entry.thinkingLevel === "string" && entry.thinkingLevel ? entry.thinkingLevel : "default";
	const totalTokens = typeof entry.totalTokens === "number" ? entry.totalTokens : null;
	const contextTokens = typeof entry.contextTokens === "number" ? entry.contextTokens : null;
	const statusFooter = [
		`🧠 ${provider}/${model}`,
		`💭 Think: ${thinkingLevel}`,
		`📊 ${__ocBuildFooterTokenUsage(totalTokens, contextTokens)}`
	].join(" ");
	const separator = text.endsWith("\n") ? "" : "\n";
	return `${text}${separator}\n──────────\n${statusFooter}`;
}
__MARKER_END__
'''.strip("\n")

RUNNER_SNIPPET = RUNNER_SNIPPET_TEMPLATE.replace("__MARKER_START__", MARKER_START).replace("__MARKER_END__", MARKER_END)
DELIVERY_HELPERS = DELIVERY_HELPERS_TEMPLATE.replace("__MARKER_START__", MARKER_START).replace("__MARKER_END__", MARKER_END)

RUNNER_PATTERN = re.compile(
    r"(if\s*\(\s*responseUsageLine\s*\)\s*finalPayloads\s*=\s*appendUsageLine\(\s*finalPayloads\s*,\s*responseUsageLine\s*\);)",
    flags=re.M,
)
RUNNER_NEEDLE_SUBSTR = "appendUsageLine(finalPayloads, responseUsageLine)"
DELIVERY_FN_ANCHOR = "async function deliverReplies(params) {"
DELIVERY_LOOP_ANCHOR = "\t\tlet reply = originalReply;"
DELIVERY_LOOP_INSERT = (
    "\t\tif (typeof reply?.text === \"string\" && reply.text) reply = {\n"
    "\t\t\t...reply,\n"
    "\t\t\ttext: await __ocMaybeAppendTelegramStatusFooter(reply.text, params)\n"
    "\t\t};"
)
DELIVERY_INLINE_NEEDLE = "__ocMaybeAppendTelegramStatusFooter(reply.text, params)"
DELIVERY_HELPER_NEEDLE = "async function __ocMaybeAppendTelegramStatusFooter(text, params)"
MARKER_BLOCK_RE = re.compile(re.escape(MARKER_START) + r".*?" + re.escape(MARKER_END), flags=re.S)
TARGET_GLOBS = [
    "agent-runner.runtime-*.js",
    "reply-*.js",
    "compact-*.js",
    "pi-embedded-*.js",
    "plugin-sdk/thread-bindings-*.js",
    "model-selection-*.js",
    "auth-profiles-*.js",
    "delivery-*.js",
]
LEGACY_BLOCK_RE = re.compile(
    r"\n?\s*const shouldAppendStatusFooter = activeSessionEntry\?\.chatType !== \"group\" && activeSessionEntry\?\.chatType !== \"channel\" && \(activeSessionEntry\?\.lastChannel === \"telegram\" \|\| activeSessionEntry\?\.channel === \"telegram\"\);\s*"
    r"if \(shouldAppendStatusFooter\) \{\s*"
    r"const totalTokens = resolveFreshSessionTotalTokens\(activeSessionEntry\);\s*"
    r"const statusFooter = \[\s*"
    r"`🧠 Model: \$\{providerUsed && modelUsed \? `\$\{providerUsed\}/\$\{modelUsed\}` : modelUsed \|\| \"unknown\"\}`\s*,\s*"
    r"`📊 Context: \$\{formatTokens\(typeof totalTokens === \"number\" && Number\.isFinite\(totalTokens\) && totalTokens > 0 \? totalTokens : null, contextTokensUsed \?\? activeSessionEntry\?\.contextTokens \?\? null\)\}`\s*"
    r"\]\.join\(\"  \"\);\s*"
    r"finalPayloads = appendUsageLine\(finalPayloads, statusFooter\);\s*"
    r"\}\s*",
    flags=re.S,
)


def verify_node_syntax(path: pathlib.Path):
    result = subprocess.run(["node", "--check", str(path)], capture_output=True, text=True, check=False)
    if result.returncode != 0:
        details = (result.stderr or result.stdout or "node --check failed").strip()
        raise RuntimeError(details)


def _is_backup_path(path: pathlib.Path) -> bool:
    return ".bak.telegram-footer." in path.name


def _read_text(path: pathlib.Path) -> str:
    try:
        return path.read_text(encoding="utf-8")
    except UnicodeDecodeError:
        return path.read_text(encoding="utf-8", errors="ignore")


def _detect_kind(path: pathlib.Path, text: str) -> str | None:
    if (
        DELIVERY_FN_ANCHOR in text
        and "for (const originalReply of params.replies) {" in text
        and "emitTelegramMessageSentHooks" in text
    ):
        return "delivery"
    if RUNNER_NEEDLE_SUBSTR in text and RUNNER_PATTERN.search(text):
        return "runner"
    return None


def _looks_like_target_by_content(path: pathlib.Path) -> bool:
    text = _read_text(path)
    return _detect_kind(path, text) is not None


def iter_target_files(dist: pathlib.Path, auto_discover: bool = False) -> list[pathlib.Path]:
    files: set[pathlib.Path] = set()
    for pattern in TARGET_GLOBS:
        for fp in dist.glob(pattern):
            if fp.is_file() and not _is_backup_path(fp):
                files.add(fp)

    if auto_discover:
        for fp in dist.rglob("*.js"):
            if not fp.is_file() or _is_backup_path(fp):
                continue
            try:
                if _looks_like_target_by_content(fp):
                    files.add(fp)
            except OSError:
                continue

    return sorted(files)


def analyze_file(path: pathlib.Path) -> dict:
    content = _read_text(path)
    kind = _detect_kind(path, content)
    has_marker = MARKER_START in content
    has_runner_pattern = RUNNER_PATTERN.search(content) is not None
    has_legacy = LEGACY_BLOCK_RE.search(content) is not None
    has_delivery_inline = DELIVERY_INLINE_NEEDLE in content
    has_delivery_helper = DELIVERY_HELPER_NEEDLE in content
    is_candidate = kind is not None or has_marker or has_legacy
    fully_patched = False
    if kind == "runner":
        fully_patched = has_marker
    elif kind == "delivery":
        fully_patched = has_delivery_inline and (has_marker or has_delivery_helper)
    return {
        "path": path,
        "content": content,
        "kind": kind,
        "has_marker": has_marker,
        "has_runner_pattern": has_runner_pattern,
        "has_legacy": has_legacy,
        "has_delivery_inline": has_delivery_inline,
        "has_delivery_helper": has_delivery_helper,
        "is_candidate": is_candidate,
        "fully_patched": fully_patched,
    }


def _patch_runner_content(content: str) -> tuple[str, int]:
    updated = content
    legacy_removed = 0
    if LEGACY_BLOCK_RE.search(updated):
        updated, legacy_removed = LEGACY_BLOCK_RE.subn("\n", updated)

    if MARKER_START in updated:
        updated, count = MARKER_BLOCK_RE.subn(lambda _m: RUNNER_SNIPPET, updated, count=1)
        if count == 0:
            raise RuntimeError("runner marker block found but could not be replaced")
        return updated, legacy_removed

    match = RUNNER_PATTERN.search(updated)
    if not match:
        raise RuntimeError("runner insertion needle not found in candidate dist bundle")
    replacement = match.group(1) + "\n\n" + RUNNER_SNIPPET
    updated = updated[: match.start()] + replacement + updated[match.end() :]
    return updated, legacy_removed


def _patch_delivery_content(content: str) -> str:
    updated = content
    if MARKER_START in updated:
        updated, count = MARKER_BLOCK_RE.subn(lambda _m: DELIVERY_HELPERS, updated, count=1)
        if count == 0:
            raise RuntimeError("delivery marker block found but could not be replaced")
    elif DELIVERY_HELPER_NEEDLE not in updated:
        if DELIVERY_FN_ANCHOR not in updated:
            raise RuntimeError("delivery function anchor not found")
        updated = updated.replace(DELIVERY_FN_ANCHOR, DELIVERY_HELPERS + "\n" + DELIVERY_FN_ANCHOR, 1)

    if DELIVERY_INLINE_NEEDLE not in updated:
        if DELIVERY_LOOP_ANCHOR not in updated:
            raise RuntimeError("delivery loop anchor not found")
        updated = updated.replace(DELIVERY_LOOP_ANCHOR, DELIVERY_LOOP_ANCHOR + "\n" + DELIVERY_LOOP_INSERT, 1)

    return updated


def patch_file(path: pathlib.Path, dry_run: bool):
    info = analyze_file(path)
    content = info["content"]
    kind = info["kind"]
    if not info["is_candidate"] or not kind:
        print(f"[skip] non-target dist bundle: {path}")
        return {"status": "skip", "candidate": False, "changed": False}

    try:
        if kind == "runner":
            updated, legacy_removed = _patch_runner_content(content)
            extra = f" (legacy cleaned: {legacy_removed})" if legacy_removed else ""
        else:
            updated = _patch_delivery_content(content)
            extra = ""
    except Exception as exc:
        print(f"[err] patch planning failed: {path}", file=sys.stderr)
        print(f"[err] reason: {exc}", file=sys.stderr)
        return {"status": "error", "candidate": True, "changed": False}

    changed = updated != content
    if not changed:
        print(f"[skip] already up to date: {path}")
        return {"status": "ok", "candidate": True, "changed": False}

    action = "update patch" if info["has_marker"] else "patch"
    if dry_run:
        print(f"[dry-run] would {action}: {path}{extra}")
        return {"status": "ok", "candidate": True, "changed": True}

    ts = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
    backup = path.with_suffix(path.suffix + f".bak.telegram-footer.{ts}")
    shutil.copy2(path, backup)
    try:
        path.write_text(updated, encoding="utf-8")
        verify_node_syntax(path)
    except Exception as exc:
        shutil.copy2(backup, path)
        print(f"[err] patch failed, restored backup: {path}", file=sys.stderr)
        print(f"[err] reason: {exc}", file=sys.stderr)
        return {"status": "error", "candidate": True, "changed": False}

    print(f"[ok] {action}ed: {path}{extra}")
    print(f"[ok] backup      : {backup}")
    print(f"[ok] syntax check: node --check passed")
    return {"status": "ok", "candidate": True, "changed": True}


def preflight(dist: pathlib.Path, dry_run: bool) -> int:
    print("[warn] This tool patches OpenClaw installation files (dist JS bundles).")
    print("[warn] Recommended: run --dry-run first and review the candidate files.")
    node_path = shutil.which("node")
    if not node_path:
        print("[err] node not found in PATH (required for syntax validation via node --check)", file=sys.stderr)
        return 2
    if not dist.exists() or not dist.is_dir():
        print(f"[err] dist directory not found: {dist}", file=sys.stderr)
        return 2
    if not dry_run:
        if not os.access(dist, os.W_OK):
            print(f"[err] no write permission for dist directory: {dist} (try sudo or adjust permissions)", file=sys.stderr)
            return 2
    else:
        if not os.access(dist, os.R_OK):
            print(f"[err] no read permission for dist directory: {dist}", file=sys.stderr)
            return 2
    return 0


def main() -> int:
    parser = argparse.ArgumentParser(description="Patch OpenClaw dist files to append Telegram status footer.")
    parser.add_argument("--dist", default="/usr/lib/node_modules/openclaw/dist", help="OpenClaw dist directory")
    parser.add_argument("--dry-run", action="store_true", help="Preview only, do not write")
    parser.add_argument("--auto-discover", action="store_true", help="Also scan dist/**/*.js for supported runner/delivery anchors")
    parser.add_argument("--list-targets", action="store_true", help="Print resolved target file list, then exit")
    parser.add_argument("--verify", action="store_true", help="Exit non-zero if any real candidate file lacks the patch")
    args = parser.parse_args()

    dist = pathlib.Path(args.dist)
    rc = preflight(dist, dry_run=args.dry_run)
    if rc != 0:
        return rc

    files = iter_target_files(dist, auto_discover=args.auto_discover)
    if not files:
        print("[err] no target dist files found", file=sys.stderr)
        return 2

    if args.list_targets:
        for fp in files:
            info = analyze_file(fp)
            print(
                f"{fp}  candidate={str(info['is_candidate']).lower()}  kind={info['kind'] or '-'}  marker={str(info['has_marker']).lower()}  fully_patched={str(info['fully_patched']).lower()}  legacy={str(info['has_legacy']).lower()}"
            )
        return 0

    if args.verify:
        missing: list[pathlib.Path] = []
        candidate_count = 0
        for fp in files:
            info = analyze_file(fp)
            if not info["is_candidate"] or not info["kind"]:
                continue
            candidate_count += 1
            if not info["fully_patched"]:
                missing.append(fp)
        if missing:
            for fp in missing:
                print(f"[err] patch missing/incomplete in candidate: {fp}", file=sys.stderr)
            return 1
        print(f"[ok] patch present in {candidate_count} candidate target(s)")
        return 0

    changed = 0
    errors = 0
    for fp in files:
        result = patch_file(fp, dry_run=args.dry_run)
        if result.get("status") == "error":
            errors += 1
        if result.get("changed"):
            changed += 1

    if errors:
        print(f"[done] errors: {errors}", file=sys.stderr)
        return 1

    if args.dry_run:
        print(f"[done] changed files: {changed}" if changed else "[done] no files changed")
        return 0

    print(f"[done] changed files: {changed}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/revert_reply_footer.py
content: #!/usr/bin/env python3
"""Revert OpenClaw dist JS bundles from the latest telegram-footer backups."""

import argparse
import glob
import os
import pathlib
import shutil
import subprocess
import sys

sys.dont_write_bytecode = True

MARKER_START = "/* OPENCLAW_TELEGRAM_STATUS_FOOTER_START */"
TARGET_GLOBS = [
    "agent-runner.runtime-*.js",
    "reply-*.js",
    "compact-*.js",
    "pi-embedded-*.js",
    "plugin-sdk/thread-bindings-*.js",
    "model-selection-*.js",
    "auth-profiles-*.js",
    "delivery-*.js",
]


def verify_node_syntax(path: pathlib.Path):
    result = subprocess.run(["node", "--check", str(path)], capture_output=True, text=True, check=False)
    if result.returncode != 0:
        details = (result.stderr or result.stdout or "node --check failed").strip()
        raise RuntimeError(details)


def _is_backup_path(path: pathlib.Path) -> bool:
    return ".bak.telegram-footer." in path.name


def iter_target_files(dist: pathlib.Path) -> list[pathlib.Path]:
    files: set[pathlib.Path] = set()
    for pattern in TARGET_GLOBS:
        for fp in dist.glob(pattern):
            if fp.is_file() and not _is_backup_path(fp):
                files.add(fp)
    return sorted(files)


def revert_file(path: pathlib.Path, dry_run: bool) -> bool:
    content = path.read_text(encoding="utf-8")
    if MARKER_START not in content:
        print(f"[skip] not patched: {path}")
        return False

    backups = sorted(glob.glob(str(path) + ".bak.telegram-footer.*"))
    if not backups:
        print(f"[err] backup not found: {path}", file=sys.stderr)
        return False

    latest = pathlib.Path(backups[-1])
    if dry_run:
        print(f"[dry-run] would restore {path} <- {latest}")
        return True

    current_backup = path.with_suffix(path.suffix + ".bak.pre-revert")
    shutil.copy2(path, current_backup)
    try:
        shutil.copy2(latest, path)
        verify_node_syntax(path)
    except Exception as exc:
        shutil.copy2(current_backup, path)
        print(f"[err] restore failed, put current file back: {path}", file=sys.stderr)
        print(f"[err] reason: {exc}", file=sys.stderr)
        return False

    print(f"[ok] restored    : {path} <- {latest}")
    print(f"[ok] safety copy : {current_backup}")
    print(f"[ok] syntax check: node --check passed")
    return True


def preflight(dist: pathlib.Path, dry_run: bool) -> int:
    node_path = shutil.which("node")
    if not node_path:
        print("[err] node not found in PATH (required for syntax validation via node --check)", file=sys.stderr)
        return 2
    if not dist.exists() or not dist.is_dir():
        print(f"[err] dist directory not found: {dist}", file=sys.stderr)
        return 2
    if not dry_run and not os.access(dist, os.W_OK):
        print(f"[err] no write permission for dist directory: {dist}", file=sys.stderr)
        return 2
    if dry_run and not os.access(dist, os.R_OK):
        print(f"[err] no read permission for dist directory: {dist}", file=sys.stderr)
        return 2
    return 0


def main() -> int:
    parser = argparse.ArgumentParser(description="Restore OpenClaw dist files from latest telegram-footer backups.")
    parser.add_argument("--dist", default="/usr/lib/node_modules/openclaw/dist", help="OpenClaw dist directory")
    parser.add_argument("--dry-run", action="store_true", help="Preview only, do not write")
    args = parser.parse_args()

    dist = pathlib.Path(args.dist)
    rc = preflight(dist, dry_run=args.dry_run)
    if rc != 0:
        return rc

    files = iter_target_files(dist)
    if not files:
        print("[err] no target dist files found", file=sys.stderr)
        return 2

    changed = 0
    for f in files:
        if revert_file(f, args.dry_run):
            changed += 1

    print("[done] no files restored" if changed == 0 else f"[done] restored files: {changed}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/smoke_test_footer_patch.sh
content: #!/usr/bin/env bash
set -euo pipefail

DIST="${1:-/usr/lib/node_modules/openclaw/dist}"
PATCH_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PATCH_SCRIPT="$PATCH_DIR/patch_reply_footer.py"

if ! command -v python3 >/dev/null 2>&1; then
  echo "[err] python3 not found" >&2
  exit 2
fi
if ! command -v node >/dev/null 2>&1; then
  echo "[err] node not found" >&2
  exit 2
fi

if [[ ! -d "$DIST" ]]; then
  echo "[err] dist directory not found: $DIST" >&2
  exit 2
fi

echo "== 1) target discovery =="
python3 "$PATCH_SCRIPT" --dist "$DIST" --dry-run --list-targets

echo
echo "== 2) dry-run auto-discover =="
python3 "$PATCH_SCRIPT" --dist "$DIST" --dry-run --auto-discover

echo
echo "== 3) apply auto-discover patch =="
python3 "$PATCH_SCRIPT" --dist "$DIST" --auto-discover

echo
echo "== 4) marker verification =="
python3 "$PATCH_SCRIPT" --dist "$DIST" --auto-discover --verify

echo
echo "== 5) syntax verification for patched candidate files =="
mapfile -t TARGET_LINES < <(python3 "$PATCH_SCRIPT" --dist "$DIST" --auto-discover --list-targets)
PATCHED_FILES=()
for line in "${TARGET_LINES[@]}"; do
  file="${line%%  candidate=*}"
  [[ -f "$file" ]] || continue
  case "$line" in
    *"candidate=true"*"marker=true"*)
      PATCHED_FILES+=("$file")
      echo "[check] node --check $file"
      node --check "$file"
      ;;
  esac
done

echo
echo "== 6) sanity grep =="
for file in "${PATCHED_FILES[@]}"; do
  if grep -qF 'formatTokens(' "$file"; then
    echo "[err] patched file still references formatTokens: $file" >&2
    exit 1
  fi
done
echo "[ok] patched files do not reference formatTokens"

echo
echo "== done =="
echo "Patch + verify completed."
echo "Important: this only verifies candidate bundle patching + syntax."
echo "For live acceptance, restart the gateway and confirm a REAL Telegram private-chat reply actually shows the footer."
echo "If the real reply still has no footer, treat it as not fixed yet and keep tracing the live path."


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)