Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
621b4e7
feat(setup): interactive onboarding wizard, no manual .env editing
r266-tech Apr 29, 2026
07be87d
fix(setup): pilk + qrcode → optional `wx` extra
r266-tech Apr 29, 2026
906d776
feat(voice): 默认本地免费 STT/TTS, MIMO env 升级
r266-tech Apr 29, 2026
6ecd89a
fix(auto-update): bump uv.lock alongside SDK upgrade — kill phantom r…
r266-tech Apr 29, 2026
58153d3
fix(auto-update): soft-fail on uv lock failure, warn loudly
r266-tech Apr 29, 2026
2d187d7
fix(setup): 模型选项不给默认值, 强制用户输入 1/2/3
r266-tech Apr 29, 2026
6acc386
feat(setup): 第三方 endpoint 真验 token + model
r266-tech Apr 29, 2026
d0564f8
fix(setup): 单独跑 setup.py 时主动探 ~/.local/bin/claude
r266-tech Apr 29, 2026
20be301
fix(setup): WX 装依赖时显式探 uv 路径
r266-tech Apr 29, 2026
84fec11
fix(install): 自动 append ~/.local/bin 到 shell rc, 不让用户手改
r266-tech Apr 29, 2026
4c38847
fix(install): pyproject.toml 加 [build-system] + 重命名 setup.py → wizard.py
r266-tech Apr 29, 2026
bc23817
chore: cleanup last commit — egg-info ignored, drop unrelated runtime…
r266-tech Apr 29, 2026
a705435
feat(wizard): WX 选 yes 时自动 sudo apt 装 build-essential
r266-tech Apr 30, 2026
eeec9d8
fix(install): 末尾启动命令用绝对路径, 不依赖 PATH
r266-tech Apr 30, 2026
1c454b7
fix(install): 装完直接启动 bot, 不再 ask
r266-tech Apr 30, 2026
83d3f07
fix(install): claude 没装直接装, 不 ask
r266-tech Apr 30, 2026
c0d0056
fix(bot): babata 启动时自动 spawn weixin_bot 子进程
r266-tech Apr 30, 2026
6ff263e
fix(install): PATH 同时 append 到 .bashrc + .profile (login shell 友好)
r266-tech Apr 30, 2026
dcf4937
fix(install): ffmpeg 自动装, 不只 warn
r266-tech Apr 30, 2026
a981079
feat(install): 自动配 systemd (Linux) / launchd (macOS) auto-start
r266-tech Apr 30, 2026
ca477d9
feat(update): auto-update 栈 — git pull + uv sync + claude update + 重启…
r266-tech Apr 30, 2026
16a3a66
feat(security): leak_guard — 拦私串泄露到 OSS
r266-tech Apr 30, 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
20 changes: 15 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,24 +39,34 @@ ANTHROPIC_API_KEY= # 默认隔离模式必填; 共享模
# /provider 命令依赖外部 cc-router 服务 — 默认空 = /provider 显示 "未配置".
# BABATA_CC_ROUTER_DIR=

# Optional TTS. Unset TTS_URL = free edge-tts. Backend must be explicit.
# TTS_BACKEND: "openai" (default, POST /audio/speech) | "mimo" (POST /chat/completions)
# ── Voice (TTS + STT) — 默认本地免费, 填 MIMO env 升级 ─────────────────
# 默认 (留空): TTS = MS Edge 免费 (zh-CN-XiaoxiaoNeural); STT = faster-whisper 本地
# (base 模型 ~150MB 首跑自动下载到 ~/.cache/). 不需要 API key.

# 可选: 切自定义 TTS endpoint (mimo / openai 兼容). 留空走免费 edge-tts.
# TTS_BACKEND: "openai" (POST /audio/speech) | "mimo" (POST /chat/completions)
TTS_URL=
TTS_BACKEND=openai
TTS_MODEL=tts-1
TTS_VOICE=nova
TTS_API_KEY=

# MiMo multimodal endpoint — used for voice STT AND video understanding.
# 可选: 升级 STT 到 MiMo Omni (中文识别更准, 视频理解一并启用).
# 不填 → 走本地 faster-whisper. 填了 → 走 MiMo. 视频功能必须填.
# Example: MIMO_API_URL=https://api.xiaomimimo.com/v1
MIMO_API_URL=
MIMO_API_KEY=

