ai_co_discuss 0.3.46 → 0.3.49

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.
Files changed (43) hide show
  1. package/.agents/skills/discuss-host/SKILL.md +1 -0
  2. package/.agents/skills/discuss-host/references/discussion.md +113 -113
  3. package/.agents/skills/discuss-host/references/free.md +36 -34
  4. package/.agents/skills/discuss-host/references/goal.md +35 -0
  5. package/.agents/skills/discuss-participant/SKILL.md +1 -0
  6. package/.agents/skills/workflow-creator/SKILL.md +2 -2
  7. package/README.md +3 -1
  8. package/backend/admin.py +104 -35
  9. package/backend/free_room.py +2170 -2136
  10. package/backend/goal_room.py +358 -0
  11. package/backend/mode_room.py +105 -0
  12. package/backend/schema.sql +1593 -1510
  13. package/backend/server.py +2636 -2555
  14. package/backend/session_room.py +1180 -1148
  15. package/docs/THIRD_PARTY_NOTICES.md +1293 -1264
  16. package/docs/deployment.md +5 -1
  17. package/docs/en/workflow.md +15 -1
  18. package/docs/runner.md +28 -7
  19. package/docs/workflow.md +15 -1
  20. package/package.json +3 -2
  21. package/runner/adapters/api.mjs +333 -313
  22. package/runner/adapters/claude.mjs +12 -16
  23. package/runner/admin_account.mjs +12 -19
  24. package/runner/device_auth.mjs +369 -0
  25. package/runner/discuss.py +1208 -1125
  26. package/runner/discussion_launch.mjs +107 -12
  27. package/runner/discussion_service.mjs +25 -1
  28. package/runner/expert_prompts.mjs +132 -132
  29. package/runner/free_runner.mjs +982 -910
  30. package/runner/pinyin_table.mjs +406 -0
  31. package/runner/project_files.mjs +52 -3
  32. package/runner/relay_channel.mjs +51 -7
  33. package/runner/remote_launch.mjs +221 -235
  34. package/runner/remote_policy.mjs +156 -0
  35. package/runner/scheduler.mjs +459 -429
  36. package/runner/stt.mjs +2 -2
  37. package/runner/tts.mjs +74 -113
  38. package/runner/tts_providers.mjs +205 -0
  39. package/runner/webauthn.mjs +155 -0
  40. package/runner/workflows.mjs +371 -348
  41. package/web/app.js +355 -51
  42. package/web/modern.css +1 -1
  43. package/web/modern.js +5764 -3816
