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/_diff.py
ADDED
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
"""Порождение событий из двух снимков состояния.
|
|
2
|
+
|
|
3
|
+
Модуль чистый: два снимка на входе, события на выходе. Ни сети, ни часов, ни
|
|
4
|
+
состояния между вызовами. Это не про стиль - без такого разделения проверить
|
|
5
|
+
поведение на неполном снимке было бы нечем, а именно там оно и опаснее всего.
|
|
6
|
+
|
|
7
|
+
Три правила объясняют почти весь код, и все три про то, чего модуль **не**
|
|
8
|
+
делает.
|
|
9
|
+
|
|
10
|
+
Сравнение идёт не со вторым снимком, а с курсором - набором того, что уже
|
|
11
|
+
известно. Разница не в оформлении. Сравнение снимка со снимком давало ложное
|
|
12
|
+
событие: строка, выпавшая из прошлого чтения из-за поломки разметки, при
|
|
13
|
+
следующем чтении выглядела новым заказом, и бот выдавал товар по заказу, который
|
|
14
|
+
существовал и раньше. Курсор хранит известное и не теряет его от одной
|
|
15
|
+
испорченной строки.
|
|
16
|
+
|
|
17
|
+
Пустой курсор событий не порождает. Не с чем сравнивать, и объявить все
|
|
18
|
+
двенадцать существующих заказов новыми означало бы разослать двенадцать
|
|
19
|
+
уведомлений об оплате при первом же запуске бота.
|
|
20
|
+
|
|
21
|
+
Событий об исчезновении не порождается вовсе. Запись, не попавшая в частично
|
|
22
|
+
прочитанную страницу, не исчезла - её не прочитали, и отличить одно от другого
|
|
23
|
+
по странице нечем. Разница между этими случаями есть разница между «заказ
|
|
24
|
+
отменён» и «мы не смогли его увидеть», а обработчик по первому вернёт деньги.
|
|
25
|
+
|
|
26
|
+
События об изменении состояния порождаются только когда обе стороны сравнения
|
|
27
|
+
прочитаны. Переход из непрочитанного состояния в прочитанное событием не
|
|
28
|
+
считается: он говорит о том, что мы научились читать, а не о том, что заказ
|
|
29
|
+
изменился. Обработчик, получивший такое событие, выдал бы товар по заказу, с
|
|
30
|
+
которым ничего не происходило.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import hashlib
|
|
36
|
+
from collections.abc import Callable, Iterable
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from datetime import datetime
|
|
39
|
+
from typing import Any, Final
|
|
40
|
+
|
|
41
|
+
from ._canonical import canonical_normalize
|
|
42
|
+
from ._chats import ChatsPage
|
|
43
|
+
from ._observed import Observed
|
|
44
|
+
from ._orders import OrdersPage
|
|
45
|
+
from ._thread import Message, Thread
|
|
46
|
+
from .errors import ConfigurationError, ValidationError
|
|
47
|
+
from .events import (
|
|
48
|
+
FINGERPRINT_DIGEST_BYTES,
|
|
49
|
+
FINGERPRINT_FIELDS,
|
|
50
|
+
FINGERPRINT_HASH,
|
|
51
|
+
FINGERPRINT_LENGTH,
|
|
52
|
+
FINGERPRINT_SEPARATOR,
|
|
53
|
+
ORDERING_KEY,
|
|
54
|
+
REVISION_APPEARED,
|
|
55
|
+
REVISION_SEPARATOR,
|
|
56
|
+
EventType,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"Delivery",
|
|
61
|
+
"make_event",
|
|
62
|
+
"UNREAD_STATUS",
|
|
63
|
+
"UNKNOWN_UNREAD",
|
|
64
|
+
"Event",
|
|
65
|
+
"diff_orders",
|
|
66
|
+
"diff_chats",
|
|
67
|
+
"diff_thread",
|
|
68
|
+
"orders_cursor",
|
|
69
|
+
"chats_cursor",
|
|
70
|
+
"thread_cursor",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
#: Как имя алгоритма из спецификации превращается в готовый хэшер.
|
|
74
|
+
#:
|
|
75
|
+
#: Таблица, а не hashlib.new: длину хэша blake2 задают аргументом конструктора,
|
|
76
|
+
#: и через new её не передать. Таблица заодно делает громким тот случай, ради
|
|
77
|
+
#: которого всё и затевалось - спецификация назвала алгоритм, которого
|
|
78
|
+
#: реализация не умеет. Молча взять другой значило бы разойтись отпечатками с
|
|
79
|
+
#: остальными SDK, а отпечаток - это ключ идемпотентности.
|
|
80
|
+
_HASHERS: Final[dict[str, Callable[[], Any]]] = {
|
|
81
|
+
"blake2s": lambda: hashlib.blake2s(digest_size=FINGERPRINT_DIGEST_BYTES),
|
|
82
|
+
"blake2b": lambda: hashlib.blake2b(digest_size=FINGERPRINT_DIGEST_BYTES),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
#: Происхождение события: выведено из разметки, а не из текста.
|
|
87
|
+
_STRUCTURAL: Final[str] = "structural"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True, slots=True)
|
|
91
|
+
class Delivery:
|
|
92
|
+
"""Метаданные доставки события.
|
|
93
|
+
|
|
94
|
+
Доставка объявлена как минимум однократной: событие, на котором обработчик
|
|
95
|
+
упал, приходит снова. Без этих полей обработчик не отличает вторую доставку
|
|
96
|
+
от нового события - а это ровно тот случай, ради которого гарантию и
|
|
97
|
+
формулируют: выдать товар дважды дешевле не становится оттого, что второй
|
|
98
|
+
раз был повтором.
|
|
99
|
+
|
|
100
|
+
Attributes:
|
|
101
|
+
attempt (int): Номер попытки доставки, с единицы. Больше единицы
|
|
102
|
+
означает повтор после отказа обработчика.
|
|
103
|
+
coalesced (bool): Собрано ли событие сжатием нескольких наблюдений.
|
|
104
|
+
Эта реализация не сжимает: опрос читает состояние целиком, и
|
|
105
|
+
промежуточные состояния между чтениями не наблюдаются вовсе, так
|
|
106
|
+
что сжимать нечего. Поле всегда False и стоит здесь потому, что
|
|
107
|
+
получателю нужен ответ, а не отсутствие поля.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
attempt: int = 1
|
|
111
|
+
coalesced: bool = False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True, slots=True)
|
|
115
|
+
class Event:
|
|
116
|
+
"""Событие в конверте, описанном спецификацией.
|
|
117
|
+
|
|
118
|
+
Attributes:
|
|
119
|
+
id (str): Идентификатор события. Отпечаток от полей, перечисленных в
|
|
120
|
+
спецификации; момента наблюдения и версии адаптера среди них нет.
|
|
121
|
+
type (EventType): Тип события.
|
|
122
|
+
account_id (str): Аккаунт, в контексте которого наблюдено событие.
|
|
123
|
+
Обязателен по конверту: получатель, разбирающий события нескольких
|
|
124
|
+
аккаунтов из одной очереди, иначе не различит их - а отпечаток,
|
|
125
|
+
куда аккаунт входит, непрозрачен и разбору не подлежит.
|
|
126
|
+
ordering_key (str): Ключ упорядочивания. Порядок сохраняется внутри
|
|
127
|
+
одного ключа, между разными ключами порядка нет.
|
|
128
|
+
entity_id (str): Идентификатор сущности, к которой относится событие.
|
|
129
|
+
observed_at (datetime): Момент наблюдения. В отпечаток не входит.
|
|
130
|
+
origin (str): Как получено: структурно либо по тексту.
|
|
131
|
+
delivery (Delivery): Метаданные доставки. По ним обработчик отличает
|
|
132
|
+
повтор от нового события.
|
|
133
|
+
payload (dict[str, Any]): Полезная нагрузка. Персональных данных в ней
|
|
134
|
+
нет: содержимое сообщений и имена сюда не кладутся.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
id: str
|
|
138
|
+
type: EventType
|
|
139
|
+
account_id: str
|
|
140
|
+
ordering_key: str
|
|
141
|
+
entity_id: str
|
|
142
|
+
observed_at: datetime
|
|
143
|
+
origin: str
|
|
144
|
+
payload: dict[str, Any]
|
|
145
|
+
delivery: Delivery = Delivery()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _fingerprint(*, account_id: str, event_type: EventType, entity_id: str, revision: str) -> str:
|
|
149
|
+
"""Строит устойчивый идентификатор события.
|
|
150
|
+
|
|
151
|
+
В отпечаток не входят ни момент наблюдения, ни версия адаптера, и это
|
|
152
|
+
запрет из спецификации, а не выбор реализации. Момент меняется от запуска к
|
|
153
|
+
запуску, версия - при каждом исправлении разметки; включение любого из них
|
|
154
|
+
обнулило бы дедупликацию ровно после перезапуска, то есть там, где она
|
|
155
|
+
нужнее всего.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
account_id (str): Идентификатор аккаунта.
|
|
159
|
+
event_type (EventType): Тип события.
|
|
160
|
+
entity_id (str): Идентификатор сущности.
|
|
161
|
+
revision (str): Версия сущности - то, что отличает одно её состояние от
|
|
162
|
+
другого.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
str: Отпечаток в шестнадцатеричном виде.
|
|
166
|
+
"""
|
|
167
|
+
parts = {
|
|
168
|
+
"account_id": account_id,
|
|
169
|
+
"type": str(event_type),
|
|
170
|
+
"entity_id": entity_id,
|
|
171
|
+
"entity_revision": revision,
|
|
172
|
+
}
|
|
173
|
+
# NFC до склейки. Одна и та же буква записывается по-разному: «ё» бывает
|
|
174
|
+
# одним знаком и парой «е» с диакритикой. Глазами не отличить, байтами -
|
|
175
|
+
# разные строки, и отпечаток от них разный. Форму задаёт не площадка, а
|
|
176
|
+
# то, через какой стек HTTP и какой разборщик разметки прошла страница, -
|
|
177
|
+
# то есть у двух реализаций она может отличаться на одних и тех же данных.
|
|
178
|
+
# Отпечаток при этом обязан совпасть: он ключ идемпотентности.
|
|
179
|
+
pieces = [canonical_normalize(parts[name]) for name in FINGERPRINT_FIELDS]
|
|
180
|
+
|
|
181
|
+
# Разделитель внутри части ломает склейку. Спецификация выбрала U+001F
|
|
182
|
+
# именно потому, что «любой печатный разделитель рано или поздно
|
|
183
|
+
# встретится внутри части, и две разные четвёрки склеятся в одну строку» -
|
|
184
|
+
# но не запретила его в самих частях, и запрета не хватило: реализация
|
|
185
|
+
# собирала версию диалога тем же знаком и клала пять частей туда, где
|
|
186
|
+
# объявлено четыре.
|
|
187
|
+
#
|
|
188
|
+
# Отвергать вслух, а не подменять. Подмена дала бы два разных события с
|
|
189
|
+
# одним отпечатком, и второе исчезло бы молча.
|
|
190
|
+
for name, piece in zip(FINGERPRINT_FIELDS, pieces, strict=True):
|
|
191
|
+
if FINGERPRINT_SEPARATOR in piece:
|
|
192
|
+
raise ValidationError(
|
|
193
|
+
f"часть отпечатка {name} содержит разделитель U+001F: {piece!r}. "
|
|
194
|
+
"Склейка перестала бы различать четвёрки полей, и два разных "
|
|
195
|
+
"события получили бы один отпечаток - молча"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
material = FINGERPRINT_SEPARATOR.join(pieces)
|
|
199
|
+
# Алгоритм, длина хэша и длина отпечатка берутся из порождённого файла, а
|
|
200
|
+
# не пишутся здесь. Отпечаток - это ключ идемпотентности, и он обязан
|
|
201
|
+
# совпасть у шести реализаций; литерал в коде разошёлся бы со
|
|
202
|
+
# спецификацией молча, а разойдясь, обнулил бы гашение повторов у всех,
|
|
203
|
+
# кто подключил две реализации сразу.
|
|
204
|
+
make_digest = _HASHERS.get(FINGERPRINT_HASH)
|
|
205
|
+
if make_digest is None:
|
|
206
|
+
raise ConfigurationError(
|
|
207
|
+
f"спецификация требует отпечаток алгоритмом {FINGERPRINT_HASH}, "
|
|
208
|
+
f"а эта реализация умеет {sorted(_HASHERS)}. Взять другой значило бы "
|
|
209
|
+
"разойтись идентификаторами событий с остальными SDK"
|
|
210
|
+
)
|
|
211
|
+
digest = make_digest()
|
|
212
|
+
digest.update(material.encode("utf-8"))
|
|
213
|
+
fingerprint: str = digest.hexdigest()[:FINGERPRINT_LENGTH]
|
|
214
|
+
return fingerprint
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def make_event(
|
|
218
|
+
*,
|
|
219
|
+
account_id: str,
|
|
220
|
+
event_type: EventType,
|
|
221
|
+
entity_id: str,
|
|
222
|
+
revision: str,
|
|
223
|
+
observed_at: datetime,
|
|
224
|
+
key_field: str,
|
|
225
|
+
payload: dict[str, Any],
|
|
226
|
+
key_value: str | None = None,
|
|
227
|
+
) -> Event:
|
|
228
|
+
"""Собирает событие с отпечатком и ключом упорядочивания из спецификации.
|
|
229
|
+
|
|
230
|
+
Через эту функцию проходят ВСЕ события пакета, включая события о самом
|
|
231
|
+
наблюдении. Прежде их было два рода: события данных строились здесь, а
|
|
232
|
+
watch.primed и snapshot.incomplete собирали идентификатор вручную строкой
|
|
233
|
+
вида «primed:{account_id}:{ordering_key}» и подставляли ключ
|
|
234
|
+
упорядочивания «account:...» мимо порождённой таблицы.
|
|
235
|
+
|
|
236
|
+
Стоило это трёх нарушений разом. Ключ упорядочивания расходился с
|
|
237
|
+
нормативным «watch:{watch_id}» - то есть второй SDK делил бы поток на
|
|
238
|
+
другие группы. Идентификатор строился не из полей, перечисленных
|
|
239
|
+
спецификацией, и у одного из двух не было версии сущности вовсе. И
|
|
240
|
+
сосуществовали два формата идентификатора: отпечаток в шестнадцатеричном
|
|
241
|
+
виде и человекочитаемая строка с двоеточиями.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
account_id (str): Идентификатор аккаунта.
|
|
245
|
+
event_type (EventType): Тип события.
|
|
246
|
+
entity_id (str): Идентификатор сущности.
|
|
247
|
+
revision (str): Версия сущности для отпечатка.
|
|
248
|
+
observed_at (datetime): Момент наблюдения.
|
|
249
|
+
key_field (str): Имя подстановки в шаблоне ключа упорядочивания.
|
|
250
|
+
payload (dict[str, Any]): Полезная нагрузка.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Event: Готовое событие.
|
|
254
|
+
"""
|
|
255
|
+
return Event(
|
|
256
|
+
id=_fingerprint(
|
|
257
|
+
account_id=account_id,
|
|
258
|
+
event_type=event_type,
|
|
259
|
+
entity_id=entity_id,
|
|
260
|
+
revision=revision,
|
|
261
|
+
),
|
|
262
|
+
type=event_type,
|
|
263
|
+
ordering_key=ORDERING_KEY[event_type].format(
|
|
264
|
+
**{key_field: entity_id if key_value is None else key_value}
|
|
265
|
+
),
|
|
266
|
+
entity_id=entity_id,
|
|
267
|
+
observed_at=observed_at,
|
|
268
|
+
origin=_STRUCTURAL,
|
|
269
|
+
account_id=account_id,
|
|
270
|
+
payload=payload,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
#: Чем отмечается заказ, состояние которого прочитать не удалось.
|
|
275
|
+
#:
|
|
276
|
+
#: Метка нужна затем, чтобы отличить «состояние было другим» от «состояния мы не
|
|
277
|
+
#: знали». Событие об изменении порождается только когда обе стороны прочитаны:
|
|
278
|
+
#: переход из непрочитанного в прочитанное - это про нас, а не про заказ.
|
|
279
|
+
UNREAD_STATUS: Final[str] = "?"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def diff_orders(
|
|
283
|
+
known: dict[str, str] | None,
|
|
284
|
+
page: OrdersPage,
|
|
285
|
+
*,
|
|
286
|
+
account_id: str,
|
|
287
|
+
) -> tuple[Event, ...]:
|
|
288
|
+
"""Порождает события по списку заказов и курсору.
|
|
289
|
+
|
|
290
|
+
Событий два вида. Заказ, которого нет в курсоре, - новый. Заказ, состояние
|
|
291
|
+
которого прочитано сейчас, прочитано было и отличается, - изменившийся.
|
|
292
|
+
|
|
293
|
+
Второе условие строгое намеренно. Переход из непрочитанного состояния в
|
|
294
|
+
прочитанное событием не считается: он говорит о том, что мы научились
|
|
295
|
+
читать, а не о том, что заказ изменился. Обработчик, получивший такое
|
|
296
|
+
событие, выдал бы товар по заказу, с которым ничего не происходило.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
known (dict[str, str] | None): Известные заказы и их состояния. None при
|
|
300
|
+
первом запуске, когда курсора ещё нет.
|
|
301
|
+
page (OrdersPage): Прочитанная страница. Может быть неполной: заказ,
|
|
302
|
+
которого нет в курсоре, новый независимо от полноты чтения.
|
|
303
|
+
account_id (str): Идентификатор аккаунта.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
tuple[Event, ...]: События. Пустой набор при отсутствии курсора:
|
|
307
|
+
сравнивать не с чем, а объявить все существующие заказы новыми означало
|
|
308
|
+
бы разослать уведомления обо всех сразу при первом запуске.
|
|
309
|
+
"""
|
|
310
|
+
if known is None:
|
|
311
|
+
return ()
|
|
312
|
+
|
|
313
|
+
events: list[Event] = []
|
|
314
|
+
for entry in page.rows(accept_incomplete=True):
|
|
315
|
+
now = entry.status.value if entry.status.is_observed else UNREAD_STATUS
|
|
316
|
+
|
|
317
|
+
if entry.order_id not in known:
|
|
318
|
+
events.append(
|
|
319
|
+
make_event(
|
|
320
|
+
account_id=account_id,
|
|
321
|
+
event_type=EventType.ORDER_CREATED,
|
|
322
|
+
entity_id=entry.order_id,
|
|
323
|
+
# Версией служит сам факт появления: заказ появляется в
|
|
324
|
+
# списке однажды, и различать разные появления одного и того
|
|
325
|
+
# же заказа не требуется. Значение берётся из порождённого
|
|
326
|
+
# файла: отпечаток обязан совпасть у всех реализаций, а
|
|
327
|
+
# литерал здесь разошёлся бы со спецификацией молча.
|
|
328
|
+
revision=REVISION_APPEARED,
|
|
329
|
+
observed_at=page.observed_at,
|
|
330
|
+
key_field="order_id",
|
|
331
|
+
payload={
|
|
332
|
+
# Идентификатор лежит и в нагрузке, и в конверте. Нагрузку
|
|
333
|
+
# принято передавать дальше отдельно от конверта - в
|
|
334
|
+
# очередь, в журнал, - и там она обязана оставаться
|
|
335
|
+
# самодостаточной.
|
|
336
|
+
"order_id": entry.order_id,
|
|
337
|
+
"href": entry.href,
|
|
338
|
+
"row_index": entry.row_index,
|
|
339
|
+
# В нагрузке отсутствие значения - None, а не метка "?".
|
|
340
|
+
# Метка живёт в курсоре, где нужна строка, и наружу ей
|
|
341
|
+
# выходить незачем: получатель, увидевший "?", решил бы,
|
|
342
|
+
# что это состояние заказа. Отсутствие типизируется само.
|
|
343
|
+
"status": entry.status.or_none(),
|
|
344
|
+
},
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
continue
|
|
348
|
+
|
|
349
|
+
before = known[entry.order_id]
|
|
350
|
+
if before == now or UNREAD_STATUS in (before, now):
|
|
351
|
+
continue
|
|
352
|
+
|
|
353
|
+
events.append(
|
|
354
|
+
make_event(
|
|
355
|
+
account_id=account_id,
|
|
356
|
+
event_type=EventType.ORDER_STATUS_CHANGED,
|
|
357
|
+
entity_id=entry.order_id,
|
|
358
|
+
# Версия - новое состояние. Повторное чтение того же состояния
|
|
359
|
+
# даёт тот же отпечаток, и гашение повторов срабатывает само.
|
|
360
|
+
revision=now,
|
|
361
|
+
observed_at=page.observed_at,
|
|
362
|
+
key_field="order_id",
|
|
363
|
+
payload={
|
|
364
|
+
"order_id": entry.order_id,
|
|
365
|
+
"href": entry.href,
|
|
366
|
+
# previous и current, а не from и to: валидатор спецификации
|
|
367
|
+
# сам предупредил, что from - ключевое слово в Python и
|
|
368
|
+
# порождённый код получил бы from_. Ключ нагрузки читают
|
|
369
|
+
# шесть языков, и имя, которое в одном из них приходится
|
|
370
|
+
# экранировать, плохое имя.
|
|
371
|
+
"previous": before,
|
|
372
|
+
"current": now,
|
|
373
|
+
},
|
|
374
|
+
)
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
return tuple(events)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def orders_cursor(page: OrdersPage) -> dict[str, str]:
|
|
381
|
+
"""Собирает курсор по прочитанной странице заказов.
|
|
382
|
+
|
|
383
|
+
Курсор снимается только с полного чтения, и решает это вызывающий. Снятый с
|
|
384
|
+
неполного, он потерял бы выпавшие строки - и при следующем чтении они
|
|
385
|
+
выглядели бы новыми заказами.
|
|
386
|
+
|
|
387
|
+
Хранится не множество идентификаторов, а соответствие «заказ - состояние».
|
|
388
|
+
Множества хватало ровно до тех пор, пока состояние было ненаблюдаемым:
|
|
389
|
+
сравнивать было нечего, и событие об изменении породить было не из чего.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
page (OrdersPage): Прочитанная страница.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
dict[str, str]: Идентификаторы заказов и их состояния. Непрочитанное
|
|
396
|
+
состояние отмечается UNREAD_STATUS.
|
|
397
|
+
"""
|
|
398
|
+
return {
|
|
399
|
+
entry.order_id: (entry.status.value if entry.status.is_observed else UNREAD_STATUS)
|
|
400
|
+
for entry in page.rows(accept_incomplete=True)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
#: Разделитель между позицией и флагом в составе версии диалога.
|
|
405
|
+
#:
|
|
406
|
+
#: Не двоеточие и не любой печатный знак: позиция непрозрачна, и в снимках она
|
|
407
|
+
#: сама содержит двоеточие - подпись вида T10:d#1. Разделитель, встречающийся
|
|
408
|
+
#: внутри значения, разрезал бы состояние не там, и сравнение сравнивало бы
|
|
409
|
+
#: обрубки.
|
|
410
|
+
#:
|
|
411
|
+
#: И НЕ U+001F, которым склеивается отпечаток. Это была настоящая ошибка, а не
|
|
412
|
+
#: придирка. Спецификация выбрала U+001F для отпечатка ровно потому, что
|
|
413
|
+
#: печатный разделитель рано или поздно встретится внутри части - и тогда две
|
|
414
|
+
#: разные четвёрки склеятся в одну строку, а два разных события получат один
|
|
415
|
+
#: отпечаток. Реализация взяла тот же знак для вложенной склейки и своими
|
|
416
|
+
#: руками положила его внутрь части: материал отпечатка получал пять кусков
|
|
417
|
+
#: там, где объявлено четыре.
|
|
418
|
+
#:
|
|
419
|
+
#: Следствие важнее самой коллизии. Спецификация НЕ называла этот разделитель
|
|
420
|
+
#: вовсе - только «знак, который не встречается внутри значений». Вторая
|
|
421
|
+
#: реализация взяла бы двоеточие или вертикальную черту и получила бы другой
|
|
422
|
+
#: отпечаток на каждое событие chat.unread_changed. Теперь знак назван
|
|
423
|
+
#: нормативно: spec/events/delivery.yaml -> revision_source.part_separator.
|
|
424
|
+
#:
|
|
425
|
+
#: U+001E - разделитель записей, U+001F - разделитель полей внутри записи.
|
|
426
|
+
#: Порядок обратен привычному по ASCII: внешняя склейка была зафиксирована
|
|
427
|
+
#: спецификацией первой, и менять её значило бы обнулить все сохранённые
|
|
428
|
+
#: отпечатки ради красоты.
|
|
429
|
+
#: Величина берётся из порождённого файла, а не пишется здесь: она входит
|
|
430
|
+
#: в отпечаток и обязана совпасть у всех реализаций.
|
|
431
|
+
_STATE_SEP: Final[str] = REVISION_SEPARATOR
|
|
432
|
+
|
|
433
|
+
#: Чем отмечается диалог, признак непрочитанного которого не выведен.
|
|
434
|
+
#:
|
|
435
|
+
#: Метка нужна затем же, зачем UNREAD_STATUS у заказов: отличить «было иначе» от
|
|
436
|
+
#: «мы не знали». Переход из неизвестного в известное событием не считается - он
|
|
437
|
+
#: про нас, а не про диалог.
|
|
438
|
+
UNKNOWN_UNREAD: Final[str] = "?"
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _chat_state(position: str, unread: Observed[bool]) -> str:
|
|
442
|
+
"""Собирает состояние диалога, по которому решается, было ли изменение.
|
|
443
|
+
|
|
444
|
+
Состояний два, и позиции одной мало. Событие называется «изменилось
|
|
445
|
+
непрочитанное», а сравнивалась только позиция последнего сообщения - то
|
|
446
|
+
есть прочтение диалога события не давало вовсе. Признак прочтения живёт в
|
|
447
|
+
другой позиции, и её никто не смотрел.
|
|
448
|
+
|
|
449
|
+
Отпечаток строится из этой же строки, и это вторая половина той же починки.
|
|
450
|
+
Сравнивай мы флаг, но оставь версией одну позицию - событие о прочтении
|
|
451
|
+
получило бы отпечаток уже доставленного и было бы съедено гашением повторов.
|
|
452
|
+
Снова молча.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
position (str): Позиция последнего сообщения диалога.
|
|
456
|
+
unread (Observed[bool]): Выведенный признак непрочитанного.
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
str: Состояние - позиция и флаг через разделитель. Флаг: 1, 0 либо знак
|
|
460
|
+
неизвестного.
|
|
461
|
+
"""
|
|
462
|
+
flag = UNKNOWN_UNREAD if not unread.is_observed else ("1" if unread.value else "0")
|
|
463
|
+
return f"{position}{_STATE_SEP}{flag}"
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _split_state(state: str) -> tuple[str, str]:
|
|
467
|
+
"""Разбирает состояние диалога на позицию и флаг.
|
|
468
|
+
|
|
469
|
+
Разбирает и прежнюю форму - голую позицию без флага. Курсор переживает
|
|
470
|
+
перезапуск, и сохранённый прежней редакцией читается как «позиция известна,
|
|
471
|
+
флаг нет»: иначе первое же чтение после обновления выдало бы по событию на
|
|
472
|
+
каждый диалог.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
state (str): Сохранённое состояние.
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
tuple[str, str]: Позиция и флаг.
|
|
479
|
+
"""
|
|
480
|
+
position, _, flag = state.partition(_STATE_SEP)
|
|
481
|
+
return position, (flag or UNKNOWN_UNREAD)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _changed(stored: str, state: str) -> bool:
|
|
485
|
+
"""Решает, переменился ли диалог.
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
stored (str): Состояние из курсора.
|
|
489
|
+
state (str): Состояние из свежего чтения.
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
bool: True, если сдвинулась позиция либо переменился выведенный признак
|
|
493
|
+
непрочитанного. Переход признака из невыведенного в выведенный
|
|
494
|
+
изменением не считается.
|
|
495
|
+
"""
|
|
496
|
+
was_position, was_flag = _split_state(stored)
|
|
497
|
+
now_position, now_flag = _split_state(state)
|
|
498
|
+
|
|
499
|
+
if was_position != now_position:
|
|
500
|
+
return True
|
|
501
|
+
if UNKNOWN_UNREAD in (was_flag, now_flag):
|
|
502
|
+
return False
|
|
503
|
+
return was_flag != now_flag
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def diff_chats(
|
|
507
|
+
known: dict[str, str] | None,
|
|
508
|
+
page: ChatsPage,
|
|
509
|
+
*,
|
|
510
|
+
account_id: str,
|
|
511
|
+
) -> tuple[Event, ...]:
|
|
512
|
+
"""Порождает события по списку диалогов и курсору.
|
|
513
|
+
|
|
514
|
+
Событие даёт любое из двух изменений: сдвинулась позиция последнего
|
|
515
|
+
сообщения либо переменился выведенный признак непрочитанного. Второе
|
|
516
|
+
добавлено потому, что событие называется «изменилось непрочитанное», а
|
|
517
|
+
замечало только первое - прочтение диалога не давало события вовсе.
|
|
518
|
+
|
|
519
|
+
Переход признака из невыведенного в выведенный событием не считается: он
|
|
520
|
+
говорит, что мы научились выводить, а не что диалог переменился. То же
|
|
521
|
+
правило, что у состояния заказа.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
known (dict[str, str] | None): Состояние по каждому известному диалогу.
|
|
525
|
+
None при первом запуске. Прежняя форма - голая позиция - читается
|
|
526
|
+
как «флаг неизвестен».
|
|
527
|
+
page (ChatsPage): Прочитанная страница.
|
|
528
|
+
account_id (str): Идентификатор аккаунта.
|
|
529
|
+
|
|
530
|
+
Returns:
|
|
531
|
+
tuple[Event, ...]: События об изменении диалогов.
|
|
532
|
+
"""
|
|
533
|
+
if known is None:
|
|
534
|
+
return ()
|
|
535
|
+
|
|
536
|
+
events: list[Event] = []
|
|
537
|
+
for entry in page.rows(accept_incomplete=True):
|
|
538
|
+
position = entry.last_message_position.or_none()
|
|
539
|
+
if position is None:
|
|
540
|
+
continue
|
|
541
|
+
|
|
542
|
+
state = _chat_state(position, entry.unread)
|
|
543
|
+
stored = known.get(entry.node_id)
|
|
544
|
+
if stored is not None and not _changed(stored, state):
|
|
545
|
+
continue
|
|
546
|
+
|
|
547
|
+
events.append(
|
|
548
|
+
make_event(
|
|
549
|
+
account_id=account_id,
|
|
550
|
+
event_type=EventType.CHAT_UNREAD_CHANGED,
|
|
551
|
+
entity_id=entry.node_id,
|
|
552
|
+
# Версия - позиция вместе с флагом. Позиция непрозрачна и при
|
|
553
|
+
# изменении диалога меняется; сравнивается она только на
|
|
554
|
+
# равенство, арифметика над ней запрещена спецификацией. Флаг
|
|
555
|
+
# добавлен к версии затем, чтобы событие о прочтении не получало
|
|
556
|
+
# отпечаток уже доставленного.
|
|
557
|
+
revision=state,
|
|
558
|
+
observed_at=page.observed_at,
|
|
559
|
+
key_field="chat_id",
|
|
560
|
+
payload={
|
|
561
|
+
"chat_id": entry.node_id,
|
|
562
|
+
"unread": entry.unread.or_none(),
|
|
563
|
+
"unread_confidence": (
|
|
564
|
+
str(entry.unread.confidence) if entry.unread.confidence else None
|
|
565
|
+
),
|
|
566
|
+
},
|
|
567
|
+
)
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
return tuple(events)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def chats_cursor(page: ChatsPage) -> dict[str, str]:
|
|
574
|
+
"""Собирает курсор по прочитанной странице диалогов.
|
|
575
|
+
|
|
576
|
+
Курсор снимается только с полного чтения, и решает это вызывающий - правило
|
|
577
|
+
то же, что у списка заказов, и записано здесь потому, что функция публичная.
|
|
578
|
+
Снятый с неполного, он потерял бы выпавшие диалоги, и при следующем чтении
|
|
579
|
+
каждый из них дал бы по ложному событию, которое гашение повторов не
|
|
580
|
+
поймает: отпечатка такого события в кэше никогда не было.
|
|
581
|
+
|
|
582
|
+
Хранится не позиция, а состояние: позиция вместе с выведенным признаком
|
|
583
|
+
непрочитанного. Одной позиции не хватало - прочтение диалога её не двигает,
|
|
584
|
+
и событие о прочтении не порождалось вовсе.
|
|
585
|
+
|
|
586
|
+
Внутри вызывается rows(accept_incomplete=True): неполнота здесь не ошибка, а
|
|
587
|
+
решение вызывающего, и глушить её исключением функция не вправе.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
page (ChatsPage): Прочитанная страница.
|
|
591
|
+
|
|
592
|
+
Returns:
|
|
593
|
+
dict[str, str]: Состояние по каждому диалогу: позиция и признак
|
|
594
|
+
непрочитанного через разделитель.
|
|
595
|
+
"""
|
|
596
|
+
cursor: dict[str, str] = {}
|
|
597
|
+
for entry in page.rows(accept_incomplete=True):
|
|
598
|
+
position = entry.last_message_position.or_none()
|
|
599
|
+
if position is None:
|
|
600
|
+
continue
|
|
601
|
+
cursor[entry.node_id] = _chat_state(position, entry.unread)
|
|
602
|
+
return cursor
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def direction_of(message: Message, own_href: str) -> str:
|
|
606
|
+
"""Определяет, кто написал сообщение по отношению к владельцу сессии.
|
|
607
|
+
|
|
608
|
+
СТРУКТУРНО, сравнением адресов профилей, а не по имени. Имя меняется, и
|
|
609
|
+
совпадение имён подделывается собеседником, назвавшимся как продавец;
|
|
610
|
+
адрес профиля подделать нельзя, не заведя аккаунт с тем же номером.
|
|
611
|
+
|
|
612
|
+
Оба адреса нормализуются: площадка отдаёт их и с завершающей косой чертой,
|
|
613
|
+
и без неё, и сравнение как есть объявило бы разными один и тот же профиль.
|
|
614
|
+
|
|
615
|
+
Аргументы:
|
|
616
|
+
message (Message): Сообщение переписки.
|
|
617
|
+
own_href (str): Адрес собственного профиля. Пустая строка означает, что
|
|
618
|
+
снять его со страницы не удалось.
|
|
619
|
+
|
|
620
|
+
Возвращает:
|
|
621
|
+
str: inbound, outbound либо unknown.
|
|
622
|
+
"""
|
|
623
|
+
if not own_href or not message.author_href.is_observed:
|
|
624
|
+
# Сравнивать нечего. Незнание объявляется значением, а не пустотой, - и
|
|
625
|
+
# греть переписку оно не вправе: тепло требует положительного
|
|
626
|
+
# свидетельства, а не отсутствия опровержения.
|
|
627
|
+
return "unknown"
|
|
628
|
+
if _normalized_href(message.author_href.value) == _normalized_href(own_href):
|
|
629
|
+
return "outbound"
|
|
630
|
+
return "inbound"
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _normalized_href(href: str) -> str:
|
|
634
|
+
"""Приводит адрес профиля к сравнимому виду.
|
|
635
|
+
|
|
636
|
+
Аргументы:
|
|
637
|
+
href (str): Адрес.
|
|
638
|
+
|
|
639
|
+
Возвращает:
|
|
640
|
+
str: Адрес без завершающей косой черты и краевых пробелов.
|
|
641
|
+
"""
|
|
642
|
+
return href.strip().rstrip("/")
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def diff_thread(
|
|
646
|
+
known: frozenset[str] | None,
|
|
647
|
+
thread: Thread,
|
|
648
|
+
*,
|
|
649
|
+
account_id: str,
|
|
650
|
+
chat_id: str,
|
|
651
|
+
own_href: str = "",
|
|
652
|
+
) -> tuple[Event, ...]:
|
|
653
|
+
"""Порождает события по переписке и курсору.
|
|
654
|
+
|
|
655
|
+
Args:
|
|
656
|
+
known (frozenset[str] | None): Идентификаторы уже известных сообщений.
|
|
657
|
+
None при первом чтении переписки.
|
|
658
|
+
thread (Thread): Прочитанная переписка.
|
|
659
|
+
account_id (str): Идентификатор аккаунта.
|
|
660
|
+
chat_id (str): Идентификатор диалога.
|
|
661
|
+
own_href (str): Адрес собственного профиля, снятый с той же страницы.
|
|
662
|
+
Пустая строка означает, что снять не удалось, и тогда направление у
|
|
663
|
+
всех сообщений выходит unknown.
|
|
664
|
+
|
|
665
|
+
Умолчание пустое НАРОЧНО, а не для удобства: функция публичная, и
|
|
666
|
+
вызывающий, у которого адреса нет, обязан получить честное незнание,
|
|
667
|
+
а не молчаливое «это писал не я».
|
|
668
|
+
|
|
669
|
+
Returns:
|
|
670
|
+
tuple[Event, ...]: События о новых сообщениях.
|
|
671
|
+
"""
|
|
672
|
+
if known is None:
|
|
673
|
+
return ()
|
|
674
|
+
|
|
675
|
+
events: list[Event] = []
|
|
676
|
+
for message in thread.messages(accept_incomplete=True):
|
|
677
|
+
if not message.message_id.is_observed:
|
|
678
|
+
continue
|
|
679
|
+
if message.message_id.value in known:
|
|
680
|
+
continue
|
|
681
|
+
|
|
682
|
+
events.append(
|
|
683
|
+
make_event(
|
|
684
|
+
account_id=account_id,
|
|
685
|
+
event_type=EventType.MESSAGE_CREATED,
|
|
686
|
+
entity_id=chat_id,
|
|
687
|
+
revision=message.message_id.value,
|
|
688
|
+
observed_at=thread.observed_at,
|
|
689
|
+
key_field="chat_id",
|
|
690
|
+
payload={
|
|
691
|
+
"chat_id": chat_id,
|
|
692
|
+
"message_id": message.message_id.value,
|
|
693
|
+
# Происхождение кладётся в нагрузку намеренно: обработчик
|
|
694
|
+
# обязан видеть его, не разбирая текст. Но даже origin равный
|
|
695
|
+
# system не является подтверждением оплаты - об этом сказано
|
|
696
|
+
# в spec/extraction/chats.yaml и в docstring разбора.
|
|
697
|
+
"origin": str(message.origin),
|
|
698
|
+
# Число, а не адреса: адреса пишет собеседник, и класть
|
|
699
|
+
# чужой ввод в нагрузку события незачем. Ненаблюдённое поле
|
|
700
|
+
# даёт None, а не ноль: ноль означал бы «ссылок не было».
|
|
701
|
+
"external_links": (
|
|
702
|
+
len(message.external_links.value)
|
|
703
|
+
if message.external_links.is_observed
|
|
704
|
+
else None
|
|
705
|
+
),
|
|
706
|
+
# Направление. Без него получатель отвечает на собственный
|
|
707
|
+
# ответ, а ограничитель исходящих считает холодной ту
|
|
708
|
+
# переписку, где собеседник только что написал сам.
|
|
709
|
+
"direction": direction_of(message, own_href),
|
|
710
|
+
},
|
|
711
|
+
)
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
return tuple(events)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def thread_cursor(thread: Thread) -> frozenset[str]:
|
|
718
|
+
"""Собирает курсор по прочитанной переписке.
|
|
719
|
+
|
|
720
|
+
В отличие от курсоров списков, этот снимается и с неполного чтения - так
|
|
721
|
+
делает цикл наблюдения, и вот почему. Сообщение, выпавшее из неполного
|
|
722
|
+
чтения и попавшее в следующее, действительно новое для получателя: событий о
|
|
723
|
+
нём никто не выдавал. Заказ в той же ситуации выглядел бы новым заказом, и
|
|
724
|
+
это было бы ложью о мире.
|
|
725
|
+
|
|
726
|
+
Обратное правило стоило дорого: пустой курсор молчит по правилу первого
|
|
727
|
+
чтения, а неполное первое чтение оставляло его пустым навсегда - диалог
|
|
728
|
+
замолкал совсем.
|
|
729
|
+
|
|
730
|
+
Args:
|
|
731
|
+
thread (Thread): Прочитанная переписка.
|
|
732
|
+
|
|
733
|
+
Returns:
|
|
734
|
+
frozenset[str]: Идентификаторы наблюдённых сообщений.
|
|
735
|
+
"""
|
|
736
|
+
return frozenset(
|
|
737
|
+
message.message_id.value
|
|
738
|
+
for message in thread.messages(accept_incomplete=True)
|
|
739
|
+
if message.message_id.is_observed
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def ordering_keys(events: Iterable[Event]) -> set[str]:
|
|
744
|
+
"""Собирает ключи упорядочивания набора событий.
|
|
745
|
+
|
|
746
|
+
Args:
|
|
747
|
+
events (Iterable[Event]): События.
|
|
748
|
+
|
|
749
|
+
Returns:
|
|
750
|
+
set[str]: Ключи. События с разными ключами обрабатываются параллельно.
|
|
751
|
+
"""
|
|
752
|
+
return {event.ordering_key for event in events}
|