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/link.py ADDED
@@ -0,0 +1,229 @@
1
+ """The bridge's own session on the broker: one place to send a prompt, one place to hear the answer.
2
+
3
+ The daemon is a normal ZettCode session, so the bridge talks to it the way a chat window does: it registers a
4
+ session of its own, sends the text with ``direct`` set (the daemon runs it as its reader's prompt and answers
5
+ with ``send_back``), and waits for that answer here.
6
+
7
+ Only the daemon's answers are taken. Everything else on this session — another session writing to it, a group
8
+ message — is refused in words, because a bridge that answers everything would turn a stray message into a turn
9
+ of the agent the reader did not ask for.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import logging
16
+ from collections.abc import Sequence
17
+ from dataclasses import dataclass, field
18
+
19
+ from zettcode.broker.models import Incoming, Receipt, SendError, SessionInfo
20
+ from zettcode.broker.protocol import VIA_USER, ImageData
21
+
22
+
23
+ class LinkError(RuntimeError):
24
+ """The message could not be handed to the daemon, and the message is what to tell the reader."""
25
+
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ #: What the answer queue carries when the connection, not the agent, ended the wait.
31
+ _LOST = object()
32
+
33
+
34
+ @dataclass
35
+ class SessionLink:
36
+ """The bridge's line to the daemon, over the session broker.
37
+
38
+ Args:
39
+ session_id: The id this bridge registers under. It only has to be stable; the roster shows the handle.
40
+ handle: What other sessions call the bridge, in the roster and in the daemon's prompt.
41
+ workspace: The directory named in the roster beside this session.
42
+ timeout: Seconds to wait for the daemon's answer after it has taken a message.
43
+ """
44
+
45
+ session_id: str
46
+ handle: str = "chat-bridge"
47
+ workspace: str = ""
48
+ timeout: float = 600.0
49
+ _client: object | None = field(default=None, init=False, repr=False)
50
+ _answers: asyncio.Queue[object] = field(default_factory=asyncio.Queue, init=False, repr=False)
51
+ _waiting: bool = field(default=False, init=False, repr=False)
52
+ #: The session this bridge is waiting on, so another session's message cannot be taken for its answer.
53
+ _asking: str = field(default="", init=False, repr=False)
54
+ #: What the broker said when the connection went out of reach, while a turn was waiting.
55
+ _problem: str = field(default="", init=False, repr=False)
56
+
57
+ # -- the life of the line -------------------------------------------------------------------------
58
+ async def start(self) -> None:
59
+ """Join the broker. Without one, every send says so rather than hanging."""
60
+ from zettcode.broker.client import BrokerClient
61
+
62
+ client = BrokerClient(on_message=self._heard, on_problem=self._trouble, on_conflict=self._conflict)
63
+ self._client = client
64
+ await client.start()
65
+ client.set_session(
66
+ SessionInfo(session_id=self.session_id, title=self.handle, workspace=self.workspace, status="idle")
67
+ )
68
+
69
+ async def close(self) -> None:
70
+ """Leave the broker. Safe to call twice, and safe when it never joined."""
71
+ client = self._client
72
+ self._client = None
73
+ if client is not None:
74
+ await client.close() # type: ignore[attr-defined]
75
+
76
+ async def connected(self, timeout: float = 10.0) -> bool:
77
+ """Return whether the broker is reachable, waiting a moment for a connection that is on its way."""
78
+ client = self._client
79
+ if client is None:
80
+ return False
81
+ return bool(await client.wait_connected(timeout)) # type: ignore[attr-defined]
82
+
83
+ def status(self, status: str) -> None:
84
+ """Say whether a turn is running, so the roster tells the truth about this session."""
85
+ client = self._client
86
+ if client is None:
87
+ return
88
+ client.set_session( # type: ignore[attr-defined]
89
+ SessionInfo(session_id=self.session_id, title=self.handle, workspace=self.workspace, status=status)
90
+ )
91
+
92
+ # -- one prompt, one answer -----------------------------------------------------------------------
93
+ async def ask(self, session: str, text: str, *, images: Sequence[ImageData] = ()) -> str:
94
+ """Send ``text`` to ``session`` as this reader's own words and return the answer it sends back.
95
+
96
+ Pictures travel with it. A ``direct`` message is the only one the broker lets carry them, which is what
97
+ this is: the daemon puts them into the same prompt the text becomes.
98
+
99
+ Raises:
100
+ LinkError: The broker or the daemon refused the message, or the answer did not come in time.
101
+ """
102
+ client = self._client
103
+ if client is None:
104
+ raise LinkError("the chat bridge is not connected to the session broker")
105
+ while not self._answers.empty(): # an answer that arrived after its timeout belongs to nobody
106
+ self._answers.get_nowait()
107
+ self._waiting = True
108
+ self._asking = session
109
+ self._problem = ""
110
+ try:
111
+ await self._hand_over(client, session, text, images)
112
+ except SendError as error:
113
+ self._waiting = False
114
+ self._asking = ""
115
+ raise LinkError(_sentence(error)) from error
116
+ except Exception as error: # noqa: BLE001 - a lost connection reads as a send that did not happen
117
+ self._waiting = False
118
+ self._asking = ""
119
+ raise LinkError(f"the message did not reach the daemon: {error}") from error
120
+ try:
121
+ answer = await asyncio.wait_for(self._answers.get(), timeout=self.timeout)
122
+ except asyncio.TimeoutError as error:
123
+ raise LinkError(
124
+ f"the daemon took the message but did not answer in {int(self.timeout)} seconds; it may still be "
125
+ "working on it, and an answer after this is not forwarded"
126
+ ) from error
127
+ finally:
128
+ self._waiting = False
129
+ self._asking = ""
130
+ if answer is _LOST:
131
+ raise LinkError(self._problem or "the connection to the session broker was lost while the agent answered")
132
+ return str(answer)
133
+
134
+ async def _hand_over(self, client: object, session: str, text: str, images: Sequence[ImageData]) -> None:
135
+ """Send the prompt, giving a daemon that has just restarted a moment to appear in the roster.
136
+
137
+ A daemon that was started or resumed is in its record before it is registered with the broker, so the first
138
+ send can be refused as ``unknown`` while the process is perfectly healthy. Three tries a second apart is
139
+ the difference between that and a sentence about a session that does not exist.
140
+ """
141
+ tries = 3
142
+ for attempt in range(tries):
143
+ try:
144
+ await client.send( # type: ignore[attr-defined]
145
+ session,
146
+ self._shorten(text, client),
147
+ direct=True,
148
+ mode="followup",
149
+ via=VIA_USER,
150
+ images=list(images),
151
+ )
152
+ return
153
+ except SendError as error:
154
+ if error.reason in {"unknown", "gone"} and attempt + 1 < tries:
155
+ await asyncio.sleep(1.0 * (attempt + 1))
156
+ continue
157
+ raise
158
+
159
+ async def send(self, session: str, text: str) -> None:
160
+ """Tell a session something without waiting for an answer — a note, not a prompt."""
161
+ client = self._client
162
+ if client is None:
163
+ return
164
+ try:
165
+ await client.send(session, self._shorten(text, client), mode="followup", via=VIA_USER) # type: ignore[attr-defined]
166
+ except Exception: # noqa: BLE001 - a note that cannot be delivered is not worth failing a turn over
167
+ return
168
+
169
+ # -- what the broker calls ------------------------------------------------------------------------
170
+ def _heard(self, message: Incoming) -> Receipt:
171
+ """Take one message: the daemon's answer goes to the turn waiting for it, anything else is refused.
172
+
173
+ Only the session that was asked may answer. Another session — a stray ``send_message`` from an agent, a
174
+ group message — would otherwise be forwarded to the chat as if the agent had said it, and the real answer
175
+ would be refused as a second one.
176
+ """
177
+ if not self._waiting:
178
+ return Receipt(accepted=False, reason="this is a chat bridge; it is not reading messages right now")
179
+ if message.group or message.sender.session_id != self._asking:
180
+ return Receipt(
181
+ accepted=False,
182
+ reason="this bridge is waiting for the daemon's answer, not for another session's message",
183
+ )
184
+ self._answers.put_nowait(message.text)
185
+ return Receipt(accepted=True, disposition="delivered")
186
+
187
+ def _trouble(self, problem: str | None) -> None:
188
+ """A connection that is out of reach ends the wait instead of leaving the chat hanging.
189
+
190
+ The wait is ended with a marker, not with an empty answer: an empty string is what a silent agent looks
191
+ like, and telling the reader the agent finished when the broker merely dropped is a lie.
192
+ """
193
+ if problem is not None and self._waiting:
194
+ self._problem = problem
195
+ self._answers.put_nowait(_LOST)
196
+
197
+ def _conflict(self, message: str | None) -> None:
198
+ """Another process holds this bridge's session on the broker; say so where the worker's log is."""
199
+ if message is not None:
200
+ logger.warning("another process holds this bridge's session on the broker: %s", message)
201
+
202
+ @staticmethod
203
+ def _shorten(text: str, client: object) -> str:
204
+ """Return ``text`` fitted to what the broker takes in one message."""
205
+ limits = getattr(client, "limits", None)
206
+ room = int(getattr(limits, "max_text", 0) or 0)
207
+ if room <= 0 or len(text) <= room:
208
+ return text
209
+ marker = f"\n[\u2026 {len(text) - room} characters not sent: the broker takes {room} in one message]"
210
+ return text[: max(0, room - len(marker))] + marker
211
+
212
+
213
+ def _sentence(error: SendError) -> str:
214
+ """Return what a refused send means for the reader, rather than the broker's code alone."""
215
+ reason = getattr(error, "reason", "") or "refused"
216
+ if reason == "gone":
217
+ return "the daemon is not connected to the session broker"
218
+ if reason == "unknown":
219
+ return "the daemon's session is not registered with the session broker"
220
+ if reason == "denied":
221
+ return "the daemon refused the message"
222
+ if reason == "too_long" and "picture" in str(error):
223
+ return f"the pictures were refused: {error}"
224
+ if reason == "invalid" and "picture" in str(error):
225
+ return f"the pictures were refused: {error}"
226
+ return f"the message was not delivered ({reason}): {error}"
227
+
228
+
229
+ __all__ = ["LinkError", "SessionLink"]
zettcode/im/state.py ADDED
@@ -0,0 +1,193 @@
1
+ """What the bridge has to remember between two starts.
2
+
3
+ The IM side is stateless on purpose (``agim`` keeps nothing), so this is the only place the continuity lives:
4
+ the login handshake and its credentials, the cursor a ``receive`` call answered with, the session the daemon
5
+ runs, and the reply context of each chat. ``send`` needs that context back, and a restart mid-batch re-delivers
6
+ the batch rather than dropping the messages behind the first one, which is why the cursor is written only after
7
+ a message has been answered.
8
+
9
+ Nothing here talks to the network. A file that cannot be read is treated as a first run — the worst it costs is
10
+ a login — and a file that cannot be written is an error the caller has to see, because a bridge that silently
11
+ loses its cursor re-answers messages the reader has already read.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import tempfile
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ #: How many event ids are remembered, so a re-delivered batch is answered once. The platform hands a batch back
24
+ #: whole after a restart, and a batch is small; a hundred is several restarts of slack.
25
+ SEEN_LIMIT = 200
26
+
27
+ #: How many chats keep their reply context. One entry per chat is tiny, and a chat that falls out of it simply
28
+ #: loses the quoting, not the answer.
29
+ CHATS_LIMIT = 100
30
+
31
+
32
+ @dataclass
33
+ class BridgeState:
34
+ """Everything the bridge persists, in one file."""
35
+
36
+ #: The login handshake as the IM client gave it, verbatim: its QR payload and the provider state behind it.
37
+ handshake: dict[str, Any] = field(default_factory=dict)
38
+ #: What a finished login handed back; the token the client needs to send and poll.
39
+ credentials: dict[str, Any] = field(default_factory=dict)
40
+ #: The cursor the last answered message came with.
41
+ cursor: str = ""
42
+ #: The session the daemon runs. Empty until the first message starts one.
43
+ session_id: str = ""
44
+ #: The reply context of the last message of each chat, by chat id.
45
+ chats: dict[str, str] = field(default_factory=dict)
46
+ #: Event ids already answered, oldest first.
47
+ seen: list[str] = field(default_factory=list)
48
+ #: What the bridge was started with, so the worker needs no flags: where the daemon works, the model it runs on,
49
+ #: its shell level, and who may drive it.
50
+ workspace: str = ""
51
+ model: str = ""
52
+ effort: str = ""
53
+ permission: str = "safe"
54
+ allow: list[str] = field(default_factory=list)
55
+ allow_all: bool = False
56
+ #: Where this state came from. Not written into the file; it is what :meth:`save` writes back to by default.
57
+ path: Path | None = field(default=None, compare=False, repr=False)
58
+
59
+ # -- what the bridge asks -------------------------------------------------------------------------
60
+ @property
61
+ def logged_in(self) -> bool:
62
+ """Return whether a login has finished, which is what a run needs: a login without a token is not one."""
63
+ return bool(self.token())
64
+
65
+ def token(self) -> str:
66
+ """Return the bot token a finished login produced, or an empty string.
67
+
68
+ The IM client hands it over inside ``credentials`` — ``{"config": {...}, "secrets": {...}}`` — and a token
69
+ read from the wrong level is an empty string that looks like a login until the first send fails, so both
70
+ the nested shape and a flat one are read.
71
+ """
72
+ secrets = self.credentials.get("secrets")
73
+ value = secrets.get("bot_token") if isinstance(secrets, dict) else None
74
+ if not isinstance(value, str) or not value:
75
+ value = self.credentials.get("bot_token")
76
+ return value if isinstance(value, str) else ""
77
+
78
+ def answered(self, event_id: str) -> bool:
79
+ """Return whether this message has already been answered."""
80
+ return event_id in self.seen
81
+
82
+ def remember(self, event_id: str, *, cursor: str, chat_id: str, reply_token: str | None) -> None:
83
+ """Record that a message has been answered, and the context the next answer of that chat needs."""
84
+ if event_id and event_id not in self.seen:
85
+ self.seen.append(event_id)
86
+ del self.seen[:-SEEN_LIMIT]
87
+ self.cursor = cursor
88
+ if reply_token:
89
+ self.chats[chat_id] = reply_token
90
+ while len(self.chats) > CHATS_LIMIT:
91
+ self.chats.pop(next(iter(self.chats)))
92
+ else:
93
+ self.chats.pop(chat_id, None)
94
+
95
+ def reply_token(self, chat_id: str) -> str | None:
96
+ """Return the context token of the last message of this chat, if it had one."""
97
+ return self.chats.get(chat_id)
98
+
99
+ # -- the file -------------------------------------------------------------------------------------
100
+ @classmethod
101
+ def load(cls, path: Path) -> BridgeState:
102
+ """Read the state at ``path``; a missing or unreadable file is a first run."""
103
+ try:
104
+ raw = json.loads(path.read_text(encoding="utf-8"))
105
+ except (OSError, ValueError):
106
+ return cls(path=path)
107
+ if not isinstance(raw, dict):
108
+ return cls(path=path)
109
+ state = cls(path=path)
110
+ handshake = raw.get("handshake")
111
+ state.handshake = handshake if isinstance(handshake, dict) else {}
112
+ credentials = raw.get("credentials")
113
+ state.credentials = credentials if isinstance(credentials, dict) else {}
114
+ cursor = raw.get("cursor")
115
+ state.cursor = cursor if isinstance(cursor, str) else ""
116
+ session_id = raw.get("session_id")
117
+ state.session_id = session_id if isinstance(session_id, str) else ""
118
+ chats = raw.get("chats")
119
+ if isinstance(chats, dict):
120
+ state.chats = {str(key): value for key, value in chats.items() if isinstance(value, str)}
121
+ seen = raw.get("seen")
122
+ if isinstance(seen, list):
123
+ state.seen = [item for item in seen if isinstance(item, str)][-SEEN_LIMIT:]
124
+ state.workspace = _text(raw.get("workspace"))
125
+ state.model = _text(raw.get("model"))
126
+ state.effort = _text(raw.get("effort"))
127
+ state.permission = _text(raw.get("permission")) or "safe"
128
+ allow = raw.get("allow")
129
+ state.allow = [item for item in allow if isinstance(item, str)] if isinstance(allow, list) else []
130
+ state.allow_all = raw.get("allow_all") is True
131
+ return state
132
+
133
+ def save_runtime(self, path: Path | None = None) -> None:
134
+ """Write what a running bridge produces, keeping the settings whoever is looking at the file set.
135
+
136
+ A worker holds the state it started with; a reader who changes the allow-list, the workspace or the shell
137
+ level while it runs must not have that change written back to the old value on the next message. So the
138
+ settings are taken from the file as it is now, and only the cursor, the answers and the login are written.
139
+ """
140
+ target = path or self.path
141
+ on_disk = BridgeState.load(target) if target is not None else None
142
+ if on_disk is not None:
143
+ self.allow = on_disk.allow
144
+ self.allow_all = on_disk.allow_all
145
+ self.workspace = on_disk.workspace or self.workspace
146
+ self.model = on_disk.model
147
+ self.effort = on_disk.effort
148
+ self.permission = on_disk.permission or self.permission
149
+ self.save(target)
150
+
151
+ def save(self, path: Path | None = None) -> None:
152
+ """Write the state to ``path`` (this state's own file by default), all at once, readable by this user only.
153
+
154
+ The write goes to a temporary file in the same directory and is moved into place, so a process killed
155
+ mid-write leaves the previous state rather than half of this one — a torn cursor is a re-answered batch.
156
+ """
157
+ target = path or self.path
158
+ if target is None:
159
+ raise ValueError("this state has no file; pass the path to save it to")
160
+ target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
161
+ payload = {
162
+ "handshake": self.handshake,
163
+ "credentials": self.credentials,
164
+ "cursor": self.cursor,
165
+ "session_id": self.session_id,
166
+ "chats": self.chats,
167
+ "seen": self.seen,
168
+ "workspace": self.workspace,
169
+ "model": self.model,
170
+ "effort": self.effort,
171
+ "permission": self.permission,
172
+ "allow": self.allow,
173
+ "allow_all": self.allow_all,
174
+ }
175
+ handle, temporary = tempfile.mkstemp(dir=target.parent, prefix=".state-", suffix=".json")
176
+ try:
177
+ with os.fdopen(handle, "w", encoding="utf-8") as stream:
178
+ json.dump(payload, stream, ensure_ascii=False, indent=2)
179
+ stream.flush()
180
+ os.fsync(stream.fileno())
181
+ os.chmod(temporary, 0o600)
182
+ os.replace(temporary, target)
183
+ except BaseException:
184
+ Path(temporary).unlink(missing_ok=True)
185
+ raise
186
+
187
+
188
+ def _text(value: object) -> str:
189
+ """Return ``value`` when it is a string, and an empty string for anything a damaged file may hold instead."""
190
+ return value if isinstance(value, str) else ""
191
+
192
+
193
+ __all__ = ["BridgeState", "CHATS_LIMIT", "SEEN_LIMIT"]
@@ -0,0 +1,5 @@
1
+ """Personal WeChat as the chat side: login by scanning, then a background process that runs the bridge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__: list[str] = []