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/bot/_delivery.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Автовыдача товара: от события о заказе до сообщения покупателю.
|
|
2
|
+
|
|
3
|
+
ЧЕРТА, ВОКРУГ КОТОРОЙ ВСЁ УСТРОЕНО. Выдача идёт сама только там, где ВСЕ условия
|
|
4
|
+
выполнены положительно. Любое незнание - не повод действовать осторожнее, а
|
|
5
|
+
повод не действовать: вопрос уходит человеку.
|
|
6
|
+
|
|
7
|
+
Условий пять, и каждое отвечает на свой вопрос:
|
|
8
|
+
|
|
9
|
+
1. Состояние заказа прочитано и равно «оплачен». Не «не закрыт»: состояний
|
|
10
|
+
известно два из скольких-то, и заказ в возврате даёт ненаблюдённое значение.
|
|
11
|
+
Правило «если не закрыт, значит ждёт выдачи» на нём и ломается.
|
|
12
|
+
2. По этому заказу ещё не выдавали. Реестр выдач переживает перезапуск.
|
|
13
|
+
3. Чтение списка продаж полно. Неполное чтение означает, что часть строк не
|
|
14
|
+
разобрана, а не что их нет.
|
|
15
|
+
4. Заказ сопоставлен с ОДНИМ собственным лотом. Двусмысленность - отказ.
|
|
16
|
+
5. Для этого лота задан товар.
|
|
17
|
+
|
|
18
|
+
Что здесь СОЗНАТЕЛЬНО не делается: выдача не отменяется. Состояние возврата не
|
|
19
|
+
наблюдалось ни разу, отменять нечем, и механизм односторонний. Продавец, которому
|
|
20
|
+
это важно, обязан знать заранее.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from datetime import UTC, datetime
|
|
29
|
+
from typing import Final
|
|
30
|
+
|
|
31
|
+
from .._delivered import QUEUED_OUTCOME, Delivery, DeliveryLedger
|
|
32
|
+
from .._matching import match_offer
|
|
33
|
+
from .._orders import OrderListEntry
|
|
34
|
+
from .._own_lots import OwnLot
|
|
35
|
+
from .._result import Completeness
|
|
36
|
+
from ..extraction import OrderStatus
|
|
37
|
+
from ._outbox import SendTicket
|
|
38
|
+
|
|
39
|
+
__all__ = ["DeliveryPlan", "DeliveryDecision", "AutoDelivery", "HOLD_FOR_OPERATOR"]
|
|
40
|
+
|
|
41
|
+
_log = logging.getLogger("funora.bot.delivery")
|
|
42
|
+
|
|
43
|
+
#: Решение: выдать нечего, вопрос уходит человеку.
|
|
44
|
+
HOLD_FOR_OPERATOR: Final[str] = "hold_for_operator"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class DeliveryDecision:
|
|
49
|
+
"""Что решено по заказу.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
order_id (str): Заказ.
|
|
53
|
+
will_deliver (bool): Выдаётся ли сам.
|
|
54
|
+
reason (str): Машиночитаемая причина решения.
|
|
55
|
+
offer_id (str): Опознанное предложение. Пустая строка, если не
|
|
56
|
+
опознано.
|
|
57
|
+
candidates (tuple[str, ...]): Подошедшие предложения. Больше одного
|
|
58
|
+
означает, что различить их нечем.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
order_id: str
|
|
62
|
+
will_deliver: bool
|
|
63
|
+
reason: str
|
|
64
|
+
offer_id: str = ""
|
|
65
|
+
candidates: tuple[str, ...] = ()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class DeliveryPlan:
|
|
70
|
+
"""Что и кому выдавать.
|
|
71
|
+
|
|
72
|
+
Attributes:
|
|
73
|
+
goods (dict[str, str]): Товар по идентификатору предложения. Что именно
|
|
74
|
+
отправить покупателю, когда заказ опознан.
|
|
75
|
+
chat_of (Callable[[str], str]): Как узнать узел диалога по заказу.
|
|
76
|
+
Отдельной функцией, а не полем: узел лежит на странице заказа, идти
|
|
77
|
+
за ним - запрос, и делать этот запрос стоит только тогда, когда всё
|
|
78
|
+
остальное сошлось.
|
|
79
|
+
declared_cold (bool): Объявлять ли обращение холодным.
|
|
80
|
+
|
|
81
|
+
ПО УМОЛЧАНИЮ ДА, и это не послабление, а исправление. Холодной
|
|
82
|
+
считается переписка, в которой собеседник нам не писал, а покупатель,
|
|
83
|
+
купивший и промолчавший, - самый обычный случай, а не редкий.
|
|
84
|
+
Автовыдача прежде звала отправку без этого признака, ограничитель
|
|
85
|
+
отвергал её с cold_outreach_not_declared, и товар не уходил ровно
|
|
86
|
+
тому, кто вёл себя тише всех. Заказ при этом уже был помечен
|
|
87
|
+
выданным.
|
|
88
|
+
|
|
89
|
+
Признание здесь честное: обращение вправду холодное. Покупка
|
|
90
|
+
переписку не греет - это записано в spec/runtime/budget.yaml и
|
|
91
|
+
остаётся верным. Но выдача оплаченного товара и есть тот случай, ради
|
|
92
|
+
которого квота холодных обращений существует.
|
|
93
|
+
|
|
94
|
+
ПОМНИТЕ ПРО ПОТОЛОК: холодных обращений три в сутки. Продавцу, у
|
|
95
|
+
которого продаж больше, придётся либо здороваться с покупателем
|
|
96
|
+
первым по событию о заказе - тогда переписка станет тёплой его
|
|
97
|
+
ответом, - либо выдавать руками. Молча упереться в потолок нельзя:
|
|
98
|
+
отправка откажет вслух, и заказ уйдёт в on_hold.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
goods: dict[str, str]
|
|
102
|
+
chat_of: Callable[[str], str]
|
|
103
|
+
declared_cold: bool = True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class AutoDelivery:
|
|
107
|
+
"""Автовыдача: решает по заказу и кладёт задание в очередь.
|
|
108
|
+
|
|
109
|
+
Сама ничего не отправляет и в сеть не ходит. Отправку выполняет очередь
|
|
110
|
+
исходящих, а значит - тот же поток, что ведёт наблюдение, и с теми же
|
|
111
|
+
пределами.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
plan (DeliveryPlan): Что и кому выдавать.
|
|
115
|
+
ledger (DeliveryLedger): Реестр уже выданного.
|
|
116
|
+
send (Callable[[str, str, str, bool], SendTicket]): Как поставить
|
|
117
|
+
отправку в очередь: узел, текст, ключ идемпотентности, признание
|
|
118
|
+
холодного обращения.
|
|
119
|
+
on_hold (Callable[[DeliveryDecision], None] | None): Что делать с
|
|
120
|
+
заказом, который сам не выдаётся. Без него такой заказ виден только
|
|
121
|
+
в журнале.
|
|
122
|
+
persist (Callable[[], None] | None): Как сохранить реестр на диск.
|
|
123
|
+
|
|
124
|
+
Без него реестр живёт в памяти процесса, и каждый перезапуск
|
|
125
|
+
обнуляет память о выданном: заказ, который в списке продаж всё ещё
|
|
126
|
+
оплачен, выдаётся второй раз.
|
|
127
|
+
|
|
128
|
+
Собирать автовыдачу руками поэтому не стоит - есть Bot.deliveries(),
|
|
129
|
+
который связывает всё правильно по построению.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
__slots__ = ("_plan", "_ledger", "_send", "_on_hold", "_persist", "_decisions")
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
plan: DeliveryPlan,
|
|
137
|
+
ledger: DeliveryLedger,
|
|
138
|
+
send: Callable[[str, str, str, bool], SendTicket],
|
|
139
|
+
on_hold: Callable[[DeliveryDecision], None] | None = None,
|
|
140
|
+
persist: Callable[[], None] | None = None,
|
|
141
|
+
) -> None:
|
|
142
|
+
self._plan = plan
|
|
143
|
+
self._ledger = ledger
|
|
144
|
+
self._send = send
|
|
145
|
+
self._on_hold = on_hold
|
|
146
|
+
self._persist = persist
|
|
147
|
+
self._decisions: list[DeliveryDecision] = []
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def decisions(self) -> tuple[DeliveryDecision, ...]:
|
|
151
|
+
"""Решения, принятые с начала работы.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
tuple[DeliveryDecision, ...]: Все решения по порядку.
|
|
155
|
+
"""
|
|
156
|
+
return tuple(self._decisions)
|
|
157
|
+
|
|
158
|
+
def decide(
|
|
159
|
+
self,
|
|
160
|
+
order: OrderListEntry,
|
|
161
|
+
lots: tuple[OwnLot, ...],
|
|
162
|
+
*,
|
|
163
|
+
page_completeness: Completeness,
|
|
164
|
+
) -> DeliveryDecision:
|
|
165
|
+
"""Решает, выдавать ли по заказу, и НИЧЕГО не отправляет.
|
|
166
|
+
|
|
167
|
+
Метод чистый нарочно: решение проверяется без сети и без очереди, а
|
|
168
|
+
решение здесь - самое дорогое место всего механизма.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
order (OrderListEntry): Строка списка продаж.
|
|
172
|
+
lots (tuple[OwnLot, ...]): Собственные лоты раздела.
|
|
173
|
+
page_completeness (Completeness): Полнота чтения списка продаж.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
DeliveryDecision: Что решено и почему.
|
|
177
|
+
"""
|
|
178
|
+
decision = self._decide(order, lots, page_completeness=page_completeness)
|
|
179
|
+
self._decisions.append(decision)
|
|
180
|
+
return decision
|
|
181
|
+
|
|
182
|
+
def _decide(
|
|
183
|
+
self,
|
|
184
|
+
order: OrderListEntry,
|
|
185
|
+
lots: tuple[OwnLot, ...],
|
|
186
|
+
*,
|
|
187
|
+
page_completeness: Completeness,
|
|
188
|
+
) -> DeliveryDecision:
|
|
189
|
+
"""Проверяет пять условий по порядку.
|
|
190
|
+
|
|
191
|
+
Порядок дешёвых проверок вперёд: сперва то, что не требует разбора
|
|
192
|
+
текста.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
order (OrderListEntry): Строка списка продаж.
|
|
196
|
+
lots (tuple[OwnLot, ...]): Собственные лоты раздела.
|
|
197
|
+
page_completeness (Completeness): Полнота чтения списка продаж.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
DeliveryDecision: Что решено и почему.
|
|
201
|
+
"""
|
|
202
|
+
if self._ledger.seen(order.order_id):
|
|
203
|
+
return DeliveryDecision(order.order_id, False, "already_delivered")
|
|
204
|
+
|
|
205
|
+
if page_completeness is not Completeness.COMPLETE:
|
|
206
|
+
# Неполное чтение означает, что часть строк не разобрана. Выдавать
|
|
207
|
+
# по такому списку значило бы решать по данным, которых нет.
|
|
208
|
+
return DeliveryDecision(order.order_id, False, "orders_page_incomplete")
|
|
209
|
+
|
|
210
|
+
if not order.status.is_observed:
|
|
211
|
+
# Состояние не прочитано. Это НЕ «ещё не закрыт»: заказ в возврате
|
|
212
|
+
# или споре даёт ровно такое значение.
|
|
213
|
+
return DeliveryDecision(order.order_id, False, "status_not_observed")
|
|
214
|
+
|
|
215
|
+
if order.status.value is not OrderStatus.PAID:
|
|
216
|
+
return DeliveryDecision(order.order_id, False, "status_not_paid")
|
|
217
|
+
|
|
218
|
+
matched = match_offer(
|
|
219
|
+
order.description_text,
|
|
220
|
+
{one.offer_id.value: one.description_text for one in lots if one.offer_id.is_observed},
|
|
221
|
+
)
|
|
222
|
+
if not matched.offer_id.is_observed:
|
|
223
|
+
return DeliveryDecision(
|
|
224
|
+
order.order_id,
|
|
225
|
+
False,
|
|
226
|
+
str(matched.offer_id.reason),
|
|
227
|
+
candidates=matched.candidates,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
offer_id = matched.offer_id.value
|
|
231
|
+
if offer_id not in self._plan.goods:
|
|
232
|
+
return DeliveryDecision(order.order_id, False, "no_goods_for_offer", offer_id=offer_id)
|
|
233
|
+
|
|
234
|
+
return DeliveryDecision(
|
|
235
|
+
order.order_id,
|
|
236
|
+
True,
|
|
237
|
+
"ready",
|
|
238
|
+
offer_id=offer_id,
|
|
239
|
+
candidates=matched.candidates,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def handle(
|
|
243
|
+
self,
|
|
244
|
+
order: OrderListEntry,
|
|
245
|
+
lots: tuple[OwnLot, ...],
|
|
246
|
+
*,
|
|
247
|
+
page_completeness: Completeness,
|
|
248
|
+
) -> SendTicket | None:
|
|
249
|
+
"""Решает и, если можно, ставит выдачу в очередь.
|
|
250
|
+
|
|
251
|
+
Запись в реестр идёт ВПЕРЕДИ постановки в очередь. Порядок тот же и по
|
|
252
|
+
той же причине, что у реестра отправок: «запишем, когда подтвердится»
|
|
253
|
+
означает не записать ровно те выдачи, которые могли уйти.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
order (OrderListEntry): Строка списка продаж.
|
|
257
|
+
lots (tuple[OwnLot, ...]): Собственные лоты раздела.
|
|
258
|
+
page_completeness (Completeness): Полнота чтения списка продаж.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
SendTicket | None: Квитанция отправки либо None, если выдача не
|
|
262
|
+
состоялась.
|
|
263
|
+
"""
|
|
264
|
+
decision = self.decide(order, lots, page_completeness=page_completeness)
|
|
265
|
+
if not decision.will_deliver:
|
|
266
|
+
_log.info(
|
|
267
|
+
"заказ %s не выдаётся сам: %s%s",
|
|
268
|
+
decision.order_id,
|
|
269
|
+
decision.reason,
|
|
270
|
+
f", кандидаты {list(decision.candidates)}" if decision.candidates else "",
|
|
271
|
+
)
|
|
272
|
+
if self._on_hold is not None:
|
|
273
|
+
self._on_hold(decision)
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
# УЗЕЛ ДИАЛОГА БЕРЁТСЯ ДО ЗАПИСИ В РЕЕСТР, и порядок здесь важнее, чем
|
|
277
|
+
# кажется. chat_of - это СЕТЕВОЕ чтение страницы заказа, а не поле:
|
|
278
|
+
# именно поэтому оно и сделано функцией.
|
|
279
|
+
#
|
|
280
|
+
# Прежде запись стояла впереди него, и всякий отказ этого чтения -
|
|
281
|
+
# оборванная сессия, исчерпанный бюджет, изменившаяся вёрстка - оставлял
|
|
282
|
+
# заказ НАВСЕГДА помеченным выданным при нуле отправок. Повторно он не
|
|
283
|
+
# выдаётся никогда, а on_hold не срабатывает: решение-то было
|
|
284
|
+
# положительным.
|
|
285
|
+
#
|
|
286
|
+
# Что довод «пишем впереди отправки» защищает - это окно между записью и
|
|
287
|
+
# ОТПРАВКОЙ. Сетевое чтение адреса в это окно не входит: оно происходит
|
|
288
|
+
# до того, как принято решение действовать, и его отказ означает «мы ещё
|
|
289
|
+
# ничего не сделали», а не «мы, возможно, уже отправили».
|
|
290
|
+
try:
|
|
291
|
+
chat_id = self._plan.chat_of(decision.order_id)
|
|
292
|
+
except Exception as exc:
|
|
293
|
+
_log.warning(
|
|
294
|
+
"заказ %s: узел диалога не прочитан (%s), выдача не состоялась и в реестр "
|
|
295
|
+
"не записана",
|
|
296
|
+
decision.order_id,
|
|
297
|
+
type(exc).__name__,
|
|
298
|
+
)
|
|
299
|
+
if self._on_hold is not None:
|
|
300
|
+
self._on_hold(
|
|
301
|
+
DeliveryDecision(
|
|
302
|
+
decision.order_id,
|
|
303
|
+
False,
|
|
304
|
+
"chat_not_read",
|
|
305
|
+
offer_id=decision.offer_id,
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
self._ledger.record(
|
|
311
|
+
Delivery(
|
|
312
|
+
order_id=decision.order_id,
|
|
313
|
+
offer_id=decision.offer_id,
|
|
314
|
+
at_ms=int(datetime.now(UTC).timestamp() * 1000),
|
|
315
|
+
outcome=QUEUED_OUTCOME,
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
# Сохранение НА ДИСК тоже впереди отправки, и по тому же доводу:
|
|
319
|
+
# перезапуск между постановкой в очередь и записью выдал бы товар
|
|
320
|
+
# второй раз.
|
|
321
|
+
if self._persist is not None:
|
|
322
|
+
self._persist()
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
return self._send(
|
|
326
|
+
chat_id,
|
|
327
|
+
self._plan.goods[decision.offer_id],
|
|
328
|
+
f"delivery:{decision.order_id}",
|
|
329
|
+
self._plan.declared_cold,
|
|
330
|
+
)
|
|
331
|
+
except Exception:
|
|
332
|
+
self._ledger.settle(decision.order_id, "queue_failed")
|
|
333
|
+
if self._persist is not None:
|
|
334
|
+
self._persist()
|
|
335
|
+
if self._on_hold is not None:
|
|
336
|
+
self._on_hold(
|
|
337
|
+
DeliveryDecision(
|
|
338
|
+
decision.order_id, False, "queue_failed", offer_id=decision.offer_id
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
raise
|
funora/bot/_outbox.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Очередь исходящих: как попросить об отправке из чужого потока.
|
|
2
|
+
|
|
3
|
+
ЗАЧЕМ ОНА ВООБЩЕ НУЖНА. Клиент не защищён ни одной блокировкой. Бюджет,
|
|
4
|
+
ограничитель исходящих и состояние сессии - обычные изменяемые объекты, и у
|
|
5
|
+
двух из них проверка с последующей записью не атомарна: ограничитель сперва
|
|
6
|
+
спрашивает `check`, потом пишет `record`, а бюджет сперва резервирует, потом
|
|
7
|
+
списывает.
|
|
8
|
+
|
|
9
|
+
Второй поток, зовущий отправку напрямую, эти пары разрывает. Проявится это не
|
|
10
|
+
отказом и не исключением, а НЕДОСЧЁТОМ: ограничитель решит, что за час ушло
|
|
11
|
+
меньше сообщений, чем ушло на самом деле. Узнаете вы об этом от площадки.
|
|
12
|
+
|
|
13
|
+
Поэтому здесь заведён единственный порядок, при котором посторонний поток может
|
|
14
|
+
попросить об отправке: положить задание в очередь. Разбирает её тот же поток,
|
|
15
|
+
что ведёт наблюдение, - в паузе между опросами.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import threading
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from queue import Empty, Full, Queue
|
|
23
|
+
from typing import Final
|
|
24
|
+
|
|
25
|
+
from .._runner import SendResult
|
|
26
|
+
|
|
27
|
+
__all__ = ["SendCommand", "SendTicket", "Outbox"]
|
|
28
|
+
|
|
29
|
+
#: Сколько заданий очередь принимает, прежде чем отказывать.
|
|
30
|
+
#:
|
|
31
|
+
#: Предел нужен: телеграм-бот, у которого что-то заклинило, наполняет очередь
|
|
32
|
+
#: быстрее, чем наблюдение её вычерпывает - три отправки за шаг против
|
|
33
|
+
#: неограниченного числа нажатий. Очередь без предела съела бы память, а
|
|
34
|
+
#: сообщения всё равно ушли бы с опозданием на часы.
|
|
35
|
+
MAX_PENDING: Final[int] = 256
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class SendCommand:
|
|
40
|
+
"""Просьба отправить сообщение.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
chat_id (str): Числовой идентификатор диалога.
|
|
44
|
+
text (str): Текст сообщения.
|
|
45
|
+
idempotency_key (str): Ключ, по которому повтор узнаётся повтором.
|
|
46
|
+
Задание с уже виденным ключом не отправляется второй раз.
|
|
47
|
+
|
|
48
|
+
Обязателен НАРОЧНО, умолчания у него нет. Отправка необратима:
|
|
49
|
+
лишнее сообщение покупателю не отменить, а перезапуск процесса,
|
|
50
|
+
повтор события и нажатие кнопки дважды - обычные вещи. Ключ, который
|
|
51
|
+
вызывающий обязан придумать сам, заставляет его хотя бы раз подумать
|
|
52
|
+
о том, что считается тем же самым сообщением.
|
|
53
|
+
declared_cold (bool): Признание, что переписка холодная и вы пишете
|
|
54
|
+
первым.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
chat_id: str
|
|
58
|
+
text: str
|
|
59
|
+
idempotency_key: str
|
|
60
|
+
declared_cold: bool = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(slots=True)
|
|
64
|
+
class SendTicket:
|
|
65
|
+
"""Квитанция о принятом задании: чем оно кончилось.
|
|
66
|
+
|
|
67
|
+
Ждать её можно из любого потока. Отправка при этом происходит не здесь, а в
|
|
68
|
+
потоке наблюдения.
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
command (SendCommand): Задание, о котором квитанция.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
command: SendCommand
|
|
75
|
+
_done: threading.Event = field(default_factory=threading.Event)
|
|
76
|
+
_result: SendResult | None = None
|
|
77
|
+
_error: Exception | None = None
|
|
78
|
+
_duplicate: bool = False
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def duplicate(self) -> bool:
|
|
82
|
+
"""Сообщает, что задание отброшено как повтор.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
bool: True, если ключ идемпотентности уже встречался.
|
|
86
|
+
"""
|
|
87
|
+
return self._duplicate
|
|
88
|
+
|
|
89
|
+
def settle(
|
|
90
|
+
self,
|
|
91
|
+
*,
|
|
92
|
+
result: SendResult | None = None,
|
|
93
|
+
error: Exception | None = None,
|
|
94
|
+
duplicate: bool = False,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Закрывает квитанцию.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
result (SendResult | None): Исход отправки.
|
|
100
|
+
error (FunoraError | None): Отказ, если отправка не состоялась.
|
|
101
|
+
duplicate (bool): Отброшено ли задание как повтор.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
None
|
|
105
|
+
"""
|
|
106
|
+
self._result = result
|
|
107
|
+
self._error = error
|
|
108
|
+
self._duplicate = duplicate
|
|
109
|
+
self._done.set()
|
|
110
|
+
|
|
111
|
+
def wait(self, timeout: float | None = None) -> SendResult | None:
|
|
112
|
+
"""Ждёт исхода задания.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
timeout (float | None): Сколько секунд ждать. None означает без
|
|
116
|
+
предела.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
SendResult | None: Исход отправки. None означает, что задание
|
|
120
|
+
отброшено как повтор либо ожидание истекло, - различить это можно
|
|
121
|
+
признаками `duplicate` и `ready`.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
FunoraError: Тот самый отказ, что случился в потоке наблюдения.
|
|
125
|
+
Бросается ЗДЕСЬ, в потоке вызывающего: иначе он о нём не узнал
|
|
126
|
+
бы вовсе - в потоке наблюдения его некому ловить.
|
|
127
|
+
"""
|
|
128
|
+
self._done.wait(timeout)
|
|
129
|
+
if self._error is not None:
|
|
130
|
+
raise self._error
|
|
131
|
+
return self._result
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def ready(self) -> bool:
|
|
135
|
+
"""Сообщает, закрыта ли квитанция.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
bool: True, если задание уже отработано.
|
|
139
|
+
"""
|
|
140
|
+
return self._done.is_set()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class Outbox:
|
|
144
|
+
"""Очередь исходящих сообщений.
|
|
145
|
+
|
|
146
|
+
Класть задания можно из любого потока. Разбирает очередь тот, кто ведёт
|
|
147
|
+
наблюдение, и только он.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
max_pending (int): Сколько заданий держать, прежде чем отказывать.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
__slots__ = ("_queue", "_seen", "_lock", "_owner")
|
|
154
|
+
|
|
155
|
+
def __init__(self, max_pending: int = MAX_PENDING) -> None:
|
|
156
|
+
# Ноль и отрицательное у очереди стандартной библиотеки означают БЕЗ
|
|
157
|
+
# ПРЕДЕЛА - ровно наоборот тому, что читается в имени. Единственный
|
|
158
|
+
# предел, объявленный здесь обязательным, снимался бы значением,
|
|
159
|
+
# которое выглядит как «не принимать ничего».
|
|
160
|
+
if max_pending < 1:
|
|
161
|
+
from ..errors import ValidationError
|
|
162
|
+
|
|
163
|
+
raise ValidationError(
|
|
164
|
+
f"предел очереди исходящих {max_pending} не годится: очередь "
|
|
165
|
+
"стандартной библиотеки понимает ноль и отрицательное как "
|
|
166
|
+
"«без предела», то есть снимает защиту, а не ужесточает её"
|
|
167
|
+
)
|
|
168
|
+
self._queue: Queue[SendTicket] = Queue(maxsize=max_pending)
|
|
169
|
+
# Ключи, которые уже отработаны. Множество растёт, и это осознанно:
|
|
170
|
+
# забыть ключ значит разрешить повтор, а повтор здесь - второе сообщение
|
|
171
|
+
# покупателю. Память тут дешевле.
|
|
172
|
+
self._seen: set[str] = set()
|
|
173
|
+
self._lock = threading.Lock()
|
|
174
|
+
self._owner: int | None = None
|
|
175
|
+
|
|
176
|
+
def put(self, command: SendCommand) -> SendTicket:
|
|
177
|
+
"""Кладёт задание в очередь.
|
|
178
|
+
|
|
179
|
+
Звать можно из любого потока.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
command (SendCommand): Просьба отправить сообщение.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
SendTicket: Квитанция, по которой можно дождаться исхода.
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
UsageError: Если очередь переполнена.
|
|
189
|
+
"""
|
|
190
|
+
from ..errors import UsageError
|
|
191
|
+
|
|
192
|
+
ticket = SendTicket(command=command)
|
|
193
|
+
|
|
194
|
+
with self._lock:
|
|
195
|
+
if command.idempotency_key in self._seen:
|
|
196
|
+
# Повтор узнаётся ДО очереди: иначе он занял бы место и дождался
|
|
197
|
+
# бы своей отбраковки только у исполнителя.
|
|
198
|
+
ticket.settle(duplicate=True)
|
|
199
|
+
return ticket
|
|
200
|
+
try:
|
|
201
|
+
self._queue.put_nowait(ticket)
|
|
202
|
+
except Full as exc:
|
|
203
|
+
raise UsageError(
|
|
204
|
+
f"очередь исходящих переполнена: {self._queue.maxsize} заданий ждут отправки"
|
|
205
|
+
) from exc
|
|
206
|
+
self._seen.add(command.idempotency_key)
|
|
207
|
+
|
|
208
|
+
return ticket
|
|
209
|
+
|
|
210
|
+
def take(self, limit: int) -> list[SendTicket]:
|
|
211
|
+
"""Забирает из очереди до указанного числа заданий.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
limit (int): Сколько заданий забрать.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
list[SendTicket]: Взятые задания в порядке поступления.
|
|
218
|
+
"""
|
|
219
|
+
taken: list[SendTicket] = []
|
|
220
|
+
for _ in range(max(0, limit)):
|
|
221
|
+
try:
|
|
222
|
+
taken.append(self._queue.get_nowait())
|
|
223
|
+
except Empty:
|
|
224
|
+
break
|
|
225
|
+
return taken
|
|
226
|
+
|
|
227
|
+
def claim(self) -> None:
|
|
228
|
+
"""Запоминает поток, который разбирает очередь.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
None
|
|
232
|
+
"""
|
|
233
|
+
from ..errors import UsageError
|
|
234
|
+
|
|
235
|
+
with self._lock:
|
|
236
|
+
if self._owner is not None:
|
|
237
|
+
raise UsageError("очередь уже принадлежит запущенному циклу бота")
|
|
238
|
+
self._owner = threading.get_ident()
|
|
239
|
+
|
|
240
|
+
def release(self) -> None:
|
|
241
|
+
"""Снимает владение после выхода из цикла."""
|
|
242
|
+
with self._lock:
|
|
243
|
+
if self._owner == threading.get_ident():
|
|
244
|
+
self._owner = None
|
|
245
|
+
|
|
246
|
+
def is_owner(self) -> bool:
|
|
247
|
+
"""Сообщает, тот ли это поток, что разбирает очередь.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
bool: True, если вызов идёт из потока наблюдения.
|
|
251
|
+
"""
|
|
252
|
+
return self._owner is not None and self._owner == threading.get_ident()
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def pending(self) -> int:
|
|
256
|
+
"""Сколько заданий ждёт отправки.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
int: Длина очереди.
|
|
260
|
+
"""
|
|
261
|
+
return self._queue.qsize()
|