zettcode-im 0.4.2__tar.gz → 0.4.4__tar.gz
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.
- {zettcode_im-0.4.2 → zettcode_im-0.4.4}/PKG-INFO +1 -1
- {zettcode_im-0.4.2 → zettcode_im-0.4.4}/src/zettcode/im/guard.py +7 -5
- {zettcode_im-0.4.2 → zettcode_im-0.4.4}/src/zettcode/im/weixin/cli.py +239 -19
- {zettcode_im-0.4.2 → zettcode_im-0.4.4}/.gitignore +0 -0
- {zettcode_im-0.4.2 → zettcode_im-0.4.4}/pyproject.toml +0 -0
- {zettcode_im-0.4.2 → zettcode_im-0.4.4}/src/zettcode/im/__init__.py +0 -0
- {zettcode_im-0.4.2 → zettcode_im-0.4.4}/src/zettcode/im/bridge.py +0 -0
- {zettcode_im-0.4.2 → zettcode_im-0.4.4}/src/zettcode/im/link.py +0 -0
- {zettcode_im-0.4.2 → zettcode_im-0.4.4}/src/zettcode/im/state.py +0 -0
- {zettcode_im-0.4.2 → zettcode_im-0.4.4}/src/zettcode/im/weixin/__init__.py +0 -0
- {zettcode_im-0.4.2 → zettcode_im-0.4.4}/src/zettcode/im/weixin/qr.py +0 -0
- {zettcode_im-0.4.2 → zettcode_im-0.4.4}/src/zettcode/im/weixin/worker.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: zettcode-im
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4
|
|
4
4
|
Summary: Chat bridges: a WeChat conversation that drives one ZettCode daemon, and a place for the next chat app
|
|
5
5
|
Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
|
|
6
6
|
Author: Chang-LeHung
|
|
@@ -47,8 +47,10 @@ class DaemonGuard:
|
|
|
47
47
|
workspace: The directory the daemon works in.
|
|
48
48
|
state: Where the session id lives between runs; the guard writes it when it learns one.
|
|
49
49
|
model: The model by name, or ``None`` for the first configured.
|
|
50
|
-
effort: The reasoning level, or ``None`` for the model
|
|
51
|
-
|
|
50
|
+
effort: The reasoning level, or ``None`` for the middle of the ones the model takes — providers do not all
|
|
51
|
+
accept every level, and a daemon nobody is watching should reason as much as its model will by default.
|
|
52
|
+
permission: The shell level the daemon gets: ``deny``, ``safe`` or ``full``. ``full`` by default, the same
|
|
53
|
+
one ``zettcode daemon spawn`` starts with: there is nobody to answer a sandbox's questions from a chat.
|
|
52
54
|
name: The label recorded beside the daemon, so a reader finds it in ``zettcode daemon list``.
|
|
53
55
|
waits: The waits between failed starts, in order; the last one repeats.
|
|
54
56
|
"""
|
|
@@ -57,7 +59,7 @@ class DaemonGuard:
|
|
|
57
59
|
state: BridgeState
|
|
58
60
|
model: str | None = None
|
|
59
61
|
effort: str | None = None
|
|
60
|
-
permission: str = "
|
|
62
|
+
permission: str = "full"
|
|
61
63
|
name: str = "chat bridge"
|
|
62
64
|
waits: tuple[float, ...] = ()
|
|
63
65
|
_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False)
|
|
@@ -119,12 +121,12 @@ class DaemonGuard:
|
|
|
119
121
|
|
|
120
122
|
async def _start(self, session: str | None) -> str:
|
|
121
123
|
"""Start a daemon, resuming ``session`` when there is one, and return its session id."""
|
|
122
|
-
from zettcode.sdk.daemons import spawn_daemon
|
|
124
|
+
from zettcode.sdk.daemons import default_effort, spawn_daemon
|
|
123
125
|
|
|
124
126
|
info = await spawn_daemon(
|
|
125
127
|
Path(self.workspace),
|
|
126
128
|
model=self.model,
|
|
127
|
-
effort=self.effort,
|
|
129
|
+
effort=self.effort or default_effort(self.model) or None,
|
|
128
130
|
resume=session,
|
|
129
131
|
name=self.name,
|
|
130
132
|
permission=self.permission,
|
|
@@ -47,13 +47,21 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
47
47
|
commands = parser.add_subparsers(dest="command")
|
|
48
48
|
for name, help_line in (
|
|
49
49
|
("login", "scan a QR code and start the background process (the default)"),
|
|
50
|
+
("start", "start the background process with the stored login, without scanning"),
|
|
51
|
+
("set", "change the settings the daemon is started with, and apply them now"),
|
|
50
52
|
("run", "run the bridge in this terminal (what the background process does)"),
|
|
51
53
|
("status", "say whether a bridge is running and what it is doing"),
|
|
52
54
|
("stop", "stop the background process"),
|
|
53
55
|
("logs", "print the end of the background process's log"),
|
|
54
56
|
):
|
|
55
57
|
sub = commands.add_parser(name, help=help_line, description=help_line)
|
|
56
|
-
_common(sub, login=name
|
|
58
|
+
_common(sub, login=name in ("login", "set"), run=name == "run")
|
|
59
|
+
if name == "set":
|
|
60
|
+
sub.add_argument(
|
|
61
|
+
"--keep-daemon",
|
|
62
|
+
action="store_true",
|
|
63
|
+
help="leave a running daemon alone; by default it is stopped so the new settings take effect",
|
|
64
|
+
)
|
|
57
65
|
if name == "login":
|
|
58
66
|
sub.add_argument(
|
|
59
67
|
"--login-timeout",
|
|
@@ -85,7 +93,7 @@ def _common(sub: argparse.ArgumentParser, *, login: bool, run: bool) -> None:
|
|
|
85
93
|
"--permission",
|
|
86
94
|
choices=("deny", "safe", "full"),
|
|
87
95
|
default=None,
|
|
88
|
-
help="the daemon's shell level (default: the one already stored, or
|
|
96
|
+
help="the daemon's shell level (default: the one already stored, or full)",
|
|
89
97
|
)
|
|
90
98
|
sub.add_argument(
|
|
91
99
|
"--allow",
|
|
@@ -114,6 +122,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
114
122
|
state_dir: Path = arguments.state_dir.expanduser()
|
|
115
123
|
if arguments.command == "login":
|
|
116
124
|
return asyncio.run(_login(arguments, state_dir))
|
|
125
|
+
if arguments.command == "start":
|
|
126
|
+
return _start(state_dir)
|
|
127
|
+
if arguments.command == "set":
|
|
128
|
+
return _set(arguments, state_dir)
|
|
117
129
|
if arguments.command == "run":
|
|
118
130
|
return asyncio.run(_run(arguments, state_dir))
|
|
119
131
|
if arguments.command == "status":
|
|
@@ -186,22 +198,12 @@ async def _login(arguments: argparse.Namespace, state_dir: Path) -> int:
|
|
|
186
198
|
# A login renews the connection; the settings are what the reader decided and outlive it. A flag replaces its
|
|
187
199
|
# setting, and a flag that was not given leaves the stored one alone — a re-scan that forgot `--allow` used to
|
|
188
200
|
# wipe the allow-list and turn every message into a refusal.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
state.
|
|
195
|
-
if arguments.effort is not None:
|
|
196
|
-
state.effort = arguments.effort
|
|
197
|
-
if arguments.permission is not None:
|
|
198
|
-
state.permission = arguments.permission
|
|
199
|
-
elif not state.permission:
|
|
200
|
-
state.permission = "safe"
|
|
201
|
-
if arguments.allow is not None:
|
|
202
|
-
state.allow = list(arguments.allow)
|
|
203
|
-
if arguments.allow_all is not None:
|
|
204
|
-
state.allow_all = bool(arguments.allow_all)
|
|
201
|
+
previous_workspace = state.workspace
|
|
202
|
+
previous_session = state.session_id
|
|
203
|
+
_apply_settings(state, arguments)
|
|
204
|
+
if state.workspace != previous_workspace:
|
|
205
|
+
# A session lives in one workspace: its id in another would resume a conversation that is not there.
|
|
206
|
+
state.session_id = ""
|
|
205
207
|
if not state.token():
|
|
206
208
|
# A login without a token is a bridge that cannot send or receive, and a background process that says
|
|
207
209
|
# nothing about it. Better to say so here, where the reader is still watching the phone.
|
|
@@ -216,6 +218,24 @@ async def _login(arguments: argparse.Namespace, state_dir: Path) -> int:
|
|
|
216
218
|
print("the old bridge did not stop, so the new login was not saved. Stop it by hand and run again.")
|
|
217
219
|
return 3
|
|
218
220
|
state.save()
|
|
221
|
+
if previous_session and state.workspace != previous_workspace:
|
|
222
|
+
# The daemon of the old workspace is nobody's now: the bridge will start a new one here, and leaving the old
|
|
223
|
+
# one running is a session a reader never asked to keep. Best effort: a busy one is reported, not waited on.
|
|
224
|
+
from zettcode.sdk.daemons import stop_daemon
|
|
225
|
+
from zettcode.sdk.errors import SessionNotFoundError
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
said = await stop_daemon(previous_session, keep=True)
|
|
229
|
+
except SessionNotFoundError:
|
|
230
|
+
said = "no daemon was running for the old workspace"
|
|
231
|
+
except Exception as error: # noqa: BLE001 - the login is finished either way
|
|
232
|
+
said = f"the daemon of the old workspace was left running ({error})"
|
|
233
|
+
print(f"daemon {said}")
|
|
234
|
+
elif previous_session and (arguments.model or arguments.effort or arguments.permission):
|
|
235
|
+
print(
|
|
236
|
+
"note a daemon that is already running keeps the settings it was started with; "
|
|
237
|
+
"`zettcode-weixin set` applies new ones now"
|
|
238
|
+
)
|
|
219
239
|
try:
|
|
220
240
|
pid = worker.start(command("--state-dir", str(state_dir)))
|
|
221
241
|
except RuntimeError as error:
|
|
@@ -280,7 +300,7 @@ async def _run(arguments: argparse.Namespace, state_dir: Path) -> int:
|
|
|
280
300
|
state=state,
|
|
281
301
|
model=state.model or None,
|
|
282
302
|
effort=state.effort or None,
|
|
283
|
-
permission=state.permission or "
|
|
303
|
+
permission=state.permission or "full",
|
|
284
304
|
name="weixin bridge",
|
|
285
305
|
)
|
|
286
306
|
await link.start()
|
|
@@ -327,6 +347,206 @@ def _bridge_id(state_dir: Path) -> str:
|
|
|
327
347
|
return "im-weixin-" + hashlib.sha1(str(state_dir.resolve()).encode("utf-8")).hexdigest()[:8]
|
|
328
348
|
|
|
329
349
|
|
|
350
|
+
# -- settings ------------------------------------------------------------------------------------------
|
|
351
|
+
def _apply_settings(state: object, arguments: argparse.Namespace) -> None:
|
|
352
|
+
"""Take the flags that were given and leave the ones that were not: a re-scan renews the connection, not the
|
|
353
|
+
decisions.
|
|
354
|
+
|
|
355
|
+
``None`` is how the parser says "not given" for every one of these, which is why ``--allow`` defaults to
|
|
356
|
+
``None`` rather than to an empty list: an empty list means "nobody is allowed", and forgetting a flag must not
|
|
357
|
+
mean that.
|
|
358
|
+
"""
|
|
359
|
+
if arguments.workspace is not None:
|
|
360
|
+
state.workspace = str(Path(arguments.workspace).expanduser().resolve()) # type: ignore[attr-defined]
|
|
361
|
+
elif not state.workspace: # type: ignore[attr-defined]
|
|
362
|
+
state.workspace = str(Path.cwd()) # type: ignore[attr-defined]
|
|
363
|
+
if arguments.model is not None:
|
|
364
|
+
state.model = arguments.model # type: ignore[attr-defined]
|
|
365
|
+
if arguments.effort is not None:
|
|
366
|
+
state.effort = arguments.effort # type: ignore[attr-defined]
|
|
367
|
+
if arguments.permission is not None:
|
|
368
|
+
state.permission = arguments.permission # type: ignore[attr-defined]
|
|
369
|
+
elif not state.permission: # type: ignore[attr-defined]
|
|
370
|
+
state.permission = "full" # type: ignore[attr-defined]
|
|
371
|
+
if arguments.allow is not None:
|
|
372
|
+
state.allow = list(arguments.allow) # type: ignore[attr-defined]
|
|
373
|
+
if arguments.allow_all is not None:
|
|
374
|
+
state.allow_all = bool(arguments.allow_all) # type: ignore[attr-defined]
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _start(state_dir: Path) -> int:
|
|
378
|
+
"""Start the background process with the login that is already stored: what a scan ends with, without the scan."""
|
|
379
|
+
from ..state import BridgeState
|
|
380
|
+
|
|
381
|
+
state = BridgeState.load(state_dir / "state.json")
|
|
382
|
+
if not state.logged_in:
|
|
383
|
+
print(f"no login in {state_dir}: run `zettcode-weixin` to scan one")
|
|
384
|
+
return 2
|
|
385
|
+
worker = Worker(state_dir)
|
|
386
|
+
if worker.running():
|
|
387
|
+
print(f"the bridge is already running (pid {worker.pid()})")
|
|
388
|
+
return 0
|
|
389
|
+
try:
|
|
390
|
+
pid = worker.start(command("--state-dir", str(state_dir)))
|
|
391
|
+
except RuntimeError as error:
|
|
392
|
+
print(f"the bridge did not start: {error}")
|
|
393
|
+
return 3
|
|
394
|
+
print(
|
|
395
|
+
f"bridge running (pid {pid}); allows: {', '.join(state.allow) or ('anyone' if state.allow_all else 'nobody')}"
|
|
396
|
+
)
|
|
397
|
+
return 0
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _setting_problem(arguments: argparse.Namespace, state: object) -> str:
|
|
401
|
+
"""Return what is wrong with the settings asked for, or an empty string.
|
|
402
|
+
|
|
403
|
+
Checked before anything is stopped or written: a level or a model the machine does not have would otherwise be
|
|
404
|
+
saved and fail on the next message, with the old daemon already gone and a chat waiting for an answer.
|
|
405
|
+
"""
|
|
406
|
+
from zettcode.sdk.models import list_models
|
|
407
|
+
|
|
408
|
+
if arguments.workspace is not None and not Path(arguments.workspace).expanduser().is_dir():
|
|
409
|
+
return f"no such directory: {arguments.workspace}"
|
|
410
|
+
try:
|
|
411
|
+
models = list(list_models())
|
|
412
|
+
except (OSError, ValueError) as error:
|
|
413
|
+
return f"the model configuration cannot be read: {error}"
|
|
414
|
+
wanted = arguments.model if arguments.model is not None else getattr(state, "model", "")
|
|
415
|
+
chosen = None
|
|
416
|
+
if wanted:
|
|
417
|
+
matched = [info for info in models if info.name == wanted or info.model == wanted]
|
|
418
|
+
if len(matched) != 1:
|
|
419
|
+
return f"no model named {wanted!r}; `zettcode daemon model` lists the ones there are"
|
|
420
|
+
chosen = matched[0]
|
|
421
|
+
elif models:
|
|
422
|
+
chosen = models[0]
|
|
423
|
+
if arguments.effort is not None and chosen is not None:
|
|
424
|
+
levels = [str(level) for level in chosen.efforts]
|
|
425
|
+
if arguments.effort not in levels:
|
|
426
|
+
return f"{chosen.name} takes {', '.join(levels) or 'no level'}, not {arguments.effort!r}"
|
|
427
|
+
return ""
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _set(arguments: argparse.Namespace, state_dir: Path) -> int:
|
|
431
|
+
"""Write the settings a daemon is started with, and put them in force now.
|
|
432
|
+
|
|
433
|
+
Two things keep the old values alive if this only wrote the file: a running daemon was started with the old
|
|
434
|
+
level, and the background process read the file once at its own start. So the daemon is stopped (the bridge
|
|
435
|
+
starts it again with the stored settings on the next message) and the process is restarted.
|
|
436
|
+
|
|
437
|
+
The daemon is stopped *before* the file is written, and a daemon that is busy stops the whole command: it would
|
|
438
|
+
otherwise keep running with the old settings while the file describes new ones, and the bridge would find it
|
|
439
|
+
alive and never start the one the reader asked for.
|
|
440
|
+
"""
|
|
441
|
+
import asyncio
|
|
442
|
+
|
|
443
|
+
from zettcode.sdk.daemons import stop_daemon
|
|
444
|
+
from zettcode.sdk.errors import ConfigurationError, SessionNotFoundError
|
|
445
|
+
|
|
446
|
+
from ..state import BridgeState
|
|
447
|
+
|
|
448
|
+
state = BridgeState.load(state_dir / "state.json")
|
|
449
|
+
if not state.logged_in:
|
|
450
|
+
print(f"no login in {state_dir}: run `zettcode-weixin` to scan one")
|
|
451
|
+
return 2
|
|
452
|
+
problem = _setting_problem(arguments, state)
|
|
453
|
+
if problem:
|
|
454
|
+
print(f"not changed: {problem}")
|
|
455
|
+
return 2
|
|
456
|
+
|
|
457
|
+
worker = Worker(state_dir)
|
|
458
|
+
was_running = worker.running()
|
|
459
|
+
if was_running:
|
|
460
|
+
print(worker.stop())
|
|
461
|
+
if worker.running():
|
|
462
|
+
# It would read the old file and write its own copy back over the new one.
|
|
463
|
+
print("not changed: the bridge did not stop; stop it by hand and run the command again")
|
|
464
|
+
return 3
|
|
465
|
+
if state.session_id and not arguments.keep_daemon:
|
|
466
|
+
try:
|
|
467
|
+
said = asyncio.run(stop_daemon(state.session_id, keep=True))
|
|
468
|
+
except SessionNotFoundError:
|
|
469
|
+
said = "no daemon was running for it"
|
|
470
|
+
except ConfigurationError as error:
|
|
471
|
+
print(f"not changed: the daemon is still doing what it was: {error}")
|
|
472
|
+
if was_running:
|
|
473
|
+
_restart_worker(worker, state_dir)
|
|
474
|
+
return 3
|
|
475
|
+
except Exception as error: # noqa: BLE001 - any other failure is still a stop that did not happen
|
|
476
|
+
print(f"not changed: the daemon could not be stopped: {error}")
|
|
477
|
+
if was_running:
|
|
478
|
+
_restart_worker(worker, state_dir)
|
|
479
|
+
return 3
|
|
480
|
+
print(f"daemon {said}")
|
|
481
|
+
|
|
482
|
+
previous_workspace = state.workspace
|
|
483
|
+
_apply_settings(state, arguments)
|
|
484
|
+
if state.workspace != previous_workspace:
|
|
485
|
+
# A session lives in one workspace; carrying its id over would resume a conversation that is not there.
|
|
486
|
+
state.session_id = ""
|
|
487
|
+
state.chats.clear()
|
|
488
|
+
dropped = _drop_incompatible_effort(state)
|
|
489
|
+
state.save()
|
|
490
|
+
print(f"workspace {state.workspace or Path.cwd()}")
|
|
491
|
+
print(f"model {state.model or '(the first configured)'}")
|
|
492
|
+
print(f"effort {state.effort or '(the middle of what the model takes)'}")
|
|
493
|
+
print(f"shell {state.permission}")
|
|
494
|
+
if dropped:
|
|
495
|
+
print(f"note {dropped}")
|
|
496
|
+
if state.allow_all:
|
|
497
|
+
print("allows anyone who can message the bot")
|
|
498
|
+
else:
|
|
499
|
+
print(f"allows {', '.join(state.allow) or 'nobody'}")
|
|
500
|
+
|
|
501
|
+
if was_running:
|
|
502
|
+
pid = _restart_worker(worker, state_dir)
|
|
503
|
+
if pid is None:
|
|
504
|
+
return 3
|
|
505
|
+
print(f"bridge running (pid {pid})")
|
|
506
|
+
else:
|
|
507
|
+
print("bridge not running; `zettcode-weixin start` reads the new settings")
|
|
508
|
+
return 0
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _drop_incompatible_effort(state: object) -> str:
|
|
512
|
+
"""Clear a stored level the model it now runs on does not take, and say so; return an empty string otherwise.
|
|
513
|
+
|
|
514
|
+
A level belongs to a model. Keeping ``high`` after moving to a model that stops at ``low`` would fail at the
|
|
515
|
+
next start, with the old daemon already gone — so the level goes, and the middle of the new model's list
|
|
516
|
+
applies instead.
|
|
517
|
+
"""
|
|
518
|
+
from zettcode.sdk.models import list_models
|
|
519
|
+
|
|
520
|
+
effort = getattr(state, "effort", "")
|
|
521
|
+
if not effort:
|
|
522
|
+
return ""
|
|
523
|
+
try:
|
|
524
|
+
models = list(list_models())
|
|
525
|
+
except (OSError, ValueError):
|
|
526
|
+
return ""
|
|
527
|
+
wanted = getattr(state, "model", "")
|
|
528
|
+
chosen = next((info for info in models if wanted and (info.name == wanted or info.model == wanted)), None)
|
|
529
|
+
if chosen is None:
|
|
530
|
+
chosen = models[0] if models else None
|
|
531
|
+
if chosen is None:
|
|
532
|
+
return ""
|
|
533
|
+
levels = [str(level) for level in chosen.efforts]
|
|
534
|
+
if effort in levels:
|
|
535
|
+
return ""
|
|
536
|
+
state.effort = "" # type: ignore[attr-defined]
|
|
537
|
+
return f"the level {effort!r} is not one {chosen.name} takes ({', '.join(levels) or 'none'}), so it is unset"
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _restart_worker(worker: Worker, state_dir: Path) -> int | None:
|
|
541
|
+
"""Start the background process again and return its pid, or say why it did not start."""
|
|
542
|
+
try:
|
|
543
|
+
pid = worker.start(command("--state-dir", str(state_dir)))
|
|
544
|
+
except RuntimeError as error:
|
|
545
|
+
print(f"the bridge did not start again: {error}")
|
|
546
|
+
return None
|
|
547
|
+
return pid
|
|
548
|
+
|
|
549
|
+
|
|
330
550
|
def _on_signals(stop: object) -> None:
|
|
331
551
|
"""Stop the bridge on ctrl-c and on the signal a stop sends, whatever this platform offers."""
|
|
332
552
|
loop = asyncio.get_running_loop()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|