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.
Files changed (87) hide show
  1. funora/__init__.py +265 -0
  2. funora/_account.py +661 -0
  3. funora/_aclient.py +1484 -0
  4. funora/_budget.py +579 -0
  5. funora/_calc.py +182 -0
  6. funora/_canonical.py +235 -0
  7. funora/_catalog.py +473 -0
  8. funora/_chat_history.py +367 -0
  9. funora/_chats.py +392 -0
  10. funora/_chips.py +403 -0
  11. funora/_classify.py +466 -0
  12. funora/_client.py +1490 -0
  13. funora/_currency_switch.py +130 -0
  14. funora/_cursor.py +120 -0
  15. funora/_delivered.py +233 -0
  16. funora/_diff.py +752 -0
  17. funora/_engine.py +4795 -0
  18. funora/_extract.py +124 -0
  19. funora/_field_schema.py +112 -0
  20. funora/_fileio.py +58 -0
  21. funora/_gate.py +69 -0
  22. funora/_hops.py +101 -0
  23. funora/_host.py +120 -0
  24. funora/_identity.py +284 -0
  25. funora/_json.py +31 -0
  26. funora/_listen.py +312 -0
  27. funora/_lot_form.py +331 -0
  28. funora/_market.py +406 -0
  29. funora/_matching.py +147 -0
  30. funora/_money.py +279 -0
  31. funora/_monitoring.py +396 -0
  32. funora/_observed.py +237 -0
  33. funora/_order.py +552 -0
  34. funora/_order_details.py +281 -0
  35. funora/_orders.py +807 -0
  36. funora/_outbound.py +453 -0
  37. funora/_own_lots.py +301 -0
  38. funora/_poll.py +410 -0
  39. funora/_price_audit.py +281 -0
  40. funora/_proxies.py +232 -0
  41. funora/_raise.py +150 -0
  42. funora/_refund.py +102 -0
  43. funora/_result.py +157 -0
  44. funora/_retry.py +213 -0
  45. funora/_review_write.py +138 -0
  46. funora/_reviews.py +584 -0
  47. funora/_runner.py +651 -0
  48. funora/_secret.py +385 -0
  49. funora/_showcase.py +362 -0
  50. funora/_signals.py +375 -0
  51. funora/_skeleton.py +752 -0
  52. funora/_snapshot.py +276 -0
  53. funora/_state.py +279 -0
  54. funora/_stock.py +32 -0
  55. funora/_thread.py +574 -0
  56. funora/_transport.py +1023 -0
  57. funora/_updates.py +292 -0
  58. funora/_verdicts.py +91 -0
  59. funora/_viewing.py +143 -0
  60. funora/_watch.py +825 -0
  61. funora/_watch_state.py +237 -0
  62. funora/_whoami.py +546 -0
  63. funora/bot/__init__.py +52 -0
  64. funora/bot/_delivery.py +341 -0
  65. funora/bot/_outbox.py +261 -0
  66. funora/bot/_runtime.py +447 -0
  67. funora/bot/_spool.py +534 -0
  68. funora/budget.py +311 -0
  69. funora/capabilities.py +297 -0
  70. funora/conformance.py +758 -0
  71. funora/contract.py +73 -0
  72. funora/errors.py +996 -0
  73. funora/events.py +189 -0
  74. funora/extraction.py +420 -0
  75. funora/observe.py +560 -0
  76. funora/operations.py +671 -0
  77. funora/py.typed +0 -0
  78. funora/reconciliation.py +51 -0
  79. funora/response_classes.py +155 -0
  80. funora/retry.py +238 -0
  81. funora/send_outcome.py +78 -0
  82. funora/skeleton_format.py +77 -0
  83. funora-0.0.1.dev2.dist-info/METADATA +294 -0
  84. funora-0.0.1.dev2.dist-info/RECORD +87 -0
  85. funora-0.0.1.dev2.dist-info/WHEEL +4 -0
  86. funora-0.0.1.dev2.dist-info/entry_points.txt +2 -0
  87. 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}