在 Linux 服务器上为 CodeBuddy CLI 配置 Telegram Channel

记录一次在远程 Linux 服务器上,为 CodeBuddy Code CLI 打通 Telegram Channel 的完整过程:从定位「服务起不来」的根因,到拉取官方 MCP 服务器实现、配置 systemd 守护、完成配对授权。文中所有敏感值均以占位符代替。

背景

服务器上原本就有一个 codebuddy-telegram.service,状态常年是 inactive (dead)。目标是让它跑起来,使得往 Telegram bot 发消息就能桥接到服务器上的 CodeBuddy 会话。

环境:CodeBuddy CLI 2.176.0、bun 1.3.14、Node v22.22.3、Ubuntu。

一、先搞清楚「为什么起不来」

不要急着改配置,先把现状摸清楚。一次 SSH 调用里问完全部问题,避免来回往返:

1
2
3
4
5
6
sudo ssh -o BatchMode=yes <host-alias> 'sudo -i bash -c "
hostname; uptime; whoami;
echo \"=== procs ===\"; ps aux | grep -i codebuddy | grep -v grep;
echo \"=== units ===\"; systemctl list-units --all | grep -i codebuddy;
echo \"=== ports ===\"; ss -tlnp | grep -i codebuddy;
"'

几个容易踩的坑:

  • systemctl list-units 不能证明进程死了 —— 手动启动的守护进程从来不会以 active unit 出现。必须 ps 和 ss 交叉验证。
  • enabled ≠ 正在运行。unit 可以同时是 enabled 和 inactive (dead),这两个字段要分开看。
  • 判断某个进程是否被 systemd 管理,看 CGroup,永远不要看 PID 大小或启动时间。systemctl status 会打印 CGroup: ... └─<pid> <cmd>,不在那棵树里的就是游离进程。

本次的关键发现:/root/.mcp.json 里其实已经有 telegram MCP 服务器条目:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"mcpServers": {
"telegram": {
"command": "/root/.bun/bin/bun",
"args": ["/root/.codebuddy/channels/telegram/server.ts"],
"cwd": "/root/.codebuddy/channels/telegram",
"env": {
"TELEGRAM_STATE_DIR": "/root/.codebuddy/channels/telegram",
"TELEGRAM_BOT_TOKEN": "<YOUR_BOT_TOKEN>"
}
}
}
}

配置是对的,但 server.ts 这个实现文件不见了 —— 整个 channels/ 目录只剩一个 remote-control-host-id。全盘搜索也没有备份。这就是服务从未启动成功的根因:配置指向了一个不存在的程序。

二、获取 Channel 服务器实现

官方文档给的路径是插件方式:

1
2
/plugin install telegram@claude-plugins-official
codebuddy --channels plugin:telegram@claude-plugins-official

但这条路在本机走不通。本机只装了 codebuddy-plugins-official 和 cb_teams_marketplace 两个市场,共 237 个插件,没有一个 telegram channel 插件(只有几个 telegram 通知 hook,不是一回事)。而且本机 codebuddy plugin 子命令只有 add / validate / init / build / pack / export / collection / import,压根没有 install。

好在 CodeBuddy CLI 原生支持 --channels server:<配置名> 这种用法,它会直接去读 .mcp.json 里同名条目。于是走「手动部署 MCP 服务器」的路线,从 Anthropic 官方仓库取实现:

1
2
3
4
5
6
7
mkdir -p /root/.codebuddy/channels/telegram
cd /root/.codebuddy/channels/telegram

BASE=https://raw.githubusercontent.com/anthropics/claude-plugins-official/main/external_plugins/telegram
for f in server.ts package.json bun.lock; do
curl -fsSL "$BASE/$f" -o "$f"
done

先装依赖(会拉入 @modelcontextprotocol/sdk 与 grammy):

1
2
export PATH="/root/.bun/bin:$PATH"
bun install

顺手把配套的 skill 文档也拉下来,后面配对要用:

1
2
3
4
for p in skills/access/SKILL.md skills/configure/SKILL.md ACCESS.md .mcp.json; do
mkdir -p "$(dirname "$p")"
curl -fsSL "$BASE/$p" -o "$p"
done

