#!/usr/bin/env python3 """ 海辛办公室 - Agent 状态主动推送脚本 用法: 1. 填入下面的 JOIN_KEY(你从海辛那里拿到的一次性 join key) 2. 填入 AGENT_NAME(你想要在办公室里显示的名字) 3. 运行:python office-agent-push.py 4. 脚本会自动先 join(首次运行),然后每 30s 向海辛办公室推送一次你的当前状态 """ import json import os import time import sys from datetime import datetime # === 你需要填入的信息 === JOIN_KEY = "" # 必填:你的一次性 join key AGENT_NAME = "" # 必填:你在办公室里的名字 OFFICE_URL = "https://office.hyacinth.im" # 海辛办公室地址(一般不用改) # === 推送配置 === PUSH_INTERVAL_SECONDS = 15 # 每隔多少秒推送一次(更实时) STATUS_ENDPOINT = "/status" JOIN_ENDPOINT = "/join-agent" PUSH_ENDPOINT = "/agent-push" # 自动状态守护:当本地状态文件不存在或长期不更新时,自动回 idle,避免“假工作中” STALE_STATE_TTL_SECONDS = int(os.environ.get("OFFICE_STALE_STATE_TTL", "600")) # 本地状态存储(记住上次 join 拿到的 agentId) STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "office-agent-state.json") # 优先读取本机 OpenClaw 工作区的状态文件(更贴合 AGENTS.md 的工作流) # 支持自动发现,减少对方手动配置成本。 DEFAULT_STATE_CANDIDATES = [ "/root/.openclaw/workspace/Star-Office-UI/state.json", # 当前仓库(大小写精确) "/root/.openclaw/workspace/star-office-ui/state.json", # 历史/兼容路径 "/root/.openclaw/workspace/state.json", os.path.join(os.getcwd(), "state.json"), os.path.join(os.path.dirname(os.path.abspath(__file__)), "state.json"), ] # 如果对方本地 /status 需要鉴权,可在这里填写 token(或通过环境变量 OFFICE_LOCAL_STATUS_TOKEN) LOCAL_STATUS_TOKEN = os.environ.get("OFFICE_LOCAL_STATUS_TOKEN", "") LOCAL_STATUS_URL = os.environ.get("OFFICE_LOCAL_STATUS_URL", "http://127.0.0.1:19000/status") # 可选:直接指定本地状态文件路径(最简单方案:绕过 /status 鉴权) LOCAL_STATE_FILE = os.environ.get("OFFICE_LOCAL_STATE_FILE", "") VERBOSE = os.environ.get("OFFICE_VERBOSE", "0") in {"1", "true", "TRUE", "yes", "YES"} def load_local_state(): if os.path.exists(STATE_FILE): try: with open(STATE_FILE, "r", encoding="utf-8") as f: return json.load(f) except Exception: pass return { "agentId": None, "joined": False, "joinKey": JOIN_KEY, "agentName": AGENT_NAME } def save_local_state(data): with open(STATE_FILE, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) def normalize_state(s): """兼容不同本地状态词,并映射到办公室识别状态。""" s = (s or "").strip().lower() if s in {"writing", "researching", "executing", "syncing", "error", "idle"}: return s if s in {"working", "busy", "write"}: return "writing" if s in {"run", "running", "execute", "exec"}: return "executing" if s in {"research", "search"}: return "researching" if s in {"sync"}: return "syncing" return "idle" def map_detail_to_state(detail, fallback_state="idle"): """当只有 detail 时,用关键词推断状态(贴近 AGENTS.md 的办公区逻辑)。""" d = (detail or "").lower() if any(k in d for k in ["报错", "error", "bug", "异常", "报警"]): return "error" if any(k in d for k in ["同步", "sync", "备份"]): return "syncing" if any(k in d for k in ["调研", "research", "搜索", "查资料"]): return "researching" if any(k in d for k in ["执行", "run", "推进", "处理任务", "工作中", "writing"]): return "writing" if any(k in d for k in ["待命", "休息", "idle", "完成", "done"]): return "idle" return fallback_state def _state_age_seconds(data): try: ts = (data or {}).get("updated_at") if not ts: return None dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00")) if dt.tzinfo is not None: from datetime import timezone return (datetime.now(timezone.utc) - dt.astimezone(timezone.utc)).total_seconds() return (datetime.now() - dt).total_seconds() except Exception: return None def fetch_local_status(): """读取本地状态: 1) 优先 state.json(符合 AGENTS.md:任务前切 writing,完成后切 idle) 2) 其次尝试本地 HTTP /status 3) 最后 fallback idle 额外防抖:如果本地状态更新时间超过 STALE_STATE_TTL_SECONDS,自动视为 idle。 """ # 1) 读本地 state.json(优先读取显式指定路径,其次自动发现) candidate_files = [] if LOCAL_STATE_FILE: candidate_files.append(LOCAL_STATE_FILE) for fp in DEFAULT_STATE_CANDIDATES: if fp not in candidate_files: candidate_files.append(fp) for fp in candidate_files: try: if fp and os.path.exists(fp): with open(fp, "r", encoding="utf-8") as f: data = json.load(f) # 只接受“状态文件”结构;避免误把 office-agent-state.json(仅缓存 agentId)当状态源 if not isinstance(data, dict): continue has_state = "state" in data has_detail = "detail" in data if (not has_state) and (not has_detail): continue state = normalize_state(data.get("state", "idle")) detail = data.get("detail", "") or "" # detail 兜底纠偏,确保“工作/休息/报警”能正确落区 state = map_detail_to_state(detail, fallback_state=state) # 防止状态文件久未更新仍停留在 working 态 age = _state_age_seconds(data) if age is not None and age > STALE_STATE_TTL_SECONDS: state = "idle" detail = f"本地状态超过{STALE_STATE_TTL_SECONDS}s未更新,自动回待命" if VERBOSE: print(f"[status-source:file] path={fp} state={state} detail={detail[:60]}") return {"state": state, "detail": detail} except Exception: pass # 2) 尝试本地 /status(可能需要鉴权) try: import requests headers = {} if LOCAL_STATUS_TOKEN: headers["Authorization"] = f"Bearer {LOCAL_STATUS_TOKEN}" r = requests.get(LOCAL_STATUS_URL, headers=headers, timeout=5) if r.status_code == 200: data = r.json() state = normalize_state(data.get("state", "idle")) detail = data.get("detail", "") or "" state = map_detail_to_state(detail, fallback_state=state) age = _state_age_seconds(data) if age is not None and age > STALE_STATE_TTL_SECONDS: state = "idle" detail = f"本地/status 超过{STALE_STATE_TTL_SECONDS}s未更新,自动回待命" if VERBOSE: print(f"[status-source:http] url={LOCAL_STATUS_URL} state={state} detail={detail[:60]}") return {"state": state, "detail": detail} # 如果 401,说明需要 token if r.status_code == 401: return {"state": "idle", "detail": "本地/status需要鉴权(401),请设置 OFFICE_LOCAL_STATUS_TOKEN"} except Exception: pass # 3) 默认 fallback if VERBOSE: print("[status-source:fallback] state=idle detail=待命中") return {"state": "idle", "detail": "待命中"} def do_join(local): import requests payload = { "name": local.get("agentName", AGENT_NAME), "joinKey": local.get("joinKey", JOIN_KEY), "state": "idle", "detail": "刚刚加入" } r = requests.post(f"{OFFICE_URL}{JOIN_ENDPOINT}", json=payload, timeout=10) if r.status_code in (200, 201): data = r.json() if data.get("ok"): local["joined"] = True local["agentId"] = data.get("agentId") save_local_state(local) print(f"✅ 已加入海辛办公室,agentId={local['agentId']}") return True print(f"❌ 加入失败:{r.text}") return False def do_push(local, status_data): import requests payload = { "agentId": local.get("agentId"), "joinKey": local.get("joinKey", JOIN_KEY), "state": status_data.get("state", "idle"), "detail": status_data.get("detail", ""), "name": local.get("agentName", AGENT_NAME) } r = requests.post(f"{OFFICE_URL}{PUSH_ENDPOINT}", json=payload, timeout=10) if r.status_code in (200, 201): data = r.json() if data.get("ok"): area = data.get("area", "breakroom") print(f"✅ 状态已同步,当前区域={area}") return True # 403/404:拒绝/移除 → 停止推送 if r.status_code in (403, 404): msg = "" try: msg = (r.json() or {}).get("msg", "") except Exception: msg = r.text print(f"⚠️ 访问拒绝或已移出房间({r.status_code}),停止推送:{msg}") local["joined"] = False local["agentId"] = None save_local_state(local) sys.exit(1) print(f"⚠️ 推送失败:{r.text}") return False def main(): local = load_local_state() # 先确认配置是否齐全 if not JOIN_KEY or not AGENT_NAME: print("❌ 请先在脚本开头填入 JOIN_KEY 和 AGENT_NAME") sys.exit(1) # 如果之前没 join,先 join if not local.get("joined") or not local.get("agentId"): ok = do_join(local) if not ok: sys.exit(1) # 持续推送 print(f"🚀 开始持续推送状态,间隔={PUSH_INTERVAL_SECONDS}秒") print("🧭 状态逻辑:任务中→工作区;待命/完成→休息区;异常→bug区") print("🔐 若本地 /status 返回 Unauthorized(401),请设置环境变量:OFFICE_LOCAL_STATUS_TOKEN 或 OFFICE_LOCAL_STATUS_URL") try: while True: try: status_data = fetch_local_status() do_push(local, status_data) except Exception as e: print(f"⚠️ 推送异常:{e}") time.sleep(PUSH_INTERVAL_SECONDS) except KeyboardInterrupt: print("\n👋 停止推送") sys.exit(0) if __name__ == "__main__": main()