# Per-task model (both default to mimo-v2-omni):
# 本地 STT 模型大小 (faster-whisper). 越大越准但越慢 + 越占空间:
# tiny (75MB / 中文一般) | base (150MB, 默认 / 中文凑合) |
# small (500MB / 中文好) | medium (1.5GB / 中文优秀) | large-v3 (3GB / 最准)
# STT_LOCAL_MODEL=base

# Per-task model (MiMo 路径; 默认 mimo-v2-omni):
# STT_MODEL=mimo-v2-omni
# VIDEO_MODEL=mimo-v2-omni

# Optional STT prompt override:
# Optional STT prompt override (MiMo 路径生效):
# STT_PROMPT=转录这段语音, 只输出文本, 不要解释。

# Optional: map button attached to tg_send_location. "amap"|"google"|"osm"|"none"
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ name: smoke
on:
push:
branches: [main]
tags: ['*']
pull_request:

jobs:
Expand All @@ -16,6 +17,9 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Leak guard
run: bash tests/leak_guard.sh

- uses: actions/setup-python@v5
with:
python-version: "3.13"
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@
__pycache__/
*.pyc
.telegraph-token
*.egg-info/
build/
dist/
9 changes: 9 additions & 0 deletions auto-update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ echo "CLI: $OLD_CLI -> $NEW_CLI"
# 2) claude-agent-sdk in babata venv (uv-managed, no pip)
OLD_SDK=$("$VENV_PY" -c "import claude_agent_sdk; print(claude_agent_sdk.__version__)" 2>/dev/null)
"$UV" pip install --python "$VENV_PY" --upgrade claude-agent-sdk 2>&1 | tail -5
# Sync uv.lock to match the upgrade. `pip install --upgrade` ignores the lock,
# so without this any dev `uv sync` would downgrade venv to lock's pinned (older)
# version → next auto-update upgrades again → phantom kickstart of all bots.
# Soft-fail: if lock update breaks (network, uv crash), keep going and warn —
# venv is already upgraded so bots still get the new SDK; lock just stays stale
# until next cycle. set -uo pipefail (no -e) means `cmd | tail` non-zero would
# silently continue without this `||` guard.
"$UV" --directory "$SCRIPT_DIR" lock --upgrade-package claude-agent-sdk 2>&1 | tail -3 \
|| echo "WARN: uv lock failed — lock now stale vs venv, expect phantom restart next cycle if dev runs uv sync"
NEW_SDK=$("$VENV_PY" -c "import claude_agent_sdk; print(claude_agent_sdk.__version__)" 2>/dev/null)
echo "SDK: $OLD_SDK -> $NEW_SDK"

Expand Down
159 changes: 125 additions & 34 deletions bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import time
import urllib.error
import urllib.request
from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
Expand Down Expand Up @@ -224,6 +225,12 @@ def _save_state() -> None:
_last_context_window: int | None = _state.get("last_context_window")
_last_used_tokens: int = int(_state.get("last_used_tokens", 0))
_last_cost: float = float(_state.get("last_cost", 0.0))
# Session id that produced the values above. /status uses _last_used_tokens
# only as a fallback when JSONL hasn't flushed yet; cross-session leak (idle
# reset spawns a new sid while state still carries the previous session's
# inflated model_usage aggregate) was the source of "317% of 1M" right after
# bot restart.
_last_session_id: str | None = _state.get("last_session_id")

# ── Formatting (physical: TG requires HTML, max 4096 chars) ──────────

