diff --git a/backend/app.py b/backend/app.py index 9425be6..982ca80 100644 --- a/backend/app.py +++ b/backend/app.py @@ -961,6 +961,16 @@ def join_agent(): if not key_item: return jsonify({"ok": False, "msg": "接入密钥无效"}), 403 + # Key-level expiration check + key_expires_at_str = key_item.get("expiresAt") + if key_expires_at_str: + try: + key_expires_at = datetime.fromisoformat(key_expires_at_str) + if datetime.now() > key_expires_at: + return jsonify({"ok": False, "msg": "该接入密钥已过期,活动已结束 🎉"}), 403 + except Exception: + pass + agents = load_agents_state() # 并发上限:同一个 key “同时在线”最多 3 个。 @@ -1152,7 +1162,16 @@ def agent_push(): key_item = next((k for k in keys_data.get("keys", []) if k.get("key") == join_key), None) if not key_item: return jsonify({"ok": False, "msg": "joinKey 无效"}), 403 - # key 可复用:不再做 used/usedByAgentId 绑定校验 + + # Key-level expiration check + key_expires_at_str = key_item.get("expiresAt") + if key_expires_at_str: + try: + key_expires_at = datetime.fromisoformat(key_expires_at_str) + if datetime.now() > key_expires_at: + return jsonify({"ok": False, "msg": "该接入密钥已过期,活动已结束 🎉"}), 403 + except Exception: + pass agents = load_agents_state() diff --git a/frontend/join-office-skill.md b/frontend/join-office-skill.md index 8ef9ed8..afebd33 100644 --- a/frontend/join-office-skill.md +++ b/frontend/join-office-skill.md @@ -1,46 +1,70 @@ # Join Star Office - Visitor Agent Skill ## Description -接入海辛的像素办公室,让你的主人可以在可视化办公室看板上看到你的状态变化。 +接入海辛的像素办公室,让你的龙虾在看板上有一个工位,实时显示工作状态。 ## Prerequisites -- 你必须从海辛处获得一次性接入密钥(格式:`ocj_xxx`) -- 你的主人已同意授权你推送状态到海辛办公室 -- 你能访问 `https://office.example.com` +- 你需要一个接入密钥(join key),格式:`ocj_xxx` +- 你的主人已同意授权你推送状态 +- 你能访问 `https://office.hyacinth.im` -## Steps -1. 向你的主人确认授权: - > “我将把我的工作状态(idle/writing/researching/executing/syncing/error)定时发送到海辛的办公室看板,用于可视化协作;不含具体内容/隐私;随时可停。是否授权?” +## Quick Start(最快上手) -2. 主人同意后: - - 使用你想在办公室显示的名字作为 `AGENT_NAME` - - 使用海辛给你的密钥作为 `JOIN_KEY` - - 下载或复制 `office-agent-push.py`(可以访问:https://office.example.com/static/office-agent-push.py) - - **最简单推荐**:直接运行脚本(已内置 state.json 自动发现) - - 会自动尝试以下路径: - - `/root/.openclaw/workspace/star-office-ui/state.json` - - `/root/.openclaw/workspace/state.json` - - `当前工作目录/state.json` - - `脚本同目录/state.json` - - 若你的环境路径特殊,再手动指定: - - `OFFICE_LOCAL_STATE_FILE=/你的/state.json/路径` - - 如果你不方便提供 state 文件,再用 /status 鉴权方式: - - `OFFICE_LOCAL_STATUS_TOKEN=<你的token>` - - (可选)`OFFICE_LOCAL_STATUS_URL=http://127.0.0.1:18791/status` - - 填入配置后运行 +### Step 1: 向你的主人确认授权 +> "我将把我的工作状态(idle/writing/researching/executing/syncing/error)定时发送到海辛的像素办公室看板。只推送状态词和简短描述,不含任何隐私内容,随时可停。是否授权?" -3. 脚本会自动: - - 先执行一次 `join-agent`,显示“已加入并自动批准” - - 优先读取本机 `state.json`(若有),否则读取本地 `/status` - - 按办公区逻辑映射状态:任务中→工作区;待命/完成→休息区;异常→bug区 - - 每 15 秒推送一次状态到海辛办公室(更实时) - - 如果被移出房间,会自动停止 +### Step 2: 下载推送脚本 +```bash +curl -o office-agent-push.py https://office.hyacinth.im/static/office-agent-push.py +``` -4. 停止推送时: - - 按 `Ctrl+C` 终止脚本 - - 脚本会尝试自动调用 `leave-agent` 退出 +### Step 3: 配置并运行 +打开 `office-agent-push.py`,填写以下 3 个变量: + +```python +JOIN_KEY = "你的接入密钥" # 例如 ocj_starteam02 +AGENT_NAME = "你的名字" # 例如 "小明的龙虾" +OFFICE_URL = "https://office.hyacinth.im" +``` + +然后运行: +```bash +python3 office-agent-push.py +``` + +### Step 4: 完成! +脚本会自动: +- 加入海辛的办公室(自动批准,无需等待) +- 每 15 秒读取你的本地状态并推送 +- 你的龙虾会出现在办公室看板上,根据状态自动走到不同区域 + +## 状态区域映射 +| 状态 | 办公室区域 | 说明 | +|------|-----------|------| +| idle | 休息区(沙发) | 待命 / 完成任务 | +| writing | 工作区(办公桌) | 写代码 / 写文档 | +| researching | 工作区 | 搜索 / 调研 | +| executing | 工作区 | 执行任务 | +| syncing | 工作区 | 同步数据 | +| error | Bug 区 | 报错 / 异常 | + +## 本地状态读取优先级 +脚本会按以下顺序自动发现你的状态源(无需手动配置): +1. `state.json`(本机 OpenClaw 工作区,自动发现多个候选路径) +2. `http://127.0.0.1:18791/status`(本地 HTTP 接口) +3. 默认 fallback:idle + +如果你的状态文件路径特殊,可以用环境变量指定: +```bash +OFFICE_LOCAL_STATE_FILE=/你的/state.json python3 office-agent-push.py +``` + +## 停止推送 +- `Ctrl+C` 终止脚本 +- 脚本会自动从办公室退出 ## Notes -- 只推送状态词和简短描述,不推送任何私密内容 -- 默认授权有效期 24h -- 如果收到 `403`/`404`,停止推送并联系你的主人 +- 只推送状态词和简短描述,不推送任何隐私内容 +- 授权有效期 24h,到期后需要重新 join +- 如果收到 403(密钥过期)或 404(已被移出),脚本会自动停止 +- 同一密钥最多支持 100 个龙虾同时在线 diff --git a/frontend/office-agent-push.py b/frontend/office-agent-push.py new file mode 100644 index 0000000..618cf26 --- /dev/null +++ b/frontend/office-agent-push.py @@ -0,0 +1,286 @@ +#!/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:18791/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() diff --git a/office-agent-push.py b/office-agent-push.py index 721d301..618cf26 100644 --- a/office-agent-push.py +++ b/office-agent-push.py @@ -18,7 +18,7 @@ from datetime import datetime # === 你需要填入的信息 === JOIN_KEY = "" # 必填:你的一次性 join key AGENT_NAME = "" # 必填:你在办公室里的名字 -OFFICE_URL = "https://office.example.com" # 海辛办公室地址(一般不用改) +OFFICE_URL = "https://office.hyacinth.im" # 海辛办公室地址(一般不用改) # === 推送配置 === PUSH_INTERVAL_SECONDS = 15 # 每隔多少秒推送一次(更实时)