| |
| """ |
| 海辛办公室 - 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 = "" |
| AGENT_NAME = "" |
| OFFICE_URL = "https://office.hyacinth.im" |
|
|
| |
| PUSH_INTERVAL_SECONDS = 15 |
| STATUS_ENDPOINT = "/status" |
| JOIN_ENDPOINT = "/join-agent" |
| PUSH_ENDPOINT = "/agent-push" |
|
|
| |
| STALE_STATE_TTL_SECONDS = int(os.environ.get("OFFICE_STALE_STATE_TTL", "600")) |
|
|
| |
| STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "office-agent-state.json") |
|
|
| |
| |
| 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"), |
| ] |
|
|
| |
| 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") |
| |
| 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。 |
| """ |
| |
| 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) |
|
|
| |
| 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 "" |
| |
| 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"本地状态超过{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 |
|
|
| |
| 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} |
| |
| if r.status_code == 401: |
| return {"state": "idle", "detail": "本地/status需要鉴权(401),请设置 OFFICE_LOCAL_STATUS_TOKEN"} |
| except Exception: |
| pass |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|
| |
| 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() |
|
|