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/_runtime.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""Рантайм бота: наблюдение и разбор очереди исходящих в одном потоке.
|
|
2
|
+
|
|
3
|
+
Здесь нет ни своего цикла, ни своей политики повторов, ни своего порядка шагов.
|
|
4
|
+
Всё это живёт в ядре и достаётся готовым: рантайм только подставляет разбор
|
|
5
|
+
очереди в паузу между опросами - через крючок on_idle у драйвера.
|
|
6
|
+
|
|
7
|
+
Одно правило, ради которого класс и написан: ПЛОЩАДКУ ТРОГАЕТ ОДИН ПОТОК.
|
|
8
|
+
Посторонний кладёт задание в очередь и ждёт квитанцию; отправляет тот же поток,
|
|
9
|
+
что ведёт наблюдение. Иначе ограничитель исходящих недосчитывает предел, и
|
|
10
|
+
узнаёте вы об этом от площадки.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from contextlib import nullcontext
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Final
|
|
20
|
+
|
|
21
|
+
from .._client import Client
|
|
22
|
+
from .._fileio import file_lock
|
|
23
|
+
from .._poll import Schedule
|
|
24
|
+
from .._watch import Router
|
|
25
|
+
from ..errors import (
|
|
26
|
+
ConfigurationError,
|
|
27
|
+
FunoraError,
|
|
28
|
+
HandlerError,
|
|
29
|
+
UsageError,
|
|
30
|
+
ValidationError,
|
|
31
|
+
)
|
|
32
|
+
from ._outbox import Outbox, SendCommand, SendTicket
|
|
33
|
+
from ._spool import Spool
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from ._delivery import AutoDelivery, DeliveryDecision, DeliveryPlan
|
|
37
|
+
|
|
38
|
+
__all__ = ["Bot", "MAX_SENDS_PER_IDLE"]
|
|
39
|
+
|
|
40
|
+
_log = logging.getLogger("funora.bot")
|
|
41
|
+
|
|
42
|
+
#: Сколько отправок разбирать за одну паузу.
|
|
43
|
+
#:
|
|
44
|
+
#: Предел нужен, и он не про вежливость. Одна отправка - это чтение страницы
|
|
45
|
+
#: диалога плюс сама отправка, а при неоднозначном ответе ещё и сверка: три
|
|
46
|
+
#: чтения переписки с паузами 1, 3 и 8 секунд. Три задания подряд в худшем
|
|
47
|
+
#: случае растягивают паузу на минуту, и всё это время наблюдение стоит.
|
|
48
|
+
MAX_SENDS_PER_IDLE: Final[int] = 3
|
|
49
|
+
|
|
50
|
+
#: Приставка ключа идемпотентности, которой автовыдача помечает свои задания.
|
|
51
|
+
#:
|
|
52
|
+
#: Единственный носитель связи между заданием в очереди и заказом: очередь про
|
|
53
|
+
#: заказы не знает и знать не должна, а реестру выдач нужно узнать, чем кончилась
|
|
54
|
+
#: отправка. Ключ чужого задания этой приставки не несёт и пропускается.
|
|
55
|
+
_DELIVERY_KEY_PREFIX: Final[str] = "delivery:"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Bot:
|
|
59
|
+
"""Наблюдение с очередью исходящих.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
client (Client): Клиент. Рантайм становится его единственным
|
|
63
|
+
пользователем: звать операции клиента из других потоков нельзя.
|
|
64
|
+
router (Router): Реестр обработчиков событий.
|
|
65
|
+
max_sends_per_idle (int): Сколько отправок разбирать за одну паузу.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
__slots__ = (
|
|
69
|
+
"_client",
|
|
70
|
+
"_router",
|
|
71
|
+
"_outbox",
|
|
72
|
+
"_spool",
|
|
73
|
+
"_limit",
|
|
74
|
+
"_sent",
|
|
75
|
+
"_refused",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
client: Client,
|
|
81
|
+
router: Router,
|
|
82
|
+
*,
|
|
83
|
+
max_sends_per_idle: int = MAX_SENDS_PER_IDLE,
|
|
84
|
+
spool_path: Path | str | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
# Ноль означал бы «не разбирать очередь никогда»: take(0) отдаёт
|
|
87
|
+
# пустой перечень, задания копятся, квитанции не закрываются, и
|
|
88
|
+
# положивший их ждёт вечно. Молча.
|
|
89
|
+
if max_sends_per_idle < 1:
|
|
90
|
+
raise ValidationError(
|
|
91
|
+
f"предел отправок за паузу {max_sends_per_idle} не годится: при "
|
|
92
|
+
"нём очередь не разбирается никогда, задания копятся, а "
|
|
93
|
+
"положивший их ждёт закрытия квитанции, которого не будет"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
self._client = client
|
|
97
|
+
self._router = router
|
|
98
|
+
self._outbox = Outbox()
|
|
99
|
+
|
|
100
|
+
#: Очередь между ПРОЦЕССАМИ. None означает, что её не просили.
|
|
101
|
+
#:
|
|
102
|
+
#: Она не заменяет очередь в памяти, а дополняет её: та решает
|
|
103
|
+
#: задачу про потоки, эта - про процессы. Телеграм-бот, поднятый
|
|
104
|
+
#: отдельной командой, до очереди в памяти не дотягивается ничем.
|
|
105
|
+
self._spool = Spool(spool_path) if spool_path is not None else None
|
|
106
|
+
self._limit = max_sends_per_idle
|
|
107
|
+
self._sent = 0
|
|
108
|
+
self._refused = 0
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def spool(self) -> Spool | None:
|
|
112
|
+
"""Очередь исходящих между процессами.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Spool | None: Очередь либо None, если её не просили.
|
|
116
|
+
"""
|
|
117
|
+
return self._spool
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def outbox(self) -> Outbox:
|
|
121
|
+
"""Очередь исходящих.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Outbox: Очередь, в которую можно класть из любого потока.
|
|
125
|
+
"""
|
|
126
|
+
return self._outbox
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def sent(self) -> int:
|
|
130
|
+
"""Сколько заданий отправлено с начала работы.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
int: Число отправок, дошедших до площадки.
|
|
134
|
+
"""
|
|
135
|
+
return self._sent
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def refused(self) -> int:
|
|
139
|
+
"""Сколько заданий отвергнуто отказом.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
int: Число заданий, на которых отправка не состоялась.
|
|
143
|
+
"""
|
|
144
|
+
return self._refused
|
|
145
|
+
|
|
146
|
+
def send(
|
|
147
|
+
self,
|
|
148
|
+
chat_id: str,
|
|
149
|
+
text: str,
|
|
150
|
+
*,
|
|
151
|
+
idempotency_key: str,
|
|
152
|
+
declared_cold: bool = False,
|
|
153
|
+
) -> SendTicket:
|
|
154
|
+
"""Просит отправить сообщение.
|
|
155
|
+
|
|
156
|
+
Звать можно из любого потока - в этом весь смысл. Сама отправка
|
|
157
|
+
произойдёт в потоке наблюдения, в ближайшей паузе.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
chat_id (str): Числовой идентификатор диалога.
|
|
161
|
+
text (str): Текст сообщения.
|
|
162
|
+
idempotency_key (str): Ключ, по которому повтор узнаётся повтором.
|
|
163
|
+
declared_cold (bool): Признание, что переписка холодная.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
SendTicket: Квитанция, по которой можно дождаться исхода.
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
UsageError: Если очередь переполнена.
|
|
170
|
+
"""
|
|
171
|
+
return self._outbox.put(
|
|
172
|
+
SendCommand(
|
|
173
|
+
chat_id=chat_id,
|
|
174
|
+
text=text,
|
|
175
|
+
idempotency_key=idempotency_key,
|
|
176
|
+
declared_cold=declared_cold,
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def run(
|
|
181
|
+
self,
|
|
182
|
+
*,
|
|
183
|
+
account_id: str = "self",
|
|
184
|
+
max_iterations: int | None = None,
|
|
185
|
+
schedule: Schedule | None = None,
|
|
186
|
+
state_path: str | Path | None = None,
|
|
187
|
+
use_channel: bool = True,
|
|
188
|
+
max_threads_per_step: int = 5,
|
|
189
|
+
on_handler_error: Callable[[HandlerError], None] | None = None,
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Ведёт наблюдение и разбирает очередь исходящих.
|
|
192
|
+
|
|
193
|
+
Метод блокирующий. Он и есть главный цикл бота.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
account_id (str): Идентификатор аккаунта для отпечатков событий.
|
|
197
|
+
max_iterations (int | None): Сколько шагов сделать.
|
|
198
|
+
schedule (Schedule | None): Расписание опроса.
|
|
199
|
+
state_path (str | Path | None): Файл состояния.
|
|
200
|
+
use_channel (bool): Слушать ли канал обновлений площадки. По
|
|
201
|
+
умолчанию да: покупатель получает ответ за секунды, а не за
|
|
202
|
+
десятки секунд. Выключение возвращает прежний опрос страниц.
|
|
203
|
+
max_threads_per_step (int): Сколько переписок дочитывать за шаг.
|
|
204
|
+
on_handler_error (Callable[[HandlerError], None] | None): Что делать
|
|
205
|
+
с отказом обработчика.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
None
|
|
209
|
+
|
|
210
|
+
Raises:
|
|
211
|
+
FunoraError: Любая ошибка чтения, которую не удалось повторить.
|
|
212
|
+
"""
|
|
213
|
+
worker = (
|
|
214
|
+
file_lock(self._spool.root / ".worker.lock", blocking=False)
|
|
215
|
+
if self._spool is not None
|
|
216
|
+
else nullcontext()
|
|
217
|
+
)
|
|
218
|
+
with worker:
|
|
219
|
+
self._outbox.claim()
|
|
220
|
+
try:
|
|
221
|
+
# Застрявшее разбирается ОДИН РАЗ, на старте, и до первого опроса.
|
|
222
|
+
# Задание, взятое умершим процессом, могло уйти на площадку и могло не
|
|
223
|
+
# уйти; повторять его нельзя, и молчать о нём нельзя тоже.
|
|
224
|
+
if self._spool is not None:
|
|
225
|
+
self._spool.recover()
|
|
226
|
+
|
|
227
|
+
self._client.run(
|
|
228
|
+
self._client.engine.watch(
|
|
229
|
+
self._router,
|
|
230
|
+
account_id=account_id,
|
|
231
|
+
max_iterations=max_iterations,
|
|
232
|
+
schedule=schedule,
|
|
233
|
+
state_path=state_path,
|
|
234
|
+
max_threads_per_step=max_threads_per_step,
|
|
235
|
+
use_channel=use_channel,
|
|
236
|
+
),
|
|
237
|
+
router=self._router,
|
|
238
|
+
on_handler_error=on_handler_error,
|
|
239
|
+
on_idle=self._drain,
|
|
240
|
+
)
|
|
241
|
+
finally:
|
|
242
|
+
self._outbox.release()
|
|
243
|
+
|
|
244
|
+
def _note_delivery(self, idempotency_key: str, outcome: str) -> None:
|
|
245
|
+
"""Проставляет реестру выдач настоящий исход отправки.
|
|
246
|
+
|
|
247
|
+
ЗАЧЕМ ЭТО ЗДЕСЬ. Автовыдача заводит запись исходом ``queued`` и уходит:
|
|
248
|
+
отправка случится позже и в другом месте. Прежде исход так и оставался
|
|
249
|
+
``queued`` навсегда, и успешная выдача была неотличима от потерянной -
|
|
250
|
+
а найти потерянные не было способа вовсе.
|
|
251
|
+
|
|
252
|
+
Заказ узнаётся по ключу идемпотентности: автовыдача составляет его как
|
|
253
|
+
``delivery:<заказ>``, и другого носителя связи между заданием и заказом
|
|
254
|
+
нет. Ключ чужого задания сюда просто не подходит и молча пропускается.
|
|
255
|
+
|
|
256
|
+
Запись не заводится, если её нет: см. DeliveryLedger.settle.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
idempotency_key (str): Ключ задания.
|
|
260
|
+
outcome (str): Исход отправки либо имя отказа.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
None
|
|
264
|
+
"""
|
|
265
|
+
if not idempotency_key.startswith(_DELIVERY_KEY_PREFIX):
|
|
266
|
+
return
|
|
267
|
+
order_id = idempotency_key[len(_DELIVERY_KEY_PREFIX) :]
|
|
268
|
+
engine = self._client.engine
|
|
269
|
+
engine.delivered.settle(order_id, outcome)
|
|
270
|
+
# Сохранение сразу: исход, оставшийся только в памяти, теряется тем же
|
|
271
|
+
# перезапуском, ради которого реестр и заведён долговечным.
|
|
272
|
+
engine.save_delivery()
|
|
273
|
+
|
|
274
|
+
def _drain(self, pause_ms: int) -> None:
|
|
275
|
+
"""Разбирает очередь исходящих.
|
|
276
|
+
|
|
277
|
+
Вызывается драйвером в паузе между опросами и потому исполняется в
|
|
278
|
+
потоке наблюдения - том единственном, которому позволено трогать
|
|
279
|
+
площадку.
|
|
280
|
+
|
|
281
|
+
Отказ ОДНОГО задания не отменяет остальные и не роняет наблюдение: он
|
|
282
|
+
уходит в квитанцию, и ждать его будет тот, кто задание положил. Уронить
|
|
283
|
+
цикл из-за одного неудачного сообщения значило бы поставить наблюдение в
|
|
284
|
+
зависимость от чужой кнопки.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
pause_ms (int): Длительность паузы в миллисекундах. Не используется:
|
|
288
|
+
предел ставится числом заданий, а не временем. Время предсказать
|
|
289
|
+
нельзя - сверка отправки сама спит до двенадцати секунд.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
None
|
|
293
|
+
"""
|
|
294
|
+
left = self._limit
|
|
295
|
+
for _ in range(left):
|
|
296
|
+
batch = self._outbox.take(1)
|
|
297
|
+
if not batch:
|
|
298
|
+
break
|
|
299
|
+
ticket = batch[0]
|
|
300
|
+
left -= 1
|
|
301
|
+
command = ticket.command
|
|
302
|
+
try:
|
|
303
|
+
result = self._client.chats.send_text(
|
|
304
|
+
command.chat_id,
|
|
305
|
+
command.text,
|
|
306
|
+
declared_cold=command.declared_cold,
|
|
307
|
+
)
|
|
308
|
+
except FunoraError as exc:
|
|
309
|
+
self._refused += 1
|
|
310
|
+
_log.warning(
|
|
311
|
+
"задание %s не отправлено: %s",
|
|
312
|
+
command.idempotency_key,
|
|
313
|
+
type(exc).__name__,
|
|
314
|
+
)
|
|
315
|
+
ticket.settle(error=exc)
|
|
316
|
+
self._note_delivery(command.idempotency_key, type(exc).__name__)
|
|
317
|
+
continue
|
|
318
|
+
except Exception as exc:
|
|
319
|
+
ticket.settle(error=exc)
|
|
320
|
+
self._note_delivery(command.idempotency_key, "unexpected_error")
|
|
321
|
+
raise
|
|
322
|
+
self._sent += 1
|
|
323
|
+
ticket.settle(result=result)
|
|
324
|
+
self._note_delivery(command.idempotency_key, result.outcome.value)
|
|
325
|
+
|
|
326
|
+
# Каталог разбирается ПОСЛЕ памяти и в остаток того же предела. Предел
|
|
327
|
+
# общий нарочно: он бережёт не очередь, а паузу между опросами, и две
|
|
328
|
+
# очереди с собственными пределами вдвое удлинили бы её.
|
|
329
|
+
#
|
|
330
|
+
# Реестр выдач при этом уже поправлен: исход проставляется сразу за
|
|
331
|
+
# квитанцией, а не в конце разбора. Разбор может оборваться на середине,
|
|
332
|
+
# и заданиям, которые успели уйти, незачем оставаться неразобранными
|
|
333
|
+
# из-за тех, которые не успели.
|
|
334
|
+
if self._spool is None:
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
for _ in range(left):
|
|
338
|
+
entries = self._spool.take(1)
|
|
339
|
+
if not entries:
|
|
340
|
+
break
|
|
341
|
+
entry = entries[0]
|
|
342
|
+
command = entry.command
|
|
343
|
+
try:
|
|
344
|
+
result = self._client.chats.send_text(
|
|
345
|
+
command.chat_id,
|
|
346
|
+
command.text,
|
|
347
|
+
declared_cold=command.declared_cold,
|
|
348
|
+
)
|
|
349
|
+
except FunoraError as exc:
|
|
350
|
+
self._refused += 1
|
|
351
|
+
_log.warning(
|
|
352
|
+
"задание %s из каталога не отправлено: %s",
|
|
353
|
+
command.idempotency_key,
|
|
354
|
+
type(exc).__name__,
|
|
355
|
+
)
|
|
356
|
+
self._spool.settle(entry, state="refused", detail=type(exc).__name__)
|
|
357
|
+
continue
|
|
358
|
+
self._sent += 1
|
|
359
|
+
self._spool.settle(entry, state="sent", detail=result.outcome.value)
|
|
360
|
+
|
|
361
|
+
def deliveries(
|
|
362
|
+
self,
|
|
363
|
+
plan: DeliveryPlan,
|
|
364
|
+
on_hold: Callable[[DeliveryDecision], None] | None = None,
|
|
365
|
+
) -> AutoDelivery:
|
|
366
|
+
"""Собирает автовыдачу, связанную с файлом состояния клиента.
|
|
367
|
+
|
|
368
|
+
СОБИРАТЬ ЕЁ РУКАМИ НЕ НАДО, и это не вежливость. Реестр выданного
|
|
369
|
+
обязан переживать перезапуск: обнулившись, он выдаст товар второй раз
|
|
370
|
+
по заказу, который в списке продаж всё ещё оплачен. Связать его с
|
|
371
|
+
файлом состояния можно только через движок, который этот файл открыл.
|
|
372
|
+
|
|
373
|
+
Собранная руками автовыдача про файл не знает и знать не может, а
|
|
374
|
+
выглядит рабочей: первый прогон отдаёт товар, второй отдаёт его снова.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
plan (DeliveryPlan): Что и кому выдавать.
|
|
378
|
+
on_hold (Callable[[DeliveryDecision], None] | None): Что делать с
|
|
379
|
+
заказом, который сам не выдаётся.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
AutoDelivery: Автовыдача с долговечным реестром.
|
|
383
|
+
|
|
384
|
+
Raises:
|
|
385
|
+
ConfigurationError: Если у клиента нет файла состояния. Реестр в
|
|
386
|
+
памяти здесь хуже отсутствия реестра: он выглядит защитой и ею
|
|
387
|
+
не является.
|
|
388
|
+
"""
|
|
389
|
+
from ._delivery import AutoDelivery
|
|
390
|
+
|
|
391
|
+
engine = self._client.engine
|
|
392
|
+
if engine._ledger is None:
|
|
393
|
+
raise ConfigurationError(
|
|
394
|
+
"автовыдача без файла состояния невозможна: реестр выданного "
|
|
395
|
+
"обнулится при перезапуске, и товар уйдёт второй раз по заказу, "
|
|
396
|
+
"который в списке продаж всё ещё оплачен. Передайте клиенту "
|
|
397
|
+
"state_path"
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
return AutoDelivery(
|
|
401
|
+
plan,
|
|
402
|
+
engine.delivered,
|
|
403
|
+
lambda chat, text, key, cold: self.send(
|
|
404
|
+
chat, text, idempotency_key=key, declared_cold=cold
|
|
405
|
+
),
|
|
406
|
+
on_hold=on_hold,
|
|
407
|
+
persist=engine.save_delivery,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
def send_now(
|
|
411
|
+
self,
|
|
412
|
+
chat_id: str,
|
|
413
|
+
text: str,
|
|
414
|
+
*,
|
|
415
|
+
declared_cold: bool = False,
|
|
416
|
+
) -> object:
|
|
417
|
+
"""Отправляет сообщение немедленно, минуя очередь.
|
|
418
|
+
|
|
419
|
+
Звать МОЖНО ТОЛЬКО из потока наблюдения - из обработчика события. Вызов
|
|
420
|
+
из чужого потока отвергается вслух.
|
|
421
|
+
|
|
422
|
+
Отказ громкий, а не молчаливая гонка, потому что гонка здесь не роняет
|
|
423
|
+
ничего и не бросает исключений: она портит счёт ограничителя исходящих.
|
|
424
|
+
Проявится это превышением настоящего предела площадки, и объяснит вам
|
|
425
|
+
это площадка.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
chat_id (str): Числовой идентификатор диалога.
|
|
429
|
+
text (str): Текст сообщения.
|
|
430
|
+
declared_cold (bool): Признание, что переписка холодная.
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
object: Квитанция отправки - SendResult.
|
|
434
|
+
|
|
435
|
+
Raises:
|
|
436
|
+
UsageError: Если вызов идёт не из потока наблюдения.
|
|
437
|
+
FunoraError: Если отправка не состоялась.
|
|
438
|
+
"""
|
|
439
|
+
if not self._outbox.is_owner():
|
|
440
|
+
raise UsageError(
|
|
441
|
+
"send_now зовут не из потока наблюдения. Клиент не защищён ни "
|
|
442
|
+
"одной блокировкой: у ограничителя исходящих проверка и запись "
|
|
443
|
+
"не атомарны, и второй поток недосчитывает предел - то есть "
|
|
444
|
+
"превышает настоящий предел площадки. Кладите задание в очередь "
|
|
445
|
+
"методом send: его разберёт тот же поток, что ведёт наблюдение"
|
|
446
|
+
)
|
|
447
|
+
return self._client.chats.send_text(chat_id, text, declared_cold=declared_cold)
|