pocket-coding 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pocket_coding-0.1.0.dist-info/METADATA +168 -0
- pocket_coding-0.1.0.dist-info/RECORD +7 -0
- pocket_coding-0.1.0.dist-info/WHEEL +4 -0
- pocket_coding-0.1.0.dist-info/entry_points.txt +2 -0
- poco/__init__.py +4 -0
- poco/app.py +577 -0
- poco/bridge.py +1221 -0
poco/bridge.py
ADDED
|
@@ -0,0 +1,1221 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import queue
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
14
|
+
|
|
15
|
+
import lark_oapi as lark
|
|
16
|
+
from lark_oapi.api.im.v1 import (
|
|
17
|
+
CreateMessageRequest,
|
|
18
|
+
CreateMessageRequestBody,
|
|
19
|
+
P2ImMessageReceiveV1,
|
|
20
|
+
UpdateMessageRequest,
|
|
21
|
+
UpdateMessageRequestBody,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
LOG = logging.getLogger("poco.bridge")
|
|
26
|
+
def chunk_text(text: str, limit: int) -> List[str]:
|
|
27
|
+
chunks: List[str] = []
|
|
28
|
+
current: List[str] = []
|
|
29
|
+
current_len = 0
|
|
30
|
+
for line in text.splitlines():
|
|
31
|
+
line = line.rstrip()
|
|
32
|
+
if len(line) > limit:
|
|
33
|
+
if current:
|
|
34
|
+
chunks.append("\n".join(current))
|
|
35
|
+
current = []
|
|
36
|
+
current_len = 0
|
|
37
|
+
for start in range(0, len(line), limit):
|
|
38
|
+
chunks.append(line[start:start + limit])
|
|
39
|
+
continue
|
|
40
|
+
extra = len(line) + (1 if current else 0)
|
|
41
|
+
if current and current_len + extra > limit:
|
|
42
|
+
chunks.append("\n".join(current))
|
|
43
|
+
current = [line]
|
|
44
|
+
current_len = len(line)
|
|
45
|
+
continue
|
|
46
|
+
current.append(line)
|
|
47
|
+
current_len += extra
|
|
48
|
+
if current:
|
|
49
|
+
chunks.append("\n".join(current))
|
|
50
|
+
if not chunks:
|
|
51
|
+
chunks.append("")
|
|
52
|
+
return chunks
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def shorten(text: str, limit: int) -> str:
|
|
56
|
+
text = " ".join(text.split())
|
|
57
|
+
if len(text) <= limit:
|
|
58
|
+
return text
|
|
59
|
+
return text[: max(0, limit - 3)] + "..."
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class AppConfig:
|
|
64
|
+
feishu_app_id: str
|
|
65
|
+
feishu_app_secret: str
|
|
66
|
+
feishu_encrypt_key: str
|
|
67
|
+
feishu_verification_token: str
|
|
68
|
+
codex_bin: str
|
|
69
|
+
codex_app_server_args: str
|
|
70
|
+
codex_model: str
|
|
71
|
+
codex_approval_policy: str
|
|
72
|
+
codex_sandbox: str
|
|
73
|
+
message_limit: int
|
|
74
|
+
live_update_initial_seconds: int
|
|
75
|
+
live_update_max_seconds: int
|
|
76
|
+
max_message_edits: int
|
|
77
|
+
allowed_open_ids: Set[str]
|
|
78
|
+
allow_all_users: bool
|
|
79
|
+
thread_state_path: str
|
|
80
|
+
worker_state_path: str
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class SentMessage:
|
|
85
|
+
message_id: str
|
|
86
|
+
chat_id: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class LiveMessage:
|
|
91
|
+
sent: SentMessage
|
|
92
|
+
edit_count: int = 0
|
|
93
|
+
last_text: str = ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class TurnSession:
|
|
98
|
+
worker_id: str
|
|
99
|
+
chat_id: str
|
|
100
|
+
thread_id: str
|
|
101
|
+
turn_id: str
|
|
102
|
+
prompt: str
|
|
103
|
+
live: LiveMessage
|
|
104
|
+
accumulated_text: str = ""
|
|
105
|
+
final_text: str = ""
|
|
106
|
+
error_text: str = ""
|
|
107
|
+
done: bool = False
|
|
108
|
+
started_at: float = field(default_factory=time.time)
|
|
109
|
+
next_delay: int = 1
|
|
110
|
+
next_update_at: float = field(default_factory=time.time)
|
|
111
|
+
updated_event: threading.Event = field(default_factory=threading.Event)
|
|
112
|
+
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
113
|
+
|
|
114
|
+
def notify(self) -> None:
|
|
115
|
+
self.updated_event.set()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class WorkerRuntime:
|
|
120
|
+
worker_id: str
|
|
121
|
+
chat_id: str
|
|
122
|
+
chat_type: str
|
|
123
|
+
cwd: str
|
|
124
|
+
codex: "AppServerClient"
|
|
125
|
+
created_at: float = field(default_factory=time.time)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class FeishuMessenger:
|
|
129
|
+
def __init__(self, config: AppConfig) -> None:
|
|
130
|
+
self._config = config
|
|
131
|
+
self._client = (
|
|
132
|
+
lark.Client.builder()
|
|
133
|
+
.app_id(config.feishu_app_id)
|
|
134
|
+
.app_secret(config.feishu_app_secret)
|
|
135
|
+
.log_level(lark.LogLevel.INFO)
|
|
136
|
+
.build()
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def create_text(self, chat_id: str, text: str) -> SentMessage:
|
|
140
|
+
first_sent: Optional[SentMessage] = None
|
|
141
|
+
for chunk in chunk_text(text, self._config.message_limit):
|
|
142
|
+
request = (
|
|
143
|
+
CreateMessageRequest.builder()
|
|
144
|
+
.receive_id_type("chat_id")
|
|
145
|
+
.request_body(
|
|
146
|
+
CreateMessageRequestBody.builder()
|
|
147
|
+
.receive_id(chat_id)
|
|
148
|
+
.msg_type("text")
|
|
149
|
+
.content(json.dumps({"text": chunk}, ensure_ascii=False))
|
|
150
|
+
.uuid(str(uuid.uuid4()))
|
|
151
|
+
.build()
|
|
152
|
+
)
|
|
153
|
+
.build()
|
|
154
|
+
)
|
|
155
|
+
response = self._client.im.v1.message.create(request)
|
|
156
|
+
if response.code != 0:
|
|
157
|
+
raise RuntimeError(
|
|
158
|
+
f"Feishu send failed: code={response.code}, msg={response.msg}, "
|
|
159
|
+
f"log_id={response.get_log_id()}"
|
|
160
|
+
)
|
|
161
|
+
if first_sent is None:
|
|
162
|
+
message_id = response.data.message_id if response.data is not None else None
|
|
163
|
+
if not message_id:
|
|
164
|
+
raise RuntimeError("Feishu send succeeded but returned no message_id")
|
|
165
|
+
first_sent = SentMessage(message_id=message_id, chat_id=chat_id)
|
|
166
|
+
assert first_sent is not None
|
|
167
|
+
return first_sent
|
|
168
|
+
|
|
169
|
+
def send_text(self, chat_id: str, text: str) -> None:
|
|
170
|
+
self.create_text(chat_id, text)
|
|
171
|
+
|
|
172
|
+
def update_text(self, message_id: str, text: str) -> None:
|
|
173
|
+
request = (
|
|
174
|
+
UpdateMessageRequest.builder()
|
|
175
|
+
.message_id(message_id)
|
|
176
|
+
.request_body(
|
|
177
|
+
UpdateMessageRequestBody.builder()
|
|
178
|
+
.msg_type("text")
|
|
179
|
+
.content(json.dumps({"text": text}, ensure_ascii=False))
|
|
180
|
+
.build()
|
|
181
|
+
)
|
|
182
|
+
.build()
|
|
183
|
+
)
|
|
184
|
+
response = self._client.im.v1.message.update(request)
|
|
185
|
+
if response.code != 0:
|
|
186
|
+
raise RuntimeError(
|
|
187
|
+
f"Feishu update failed: code={response.code}, msg={response.msg}, "
|
|
188
|
+
f"log_id={response.get_log_id()}"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class ThreadStore:
|
|
193
|
+
def __init__(self, path: Path) -> None:
|
|
194
|
+
self._path = path
|
|
195
|
+
self._lock = threading.Lock()
|
|
196
|
+
self._data: Dict[str, str] = {}
|
|
197
|
+
self._load()
|
|
198
|
+
|
|
199
|
+
def _load(self) -> None:
|
|
200
|
+
if not self._path.exists():
|
|
201
|
+
return
|
|
202
|
+
try:
|
|
203
|
+
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
|
204
|
+
if isinstance(raw, dict):
|
|
205
|
+
self._data = {str(k): str(v) for k, v in raw.items()}
|
|
206
|
+
except Exception:
|
|
207
|
+
LOG.warning("Failed to load thread state from %s", self._path)
|
|
208
|
+
|
|
209
|
+
def _save(self) -> None:
|
|
210
|
+
self._path.write_text(
|
|
211
|
+
json.dumps(self._data, ensure_ascii=False, indent=2),
|
|
212
|
+
encoding="utf-8",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def get(self, chat_id: str) -> Optional[str]:
|
|
216
|
+
with self._lock:
|
|
217
|
+
return self._data.get(chat_id)
|
|
218
|
+
|
|
219
|
+
def set(self, chat_id: str, thread_id: str) -> None:
|
|
220
|
+
with self._lock:
|
|
221
|
+
self._data[chat_id] = thread_id
|
|
222
|
+
self._save()
|
|
223
|
+
|
|
224
|
+
def clear(self, chat_id: str) -> None:
|
|
225
|
+
with self._lock:
|
|
226
|
+
if chat_id in self._data:
|
|
227
|
+
del self._data[chat_id]
|
|
228
|
+
self._save()
|
|
229
|
+
|
|
230
|
+
def items(self) -> List[Tuple[str, str]]:
|
|
231
|
+
with self._lock:
|
|
232
|
+
return sorted(self._data.items())
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class WorkerStore:
|
|
236
|
+
def __init__(self, path: Path) -> None:
|
|
237
|
+
self._path = path
|
|
238
|
+
self._lock = threading.Lock()
|
|
239
|
+
self._data: Dict[str, Dict[str, object]] = {}
|
|
240
|
+
self._load()
|
|
241
|
+
|
|
242
|
+
def _load(self) -> None:
|
|
243
|
+
if not self._path.exists():
|
|
244
|
+
return
|
|
245
|
+
try:
|
|
246
|
+
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
|
247
|
+
if isinstance(raw, dict):
|
|
248
|
+
cleaned: Dict[str, Dict[str, object]] = {}
|
|
249
|
+
for worker_id, payload in raw.items():
|
|
250
|
+
if not isinstance(payload, dict):
|
|
251
|
+
continue
|
|
252
|
+
alias = str(payload.get("alias", "")).strip()
|
|
253
|
+
mode = str(payload.get("mode", "mention")).strip() or "mention"
|
|
254
|
+
enabled = bool(payload.get("enabled", False))
|
|
255
|
+
onboarding_sent = bool(payload.get("onboarding_sent", False))
|
|
256
|
+
cwd = str(payload.get("cwd", "")).strip()
|
|
257
|
+
cleaned[str(worker_id)] = {
|
|
258
|
+
"alias": alias,
|
|
259
|
+
"mode": mode,
|
|
260
|
+
"enabled": enabled,
|
|
261
|
+
"onboarding_sent": onboarding_sent,
|
|
262
|
+
"cwd": cwd,
|
|
263
|
+
}
|
|
264
|
+
self._data = cleaned
|
|
265
|
+
except Exception:
|
|
266
|
+
LOG.warning("Failed to load worker state from %s", self._path)
|
|
267
|
+
|
|
268
|
+
def _save(self) -> None:
|
|
269
|
+
self._path.write_text(
|
|
270
|
+
json.dumps(self._data, ensure_ascii=False, indent=2),
|
|
271
|
+
encoding="utf-8",
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def alias_for(self, worker_id: str) -> str:
|
|
275
|
+
with self._lock:
|
|
276
|
+
payload = self._ensure_record_locked(worker_id)
|
|
277
|
+
return str(payload.get("alias", "")).strip()
|
|
278
|
+
|
|
279
|
+
def set_alias(self, worker_id: str, alias: str) -> None:
|
|
280
|
+
alias = alias.strip()
|
|
281
|
+
with self._lock:
|
|
282
|
+
for existing_worker_id, payload in list(self._data.items()):
|
|
283
|
+
if existing_worker_id != worker_id and payload.get("alias", "") == alias:
|
|
284
|
+
raise ValueError(f"别名 {alias} 已被 worker {existing_worker_id} 使用。")
|
|
285
|
+
record = self._ensure_record_locked(worker_id)
|
|
286
|
+
record["alias"] = alias
|
|
287
|
+
self._save()
|
|
288
|
+
|
|
289
|
+
def clear_alias(self, worker_id: str) -> None:
|
|
290
|
+
with self._lock:
|
|
291
|
+
payload = self._ensure_record_locked(worker_id)
|
|
292
|
+
payload["alias"] = ""
|
|
293
|
+
self._save()
|
|
294
|
+
|
|
295
|
+
def mode_for(self, worker_id: str) -> str:
|
|
296
|
+
with self._lock:
|
|
297
|
+
payload = self._ensure_record_locked(worker_id)
|
|
298
|
+
return str(payload.get("mode", "mention")).strip() or "mention"
|
|
299
|
+
|
|
300
|
+
def set_mode(self, worker_id: str, mode: str) -> None:
|
|
301
|
+
with self._lock:
|
|
302
|
+
payload = self._ensure_record_locked(worker_id)
|
|
303
|
+
payload["mode"] = mode
|
|
304
|
+
self._save()
|
|
305
|
+
|
|
306
|
+
def enabled_for(self, worker_id: str) -> bool:
|
|
307
|
+
with self._lock:
|
|
308
|
+
payload = self._ensure_record_locked(worker_id)
|
|
309
|
+
return bool(payload.get("enabled", False))
|
|
310
|
+
|
|
311
|
+
def set_enabled(self, worker_id: str, enabled: bool) -> None:
|
|
312
|
+
with self._lock:
|
|
313
|
+
payload = self._ensure_record_locked(worker_id)
|
|
314
|
+
payload["enabled"] = enabled
|
|
315
|
+
if enabled:
|
|
316
|
+
payload["onboarding_sent"] = True
|
|
317
|
+
self._save()
|
|
318
|
+
|
|
319
|
+
def onboarding_sent_for(self, worker_id: str) -> bool:
|
|
320
|
+
with self._lock:
|
|
321
|
+
payload = self._ensure_record_locked(worker_id)
|
|
322
|
+
return bool(payload.get("onboarding_sent", False))
|
|
323
|
+
|
|
324
|
+
def mark_onboarding_sent(self, worker_id: str) -> None:
|
|
325
|
+
with self._lock:
|
|
326
|
+
payload = self._ensure_record_locked(worker_id)
|
|
327
|
+
payload["onboarding_sent"] = True
|
|
328
|
+
self._save()
|
|
329
|
+
|
|
330
|
+
def ensure_worker(self, worker_id: str) -> None:
|
|
331
|
+
with self._lock:
|
|
332
|
+
self._ensure_record_locked(worker_id)
|
|
333
|
+
self._save()
|
|
334
|
+
|
|
335
|
+
def cwd_for(self, worker_id: str) -> str:
|
|
336
|
+
with self._lock:
|
|
337
|
+
payload = self._ensure_record_locked(worker_id)
|
|
338
|
+
return str(payload.get("cwd", "")).strip()
|
|
339
|
+
|
|
340
|
+
def set_cwd(self, worker_id: str, cwd: str) -> None:
|
|
341
|
+
with self._lock:
|
|
342
|
+
payload = self._ensure_record_locked(worker_id)
|
|
343
|
+
payload["cwd"] = cwd.strip()
|
|
344
|
+
self._save()
|
|
345
|
+
|
|
346
|
+
def resolve(self, identifier: str) -> Optional[str]:
|
|
347
|
+
key = identifier.strip()
|
|
348
|
+
if not key:
|
|
349
|
+
return None
|
|
350
|
+
with self._lock:
|
|
351
|
+
if key in self._data:
|
|
352
|
+
return key
|
|
353
|
+
for worker_id, payload in self._data.items():
|
|
354
|
+
if payload.get("alias", "") == key:
|
|
355
|
+
return worker_id
|
|
356
|
+
return key if key.startswith("oc_") else None
|
|
357
|
+
|
|
358
|
+
def items(self) -> List[Tuple[str, str]]:
|
|
359
|
+
with self._lock:
|
|
360
|
+
return sorted((worker_id, payload.get("alias", "")) for worker_id, payload in self._data.items())
|
|
361
|
+
|
|
362
|
+
def remove(self, worker_id: str) -> None:
|
|
363
|
+
with self._lock:
|
|
364
|
+
if worker_id in self._data:
|
|
365
|
+
del self._data[worker_id]
|
|
366
|
+
self._save()
|
|
367
|
+
|
|
368
|
+
def _ensure_record_locked(self, worker_id: str) -> Dict[str, object]:
|
|
369
|
+
payload = self._data.get(worker_id)
|
|
370
|
+
if payload is None:
|
|
371
|
+
payload = {
|
|
372
|
+
"alias": "",
|
|
373
|
+
"mode": "mention",
|
|
374
|
+
"enabled": False,
|
|
375
|
+
"onboarding_sent": False,
|
|
376
|
+
"cwd": "",
|
|
377
|
+
}
|
|
378
|
+
self._data[worker_id] = payload
|
|
379
|
+
return payload
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class AppServerClient:
|
|
383
|
+
def __init__(self, config: AppConfig, cwd: str) -> None:
|
|
384
|
+
self._config = config
|
|
385
|
+
self._cwd = cwd
|
|
386
|
+
self._process: Optional[subprocess.Popen[str]] = None
|
|
387
|
+
self._request_id = 0
|
|
388
|
+
self._responses: Dict[int, "queue.Queue[dict]"] = {}
|
|
389
|
+
self._responses_lock = threading.Lock()
|
|
390
|
+
self._write_lock = threading.Lock()
|
|
391
|
+
self._notify_queue: "queue.Queue[dict]" = queue.Queue()
|
|
392
|
+
self._loaded_threads: Set[str] = set()
|
|
393
|
+
|
|
394
|
+
def start(self) -> None:
|
|
395
|
+
cmd = [self._config.codex_bin, "app-server"]
|
|
396
|
+
extra_args = shlex.split(self._config.codex_app_server_args)
|
|
397
|
+
cmd.extend(extra_args)
|
|
398
|
+
self._process = subprocess.Popen(
|
|
399
|
+
cmd,
|
|
400
|
+
stdin=subprocess.PIPE,
|
|
401
|
+
stdout=subprocess.PIPE,
|
|
402
|
+
stderr=subprocess.PIPE,
|
|
403
|
+
text=True,
|
|
404
|
+
bufsize=1,
|
|
405
|
+
)
|
|
406
|
+
assert self._process.stdin is not None
|
|
407
|
+
assert self._process.stdout is not None
|
|
408
|
+
assert self._process.stderr is not None
|
|
409
|
+
threading.Thread(target=self._stdout_loop, daemon=True).start()
|
|
410
|
+
threading.Thread(target=self._stderr_loop, daemon=True).start()
|
|
411
|
+
result = self._request(
|
|
412
|
+
"initialize",
|
|
413
|
+
{
|
|
414
|
+
"clientInfo": {"name": "feishu-codex-bridge", "version": "0.1"},
|
|
415
|
+
"capabilities": {"experimentalApi": True},
|
|
416
|
+
},
|
|
417
|
+
)
|
|
418
|
+
LOG.info("App server initialized: %s", result)
|
|
419
|
+
|
|
420
|
+
def notifications(self) -> "queue.Queue[dict]":
|
|
421
|
+
return self._notify_queue
|
|
422
|
+
|
|
423
|
+
def close(self) -> None:
|
|
424
|
+
if self._process is None:
|
|
425
|
+
return
|
|
426
|
+
try:
|
|
427
|
+
self._process.terminate()
|
|
428
|
+
except Exception:
|
|
429
|
+
LOG.exception("Failed to terminate app-server")
|
|
430
|
+
try:
|
|
431
|
+
self._notify_queue.put({"method": "__closed__"})
|
|
432
|
+
except Exception:
|
|
433
|
+
LOG.exception("Failed to notify app-server close")
|
|
434
|
+
self._process = None
|
|
435
|
+
|
|
436
|
+
def _stdout_loop(self) -> None:
|
|
437
|
+
assert self._process is not None
|
|
438
|
+
assert self._process.stdout is not None
|
|
439
|
+
for raw_line in self._process.stdout:
|
|
440
|
+
line = raw_line.strip()
|
|
441
|
+
if not line:
|
|
442
|
+
continue
|
|
443
|
+
try:
|
|
444
|
+
message = json.loads(line)
|
|
445
|
+
except json.JSONDecodeError:
|
|
446
|
+
LOG.warning("Invalid JSON from app-server stdout: %s", line)
|
|
447
|
+
continue
|
|
448
|
+
if "id" in message and ("result" in message or "error" in message):
|
|
449
|
+
self._handle_response(message)
|
|
450
|
+
elif "method" in message:
|
|
451
|
+
self._notify_queue.put(message)
|
|
452
|
+
else:
|
|
453
|
+
LOG.debug("Unrecognized app-server message: %s", message)
|
|
454
|
+
|
|
455
|
+
def _stderr_loop(self) -> None:
|
|
456
|
+
assert self._process is not None
|
|
457
|
+
assert self._process.stderr is not None
|
|
458
|
+
for raw_line in self._process.stderr:
|
|
459
|
+
line = raw_line.rstrip("\n")
|
|
460
|
+
if line:
|
|
461
|
+
LOG.warning("app-server stderr: %s", line)
|
|
462
|
+
|
|
463
|
+
def _handle_response(self, message: dict) -> None:
|
|
464
|
+
response_id = message.get("id")
|
|
465
|
+
if not isinstance(response_id, int):
|
|
466
|
+
return
|
|
467
|
+
with self._responses_lock:
|
|
468
|
+
waiter = self._responses.get(response_id)
|
|
469
|
+
if waiter is not None:
|
|
470
|
+
waiter.put(message)
|
|
471
|
+
|
|
472
|
+
def _request(self, method: str, params: dict, timeout: float = 30.0) -> dict:
|
|
473
|
+
if self._process is None or self._process.poll() is not None:
|
|
474
|
+
raise RuntimeError("app-server is not running")
|
|
475
|
+
with self._responses_lock:
|
|
476
|
+
self._request_id += 1
|
|
477
|
+
request_id = self._request_id
|
|
478
|
+
waiter: "queue.Queue[dict]" = queue.Queue(maxsize=1)
|
|
479
|
+
self._responses[request_id] = waiter
|
|
480
|
+
message = {"id": request_id, "method": method, "params": params}
|
|
481
|
+
with self._write_lock:
|
|
482
|
+
assert self._process.stdin is not None
|
|
483
|
+
self._process.stdin.write(json.dumps(message, ensure_ascii=False) + "\n")
|
|
484
|
+
self._process.stdin.flush()
|
|
485
|
+
try:
|
|
486
|
+
response = waiter.get(timeout=timeout)
|
|
487
|
+
except queue.Empty as exc:
|
|
488
|
+
raise RuntimeError(f"app-server request timed out: {method}") from exc
|
|
489
|
+
finally:
|
|
490
|
+
with self._responses_lock:
|
|
491
|
+
self._responses.pop(request_id, None)
|
|
492
|
+
if "error" in response:
|
|
493
|
+
error = response["error"]
|
|
494
|
+
raise RuntimeError(f"app-server error on {method}: {error}")
|
|
495
|
+
return response["result"]
|
|
496
|
+
|
|
497
|
+
def ensure_thread(self, thread_id: Optional[str]) -> str:
|
|
498
|
+
if thread_id:
|
|
499
|
+
if thread_id not in self._loaded_threads:
|
|
500
|
+
self._request(
|
|
501
|
+
"thread/resume",
|
|
502
|
+
{
|
|
503
|
+
"threadId": thread_id,
|
|
504
|
+
"cwd": self._cwd,
|
|
505
|
+
"approvalPolicy": self._config.codex_approval_policy,
|
|
506
|
+
"sandbox": self._config.codex_sandbox,
|
|
507
|
+
"model": self._config.codex_model or None,
|
|
508
|
+
},
|
|
509
|
+
)
|
|
510
|
+
self._loaded_threads.add(thread_id)
|
|
511
|
+
return thread_id
|
|
512
|
+
result = self._request(
|
|
513
|
+
"thread/start",
|
|
514
|
+
{
|
|
515
|
+
"cwd": self._cwd,
|
|
516
|
+
"approvalPolicy": self._config.codex_approval_policy,
|
|
517
|
+
"sandbox": self._config.codex_sandbox,
|
|
518
|
+
"model": self._config.codex_model or None,
|
|
519
|
+
},
|
|
520
|
+
)
|
|
521
|
+
new_thread_id = result["thread"]["id"]
|
|
522
|
+
self._loaded_threads.add(new_thread_id)
|
|
523
|
+
return new_thread_id
|
|
524
|
+
|
|
525
|
+
def start_turn(self, thread_id: str, text: str) -> str:
|
|
526
|
+
result = self._request(
|
|
527
|
+
"turn/start",
|
|
528
|
+
{
|
|
529
|
+
"threadId": thread_id,
|
|
530
|
+
"input": [{"type": "text", "text": text}],
|
|
531
|
+
},
|
|
532
|
+
timeout=60.0,
|
|
533
|
+
)
|
|
534
|
+
return result["turn"]["id"]
|
|
535
|
+
|
|
536
|
+
def interrupt_turn(self, thread_id: str, turn_id: str) -> None:
|
|
537
|
+
self._request(
|
|
538
|
+
"turn/interrupt",
|
|
539
|
+
{"threadId": thread_id, "turnId": turn_id},
|
|
540
|
+
timeout=10.0,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
class BridgeApp:
|
|
545
|
+
def __init__(self, config: AppConfig) -> None:
|
|
546
|
+
self._config = config
|
|
547
|
+
self._messenger = FeishuMessenger(config)
|
|
548
|
+
self._store = ThreadStore(Path(config.thread_state_path))
|
|
549
|
+
self._worker_store = WorkerStore(Path(config.worker_state_path))
|
|
550
|
+
self._command_queue: "queue.Queue[Dict[str, object]]" = queue.Queue()
|
|
551
|
+
self._known_message_ids: Set[str] = set()
|
|
552
|
+
self._workers: Dict[str, WorkerRuntime] = {}
|
|
553
|
+
self._active_by_worker: Dict[str, TurnSession] = {}
|
|
554
|
+
self._active_by_turn: Dict[Tuple[str, str, str], TurnSession] = {}
|
|
555
|
+
self._lock = threading.Lock()
|
|
556
|
+
|
|
557
|
+
def run(self) -> None:
|
|
558
|
+
threading.Thread(target=self._command_loop, daemon=True).start()
|
|
559
|
+
event_handler = (
|
|
560
|
+
lark.EventDispatcherHandler.builder(
|
|
561
|
+
self._config.feishu_encrypt_key,
|
|
562
|
+
self._config.feishu_verification_token,
|
|
563
|
+
)
|
|
564
|
+
.register_p2_im_message_receive_v1(self._on_message_received)
|
|
565
|
+
.build()
|
|
566
|
+
)
|
|
567
|
+
ws_client = lark.ws.Client(
|
|
568
|
+
self._config.feishu_app_id,
|
|
569
|
+
self._config.feishu_app_secret,
|
|
570
|
+
event_handler=event_handler,
|
|
571
|
+
log_level=lark.LogLevel.INFO,
|
|
572
|
+
)
|
|
573
|
+
LOG.info("App-server bridge is running.")
|
|
574
|
+
ws_client.start()
|
|
575
|
+
|
|
576
|
+
def _on_message_received(self, data: P2ImMessageReceiveV1) -> None:
|
|
577
|
+
message = data.event.message
|
|
578
|
+
sender = data.event.sender
|
|
579
|
+
if message is None or sender is None or sender.sender_id is None or not message.chat_id:
|
|
580
|
+
return
|
|
581
|
+
if message.message_type != "text":
|
|
582
|
+
return
|
|
583
|
+
if sender.sender_type != "user":
|
|
584
|
+
return
|
|
585
|
+
chat_type = message.chat_type or ""
|
|
586
|
+
open_id = sender.sender_id.open_id or ""
|
|
587
|
+
LOG.info(
|
|
588
|
+
"Received message from open_id=%s chat_id=%s chat_type=%s",
|
|
589
|
+
open_id,
|
|
590
|
+
message.chat_id,
|
|
591
|
+
chat_type,
|
|
592
|
+
)
|
|
593
|
+
if not self._is_allowed_user(open_id):
|
|
594
|
+
LOG.warning("Ignored message from unauthorized user: %s", open_id)
|
|
595
|
+
return
|
|
596
|
+
if message.message_id in self._known_message_ids:
|
|
597
|
+
return
|
|
598
|
+
self._known_message_ids.add(message.message_id)
|
|
599
|
+
text = self._extract_text(message.content)
|
|
600
|
+
if not text:
|
|
601
|
+
return
|
|
602
|
+
self._command_queue.put(
|
|
603
|
+
{
|
|
604
|
+
"chat_id": message.chat_id,
|
|
605
|
+
"chat_type": chat_type,
|
|
606
|
+
"text": text,
|
|
607
|
+
"mentions": list(message.mentions or []),
|
|
608
|
+
}
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
def _command_loop(self) -> None:
|
|
612
|
+
while True:
|
|
613
|
+
payload = self._command_queue.get()
|
|
614
|
+
threading.Thread(target=self._process_command, args=(payload,), daemon=True).start()
|
|
615
|
+
|
|
616
|
+
def _process_command(self, payload: Dict[str, object]) -> None:
|
|
617
|
+
chat_id = str(payload["chat_id"])
|
|
618
|
+
chat_type = str(payload["chat_type"])
|
|
619
|
+
text = str(payload["text"])
|
|
620
|
+
mentions = list(payload.get("mentions", []))
|
|
621
|
+
try:
|
|
622
|
+
if chat_type == "p2p":
|
|
623
|
+
self._handle_dm_command(chat_id, text)
|
|
624
|
+
else:
|
|
625
|
+
self._handle_group_command(chat_id, chat_type, text, mentions)
|
|
626
|
+
except Exception as exc:
|
|
627
|
+
LOG.exception("Command failed")
|
|
628
|
+
self._safe_send(chat_id, f"[bridge] 执行失败\n{exc}")
|
|
629
|
+
|
|
630
|
+
def _parse_poco_command(self, text: str) -> Tuple[Optional[str], str]:
|
|
631
|
+
stripped = text.strip()
|
|
632
|
+
if not stripped.startswith("/poco"):
|
|
633
|
+
return None, ""
|
|
634
|
+
rest = stripped[len("/poco"):].strip()
|
|
635
|
+
if not rest:
|
|
636
|
+
return "help", ""
|
|
637
|
+
if " " not in rest:
|
|
638
|
+
return rest.lower(), ""
|
|
639
|
+
command, arg = rest.split(" ", 1)
|
|
640
|
+
return command.lower(), arg.strip()
|
|
641
|
+
|
|
642
|
+
def _handle_dm_command(self, chat_id: str, text: str) -> None:
|
|
643
|
+
command, arg = self._parse_poco_command(text)
|
|
644
|
+
if command is None:
|
|
645
|
+
self._safe_send(chat_id, self._dm_help_text())
|
|
646
|
+
return
|
|
647
|
+
if command == "help":
|
|
648
|
+
self._safe_send(chat_id, self._dm_help_text())
|
|
649
|
+
return
|
|
650
|
+
if command in {"workers", "list"}:
|
|
651
|
+
self._safe_send(chat_id, self._workers_text())
|
|
652
|
+
return
|
|
653
|
+
if command == "status":
|
|
654
|
+
if not arg:
|
|
655
|
+
self._safe_send(chat_id, self._workers_text())
|
|
656
|
+
return
|
|
657
|
+
self._safe_send(chat_id, self._worker_status_text(self._resolve_worker_id(arg), requested=arg))
|
|
658
|
+
return
|
|
659
|
+
if command == "stop":
|
|
660
|
+
if not arg:
|
|
661
|
+
self._safe_send(chat_id, "[bridge] 用法:/poco stop <worker_alias|group_chat_id>")
|
|
662
|
+
return
|
|
663
|
+
worker_id = self._resolve_worker_id(arg)
|
|
664
|
+
if worker_id is None:
|
|
665
|
+
self._safe_send(chat_id, f"[bridge] 找不到 worker:{arg}")
|
|
666
|
+
return
|
|
667
|
+
with self._lock:
|
|
668
|
+
session = self._active_by_worker.get(worker_id)
|
|
669
|
+
if session is None:
|
|
670
|
+
self._safe_send(chat_id, f"[bridge] worker {worker_id} 当前没有进行中的回复。")
|
|
671
|
+
return
|
|
672
|
+
worker = self._get_worker(worker_id)
|
|
673
|
+
if worker is None:
|
|
674
|
+
self._safe_send(chat_id, f"[bridge] worker {worker_id} 不存在。")
|
|
675
|
+
return
|
|
676
|
+
worker.codex.interrupt_turn(session.thread_id, session.turn_id)
|
|
677
|
+
self._safe_send(chat_id, f"[bridge] 已请求停止 worker {worker_id} 的当前回复。")
|
|
678
|
+
return
|
|
679
|
+
if command == "reset":
|
|
680
|
+
if not arg:
|
|
681
|
+
self._safe_send(chat_id, "[bridge] 用法:/poco reset <worker_alias|group_chat_id>")
|
|
682
|
+
return
|
|
683
|
+
worker_id = self._resolve_worker_id(arg)
|
|
684
|
+
if worker_id is None:
|
|
685
|
+
self._safe_send(chat_id, f"[bridge] 找不到 worker:{arg}")
|
|
686
|
+
return
|
|
687
|
+
with self._lock:
|
|
688
|
+
session = self._active_by_worker.get(worker_id)
|
|
689
|
+
if session is not None:
|
|
690
|
+
self._safe_send(chat_id, "[bridge] 该 worker 当前还有一轮在进行中,先等待完成或使用 /stop。")
|
|
691
|
+
return
|
|
692
|
+
self._store.clear(worker_id)
|
|
693
|
+
self._safe_send(chat_id, f"[bridge] 已清空 worker {worker_id} 绑定的 Codex 会话。")
|
|
694
|
+
return
|
|
695
|
+
if command == "remove":
|
|
696
|
+
if not arg:
|
|
697
|
+
self._safe_send(chat_id, "[bridge] 用法:/poco remove <worker_alias|group_chat_id>")
|
|
698
|
+
return
|
|
699
|
+
worker_id = self._resolve_worker_id(arg)
|
|
700
|
+
if worker_id is None:
|
|
701
|
+
self._safe_send(chat_id, f"[bridge] 找不到 worker:{arg}")
|
|
702
|
+
return
|
|
703
|
+
self._remove_worker(worker_id)
|
|
704
|
+
self._safe_send(chat_id, f"[bridge] 已移除 worker {worker_id}。")
|
|
705
|
+
return
|
|
706
|
+
self._safe_send(chat_id, "[bridge] 未知管理命令。使用 /poco help 查看可用命令。")
|
|
707
|
+
|
|
708
|
+
def _handle_group_command(self, chat_id: str, chat_type: str, text: str, mentions: list) -> None:
|
|
709
|
+
worker_id = chat_id
|
|
710
|
+
self._worker_store.ensure_worker(worker_id)
|
|
711
|
+
command, arg = self._parse_poco_command(text)
|
|
712
|
+
if command == "help":
|
|
713
|
+
self._safe_send(chat_id, self._group_help_text())
|
|
714
|
+
return
|
|
715
|
+
if command == "mode":
|
|
716
|
+
if arg not in {"mention", "auto"}:
|
|
717
|
+
self._safe_send(chat_id, "[bridge] 用法:/poco mode <mention|auto>")
|
|
718
|
+
return
|
|
719
|
+
self._worker_store.set_mode(worker_id, arg)
|
|
720
|
+
self._safe_send(chat_id, f"[bridge] 当前群模式已设置为 {arg}")
|
|
721
|
+
return
|
|
722
|
+
if command == "cwd":
|
|
723
|
+
cwd = arg.strip()
|
|
724
|
+
if not cwd:
|
|
725
|
+
current_cwd = self._worker_store.cwd_for(worker_id)
|
|
726
|
+
self._safe_send(chat_id, f"[bridge] 当前群 cwd: {current_cwd or '(未设置)'}")
|
|
727
|
+
return
|
|
728
|
+
validation_error = self._validate_cwd(cwd)
|
|
729
|
+
if validation_error:
|
|
730
|
+
self._safe_send(chat_id, f"[bridge] cwd 无效:{validation_error}")
|
|
731
|
+
return
|
|
732
|
+
self._worker_store.set_cwd(worker_id, cwd)
|
|
733
|
+
self._safe_send(chat_id, f"[bridge] 当前群工作目录已设置为 {cwd}")
|
|
734
|
+
return
|
|
735
|
+
if command == "enable":
|
|
736
|
+
cwd = self._worker_store.cwd_for(worker_id)
|
|
737
|
+
if not cwd:
|
|
738
|
+
self._safe_send(chat_id, "[bridge] 启用前必须先设置 /poco cwd <path>")
|
|
739
|
+
return
|
|
740
|
+
validation_error = self._validate_cwd(cwd)
|
|
741
|
+
if validation_error:
|
|
742
|
+
self._safe_send(chat_id, f"[bridge] 当前群 cwd 无效:{validation_error}")
|
|
743
|
+
return
|
|
744
|
+
self._worker_store.set_enabled(worker_id, True)
|
|
745
|
+
self._safe_send(chat_id, "[bridge] 当前群已启用。之后会按当前 mode 处理新消息。")
|
|
746
|
+
return
|
|
747
|
+
if command == "disable":
|
|
748
|
+
self._worker_store.set_enabled(worker_id, False)
|
|
749
|
+
self._safe_send(chat_id, "[bridge] 当前群已停用。未重新启用前,不会把群消息发给 Codex。")
|
|
750
|
+
return
|
|
751
|
+
if command in {"reset", "new"}:
|
|
752
|
+
with self._lock:
|
|
753
|
+
session = self._active_by_worker.get(worker_id)
|
|
754
|
+
if session is not None:
|
|
755
|
+
self._safe_send(chat_id, "[bridge] 当前还有一轮在进行中,先等待完成或使用 /stop。")
|
|
756
|
+
return
|
|
757
|
+
self._store.clear(worker_id)
|
|
758
|
+
self._safe_send(chat_id, "[bridge] 已清空当前项目群绑定的 Codex 会话。下一条消息会新开线程。")
|
|
759
|
+
return
|
|
760
|
+
if command == "status":
|
|
761
|
+
self._safe_send(chat_id, self._worker_status_text(worker_id))
|
|
762
|
+
return
|
|
763
|
+
if command == "name":
|
|
764
|
+
alias = arg.strip()
|
|
765
|
+
if not alias:
|
|
766
|
+
self._safe_send(chat_id, "[bridge] 用法:/poco name <alias>")
|
|
767
|
+
return
|
|
768
|
+
normalized_alias = self._normalize_alias(alias)
|
|
769
|
+
if normalized_alias is None:
|
|
770
|
+
self._safe_send(chat_id, "[bridge] alias 只能包含小写字母、数字、- 和 _,长度 2-32。")
|
|
771
|
+
return
|
|
772
|
+
self._worker_store.set_alias(worker_id, normalized_alias)
|
|
773
|
+
self._safe_send(chat_id, f"[bridge] 当前项目 worker 已命名为 {normalized_alias}")
|
|
774
|
+
return
|
|
775
|
+
if command in {"unname", "clear-name"}:
|
|
776
|
+
self._worker_store.clear_alias(worker_id)
|
|
777
|
+
self._safe_send(chat_id, "[bridge] 当前项目 worker 的别名已清空。")
|
|
778
|
+
return
|
|
779
|
+
if command == "stop":
|
|
780
|
+
with self._lock:
|
|
781
|
+
session = self._active_by_worker.get(worker_id)
|
|
782
|
+
if session is None:
|
|
783
|
+
self._safe_send(chat_id, "[bridge] 当前没有进行中的回复。")
|
|
784
|
+
return
|
|
785
|
+
worker = self._ensure_worker(worker_id, chat_id, chat_type)
|
|
786
|
+
worker.codex.interrupt_turn(session.thread_id, session.turn_id)
|
|
787
|
+
self._safe_send(chat_id, "[bridge] 已请求停止当前回复。")
|
|
788
|
+
return
|
|
789
|
+
if command == "remove":
|
|
790
|
+
self._remove_worker(worker_id)
|
|
791
|
+
self._safe_send(chat_id, "[bridge] 已移除当前项目 worker。下次 @bot 发消息会自动重新创建。")
|
|
792
|
+
return
|
|
793
|
+
if command is not None:
|
|
794
|
+
self._safe_send(chat_id, "[bridge] 未知管理命令。使用 /poco help 查看可用命令。")
|
|
795
|
+
return
|
|
796
|
+
|
|
797
|
+
if not self._worker_store.enabled_for(worker_id):
|
|
798
|
+
self._send_group_setup_prompt(chat_id, worker_id)
|
|
799
|
+
return
|
|
800
|
+
|
|
801
|
+
mode = self._worker_store.mode_for(worker_id)
|
|
802
|
+
if mode == "mention":
|
|
803
|
+
if not self._is_group_bot_mention(mentions):
|
|
804
|
+
return
|
|
805
|
+
text = self._strip_mentions(text, mentions)
|
|
806
|
+
if not text:
|
|
807
|
+
return
|
|
808
|
+
elif mode == "auto":
|
|
809
|
+
text = self._strip_mentions(text, mentions)
|
|
810
|
+
else:
|
|
811
|
+
self._safe_send(chat_id, f"[bridge] 未知群模式:{mode}")
|
|
812
|
+
return
|
|
813
|
+
|
|
814
|
+
with self._lock:
|
|
815
|
+
if worker_id in self._active_by_worker:
|
|
816
|
+
self._safe_send(chat_id, "[bridge] 上一轮还在处理中,请先等待完成或使用 /stop。")
|
|
817
|
+
return
|
|
818
|
+
|
|
819
|
+
worker = self._ensure_worker(worker_id, chat_id, chat_type)
|
|
820
|
+
thread_id = worker.codex.ensure_thread(self._store.get(worker_id))
|
|
821
|
+
self._store.set(worker_id, thread_id)
|
|
822
|
+
live = LiveMessage(
|
|
823
|
+
sent=self._messenger.create_text(chat_id, "[bridge] 已发送给 Codex,处理中..."),
|
|
824
|
+
last_text="[bridge] 已发送给 Codex,处理中...",
|
|
825
|
+
)
|
|
826
|
+
turn_id = worker.codex.start_turn(thread_id, text)
|
|
827
|
+
session = TurnSession(
|
|
828
|
+
worker_id=worker_id,
|
|
829
|
+
chat_id=chat_id,
|
|
830
|
+
thread_id=thread_id,
|
|
831
|
+
turn_id=turn_id,
|
|
832
|
+
prompt=text,
|
|
833
|
+
live=live,
|
|
834
|
+
next_delay=max(1, self._config.live_update_initial_seconds),
|
|
835
|
+
next_update_at=time.time() + max(1, self._config.live_update_initial_seconds),
|
|
836
|
+
)
|
|
837
|
+
with self._lock:
|
|
838
|
+
self._active_by_worker[worker_id] = session
|
|
839
|
+
self._active_by_turn[(worker_id, thread_id, turn_id)] = session
|
|
840
|
+
threading.Thread(target=self._render_loop, args=(session,), daemon=True).start()
|
|
841
|
+
|
|
842
|
+
def _notification_loop(self, worker_id: str, codex: AppServerClient) -> None:
|
|
843
|
+
notifications = codex.notifications()
|
|
844
|
+
while True:
|
|
845
|
+
message = notifications.get()
|
|
846
|
+
if message.get("method") == "__closed__":
|
|
847
|
+
return
|
|
848
|
+
try:
|
|
849
|
+
self._handle_notification(worker_id, message)
|
|
850
|
+
except Exception:
|
|
851
|
+
LOG.exception("Failed to handle app-server notification for worker %s", worker_id)
|
|
852
|
+
|
|
853
|
+
def _handle_notification(self, worker_id: str, message: dict) -> None:
|
|
854
|
+
method = message.get("method")
|
|
855
|
+
params = message.get("params", {})
|
|
856
|
+
if method == "item/agentMessage/delta":
|
|
857
|
+
self._on_agent_delta(worker_id, params)
|
|
858
|
+
return
|
|
859
|
+
if method == "item/completed":
|
|
860
|
+
self._on_item_completed(worker_id, params)
|
|
861
|
+
return
|
|
862
|
+
if method == "turn/completed":
|
|
863
|
+
self._on_turn_completed(worker_id, params)
|
|
864
|
+
return
|
|
865
|
+
if method == "error":
|
|
866
|
+
LOG.error("app-server notification error: %s", params)
|
|
867
|
+
return
|
|
868
|
+
LOG.debug("Ignored app-server notification: %s", method)
|
|
869
|
+
|
|
870
|
+
def _lookup_session(self, worker_id: str, thread_id: str, turn_id: str) -> Optional[TurnSession]:
|
|
871
|
+
with self._lock:
|
|
872
|
+
return self._active_by_turn.get((worker_id, thread_id, turn_id))
|
|
873
|
+
|
|
874
|
+
def _on_agent_delta(self, worker_id: str, params: dict) -> None:
|
|
875
|
+
thread_id = params.get("threadId")
|
|
876
|
+
turn_id = params.get("turnId")
|
|
877
|
+
delta = str(params.get("delta", ""))
|
|
878
|
+
if not thread_id or not turn_id or not delta:
|
|
879
|
+
return
|
|
880
|
+
session = self._lookup_session(worker_id, thread_id, turn_id)
|
|
881
|
+
if session is None:
|
|
882
|
+
return
|
|
883
|
+
with session.lock:
|
|
884
|
+
session.accumulated_text += delta
|
|
885
|
+
session.notify()
|
|
886
|
+
|
|
887
|
+
def _on_item_completed(self, worker_id: str, params: dict) -> None:
|
|
888
|
+
thread_id = params.get("threadId")
|
|
889
|
+
turn_id = params.get("turnId")
|
|
890
|
+
item = params.get("item", {})
|
|
891
|
+
if not thread_id or not turn_id or not isinstance(item, dict):
|
|
892
|
+
return
|
|
893
|
+
if item.get("type") != "agentMessage":
|
|
894
|
+
return
|
|
895
|
+
session = self._lookup_session(worker_id, thread_id, turn_id)
|
|
896
|
+
if session is None:
|
|
897
|
+
return
|
|
898
|
+
final_text = str(item.get("text", "")).strip()
|
|
899
|
+
if not final_text:
|
|
900
|
+
return
|
|
901
|
+
with session.lock:
|
|
902
|
+
session.accumulated_text = final_text
|
|
903
|
+
session.final_text = final_text
|
|
904
|
+
session.notify()
|
|
905
|
+
|
|
906
|
+
def _on_turn_completed(self, worker_id: str, params: dict) -> None:
|
|
907
|
+
thread_id = params.get("threadId")
|
|
908
|
+
turn = params.get("turn", {})
|
|
909
|
+
if not thread_id or not isinstance(turn, dict):
|
|
910
|
+
return
|
|
911
|
+
turn_id = turn.get("id")
|
|
912
|
+
if not turn_id:
|
|
913
|
+
return
|
|
914
|
+
session = self._lookup_session(worker_id, thread_id, turn_id)
|
|
915
|
+
if session is None:
|
|
916
|
+
return
|
|
917
|
+
status = str(turn.get("status", "completed"))
|
|
918
|
+
with session.lock:
|
|
919
|
+
session.done = True
|
|
920
|
+
if status != "completed" and not session.error_text:
|
|
921
|
+
session.error_text = f"[bridge] 当前回复已结束,状态:{status}"
|
|
922
|
+
session.notify()
|
|
923
|
+
|
|
924
|
+
def _render_loop(self, session: TurnSession) -> None:
|
|
925
|
+
max_delay = max(
|
|
926
|
+
max(1, self._config.live_update_initial_seconds),
|
|
927
|
+
self._config.live_update_max_seconds,
|
|
928
|
+
)
|
|
929
|
+
while True:
|
|
930
|
+
session.updated_event.wait(timeout=0.5)
|
|
931
|
+
session.updated_event.clear()
|
|
932
|
+
now = time.time()
|
|
933
|
+
|
|
934
|
+
with session.lock:
|
|
935
|
+
done = session.done
|
|
936
|
+
accumulated = session.accumulated_text
|
|
937
|
+
final_text = session.final_text
|
|
938
|
+
error_text = session.error_text
|
|
939
|
+
next_update_at = session.next_update_at
|
|
940
|
+
next_delay = session.next_delay
|
|
941
|
+
elapsed = int(now - session.started_at)
|
|
942
|
+
|
|
943
|
+
if done:
|
|
944
|
+
if error_text and not accumulated:
|
|
945
|
+
self._render_text(session, error_text, final=True)
|
|
946
|
+
elif final_text or accumulated:
|
|
947
|
+
self._render_text(session, final_text or accumulated, final=True)
|
|
948
|
+
else:
|
|
949
|
+
self._render_text(session, "[bridge] 回复已完成,但没有可显示的文本。", final=True)
|
|
950
|
+
self._cleanup_session(session)
|
|
951
|
+
return
|
|
952
|
+
|
|
953
|
+
if accumulated and now >= next_update_at:
|
|
954
|
+
self._render_text(session, accumulated, final=False)
|
|
955
|
+
with session.lock:
|
|
956
|
+
session.next_delay = min(next_delay * 2, max_delay)
|
|
957
|
+
session.next_update_at = now + session.next_delay
|
|
958
|
+
continue
|
|
959
|
+
|
|
960
|
+
if not accumulated and now >= next_update_at:
|
|
961
|
+
status_text = (
|
|
962
|
+
"[bridge] Codex 正在处理...\n"
|
|
963
|
+
f"已等待 {elapsed}s\n"
|
|
964
|
+
f"本轮问题:{shorten(session.prompt, 100)}"
|
|
965
|
+
)
|
|
966
|
+
self._render_text(session, status_text, final=False)
|
|
967
|
+
with session.lock:
|
|
968
|
+
session.next_delay = min(next_delay * 2, max_delay)
|
|
969
|
+
session.next_update_at = now + session.next_delay
|
|
970
|
+
|
|
971
|
+
def _render_text(self, session: TurnSession, text: str, final: bool) -> None:
|
|
972
|
+
chunks = chunk_text(text, self._config.message_limit)
|
|
973
|
+
if not chunks:
|
|
974
|
+
chunks = [""]
|
|
975
|
+
live = session.live
|
|
976
|
+
live = self._update_live_message(session.chat_id, live, chunks[0])
|
|
977
|
+
session.live = live
|
|
978
|
+
if final:
|
|
979
|
+
for chunk in chunks[1:]:
|
|
980
|
+
self._messenger.send_text(session.chat_id, chunk)
|
|
981
|
+
|
|
982
|
+
def _update_live_message(self, chat_id: str, live: LiveMessage, text: str) -> LiveMessage:
|
|
983
|
+
if text == live.last_text:
|
|
984
|
+
return live
|
|
985
|
+
if live.edit_count >= self._config.max_message_edits:
|
|
986
|
+
new_sent = self._messenger.create_text(chat_id, text)
|
|
987
|
+
return LiveMessage(sent=new_sent, edit_count=0, last_text=text)
|
|
988
|
+
try:
|
|
989
|
+
self._messenger.update_text(live.sent.message_id, text)
|
|
990
|
+
live.edit_count += 1
|
|
991
|
+
live.last_text = text
|
|
992
|
+
return live
|
|
993
|
+
except Exception:
|
|
994
|
+
LOG.exception("Message update failed, falling back to create a new message")
|
|
995
|
+
new_sent = self._messenger.create_text(chat_id, text)
|
|
996
|
+
return LiveMessage(sent=new_sent, edit_count=0, last_text=text)
|
|
997
|
+
|
|
998
|
+
def _cleanup_session(self, session: TurnSession) -> None:
|
|
999
|
+
with self._lock:
|
|
1000
|
+
current = self._active_by_turn.pop((session.worker_id, session.thread_id, session.turn_id), None)
|
|
1001
|
+
if current is not None and self._active_by_worker.get(session.worker_id) is current:
|
|
1002
|
+
del self._active_by_worker[session.worker_id]
|
|
1003
|
+
|
|
1004
|
+
def _extract_text(self, raw: Optional[str]) -> str:
|
|
1005
|
+
if not raw:
|
|
1006
|
+
return ""
|
|
1007
|
+
try:
|
|
1008
|
+
data = json.loads(raw)
|
|
1009
|
+
except json.JSONDecodeError:
|
|
1010
|
+
return raw.strip()
|
|
1011
|
+
return str(data.get("text", "")).strip()
|
|
1012
|
+
|
|
1013
|
+
def _is_allowed_user(self, open_id: str) -> bool:
|
|
1014
|
+
if self._config.allow_all_users:
|
|
1015
|
+
return True
|
|
1016
|
+
if not self._config.allowed_open_ids:
|
|
1017
|
+
return False
|
|
1018
|
+
return open_id in self._config.allowed_open_ids
|
|
1019
|
+
|
|
1020
|
+
def _is_group_bot_mention(self, mentions: Optional[list]) -> bool:
|
|
1021
|
+
return bool(mentions)
|
|
1022
|
+
|
|
1023
|
+
def _strip_mentions(self, text: str, mentions: Optional[list]) -> str:
|
|
1024
|
+
cleaned = text
|
|
1025
|
+
for mention in mentions or []:
|
|
1026
|
+
key = getattr(mention, "key", None)
|
|
1027
|
+
name = getattr(mention, "name", None)
|
|
1028
|
+
if key:
|
|
1029
|
+
cleaned = cleaned.replace(str(key), " ")
|
|
1030
|
+
if name:
|
|
1031
|
+
cleaned = cleaned.replace(f"@{name}", " ")
|
|
1032
|
+
return " ".join(cleaned.split())
|
|
1033
|
+
|
|
1034
|
+
def _ensure_worker(self, worker_id: str, chat_id: str, chat_type: str) -> WorkerRuntime:
|
|
1035
|
+
self._worker_store.ensure_worker(worker_id)
|
|
1036
|
+
cwd = self._worker_store.cwd_for(worker_id)
|
|
1037
|
+
if not cwd:
|
|
1038
|
+
raise RuntimeError("当前 worker 还没有配置 cwd。先执行 /poco cwd <path>。")
|
|
1039
|
+
validation_error = self._validate_cwd(cwd)
|
|
1040
|
+
if validation_error:
|
|
1041
|
+
raise RuntimeError(f"当前 worker 的 cwd 无效:{validation_error}")
|
|
1042
|
+
with self._lock:
|
|
1043
|
+
existing = self._workers.get(worker_id)
|
|
1044
|
+
if existing is not None:
|
|
1045
|
+
return existing
|
|
1046
|
+
codex = AppServerClient(self._config, cwd)
|
|
1047
|
+
codex.start()
|
|
1048
|
+
worker = WorkerRuntime(
|
|
1049
|
+
worker_id=worker_id,
|
|
1050
|
+
chat_id=chat_id,
|
|
1051
|
+
chat_type=chat_type,
|
|
1052
|
+
cwd=cwd,
|
|
1053
|
+
codex=codex,
|
|
1054
|
+
)
|
|
1055
|
+
self._workers[worker_id] = worker
|
|
1056
|
+
threading.Thread(
|
|
1057
|
+
target=self._notification_loop,
|
|
1058
|
+
args=(worker_id, codex),
|
|
1059
|
+
daemon=True,
|
|
1060
|
+
name=f"poco-worker-{worker_id[:8]}",
|
|
1061
|
+
).start()
|
|
1062
|
+
LOG.info("Started worker %s for chat_id=%s chat_type=%s", worker_id, chat_id, chat_type)
|
|
1063
|
+
return worker
|
|
1064
|
+
|
|
1065
|
+
def _get_worker(self, worker_id: str) -> Optional[WorkerRuntime]:
|
|
1066
|
+
with self._lock:
|
|
1067
|
+
return self._workers.get(worker_id)
|
|
1068
|
+
|
|
1069
|
+
def _remove_worker(self, worker_id: str) -> None:
|
|
1070
|
+
with self._lock:
|
|
1071
|
+
session = self._active_by_worker.get(worker_id)
|
|
1072
|
+
if session is not None:
|
|
1073
|
+
raise RuntimeError("该 worker 当前还有一轮在进行中,先等待完成或使用 /poco stop。")
|
|
1074
|
+
worker = self._workers.pop(worker_id, None)
|
|
1075
|
+
self._store.clear(worker_id)
|
|
1076
|
+
self._worker_store.remove(worker_id)
|
|
1077
|
+
if worker is not None:
|
|
1078
|
+
worker.codex.close()
|
|
1079
|
+
LOG.info("Removed worker %s", worker_id)
|
|
1080
|
+
|
|
1081
|
+
def _send_group_setup_prompt(self, chat_id: str, worker_id: str) -> None:
|
|
1082
|
+
if self._worker_store.onboarding_sent_for(worker_id):
|
|
1083
|
+
self._safe_send(chat_id, "[bridge] 当前群还没启用。请先执行 /poco help 查看初始化步骤。")
|
|
1084
|
+
return
|
|
1085
|
+
self._worker_store.mark_onboarding_sent(worker_id)
|
|
1086
|
+
self._safe_send(
|
|
1087
|
+
chat_id,
|
|
1088
|
+
(
|
|
1089
|
+
"[bridge] 我已加入这个群,但当前还没启用。\n"
|
|
1090
|
+
"请先完成初始化:\n"
|
|
1091
|
+
"1. /poco name <alias> 给当前项目 worker 取个名字\n"
|
|
1092
|
+
"2. /poco cwd <path> 设置当前项目工作目录\n"
|
|
1093
|
+
"3. /poco mode <mention|auto> 设置群模式\n"
|
|
1094
|
+
"4. /poco enable 启用当前群\n"
|
|
1095
|
+
"在启用前,我不会把群消息发给 Codex。"
|
|
1096
|
+
),
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
def _resolve_worker_id(self, identifier: str) -> Optional[str]:
|
|
1100
|
+
return self._worker_store.resolve(identifier)
|
|
1101
|
+
|
|
1102
|
+
def _display_worker_name(self, worker_id: str) -> str:
|
|
1103
|
+
alias = self._worker_store.alias_for(worker_id)
|
|
1104
|
+
return alias or worker_id
|
|
1105
|
+
|
|
1106
|
+
def _normalize_alias(self, alias: str) -> Optional[str]:
|
|
1107
|
+
value = alias.strip().lower()
|
|
1108
|
+
if len(value) < 2 or len(value) > 32:
|
|
1109
|
+
return None
|
|
1110
|
+
for char in value:
|
|
1111
|
+
if not (char.islower() or char.isdigit() or char in {"-", "_"}):
|
|
1112
|
+
return None
|
|
1113
|
+
return value
|
|
1114
|
+
|
|
1115
|
+
def _validate_cwd(self, cwd: str) -> Optional[str]:
|
|
1116
|
+
path = Path(cwd).expanduser()
|
|
1117
|
+
if not path.exists():
|
|
1118
|
+
return "路径不存在"
|
|
1119
|
+
if not path.is_dir():
|
|
1120
|
+
return "路径不是目录"
|
|
1121
|
+
if not os.access(path, os.R_OK | os.X_OK):
|
|
1122
|
+
return "当前进程没有访问权限"
|
|
1123
|
+
return None
|
|
1124
|
+
|
|
1125
|
+
def _workers_text(self) -> str:
|
|
1126
|
+
thread_items = dict(self._store.items())
|
|
1127
|
+
alias_items = dict(self._worker_store.items())
|
|
1128
|
+
known_worker_ids = sorted(set(thread_items) | set(alias_items) | set(self._workers))
|
|
1129
|
+
if not known_worker_ids:
|
|
1130
|
+
return "[bridge] 当前还没有项目 worker。把 bot 拉进项目群后,在群里 @bot 发消息即可自动创建。"
|
|
1131
|
+
lines = ["[bridge] 当前 workers:"]
|
|
1132
|
+
with self._lock:
|
|
1133
|
+
active_by_worker = {key: session.turn_id for key, session in self._active_by_worker.items()}
|
|
1134
|
+
known_workers = set(self._workers)
|
|
1135
|
+
for worker_id in known_worker_ids:
|
|
1136
|
+
alias = alias_items.get(worker_id, "")
|
|
1137
|
+
thread_id = thread_items.get(worker_id, "(none)")
|
|
1138
|
+
running = worker_id in known_workers
|
|
1139
|
+
active_turn = active_by_worker.get(worker_id, "(none)")
|
|
1140
|
+
enabled = self._worker_store.enabled_for(worker_id)
|
|
1141
|
+
mode = self._worker_store.mode_for(worker_id)
|
|
1142
|
+
cwd = self._worker_store.cwd_for(worker_id)
|
|
1143
|
+
lines.append(
|
|
1144
|
+
f"- {alias or worker_id}\n"
|
|
1145
|
+
f" worker_id={worker_id}\n"
|
|
1146
|
+
f" enabled={enabled}\n"
|
|
1147
|
+
f" mode={mode}\n"
|
|
1148
|
+
f" cwd={cwd or '(unset)'}\n"
|
|
1149
|
+
f" thread_id={thread_id}\n"
|
|
1150
|
+
f" process_running={running}\n"
|
|
1151
|
+
f" active_turn={active_turn}"
|
|
1152
|
+
)
|
|
1153
|
+
return "\n".join(lines)
|
|
1154
|
+
|
|
1155
|
+
def _worker_status_text(self, worker_id: Optional[str], *, requested: Optional[str] = None) -> str:
|
|
1156
|
+
if worker_id is None:
|
|
1157
|
+
target = requested or "(unknown)"
|
|
1158
|
+
return f"[bridge] worker {target} 不存在。"
|
|
1159
|
+
thread_id = self._store.get(worker_id)
|
|
1160
|
+
worker = self._get_worker(worker_id)
|
|
1161
|
+
with self._lock:
|
|
1162
|
+
active = self._active_by_worker.get(worker_id)
|
|
1163
|
+
active_turn = active.turn_id if active is not None else "(none)"
|
|
1164
|
+
alias = self._worker_store.alias_for(worker_id)
|
|
1165
|
+
enabled = self._worker_store.enabled_for(worker_id)
|
|
1166
|
+
mode = self._worker_store.mode_for(worker_id)
|
|
1167
|
+
cwd = self._worker_store.cwd_for(worker_id)
|
|
1168
|
+
if thread_id is None and worker is None and active is None and not alias and not enabled and not cwd:
|
|
1169
|
+
return f"[bridge] worker {worker_id} 不存在。"
|
|
1170
|
+
return (
|
|
1171
|
+
f"[bridge] worker: {alias or worker_id}\n"
|
|
1172
|
+
f"worker_id: {worker_id}\n"
|
|
1173
|
+
f"enabled: {enabled}\n"
|
|
1174
|
+
f"mode: {mode}\n"
|
|
1175
|
+
f"cwd: {cwd or '(unset)'}\n"
|
|
1176
|
+
f"thread_id: {thread_id or '(none)'}\n"
|
|
1177
|
+
f"process_running: {worker is not None}\n"
|
|
1178
|
+
f"active_turn: {active_turn}"
|
|
1179
|
+
)
|
|
1180
|
+
|
|
1181
|
+
def _safe_send(self, chat_id: str, text: str) -> None:
|
|
1182
|
+
try:
|
|
1183
|
+
self._messenger.send_text(chat_id, text)
|
|
1184
|
+
except Exception:
|
|
1185
|
+
LOG.exception("Failed to send Feishu message")
|
|
1186
|
+
|
|
1187
|
+
@staticmethod
|
|
1188
|
+
def _group_help_text() -> str:
|
|
1189
|
+
return (
|
|
1190
|
+
"[bridge] 这是项目工作区模式。\n"
|
|
1191
|
+
"当前群支持两种模式:mention 和 auto。\n"
|
|
1192
|
+
"/poco help 查看帮助\n"
|
|
1193
|
+
"/poco cwd <path> 设置当前项目工作目录\n"
|
|
1194
|
+
"/poco mode <mention|auto> 设置群模式\n"
|
|
1195
|
+
"/poco enable 启用当前群\n"
|
|
1196
|
+
"/poco disable 停用当前群\n"
|
|
1197
|
+
"/poco reset 清空当前项目群绑定的 Codex 会话\n"
|
|
1198
|
+
"/poco new 新建会话(等同 reset)\n"
|
|
1199
|
+
"/poco name <alias> 给当前项目 worker 取别名\n"
|
|
1200
|
+
"/poco unname 清空当前项目 worker 的别名\n"
|
|
1201
|
+
"/poco status 查看当前 worker 的状态\n"
|
|
1202
|
+
"/poco stop 停止当前回复\n"
|
|
1203
|
+
"/poco remove 移除当前项目 worker\n"
|
|
1204
|
+
"启用后:\n"
|
|
1205
|
+
"- mention 模式:只有 @bot 的消息会发给 Codex\n"
|
|
1206
|
+
"- auto 模式:群里后续所有新消息都会发给 Codex\n"
|
|
1207
|
+
"在启用前,普通群消息会被 bridge 拦截。"
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
@staticmethod
|
|
1211
|
+
def _dm_help_text() -> str:
|
|
1212
|
+
return (
|
|
1213
|
+
"[bridge] 这是单聊管理控制台。\n"
|
|
1214
|
+
"/poco help 查看帮助\n"
|
|
1215
|
+
"/poco workers 或 /poco list 列出当前所有项目 worker\n"
|
|
1216
|
+
"/poco status <worker_alias|group_chat_id> 查看某个 worker 状态\n"
|
|
1217
|
+
"/poco stop <worker_alias|group_chat_id> 停止某个 worker 当前回复\n"
|
|
1218
|
+
"/poco reset <worker_alias|group_chat_id> 清空某个 worker 的会话\n"
|
|
1219
|
+
"/poco remove <worker_alias|group_chat_id> 移除某个 worker\n"
|
|
1220
|
+
"把 bot 拉进项目群后,在群里 @bot 发消息即可创建独立 worker。"
|
|
1221
|
+
)
|