Expand Down Expand Up @@ -793,6 +800,13 @@ async def _handle_turn_end(self, resp: Response) -> None:
if all_done:
self._schedule_marks(all_done, "👌")
self._reset_turn_state(exit_inflight=True)
# Turn 结束 → 清 bridge.reply_to. 不清的话, V turn 之后 cron
# 走 mcp__tg__tg_send_* 发的消息 (gmail PR-merged 通报 / weekly
# report / X 日报...) 都会带上 V 上一条 message_id 当 reply_to,
# TG 渲染成"引用 V 上一条". chat_id 保留 — cron 还得知道发哪
# 个 chat. 只清 reply_to.
with suppress(Exception):
bridge.reply_to = None
# 不再 P1.4 promote — batch 模式 SDK 不会发第二个 turn_end,
# promote 会让 in_flight 永久卡死. per-msg 模式后续 SDK ev
# 通过 _handle_text_delta 的 _latest_payload fallback 渲染.
Expand Down Expand Up @@ -888,7 +902,7 @@ async def _deliver_response(self, payload: Payload, resp: Response) -> None:

def _apply_accounting(self, resp: Response) -> None:
global _session_cost, _session_turns, _last_model, _last_context_window
global _last_used_tokens, _last_cost
global _last_used_tokens, _last_cost, _last_session_id
if not resp.session_id and resp.cost == 0.0 and not resp.tools:
_session_cost = 0.0
_session_turns = 0
Expand All @@ -907,6 +921,8 @@ def _apply_accounting(self, resp: Response) -> None:
+ resp.cache_creation_tokens
+ resp.cache_read_tokens
)
if resp.session_id:
_last_session_id = resp.session_id

_state["session_cost"] = _session_cost
_state["session_turns"] = _session_turns
Expand All @@ -916,6 +932,8 @@ def _apply_accounting(self, resp: Response) -> None:
_state["last_model"] = _last_model
if _last_context_window:
_state["last_context_window"] = _last_context_window
if _last_session_id:
_state["last_session_id"] = _last_session_id
_save_state()