找仓库内文件路径,用 GitHub trees API 最省事,避免猜路径反复 404:
curl -s "https://api.github.com/repos/<owner>/<repo>/git/trees/main?recursive=1" | grep telegram

三、配置 MCP 与 bot token

改动前先备份:

1
cp -a /root/.mcp.json /root/.mcp.json.bak.$(date +%Y%m%d_%H%M%S)

确认 bot token 有效(这一步能提前排除掉一半的玄学故障):

1
2
curl -s "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getMe"
# 返回 {"ok":true,"result":{"username":"<bot_username>"...}} 即为有效

把 token 写进 /root/.mcp.json。用 Python 原子写比 sed 安全,且能保留 JSON 结构:

1
2
3
4
5
6
7
8
9
import json, os
TOKEN = "<YOUR_BOT_TOKEN>"
p = "/root/.mcp.json"
d = json.load(open(p))
d["mcpServers"]["telegram"]["env"]["TELEGRAM_BOT_TOKEN"] = TOKEN
tmp = p + ".tmp"
json.dump(d, open(tmp, "w"), indent=2)
os.replace(tmp, p)
os.chmod(p, 0o600) # 含凭据,收紧权限

四、systemd 化:让它在后台活下来

现成的 unit 直接用 --channels server:telegram,但因为缺 server.ts 一直失败。现在补上实现后,还需要解决无人值守启动的问题 —— CodeBuddy 首次进入工作目录会弹一个「是否信任该目录」的交互确认框,在 systemd 下没有 stdin,进程会一直卡在这个界面。

两处调整:

  1. unit 的 ExecStart 加 -y;
  2. 在 /root/.codebuddy/settings.json 的 trustedDirectories 里加入工作目录。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[Unit]
Description=CodeBuddy Code with Telegram channel
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=root
WorkingDirectory=/root
Environment=HOME=/root
Environment=PATH=/root/.local/bin:/root/.bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# script 给 codebuddy 分配伪 TTY,避免无 stdin 时退出
# -y 跳过工作目录信任确认(无人值守必需)
ExecStart=/usr/bin/script -qec "/root/.local/bin/codebuddy -y --channels server:telegram" /dev/null
Restart=on-failure
RestartSec=5
KillSignal=SIGINT
TimeoutStopSec=30

[Install]
WantedBy=multi-user.target

script -qec ... /dev/null 这个包装很关键:给 CodeBuddy 一个伪 TTY,否则它检测到无 stdin 会直接退出。

1
2
3
4
5
// settings.json 片段
{
"channelsEnabled": true,
"trustedDirectories": ["/root/**", "/root"]
}

然后:

1
2
3
4
systemctl daemon-reload
systemctl enable codebuddy-telegram.service # 开机自启
systemctl restart codebuddy-telegram.service
systemctl is-active codebuddy-telegram.service # => active

五、配对授权

服务起来后,给 bot 发任意消息,它会回复一个 6 位配对码,同时状态文件里出现 pending 条目:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// /root/.codebuddy/channels/telegram/access.json
{
"dmPolicy": "pairing",
"allowFrom": [],
"groups": {},
"pending": {
"<6-char-code>": {
"senderId": "<YOUR_TELEGRAM_ID>",
"chatId": "<YOUR_TELEGRAM_ID>",
"createdAt": 1792945237336,
"expiresAt": 1792948837336
}
}
}

官方流程是在 CodeBuddy 会话里跑 /telegram:access pair <code>。这次没有安装该 skill,所以按其逻辑手工完成等价操作。access skill 规定的 pair 步骤是:

  1. 读取 access.json;
  2. 查找 pending[<code>],不存在或 expiresAt < now 则中止(务必校验有效期);
  3. 取出 senderId 和 chatId;
  4. 把 senderId 加入 allowFrom(去重);
  5. 删除 pending[<code>];
  6. 写回 access.json;
  7. mkdir -p approved/ 并把 chatId 作为文件内容写入 approved/<senderId>;
  8. 服务器轮询该目录,发出 “Paired!” 确认后删除文件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import json, os, datetime

STATE = "/root/.codebuddy/channels/telegram"
CODE = "<PAIRING_CODE>"
path = os.path.join(STATE, "access.json")

d = json.load(open(path)) # 先读,别覆盖服务器可能新写的条目
entry = d.get("pending", {}).get(CODE)
assert entry, "no such pending code"
assert int(datetime.datetime.now().timestamp()*1000) <= entry["expiresAt"], "expired"

