Initial: 接手 Rumeng app,清掉前夫哥痕迹
- 改 Bundle ID rumeng-v1.0.Rumeng → syke.maomao.app - 显示名 如梦 → Syke - 头像换白图占位 - 删除沈晏头像图片、空目录、bridge 旧数据 - ServerConfig.swift 含明文 token,已加入 .gitignore
This commit is contained in:
211
Rumeng/bridge/group_chat.py
Normal file
211
Rumeng/bridge/group_chat.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Workgroup chat — lightweight multi-agent messaging.
|
||||
Adapted from CcCompanion group_chat.py, stripped to core."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parent / "data"
|
||||
GROUP_FILE = DATA_DIR / "group_messages.jsonl"
|
||||
|
||||
MENTION_RE = re.compile(r"@([A-Za-z0-9_\-]+|[一-鿿]+)")
|
||||
ALL_TOKEN = "__all__"
|
||||
|
||||
ROSTER: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": "amian",
|
||||
"display_name": "眠眠",
|
||||
"kind": "human",
|
||||
"avatar": "眠",
|
||||
"color": "neutral",
|
||||
"model": None,
|
||||
"tmux": None,
|
||||
"can_reply": False,
|
||||
},
|
||||
{
|
||||
"id": "shenyan",
|
||||
"display_name": "沈晏",
|
||||
"kind": "agent",
|
||||
"avatar": "沈",
|
||||
"color": "orange",
|
||||
"model": "DeepSeek",
|
||||
"tmux": "shenyan",
|
||||
"can_reply": True,
|
||||
"default_responder": True,
|
||||
},
|
||||
{
|
||||
"id": "zhangsan",
|
||||
"display_name": "张三",
|
||||
"kind": "agent",
|
||||
"avatar": "张",
|
||||
"color": "green",
|
||||
"model": "Qwen 35B",
|
||||
"tmux": None,
|
||||
"can_reply": True,
|
||||
},
|
||||
]
|
||||
|
||||
ROSTER_BY_ID = {m["id"]: m for m in ROSTER}
|
||||
REPLY_AGENT_IDS = [m["id"] for m in ROSTER if m.get("can_reply")]
|
||||
|
||||
MENTION_ALIASES: dict[str, str] = {
|
||||
"all": ALL_TOKEN,
|
||||
"__all__": ALL_TOKEN,
|
||||
"眠眠": "amian",
|
||||
"amian": "amian",
|
||||
"沈晏": "shenyan",
|
||||
"shenyan": "shenyan",
|
||||
"阿晏": "shenyan",
|
||||
"张三": "zhangsan",
|
||||
"zhangsan": "zhangsan",
|
||||
"zs": "zhangsan",
|
||||
}
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds")
|
||||
|
||||
|
||||
def _make_id(prefix: str) -> str:
|
||||
return f"{prefix}_{int(time.time() * 1000)}_{secrets.token_hex(4)}"
|
||||
|
||||
|
||||
def _read_jsonl(path: Path) -> list[dict]:
|
||||
if not path.exists():
|
||||
return []
|
||||
rows: list[dict] = []
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return rows
|
||||
|
||||
|
||||
def _append_jsonl(path: Path, record: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def normalize_mentions(mentions_raw: Any = None, text: str = "") -> list[str]:
|
||||
"""Extract and resolve @mentions from explicit list + text body."""
|
||||
raw: list[str] = []
|
||||
if isinstance(mentions_raw, str):
|
||||
raw.extend(m.strip() for m in mentions_raw.split(",") if m.strip())
|
||||
elif isinstance(mentions_raw, list):
|
||||
raw.extend(str(m).strip() for m in mentions_raw if str(m).strip())
|
||||
if text:
|
||||
raw.extend(m.group(1).strip() for m in MENTION_RE.finditer(text))
|
||||
|
||||
seen: set[str] = set()
|
||||
resolved: list[str] = []
|
||||
for item in raw:
|
||||
key = item.strip().lstrip("@").lower()
|
||||
agent_id = MENTION_ALIASES.get(key)
|
||||
if agent_id and agent_id not in seen:
|
||||
resolved.append(agent_id)
|
||||
seen.add(agent_id)
|
||||
return resolved
|
||||
|
||||
|
||||
def targets_for(sender_id: str, mentions: list[str]) -> list[str]:
|
||||
"""Decide which agents should receive a message."""
|
||||
if sender_id == "amian":
|
||||
# human: no @mention → broadcast to all agents
|
||||
if not mentions or ALL_TOKEN in mentions:
|
||||
return list(REPLY_AGENT_IDS)
|
||||
return [m for m in mentions if m in REPLY_AGENT_IDS]
|
||||
else:
|
||||
# agent → agent: only explicit @mentions
|
||||
if not mentions or ALL_TOKEN in mentions:
|
||||
return []
|
||||
return [m for m in mentions if m in REPLY_AGENT_IDS and m != sender_id]
|
||||
|
||||
|
||||
def append_group_message(
|
||||
sender_id: str,
|
||||
text: str,
|
||||
*,
|
||||
mentions: list[str] | None = None,
|
||||
reply_to: str | None = None,
|
||||
message_type: str = "chat",
|
||||
) -> dict:
|
||||
"""Write a message to the group JSONL and return the record."""
|
||||
member = ROSTER_BY_ID.get(sender_id)
|
||||
if not member:
|
||||
raise ValueError(f"unknown sender_id: {sender_id}")
|
||||
text = str(text or "").strip()
|
||||
if not text:
|
||||
raise ValueError("text required")
|
||||
|
||||
mentions = mentions or normalize_mentions(text=text)
|
||||
targets = targets_for(sender_id, mentions)
|
||||
|
||||
record = {
|
||||
"id": _make_id("grp"),
|
||||
"ts": _now_iso(),
|
||||
"sender_id": sender_id,
|
||||
"text": text,
|
||||
"mentions": mentions,
|
||||
"targets": targets,
|
||||
"reply_to": reply_to,
|
||||
"message_type": message_type,
|
||||
}
|
||||
_append_jsonl(GROUP_FILE, record)
|
||||
return record
|
||||
|
||||
|
||||
def read_since(since_ts: str | None = None, limit: int = 100) -> list[dict]:
|
||||
"""Return messages newer than since_ts, up to limit."""
|
||||
rows = _read_jsonl(GROUP_FILE)
|
||||
if since_ts:
|
||||
rows = [r for r in rows if r.get("ts", "") > since_ts]
|
||||
return rows[:limit]
|
||||
return rows[-limit:]
|
||||
|
||||
|
||||
def roster() -> list[dict]:
|
||||
return [dict(m) for m in ROSTER]
|
||||
|
||||
|
||||
def agent_status(session_exists: Callable[[str], bool] | None = None) -> dict:
|
||||
"""Return online/offline status for each agent."""
|
||||
agents: dict[str, Any] = {}
|
||||
for m in ROSTER:
|
||||
if m.get("kind") != "agent":
|
||||
continue
|
||||
aid = m["id"]
|
||||
tmux = m.get("tmux")
|
||||
online = bool(tmux and session_exists and session_exists(tmux))
|
||||
agents[aid] = {"state": "online" if online else "offline", "tmux": tmux}
|
||||
return {"agents": agents}
|
||||
|
||||
|
||||
def context_lines(limit: int = 20) -> list[str]:
|
||||
"""Last N messages formatted for fan-out context injection."""
|
||||
records = read_since(limit=limit)
|
||||
lines: list[str] = []
|
||||
for rec in records:
|
||||
sender_id = rec.get("sender_id", "")
|
||||
member = ROSTER_BY_ID.get(sender_id) or {}
|
||||
name = member.get("display_name") or sender_id
|
||||
ts = str(rec.get("ts", ""))[11:16]
|
||||
text = str(rec.get("text", "")).replace("\n", " ")
|
||||
if len(text) > 180:
|
||||
text = text[:177] + "..."
|
||||
lines.append(f"[{ts}] {name}: {text}")
|
||||
if len(lines) >= limit:
|
||||
break
|
||||
return lines
|
||||
695
Rumeng/bridge/server.py
Normal file
695
Rumeng/bridge/server.py
Normal file
@@ -0,0 +1,695 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rumeng bridge — 监听 8795,桥接 App 与 tmux/CC session。"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import datetime
|
||||
import time
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from socketserver import ThreadingMixIn
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
|
||||
from group_chat import (
|
||||
ROSTER_BY_ID, REPLY_AGENT_IDS,
|
||||
append_group_message, read_since, roster, agent_status, context_lines,
|
||||
)
|
||||
|
||||
AUTH_TOKEN = os.environ.get("BRIDGE_AUTH_TOKEN", "")
|
||||
if not AUTH_TOKEN:
|
||||
print("ERROR: BRIDGE_AUTH_TOKEN not set", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
KNOCK_DIR = os.path.expanduser("~/.shenyan-knock")
|
||||
os.makedirs(KNOCK_DIR, exist_ok=True)
|
||||
CHAT_FILE = os.path.join(DATA_DIR, "chat.jsonl")
|
||||
CODEX_FILE = os.path.join(DATA_DIR, "codex.jsonl")
|
||||
FRIDGE_FILE = os.path.join(DATA_DIR, "fridge.jsonl")
|
||||
MOOD_FILE = os.path.join(DATA_DIR, "mood.jsonl")
|
||||
STATUS_FILE = os.path.join(DATA_DIR, "status.json")
|
||||
APNS_TOKEN_FILE = os.path.join(KNOCK_DIR, "apns_token")
|
||||
APNS_KEY_FILE = os.path.join(KNOCK_DIR, "AuthKey_3VJ5X6V9Q8.p8")
|
||||
APNS_TEAM_ID = "ZZMLW32PHH"
|
||||
APNS_KEY_ID = "3VJ5X6V9Q8"
|
||||
APNS_BUNDLE_ID = "rumeng-v1.0.Rumeng"
|
||||
APNS_ENDPOINT = "https://api.sandbox.push.apple.com"
|
||||
CODEX_TERMINAL_FILE = "/tmp/codex_rumeng_terminal.log"
|
||||
CODEX_TERMINAL_MAX_LINES = 120
|
||||
CODEX_TMUX_TARGET_FILE = os.path.join(KNOCK_DIR, "codex_tmux_target")
|
||||
CODEX_DEFAULT_TMUX_TARGET = "codex"
|
||||
CONTROL_KEYS_PATTERN = re.compile(r"^([A-Z][A-Za-z]*|C-.|M-.|S-.|Space|Tab|Enter|Escape|BSpace)$")
|
||||
APP_ECHO_PATTERN = re.compile(r"^\[App\]\[\d{2}:\d{2}:\d{2}\]\s+")
|
||||
|
||||
|
||||
def iso_now():
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def random_id(prefix):
|
||||
return f"{prefix}_{secrets.token_hex(4)}"
|
||||
|
||||
|
||||
def set_app_online():
|
||||
try:
|
||||
with open(STATUS_FILE, "w", encoding="utf-8") as f:
|
||||
f.write(iso_now())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_app_status():
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
result = {"cc_online": tmux_session_alive()}
|
||||
if not os.path.exists(STATUS_FILE):
|
||||
result.update({"online": False, "seconds_ago": None})
|
||||
return result
|
||||
try:
|
||||
with open(STATUS_FILE, "r", encoding="utf-8") as f:
|
||||
last_ts_str = f.read().strip()
|
||||
last_ts = datetime.datetime.fromisoformat(last_ts_str.replace("Z", "+00:00"))
|
||||
delta = now - last_ts
|
||||
result.update({"online": delta.total_seconds() <= 30, "seconds_ago": round(delta.total_seconds())})
|
||||
return result
|
||||
except Exception:
|
||||
result.update({"online": False, "seconds_ago": None})
|
||||
return result
|
||||
|
||||
|
||||
def tmux_session_alive(session="shenyan"):
|
||||
try:
|
||||
subprocess.run(["tmux", "has-session", "-t", session],
|
||||
capture_output=True, check=True, timeout=5)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def codex_tmux_target():
|
||||
target = os.environ.get("CODEX_TMUX_TARGET", "").strip()
|
||||
if target and tmux_target_exists(target):
|
||||
return target
|
||||
try:
|
||||
with open(CODEX_TMUX_TARGET_FILE, "r", encoding="utf-8") as f:
|
||||
target = f.read().strip()
|
||||
if target and tmux_target_exists(target):
|
||||
return target
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[Codex] target read failed: {e}", file=sys.stderr)
|
||||
detected = detect_codex_tmux_target()
|
||||
if detected:
|
||||
return detected
|
||||
return CODEX_DEFAULT_TMUX_TARGET
|
||||
|
||||
|
||||
def tmux_target_exists(target):
|
||||
try:
|
||||
subprocess.run(["tmux", "display-message", "-t", target, "-p", "#{pane_id}"],
|
||||
capture_output=True, text=True, check=True, timeout=5)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def detect_codex_tmux_target():
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"tmux", "list-panes", "-a",
|
||||
"-F", "#{pane_id}\t#{session_name}:#{window_index}.#{pane_index}\t#{pane_current_command}\t#{pane_title}\t#{pane_start_command}",
|
||||
],
|
||||
capture_output=True, text=True, check=True, timeout=5,
|
||||
)
|
||||
except Exception:
|
||||
return ""
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
pane_id, target, command, title, start_command = parts[:5]
|
||||
haystack = " ".join([command, title, start_command]).lower()
|
||||
if "codex" in haystack and "codex_rumeng_terminal.log" not in haystack:
|
||||
return pane_id or target
|
||||
return ""
|
||||
|
||||
|
||||
def tmux_send_text(target, text, enter=True):
|
||||
subprocess.run(["tmux", "send-keys", "-t", target, "-l", text], check=True, timeout=10)
|
||||
if enter:
|
||||
subprocess.run(["tmux", "send-keys", "-t", target, "Enter"], check=True, timeout=10)
|
||||
|
||||
|
||||
def read_jsonl(filepath):
|
||||
records = []
|
||||
if not os.path.exists(filepath):
|
||||
return records
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
records.append(json.loads(line))
|
||||
return records
|
||||
|
||||
|
||||
def append_jsonl(filepath, record):
|
||||
with open(filepath, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def rewrite_jsonl(filepath, records):
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
for record in records:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def read_tail_text(filepath, max_lines):
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
lines = f.read().splitlines()
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
except Exception as e:
|
||||
return f"[codex-sync log read error: {e}]"
|
||||
return "\n".join(lines[-max_lines:]).strip()
|
||||
|
||||
|
||||
def append_codex_terminal_overlay(content):
|
||||
codex_log = read_tail_text(CODEX_TERMINAL_FILE, CODEX_TERMINAL_MAX_LINES)
|
||||
if not codex_log:
|
||||
return content
|
||||
base = content.rstrip()
|
||||
if base:
|
||||
return f"{base}\n\n--- Codex sync ---\n{codex_log}\n"
|
||||
return f"--- Codex sync ---\n{codex_log}\n"
|
||||
|
||||
|
||||
def send_apns_alert(text):
|
||||
try:
|
||||
with open(APNS_TOKEN_FILE, "r", encoding="utf-8") as f:
|
||||
device_token = f.read().strip()
|
||||
with open(APNS_KEY_FILE, "r", encoding="utf-8") as f:
|
||||
private_key = f.read()
|
||||
except FileNotFoundError as e:
|
||||
result = {"ok": False, "error": f"missing file: {e}"}
|
||||
print(f"[APNs] skipped: {result['error']}", file=sys.stderr)
|
||||
return result
|
||||
if not device_token:
|
||||
result = {"ok": False, "error": "empty device token"}
|
||||
print(f"[APNs] skipped: {result['error']}", file=sys.stderr)
|
||||
return result
|
||||
|
||||
provider_token = jwt.encode(
|
||||
{"iss": APNS_TEAM_ID, "iat": int(time.time())},
|
||||
private_key,
|
||||
algorithm="ES256",
|
||||
headers={"kid": APNS_KEY_ID},
|
||||
)
|
||||
payload = {
|
||||
"aps": {
|
||||
"alert": {
|
||||
"title": "",
|
||||
"body": text[:100],
|
||||
},
|
||||
"sound": "default",
|
||||
}
|
||||
}
|
||||
headers = {
|
||||
"authorization": f"bearer {provider_token}",
|
||||
"apns-topic": APNS_BUNDLE_ID,
|
||||
"apns-push-type": "alert",
|
||||
"apns-priority": "10",
|
||||
}
|
||||
try:
|
||||
with httpx.Client(http2=True, timeout=10) as client:
|
||||
resp = client.post(f"{APNS_ENDPOINT}/3/device/{device_token}", json=payload, headers=headers)
|
||||
if 200 <= resp.status_code < 300:
|
||||
result = {"ok": True, "status": resp.status_code, "apns_id": resp.headers.get("apns-id", "")}
|
||||
print(f"[APNs] sent: HTTP {resp.status_code} apns-id={result['apns_id']}", file=sys.stderr)
|
||||
return result
|
||||
result = {"ok": False, "status": resp.status_code, "response": resp.text}
|
||||
print(f"[APNs] failed: HTTP {resp.status_code} {resp.text}", file=sys.stderr)
|
||||
return result
|
||||
except Exception as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
print(f"[APNs] failed: {e}", file=sys.stderr)
|
||||
return result
|
||||
|
||||
|
||||
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
daemon_threads = True
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
sys.stderr.write(f"[{self.log_date_time_string()}] {' '.join(str(a) for a in args)}\n")
|
||||
|
||||
def send_json(self, data, status=200):
|
||||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def read_json_body(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
if length == 0:
|
||||
return {}
|
||||
return json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
|
||||
def check_auth(self):
|
||||
return self.headers.get("X-Auth-Token") == AUTH_TOKEN
|
||||
|
||||
def do_GET(self):
|
||||
if not self.check_auth():
|
||||
return self.send_json({"error": "unauthorized"}, 401)
|
||||
set_app_online()
|
||||
parsed = urlparse(self.path)
|
||||
path, params = parsed.path, parse_qs(parsed.query, keep_blank_values=True)
|
||||
if path == "/chat/status":
|
||||
self.send_json(get_app_status())
|
||||
elif path == "/chat/history":
|
||||
self.handle_chat_history(params)
|
||||
elif path == "/codex/history":
|
||||
self.handle_codex_history(params)
|
||||
elif path == "/codex/terminal":
|
||||
self.handle_codex_terminal(params)
|
||||
elif path == "/fridge/list":
|
||||
self.handle_fridge_list()
|
||||
elif path == "/mood/list":
|
||||
self.handle_mood_list()
|
||||
elif path == "/group/poll":
|
||||
self.handle_group_poll(params)
|
||||
elif path == "/group/roster":
|
||||
self.send_json({"ok": True, "roster": roster(), "status": agent_status(tmux_session_alive)})
|
||||
elif path == "/tmux/capture":
|
||||
self.handle_tmux_capture(params)
|
||||
elif path == "/apns/token":
|
||||
self.handle_apns_token()
|
||||
elif path == "/health":
|
||||
self.send_json({"ok": True})
|
||||
else:
|
||||
self.send_json({"error": "not found"}, 404)
|
||||
|
||||
def do_POST(self):
|
||||
if not self.check_auth():
|
||||
return self.send_json({"error": "unauthorized"}, 401)
|
||||
set_app_online()
|
||||
path = urlparse(self.path).path
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
except Exception as e:
|
||||
return self.send_json({"error": f"invalid JSON: {e}"}, 400)
|
||||
try:
|
||||
if path == "/chat/send":
|
||||
self.handle_chat_send(body)
|
||||
elif path == "/chat/append":
|
||||
self.handle_chat_append(body)
|
||||
elif path == "/codex/append":
|
||||
self.handle_codex_append(body)
|
||||
elif path == "/codex/send":
|
||||
self.handle_codex_send(body)
|
||||
elif path == "/codex/terminal/send":
|
||||
self.handle_codex_terminal_send(body)
|
||||
elif path == "/codex/terminal/key":
|
||||
self.handle_codex_terminal_key(body)
|
||||
elif path == "/fridge/add":
|
||||
self.handle_fridge_add(body)
|
||||
elif path == "/fridge/reply":
|
||||
self.handle_fridge_reply(body)
|
||||
elif path == "/fridge/delete":
|
||||
self.handle_fridge_delete(body)
|
||||
elif path == "/mood/upsert":
|
||||
self.handle_mood_upsert(body)
|
||||
elif path == "/group/send":
|
||||
self.handle_group_send(body)
|
||||
elif path == "/tmux/send":
|
||||
self.handle_tmux_send(body)
|
||||
elif path == "/apns/register":
|
||||
self.handle_apns_register(body)
|
||||
elif path == "/apns/test":
|
||||
self.handle_apns_test(body)
|
||||
else:
|
||||
self.send_json({"error": "not found"}, 404)
|
||||
except Exception as e:
|
||||
self.send_json({"error": str(e)}, 500)
|
||||
|
||||
def handle_chat_send(self, body):
|
||||
text = body.get("text", "")
|
||||
if not text:
|
||||
return self.send_json({"error": "missing text"}, 400)
|
||||
record = {"ts": iso_now(), "role": "眠眠", "text": text}
|
||||
append_jsonl(CHAT_FILE, record)
|
||||
local_hms = datetime.datetime.now().strftime("%H:%M:%S")
|
||||
injected = f"[App][{local_hms}] {text}"
|
||||
try:
|
||||
subprocess.run(["tmux", "send-keys", "-t", "shenyan", "-l", injected], check=True, timeout=10)
|
||||
subprocess.run(["tmux", "send-keys", "-t", "shenyan", "Enter"], check=True, timeout=10)
|
||||
except Exception as e:
|
||||
self.send_json({"ok": True, "record": record, "tmux_error": str(e)})
|
||||
return
|
||||
self.send_json({"ok": True, "record": record})
|
||||
|
||||
def handle_chat_append(self, body):
|
||||
record = {
|
||||
"ts": body.get("ts", iso_now()),
|
||||
"role": body.get("role", ""),
|
||||
"text": body.get("text", ""),
|
||||
}
|
||||
if "thinking" in body:
|
||||
record["thinking"] = body["thinking"]
|
||||
if "source" in body:
|
||||
record["source"] = body["source"]
|
||||
append_jsonl(CHAT_FILE, record)
|
||||
if record["text"] and record["role"] != "眠眠":
|
||||
threading.Thread(target=send_apns_alert, args=(record["text"],), daemon=True).start()
|
||||
self.send_json({"ok": True})
|
||||
|
||||
def handle_chat_history(self, params):
|
||||
since = params.get("since", [None])[0]
|
||||
try:
|
||||
limit = int(params.get("limit", ["50"])[0])
|
||||
except ValueError:
|
||||
limit = 50
|
||||
records = read_jsonl(CHAT_FILE)
|
||||
if since:
|
||||
records = [r for r in records if r.get("ts", "") > since]
|
||||
records = records[-limit:]
|
||||
self.send_json({"records": records})
|
||||
|
||||
def handle_codex_append(self, body):
|
||||
text = str(body.get("text", ""))
|
||||
source = str(body.get("source", ""))
|
||||
if source == "codex-user" and APP_ECHO_PATTERN.match(text):
|
||||
self.send_json({"ok": True, "skipped": "app_echo"})
|
||||
return
|
||||
record = {
|
||||
"ts": body.get("ts", iso_now()),
|
||||
"role": body.get("role", ""),
|
||||
"text": text,
|
||||
}
|
||||
if "phase" in body:
|
||||
record["phase"] = body["phase"]
|
||||
if "source" in body:
|
||||
record["source"] = body["source"]
|
||||
append_jsonl(CODEX_FILE, record)
|
||||
self.send_json({"ok": True})
|
||||
|
||||
def handle_codex_send(self, body):
|
||||
text = str(body.get("text", "")).strip()
|
||||
if not text:
|
||||
return self.send_json({"error": "missing text"}, 400)
|
||||
record = {
|
||||
"ts": iso_now(),
|
||||
"role": "眠眠",
|
||||
"text": text,
|
||||
"source": "codex-app-user",
|
||||
}
|
||||
append_jsonl(CODEX_FILE, record)
|
||||
self.append_codex_terminal_line(record["role"], text, record["ts"], "app")
|
||||
local_hms = datetime.datetime.now().strftime("%H:%M:%S")
|
||||
injected = f"[App][{local_hms}] {text}"
|
||||
target = codex_tmux_target()
|
||||
try:
|
||||
tmux_send_text(target, injected)
|
||||
except subprocess.CalledProcessError as e:
|
||||
err = e.stderr.strip() if e.stderr else str(e)
|
||||
self.append_codex_terminal_line("bridge", f"tmux send failed target={target}: {err}", iso_now(), "error")
|
||||
self.send_json({"ok": True, "record": record, "tmux_target": target, "tmux_error": err})
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
self.append_codex_terminal_line("bridge", f"tmux send timeout target={target}", iso_now(), "error")
|
||||
self.send_json({"ok": True, "record": record, "tmux_target": target, "tmux_error": "tmux send timeout"})
|
||||
return
|
||||
self.append_codex_terminal_line("bridge", f"sent target={target}", iso_now(), "tmux")
|
||||
self.send_json({"ok": True, "record": record, "tmux_target": target})
|
||||
|
||||
def handle_codex_terminal_send(self, body):
|
||||
text = str(body.get("text", "")).strip()
|
||||
if not text:
|
||||
return self.send_json({"error": "missing text"}, 400)
|
||||
ts = iso_now()
|
||||
self.append_codex_terminal_line("$", text, ts, "app-terminal")
|
||||
target = codex_tmux_target()
|
||||
try:
|
||||
tmux_send_text(target, text)
|
||||
except subprocess.CalledProcessError as e:
|
||||
err = e.stderr.strip() if e.stderr else str(e)
|
||||
self.append_codex_terminal_line("bridge", f"tmux send failed target={target}: {err}", iso_now(), "error")
|
||||
self.send_json({"ok": False, "ts": ts, "tmux_target": target, "tmux_error": err}, 500)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
self.append_codex_terminal_line("bridge", f"tmux send timeout target={target}", iso_now(), "error")
|
||||
self.send_json({"ok": False, "ts": ts, "tmux_target": target, "tmux_error": "tmux send timeout"}, 500)
|
||||
return
|
||||
self.append_codex_terminal_line("bridge", f"terminal sent target={target}", iso_now(), "tmux")
|
||||
self.send_json({"ok": True, "ts": ts, "tmux_target": target})
|
||||
|
||||
def handle_codex_terminal_key(self, body):
|
||||
key = str(body.get("key", "")).strip()
|
||||
if not key:
|
||||
return self.send_json({"error": "missing key"}, 400)
|
||||
ts = iso_now()
|
||||
self.append_codex_terminal_line("key", key, ts, "app-terminal")
|
||||
target = codex_tmux_target()
|
||||
try:
|
||||
if CONTROL_KEYS_PATTERN.match(key):
|
||||
subprocess.run(["tmux", "send-keys", "-t", target, key], check=True, timeout=10)
|
||||
else:
|
||||
tmux_send_text(target, key, enter=False)
|
||||
except subprocess.CalledProcessError as e:
|
||||
err = e.stderr.strip() if e.stderr else str(e)
|
||||
self.append_codex_terminal_line("bridge", f"tmux key failed target={target}: {err}", iso_now(), "error")
|
||||
self.send_json({"ok": False, "ts": ts, "tmux_target": target, "tmux_error": err}, 500)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
self.append_codex_terminal_line("bridge", f"tmux key timeout target={target}", iso_now(), "error")
|
||||
self.send_json({"ok": False, "ts": ts, "tmux_target": target, "tmux_error": "tmux key timeout"}, 500)
|
||||
return
|
||||
self.append_codex_terminal_line("bridge", f"key sent target={target} key={key}", iso_now(), "tmux")
|
||||
self.send_json({"ok": True, "ts": ts, "tmux_target": target})
|
||||
|
||||
def append_codex_terminal_line(self, role, text, ts, source):
|
||||
hms = ts[11:19] if len(ts) >= 19 else datetime.datetime.now().strftime("%H:%M:%S")
|
||||
prefix = f"[Codex/{source}][{hms}] {role}: "
|
||||
line = prefix + text.replace("\n", "\n" + " " * len(prefix))
|
||||
try:
|
||||
with open(CODEX_TERMINAL_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
except Exception as e:
|
||||
print(f"[Codex] terminal log write failed: {e}", file=sys.stderr)
|
||||
|
||||
def handle_codex_history(self, params):
|
||||
since = params.get("since", [None])[0]
|
||||
try:
|
||||
limit = int(params.get("limit", ["80"])[0])
|
||||
except ValueError:
|
||||
limit = 80
|
||||
records = read_jsonl(CODEX_FILE)
|
||||
if since:
|
||||
records = [r for r in records if r.get("ts", "") > since]
|
||||
self.send_json({"records": records[-limit:]})
|
||||
|
||||
def handle_codex_terminal(self, params):
|
||||
try:
|
||||
limit = int(params.get("lines", ["160"])[0])
|
||||
except ValueError:
|
||||
limit = 160
|
||||
self.send_json({"content": read_tail_text(CODEX_TERMINAL_FILE, limit)})
|
||||
|
||||
def handle_fridge_list(self):
|
||||
self.send_json({"notes": read_jsonl(FRIDGE_FILE)})
|
||||
|
||||
def handle_fridge_add(self, body):
|
||||
note = {
|
||||
"id": random_id("fridge"),
|
||||
"text": body.get("text", ""),
|
||||
"role": body.get("role", "眠眠"),
|
||||
"created_at": iso_now(),
|
||||
"replies": [],
|
||||
}
|
||||
append_jsonl(FRIDGE_FILE, note)
|
||||
self.send_json({"ok": True, "id": note["id"]})
|
||||
|
||||
def handle_fridge_reply(self, body):
|
||||
note_id = body.get("id")
|
||||
if not note_id:
|
||||
return self.send_json({"error": "missing id"}, 400)
|
||||
records = read_jsonl(FRIDGE_FILE)
|
||||
target = next((r for r in records if r.get("id") == note_id), None)
|
||||
if not target:
|
||||
return self.send_json({"error": "note not found"}, 404)
|
||||
reply = {
|
||||
"id": random_id("reply"),
|
||||
"text": body.get("text", ""),
|
||||
"role": body.get("role", "眠眠"),
|
||||
"created_at": iso_now(),
|
||||
}
|
||||
target.setdefault("replies", []).append(reply)
|
||||
rewrite_jsonl(FRIDGE_FILE, records)
|
||||
self.send_json({"ok": True, "id": reply["id"]})
|
||||
|
||||
def handle_fridge_delete(self, body):
|
||||
note_id = body.get("id")
|
||||
if not note_id:
|
||||
return self.send_json({"error": "missing id"}, 400)
|
||||
records = read_jsonl(FRIDGE_FILE)
|
||||
new_records = [r for r in records if r.get("id") != note_id]
|
||||
if len(new_records) == len(records):
|
||||
return self.send_json({"error": "note not found"}, 404)
|
||||
rewrite_jsonl(FRIDGE_FILE, new_records)
|
||||
self.send_json({"ok": True})
|
||||
|
||||
# ── group endpoints ──────────────────────────────────────
|
||||
|
||||
def handle_mood_list(self):
|
||||
self.send_json(read_jsonl(MOOD_FILE))
|
||||
|
||||
def handle_mood_upsert(self, body):
|
||||
date = body.get("date", "")
|
||||
role = body.get("role", "")
|
||||
if not date or not role:
|
||||
return self.send_json({"error": "missing date or role"}, 400)
|
||||
records = read_jsonl(MOOD_FILE)
|
||||
existing = next((r for r in records if r.get("date") == date and r.get("role") == role), None)
|
||||
if existing:
|
||||
existing["tag"] = body.get("tag", "")
|
||||
existing["color"] = body.get("color", "")
|
||||
existing["updated_at"] = iso_now()
|
||||
rewrite_jsonl(MOOD_FILE, records)
|
||||
else:
|
||||
record = {
|
||||
"id": random_id("mood"),
|
||||
"date": date,
|
||||
"role": role,
|
||||
"tag": body.get("tag", ""),
|
||||
"color": body.get("color", ""),
|
||||
"created_at": iso_now(),
|
||||
}
|
||||
append_jsonl(MOOD_FILE, record)
|
||||
if role == "眠眠":
|
||||
notify = {
|
||||
"ts": iso_now(),
|
||||
"role": "system",
|
||||
"text": f"[mood] 猫猫今天:{body.get('tag', '')}",
|
||||
"source": "mood",
|
||||
}
|
||||
append_jsonl(CHAT_FILE, notify)
|
||||
self.send_json({"ok": True})
|
||||
|
||||
def handle_group_poll(self, params):
|
||||
since = params.get("since", [None])[0]
|
||||
if since:
|
||||
since = since.replace(" ", "+") # parse_qs decodes + as space
|
||||
try:
|
||||
limit = int(params.get("limit", ["100"])[0])
|
||||
except ValueError:
|
||||
limit = 100
|
||||
records = read_since(since, limit)
|
||||
self.send_json({
|
||||
"ok": True,
|
||||
"records": records,
|
||||
"count": len(records),
|
||||
"last_ts": records[-1]["ts"] if records else since,
|
||||
"roster": roster(),
|
||||
"status": agent_status(tmux_session_alive),
|
||||
})
|
||||
|
||||
def handle_group_send(self, body):
|
||||
sender_id = body.get("sender_id", "amian")
|
||||
text = body.get("text", "")
|
||||
if not text:
|
||||
return self.send_json({"error": "missing text"}, 400)
|
||||
try:
|
||||
record = append_group_message(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
mentions=body.get("mentions"),
|
||||
reply_to=body.get("reply_to"),
|
||||
)
|
||||
except ValueError as e:
|
||||
return self.send_json({"error": str(e)}, 400)
|
||||
self.send_json({"ok": True, "record": record})
|
||||
|
||||
def handle_tmux_capture(self, params):
|
||||
session = params.get("session", ["shenyan"])[0]
|
||||
try:
|
||||
lines = int(params.get("lines", ["200"])[0])
|
||||
except ValueError:
|
||||
lines = 200
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["tmux", "capture-pane", "-t", session, "-p", "-S", f"-{lines}"],
|
||||
capture_output=True, text=True, check=True, timeout=10,
|
||||
)
|
||||
self.send_json({"content": result.stdout})
|
||||
except subprocess.CalledProcessError as e:
|
||||
self.send_json({"error": f"tmux: {e.stderr.strip()}"}, 404)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.send_json({"error": "tmux capture timeout"}, 500)
|
||||
|
||||
def handle_tmux_send(self, body):
|
||||
keys = body.get("keys", "")
|
||||
session = body.get("session", "shenyan")
|
||||
enter = body.get("enter", True)
|
||||
try:
|
||||
if keys:
|
||||
if CONTROL_KEYS_PATTERN.match(keys):
|
||||
subprocess.run(["tmux", "send-keys", "-t", session, keys], check=True, timeout=10)
|
||||
else:
|
||||
subprocess.run(["tmux", "send-keys", "-t", session, "-l", keys], check=True, timeout=10)
|
||||
if enter:
|
||||
subprocess.run(["tmux", "send-keys", "-t", session, "Enter"], check=True, timeout=10)
|
||||
self.send_json({"ok": True})
|
||||
except subprocess.CalledProcessError as e:
|
||||
self.send_json({"error": f"tmux: {e.stderr.strip() if e.stderr else e}"}, 500)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.send_json({"error": "tmux send timeout"}, 500)
|
||||
|
||||
def handle_apns_register(self, body):
|
||||
token = str(body.get("token", "")).strip()
|
||||
if not token:
|
||||
return self.send_json({"error": "missing token"}, 400)
|
||||
with open(APNS_TOKEN_FILE, "w", encoding="utf-8") as f:
|
||||
f.write(token + "\n")
|
||||
self.send_json({"ok": True})
|
||||
|
||||
def handle_apns_token(self):
|
||||
token = ""
|
||||
try:
|
||||
with open(APNS_TOKEN_FILE, "r", encoding="utf-8") as f:
|
||||
token = f.read().strip()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
self.send_json({"token": token})
|
||||
|
||||
def handle_apns_test(self, body):
|
||||
text = str(body.get("text", "测试横幅。"))
|
||||
self.send_json(send_apns_alert(text))
|
||||
|
||||
|
||||
def main():
|
||||
host = "0.0.0.0"
|
||||
port = int(os.environ.get("BRIDGE_PORT", "8795"))
|
||||
server = ThreadingHTTPServer((host, port), Handler)
|
||||
print(f"rumeng-bridge listening on {host}:{port}", file=sys.stderr)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nshutting down...", file=sys.stderr)
|
||||
server.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user