def _reset_turn_state(
Expand Down Expand Up @@ -1438,7 +1456,12 @@ async def cmd_status(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
actual = cfg_model

win = _last_context_window or _infer_window_from_alias(cfg_model)
used = _last_prompt_tokens(cc._session_id) or _last_used_tokens
# Fallback to _last_used_tokens only when it belongs to the current session.
# Otherwise idle-reset (4am daily restart spawns a fresh sid) leaks the
# previous session's inflated model_usage aggregate into the new session's
# bar — observed as "317% of 1M" before JSONL flushed the first turn.
fallback_used = _last_used_tokens if _last_session_id == cc._session_id else 0
used = _last_prompt_tokens(cc._session_id) or fallback_used
pct_ctx = (used / win * 100) if (win and used > 0) else 0.0
bar = _progress_bar(pct_ctx)
model_short = _short_model(actual)
Expand Down Expand Up @@ -1940,38 +1963,62 @@ def _fmt_ago(ts: float) -> str:
# 的一次性 session 把 bb 交互列表塞满. 判定在 cc.list_recent_sessions 按 JSONL
# entrypoint 字段打 label.
_CURRENT_LABEL = INSTANCE_LABELS.get(INSTANCE, INSTANCE or PROJECT)
_RESUME_CATEGORIES: list[tuple[str, str, list[str]]] = [
# (category_id, 中文显示名, channel labels in cc.py)
("tg", "当前", [_CURRENT_LABEL]),
("wx", "微信", [INSTANCE_LABELS["weixin"]]),
("term", "终端", ["term"]),
("oneshot", "一次性", ["oneshot"]),
# (category_id, 中文显示名, channel labels in cc.py, scan_all_buckets)
# scan_all_buckets=True: 跨 ~/.claude/projects/<cwd-hash>/ 全部 bucket 扫.
# tg/wx 是 babata 自己拥有的 channel, sids 在 babata 自己 cwd 对应的单 bucket
# 里, 不用全扫. term/oneshot 是 V 在终端 (任意 cwd) 开的原生 CC, sessions
# 散落在不同 bucket, 必须全扫才能跟终端 /resume 对齐.
_RESUME_CATEGORIES: list[tuple[str, str, list[str], bool]] = [
("tg", "当前", [_CURRENT_LABEL], False),
("wx", "微信", [INSTANCE_LABELS["weixin"]], False),
("term", "终端", ["term"], True),
("oneshot", "一次性", ["oneshot"], True),
]


def _render_resume_channel_picker() -> tuple[str, "InlineKeyboardMarkup"]:
"""Build the Level-1 渠道 picker (header text + keyboard).

Shared between /resume command (initial display) and resume-back callback
(从 Level 2 session 列表返回).
"""
from telegram import InlineKeyboardButton, InlineKeyboardMarkup

buttons = [
[InlineKeyboardButton(name, callback_data=f"resume-ch:{cat}")]
for cat, name, _, _ in _RESUME_CATEGORIES
]
cur = cc._session_id
header = f"当前: {cur[:8]}\n选一个渠道:" if cur else "当前: (无)\n选一个渠道:"
return header, InlineKeyboardMarkup(buttons)


async def cmd_resume(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
"""Two-level session picker.

Level 1: 选渠道 (TG / 微信 / 终端 / 一次性). /resume 直接给的这层.
Level 2: 选具体 session. 对应渠道内最近 5 条.
Level 2: 选具体 session. 对应渠道内最近 5 条, 底部带"← 返回"回到 Level 1.

两级设计避免跨渠道 session 混在一个 list 里噪音大, V 明确指定"在哪个渠道
的历史里挑"让 picker 更聚焦. 仍然跨渠道可见 — 在 TG 里也能看到终端 /微信开的
session, 只是需要先点对应按钮.
"""
if not _allowed(update):
return
from telegram import InlineKeyboardButton, InlineKeyboardMarkup

buttons = [
[InlineKeyboardButton(name, callback_data=f"resume-ch:{cat}")]
for cat, name, _ in _RESUME_CATEGORIES
]
cur = cc._session_id
header = f"当前: {cur[:8]}\n选一个渠道:" if cur else "当前: (无)\n选一个渠道:"
await update.message.reply_text(
header, reply_markup=InlineKeyboardMarkup(buttons),
)
header, markup = _render_resume_channel_picker()
await update.message.reply_text(header, reply_markup=markup)


async def on_resume_back(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
"""Back-button callback: 从 Level 2 session 列表回到 Level 1 渠道 picker."""
query = update.callback_query
await query.answer()
header, markup = _render_resume_channel_picker()
try:
await query.edit_message_text(header, reply_markup=markup)
except Exception:
pass


async def on_resume_channel_pick(
Expand All @@ -1992,10 +2039,14 @@ async def on_resume_channel_pick(
except Exception:
pass
return
_, cat_name, channel_labels = cat
_, cat_name, channel_labels, scan_all_buckets = cat

from telegram import InlineKeyboardButton, InlineKeyboardMarkup
sessions = cc.list_recent_sessions(limit=5, channel_filter=channel_labels)
sessions = cc.list_recent_sessions(
limit=5,
channel_filter=channel_labels,
scan_all_buckets=scan_all_buckets,
)
if not sessions:
try:
await query.edit_message_text(f"{cat_name}: 暂无历史 session")
Expand All @@ -2018,6 +2069,10 @@ async def on_resume_channel_pick(
buttons.append([
InlineKeyboardButton(label, callback_data=f"resume:{s['sid']}"),
])
# 底部"← 返回"回到 Level 1 渠道 picker — V 选错渠道时不用重发 /resume.
buttons.append([
InlineKeyboardButton("← 返回", callback_data="resume-back"),
])
try:
await query.edit_message_text(
f"{cat_name} 渠道最近 session, 选一个恢复:",
Expand Down Expand Up @@ -2068,32 +2123,45 @@ async def on_resume_click(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> Non
_state["last_cost"] = 0.0
_save_state()

# Show the last 2 rounds of the resumed session so V can recognize which
# thread it is — picker's 48-char first-user preview is often ambiguous
# between nearby sessions. Fall back to bare confirm if JSONL has no
# text-bearing turns yet (fresh session, or all turns were tool-only).
# Cross-bucket fork: 跨 cwd-bucket 选的 sid, cc 已 import 成新 uuid 写到
# babata bucket (见 cc._import_jsonl_to_bucket). 这里用 cc._session_id 反映
# 真实激活的 sid, 让 V 知道发生了 fork.
active_sid = cc._session_id or sid
forked = active_sid != sid

# 读 turn 用 *原* sid — 源文件还在原 bucket 完整可读, fork 后的副本内容跟原
# 一致, 任选其一. 用原 sid 走 _find_jsonl_any_bucket 避免依赖 import 完整性.
turns = cc.get_recent_turns(sid, pairs=2)
head = (
f"✅ 已恢复 <code>{sid[:8]}</code> "
f"(fork → <code>{active_sid[:8]}</code>)"
if forked else
f"✅ 已恢复 <code>{active_sid[:8]}</code>"
)
if turns:
blocks = []
for role, text in turns:
who = "V" if role == "user" else "CC"
blocks.append(f"<b>{who}:</b> {html.escape(text)}")
preview = "\n\n".join(blocks)
body = (
f"✅ 已恢复 <code>{sid[:8]}</code>\n\n"
f"{head}\n\n"
f"<blockquote>{preview}</blockquote>\n\n"
"继续发消息即可。"
)
parse = "HTML"
else:
body = f"✅ 已恢复 {sid[:8]},继续发消息即可。"
parse = None
body = f"{head},继续发消息即可。"
try:
await query.edit_message_text(body, parse_mode=parse)
await query.edit_message_text(body, parse_mode="HTML")
except Exception:
# HTML rejection (escaped something TG parser still dislikes) — retry plain
# HTML rejection — retry plain.
try:
await query.edit_message_text(f"✅ 已恢复 {sid[:8]},继续发消息即可。")
plain = (
f"✅ 已恢复 {sid[:8]} (fork → {active_sid[:8]}),继续发消息即可。"
if forked else
f"✅ 已恢复 {active_sid[:8]},继续发消息即可。"
)
await query.edit_message_text(plain)
except Exception:
pass

Expand Down Expand Up @@ -2208,7 +2276,29 @@ async def _post_init(app: Application) -> None:
log.warning("startup notice send failed: %s", e)


def _spawn_weixin_if_configured() -> "subprocess.Popen | None":
"""装了 WX 就 spawn weixin_bot.py 子进程 — 让 babata 一条命令跑 TG+WX.
判据: ~/.babata/weixin/accounts/ 有 token 文件. 没有则 V 没装 WX, 不 spawn.
生产 launchd 模式各 channel 独立 plist 跑, 通过 BABATA_NO_AUTO_WX=1 关掉.
"""
if os.environ.get("BABATA_NO_AUTO_WX"):
return None
accounts = Path.home() / ".babata" / "weixin" / "accounts"
if not accounts.exists() or not any(accounts.iterdir()):
return None
weixin_main = Path(__file__).parent / "weixin_bot.py"
if not weixin_main.exists():
return None
log.info("WX channel detected — spawning weixin_bot.py")
py = VENV_PYTHON if Path(VENV_PYTHON).exists() else "python3"
proc = subprocess.Popen([py, str(weixin_main)])
import atexit
atexit.register(lambda: proc.terminate() if proc.poll() is None else None)
return proc


def main() -> None:
_spawn_weixin_if_configured()
app = Application.builder().token(TOKEN).concurrent_updates(True).post_init(_post_init).build()

app.add_handler(CommandHandler("status", cmd_status))
Expand All @@ -2221,9 +2311,10 @@ def main() -> None:
app.add_handler(CommandHandler("new", on_text))
app.add_handler(CallbackQueryHandler(on_verbose_click, pattern=r"^verbose:"))
app.add_handler(CallbackQueryHandler(on_provider_click, pattern=r"^provider:"))
# resume-ch: 必须注册在 resume: 之前匹配更精确, 但 ^resume: 不会吞 ^resume-ch:
# (第 7 个字符 '-' vs ':'), 两者 pattern 互斥, 顺序无关紧要.
# resume-ch: / resume-back / resume: 三个 pattern 互斥 (第 7 字符不同),
# 注册顺序无关紧要; 仍按 specific → generic 排列利于阅读.
app.add_handler(CallbackQueryHandler(on_resume_channel_pick, pattern=r"^resume-ch:"))
app.add_handler(CallbackQueryHandler(on_resume_back, pattern=r"^resume-back$"))
app.add_handler(CallbackQueryHandler(on_resume_click, pattern=r"^resume:"))
app.add_handler(CallbackQueryHandler(on_button_click, pattern=r"^mcp:"))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, on_text))
Expand Down
Loading
Loading