zettcode-im 0.4.2__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/.gitignore +42 -0
- zettcode_im-0.4.2/PKG-INFO +22 -0
- zettcode_im-0.4.2/pyproject.toml +55 -0
- zettcode_im-0.4.2/src/zettcode/im/__init__.py +21 -0
- zettcode_im-0.4.2/src/zettcode/im/bridge.py +368 -0
- zettcode_im-0.4.2/src/zettcode/im/guard.py +154 -0
- zettcode_im-0.4.2/src/zettcode/im/link.py +229 -0
- zettcode_im-0.4.2/src/zettcode/im/state.py +193 -0
- zettcode_im-0.4.2/src/zettcode/im/weixin/__init__.py +5 -0
- zettcode_im-0.4.2/src/zettcode/im/weixin/cli.py +374 -0
- zettcode_im-0.4.2/src/zettcode/im/weixin/qr.py +45 -0
- zettcode_im-0.4.2/src/zettcode/im/weixin/worker.py +160 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
**/.venv/
|
|
2
|
+
**/__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
build/
|
|
6
|
+
dist/
|
|
7
|
+
.smoke/
|
|
8
|
+
.coverage
|
|
9
|
+
**/.pytest_cache/
|
|
10
|
+
**/.ruff_cache/
|
|
11
|
+
.zett-tool-output/
|
|
12
|
+
.env
|
|
13
|
+
.DS_Store
|
|
14
|
+
|
|
15
|
+
# viztracer writes its trace here by default.
|
|
16
|
+
/result.json
|
|
17
|
+
|
|
18
|
+
# The docs site: dependencies, build output, and VitePress' cache.
|
|
19
|
+
node_modules/
|
|
20
|
+
docs/.vitepress/dist/
|
|
21
|
+
docs/.vitepress/cache/
|
|
22
|
+
docs/.vitepress/.temp/
|
|
23
|
+
|
|
24
|
+
# The TypeScript client is built from its sources; only dist/ is published.
|
|
25
|
+
clients/typescript/dist/
|
|
26
|
+
|
|
27
|
+
# The gateway's page is built from clients/gateway into the package when a distribution is made (`make gateway-ui`);
|
|
28
|
+
# the build output is not kept in the repository. pyproject's `artifacts` puts it in the wheel all the same.
|
|
29
|
+
packages/zettcode-gateway/src/zettcode/gateway/static/
|
|
30
|
+
|
|
31
|
+
# The palette gallery is generated by `make palettes`: every widget painted in
|
|
32
|
+
# every palette, written by docs/scripts/palette_gallery.py. Regenerate it
|
|
33
|
+
# rather than committing a 2 MB copy that goes stale the moment a colour moves.
|
|
34
|
+
docs/internal/palettes.html
|
|
35
|
+
|
|
36
|
+
# The release order is generated from the pyproject.toml files (`make order`); it is not kept in git.
|
|
37
|
+
packages/order.txt
|
|
38
|
+
|
|
39
|
+
# The desktop and phone apps build the gateway's page into their own folders, and electron-builder writes its
|
|
40
|
+
# installers under release/.
|
|
41
|
+
clients/desktop/renderer/
|
|
42
|
+
clients/desktop/release/
|
|
@@ -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,55 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "zettcode-im"
|
|
3
|
+
description = "Chat bridges: a WeChat conversation that drives one ZettCode daemon, and a place for the next chat app"
|
|
4
|
+
requires-python = ">=3.10"
|
|
5
|
+
dynamic = ["version"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [{name = "Chang-LeHung"}]
|
|
8
|
+
keywords = ["zettcode", "im", "wechat", "bridge", "daemon"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 4 - Beta",
|
|
11
|
+
"Programming Language :: Python :: 3.10",
|
|
12
|
+
"Programming Language :: Python :: 3.11",
|
|
13
|
+
"Programming Language :: Python :: 3.12",
|
|
14
|
+
"Programming Language :: Python :: 3.13",
|
|
15
|
+
"Programming Language :: Python :: 3.14",
|
|
16
|
+
"Programming Language :: Python :: 3.15",
|
|
17
|
+
"Typing :: Typed",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
# One stateless interface for IM login, receive and send, with the personal-WeChat client behind it.
|
|
21
|
+
"agim>=0.0.3,<0.1",
|
|
22
|
+
# The code a login puts in the terminal. No image library: the modules are drawn with block characters.
|
|
23
|
+
"qrcode>=8,<9",
|
|
24
|
+
# The daemon it owns is the SDK's, the session it talks over is the broker's, and the lock and the version are
|
|
25
|
+
# the engine's.
|
|
26
|
+
"zettcode-engine",
|
|
27
|
+
"zettcode-broker",
|
|
28
|
+
"zettcode-sdk",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# A program of its own, not a part of `zettcode`: nothing in the terminal needs it, and a reader who wants it
|
|
32
|
+
# installs it beside the program.
|
|
33
|
+
[project.scripts]
|
|
34
|
+
zettcode-weixin = "zettcode.im.weixin.cli:main"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Repository = "https://github.com/Chang-LeHung/zettcode"
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["hatchling"]
|
|
41
|
+
build-backend = "hatchling.build"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.version]
|
|
44
|
+
# One version for the whole repository: the engine ships the file.
|
|
45
|
+
path = "../zettcode-engine/src/zettcode/version.py"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/zettcode"]
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.sdist]
|
|
51
|
+
include = ["/src", "/pyproject.toml"]
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
testpaths = ["tests"]
|
|
55
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""The line between a chat app and one ZettCode daemon.
|
|
2
|
+
|
|
3
|
+
A chat message becomes a prompt for a background agent, and the agent's answer goes back to the chat it
|
|
4
|
+
came from. The parts:
|
|
5
|
+
|
|
6
|
+
- ``state`` — what has to survive a restart: the IM login handshake, its credentials, the receive cursor, the
|
|
7
|
+
id of the session the daemon runs, and the reply context of each chat. The IM client keeps none of this
|
|
8
|
+
itself.
|
|
9
|
+
- ``guard`` — the one daemon the bridge owns: started on demand, started again when it goes away, left alone
|
|
10
|
+
when it is running.
|
|
11
|
+
- ``link`` — the bridge's own session on the broker, so the daemon reads the message as a reader's and answers
|
|
12
|
+
the bridge, which forwards the answer.
|
|
13
|
+
- ``bridge`` — the loop that ties them together, and everything that can go wrong in it.
|
|
14
|
+
|
|
15
|
+
``cli`` and ``worker`` make a command of it: ``zettcode-weixin`` logs in, then hands the connection to a
|
|
16
|
+
background process that runs the bridge.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""One chat message becomes one turn of the daemon, and the answer goes back where it came from.
|
|
2
|
+
|
|
3
|
+
The loop has two halves on purpose. Receiving is a long poll that must keep running — a turn can take minutes,
|
|
4
|
+
and a poll that stops while the agent thinks is a message the platform has to keep for us. So one task takes
|
|
5
|
+
messages off the platform as fast as they arrive, and one task runs them, one at a time, because there is one
|
|
6
|
+
daemon and one conversation behind it.
|
|
7
|
+
|
|
8
|
+
Everything that can go wrong has a sentence for the reader, because a chat app has no stack trace to show:
|
|
9
|
+
|
|
10
|
+
- the daemon is down and cannot start: how long until the next attempt, or what the start said;
|
|
11
|
+
- the broker is out of reach or the daemon refused the message;
|
|
12
|
+
- the daemon took it but has not answered: the answer is not lost, and the next message starts a fresh turn;
|
|
13
|
+
- the text is too long for one message: it is split, and the split points are on line breaks where it can;
|
|
14
|
+
- the same event delivered twice (a restart mid-batch): answered once;
|
|
15
|
+
- a sender who is not allowed to drive the agent: refused, with the id to allow;
|
|
16
|
+
- pictures: forwarded with the text they came with, and the ones that cannot go — too many, too large, a format the
|
|
17
|
+
broker does not take — named back to the sender instead of lost quietly. A daemon whose model reads no pictures
|
|
18
|
+
refuses the message itself, and that refusal is what the chat is told.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import hashlib
|
|
25
|
+
import logging
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from .guard import DaemonGuard, DaemonUnavailable
|
|
31
|
+
from .link import LinkError, SessionLink
|
|
32
|
+
from .state import BridgeState
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
#: Characters per outgoing message. Chat apps are generous but not unlimited, and a long answer that fails to
|
|
37
|
+
#: send is lost, so it is split below the limit rather than at it.
|
|
38
|
+
CHUNK = 3000
|
|
39
|
+
|
|
40
|
+
#: How often the daemon is looked at when no message is arriving, so a daemon that died is back before somebody
|
|
41
|
+
#: writes rather than after.
|
|
42
|
+
HEALTH_EVERY = 20.0
|
|
43
|
+
|
|
44
|
+
#: Seconds to wait before polling again after the platform itself failed.
|
|
45
|
+
POLL_BACKOFF = 5.0
|
|
46
|
+
|
|
47
|
+
#: Seconds one poll may stay out before it is dropped and asked again (see :meth:`Bridge._receive`).
|
|
48
|
+
POLL_TIMEOUT = 120.0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class BridgeSettings:
|
|
53
|
+
"""What the bridge allows and how it behaves.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
allow: The user ids that may drive the agent. Empty means nobody: the agent has tools, and a bridge that
|
|
57
|
+
answers a stranger is a shell a stranger can reach.
|
|
58
|
+
allow_all: Accept every sender. For a bot nobody else can find; logged loudly when the bridge starts.
|
|
59
|
+
chunk: Characters per outgoing message.
|
|
60
|
+
health_every: Seconds between looks at the daemon when nothing is arriving.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
allow: frozenset[str] = frozenset()
|
|
64
|
+
allow_all: bool = False
|
|
65
|
+
chunk: int = CHUNK
|
|
66
|
+
health_every: float = HEALTH_EVERY
|
|
67
|
+
#: Seconds a single poll may stay out before it is abandoned and asked again. The platform's long poll is under
|
|
68
|
+
#: a minute; this is the width of the slack, not the poll's own timeout.
|
|
69
|
+
poll_timeout: float = POLL_TIMEOUT
|
|
70
|
+
|
|
71
|
+
def permits(self, user_id: str) -> bool:
|
|
72
|
+
"""Return whether this sender may drive the agent."""
|
|
73
|
+
return self.allow_all or user_id in self.allow
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class Bridge:
|
|
78
|
+
"""The chat side, the daemon, and the loop between them.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
im: The platform client, already logged in (``agim``'s ``IMClient``).
|
|
82
|
+
guard: The one daemon.
|
|
83
|
+
link: The bridge's session on the broker.
|
|
84
|
+
state: What has to survive a restart.
|
|
85
|
+
path: Where ``state`` is written.
|
|
86
|
+
settings: What it accepts and how it behaves.
|
|
87
|
+
error: Where a change worth a reader's eye goes; the worker hands it the log.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
im: Any
|
|
91
|
+
guard: DaemonGuard
|
|
92
|
+
link: SessionLink
|
|
93
|
+
state: BridgeState
|
|
94
|
+
path: Any
|
|
95
|
+
settings: BridgeSettings = field(default_factory=BridgeSettings)
|
|
96
|
+
error: Callable[[str], None] = logger.warning
|
|
97
|
+
_incoming: asyncio.Queue = field(default_factory=asyncio.Queue, init=False, repr=False)
|
|
98
|
+
_stopped: asyncio.Event = field(default_factory=asyncio.Event, init=False, repr=False)
|
|
99
|
+
#: Messages whose answer could not be delivered, and how many times it was tried.
|
|
100
|
+
_undelivered: dict[str, int] = field(default_factory=dict, init=False, repr=False)
|
|
101
|
+
|
|
102
|
+
# -- the run --------------------------------------------------------------------------------------
|
|
103
|
+
async def run(self) -> None:
|
|
104
|
+
"""Answer messages until :meth:`stop` is called or the platform goes away.
|
|
105
|
+
|
|
106
|
+
A platform that fails to poll is retried after a pause; it is not a reason to leave, because a long poll
|
|
107
|
+
that times out is normal and the next one usually has the message.
|
|
108
|
+
"""
|
|
109
|
+
if not self.settings.allow and not self.settings.allow_all:
|
|
110
|
+
self.error("no sender is allowed: every message will be refused until --allow names one")
|
|
111
|
+
tasks = [
|
|
112
|
+
asyncio.create_task(self._receive()),
|
|
113
|
+
asyncio.create_task(self._answer()),
|
|
114
|
+
asyncio.create_task(self._watch()),
|
|
115
|
+
]
|
|
116
|
+
try:
|
|
117
|
+
await self._stopped.wait()
|
|
118
|
+
finally:
|
|
119
|
+
for task in tasks:
|
|
120
|
+
task.cancel()
|
|
121
|
+
for task in tasks:
|
|
122
|
+
try:
|
|
123
|
+
await task
|
|
124
|
+
except (asyncio.CancelledError, Exception): # noqa: BLE001 - the run is ending anyway
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
def stop(self) -> None:
|
|
128
|
+
"""Leave after the message being answered."""
|
|
129
|
+
self._stopped.set()
|
|
130
|
+
|
|
131
|
+
# -- the two halves -------------------------------------------------------------------------------
|
|
132
|
+
async def _receive(self) -> None:
|
|
133
|
+
"""Take messages off the platform and queue them; never run a turn here.
|
|
134
|
+
|
|
135
|
+
The poll is given a deadline. The platform's long poll is under a minute, so a call that is still out after
|
|
136
|
+
``poll_timeout`` is not waiting for a message — it is a connection the network quietly dropped, and a bridge
|
|
137
|
+
that keeps waiting on it is a bridge that never speaks again. The deadline turns that into one more poll.
|
|
138
|
+
"""
|
|
139
|
+
while not self._stopped.is_set():
|
|
140
|
+
try:
|
|
141
|
+
result = await asyncio.wait_for(
|
|
142
|
+
self.im.receive(self.state.cursor or None), timeout=max(1.0, self.settings.poll_timeout)
|
|
143
|
+
)
|
|
144
|
+
except asyncio.TimeoutError:
|
|
145
|
+
# The usual end of a quiet chat, not a fault: the IM client's own poll never returns without a
|
|
146
|
+
# message, so this fires every `poll_timeout` seconds for as long as nobody writes.
|
|
147
|
+
logger.debug("no message in %d seconds; polling again", int(self.settings.poll_timeout))
|
|
148
|
+
continue
|
|
149
|
+
except asyncio.CancelledError:
|
|
150
|
+
raise
|
|
151
|
+
except Exception as error: # noqa: BLE001 - a poll that fails is retried, not fatal
|
|
152
|
+
self.error(f"receiving failed: {error}")
|
|
153
|
+
await asyncio.sleep(POLL_BACKOFF)
|
|
154
|
+
continue
|
|
155
|
+
message = result.message
|
|
156
|
+
# Every delivery is queued, duplicates included: the state is only written when an answer is done, so a
|
|
157
|
+
# batch handed over twice can be in the queue twice, and it is `_turn` that sees it there.
|
|
158
|
+
self._incoming.put_nowait((message, result.cursor))
|
|
159
|
+
|
|
160
|
+
async def _answer(self) -> None:
|
|
161
|
+
"""Run queued messages one at a time: one daemon, one conversation, one turn."""
|
|
162
|
+
while not self._stopped.is_set():
|
|
163
|
+
message, cursor = await self._incoming.get()
|
|
164
|
+
try:
|
|
165
|
+
await self._turn(message, cursor)
|
|
166
|
+
except asyncio.CancelledError:
|
|
167
|
+
raise
|
|
168
|
+
except Exception as error: # noqa: BLE001 - one bad message must not end the bridge
|
|
169
|
+
self.error(f"answering {message.event_id} failed: {error}")
|
|
170
|
+
|
|
171
|
+
async def _watch(self) -> None:
|
|
172
|
+
"""Bring the daemon back when it goes away, without a message having to arrive first."""
|
|
173
|
+
while not self._stopped.is_set():
|
|
174
|
+
await asyncio.sleep(max(1.0, self.settings.health_every))
|
|
175
|
+
if self._stopped.is_set() or not self.state.session_id:
|
|
176
|
+
continue
|
|
177
|
+
try:
|
|
178
|
+
await self.guard.ensure()
|
|
179
|
+
except DaemonUnavailable as error:
|
|
180
|
+
# Said once per failure at most: the guard already waited before this attempt, and a log line per
|
|
181
|
+
# twenty seconds about a broken model key helps nobody.
|
|
182
|
+
self.error(f"the daemon is not up: {error}")
|
|
183
|
+
|
|
184
|
+
# -- one message ----------------------------------------------------------------------------------
|
|
185
|
+
async def _turn(self, message: Any, cursor: str) -> None:
|
|
186
|
+
"""Answer one inbound message, and remember that it was answered."""
|
|
187
|
+
identity = _identity(message)
|
|
188
|
+
if self.state.answered(identity):
|
|
189
|
+
# The queue is a moment behind the state: a batch handed over twice may be queued twice before the
|
|
190
|
+
# first answer is written. The state is the word, not the queue — but the cursor still moves, or the
|
|
191
|
+
# next poll would ask for this message again.
|
|
192
|
+
self.state.cursor = cursor or self.state.cursor
|
|
193
|
+
self._save()
|
|
194
|
+
return
|
|
195
|
+
chat = str(message.chat_id)
|
|
196
|
+
# One line per message, before anything can fail: without it a turn that worked and a message that never
|
|
197
|
+
# arrived look the same in the log, which is the worst thing to be unsure about.
|
|
198
|
+
self.error(
|
|
199
|
+
f"got {message.event_id} from {message.user_id} in {chat}: "
|
|
200
|
+
f"{len(str(message.text))} characters, {len(getattr(message, 'media', ()) or ())} attachment(s)"
|
|
201
|
+
)
|
|
202
|
+
if not self.settings.permits(str(message.user_id)):
|
|
203
|
+
self.error(f"refused {message.event_id}: {message.user_id} is not in the allowed senders")
|
|
204
|
+
await self._say(
|
|
205
|
+
chat, message, f"this chat is not allowed to use the agent. The sender id is {message.user_id}"
|
|
206
|
+
)
|
|
207
|
+
self._done(message, cursor)
|
|
208
|
+
return
|
|
209
|
+
prompt = _prompt(message)
|
|
210
|
+
pictures, notes = _pictures(message)
|
|
211
|
+
if notes:
|
|
212
|
+
prompt = "\n".join([prompt, *(f"({note})" for note in notes)])
|
|
213
|
+
if not message.text.strip() and not pictures and not notes:
|
|
214
|
+
# Nothing to read and nobody to tell: no turn, no cost.
|
|
215
|
+
await self._say(chat, message, "I cannot read that kind of message yet; send words or a picture.")
|
|
216
|
+
self._done(message, cursor)
|
|
217
|
+
return
|
|
218
|
+
try:
|
|
219
|
+
session = await self.guard.ensure()
|
|
220
|
+
except DaemonUnavailable as error:
|
|
221
|
+
await self._say(chat, message, self._unavailable(str(error)))
|
|
222
|
+
self._done(message, cursor)
|
|
223
|
+
return
|
|
224
|
+
self.link.status("working")
|
|
225
|
+
try:
|
|
226
|
+
answer = await self.link.ask(session, prompt, images=pictures)
|
|
227
|
+
except LinkError as error:
|
|
228
|
+
self.error(f"the daemon did not answer {message.event_id}: {error}")
|
|
229
|
+
await self._say(chat, message, f"I could not ask the agent: {error}")
|
|
230
|
+
self._done(message, cursor)
|
|
231
|
+
return
|
|
232
|
+
finally:
|
|
233
|
+
self.link.status("idle")
|
|
234
|
+
if not answer.strip():
|
|
235
|
+
answer = "the agent finished without an answer to send back."
|
|
236
|
+
if await self._say(chat, message, answer):
|
|
237
|
+
self.error(f"answered {message.event_id}: {len(answer)} characters")
|
|
238
|
+
self._done(message, cursor)
|
|
239
|
+
return
|
|
240
|
+
# The answer exists and the chat will not take it. Answering the same message again would run the turn
|
|
241
|
+
# again — and again, for as long as the platform keeps refusing — so the message is behind us after a
|
|
242
|
+
# second attempt, and the log is where the reason is.
|
|
243
|
+
attempts = self._undelivered.get(identity, 0) + 1
|
|
244
|
+
self._undelivered[identity] = attempts
|
|
245
|
+
if attempts >= 2:
|
|
246
|
+
self._undelivered.pop(identity, None)
|
|
247
|
+
self.error(f"giving up on delivering the answer to {message.event_id}: {attempts} attempts")
|
|
248
|
+
self._done(message, cursor)
|
|
249
|
+
|
|
250
|
+
def _unavailable(self, said: str) -> str:
|
|
251
|
+
"""Return what a chat is told when the daemon is not there."""
|
|
252
|
+
wait = self.guard.wait_left()
|
|
253
|
+
if wait > 0:
|
|
254
|
+
return f"the agent is not running: {said}. I will try again in {int(wait) + 1} seconds."
|
|
255
|
+
return f"the agent could not be started: {said}"
|
|
256
|
+
|
|
257
|
+
async def _say(self, chat: str, message: Any, text: str) -> bool:
|
|
258
|
+
"""Send ``text`` to the chat in pieces; ``False`` when a piece did not go out.
|
|
259
|
+
|
|
260
|
+
The reply context is the one this message carried, or the last one this chat had: a message that arrives
|
|
261
|
+
without one (a refused attachment, for instance) should still be answered in the conversation it belongs to.
|
|
262
|
+
"""
|
|
263
|
+
context = getattr(message, "reply_token", None) or self.state.reply_token(chat)
|
|
264
|
+
for piece in split(text, self.settings.chunk):
|
|
265
|
+
try:
|
|
266
|
+
await self.im.send(chat, piece, context_token=context)
|
|
267
|
+
except Exception as error: # noqa: BLE001 - the chat is gone or the platform refused it
|
|
268
|
+
self.error(f"sending to {chat} failed: {error}")
|
|
269
|
+
return False
|
|
270
|
+
return True
|
|
271
|
+
|
|
272
|
+
def _done(self, message: Any, cursor: str) -> None:
|
|
273
|
+
"""Record the answer: the message is behind us, and the next poll starts after it."""
|
|
274
|
+
self.state.remember(
|
|
275
|
+
_identity(message), cursor=cursor or "", chat_id=str(message.chat_id), reply_token=message.reply_token
|
|
276
|
+
)
|
|
277
|
+
self._save()
|
|
278
|
+
|
|
279
|
+
def _save(self) -> None:
|
|
280
|
+
"""Write the state, and say so if it cannot be written: a lost cursor is a re-answered batch."""
|
|
281
|
+
try:
|
|
282
|
+
self.state.save_runtime(self.path)
|
|
283
|
+
except OSError as error:
|
|
284
|
+
self.error(f"the state at {self.path} could not be written: {error}")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _identity(message: Any) -> str:
|
|
288
|
+
"""Return the key a message is remembered by: the chat, the platform's id for it, and what it said.
|
|
289
|
+
|
|
290
|
+
The platform's own id cannot stand alone. When a message carries no id the IM client falls back to a sequence
|
|
291
|
+
number — one that starts at 1 in every conversation and again after every login — so ``"1"`` names a different
|
|
292
|
+
message tomorrow. Remembering it alone made a new message look answered, and the bridge dropped it without a
|
|
293
|
+
word. The chat and a short digest of the text make a collision need the same sequence number, in the same chat,
|
|
294
|
+
with the same words, which is what a genuine re-delivery of one message is.
|
|
295
|
+
"""
|
|
296
|
+
digest = hashlib.sha1(str(message.text).encode("utf-8")).hexdigest()[:10]
|
|
297
|
+
return f"{message.chat_id}|{message.event_id}|{digest}"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _prompt(message: Any) -> str:
|
|
301
|
+
"""Return what the daemon is asked, with who said it; the pictures are gathered separately."""
|
|
302
|
+
parts = [f"[chat {message.chat_id} from {message.user_id}]"]
|
|
303
|
+
if message.text.strip():
|
|
304
|
+
parts.append(message.text.strip())
|
|
305
|
+
return "\n".join(parts)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _pictures(message: Any) -> tuple[list[Any], list[str]]:
|
|
309
|
+
"""Return the pictures that can travel with the prompt, and a note for each one that cannot.
|
|
310
|
+
|
|
311
|
+
The broker takes four pictures, four mebibytes each and eight together, in four formats. The limit is enforced
|
|
312
|
+
there, so a message over it would be refused whole — text and all — which is why the count happens here: the
|
|
313
|
+
text still goes, and the sender is told what stayed behind.
|
|
314
|
+
"""
|
|
315
|
+
from zettcode.broker.protocol import IMAGE_TYPES, MAX_IMAGE_BYTES, MAX_IMAGES, MAX_IMAGES_BYTES, ImageData
|
|
316
|
+
|
|
317
|
+
media = list(getattr(message, "media", ()) or ())
|
|
318
|
+
pictures: list[ImageData] = []
|
|
319
|
+
notes: list[str] = []
|
|
320
|
+
total = 0
|
|
321
|
+
for item in media:
|
|
322
|
+
kind = str(getattr(item, "kind", "file"))
|
|
323
|
+
if kind != "image":
|
|
324
|
+
notes.append(f"1 {kind} not forwarded: only pictures and text reach the agent")
|
|
325
|
+
continue
|
|
326
|
+
media_type = str(getattr(item, "media_type", ""))
|
|
327
|
+
data = bytes(getattr(item, "data", b""))
|
|
328
|
+
if media_type not in IMAGE_TYPES:
|
|
329
|
+
notes.append(f"1 picture not forwarded: {media_type or 'unknown type'} is not one the agent takes")
|
|
330
|
+
continue
|
|
331
|
+
if len(data) > MAX_IMAGE_BYTES:
|
|
332
|
+
notes.append(f"1 picture not forwarded: {len(data) // (1 << 20)} MiB is over the 4 MiB limit")
|
|
333
|
+
continue
|
|
334
|
+
if len(pictures) >= MAX_IMAGES:
|
|
335
|
+
notes.append(f"1 picture not forwarded: at most {MAX_IMAGES} go with one message")
|
|
336
|
+
continue
|
|
337
|
+
if total + len(data) > MAX_IMAGES_BYTES:
|
|
338
|
+
notes.append("1 picture not forwarded: the pictures together are over 8 MiB")
|
|
339
|
+
continue
|
|
340
|
+
pictures.append(ImageData(media_type, data))
|
|
341
|
+
total += len(data)
|
|
342
|
+
refused = list(getattr(message, "rejected_media", ()) or ())
|
|
343
|
+
if refused:
|
|
344
|
+
notes.append(f"{len(refused)} attachment(s) the platform refused: too large")
|
|
345
|
+
return pictures, notes
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def split(text: str, limit: int) -> list[str]:
|
|
349
|
+
"""Split ``text`` into pieces of at most ``limit`` characters, on line breaks where it can.
|
|
350
|
+
|
|
351
|
+
A single line longer than the limit is cut at the limit, which is the only place a reader may see a word
|
|
352
|
+
broken in two; everything else starts a piece at a line break.
|
|
353
|
+
"""
|
|
354
|
+
if limit <= 0:
|
|
355
|
+
return [text]
|
|
356
|
+
pieces: list[str] = []
|
|
357
|
+
rest = text
|
|
358
|
+
while len(rest) > limit:
|
|
359
|
+
cut = rest.rfind("\n", 0, limit)
|
|
360
|
+
if cut <= 0:
|
|
361
|
+
cut = limit
|
|
362
|
+
pieces.append(rest[:cut])
|
|
363
|
+
rest = rest[cut:].lstrip("\n")
|
|
364
|
+
pieces.append(rest)
|
|
365
|
+
return [piece for piece in pieces if piece]
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
__all__ = ["Bridge", "BridgeSettings", "CHUNK", "HEALTH_EVERY", "POLL_BACKOFF", "split"]
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""The one daemon the bridge owns: started when it is needed, again when it goes away, never twice.
|
|
2
|
+
|
|
3
|
+
The daemon is a background ZettCode session with a fixed id, so every message a chat sends is one more turn of
|
|
4
|
+
the same conversation. The id is learned the first time a daemon is started and then kept: a start afterwards
|
|
5
|
+
resumes that session.
|
|
6
|
+
|
|
7
|
+
Two things here are about failing well. A start that fails is not retried on the next message as if nothing had
|
|
8
|
+
happened — the wait between attempts grows, and while it is running the bridge says how long it will be rather
|
|
9
|
+
than starting a second process that would fight the first for the session lock. And a lock somebody else holds is
|
|
10
|
+
not a failure to retry at all: the daemon would only be refused again, so the reader is told once and the bridge
|
|
11
|
+
carries on answering with what it has.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .state import BridgeState
|
|
22
|
+
|
|
23
|
+
#: How long to wait after the first failed start, and the most it grows to. A daemon that cannot start because
|
|
24
|
+
#: its model credentials are wrong will not start in a minute either, and the bridge should not spend the day
|
|
25
|
+
#: spawning processes to find that out.
|
|
26
|
+
FIRST_WAIT = 5.0
|
|
27
|
+
MAX_WAIT = 300.0
|
|
28
|
+
|
|
29
|
+
#: What a daemon record says about a process that is there. Anything else — ``dead``, or no record at all — is a
|
|
30
|
+
#: daemon the bridge may start.
|
|
31
|
+
ALIVE = frozenset({"starting", "idle", "working", "stopping"})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DaemonUnavailable(RuntimeError):
|
|
35
|
+
"""The daemon could not be started, and the message says what to tell the reader."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, message: str, *, retry_in: float = 0.0) -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.retry_in = retry_in
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class DaemonGuard:
|
|
44
|
+
"""Keeps one daemon reachable, and remembers the session it runs.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
workspace: The directory the daemon works in.
|
|
48
|
+
state: Where the session id lives between runs; the guard writes it when it learns one.
|
|
49
|
+
model: The model by name, or ``None`` for the first configured.
|
|
50
|
+
effort: The reasoning level, or ``None`` for the model's own.
|
|
51
|
+
permission: The shell level the daemon gets: ``deny``, ``safe`` or ``full``.
|
|
52
|
+
name: The label recorded beside the daemon, so a reader finds it in ``zettcode daemon list``.
|
|
53
|
+
waits: The waits between failed starts, in order; the last one repeats.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
workspace: Path
|
|
57
|
+
state: BridgeState
|
|
58
|
+
model: str | None = None
|
|
59
|
+
effort: str | None = None
|
|
60
|
+
permission: str = "safe"
|
|
61
|
+
name: str = "chat bridge"
|
|
62
|
+
waits: tuple[float, ...] = ()
|
|
63
|
+
_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False)
|
|
64
|
+
_failures: int = field(default=0, init=False, repr=False)
|
|
65
|
+
_next_try: float = field(default=0.0, init=False, repr=False)
|
|
66
|
+
_said: str = field(default="", init=False, repr=False)
|
|
67
|
+
|
|
68
|
+
# -- what the bridge asks -------------------------------------------------------------------------
|
|
69
|
+
async def ensure(self) -> str:
|
|
70
|
+
"""Return the session the daemon runs, starting it when it is not there.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
DaemonUnavailable: The last attempt failed and its wait has not passed, or this attempt did.
|
|
74
|
+
"""
|
|
75
|
+
async with self._lock:
|
|
76
|
+
session = str(getattr(self.state, "session_id", ""))
|
|
77
|
+
running = await asyncio.to_thread(self._running, session)
|
|
78
|
+
if running:
|
|
79
|
+
self._ok()
|
|
80
|
+
return session
|
|
81
|
+
wait = self._next_try - time.monotonic()
|
|
82
|
+
if wait > 0:
|
|
83
|
+
raise DaemonUnavailable(self._said or "the daemon is not up yet", retry_in=wait)
|
|
84
|
+
try:
|
|
85
|
+
session = await self._start(session or None)
|
|
86
|
+
except Exception as error: # noqa: BLE001 - every way a start fails is the reader's sentence
|
|
87
|
+
wait = self._failed(str(error) or error.__class__.__name__)
|
|
88
|
+
raise DaemonUnavailable(str(error) or "the daemon did not start", retry_in=wait) from error
|
|
89
|
+
self._ok()
|
|
90
|
+
self.state.session_id = session
|
|
91
|
+
self.state.save()
|
|
92
|
+
return session
|
|
93
|
+
|
|
94
|
+
def forget(self) -> None:
|
|
95
|
+
"""Drop the failure count, so the next message tries at once."""
|
|
96
|
+
self._ok()
|
|
97
|
+
|
|
98
|
+
def wait_left(self) -> float:
|
|
99
|
+
"""Return how many seconds the next attempt has to wait, or zero when it may try now."""
|
|
100
|
+
return max(0.0, self._next_try - time.monotonic())
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def failures(self) -> int:
|
|
104
|
+
"""How many starts have failed in a row."""
|
|
105
|
+
return self._failures
|
|
106
|
+
|
|
107
|
+
# -- what it does ---------------------------------------------------------------------------------
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _running(session: str) -> bool:
|
|
110
|
+
"""Return whether a daemon for ``session`` is up, read from its record."""
|
|
111
|
+
if not session:
|
|
112
|
+
return False
|
|
113
|
+
from zettcode.sdk.daemons import list_daemons
|
|
114
|
+
|
|
115
|
+
for info in list_daemons():
|
|
116
|
+
if info.session_id == session:
|
|
117
|
+
return str(info.status) in ALIVE
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
async def _start(self, session: str | None) -> str:
|
|
121
|
+
"""Start a daemon, resuming ``session`` when there is one, and return its session id."""
|
|
122
|
+
from zettcode.sdk.daemons import spawn_daemon
|
|
123
|
+
|
|
124
|
+
info = await spawn_daemon(
|
|
125
|
+
Path(self.workspace),
|
|
126
|
+
model=self.model,
|
|
127
|
+
effort=self.effort,
|
|
128
|
+
resume=session,
|
|
129
|
+
name=self.name,
|
|
130
|
+
permission=self.permission,
|
|
131
|
+
)
|
|
132
|
+
return info.session_id
|
|
133
|
+
|
|
134
|
+
def _wait(self) -> float:
|
|
135
|
+
"""Return how long the next attempt waits, and remember it: five seconds, doubling, up to five minutes."""
|
|
136
|
+
if self.waits:
|
|
137
|
+
return self.waits[min(self._failures, len(self.waits) - 1)]
|
|
138
|
+
return min(FIRST_WAIT * (2**self._failures), MAX_WAIT)
|
|
139
|
+
|
|
140
|
+
def _failed(self, said: str) -> float:
|
|
141
|
+
"""Count a failed attempt, wait longer before the next, and return how long that is."""
|
|
142
|
+
self._failures += 1
|
|
143
|
+
wait = self._wait()
|
|
144
|
+
self._next_try = time.monotonic() + wait
|
|
145
|
+
self._said = said
|
|
146
|
+
return wait
|
|
147
|
+
|
|
148
|
+
def _ok(self) -> None:
|
|
149
|
+
self._failures = 0
|
|
150
|
+
self._next_try = 0.0
|
|
151
|
+
self._said = ""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
__all__ = ["ALIVE", "DaemonGuard", "DaemonUnavailable", "FIRST_WAIT", "MAX_WAIT"]
|