@@ -0,0 +1,358 @@
1
+ """Goal mode: a user-set objective the host keeps pursuing; when the room goes idle the runner wakes the host again."""
2
+
3
+ import hashlib
4
+ import json
5
+ import time
6
+
7
+ from .free_room import insert_request, record_cancel_reasons, require_member
8
+ from .runtime import begin_write, require_owner
9
+
10
+ OPEN_STATUSES = ("active", "paused")
11
+ MAX_OBJECTIVE_LENGTH = 2000
12
+ MAX_NOTE_LENGTH = 1000
13
+ MAX_CONTINUES = 1000
14
+ MAX_MINUTES = 7 * 24 * 60
15
+ # The same wake-up failing this many times in a row stops the goal instead of retrying forever.
16
+ FAILED_CONTINUATIONS = 3
17
+ GOAL_AUTHOR = "系统"
18
+ END_LABELS = {"complete": "Goal 已完成", "blocked": "Goal 已停止:卡住", "budget_limited": "Goal 预算已用完:请主持人收尾,总结进展、剩余工作和下一步",
19
+ "cleared": "Goal 已结束"}
20
+
21
+
22
+ def open_goal(connection, room_id):
23
+ """The room's goal while it is active or paused."""
24
+ row = connection.execute("SELECT * FROM room_goals WHERE room_id=? AND status IN ('active','paused') ORDER BY id DESC LIMIT 1",
25
+ (room_id,)).fetchone()
26
+ return dict(row) if row else None
27
+
28
+
29
+ def goal_host(connection, room_id):
30
+ """The host the runner can wake; a goal has no meaning without one."""
31
+ row = connection.execute("SELECT * FROM room_members WHERE room_id=? AND role='host' AND status='active' AND access='runner'",
32
+ (room_id,)).fetchone()
33
+ if not row:
34
+ raise ValueError("Goal 需要一位由本机唤醒的 AI 主持人")
35
+ return dict(row)
36
+
37
+
38
+ def check_exclusive(connection, room_id):
39
+ """Goal and discussion never run at the same time."""
40
+ if connection.execute("SELECT 1 FROM discussion_sessions WHERE room_id=? AND status IN ('active','paused')", (room_id,)).fetchone():
41
+ raise FileExistsError("讨论进行中;结束讨论后才能开启 Goal")
42
+ if open_goal(connection, room_id):
43
+ raise FileExistsError("Goal 已在进行;可用 /goal pause、/goal resume、/goal clear 或 /goal edit 新目标")
44
+
45
+
46
+ def clean_objective(value):
47
+ """An objective is required, single text of bounded length."""
48
+ objective = str(value or "").strip()
49
+ if not objective or len(objective) > MAX_OBJECTIVE_LENGTH:
50
+ raise ValueError(f"Goal 目标须为 1–{MAX_OBJECTIVE_LENGTH} 字")
51
+ return objective
52
+
53
+
54
+ def clean_budget(payload, current=None):
55
+ """Optional budgets set by the owner: whole numbers, 0 meaning no limit."""
56
+ result = {}
57
+ for field, limit in (("max_continues", MAX_CONTINUES), ("max_minutes", MAX_MINUTES)):
58
+ value = payload.get(field, (current or {}).get(field, 0))
59
+ if type(value) is not int or not 0 <= value <= limit:
60
+ raise ValueError(f"{field} 须为 0–{limit} 的整数,0 表示不限")
61
+ result[field] = value
62
+ return result
63
+
64
+
65
+ def elapsed_seconds(goal, now=None):
66
+ """Time spent pursuing the goal, excluding pauses."""
67
+ now = now or int(time.time())
68
+ end = goal["ended_at"] or now
69
+ paused = goal["paused_seconds"] + (max(0, end - goal["paused_at"]) if goal["paused_at"] else 0)
70
+ return max(0, end - goal["started_at"] - paused)
71
+
72
+
73
+ def continuation_statuses(connection, goal_id, limit=FAILED_CONTINUATIONS):
74
+ """Statuses of the goal's latest wake-up requests, newest first."""
75
+ return [row[0] for row in connection.execute("""SELECT r.status FROM goal_continuations c JOIN member_requests r ON r.id=c.request_id
76
+ WHERE c.goal_id=? ORDER BY c.id DESC LIMIT ?""", (goal_id, limit))]
77
+
78
+
79
+ def room_idle(connection, room_id, goal):
80
+ """Whether an active goal should wake the host: nothing runs, waits or is held, and the owner did not speak last."""
81
+ if not goal or goal["status"] != "active":
82
+ return False
83
+ busy = connection.execute("""SELECT
84
+ EXISTS(SELECT 1 FROM member_requests WHERE room_id=?1 AND status IN ('pending','delivering'))
85
+ OR EXISTS(SELECT 1 FROM member_executions WHERE room_id=?1 AND status IN ('dispatched','started'))
86
+ OR EXISTS(SELECT 1 FROM free_chain_holds WHERE room_id=?1 AND message_id>0)
87
+ OR EXISTS(SELECT 1 FROM free_queue_waiting WHERE room_id=?1)
88
+ OR EXISTS(SELECT 1 FROM queued_messages WHERE room_id=?1)""", (room_id,)).fetchone()[0]
89
+ if busy:
90
+ return False
91
+ last = connection.execute("""SELECT author FROM messages m WHERE room_id=? AND NOT EXISTS(
92
+ SELECT 1 FROM internal_task_messages t WHERE t.message_id=m.id) ORDER BY id DESC LIMIT 1""", (room_id,)).fetchone()
93
+ if last and last[0] == "用户":
94
+ return False
95
+ # A wake-up that failed did no work, so the next idle moment may try again until the failure limit stops the goal.
96
+ return bool(goal["armed"]) or continuation_statuses(connection, goal["id"], 1) == ["failed"]
97
+
98
+
99
+ def goal_revision(connection, room_id, goal):
100
+ """Bind state-dependent goal writes to the goal row, the newest message and the room's open work."""
101
+ latest = connection.execute("SELECT COALESCE(MAX(id),0) FROM messages WHERE room_id=?", (room_id,)).fetchone()[0]
102
+ work = [list(row) for row in connection.execute(
103
+ "SELECT id,status FROM member_requests WHERE room_id=? AND status IN ('pending','delivering') ORDER BY id", (room_id,))]
104
+ session = connection.execute("SELECT id,status FROM discussion_sessions WHERE room_id=? ORDER BY id DESC LIMIT 1", (room_id,)).fetchone()
105
+ row = [goal[key] for key in ("id", "status", "objective", "armed", "continues", "max_continues", "max_minutes")] if goal else None
106
+ encoded = json.dumps([room_id, row, latest, work, list(session) if session else None], ensure_ascii=False, separators=(",", ":"))
107
+ return hashlib.sha256(encoded.encode()).hexdigest()[:16]
108
+
109
+
110
+ def goal_view(connection, room_id):
111
+ """The room's latest goal (open or ended) with usage, idle state and the revision its writes must carry."""
112
+ row = connection.execute("SELECT * FROM room_goals WHERE room_id=? ORDER BY id DESC LIMIT 1", (room_id,)).fetchone()
113
+ goal = dict(row) if row else None
114
+ open_row = goal if goal and goal["status"] in OPEN_STATUSES else None
115
+ if goal:
116
+ goal["elapsed_seconds"] = elapsed_seconds(goal)
117
+ goal["idle"] = room_idle(connection, room_id, goal)
118
+ return {"goal": goal, "revision": goal_revision(connection, room_id, open_row)}
119
+
120
+
121
+ def require_revision(connection, room_id, payload, action):
122
+ """State-dependent writes carry the revision read from GET goal."""
123
+ if payload.get("revision") is None:
124
+ raise ValueError(f"{action}前须先读取 Goal 状态(GET goal)并带上 revision")
125
+ if str(payload["revision"]) != goal_view(connection, room_id)["revision"]:
126
+ raise FileExistsError("Goal 或房间状态已变化,请重新读取后再提交")
127
+
128
+
129
+ def system_message(connection, room_id, text, key):
130
+ """A visible goal notice in the transcript; requests created by the goal hang on it."""
131
+ created_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
132
+ cursor = connection.execute("INSERT INTO messages(room_id,author,text,source,submission_id,created_at) VALUES(?,?,?,?,?,?)",
133
+ (room_id, GOAL_AUTHOR, text, "goal", key, created_at))
134
+ connection.execute("INSERT INTO message_details (message_id,kind,submitted_by) VALUES (?,?,?)", (cursor.lastrowid, "goal", GOAL_AUTHOR))
135
+ return cursor.lastrowid
136
+
137
+
138
+ def create_goal(connection, room_id, objective, budget, start_request_id=None):
139
+ """Open a goal; callers have checked exclusivity and the host.
140
+
141
+ A goal opened from the page gets a start notice. One opened from a `/goal` command starts at that message instead:
142
+ the host's reply sits right under the command, so a separate notice would land after the reply.
143
+ """
144
+ now = int(time.time())
145
+ cursor = connection.execute("""INSERT INTO room_goals (room_id,objective,max_continues,max_minutes,start_request_id,started_at)
146
+ VALUES (?,?,?,?,?,?)""", (room_id, objective, budget["max_continues"], budget["max_minutes"], start_request_id, now))
147
+ goal_id = cursor.lastrowid
148
+ if start_request_id is not None:
149
+ message_id = connection.execute("SELECT message_id FROM member_requests WHERE id=?", (start_request_id,)).fetchone()[0]
150
+ else:
151
+ message_id = system_message(connection, room_id, f"Goal 已开启:{objective}", f"goal-{goal_id}-start")
152
+ connection.execute("UPDATE room_goals SET started_message_id=? WHERE id=?", (message_id, goal_id))
153
+ return goal_id
154
+
155
+
156
+ def end_goal(connection, goal, status, note=""):
157
+ """Close the goal with its reason and leave an end notice; the owner clearing it also cancels wake-ups not yet started.
158
+
159
+ A spent budget keeps its wrap-up request, and the host ending the goal is itself answering one.
160
+ """
161
+ now = int(time.time())
162
+ paused = max(0, now - goal["paused_at"]) if goal["paused_at"] else 0
163
+ label = END_LABELS[status] + (f":{note}" if note else "")
164
+ message_id = system_message(connection, goal["room_id"], label, f"goal-{goal['id']}-end")
165
+ connection.execute("""UPDATE room_goals SET status=?,ended_at=?,end_note=?,ended_message_id=?,paused_at=0,paused_seconds=paused_seconds+?
166
+ WHERE id=?""", (status, now, note, message_id, paused, goal["id"]))
167
+ if status == "cleared":
168
+ where = "status='pending' AND id IN (SELECT request_id FROM goal_continuations WHERE goal_id=?)"
169
+ record_cancel_reasons(connection, where, (goal["id"],), "user_stop")
170
+ connection.execute(f"UPDATE member_requests SET status='cancelled' WHERE {where}", (goal["id"],))
171
+
172
+
173
+ def check_goal_command(connection, room_id, command, argument):
174
+ """Validate an owner's `/goal` command before its message is saved, so a refused command leaves no message behind."""
175
+ goal = open_goal(connection, room_id)
176
+ if command == "start":
177
+ check_exclusive(connection, room_id)
178
+ goal_host(connection, room_id)
179
+ if len(argument) > MAX_OBJECTIVE_LENGTH:
180
+ raise ValueError(f"Goal 目标最多 {MAX_OBJECTIVE_LENGTH} 字")
181
+ elif not goal:
182
+ raise FileExistsError("房间没有进行中的 Goal")
183
+ elif command == "pause" and goal["status"] != "active":
184
+ raise FileExistsError("Goal 已经暂停")
185
+ elif command == "resume" and goal["status"] != "paused":
186
+ raise FileExistsError("Goal 当前没有暂停")
187
+ elif command == "edit":
188
+ clean_objective(argument)
189
+
190
+
191
+ def apply_goal_command(connection, room_id, message_id, command, argument):
192
+ """Run a saved `/goal` message: a start asks the host to fill in the objective, the rest act on the open goal."""
193
+ if command == "start":
194
+ host = goal_host(connection, room_id)
195
+ insert_request(connection, room_id, host["id"], message_id, message_id, "goal_start", "user")
196
+ return
197
+ goal = open_goal(connection, room_id)
198
+ if command == "edit":
199
+ update_goal(connection, goal, {"objective": argument})
200
+ else:
201
+ control_goal(connection, goal, command)
202
+
203
+
204
+ def control_goal(connection, goal, action):
205
+ """Owner controls on the open goal: pause, resume or clear."""
206
+ now = int(time.time())
207
+ if action == "pause":
208
+ if goal["status"] != "active":
209
+ raise FileExistsError("Goal 已经暂停")
210
+ connection.execute("UPDATE room_goals SET status='paused',paused_at=? WHERE id=?", (now, goal["id"]))
211
+ elif action == "resume":
212
+ if goal["status"] != "paused":
213
+ raise FileExistsError("Goal 当前没有暂停")
214
+ connection.execute("""UPDATE room_goals SET status='active',armed=1,paused_seconds=paused_seconds+MAX(0,?-paused_at),paused_at=0
215
+ WHERE id=?""", (now, goal["id"]))
216
+ elif action == "clear":
217
+ end_goal(connection, goal, "cleared", "用户结束")
218
+ else:
219
+ raise ValueError("action 须为 start、continue、wait、done、block、pause、resume、clear 或 update")
220
+
221
+
222
+ def update_goal(connection, goal, payload):
223
+ """Owner edits of the open goal's objective or budgets."""
224
+ objective = clean_objective(payload["objective"]) if "objective" in payload else goal["objective"]
225
+ budget = clean_budget(payload, goal)
226
+ connection.execute("UPDATE room_goals SET objective=?,max_continues=?,max_minutes=? WHERE id=?",
227
+ (objective, budget["max_continues"], budget["max_minutes"], goal["id"]))
228
+
229
+
230
+ def start_goal(connection, room_id, token, payload):
231
+ """Open a goal: the host completing a `/goal` start request, or the owner from the page with the revision it read."""
232
+ begin_write(connection)
233
+ objective = clean_objective(payload.get("objective"))
234
+ request_id = payload.get("request_id")
235
+ if request_id is not None:
236
+ member = require_member(connection, room_id, token)
237
+ if member["role"] != "host":
238
+ raise PermissionError("只有主持人能补全并开启 Goal")
239
+ existing = connection.execute("SELECT id FROM room_goals WHERE start_request_id=?", (request_id,)).fetchone()
240
+ if existing:
241
+ return {**goal_view(connection, room_id), "duplicate": True}
242
+ request = connection.execute("SELECT * FROM member_requests WHERE room_id=? AND id=?", (room_id, request_id)).fetchone()
243
+ if (not request or request["purpose"] != "goal_start" or request["member_id"] != member["id"]
244
+ or request["status"] not in ("pending", "delivering")):
245
+ raise FileExistsError("Goal 启动请求不存在、不属于你或已处理;只有用户的 /goal 命令能开启 Goal")
246
+ budget = clean_budget({})
247
+ else:
248
+ require_owner(connection, room_id, token)
249
+ require_revision(connection, room_id, payload, "开启 Goal")
250
+ budget = clean_budget(payload)
251
+ check_exclusive(connection, room_id)
252
+ host = goal_host(connection, room_id)
253
+ goal_id = create_goal(connection, room_id, objective, budget, request_id)
254
+ if request_id is None:
255
+ # Started from the page, nobody is working on it yet: hand it to the host right away instead of waiting for idle.
256
+ message_id = connection.execute("SELECT started_message_id FROM room_goals WHERE id=?", (goal_id,)).fetchone()[0]
257
+ insert_request(connection, room_id, host["id"], message_id, message_id, "goal_continue", "user")
258
+ request = connection.execute("SELECT id FROM member_requests WHERE message_id=? AND member_id=?", (message_id, host["id"])).fetchone()[0]
259
+ connection.execute("INSERT INTO goal_continuations (goal_id,room_id,message_id,request_id) VALUES (?,?,?,?)",
260
+ (goal_id, room_id, message_id, request))
261
+ connection.execute("UPDATE room_goals SET armed=0 WHERE id=?", (goal_id,))
262
+ return {**goal_view(connection, room_id), "duplicate": False}
263
+
264
+
265
+ def continue_goal(connection, room_id, token, payload):
266
+ """The runner wakes the host once for an idle room; budget and repeated failures end the goal instead."""
267
+ require_owner(connection, room_id, token)
268
+ begin_write(connection)
269
+ require_revision(connection, room_id, payload, "Goal 续跑")
270
+ goal = open_goal(connection, room_id)
271
+ if not goal or not room_idle(connection, room_id, goal):
272
+ raise FileExistsError("Goal 当前不需要续跑")
273
+ host = goal_host(connection, room_id)
274
+ if continuation_statuses(connection, goal["id"]) == ["failed"] * FAILED_CONTINUATIONS:
275
+ end_goal(connection, goal, "blocked", f"连续 {FAILED_CONTINUATIONS} 次续跑执行失败")
276
+ return goal_view(connection, room_id)
277
+ over = (goal["max_continues"] and goal["continues"] >= goal["max_continues"]
278
+ or goal["max_minutes"] and elapsed_seconds(goal) >= goal["max_minutes"] * 60)
279
+ number = goal["continues"] + 1
280
+ if over:
281
+ # The end notice is also where the host's one wrap-up request hangs, so the transcript shows a single card.
282
+ end_goal(connection, goal, "budget_limited", "")
283
+ message_id = connection.execute("SELECT ended_message_id FROM room_goals WHERE id=?", (goal["id"],)).fetchone()[0]
284
+ else:
285
+ message_id = system_message(connection, room_id, f"Goal 续跑 #{number}:房间已空闲,请主持人继续推进目标。",
286
+ f"goal-{goal['id']}-continue-{number}")
287
+ insert_request(connection, room_id, host["id"], message_id, message_id, "goal_continue", "user")
288
+ request_id = connection.execute("SELECT id FROM member_requests WHERE message_id=? AND member_id=?", (message_id, host["id"])).fetchone()[0]
289
+ connection.execute("INSERT INTO goal_continuations (goal_id,room_id,message_id,request_id,wrapup) VALUES (?,?,?,?,?)",
290
+ (goal["id"], room_id, message_id, request_id, int(bool(over))))
291
+ if not over:
292
+ connection.execute("UPDATE room_goals SET continues=?,armed=0 WHERE id=?", (number, goal["id"]))
293
+ return goal_view(connection, room_id)
294
+
295
+
296
+ def wait_goal(connection, room_id, token, payload):
297
+ """The runner's decision model judged the idle room to be waiting for the user: stand down until new work arrives.
298
+
299
+ Only an armed goal stands down; an idle moment after a failed wake-up keeps retrying toward the failure limit.
300
+ """
301
+ require_owner(connection, room_id, token)
302
+ begin_write(connection)
303
+ require_revision(connection, room_id, payload, "Goal 等待用户")
304
+ goal = open_goal(connection, room_id)
305
+ if not goal or not goal["armed"] or not room_idle(connection, room_id, goal):
306
+ raise FileExistsError("Goal 当前不在等待唤醒")
307
+ connection.execute("UPDATE room_goals SET armed=0 WHERE id=?", (goal["id"],))
308
+ return goal_view(connection, room_id)
309
+
310
+
311
+ def finish_goal(connection, room_id, token, payload, status):
312
+ """The host declares the goal complete (verified against the current state) or blocked."""
313
+ begin_write(connection)
314
+ member = require_member(connection, room_id, token)
315
+ if member["role"] != "host":
316
+ raise PermissionError("只有主持人能宣告 Goal 完成或卡住")
317
+ require_revision(connection, room_id, payload, "宣告 Goal 完成" if status == "complete" else "宣告 Goal 卡住")
318
+ goal = open_goal(connection, room_id)
319
+ if not goal:
320
+ raise FileExistsError("房间没有进行中的 Goal")
321
+ note = str(payload.get("note") or "").strip()
322
+ if len(note) > MAX_NOTE_LENGTH or status == "blocked" and not note:
323
+ raise ValueError(f"说明须为 {'1' if status == 'blocked' else '0'}–{MAX_NOTE_LENGTH} 字;宣告卡住须写明阻碍")
324
+ if status == "complete":
325
+ others = [row[0] for row in connection.execute("""SELECT DISTINCT m.name FROM member_requests r JOIN room_members m ON m.id=r.member_id
326
+ WHERE r.room_id=? AND r.status IN ('pending','delivering') AND r.member_id<>? ORDER BY m.name""", (room_id, member["id"]))]
327
+ if others:
328
+ raise ValueError(f"仍有成员的请求未结束:{'、'.join(others)};等它们完成或取消后再宣告完成")
329
+ end_goal(connection, goal, status, note)
330
+ return goal_view(connection, room_id)
331
+
332
+
333
+ def goal_action(connection, room_id, token, payload):
334
+ """Dispatch POST /goal by action; owner controls need the room credential, the host's declarations its own."""
335
+ action = payload.get("action")
336
+ if action == "start":
337
+ return start_goal(connection, room_id, token, payload)
338
+ if action == "continue":
339
+ return continue_goal(connection, room_id, token, payload)
340
+ if action == "wait":
341
+ return wait_goal(connection, room_id, token, payload)
342
+ if action in ("done", "block"):
343
+ return finish_goal(connection, room_id, token, payload, "complete" if action == "done" else "blocked")
344
+ require_owner(connection, room_id, token)
345
+ begin_write(connection)
346
+ goal = open_goal(connection, room_id)
347
+ if not goal:
348
+ raise FileExistsError("房间没有进行中的 Goal")
349
+ if action == "update":
350
+ update_goal(connection, goal, payload)
351
+ else:
352
+ control_goal(connection, goal, action)
353
+ return goal_view(connection, room_id)
354
+
355
+
356
+ def arm_goal(connection, room_id):
357
+ """New work (an owner message or a host dispatch) lets the next idle moment wake the host again."""
358
+ connection.execute("UPDATE room_goals SET armed=1 WHERE room_id=? AND status='active'", (room_id,))
@@ -0,0 +1,105 @@
1
+ """Room modes: the owner's `/goal` and `/discuss` commands, and one view and completion entry for whichever mode is open.
2
+
3
+ A mode is a built-in way to push the room forward (goal, discussion); at most one is open. Commands are owner
4
+ messages: a bare start asks the host to fill in the objective, subcommands act on the open mode directly.
5
+ """
6
+
7
+ import re
8
+
9
+ from . import goal_room
10
+ from .free_room import insert_request, require_member
11
+ from .runtime import begin_write
12
+ from .session_room import (conclude_session_by_host, control_session, current_session, session_conclusion_view, session_room)
13
+
14
+ MODE_COMMAND = re.compile(r"^/(goal|discuss)(?:\s+([\s\S]*))?$")
15
+ SUBCOMMANDS = {"goal": ("pause", "resume", "clear", "edit"), "discuss": ("pause", "resume", "stop")}
16
+ MAX_DISCUSSION_GOAL_LENGTH = 1000
17
+
18
+
19
+ def parse_mode_command(text):
20
+ """Split an owner's `/goal …` or `/discuss …` message into (mode, subcommand, argument); None for other text."""
21
+ match = MODE_COMMAND.match(str(text or "").strip())
22
+ if not match:
23
+ return None
24
+ mode, rest = match.group(1), (match.group(2) or "").strip()
25
+ parts = rest.split(None, 1)
26
+ if parts and parts[0] in SUBCOMMANDS[mode]:
27
+ return mode, parts[0], parts[1].strip() if len(parts) > 1 else ""
28
+ return mode, "start", rest
29
+
30
+
31
+ def check_discuss_command(connection, room_id, command, argument):
32
+ """Validate a `/discuss` command before its message is saved."""
33
+ session = current_session(connection, room_id, active_only=True)
34
+ if command == "start":
35
+ if session:
36
+ raise FileExistsError("讨论已在进行;可用 /discuss pause、/discuss resume 或 /discuss stop")
37
+ if goal_room.open_goal(connection, room_id):
38
+ raise FileExistsError("Goal 进行中;结束 Goal 后才能开启讨论")
39
+ goal_room.goal_host(connection, room_id)
40
+ if len(argument) > MAX_DISCUSSION_GOAL_LENGTH:
41
+ raise ValueError(f"讨论目标最多 {MAX_DISCUSSION_GOAL_LENGTH} 字")
42
+ elif not session:
43
+ raise FileExistsError("房间没有进行中的讨论")
44
+ elif command == "pause" and session["status"] != "active":
45
+ raise FileExistsError("讨论已经暂停")
46
+ elif command == "resume" and session["status"] != "paused":
47
+ raise FileExistsError("讨论当前没有暂停")
48
+
49
+
50
+ def check_mode_command(connection, room_id, payload):
51
+ """Parse and validate an owner message that is a mode command; None when it is ordinary text."""
52
+ parsed = parse_mode_command(payload.get("text"))
53
+ if not parsed:
54
+ return None
55
+ mode, command, argument = parsed
56
+ if payload.get("to"):
57
+ raise ValueError(f"/{mode} 命令不能同时 @ 成员")
58
+ if mode == "goal":
59
+ goal_room.check_goal_command(connection, room_id, command, argument)
60
+ else:
61
+ check_discuss_command(connection, room_id, command, argument)
62
+ return parsed
63
+
64
+
65
+ def apply_mode_command(connection, room_id, message_id, parsed):
66
+ """Run a saved mode command; the owner's message is itself the authority, so no revision is needed."""
67
+ mode, command, argument = parsed
68
+ if mode == "goal":
69
+ goal_room.apply_goal_command(connection, room_id, message_id, command, argument)
70
+ elif command == "start":
71
+ host = goal_room.goal_host(connection, room_id)
72
+ insert_request(connection, room_id, host["id"], message_id, message_id, "discuss_start", "user")
73
+ else:
74
+ # The full session view: resuming keys its host request by the session revision.
75
+ control_session(connection, session_room(connection, room_id)["session"], command)
76
+
77
+
78
+ def mode_view(connection, room_id):
79
+ """Which mode is open and the revision its completion needs: the goal's revision, or the discussion's conclusion."""
80
+ goal = goal_room.goal_view(connection, room_id)
81
+ if goal["goal"] and goal["goal"]["status"] in goal_room.OPEN_STATUSES:
82
+ return {"mode": "goal", "status": goal["goal"]["status"], "revision": goal["revision"], "goal": goal["goal"]}
83
+ session = current_session(connection, room_id, active_only=True)
84
+ if session:
85
+ conclusion = session_conclusion_view(connection, room_id)
86
+ return {"mode": "discussion", "status": session["status"], "revision": conclusion["revision"],
87
+ "discussion": {"id": session["id"], "goal": session["goal"], **conclusion}}
88
+ return {"mode": None, "status": None, "revision": None}
89
+
90
+
91
+ def finish_mode(connection, room_id, token, payload):
92
+ """The host completes whichever mode is open, with the revision `GET mode` returned."""
93
+ action = payload.get("action")
94
+ if action != "done":
95
+ raise ValueError("action 须为 done;宣告 Goal 卡住用 goal block")
96
+ begin_write(connection)
97
+ mode = mode_view(connection, room_id)
98
+ if mode["mode"] == "goal":
99
+ return goal_room.finish_goal(connection, room_id, token, payload, "complete")
100
+ if mode["mode"] == "discussion":
101
+ if require_member(connection, room_id, token)["role"] != "host":
102
+ raise PermissionError("只有主持人能宣告讨论完成")
103
+ conclude_session_by_host(connection, room_id, {"conclusion": payload.get("revision")})
104
+ return mode_view(connection, room_id)
105
+ raise FileExistsError("房间没有进行中的 Goal 或讨论")