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.
- zettcode/im/__init__.py +21 -0
- zettcode/im/bridge.py +368 -0
- zettcode/im/guard.py +154 -0
- zettcode/im/link.py +229 -0
- zettcode/im/state.py +193 -0
- zettcode/im/weixin/__init__.py +5 -0
- zettcode/im/weixin/cli.py +374 -0
- zettcode/im/weixin/qr.py +45 -0
- zettcode/im/weixin/worker.py +160 -0
- zettcode_im-0.4.2.dist-info/METADATA +22 -0
- zettcode_im-0.4.2.dist-info/RECORD +13 -0
- zettcode_im-0.4.2.dist-info/WHEEL +4 -0
- zettcode_im-0.4.2.dist-info/entry_points.txt +2 -0
|
@@ -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())
|
zettcode/im/weixin/qr.py
ADDED
|
@@ -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,,
|