funora 0.0.1.dev2__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.
- funora/__init__.py +265 -0
- funora/_account.py +661 -0
- funora/_aclient.py +1484 -0
- funora/_budget.py +579 -0
- funora/_calc.py +182 -0
- funora/_canonical.py +235 -0
- funora/_catalog.py +473 -0
- funora/_chat_history.py +367 -0
- funora/_chats.py +392 -0
- funora/_chips.py +403 -0
- funora/_classify.py +466 -0
- funora/_client.py +1490 -0
- funora/_currency_switch.py +130 -0
- funora/_cursor.py +120 -0
- funora/_delivered.py +233 -0
- funora/_diff.py +752 -0
- funora/_engine.py +4795 -0
- funora/_extract.py +124 -0
- funora/_field_schema.py +112 -0
- funora/_fileio.py +58 -0
- funora/_gate.py +69 -0
- funora/_hops.py +101 -0
- funora/_host.py +120 -0
- funora/_identity.py +284 -0
- funora/_json.py +31 -0
- funora/_listen.py +312 -0
- funora/_lot_form.py +331 -0
- funora/_market.py +406 -0
- funora/_matching.py +147 -0
- funora/_money.py +279 -0
- funora/_monitoring.py +396 -0
- funora/_observed.py +237 -0
- funora/_order.py +552 -0
- funora/_order_details.py +281 -0
- funora/_orders.py +807 -0
- funora/_outbound.py +453 -0
- funora/_own_lots.py +301 -0
- funora/_poll.py +410 -0
- funora/_price_audit.py +281 -0
- funora/_proxies.py +232 -0
- funora/_raise.py +150 -0
- funora/_refund.py +102 -0
- funora/_result.py +157 -0
- funora/_retry.py +213 -0
- funora/_review_write.py +138 -0
- funora/_reviews.py +584 -0
- funora/_runner.py +651 -0
- funora/_secret.py +385 -0
- funora/_showcase.py +362 -0
- funora/_signals.py +375 -0
- funora/_skeleton.py +752 -0
- funora/_snapshot.py +276 -0
- funora/_state.py +279 -0
- funora/_stock.py +32 -0
- funora/_thread.py +574 -0
- funora/_transport.py +1023 -0
- funora/_updates.py +292 -0
- funora/_verdicts.py +91 -0
- funora/_viewing.py +143 -0
- funora/_watch.py +825 -0
- funora/_watch_state.py +237 -0
- funora/_whoami.py +546 -0
- funora/bot/__init__.py +52 -0
- funora/bot/_delivery.py +341 -0
- funora/bot/_outbox.py +261 -0
- funora/bot/_runtime.py +447 -0
- funora/bot/_spool.py +534 -0
- funora/budget.py +311 -0
- funora/capabilities.py +297 -0
- funora/conformance.py +758 -0
- funora/contract.py +73 -0
- funora/errors.py +996 -0
- funora/events.py +189 -0
- funora/extraction.py +420 -0
- funora/observe.py +560 -0
- funora/operations.py +671 -0
- funora/py.typed +0 -0
- funora/reconciliation.py +51 -0
- funora/response_classes.py +155 -0
- funora/retry.py +238 -0
- funora/send_outcome.py +78 -0
- funora/skeleton_format.py +77 -0
- funora-0.0.1.dev2.dist-info/METADATA +294 -0
- funora-0.0.1.dev2.dist-info/RECORD +87 -0
- funora-0.0.1.dev2.dist-info/WHEEL +4 -0
- funora-0.0.1.dev2.dist-info/entry_points.txt +2 -0
- funora-0.0.1.dev2.dist-info/licenses/LICENSE +201 -0
funora/_outbound.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""Ограничитель исходящих сообщений.
|
|
2
|
+
|
|
3
|
+
НЕ ВЕДРО ТОКЕНОВ, и это не вкусовщина. Три предела из четырёх ведром невыразимы:
|
|
4
|
+
множество РАЗЛИЧНЫХ адресатов, пауза на отдельную переписку и условная квота.
|
|
5
|
+
Ведро же отвечает на вопрос «сколько ждать», а здесь ждать нельзя вовсе: пределы
|
|
6
|
+
часовые при объявленном пределе ожидания в пять секунд.
|
|
7
|
+
|
|
8
|
+
УСТРОЙСТВО - ОДИН ЖУРНАЛ, а не четыре счётчика. Четыре предела - это четыре
|
|
9
|
+
запроса к одной записи. Четыре счётчика пришлось бы четырьмя способами обнулять,
|
|
10
|
+
сохранять и восстанавливать, и разошлись бы они молча.
|
|
11
|
+
|
|
12
|
+
ЗАЧЕМ ЭТО ВООБЩЕ. Отсутствие метода массовой рассылки не мешает написать цикл в
|
|
13
|
+
пять строк, а наказание по пункту 1.9 публичных правил площадки получает
|
|
14
|
+
продавец. Ограничитель - единственное, что стоит между тем и другим.
|
|
15
|
+
|
|
16
|
+
СЧИТАЕТСЯ ПОПЫТКА, А НЕ УСПЕХ, и запись делается ВПЕРЕДИ запроса. Иначе
|
|
17
|
+
неоднозначный исход не учитывался бы вовсе: форма отказа канала не наблюдалась, а
|
|
18
|
+
транспортный отказ объявлен способным иметь последствия. «Не засчитаем, раз не
|
|
19
|
+
подтвердилось» означало бы не считать ровно те отправки, которые могли уйти.
|
|
20
|
+
|
|
21
|
+
ДВЕ МЕТКИ У КАЖДОЙ ЗАПИСИ. Стенная - чтобы пережить перезапуск; монотонная -
|
|
22
|
+
чтобы перевод часов не сбрасывал квоту, пока процесс работает. После перезапуска
|
|
23
|
+
монотонной нет, и остаётся стенная: это честная половина, и она объявлена в
|
|
24
|
+
контракте прямо.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from typing import Any, Final
|
|
31
|
+
|
|
32
|
+
from .budget import (
|
|
33
|
+
COLD_OUTREACH_QUOTA_PER_HOUR,
|
|
34
|
+
COLD_OUTREACH_WINDOW_MS,
|
|
35
|
+
OUTBOUND_MESSAGES_PER_HOUR,
|
|
36
|
+
OUTBOUND_MIN_INTERVAL_PER_CHAT_MS,
|
|
37
|
+
OUTBOUND_UNIQUE_RECIPIENTS_PER_HOUR,
|
|
38
|
+
OUTBOUND_WARMING_EVENTS,
|
|
39
|
+
OUTBOUND_WINDOW_MS,
|
|
40
|
+
)
|
|
41
|
+
from .errors import StateSchemaIncompatibleError
|
|
42
|
+
|
|
43
|
+
#: Отметка о снятой защите: отправка разрешена без долговечного реестра.
|
|
44
|
+
#:
|
|
45
|
+
#: Имя объявлено контрактом в spec/runtime/budget.yaml, раздел durability, и
|
|
46
|
+
#: читается состоянием здоровья клиента. Отметка, которую никто не читает, - это
|
|
47
|
+
#: след, никуда не ведущий, и такой уже был у понижения нижнего предела опроса.
|
|
48
|
+
UNSAFE_SENDS_WITHOUT_LEDGER: Final[str] = "unsafe_sends_without_ledger"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
__all__ = ["OutboundGovernor", "OutboundRefusal", "Sending", "UNSAFE_SENDS_WITHOUT_LEDGER"]
|
|
52
|
+
|
|
53
|
+
#: Имена пределов в объявленном порядке проверки.
|
|
54
|
+
#:
|
|
55
|
+
#: Порядок НОРМАТИВЕН. На решении «пропустить или отказать» он не сказывается -
|
|
56
|
+
#: отказ есть отказ, - а на имени упёршегося предела сказывается, и имя есть
|
|
57
|
+
#: часть ответа: по нему вызывающий решает, ждать полминуты или не писать до
|
|
58
|
+
#: завтра.
|
|
59
|
+
LIMIT_ORDER: Final[tuple[str, ...]] = (
|
|
60
|
+
"cold_outreach_not_declared",
|
|
61
|
+
"min_interval_per_chat",
|
|
62
|
+
"cold_outreach_quota",
|
|
63
|
+
"messages_per_hour",
|
|
64
|
+
"unique_recipients_per_hour",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class Sending:
|
|
70
|
+
"""Одна попытка отправки.
|
|
71
|
+
|
|
72
|
+
Attributes:
|
|
73
|
+
chat_id (str): В какую переписку.
|
|
74
|
+
wall_ms (int): Момент по стенным часам, миллисекунды от эпохи.
|
|
75
|
+
monotonic_s (float | None): Показание монотонных часов. None у записи,
|
|
76
|
+
восстановленной после перезапуска: у неё монотонной метки нет и быть
|
|
77
|
+
не может - отсчёт монотонных часов свой в каждом запуске.
|
|
78
|
+
cold (bool): Было ли обращение холодным.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
chat_id: str
|
|
82
|
+
wall_ms: int
|
|
83
|
+
monotonic_s: float | None
|
|
84
|
+
cold: bool
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class OutboundRefusal:
|
|
89
|
+
"""Отказ ограничителя.
|
|
90
|
+
|
|
91
|
+
Attributes:
|
|
92
|
+
limit (str): Какой предел упёрся. Имя из объявленного перечня.
|
|
93
|
+
retry_after_ms (int): Через сколько предел освободится. Ноль означает,
|
|
94
|
+
что ожидание не поможет: исправлять надо вызов.
|
|
95
|
+
detail (str): Пояснение для человека.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
limit: str
|
|
99
|
+
retry_after_ms: int
|
|
100
|
+
detail: str
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(slots=True)
|
|
104
|
+
class OutboundGovernor:
|
|
105
|
+
"""Журнал исходящих и четыре предела над ним.
|
|
106
|
+
|
|
107
|
+
Attributes:
|
|
108
|
+
durable (bool): Есть ли долговечный реестр. Без него отправка отказывает:
|
|
109
|
+
пределы часовые, а память обнуляется перезапуском, и тридцать
|
|
110
|
+
сообщений в час превратились бы в тридцать НА ЗАПУСК.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
durable: bool = True
|
|
114
|
+
_sent: list[Sending] = field(default_factory=list, repr=False)
|
|
115
|
+
_incoming: dict[str, int] = field(default_factory=dict, repr=False)
|
|
116
|
+
|
|
117
|
+
def _age_ms(self, one: Sending, *, now_ms: int, now_s: float) -> int:
|
|
118
|
+
"""Возвращает возраст записи в миллисекундах.
|
|
119
|
+
|
|
120
|
+
Считается по МОНОТОННЫМ часам, если они у записи есть. Перевод системных
|
|
121
|
+
часов во время работы тогда квоту не трогает. После перезапуска
|
|
122
|
+
монотонной метки нет, и остаётся стенная.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
one (Sending): Запись.
|
|
126
|
+
now_ms (int): Текущий момент по стенным часам.
|
|
127
|
+
now_s (float): Текущее показание монотонных часов.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
int: Возраст в миллисекундах. Никогда не отрицателен: запись с
|
|
131
|
+
меткой из будущего считается свежей, а не просроченной - часы,
|
|
132
|
+
подведённые назад, не должны обнулять квоту.
|
|
133
|
+
"""
|
|
134
|
+
if one.monotonic_s is not None:
|
|
135
|
+
return max(0, int((now_s - one.monotonic_s) * 1000))
|
|
136
|
+
return max(0, now_ms - one.wall_ms)
|
|
137
|
+
|
|
138
|
+
def _within(self, window_ms: int, *, now_ms: int, now_s: float) -> list[Sending]:
|
|
139
|
+
"""Возвращает записи, попадающие в окно.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
window_ms (int): Ширина окна.
|
|
143
|
+
now_ms (int): Текущий момент по стенным часам.
|
|
144
|
+
now_s (float): Текущее показание монотонных часов.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
list[Sending]: Записи внутри окна.
|
|
148
|
+
"""
|
|
149
|
+
return [
|
|
150
|
+
one for one in self._sent if self._age_ms(one, now_ms=now_ms, now_s=now_s) < window_ms
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
def is_warm(self, chat_id: str, *, now_ms: int) -> bool:
|
|
154
|
+
"""Говорит, тёплая ли переписка.
|
|
155
|
+
|
|
156
|
+
Тепло требует ПОЛОЖИТЕЛЬНОГО свидетельства - наблюдённого входящего
|
|
157
|
+
сообщения в окне. Переписка считается холодной, пока не доказано
|
|
158
|
+
обратное.
|
|
159
|
+
|
|
160
|
+
Ошибка здесь стоит по-разному в две стороны: лишний отказ - неудобство,
|
|
161
|
+
лишняя отправка - наказание продавцу.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
chat_id (str): Переписка.
|
|
165
|
+
now_ms (int): Текущий момент по стенным часам.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
bool: True, если входящее сообщение наблюдалось в окне.
|
|
169
|
+
"""
|
|
170
|
+
seen = self._incoming.get(chat_id)
|
|
171
|
+
return seen is not None and 0 <= now_ms - seen < COLD_OUTREACH_WINDOW_MS
|
|
172
|
+
|
|
173
|
+
def note_event(self, event_type: str, chat_id: str, *, at_ms: int) -> bool:
|
|
174
|
+
"""Отмечает событие и говорит, согрело ли оно переписку.
|
|
175
|
+
|
|
176
|
+
ГРЕЮЩИЕ ВИДЫ БЕРУТСЯ ИЗ КОНТРАКТА, а не пишутся здесь литералом. Перечень
|
|
177
|
+
закрыт и состоит сегодня из одного вида - создания сообщения.
|
|
178
|
+
|
|
179
|
+
Счётчик непрочитанного в него не входит нарочно: он меняется и от НАШЕЙ
|
|
180
|
+
отправки, и ограничитель на нём отменял бы сам себя - первая отправка в
|
|
181
|
+
холодную переписку делала бы её тёплой, и квота холодных не сработала бы
|
|
182
|
+
ни разу.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
event_type (str): Вид события.
|
|
186
|
+
chat_id (str): Переписка.
|
|
187
|
+
at_ms (int): Момент по стенным часам.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
bool: True, если событие греет переписку.
|
|
191
|
+
"""
|
|
192
|
+
if event_type not in OUTBOUND_WARMING_EVENTS:
|
|
193
|
+
return False
|
|
194
|
+
self.note_incoming(chat_id, at_ms=at_ms)
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
def note_incoming(self, chat_id: str, *, at_ms: int) -> None:
|
|
198
|
+
"""Отмечает ВХОДЯЩЕЕ сообщение, греющее переписку.
|
|
199
|
+
|
|
200
|
+
Зовётся после того, как сторона сообщения уже установлена: греет только
|
|
201
|
+
входящее. Своё сообщение греть не может - см. note_event.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
chat_id (str): Переписка.
|
|
205
|
+
at_ms (int): Момент по стенным часам.
|
|
206
|
+
"""
|
|
207
|
+
known = self._incoming.get(chat_id)
|
|
208
|
+
if known is None or at_ms > known:
|
|
209
|
+
self._incoming[chat_id] = at_ms
|
|
210
|
+
|
|
211
|
+
def check(
|
|
212
|
+
self, chat_id: str, *, now_ms: int, now_s: float, declared_cold: bool = False
|
|
213
|
+
) -> OutboundRefusal | None:
|
|
214
|
+
"""Решает, можно ли отправить прямо сейчас.
|
|
215
|
+
|
|
216
|
+
Пределы проверяются в ОБЪЯВЛЕННОМ порядке: от ошибки вызывающего к
|
|
217
|
+
истории, и в истории - от узкого к широкому. Так названный предел
|
|
218
|
+
оказывается самым близким к разрешению из упёршихся.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
chat_id (str): Переписка.
|
|
222
|
+
now_ms (int): Текущий момент по стенным часам.
|
|
223
|
+
now_s (float): Показание монотонных часов. Приходит СНАРУЖИ, как и
|
|
224
|
+
момент наблюдения у разбора: ограничитель часов не читает,
|
|
225
|
+
иначе его нельзя ни проверить, ни повторить.
|
|
226
|
+
declared_cold (bool): Объявил ли вызывающий, что пишет первым.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
OutboundRefusal | None: Отказ либо None, если можно.
|
|
230
|
+
"""
|
|
231
|
+
if not self.durable:
|
|
232
|
+
return OutboundRefusal(
|
|
233
|
+
limit="no_durable_ledger",
|
|
234
|
+
retry_after_ms=0,
|
|
235
|
+
detail=(
|
|
236
|
+
"долговечного реестра отправок нет, и без него пределы обходятся "
|
|
237
|
+
"перезапуском процесса: часовая квота стала бы квотой на запуск"
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
cold = not self.is_warm(chat_id, now_ms=now_ms)
|
|
242
|
+
|
|
243
|
+
# 1. Ошибка вызывающего. Она о самом вызове, а не об истории.
|
|
244
|
+
if cold and not declared_cold:
|
|
245
|
+
return OutboundRefusal(
|
|
246
|
+
limit="cold_outreach_not_declared",
|
|
247
|
+
retry_after_ms=0,
|
|
248
|
+
detail=(
|
|
249
|
+
"по этой переписке не наблюдалось входящего сообщения в окне, "
|
|
250
|
+
"то есть обращение холодное. Холодное обращение требует явного "
|
|
251
|
+
"признака: ожидание тут не поможет, признак ставит вызывающий"
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# 2. Пауза на отдельную переписку - самый узкий предел.
|
|
256
|
+
same = [one for one in self._sent if one.chat_id == chat_id]
|
|
257
|
+
if same:
|
|
258
|
+
youngest = min(self._age_ms(one, now_ms=now_ms, now_s=now_s) for one in same)
|
|
259
|
+
if youngest < OUTBOUND_MIN_INTERVAL_PER_CHAT_MS:
|
|
260
|
+
return OutboundRefusal(
|
|
261
|
+
limit="min_interval_per_chat",
|
|
262
|
+
retry_after_ms=OUTBOUND_MIN_INTERVAL_PER_CHAT_MS - youngest,
|
|
263
|
+
detail=(
|
|
264
|
+
f"в эту переписку писали {youngest} мс назад при пределе "
|
|
265
|
+
f"{OUTBOUND_MIN_INTERVAL_PER_CHAT_MS} мс"
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# 3. Квота холодных обращений.
|
|
270
|
+
if cold:
|
|
271
|
+
chilly = [
|
|
272
|
+
one
|
|
273
|
+
for one in self._within(COLD_OUTREACH_WINDOW_MS, now_ms=now_ms, now_s=now_s)
|
|
274
|
+
if one.cold
|
|
275
|
+
]
|
|
276
|
+
if len(chilly) >= COLD_OUTREACH_QUOTA_PER_HOUR:
|
|
277
|
+
return OutboundRefusal(
|
|
278
|
+
limit="cold_outreach_quota",
|
|
279
|
+
retry_after_ms=self._frees_in(
|
|
280
|
+
chilly, COLD_OUTREACH_WINDOW_MS, now_ms=now_ms, now_s=now_s
|
|
281
|
+
),
|
|
282
|
+
detail=(
|
|
283
|
+
f"холодных обращений в окне {len(chilly)} при квоте "
|
|
284
|
+
f"{COLD_OUTREACH_QUOTA_PER_HOUR}"
|
|
285
|
+
),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
recent = self._within(OUTBOUND_WINDOW_MS, now_ms=now_ms, now_s=now_s)
|
|
289
|
+
|
|
290
|
+
# 4. Число сообщений в час.
|
|
291
|
+
if len(recent) >= OUTBOUND_MESSAGES_PER_HOUR:
|
|
292
|
+
return OutboundRefusal(
|
|
293
|
+
limit="messages_per_hour",
|
|
294
|
+
retry_after_ms=self._frees_in(
|
|
295
|
+
recent, OUTBOUND_WINDOW_MS, now_ms=now_ms, now_s=now_s
|
|
296
|
+
),
|
|
297
|
+
detail=f"сообщений в окне {len(recent)} при пределе {OUTBOUND_MESSAGES_PER_HOUR}",
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# 5. Число различных адресатов в час.
|
|
301
|
+
#
|
|
302
|
+
# Предел не трогает переписку, которая в окне УЖЕ есть: писать тому, кому
|
|
303
|
+
# писал, - не новый адресат.
|
|
304
|
+
recipients = {one.chat_id for one in recent}
|
|
305
|
+
if chat_id not in recipients and len(recipients) >= OUTBOUND_UNIQUE_RECIPIENTS_PER_HOUR:
|
|
306
|
+
return OutboundRefusal(
|
|
307
|
+
limit="unique_recipients_per_hour",
|
|
308
|
+
retry_after_ms=self._frees_in(
|
|
309
|
+
recent, OUTBOUND_WINDOW_MS, now_ms=now_ms, now_s=now_s
|
|
310
|
+
),
|
|
311
|
+
detail=(
|
|
312
|
+
f"различных адресатов в окне {len(recipients)} при пределе "
|
|
313
|
+
f"{OUTBOUND_UNIQUE_RECIPIENTS_PER_HOUR}"
|
|
314
|
+
),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
def _frees_in(
|
|
320
|
+
self, records: list[Sending], window_ms: int, *, now_ms: int, now_s: float
|
|
321
|
+
) -> int:
|
|
322
|
+
"""Считает, через сколько окно освободит одно место.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
records (list[Sending]): Записи внутри окна.
|
|
326
|
+
window_ms (int): Ширина окна.
|
|
327
|
+
now_ms (int): Текущий момент по стенным часам.
|
|
328
|
+
now_s (float): Текущее показание монотонных часов.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
int: Миллисекунды до выхода самой старой записи из окна.
|
|
332
|
+
"""
|
|
333
|
+
if not records:
|
|
334
|
+
return 0
|
|
335
|
+
oldest = max(self._age_ms(one, now_ms=now_ms, now_s=now_s) for one in records)
|
|
336
|
+
return max(0, window_ms - oldest)
|
|
337
|
+
|
|
338
|
+
def record(self, chat_id: str, *, now_ms: int, now_s: float) -> None:
|
|
339
|
+
"""Записывает ПОПЫТКУ отправки.
|
|
340
|
+
|
|
341
|
+
Зовётся ВПЕРЕДИ запроса, а не после ответа. Форма отказа канала не
|
|
342
|
+
наблюдалась, и «не засчитаем, раз не подтвердилось» означало бы не
|
|
343
|
+
считать ровно те отправки, которые могли уйти.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
chat_id (str): Переписка.
|
|
347
|
+
now_ms (int): Момент по стенным часам.
|
|
348
|
+
now_s (float): Показание монотонных часов.
|
|
349
|
+
"""
|
|
350
|
+
self._sent.append(
|
|
351
|
+
Sending(
|
|
352
|
+
chat_id=chat_id,
|
|
353
|
+
wall_ms=now_ms,
|
|
354
|
+
monotonic_s=now_s,
|
|
355
|
+
cold=not self.is_warm(chat_id, now_ms=now_ms),
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def forget_expired(self, *, now_ms: int, now_s: float) -> None:
|
|
360
|
+
"""Выбрасывает записи, вышедшие из самого широкого окна.
|
|
361
|
+
|
|
362
|
+
Реестр иначе растёт без предела: он переживает перезапуск, а значит
|
|
363
|
+
живёт столько же, сколько аккаунт.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
now_ms (int): Текущий момент по стенным часам.
|
|
367
|
+
now_s (float): Показание монотонных часов.
|
|
368
|
+
"""
|
|
369
|
+
widest = max(OUTBOUND_WINDOW_MS, COLD_OUTREACH_WINDOW_MS)
|
|
370
|
+
self._sent = [
|
|
371
|
+
one for one in self._sent if self._age_ms(one, now_ms=now_ms, now_s=now_s) < widest
|
|
372
|
+
]
|
|
373
|
+
# Метка ИЗ БУДУЩЕГО остаётся, а не выбрасывается как просроченная. Тот
|
|
374
|
+
# же возраст и то же правило, что у отправок: контракт требует этого
|
|
375
|
+
# прямо, в разделе clock_went_backwards.
|
|
376
|
+
#
|
|
377
|
+
# Разница с is_warm намеренная и записана в контракте отдельно. Там
|
|
378
|
+
# метка из будущего означает «переписка НЕ тёплая» - отказ временный, до
|
|
379
|
+
# того как часы догонят метку. Здесь выброс был бы ОКОНЧАТЕЛЬНЫМ:
|
|
380
|
+
# переведённые назад часы стирали бы тепло навсегда, и переписка,
|
|
381
|
+
# согретая покупателем, остывала бы от одной поправки времени.
|
|
382
|
+
self._incoming = {
|
|
383
|
+
chat: at
|
|
384
|
+
for chat, at in self._incoming.items()
|
|
385
|
+
if max(0, now_ms - at) < COLD_OUTREACH_WINDOW_MS
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
def snapshot(self) -> dict[str, Any]:
|
|
389
|
+
"""Отдаёт состояние обычными значениями для файла состояния.
|
|
390
|
+
|
|
391
|
+
Монотонные метки НАРУЖУ НЕ УХОДЯТ. Отсчёт их свой в каждом запуске, и
|
|
392
|
+
сохранённая монотонная метка после перезапуска означала бы не то, что
|
|
393
|
+
значила при записи: запись либо не истекала бы никогда, либо реестр
|
|
394
|
+
выбрасывался бы разом.
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
dict[str, Any]: Состояние, пригодное для записи в файл.
|
|
398
|
+
"""
|
|
399
|
+
return {
|
|
400
|
+
"sent": [
|
|
401
|
+
{"chat_id": one.chat_id, "at_ms": one.wall_ms, "cold": one.cold}
|
|
402
|
+
for one in self._sent
|
|
403
|
+
],
|
|
404
|
+
"incoming": dict(self._incoming),
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
def restore(self, payload: dict[str, Any], *, merge: bool = False) -> None:
|
|
408
|
+
"""Восстанавливает состояние из файла.
|
|
409
|
+
|
|
410
|
+
У восстановленных записей монотонной метки НЕТ - и не подставляется:
|
|
411
|
+
подставленная означала бы, что запись сделана сейчас, и квота обнулялась
|
|
412
|
+
бы перезапуском ровно так, как этого нельзя допустить.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
payload (dict[str, Any]): Прочитанное из файла состояния.
|
|
416
|
+
merge (bool): Добавить прочитанное к работающему ограничителю,
|
|
417
|
+
сохранив его монотонные метки и наиболее свежие входящие.
|
|
418
|
+
|
|
419
|
+
Raises:
|
|
420
|
+
StateSchemaIncompatibleError: Повреждённый журнал. Прежнее
|
|
421
|
+
состояние сохраняется целиком: пропуск записи сбросил бы квоту.
|
|
422
|
+
"""
|
|
423
|
+
if not isinstance(payload, dict):
|
|
424
|
+
raise StateSchemaIncompatibleError("журнал исходящих должен быть объектом")
|
|
425
|
+
sent = payload.get("sent", [])
|
|
426
|
+
incoming = payload.get("incoming", {})
|
|
427
|
+
if not isinstance(sent, list) or any(
|
|
428
|
+
not isinstance(one, dict)
|
|
429
|
+
or not isinstance(one.get("chat_id"), str)
|
|
430
|
+
or not one["chat_id"]
|
|
431
|
+
or type(one.get("at_ms")) is not int
|
|
432
|
+
or type(one.get("cold", True)) is not bool
|
|
433
|
+
for one in sent
|
|
434
|
+
):
|
|
435
|
+
raise StateSchemaIncompatibleError("непригодные записи отправок в журнале исходящих")
|
|
436
|
+
if not isinstance(incoming, dict) or any(
|
|
437
|
+
not isinstance(chat, str) or not chat or type(at) is not int
|
|
438
|
+
for chat, at in incoming.items()
|
|
439
|
+
):
|
|
440
|
+
raise StateSchemaIncompatibleError("непригодные входящие в журнале исходящих")
|
|
441
|
+
|
|
442
|
+
restored_sent = [
|
|
443
|
+
Sending(one["chat_id"], one["at_ms"], None, one.get("cold", True)) for one in sent
|
|
444
|
+
]
|
|
445
|
+
restored_incoming = dict(incoming)
|
|
446
|
+
if merge:
|
|
447
|
+
# Записи текущего процесса сохраняют монотонные часы. Прогон через
|
|
448
|
+
# snapshot превратил бы подключение файла в перезапуск ограничителя.
|
|
449
|
+
restored_sent = [*self._sent, *restored_sent]
|
|
450
|
+
for chat, at in self._incoming.items():
|
|
451
|
+
restored_incoming[chat] = max(at, restored_incoming.get(chat, at))
|
|
452
|
+
self._sent = restored_sent
|
|
453
|
+
self._incoming = restored_incoming
|