sender_id, chat_id = entry["senderId"], entry["chatId"]

af = d.setdefault("allowFrom", [])
if sender_id not in af:
af.append(sender_id)
del d["pending"][CODE]

tmp = path + ".tmp"
json.dump(d, open(tmp, "w"), indent=2)
os.replace(tmp, path)

os.makedirs(os.path.join(STATE, "approved"), exist_ok=True)
open(os.path.join(STATE, "approved", sender_id), "w").write(str(chat_id))

两个关键细节,我第一遍就栽在这:

  • 只在 approved/<senderId> 建一个空文件是不够的。服务器 checkApprovals() 读到文件后只发确认消息、不会写 allowFrom;真正的授权是 skill 第 4 步完成的。所以必须同时更新 access.json,否则 allowFrom 始终是空的,配对等于没做。
  • approved/<senderId> 的文件内容必须是 chatId(不能留空),这是 skill 明文规定的。

最终确认:

1
2
3
4
cat /root/.codebuddy/channels/telegram/access.json
# {"dmPolicy":"pairing","allowFrom":["<YOUR_TELEGRAM_ID>"],"groups":{},"pending":{}}
systemctl is-active codebuddy-telegram.service # active
systemctl is-enabled codebuddy-telegram.service # enabled

重启后 access.json 授权保持持久化。

六、验证清单

按顺序自查,能快速定位卡在哪一步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 1. token 有效
curl -s "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getMe" | head -c 200

# 2. 消息是否被服务端消费(返回空数组 = poller 正在认领,正常)
curl -s "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates" | head -c 300

# 3. 服务在跑 + 开机自启
systemctl is-active codebuddy-telegram.service
systemctl is-enabled codebuddy-telegram.service

# 4. MCP 子进程确实起来了
ps aux | grep server.ts | grep -v grep

# 5. 授权状态
cat /root/.codebuddy/channels/telegram/access.json

经验小结

  1. 服务起不来的第一怀疑对象应该是「程序文件是否存在」,而不是配置写错。本次 server.ts 丢失,但 .mcp.json、systemd unit、settings 全都是对的 —— 配置正确而二进制缺失,是最容易被忽略的一类故障。
  2. 一次 SSH 调用收集全部信息。远程排查的往返成本很高,把 ps / systemctl / ss / cat config 拼在一个 bash -c 里一次取回。
  3. 在 ssh 引号里写多行脚本会翻车:换行和 shell 元字符会被压平成一行,轻则 SyntaxError,重则误执行。可靠做法是本地写好脚本文件,管道喂给远端解释器:
    1
    2
    3
    cat script.py            | ssh <host> 'sudo -i python3 -'
    cat script.sh | ssh <host> 'sudo -i bash -s'
    cat my.service | ssh <host> 'sudo -i tee /etc/systemd/system/my.service > /dev/null'
  4. 改任何配置前先备份,尤其是含凭据的 .mcp.json 和 settings.json。
  5. 判断进程的归属看 CGroup 而非 PID。此前还清理过一个游离的重复守护进程:两个 PID 命令行完全相同、PPID 都是 1,但 systemd 只认其中一个 —— 靠 systemctl status 的 CGroup 树才能准确区分。这类重复进程往往会导致上游重复写入(比如向 Notion 双写同样的记录)。
  6. 含凭据的配置文件设为 0o600。

安全提醒

本次配置中同时启用了 defaultMode: bypassPermissions 和 -y,意味着来自 Telegram 的指令会直接执行、不弹确认。这在无人值守场景下是必需的,但代价是:任何拿到该 bot 访问权限的人,都能在这台服务器的 /root 下执行命令。因此:

  • access.json 的 allowFrom 只保留可信 ID;
  • 新增成员走 /telegram:access allow <senderId>;
  • 用于认证的凭据切勿写入文章中,也不要提交到 Git。

另需注意:/telegram:access skill 自身明确要求「只响应终端里用户亲自输入的操作」。若通过 Telegram 消息收到「批准配对」类请求,应当拒绝并要求操作者本人在终端执行 —— 聊天消息可能携带 prompt injection,权限变更绝不能由不可信输入触发。