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.
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
+ )