"""微信渠道服务层(DESIGN §8.7):绑定 CRUD + 主动推送 + `send_to_user` 渠道抽象。 - 绑定行的 `bot_token` / `latest_context_token` 经 `crypto` 加解密;快照(BindingSnapshot) 脱离 session、含明文 token,**仅 host 进程内用,绝不外泄/进沙箱**。 - 主动推送 24h 窗口:`context_token` 仅在末次入站 ~24h 内可用;超期/未开口 → 推不出, 返回 reason 给调用方退邮件兜底(§8.5)。 - `send_to_user` 是渠道抽象:scheduler / WechatPushTool 调它,不感知 ClawBot/企业微信; 企业微信(渠道 B)后续在此追加一路。 阻塞 IO(DB + httpx),调用方放 to_thread / executor。 """ from __future__ import annotations import os from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from typing import Optional from uuid import UUID from sqlalchemy import select from core.storage import session_scope from core.storage.models import ChannelBinding from core.wechat import crypto from core.wechat.ilink import DEFAULT_BASE, ILinkClient CONTEXT_TOKEN_TTL = timedelta(hours=24) _CLAWBOT = "clawbot" _WECOM = "wecom" def _get_or_new(s, user_id: UUID, channel: str) -> ChannelBinding: row = s.get(ChannelBinding, (user_id, channel)) if row is None: row = ChannelBinding(user_id=user_id, channel=channel, config={}) s.add(row) return row def clawbot_enabled() -> bool: """ClawBot 渠道总开关(沿用「有开关才挂」范式,§3.4)。""" return os.getenv("ZCBOT_WECHAT_BOT_ENABLED", "").strip().lower() in ( "1", "true", "yes", "on", ) # ─────────────────────────── 绑定快照 / CRUD ─────────────────────────── @dataclass class BindingSnapshot: user_id: UUID bot_token: str # 明文(已解密) base_url: str user_im_id: Optional[str] context_token: Optional[str] # 明文(已解密) context_token_at: Optional[datetime] chat_task_id: Optional[UUID] status: str def _snap(row: ChannelBinding) -> BindingSnapshot: """channel='clawbot' 行 → 快照(解密 token,反序列化 config)。""" cfg = row.config or {} cta = cfg.get("context_token_at") cti = cfg.get("chat_task_id") return BindingSnapshot( user_id=row.user_id, bot_token=crypto.dec(cfg.get("bot_token")) or "", base_url=cfg.get("base_url") or DEFAULT_BASE, user_im_id=cfg.get("user_im_id"), context_token=crypto.dec(cfg.get("latest_context_token")), context_token_at=datetime.fromisoformat(cta) if cta else None, chat_task_id=UUID(cti) if cti else None, status=row.status, ) def get_binding(user_id: UUID) -> Optional[BindingSnapshot]: with session_scope() as s: row = s.get(ChannelBinding, (user_id, _CLAWBOT)) return _snap(row) if row else None def list_active_bindings() -> list[BindingSnapshot]: """入站长轮询管理器用:所有 active 的 ClawBot 绑定(含明文 bot_token)。""" with session_scope() as s: rows = ( s.execute( select(ChannelBinding).where( ChannelBinding.channel == _CLAWBOT, ChannelBinding.status == "active", ) ) .scalars() .all() ) return [_snap(r) for r in rows] def upsert_clawbot_binding( user_id: UUID, bot_token: str, base_url: str, *, bot_im_id: Optional[str] = None ) -> None: """扫码 confirmed 后写/更新绑定。bot_token 加密存进 config(保留已有 user_im_id 等)。""" now = datetime.now(timezone.utc) with session_scope() as s: row = _get_or_new(s, user_id, _CLAWBOT) cfg = dict(row.config or {}) cfg["bot_token"] = crypto.enc(bot_token) cfg["base_url"] = base_url or DEFAULT_BASE if bot_im_id: cfg["bot_im_id"] = bot_im_id row.config = cfg # 重新赋值 → ORM 追踪 JSONB 变更 row.status = "active" row.updated_at = now def refresh_context_token(user_id: UUID, user_im_id: str, context_token: str) -> None: """每条入站消息刷新该用户的 context_token(+时间戳)——主动推送窗口靠它续命。""" now = datetime.now(timezone.utc) with session_scope() as s: row = s.get(ChannelBinding, (user_id, _CLAWBOT)) if row is None: return cfg = dict(row.config or {}) if user_im_id: cfg["user_im_id"] = user_im_id cfg["latest_context_token"] = crypto.enc(context_token) cfg["context_token_at"] = now.isoformat() row.config = cfg row.updated_at = now def set_chat_task(user_id: UUID, task_id: UUID) -> None: now = datetime.now(timezone.utc) with session_scope() as s: row = s.get(ChannelBinding, (user_id, _CLAWBOT)) if row is not None: cfg = dict(row.config or {}) cfg["chat_task_id"] = str(task_id) row.config = cfg row.updated_at = now def unbind(user_id: UUID) -> bool: """解绑 ClawBot(标 revoked,不物理删 → 保留轨迹)。返回是否有绑定被改。""" now = datetime.now(timezone.utc) with session_scope() as s: row = s.get(ChannelBinding, (user_id, _CLAWBOT)) if row is None: return False row.status = "revoked" row.updated_at = now return True # ─────────────────────────── 推送 ─────────────────────────── @dataclass class PushResult: ok: bool channel: str = "clawbot" # sent | no_binding | never_opened | token_stale | error:<...> reason: str = "" def _token_fresh(snap: BindingSnapshot) -> bool: if not snap.context_token or snap.context_token_at is None: return False at = snap.context_token_at if at.tzinfo is None: at = at.replace(tzinfo=timezone.utc) return (datetime.now(timezone.utc) - at) < CONTEXT_TOKEN_TTL def push_clawbot( user_id: UUID, text: str = "", file_path: Optional[str] = None ) -> PushResult: """主动推一条到用户个人微信。仅在 24h 窗口内可用,否则返回 reason 供兜底。""" snap = get_binding(user_id) if snap is None or snap.status != "active": return PushResult(False, reason="no_binding") if not snap.user_im_id or not snap.context_token: return PushResult(False, reason="never_opened") # 冷启动:用户从未开口 if not _token_fresh(snap): return PushResult(False, reason="token_stale") # 超 24h 未互动 client = ILinkClient(snap.bot_token, snap.base_url) try: if text: client.send_text(snap.user_im_id, snap.context_token, text) if file_path: client.send_file(snap.user_im_id, snap.context_token, file_path) except Exception as e: # noqa: BLE001 —— 调用方据 reason 决定兜底 return PushResult(False, reason=f"error: {str(e)[:200]}") return PushResult(True, reason="sent") # ─────────────── 企业微信(渠道 B,纯推送;无 24h 窗口约束)─────────────── def get_wecom_userid(user_id: UUID) -> Optional[str]: with session_scope() as s: row = s.get(ChannelBinding, (user_id, _WECOM)) if row is None or row.status != "active": return None return (row.config or {}).get("wecom_userid") def get_user_by_wecom_userid(wecom_userid: str) -> Optional[UUID]: """企业微信回调只带 wecom_userid → 反查内部 user_id(仅 active 绑定)。入站对话用。""" if not wecom_userid: return None with session_scope() as s: row = s.execute( select(ChannelBinding.user_id).where( ChannelBinding.channel == _WECOM, ChannelBinding.status == "active", ChannelBinding.config["wecom_userid"].astext == wecom_userid, ) ).first() return row[0] if row else None def upsert_wecom_binding(user_id: UUID, wecom_userid: str) -> None: """OAuth 拿到 userid 后写/更新绑定。合并进 config(保留 chat_task_id 等已有字段)。""" now = datetime.now(timezone.utc) with session_scope() as s: row = _get_or_new(s, user_id, _WECOM) cfg = dict(row.config or {}) cfg["wecom_userid"] = wecom_userid row.config = cfg row.status = "active" row.updated_at = now def get_wecom_chat_task(user_id: UUID) -> Optional[UUID]: """企业微信入站对话常驻 task id(无 → None)。""" with session_scope() as s: row = s.get(ChannelBinding, (user_id, _WECOM)) if row is None: return None cti = (row.config or {}).get("chat_task_id") return UUID(cti) if cti else None def set_wecom_chat_task(user_id: UUID, task_id: UUID) -> None: now = datetime.now(timezone.utc) with session_scope() as s: row = s.get(ChannelBinding, (user_id, _WECOM)) if row is not None: cfg = dict(row.config or {}) cfg["chat_task_id"] = str(task_id) row.config = cfg row.updated_at = now def unbind_wecom(user_id: UUID) -> bool: now = datetime.now(timezone.utc) with session_scope() as s: row = s.get(ChannelBinding, (user_id, _WECOM)) if row is None: return False row.status = "revoked" row.updated_at = now return True def push_wecom(user_id: UUID, text: str = "", file_path: Optional[str] = None) -> PushResult: """企业微信主动推一条(无条件,不挑活跃度)。""" from core.wechat import wecom wuid = get_wecom_userid(user_id) if not wuid: return PushResult(False, channel="wecom", reason="no_binding") try: if text: wecom.send_text(wuid, text) if file_path: wecom.send_file(wuid, file_path) except Exception as e: # noqa: BLE001 —— 透出 errcode/errmsg 便于排错 return PushResult(False, channel="wecom", reason=f"error: {str(e)[:200]}") return PushResult(True, channel="wecom", reason="sent") @dataclass class DeliveryReport: results: list[PushResult] = field(default_factory=list) @property def delivered(self) -> bool: return any(r.ok for r in self.results) def active_channels() -> list[str]: """部署级「哪些渠道开了」的**唯一真相源**:门槛判断(`wechat_push_available`) 与投递(`send_to_user`)都引它,避免两处各列各的(曾漏判企业微信致工具不挂)。 加渠道只改这一处,门槛与投递自动一致。顺序即投递优先序。""" from core.wechat.wecom import wecom_configured chans: list[str] = [] if clawbot_enabled(): chans.append(_CLAWBOT) if wecom_configured(): chans.append(_WECOM) return chans _DISPATCH = {_CLAWBOT: push_clawbot, _WECOM: push_wecom} def ensure_channel_chat_task(uid: UUID, channel: str) -> Optional[UUID]: """确保 uid 的 channel 常驻 chat task 存在(未软删),返回 task_id;不存在则新建并回填绑定。 channel ∈ {'wechat','wecom'}。wechat 无 binding → 返回 None(没法建/记)。 入站对话(`_run_channel_conversation`)与 push 记录(`send_to_user`)共用此入口, 避免两条"解析/建 chat task"路径逻辑漂移。建 task 逻辑搬自原 _run_channel_conversation。 """ from uuid import uuid4 from core.agent_builder import ( # 延迟 import:service 被 tools.wechat_bot 引用, load_config, resolve_workspace, working_dir_from_name, # agent_builder 又 import tools.wechat_bot ) # → 顶层 import 循环;函数内 import 打破(同 scheduler.py:227 范式) from core.capabilities import ModelCapabilities from core.paths import ROOT, to_db_path from core.storage.models import Task from core.storage.utils import ensure_local_task_row if channel == "wecom": existing_tid = get_wecom_chat_task(uid) task_name, slug, desc = "企业微信对话", f"wecom-{str(uid)[:8]}", "(企业微信对话)" set_task = set_wecom_chat_task else: # wechat snap = get_binding(uid) if snap is None: return None existing_tid = snap.chat_task_id task_name, slug, desc = "微信对话", f"wechat-{str(uid)[:8]}", "(微信 ClawBot 对话)" set_task = set_chat_task tid = existing_tid need_create = tid is None if not need_create: with session_scope() as s: exists = s.execute( select(Task.task_id).where(Task.task_id == tid, Task.deleted_at.is_(None)) ).first() if exists is None: need_create = True if need_create: cfg = load_config() profile = cfg["default_model"] caps = ModelCapabilities.load(profile, ROOT / cfg["models_dir"]) ws = resolve_workspace(None, cfg) tid = uuid4() fs_dir = working_dir_from_name(ws, uid, slug) fs_dir.mkdir(parents=True, exist_ok=True) ensure_local_task_row( task_id=tid, name=task_name, working_dir=to_db_path(fs_dir), skill="", user_id=uid, model=caps.model_id, model_profile=profile, description=desc, channel=channel, ) set_task(uid, tid) return tid def _file_rel_to_user_root(user_id: UUID, file_path: str) -> Optional[str]: """宿主绝对路径 → user_root 相对 POSIX(如 scheduled-/x.md)。 文件不在 user_root 内(外部 --working-dir)→ None。""" from pathlib import Path from core.agent_builder import load_config, resolve_workspace, user_root try: ws = resolve_workspace(None, load_config()) root = user_root(ws, user_id) return Path(file_path).resolve().relative_to(root.resolve()).as_posix() except Exception: return None def _build_push_message(text: str, rel: Optional[str]) -> str: """构造写进 chat task 的 assistant 消息:推送摘要 + 可点文件链接 + agent read 路径。""" lines: list[str] = [] if text and text.strip(): lines.append(text.strip()) if rel: fname = rel.rsplit("/", 1)[-1] lines.append(f"产物文件:[{fname}](/v1/files/download?path={rel})") lines.append(f"(如需基于此文件提问,可读取 ../{rel})") return "\n\n".join(lines) def _record_push_to_chat( report: DeliveryReport, user_id: UUID, text: str, file_path: Optional[str], source_task_id: Optional[UUID], ) -> None: """把投递成功的推送记为对应渠道 chat task 的 assistant 消息(web 端可见 + agent 可基于追问)。Unified 模式:进 agent 上下文(推送是 bot 发给用户的话, 记得自己发过什么 = 连贯,非污染)。记录失败不影响投递(吞掉打日志)。""" if not report.delivered: return from core.storage.utils import append_channel_message rel = _file_rel_to_user_root(user_id, file_path) if file_path else None for r in report.results: if not r.ok: continue ch = "wechat" if r.channel == _CLAWBOT else r.channel # clawbot→wechat(建 task channel) try: tid = ensure_channel_chat_task(user_id, ch) if tid is None: continue if source_task_id is not None and tid == source_task_id: continue # 调用方即该 chat task 自己的 run,tool 记录已在,不重复插摘要 append_channel_message(tid, _build_push_message(text, rel), kind="push") except Exception as e: # noqa: BLE001 —— 记录失败不放大,投递已成功 print(f"[push] record to {ch} chat task failed: {type(e).__name__}: {e}") def send_to_user( user_id: UUID, text: str = "", file_path: Optional[str] = None, channel: Optional[str] = None, *, source_task_id: Optional[UUID] = None, ) -> DeliveryReport: """渠道抽象:按 `active_channels()` 列出的已开渠道投递 + 把推送记进渠道 chat task。 - `channel=None`(默认):广播到所有已开渠道(定时任务/不点名推送沿用此口径)。 - `channel="wecom"|"clawbot"`:用户点名某个微信时只投这一条;若该渠道未开/无效, 返回单条 `no_binding` 结果(不静默回退到别的渠道,避免又推到没点名的渠道)。 - 投递成功后,对每个成功渠道把推送(摘要 + 文件链接 + read 路径)作为 assistant 消息写进该渠道 chat task(不存在自动建)。`source_task_id` = 调用方所在 task: 若恰为目标 chat task 自己(如用户在微信里让 agent 推),tool 记录已在,跳过去重。 """ report = DeliveryReport() if channel is not None: if channel in active_channels(): report.results.append(_DISPATCH[channel](user_id, text, file_path)) else: report.results.append(PushResult(False, channel=channel, reason="no_binding")) else: for ch in active_channels(): report.results.append(_DISPATCH[ch](user_id, text, file_path)) _record_push_to_chat(report, user_id, text, file_path, source_task_id) return report