zettcode-im 0.4.2__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.
@@ -0,0 +1,374 @@
1
+ """``zettcode-weixin``: log in by scanning, then leave a background process to answer the chat.
2
+
3
+ The command is a handover. ``zettcode-weixin`` starts a login, draws the QR code in the terminal, waits for the
4
+ phone, writes the handshake away, and starts a process that owns the connection from then on. It then prints
5
+ where that process is and exits, because a QR code and a running agent do not belong in the same window: the
6
+ reader scans and closes the terminal, and the agent keeps answering.
7
+
8
+ Running it again is how a login is replaced. A code is only good for the login it started, so the old process is
9
+ stopped first and a new one takes over — the same daemon and the same conversation, a new connection to WeChat.
10
+
11
+ A message may carry pictures: they travel with the text to the daemon, which puts them in the same prompt. The
12
+ answer is text, because the platform's send takes text — an agent that writes a file says where it wrote it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import asyncio
19
+ import contextlib
20
+ import signal
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ from zettcode.version import __version__
25
+
26
+ from . import qr
27
+ from .worker import Worker, command
28
+
29
+ #: Where the login, the cursor and the lock live when ``--state-dir`` is not given.
30
+ DEFAULT_DIR = Path.home() / ".zettcode" / "im" / "weixin"
31
+
32
+ #: Seconds to wait for the phone to scan, and seconds between looks at the login.
33
+ LOGIN_TIMEOUT = 300.0
34
+ POLL = 2.0
35
+
36
+
37
+ def build_parser() -> argparse.ArgumentParser:
38
+ """Return the parser: the four things a bridge does, and the settings they share."""
39
+ parser = argparse.ArgumentParser(
40
+ prog="zettcode-weixin",
41
+ description=(
42
+ "Drive one ZettCode daemon from a personal WeChat conversation. With no command it logs in: a QR "
43
+ "code to scan, then a background process that answers the chat, pictures included."
44
+ ),
45
+ )
46
+ parser.add_argument("--version", action="version", version=f"zettcode-weixin {__version__}")
47
+ commands = parser.add_subparsers(dest="command")
48
+ for name, help_line in (
49
+ ("login", "scan a QR code and start the background process (the default)"),
50
+ ("run", "run the bridge in this terminal (what the background process does)"),
51
+ ("status", "say whether a bridge is running and what it is doing"),
52
+ ("stop", "stop the background process"),
53
+ ("logs", "print the end of the background process's log"),
54
+ ):
55
+ sub = commands.add_parser(name, help=help_line, description=help_line)
56
+ _common(sub, login=name == "login", run=name == "run")
57
+ if name == "login":
58
+ sub.add_argument(
59
+ "--login-timeout",
60
+ type=float,
61
+ default=LOGIN_TIMEOUT,
62
+ help=f"seconds to wait for the scan (default: {int(LOGIN_TIMEOUT)})",
63
+ )
64
+ if name == "logs":
65
+ sub.add_argument("-n", "--lines", type=int, default=40, help="how many lines to print (default: 40)")
66
+ return parser
67
+
68
+
69
+ def _common(sub: argparse.ArgumentParser, *, login: bool, run: bool) -> None:
70
+ """Add the settings a login writes and a run reads."""
71
+ sub.add_argument(
72
+ "--state-dir", type=Path, default=DEFAULT_DIR, help=f"where the bridge keeps itself (default: {DEFAULT_DIR})"
73
+ )
74
+ if login:
75
+ sub.add_argument(
76
+ "-w",
77
+ "--workspace",
78
+ type=Path,
79
+ default=None,
80
+ help="directory the agent works in (default: the one already stored, or the current directory)",
81
+ )
82
+ sub.add_argument("--model", default=None, help="the model the daemon runs on (default: the one already stored)")
83
+ sub.add_argument("--effort", default=None, help="the reasoning level (default: the one already stored)")
84
+ sub.add_argument(
85
+ "--permission",
86
+ choices=("deny", "safe", "full"),
87
+ default=None,
88
+ help="the daemon's shell level (default: the one already stored, or safe)",
89
+ )
90
+ sub.add_argument(
91
+ "--allow",
92
+ action="append",
93
+ default=None,
94
+ help="a sender id allowed to drive the agent, replacing the stored list; repeatable",
95
+ )
96
+ sub.add_argument(
97
+ "--allow-all",
98
+ action="store_true",
99
+ default=None,
100
+ help="let anyone who can message the bot drive the agent (it has a shell and this machine's files)",
101
+ )
102
+ if run:
103
+ sub.add_argument(
104
+ "--turn-timeout", type=float, default=600.0, help="seconds to wait for one answer (default: 600)"
105
+ )
106
+
107
+
108
+ def main(argv: list[str] | None = None) -> int:
109
+ """Parse the command line and do what it says; return the exit status."""
110
+ arguments = build_parser().parse_args(argv)
111
+ if arguments.command is None:
112
+ # No command: the shared flags still apply, so parse them for the login the reader meant.
113
+ arguments = build_parser().parse_args(["login", *(sys.argv[1:] if argv is None else argv)])
114
+ state_dir: Path = arguments.state_dir.expanduser()
115
+ if arguments.command == "login":
116
+ return asyncio.run(_login(arguments, state_dir))
117
+ if arguments.command == "run":
118
+ return asyncio.run(_run(arguments, state_dir))
119
+ if arguments.command == "status":
120
+ return _status(state_dir)
121
+ if arguments.command == "stop":
122
+ print(Worker(state_dir).stop())
123
+ return 0
124
+ if arguments.command == "logs":
125
+ text = Worker(state_dir).tail(arguments.lines)
126
+ print(text or f"nothing has been written to {Worker(state_dir).log} yet")
127
+ return 0
128
+ raise AssertionError(f"unknown command {arguments.command!r}")
129
+
130
+
131
+ # -- login ---------------------------------------------------------------------------------------------
132
+ async def _login(arguments: argparse.Namespace, state_dir: Path) -> int:
133
+ """Draw the QR code, wait for the scan, write the state, and start the background process."""
134
+ from agim.models import LoginStatus # type: ignore[import-untyped]
135
+ from agim.wechat.client import WeChatClient # type: ignore[import-untyped]
136
+
137
+ from ..state import BridgeState
138
+
139
+ state_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
140
+ worker = Worker(state_dir)
141
+ # The running bridge stays up while the code is on screen: a scan that fails, or a reader who walks away,
142
+ # must not cost the bridge they already had. Nothing is written until the login has finished, so the old
143
+ # process keeps working on the state it started with.
144
+ state = BridgeState.load(state_dir / "state.json")
145
+ client = WeChatClient()
146
+ state_now = None
147
+ try:
148
+ handshake = await client.login()
149
+ print("Scan this with WeChat (the code is only good for this login):\n")
150
+ print(qr.show(handshake.qr_content))
151
+ if handshake.qr_url:
152
+ print(f"or open {handshake.qr_url}\n")
153
+ said: str = ""
154
+ verify: str | None = None
155
+ deadline = asyncio.get_running_loop().time() + max(1.0, arguments.login_timeout)
156
+ while asyncio.get_running_loop().time() < deadline:
157
+ state_now = await client.is_login(handshake, verify_code=verify)
158
+ status = state_now.status
159
+ if status == LoginStatus.CONNECTED:
160
+ break
161
+ if status in (LoginStatus.FAILED, LoginStatus.EXPIRED):
162
+ print(f"\nthe login did not finish: {state_now.message}")
163
+ return 2
164
+ if status == LoginStatus.SCANNED and said != "scanned":
165
+ said = "scanned"
166
+ print("\nscanned \u2014 confirm the login on your phone")
167
+ elif status == LoginStatus.VERIFY_REQUIRED and verify is None:
168
+ print(f"\n{state_now.message}")
169
+ verify = input("pairing code: ").strip() or None
170
+ await asyncio.sleep(POLL)
171
+ else:
172
+ print(f"\nnothing was scanned in {int(arguments.login_timeout)} seconds; run the command again")
173
+ return 2
174
+ finally:
175
+ await client.aclose()
176
+
177
+ credentials = state_now.credentials if state_now is not None else None
178
+ state.handshake = handshake.model_dump(mode="json")
179
+ state.credentials = credentials.model_dump(mode="json") if credentials is not None else {}
180
+ # A new login is a new connection: the cursor, the reply contexts and the answered ids belong to the old one —
181
+ # the platform numbers messages from the start again, so an id kept across a login can name a new message.
182
+ # The session stays, so the conversation and its history carry over.
183
+ state.cursor = ""
184
+ state.chats.clear()
185
+ state.seen.clear()
186
+ # A login renews the connection; the settings are what the reader decided and outlive it. A flag replaces its
187
+ # setting, and a flag that was not given leaves the stored one alone — a re-scan that forgot `--allow` used to
188
+ # wipe the allow-list and turn every message into a refusal.
189
+ if arguments.workspace is not None:
190
+ state.workspace = str(Path(arguments.workspace).expanduser().resolve())
191
+ elif not state.workspace:
192
+ state.workspace = str(Path.cwd())
193
+ if arguments.model is not None:
194
+ state.model = arguments.model
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)
205
+ if not state.token():
206
+ # A login without a token is a bridge that cannot send or receive, and a background process that says
207
+ # nothing about it. Better to say so here, where the reader is still watching the phone.
208
+ print("the login finished, but WeChat handed back no bot token. Nothing was started; run the command again")
209
+ return 3
210
+
211
+ # Only now is the old process stopped, and it is stopped before the new state is written: one that is still
212
+ # running keeps its own copy and would write that back over the new token at its next message.
213
+ if worker.running():
214
+ print(worker.stop(), "\u2014 the new login replaces it")
215
+ if worker.running():
216
+ print("the old bridge did not stop, so the new login was not saved. Stop it by hand and run again.")
217
+ return 3
218
+ state.save()
219
+ try:
220
+ pid = worker.start(command("--state-dir", str(state_dir)))
221
+ except RuntimeError as error:
222
+ print(f"the login was saved, but the background process did not start: {error}")
223
+ return 3
224
+ print(f"\nlogged in. The bridge is running (pid {pid}) and will answer messages as they come.")
225
+ print(f" log: {worker.log}")
226
+ print(f" stop: zettcode-weixin stop --state-dir {state_dir}")
227
+ if not state.allow and not state.allow_all:
228
+ print(" note: no sender is allowed yet \u2014 each message is refused and names the id to allow")
229
+ elif state.allow_all:
230
+ print(" allows: anyone who can message the bot")
231
+ else:
232
+ print(f" allows: {', '.join(state.allow)}")
233
+ return 0
234
+
235
+
236
+ # -- run -----------------------------------------------------------------------------------------------
237
+ async def _run(arguments: argparse.Namespace, state_dir: Path) -> int:
238
+ """Run the bridge here, which is what the background process does."""
239
+ from agim.models import LoginHandshake, LoginStatus
240
+ from agim.wechat.client import WeChatClient
241
+
242
+ from zettcode.filelock import SingletonLock
243
+
244
+ from ..bridge import Bridge, BridgeSettings
245
+ from ..guard import DaemonGuard
246
+ from ..link import SessionLink
247
+ from ..state import BridgeState
248
+
249
+ state = BridgeState.load(state_dir / "state.json")
250
+ if not state.logged_in:
251
+ print(f"no login in {state_dir}: run `zettcode-weixin` first")
252
+ return 2
253
+ client = WeChatClient(token=state.token())
254
+ link = SessionLink(
255
+ session_id=_bridge_id(state_dir),
256
+ handle="weixin",
257
+ workspace=state.workspace,
258
+ timeout=arguments.turn_timeout,
259
+ )
260
+ lock: SingletonLock | None = None
261
+ try:
262
+ # The stored handshake is the *login* handshake, and it expires once the code has been scanned — the token
263
+ # it produced lives on. So this is a hint, not a gate: the poll below is what proves the token works.
264
+ try:
265
+ handshake = LoginHandshake.model_validate(state.handshake)
266
+ login = await client.is_login(handshake)
267
+ except Exception as error: # noqa: BLE001 - a handshake this build cannot read says nothing about the token
268
+ print(f"note: the saved login could not be checked ({error}); using its token")
269
+ else:
270
+ if login.status is not LoginStatus.CONNECTED:
271
+ print(f"note: the login handshake reads {login.status.value} ({login.message}); using its token")
272
+ # The lock is taken only once the things that can still refuse this run have been checked: a process that
273
+ # takes the lock and then exits is a start the reader was told had worked.
274
+ lock = SingletonLock(Worker(state_dir).lock)
275
+ if not lock.acquire(wait=0.2): # a probe takes the lock for an instant; a short wait tells the two apart
276
+ print("another bridge is running; `zettcode-weixin status` says which, `stop` ends it")
277
+ return 3
278
+ guard = DaemonGuard(
279
+ workspace=Path(state.workspace or Path.cwd()),
280
+ state=state,
281
+ model=state.model or None,
282
+ effort=state.effort or None,
283
+ permission=state.permission or "safe",
284
+ name="weixin bridge",
285
+ )
286
+ await link.start()
287
+ if not await link.connected():
288
+ print("warning: the session broker is not reachable; the first message will say so")
289
+ bridge = Bridge(
290
+ im=client,
291
+ guard=guard,
292
+ link=link,
293
+ state=state,
294
+ path=state_dir / "state.json",
295
+ settings=BridgeSettings(
296
+ allow=frozenset(state.allow),
297
+ allow_all=state.allow_all,
298
+ ),
299
+ error=_log,
300
+ )
301
+ _on_signals(bridge.stop)
302
+ print(f"weixin bridge running for {state.workspace or Path.cwd()}; ctrl-c stops it")
303
+ if state.allow_all:
304
+ print("allows anyone who can message the bot")
305
+ elif state.allow:
306
+ print(f"allows: {', '.join(state.allow)}")
307
+ else:
308
+ print("allows nobody yet: each message is refused and names the id to allow")
309
+ print(f"daemon session: {state.session_id or '(starts on the first message)'}")
310
+ await bridge.run()
311
+ return 0
312
+ finally:
313
+ await link.close()
314
+ await client.aclose()
315
+ if lock is not None:
316
+ lock.release()
317
+
318
+
319
+ def _bridge_id(state_dir: Path) -> str:
320
+ """Return the broker id of the bridge in one directory: stable per bridge, different between two of them.
321
+
322
+ Two bridges — two WeChat accounts, or a stale worker on another ``--state-dir`` — would otherwise claim the
323
+ same session on the broker, and the second would see every send refused as if it were the agent's fault.
324
+ """
325
+ import hashlib
326
+
327
+ return "im-weixin-" + hashlib.sha1(str(state_dir.resolve()).encode("utf-8")).hexdigest()[:8]
328
+
329
+
330
+ def _on_signals(stop: object) -> None:
331
+ """Stop the bridge on ctrl-c and on the signal a stop sends, whatever this platform offers."""
332
+ loop = asyncio.get_running_loop()
333
+ for number in (signal.SIGINT, signal.SIGTERM):
334
+ try:
335
+ loop.add_signal_handler(number, stop) # type: ignore[arg-type]
336
+ except (NotImplementedError, RuntimeError, ValueError): # pragma: no cover - Windows, and not the main thread
337
+ with contextlib.suppress(ValueError, OSError):
338
+ signal.signal(number, lambda *_: stop()) # type: ignore[operator]
339
+
340
+
341
+ def _log(said: str) -> None:
342
+ """Say one thing where a background process's reader can find it."""
343
+ print(said, flush=True)
344
+
345
+
346
+ # -- status, stop, logs --------------------------------------------------------------------------------
347
+ def _status(state_dir: Path) -> int:
348
+ """Say whether a bridge is running and what it knows."""
349
+ from ..state import BridgeState
350
+
351
+ worker = Worker(state_dir)
352
+ state = BridgeState.load(state_dir / "state.json")
353
+ print(f"bridge: running (pid {worker.pid()})" if worker.running() else "bridge: not running")
354
+ print(f"wechat: {'logged in' if state.logged_in else 'not logged in'}")
355
+ print(f"workspace: {state.workspace or '(not recorded)'}")
356
+ print(f"session: {state.session_id or '(the daemon starts on the first message)'}")
357
+ short = state.cursor[:12] + ("\u2026" if len(state.cursor) > 12 else "") if state.cursor else "(none)"
358
+ print(f"cursor: {short}")
359
+ if state.allow_all:
360
+ print("allows: everyone who can message the bot")
361
+ elif state.allow:
362
+ print(f"allows: {', '.join(state.allow)}")
363
+ else:
364
+ print("allows: nobody yet")
365
+ print(f"state: {state_dir / 'state.json'}")
366
+ print(f"log: {worker.log}")
367
+ return 0
368
+
369
+
370
+ __all__ = ["DEFAULT_DIR", "LOGIN_TIMEOUT", "POLL", "build_parser", "main"]
371
+
372
+
373
+ if __name__ == "__main__": # the background process runs this module, not the console script
374
+ sys.exit(main())
@@ -0,0 +1,45 @@
1
+ """A QR code in the terminal, from the payload the login handshake carries.
2
+
3
+ Two modules of the code share one character: the top half and the bottom half are drawn as ``▀`` and ``▄``, so
4
+ a code of 33 modules is 17 lines and fits above the fold of a normal window. A terminal that cannot show those
5
+ characters still shows the link the handshake also carries, which the caller prints beside it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ #: Columns of white around the code. Below the four a spec asks for, and above the zero that makes a scanner
11
+ #: fight the text next to it.
12
+ BORDER = 2
13
+
14
+ #: The character for each pair of modules: (top, bottom) drawn into one cell.
15
+ _CELLS = {(True, True): "\u2588", (True, False): "\u2580", (False, True): "\u2584", (False, False): " "}
16
+
17
+
18
+ def render(payload: str, *, border: int = BORDER) -> str:
19
+ """Return ``payload`` as a QR code drawn with half-block characters, one line per two rows of modules."""
20
+ if not payload.strip():
21
+ raise ValueError("a QR code needs something to encode")
22
+ import qrcode # type: ignore[import-untyped]
23
+
24
+ code = qrcode.QRCode(border=border, error_correction=qrcode.constants.ERROR_CORRECT_M)
25
+ code.add_data(payload)
26
+ code.make(fit=True)
27
+ matrix = code.get_matrix()
28
+ rows = len(matrix)
29
+ lines: list[str] = []
30
+ for top in range(0, rows, 2):
31
+ bottom = top + 1
32
+ line = "".join(
33
+ _CELLS[(bool(matrix[top][column]), bool(matrix[bottom][column]) if bottom < rows else False)]
34
+ for column in range(len(matrix[top]))
35
+ )
36
+ lines.append(line)
37
+ return "\n".join(lines)
38
+
39
+
40
+ def show(payload: str, *, border: int = BORDER) -> str:
41
+ """Return the code with the blank line a terminal needs above and below it."""
42
+ return "\n" + render(payload, border=border) + "\n"
43
+
44
+
45
+ __all__ = ["BORDER", "render", "show"]
@@ -0,0 +1,160 @@
1
+ """The background process, and the files that say whether it is there.
2
+
3
+ Three files live in the bridge's directory:
4
+
5
+ - ``worker.lock`` — held by the running process for as long as it runs. The operating system drops it when the
6
+ process dies, so this is what "is a bridge running" means; a pid in a file cannot answer that, because a pid
7
+ outlives its process while the system hands it to somebody else.
8
+ - ``worker.pid`` — the number to signal. Read only while the lock says somebody is there.
9
+ - ``worker.log`` — everything the process printed, appended across restarts so a reason is still there after
10
+ the restart that hid it.
11
+
12
+ Starting is a handover: the new process takes the lock, and the parent waits for that before it says the bridge
13
+ is up, so a process that dies on its first line is reported instead of announced.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import signal
20
+ import subprocess
21
+ import sys
22
+ import time
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+ #: How long a starting process has to take the lock, and how long it has to let go of it when told to stop.
27
+ READY_TIMEOUT = 40.0
28
+ STOP_TIMEOUT = 15.0
29
+
30
+ #: Seconds between looks at the lock while waiting for a start or a stop.
31
+ POLL = 0.1
32
+
33
+
34
+ @dataclass
35
+ class Worker:
36
+ """The background process of one bridge, in one directory."""
37
+
38
+ directory: Path
39
+
40
+ @property
41
+ def lock(self) -> Path:
42
+ """The file the running process holds."""
43
+ return self.directory / "worker.lock"
44
+
45
+ @property
46
+ def pid_file(self) -> Path:
47
+ """The file naming the process to signal."""
48
+ return self.directory / "worker.pid"
49
+
50
+ @property
51
+ def log(self) -> Path:
52
+ """Where the process writes what it prints."""
53
+ return self.directory / "worker.log"
54
+
55
+ # -- what a reader asks ---------------------------------------------------------------------------
56
+ def running(self) -> bool:
57
+ """Return whether a bridge process is alive now."""
58
+ from zettcode.filelock import is_held
59
+
60
+ return is_held(self.lock)
61
+
62
+ def pid(self) -> int | None:
63
+ """Return the pid of the running bridge, or ``None`` when there is none."""
64
+ if not self.running():
65
+ return None
66
+ try:
67
+ return int(self.pid_file.read_text(encoding="utf-8").strip())
68
+ except (OSError, ValueError):
69
+ return None
70
+
71
+ def tail(self, lines: int = 40) -> str:
72
+ """Return the end of the log, for a reader who asks why nothing happened."""
73
+ try:
74
+ text = self.log.read_text(encoding="utf-8", errors="replace")
75
+ except OSError:
76
+ return ""
77
+ return "\n".join(text.splitlines()[-lines:])
78
+
79
+ # -- what a reader does ---------------------------------------------------------------------------
80
+ def start(self, argv: list[str], *, timeout: float = READY_TIMEOUT) -> int:
81
+ """Start the process and return its pid once it has taken the lock.
82
+
83
+ Raises:
84
+ RuntimeError: A bridge is already running, or the new process exited before it was ready; the
85
+ message carries the end of its log, which is where the reason is.
86
+ """
87
+ if self.running():
88
+ raise RuntimeError(f"a bridge is already running (pid {self.pid()}); stop it first")
89
+ self.directory.mkdir(parents=True, exist_ok=True, mode=0o700)
90
+ with open(self.log, "a", encoding="utf-8") as log:
91
+ log.write(f"\n--- {time.strftime('%Y-%m-%d %H:%M:%S')} starting\n")
92
+ log.flush()
93
+ # Unbuffered, so what the process says before it is killed is in the log: a file is block-buffered,
94
+ # and a start that fails early is exactly the case this log exists for.
95
+ environment = {**os.environ, "PYTHONUNBUFFERED": "1"}
96
+ if os.name == "nt": # pragma: no cover - the platform's own way of saying "not in this console's group"
97
+ process = subprocess.Popen( # noqa: S603 - the arguments are this program's own module and flags
98
+ argv,
99
+ stdin=subprocess.DEVNULL,
100
+ stdout=log,
101
+ stderr=subprocess.STDOUT,
102
+ close_fds=True,
103
+ env=environment,
104
+ creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
105
+ )
106
+ else:
107
+ process = subprocess.Popen( # noqa: S603 - as above
108
+ argv,
109
+ stdin=subprocess.DEVNULL,
110
+ stdout=log,
111
+ stderr=subprocess.STDOUT,
112
+ close_fds=True,
113
+ env=environment,
114
+ start_new_session=True,
115
+ )
116
+ self.pid_file.write_text(str(process.pid), encoding="utf-8")
117
+ deadline = time.monotonic() + timeout
118
+ while time.monotonic() < deadline:
119
+ if self.running():
120
+ return process.pid
121
+ code = process.poll()
122
+ if code is not None:
123
+ reason = self.tail(20) or f"it exited with status {code} and said nothing"
124
+ raise RuntimeError(f"the bridge exited before it was ready:\n{reason}")
125
+ time.sleep(POLL)
126
+ raise RuntimeError(f"the bridge did not start within {int(timeout)} seconds; see {self.log}")
127
+
128
+ def stop(self, *, timeout: float = STOP_TIMEOUT) -> str:
129
+ """Stop the process and return a sentence saying what happened."""
130
+ if not self.running():
131
+ return "no bridge is running"
132
+ pid = self.pid()
133
+ if pid is None:
134
+ return f"a bridge holds {self.lock} but left no pid to signal"
135
+ try:
136
+ os.kill(pid, signal.SIGTERM)
137
+ except OSError as error:
138
+ return f"could not signal the bridge (pid {pid}): {error}"
139
+ deadline = time.monotonic() + timeout
140
+ while time.monotonic() < deadline:
141
+ if not self.running():
142
+ return f"stopped the bridge (pid {pid})"
143
+ time.sleep(POLL)
144
+ try:
145
+ os.kill(pid, getattr(signal, "SIGKILL", signal.SIGTERM))
146
+ except OSError:
147
+ return f"the bridge (pid {pid}) did not stop and could not be killed"
148
+ for _ in range(int(5 / POLL)):
149
+ if not self.running():
150
+ break
151
+ time.sleep(POLL)
152
+ return f"the bridge (pid {pid}) did not stop in {int(timeout)} seconds; it was killed"
153
+
154
+
155
+ def command(*flags: str) -> list[str]:
156
+ """Return the command line that runs the worker: this interpreter, this program, ``run`` and ``flags``."""
157
+ return [sys.executable, "-m", "zettcode.im.weixin.cli", "run", *flags]
158
+
159
+
160
+ __all__ = ["READY_TIMEOUT", "STOP_TIMEOUT", "Worker", "command"]
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.5
2
+ Name: zettcode-im
3
+ Version: 0.4.2
4
+ Summary: Chat bridges: a WeChat conversation that drives one ZettCode daemon, and a place for the next chat app
5
+ Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
6
+ Author: Chang-LeHung
7
+ License-Expression: MIT
8
+ Keywords: bridge,daemon,im,wechat,zettcode
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Programming Language :: Python :: 3.15
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: agim<0.1,>=0.0.3
19
+ Requires-Dist: qrcode<9,>=8
20
+ Requires-Dist: zettcode-broker
21
+ Requires-Dist: zettcode-engine
22
+ Requires-Dist: zettcode-sdk
@@ -0,0 +1,13 @@
1
+ zettcode/im/__init__.py,sha256=GMN__-jXMyEqjGhqPKeyLJIvGfRgDnFY6CBTQlWcxtw,1002
2
+ zettcode/im/bridge.py,sha256=HgE1uN2aL1QWRvMdx22frsGbv7L-7yT53P5kpd26knE,17999
3
+ zettcode/im/guard.py,sha256=AlHiuQKM9S_SEsB4EjgHTHgXnNJ_EebO9rISfn51yMY,6405
4
+ zettcode/im/link.py,sha256=RC_b8qJz9tqCBwlxu0F0_8qoRSdiG2RXZ8wMyH_zx7c,10905
5
+ zettcode/im/state.py,sha256=dexA2U5xsBU8iOFBRYKj4IMwkquQu-dOkrnCiRMDMBQ,9224
6
+ zettcode/im/weixin/__init__.py,sha256=mmClFFsf0cXeRZLCWa48K25fW_q4YR6-FW7N7FDp0-8,168
7
+ zettcode/im/weixin/cli.py,sha256=-pBBa42hotwdb33ezFg7_1SVJYzw8U5zStw0HvgWX48,17383
8
+ zettcode/im/weixin/qr.py,sha256=z00AIENqdU6iWaf3bM2im-3fv0NlPbuM0C889vJZuxc,1850
9
+ zettcode/im/weixin/worker.py,sha256=AbD2EDVSvSjTunfvysJtVBL-b8mwIOp0ZbJbO0Y7qv0,6721
10
+ zettcode_im-0.4.2.dist-info/METADATA,sha256=y_wKPECYeoZJ82LwpRqjNHyKWHWmp1y2i1QCc4JHb60,872
11
+ zettcode_im-0.4.2.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
12
+ zettcode_im-0.4.2.dist-info/entry_points.txt,sha256=uGNXx1PUYjrqqiqNivK7v6wRSS5GAPuL0Fd-dGt6bK8,64
13
+ zettcode_im-0.4.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ zettcode-weixin = zettcode.im.weixin.cli:main