feat: key-level expiration + activity key ocj_starteam02 (100 concurrent, expires EOD)
This commit is contained in:
1 parent
5aef4302b9
commit
02a731e8af
4 files changed
+366
-37
No files matched your search
+20
-1
@@ -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()
|
||||
|
||||
@@ -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 个龙虾同时在线
|
||||
@@ -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()
|
||||
@@ -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 # 每隔多少秒推送一次(更实时)
|
||||
|
||||
Reference in new issue
Block a user