Bridge THIS Claude Code or Codex session to Telegram, Discord, Feishu/Lark, QQ, or WeChat so the
#!/usr/bin/env bash
set -euo pipefail
CTI_HOME="$HOME/.claude-to-im"
CONFIG_FILE="$CTI_HOME/config.env"
PID_FILE="$CTI_HOME/runtime/bridge.pid"
LOG_FILE="$CTI_HOME/logs/bridge.log"
PASS=0
FAIL=0
check() {
local label="$1"
local result="$2"
if [ "$result" = "0" ]; then
echo "[OK] $label"
PASS=$((PASS + 1))
else
echo "[FAIL] $label"
FAIL=$((FAIL + 1))
fi
}
# --- Node.js version ---
if command -v node &>/dev/null; then
NODE_VER=$(node -v | sed 's/v//' | cut -d. -f1)
if [ "$NODE_VER" -ge 20 ] 2>/dev/null; then
check "Node.js >= 20 (found v$(node -v | sed 's/v//'))" 0
else
check "Node.js >= 20 (found v$(node -v | sed 's/v//'), need >= 20)" 1
fi
else
check "Node.js installed" 1
fi
# --- Helper: read a value from config.env ---
get_config() { grep "^$1=" "$CONFIG_FILE" 2>/dev/null | head -1 | cut -d= -f2- | sed 's/^["'"'"']//;s/["'"'"']$//'; }
# --- Read runtime setting ---
SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
CTI_RUNTIME=$(get_config CTI_RUNTIME)
CTI_RUNTIME="${CTI_RUNTIME:-claude}"
echo "Runtime: $CTI_RUNTIME"
echo ""
# --- Claude CLI available (claude/auto modes) ---
if [ "$CTI_RUNTIME" = "claude" ] || [ "$CTI_RUNTIME" = "auto" ]; then
# Resolve CLI path matching the daemon's checkCliCompatibility logic:
# - Version >= 2.x AND all required flags present
# - Skip candidates that fail either check (same as resolveClaudeCliPath)
CLAUDE_PATH=""
CLAUDE_VER=""
CLAUDE_COMPAT=1
REQUIRED_FLAGS="output-format input-format permission-mode setting-sources"
# Helper: check if a candidate passes both version and flags checks.
# Sets CLAUDE_PATH/CLAUDE_VER/CLAUDE_COMPAT on success.
try_candidate() {
local cand="$1"
[ -x "$cand" ] || return 1
local ver
ver=$("$cand" --version 2>/dev/null || true)
[ -z "$ver" ] && return 1
local major
major=$(echo "$ver" | sed -E -n 's/^[^0-9]*([0-9]+)\..*/\1/p' | head -1)
if [ -z "$major" ] || ! [ "$major" -ge 2 ] 2>/dev/null; then
echo " (skipping $cand — version $ver is too old, need >= 2.x)"
return 1
fi
# Version OK — check flags
local help_text
help_text=$("$cand" --help 2>&1 || true)
for flag in $REQUIRED_FLAGS; do
if ! echo "$help_text" | grep -q "$flag"; then
echo " (skipping $cand — version $ver OK but missing --$flag)"
return 1
fi
done
# Fully compatible
CLAUDE_PATH="$cand"
CLAUDE_VER="$ver"
CLAUDE_COMPAT=0
return 0
}
# 1. Explicit env var — if set, daemon uses it unconditionally (no fallback).
# Doctor must mirror this: report on this path only, never scan further.
CTI_EXE=$(get_config CTI_CLAUDE_CODE_EXECUTABLE 2>/dev/null || true)
if [ -n "$CTI_EXE" ]; then
if [ -x "$CTI_EXE" ]; then
if ! try_candidate "$CTI_EXE"; then
# Explicit path is set but incompatible — daemon WILL use it and fail.
# Report it as the selected CLI so the user sees the real problem.
CLAUDE_PATH="$CTI_EXE"
CLAUDE_VER=$("$CTI_EXE" --version 2>/dev/null || echo "unknown")
# CLAUDE_COMPAT stays 1 (incompatible) — checks below will report failure
fi
else
CLAUDE_PATH="$CTI_EXE"
CLAUDE_VER="(not executable)"
fi
fi
# 2. All PATH candidates (only if no explicit env var was set)
if [ -z "$CTI_EXE" ] && [ -z "$CLAUDE_PATH" ]; then
ALL_CLAUDES=$(which -a claude 2>/dev/null || true)
for cand in $ALL_CLAUDES; do
try_candidate "$cand" && break
done
fi
# 3. Well-known locations (only if no explicit env var was set)
if [ -z "$CTI_EXE" ] && [ -z "$CLAUDE_PATH" ]; then
for cand in \
"$HOME/.claude/local/claude" \
"$HOME/.local/bin/claude" \
"/usr/local/bin/claude" \
"/opt/homebrew/bin/claude" \
"$HOME/.npm-global/bin/claude"; do
try_candidate "$cand" && break
done
fi
if [ -n "$CLAUDE_PATH" ] && [ "$CLAUDE_COMPAT" = "0" ]; then
check "Claude CLI compatible (${CLAUDE_VER} at ${CLAUDE_PATH})" 0
elif [ -n "$CLAUDE_PATH" ]; then
# Path found but incompatible (too old, missing flags, or not executable)
check "Claude CLI compatible (${CLAUDE_VER} at ${CLAUDE_PATH} — incompatible, see above)" 1
else
if [ "$CTI_RUNTIME" = "claude" ]; then
check "Claude CLI available (not found in PATH or common locations)" 1
else
check "Claude CLI available (not found — will use Codex fallback)" 0
fi
fi
# --- Claude CLI authenticated ---
# Skip this check if third-party API credentials are configured in config.env.
# In that mode the bridge authenticates via ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN,
# not via `claude auth login`, so a missing interactive login is expected and harmless.
HAS_THIRD_PARTY_AUTH=false
if [ -f "$CONFIG_FILE" ] && grep -qE "^ANTHROPIC_(API_KEY|AUTH_TOKEN)=" "$CONFIG_FILE" 2>/dev/null; then
HAS_THIRD_PARTY_AUTH=true
fi
if [ -n "$CLAUDE_PATH" ] && [ "$CLAUDE_COMPAT" = "0" ]; then
if [ "$HAS_THIRD_PARTY_AUTH" = "true" ]; then
check "Claude CLI auth (skipped — using third-party API credentials from config.env)" 0
else
AUTH_OUT=$("$CLAUDE_PATH" auth status 2>&1 || true)
if echo "$AUTH_OUT" | grep -qiE 'loggedIn.*true|logged.in'; then
check "Claude CLI authenticated" 0
else
check "Claude CLI authenticated (run 'claude auth login')" 1
fi
fi
fi
# --- ANTHROPIC_* env reachability ---
# Check whether ANTHROPIC_* vars are configured in config.env.
# This is what matters for the daemon — the current shell env is irrelevant
# because on macOS the daemon runs under launchd with only plist env vars.
HAS_ANTHROPIC_CONFIG=false
if [ -f "$CONFIG_FILE" ]; then
if grep -q "^ANTHROPIC_" "$CONFIG_FILE" 2>/dev/null; then
HAS_ANTHROPIC_CONFIG=true
fi
fi
if [ "$HAS_ANTHROPIC_CONFIG" = "true" ]; then
check "ANTHROPIC_* vars in config.env (third-party API provider)" 0
PLIST_FILE="$HOME/Library/LaunchAgents/com.claude-to-im.bridge.plist"
# On macOS, verify the launchd plist also has the vars
if [ "$(uname -s)" = "Darwin" ] && [ -f "$PLIST_FILE" ]; then
if grep -q "ANTHROPIC_" "$PLIST_FILE" 2>/dev/null; then
check "ANTHROPIC_* vars in launchd plist" 0
else
check "ANTHROPIC_* vars in launchd plist (NOT present — restart bridge to regenerate plist)" 1
fi
fi
# If bridge is running, verify the LIVE process has the vars.
# The plist may be correct on disk but if the daemon hasn't been
# restarted since the plist was regenerated, it still runs with the
# old environment.
BRIDGE_PID=$(cat "$PID_FILE" 2>/dev/null || true)
if [ -n "$BRIDGE_PID" ] && kill -0 "$BRIDGE_PID" 2>/dev/null; then
# ps eww shows the process environment on macOS/Linux
PROC_ENV=$(ps eww -p "$BRIDGE_PID" 2>/dev/null || true)
if echo "$PROC_ENV" | grep -q "ANTHROPIC_"; then
check "Running bridge process has ANTHROPIC_* env vars" 0
else
check "Running bridge process has ANTHROPIC_* env vars (NOT in process env — restart the bridge)" 1
fi
fi
else
check "ANTHROPIC_* vars in config.env (not set — OK if using default Anthropic auth)" 0
fi
# --- SDK cli.js resolvable ---
SDK_CLI=""
for candidate in \
"$SKILL_DIR/node_modules/@anthropic-ai/claude-agent-sdk/cli.js" \
"$SKILL_DIR/node_modules/@anthropic-ai/claude-agent-sdk/dist/cli.js"; do
if [ -f "$candidate" ]; then
SDK_CLI="$candidate"
break
fi
done
if [ -n "$SDK_CLI" ]; then
check "Claude SDK cli.js exists ($SDK_CLI)" 0
else
if [ "$CTI_RUNTIME" = "claude" ]; then
check "Claude SDK cli.js exists (not found — run 'npm install' in $SKILL_DIR)" 1
else
check "Claude SDK cli.js exists (not found — OK for auto/codex mode)" 0
fi
fi
fi
# --- Codex checks (codex/auto modes) ---
if [ "$CTI_RUNTIME" = "codex" ] || [ "$CTI_RUNTIME" = "auto" ]; then
if command -v codex &>/dev/null; then
CODEX_VER=$(codex --version 2>/dev/null || echo "unknown")
check "Codex CLI available (${CODEX_VER})" 0
else
if [ "$CTI_RUNTIME" = "codex" ]; then
check "Codex CLI available (not found in PATH)" 1
else
check "Codex CLI available (not found — will use Claude)" 0
fi
fi
# Check @openai/codex-sdk
CODEX_SDK="$SKILL_DIR/node_modules/@openai/codex-sdk"
if [ -d "$CODEX_SDK" ]; then
check "@openai/codex-sdk installed" 0
else
if [ "$CTI_RUNTIME" = "codex" ]; then
check "@openai/codex-sdk installed (not found — run 'npm install' in $SKILL_DIR)" 1
else
check "@openai/codex-sdk installed (not found — OK for auto/claude mode)" 0
fi
fi
# Check Codex auth: any of CTI_CODEX_API_KEY / CODEX_API_KEY / OPENAI_API_KEY,
# or `codex auth status` showing logged-in (interactive login).
CODEX_AUTH=1
if [ -n "${CTI_CODEX_API_KEY:-}" ] || [ -n "${CODEX_API_KEY:-}" ] || [ -n "${OPENAI_API_KEY:-}" ]; then
CODEX_AUTH=0
elif command -v codex &>/dev/null; then
CODEX_AUTH_OUT=$(codex auth status 2>&1 || true)
if echo "$CODEX_AUTH_OUT" | grep -qiE 'logged.in|authenticated'; then
CODEX_AUTH=0
fi
fi
if [ "$CODEX_AUTH" = "0" ]; then
check "Codex auth available (API key or login)" 0
else
if [ "$CTI_RUNTIME" = "codex" ]; then
check "Codex auth available (set OPENAI_API_KEY or run 'codex auth login')" 1
else
check "Codex auth available (not found — needed only for Codex fallback)" 0
fi
fi
fi
# --- dist/daemon.mjs freshness ---
DAEMON_MJS="$SKILL_DIR/dist/daemon.mjs"
if [ -f "$DAEMON_MJS" ]; then
STALE_SRC=$(find "$SKILL_DIR/src" -name '*.ts' -newer "$DAEMON_MJS" 2>/dev/null | head -1)
if [ -z "$STALE_SRC" ]; then
check "dist/daemon.mjs is up to date" 0
else
check "dist/daemon.mjs is stale (src changed, run 'npm run build')" 1
fi
else
check "dist/daemon.mjs exists (not built — run 'npm run build')" 1
fi
# --- config.env exists ---
if [ -f "$CONFIG_FILE" ]; then
check "config.env exists" 0
else
check "config.env exists ($CONFIG_FILE not found)" 1
fi
# --- config.env permissions ---
if [ -f "$CONFIG_FILE" ]; then
PERMS=$(stat -f "%Lp" "$CONFIG_FILE" 2>/dev/null || stat -c "%a" "$CONFIG_FILE" 2>/dev/null || echo "unknown")
if [ "$PERMS" = "600" ]; then
check "config.env permissions are 600" 0
else
check "config.env permissions are 600 (currently $PERMS)" 1
fi
fi
# --- Load config for channel checks ---
if [ -f "$CONFIG_FILE" ]; then
CTI_CHANNELS=$(get_config CTI_ENABLED_CHANNELS)
# --- Telegram ---
if echo "$CTI_CHANNELS" | grep -q telegram; then
TG_TOKEN=$(get_config CTI_TG_BOT_TOKEN)
if [ -n "$TG_TOKEN" ]; then
TG_RESULT=$(curl -s --max-time 5 "https://api.telegram.org/bot${TG_TOKEN}/getMe" 2>/dev/null || echo '{"ok":false}')
if echo "$TG_RESULT" | grep -q '"ok":true'; then
check "Telegram bot token is valid" 0
else
check "Telegram bot token is valid (getMe failed)" 1
fi
else
check "Telegram bot token configured" 1
fi
fi
# --- Feishu ---
if echo "$CTI_CHANNELS" | grep -q feishu; then
FS_APP_ID=$(get_config CTI_FEISHU_APP_ID)
FS_SECRET=$(get_config CTI_FEISHU_APP_SECRET)
FS_DOMAIN=$(get_config CTI_FEISHU_DOMAIN)
FS_DOMAIN="${FS_DOMAIN:-https://open.feishu.cn}"
if [ -n "$FS_APP_ID" ] && [ -n "$FS_SECRET" ]; then
FEISHU_RESULT=$(curl -s --max-time 5 -X POST "${FS_DOMAIN}/open-apis/auth/v3/tenant_access_token/internal" \
-H "Content-Type: application/json" \
-d "{\"app_id\":\"${FS_APP_ID}\",\"app_secret\":\"${FS_SECRET}\"}" 2>/dev/null || echo '{"code":1}')
if echo "$FEISHU_RESULT" | grep -q '"code"[[:space:]]*:[[:space:]]*0'; then
check "Feishu app credentials are valid" 0
else
check "Feishu app credentials are valid (token request failed)" 1
fi
else
check "Feishu app credentials configured" 1
fi
fi
# --- QQ ---
if echo "$CTI_CHANNELS" | grep -q qq; then
QQ_APP_ID=$(get_config CTI_QQ_APP_ID)
QQ_APP_SECRET=$(get_config CTI_QQ_APP_SECRET)
if [ -n "$QQ_APP_ID" ] && [ -n "$QQ_APP_SECRET" ]; then
QQ_TOKEN_RESULT=$(curl -s --max-time 10 -X POST "https://bots.qq.com/app/getAppAccessToken" \
-H "Content-Type: application/json" \
-d "{\"appId\":\"${QQ_APP_ID}\",\"clientSecret\":\"${QQ_APP_SECRET}\"}" 2>/dev/null || echo '{}')
QQ_ACCESS_TOKEN=$(echo "$QQ_TOKEN_RESULT" | sed -n 's/.*"access_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
if [ -n "$QQ_ACCESS_TOKEN" ]; then
check "QQ app credentials are valid (access_token obtained)" 0
# Verify gateway availability
QQ_GW_RESULT=$(curl -s --max-time 10 "https://api.sgroup.qq.com/gateway" \
-H "Authorization: QQBot ${QQ_ACCESS_TOKEN}" 2>/dev/null || echo '{}')
if echo "$QQ_GW_RESULT" | grep -q '"url"'; then
check "QQ gateway is reachable" 0
else
check "QQ gateway is reachable (GET /gateway failed)" 1
fi
else
check "QQ app credentials are valid (getAppAccessToken failed)" 1
fi
else
check "QQ app credentials configured" 1
fi
fi
# --- Discord ---
if echo "$CTI_CHANNELS" | grep -q discord; then
DC_TOKEN=$(get_config CTI_DISCORD_BOT_TOKEN)
if [ -n "$DC_TOKEN" ]; then
if echo "${DC_TOKEN}" | grep -qE '^[A-Za-z0-9_-]{20,}\.'; then
check "Discord bot token format" 0
else
check "Discord bot token format (does not match expected pattern)" 1
fi
else
check "Discord bot token configured" 1
fi
fi
# --- Weixin ---
if echo "$CTI_CHANNELS" | grep -q weixin; then
WX_ACCOUNTS_FILE="$CTI_HOME/data/weixin-accounts.json"
if [ -f "$WX_ACCOUNTS_FILE" ]; then
WX_COUNTS=$(node -e '
const fs = require("fs");
const file = process.argv[1];
const accounts = JSON.parse(fs.readFileSync(file, "utf8"));
const enabled = accounts.filter((a) => a && a.enabled && a.token).length;
process.stdout.write(`${enabled}:${accounts.length}`);
' "$WX_ACCOUNTS_FILE" 2>/dev/null || echo "0:0")
WX_ENABLED="${WX_COUNTS%%:*}"
WX_TOTAL="${WX_COUNTS##*:}"
if [ "${WX_ENABLED:-0}" -ge 1 ] 2>/dev/null; then
if [ "${WX_TOTAL:-0}" -gt 1 ] 2>/dev/null; then
check "Weixin linked account store (single-account mode; ${WX_TOTAL} records on disk, newest enabled record will be used)" 0
else
check "Weixin linked account store (single linked account ready)" 0
fi
else
check "Weixin linked account store (found file, but no enabled linked account with token — run 'cd $SKILL_DIR && npm run weixin:login')" 1
fi
else
check "Weixin linked account store (missing — run 'cd $SKILL_DIR && npm run weixin:login')" 1
fi
fi
fi
# --- Log directory writable ---
LOG_DIR="$CTI_HOME/logs"
if [ -d "$LOG_DIR" ] && [ -w "$LOG_DIR" ]; then
check "Log directory is writable" 0
else
check "Log directory is writable ($LOG_DIR)" 1
fi
# --- PID file consistency ---
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if kill -0 "$PID" 2>/dev/null; then
check "PID file consistent (process $PID is running)" 0
else
check "PID file consistent (stale PID $PID, process not running)" 1
fi
else
check "PID file consistency (no PID file, OK)" 0
fi
# --- Recent errors in log ---
if [ -f "$LOG_FILE" ]; then
ERROR_COUNT=$(tail -50 "$LOG_FILE" | grep -ciE 'ERROR|Fatal' || true)
if [ "$ERROR_COUNT" -eq 0 ]; then
check "No recent errors in log (last 50 lines)" 0
else
check "No recent errors in log (found $ERROR_COUNT ERROR/Fatal lines)" 1
fi
else
check "Log file exists (not yet created)" 0
fi
echo ""
echo "Results: $PASS passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
echo ""
echo "Common fixes:"
echo " SDK cli.js missing → cd $SKILL_DIR && npm install"
echo " dist/daemon.mjs stale → cd $SKILL_DIR && npm run build"
echo " config.env missing → run setup wizard"
echo " Weixin linked account missing→ cd $SKILL_DIR && npm run weixin:login"
echo " Stale PID file → run stop, then start"
fi
[ "$FAIL" -eq 0 ] && exit 0 || exit 1