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
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"]
|