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/_runner.py ADDED
@@ -0,0 +1,651 @@
1
+ """Сборка обращения к каналу обновлений.
2
+
3
+ Канал - это POST /runner/ с формой из трёх полей: защитного токена, подписки и
4
+ запроса. Тем же обращением площадка и опрашивается, и меняется: при опросе поле
5
+ request не несёт действия, при отправке в нём лежит объект с именем действия.
6
+
7
+ ЗДЕСЬ ТОЛЬКО ЧТЕНИЕ СТРАНИЦЫ, без сети и без сборки самого действия. Модуль
8
+ отвечает на один вопрос: что нужно взять со страницы, чтобы обращение вообще
9
+ можно было составить.
10
+
11
+ ПОЧЕМУ ЭТО ОТДЕЛЬНЫЙ МОДУЛЬ. Всё нужное лежит на одной странице, но в шести
12
+ разных местах - в объекте настроек, в пяти атрибутах виджета и в атрибуте
13
+ активной строки списка. Собранное вместе, оно проверяется на снимке целиком;
14
+ разбросанное по операциям, оно проверялось бы по частям, и первая же операция
15
+ записи собрала бы запрос из того, чего на странице не оказалось.
16
+
17
+ ОТКУДА ЧТО БЕРЁТСЯ - НАБЛЮДЕНИЕ, А НЕ ДОГАДКА. Сборщик наблюдений сверяет
18
+ значения полей запроса со значениями атрибутов страницы в живой вкладке и
19
+ записывает имя совпавшего атрибута. Иначе установить это было нельзя: скелет
20
+ страницы и сетевая запись маскируют значения независимо, и одинаковые подписи
21
+ совпадают у любых двух значений той же длины и состава.
22
+
23
+ СТРАНИЦА НУЖНА С ОТКРЫТЫМ ДИАЛОГОМ. На списке диалогов у виджета класс
24
+ chat-not-selected, и из пяти атрибутов остаются два: ни имени диалога, ни его
25
+ метки там нет. Отправить, прочитав только список, нельзя, и молчать об этом
26
+ нельзя тоже - вызывающий принял бы пробел за отсутствие диалога.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass
32
+ from typing import Final
33
+
34
+ from selectolax.parser import HTMLParser, Node
35
+
36
+ from ._observed import Observed
37
+ from ._result import Completeness, Defect, Severity
38
+ from ._secret import Secret
39
+ from ._thread import Origin, Thread
40
+ from ._updates import load_runner_json
41
+ from ._whoami import parse_app_data
42
+ from .extraction import ATTRIBUTES, SELECTORS
43
+ from .reconciliation import ReconcileVerdict
44
+ from .send_outcome import SendOutcome
45
+
46
+ __all__ = [
47
+ "Anchor",
48
+ "Reconciliation",
49
+ "RunnerContext",
50
+ "SendResult",
51
+ "classify_send_response",
52
+ "parse_runner_context",
53
+ "reconcile",
54
+ "take_anchor",
55
+ ]
56
+
57
+ #: Имя вида объекта, которым канал отвечает об изменении в диалоге.
58
+ #:
59
+ #: Записано ДОСЛОВНО: сборщик наблюдений хранит значения полей type без
60
+ #: маскирования, потому что это протокольные знаки, а не то, что написал
61
+ #: человек.
62
+ _CHAT_NODE_TYPE: Final[str] = "chat_node"
63
+
64
+ #: Узел виджета переписки. Он же носитель имени диалога и меток подписки.
65
+ _WIDGET: Final[str] = SELECTORS["order.chat.widget"]
66
+
67
+ #: Строка списка диалогов.
68
+ _CONTACT: Final[str] = SELECTORS["chats.contact_list.item"]
69
+
70
+ #: Скрытый узел с меткой подписки на счётчики продаж.
71
+ _ORDERS_TAG_CARRIER: Final[str] = SELECTORS["updates.tags.carrier"]
72
+
73
+ #: Собственный идентификатор.
74
+ _OWN_ID: Final[str] = SELECTORS["session.identity.own_user_id"]
75
+
76
+ _NODE_NAME: Final[str] = ATTRIBUTES["order.chat.widget.attributes.node_name"]
77
+ _NODE_ID: Final[str] = ATTRIBUTES["order.chat.widget.attributes.node_id"]
78
+ _CHAT_TAG: Final[str] = ATTRIBUTES["order.chat.widget.attributes.tag"]
79
+ _BOOKMARKS_TAG: Final[str] = ATTRIBUTES["order.chat.widget.attributes.bookmarks_tag"]
80
+ _LAST_MESSAGE: Final[str] = ATTRIBUTES["chats.contact_list.attributes.last_message_position"]
81
+ _CONTACT_ID: Final[str] = ATTRIBUTES["chats.contact_list.attributes.node_id"]
82
+ _ORDERS_TAG: Final[str] = ATTRIBUTES["updates.tags.carrier.attributes.orders_tag"]
83
+ _MESSAGE_ITEM: Final[str] = SELECTORS["chats.message.item"]
84
+ _MESSAGE_ID: Final[str] = ATTRIBUTES["order.chat.messages.attributes.message_id"]
85
+ _OWN_PROFILE_LINK: Final[str] = SELECTORS["session.identity.own_profile_link"]
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class RunnerContext:
90
+ """Всё, что нужно со страницы для обращения к каналу.
91
+
92
+ Attributes:
93
+ csrf_token (Secret | None): Защитный токен. Обёрнут нарочно: значение
94
+ видно только по явному вызову reveal и в вывод не попадает.
95
+ node_name (Observed[str]): Составное имя диалога. Идёт в поле node.
96
+ node_id (Observed[str]): Числовой идентификатор диалога.
97
+ upload_size_max (Observed[int]): Предел размера выгружаемого файла,
98
+ объявленный площадкой на самой странице.
99
+ chat_tag (Observed[str]): Метка подписки на диалог.
100
+ bookmarks_tag (Observed[str]): Метка подписки на закладки.
101
+ orders_tag (Observed[str]): Метка подписки на счётчики продаж.
102
+ own_user_id (Observed[str]): Собственный идентификатор.
103
+ last_message (Observed[str]): Позиция последнего сообщения диалога.
104
+ can_send (bool): Годится ли страница для отправки.
105
+ defects (tuple[Defect, ...]): Замеченные повреждения.
106
+ """
107
+
108
+ csrf_token: Secret | None
109
+ node_name: Observed[str]
110
+ node_id: Observed[str]
111
+ upload_size_max: Observed[int]
112
+ chat_tag: Observed[str]
113
+ bookmarks_tag: Observed[str]
114
+ orders_tag: Observed[str]
115
+ own_user_id: Observed[str]
116
+ last_message: Observed[str]
117
+ can_send: bool
118
+ defects: tuple[Defect, ...] = ()
119
+
120
+
121
+ def _attribute(node: Node | None, name: str, field_name: str) -> Observed[str]:
122
+ """Читает атрибут узла как наблюдение.
123
+
124
+ Args:
125
+ node (Node | None): Узел либо None.
126
+ name (str): Имя атрибута.
127
+ field_name (str): Имя поля для причины отсутствия.
128
+
129
+ Returns:
130
+ Observed[str]: Наблюдение.
131
+ """
132
+ if node is None:
133
+ return Observed.missing(f"carrier_missing:{field_name}")
134
+ attributes = node.attributes or {}
135
+ if name not in attributes:
136
+ return Observed.missing(f"attribute_absent:{field_name}")
137
+ value = (attributes.get(name) or "").strip()
138
+ return Observed.present(value) if value else Observed.empty("")
139
+
140
+
141
+ def _active_contact(tree: HTMLParser, widget: Node | None) -> tuple[Node | None, list[Defect]]:
142
+ """Находит строку списка, отвечающую открытому диалогу.
143
+
144
+ Ищется НЕ по классу active, а по совпадению идентификатора со строкой
145
+ виджета. Класс - оформление, и опираться на него значило бы читать чужое
146
+ решение о подсветке; идентификатор же говорит о том, какой диалог открыт, по
147
+ существу.
148
+
149
+ Класс всё же используется - запасным путём, когда виджет идентификатора не
150
+ несёт. Порядок именно такой: сперва равенство значений, потом признак вида.
151
+
152
+ Args:
153
+ tree (HTMLParser): Разобранная страница.
154
+ widget (Node | None): Узел виджета либо None.
155
+
156
+ Returns:
157
+ tuple[Node | None, list[Defect]]: Строка и перечень повреждений.
158
+ """
159
+ rows = tree.css(_CONTACT)
160
+ if not rows:
161
+ return None, [
162
+ Defect(
163
+ severity=Severity.PAGE,
164
+ code="contact_list_missing",
165
+ detail=f"на странице нет строк списка диалогов ({_CONTACT})",
166
+ field_name="last_message",
167
+ )
168
+ ]
169
+
170
+ wanted = ((widget.attributes or {}).get(_NODE_ID) or "").strip() if widget is not None else ""
171
+ if wanted:
172
+ matched = [
173
+ one for one in rows if ((one.attributes or {}).get(_CONTACT_ID) or "").strip() == wanted
174
+ ]
175
+ if len(matched) == 1:
176
+ return matched[0], []
177
+ if len(matched) > 1:
178
+ return None, [
179
+ Defect(
180
+ severity=Severity.PAGE,
181
+ code="contact_rows_ambiguous",
182
+ detail=(
183
+ f"строк с идентификатором открытого диалога найдено {len(matched)}. "
184
+ "Взять любую значило бы выбрать наугад"
185
+ ),
186
+ field_name="last_message",
187
+ )
188
+ ]
189
+
190
+ marked = [
191
+ one for one in rows if "active" in ((one.attributes or {}).get("class") or "").split()
192
+ ]
193
+ if len(marked) == 1:
194
+ return marked[0], []
195
+
196
+ return None, [
197
+ Defect(
198
+ severity=Severity.PAGE,
199
+ code="active_contact_not_found",
200
+ detail=(
201
+ f"строка открытого диалога не нашлась: по идентификатору совпадений нет, "
202
+ f"по признаку оформления найдено {len(marked)}"
203
+ ),
204
+ field_name="last_message",
205
+ )
206
+ ]
207
+
208
+
209
+ def parse_runner_context(html: str) -> RunnerContext:
210
+ """Собирает со страницы всё нужное для обращения к каналу.
211
+
212
+ НЕ ПАДАЕТ НИ НА ЧЁМ. Страница может не годиться для отправки - это ответ, а
213
+ не происшествие, и вызывающему он нужен целиком: чего именно не хватило,
214
+ решает, перечитывать ли другую страницу или разбираться с разметкой.
215
+
216
+ Args:
217
+ html (str): Тело страницы.
218
+
219
+ Returns:
220
+ RunnerContext: Прочитанное и признак пригодности страницы.
221
+ """
222
+ tree = HTMLParser(html)
223
+ widget = tree.css_first(_WIDGET)
224
+ settings = parse_app_data(html)
225
+
226
+ defects: list[Defect] = list(settings.defects)
227
+
228
+ if widget is None:
229
+ defects.append(
230
+ Defect(
231
+ severity=Severity.PAGE,
232
+ code="chat_widget_missing",
233
+ detail=f"на странице нет виджета переписки ({_WIDGET})",
234
+ field_name="node_name",
235
+ )
236
+ )
237
+
238
+ node_name = _attribute(widget, _NODE_NAME, "node_name")
239
+ node_id = _attribute(widget, _NODE_ID, "node_id")
240
+ chat_tag = _attribute(widget, _CHAT_TAG, "chat_tag")
241
+
242
+ if widget is not None and not node_name.is_observed:
243
+ # Признак объявляется ПОЛОЖИТЕЛЬНЫЙ - отсутствие имени диалога, - а не
244
+ # наличие класса chat-not-selected: класс волен смениться, а без
245
+ # значения запрос всё равно не собрать.
246
+ defects.append(
247
+ Defect(
248
+ severity=Severity.PAGE,
249
+ code="chat_not_selected",
250
+ detail=(
251
+ f"у виджета нет имени диалога ({_NODE_NAME}). Так выглядит список "
252
+ "диалогов без открытого собеседника: отправить с него нельзя"
253
+ ),
254
+ field_name="node_name",
255
+ )
256
+ )
257
+
258
+ row, row_defects = _active_contact(tree, widget)
259
+ defects += row_defects
260
+
261
+ last_message = _attribute(row, _LAST_MESSAGE, "last_message")
262
+ # Позиция уходит в запрос ЧИСЛОМ - так наблюдено. Значение не из цифр
263
+ # означает, что прочитано не то: на снимке там подпись скелета, на живой
264
+ # странице - разметка изменилась. Собрать запрос из этого нельзя, и молчать
265
+ # нельзя тоже: негодная позиция ушла бы на площадку.
266
+ numeric = last_message.is_observed and last_message.value.isdigit()
267
+ if last_message.is_observed and not numeric:
268
+ defects.append(
269
+ Defect(
270
+ severity=Severity.PAGE,
271
+ code="last_message_not_numeric",
272
+ detail=(
273
+ f"позиция последнего сообщения ({_LAST_MESSAGE}) прочитана не "
274
+ "числом. В запрос она уходит числом, и подставить туда иное "
275
+ "значит отправить неизвестно что"
276
+ ),
277
+ field_name="last_message",
278
+ )
279
+ )
280
+
281
+ return RunnerContext(
282
+ csrf_token=settings.csrf_token,
283
+ node_name=node_name,
284
+ node_id=node_id,
285
+ upload_size_max=settings.upload_size_max,
286
+ chat_tag=chat_tag,
287
+ bookmarks_tag=_attribute(widget, _BOOKMARKS_TAG, "bookmarks_tag"),
288
+ orders_tag=_attribute(tree.css_first(_ORDERS_TAG_CARRIER), _ORDERS_TAG, "orders_tag"),
289
+ own_user_id=_attribute(tree.css_first(_OWN_ID), "data-user", "own_user_id"),
290
+ last_message=last_message,
291
+ can_send=(
292
+ settings.csrf_token is not None
293
+ and node_name.is_observed
294
+ and chat_tag.is_observed
295
+ and numeric
296
+ ),
297
+ defects=tuple(defects),
298
+ )
299
+
300
+
301
+ @dataclass(frozen=True, slots=True)
302
+ class SendResult:
303
+ """Чем окончилось обращение к каналу с действием.
304
+
305
+ НЕ СООБЩЕНИЕ, А КВИТАНЦИЯ. Текста здесь нет и быть не может: в ответе лежит
306
+ готовая разметка, значения которой не наблюдают намеренно - это чужая
307
+ переписка. Подставить эхо своего ввода не то же самое: что площадка
308
+ сохранила, неизвестно.
309
+
310
+ Attributes:
311
+ outcome (SendOutcome): Исход. Три значения, и третье - честное незнание.
312
+ reason (str): Машиночитаемая причина решения из закрытого перечня.
313
+ channel_message_id (Observed[int]): Идентификатор сообщения В ОТВЕТЕ
314
+ КАНАЛА. Имя нарочно не message_id: в разметке идентификатор - строка,
315
+ в канале число, и что это одно значение, не наблюдалось.
316
+ node (Observed[str]): Имя диалога, подтверждённое площадкой.
317
+ messages_in_answer (int): Сколько сообщений пришло в ответе.
318
+ reconciled (str): Чем окончилась сверка по истории. Значение
319
+ not_attempted означает, что сверка НЕ ДЕЛАЛАСЬ, и это не пробел: при
320
+ подтверждённом исходе читать историю незачем - ответ канала сам
321
+ несёт новое сообщение.
322
+ """
323
+
324
+ outcome: SendOutcome
325
+ reason: str
326
+ channel_message_id: Observed[int]
327
+ node: Observed[str]
328
+ messages_in_answer: int
329
+ reconciled: str = "not_attempted"
330
+
331
+ @property
332
+ def is_confirmed(self) -> bool:
333
+ """Говорит, подтвердила ли площадка отправку.
334
+
335
+ Свойство именованное, а не приведение к булеву: у квитанции три исхода,
336
+ и молчаливое `if result` читало бы unconfirmed как успех - ровно ту
337
+ ошибку, ради которой третий исход и заведён.
338
+
339
+ Returns:
340
+ bool: True только при исходе confirmed.
341
+ """
342
+ return self.outcome is SendOutcome.CONFIRMED
343
+
344
+
345
+ def _unconfirmed(reason: str) -> SendResult:
346
+ """Собирает квитанцию без подтверждения.
347
+
348
+ Args:
349
+ reason (str): Машиночитаемая причина.
350
+
351
+ Returns:
352
+ SendResult: Квитанция с исходом unconfirmed.
353
+ """
354
+ return SendResult(
355
+ outcome=SendOutcome.UNCONFIRMED,
356
+ reason=reason,
357
+ channel_message_id=Observed.missing(reason),
358
+ node=Observed.missing(reason),
359
+ messages_in_answer=0,
360
+ )
361
+
362
+
363
+ def classify_send_response(
364
+ body: str, *, sent_to: str, transport_failed: bool = False, http_status: int = 200
365
+ ) -> SendResult:
366
+ """Устанавливает исход отправки по ответу канала.
367
+
368
+ ПОРЯДОК ШАГОВ НОРМАТИВЕН и объявлен в spec/protocol/send-outcome.yaml. Две
369
+ реализации, проверившие условия в разном порядке, разойдутся ровно на том
370
+ ответе, ради которого правило написано.
371
+
372
+ Подтверждение объявляется по ПОЛОЖИТЕЛЬНОМУ признаку - площадка вернула
373
+ сообщение в том самом диалоге, - а не по отсутствию отказа. Отсутствие
374
+ отказа означало бы «отправлено» о всяком ответе с кодом 200.
375
+
376
+ Args:
377
+ body (str): Тело ответа канала.
378
+ sent_to (str): Имя диалога, в который отправляли. Сверяется с тем, что
379
+ вернула площадка: канал отвечает и о чужих диалогах, потому что
380
+ подписка едет в каждом запросе.
381
+
382
+ Returns:
383
+ SendResult: Исход, причина и прочитанное из ответа.
384
+ """
385
+ # Шаги 1-3. Ответ получен, статус допускает подтверждение, тело - JSON.
386
+ if transport_failed:
387
+ return _unconfirmed("transport_error")
388
+ if http_status != 200:
389
+ return _unconfirmed("unexpected_http_status")
390
+ try:
391
+ parsed: object = load_runner_json(body)
392
+ except (ValueError, RecursionError):
393
+ return _unconfirmed("body_not_json")
394
+
395
+ # Шаг 4. Разобранное - объект.
396
+ if not isinstance(parsed, dict):
397
+ return _unconfirmed("body_not_an_object")
398
+
399
+ # Шаг 5. Поле response - объект.
400
+ #
401
+ # При запросе БЕЗ действия оно приходит булевым, и это наблюдено. Булево
402
+ # здесь означает, что ответ пришёл на опрос, а не на действие: подтверждать
403
+ # им отправку нечем.
404
+ answer = parsed.get("response")
405
+ if not isinstance(answer, dict):
406
+ return _unconfirmed("response_not_an_object")
407
+
408
+ # Шаг 6. Отсутствующее поле не свидетельствует об успешном действии.
409
+ if "error" not in answer:
410
+ return _unconfirmed("response_error_missing")
411
+
412
+ # Шаг 7. Поле error равно null.
413
+ #
414
+ # Формы отказа никто не видел, и она здесь не нужна: довольно предиката.
415
+ if answer.get("error") is not None:
416
+ return SendResult(
417
+ outcome=SendOutcome.REFUSED,
418
+ reason="channel_reported_error",
419
+ channel_message_id=Observed.missing("channel_reported_error"),
420
+ node=Observed.missing("channel_reported_error"),
421
+ messages_in_answer=0,
422
+ )
423
+
424
+ # Шаг 8. Среди объектов есть узел диалога.
425
+ objects = parsed.get("objects")
426
+ nodes = [
427
+ one
428
+ for one in (objects if isinstance(objects, list) else [])
429
+ if isinstance(one, dict) and one.get("type") == _CHAT_NODE_TYPE
430
+ ]
431
+ if not nodes:
432
+ return _unconfirmed("no_chat_node_in_answer")
433
+
434
+ # Шаг 9. Узел диалога - тот самый.
435
+ mine = None
436
+ for one in nodes:
437
+ data = one.get("data")
438
+ if not isinstance(data, dict):
439
+ continue
440
+ node = data.get("node")
441
+ name = node.get("name") if isinstance(node, dict) else None
442
+ if isinstance(name, str) and name == sent_to:
443
+ mine = one
444
+ break
445
+ if mine is None:
446
+ return _unconfirmed("node_mismatch")
447
+
448
+ # Шаг 10. Список сообщений непуст.
449
+ data = mine.get("data")
450
+ messages = data.get("messages") if isinstance(data, dict) else None
451
+ written = [
452
+ one for one in (messages if isinstance(messages, list) else []) if isinstance(one, dict)
453
+ ]
454
+ if not written:
455
+ return _unconfirmed("empty_message_list")
456
+
457
+ # Шаг 11. Подтверждено.
458
+ last = written[-1].get("id")
459
+ return SendResult(
460
+ outcome=SendOutcome.CONFIRMED,
461
+ reason="confirmed_by_channel",
462
+ channel_message_id=(
463
+ Observed.present(last)
464
+ if isinstance(last, int) and not isinstance(last, bool)
465
+ else Observed.missing("channel_message_id_not_a_number")
466
+ ),
467
+ node=Observed.present(sent_to),
468
+ messages_in_answer=len(written),
469
+ )
470
+
471
+
472
+ @dataclass(frozen=True, slots=True)
473
+ class Anchor:
474
+ """Опора сверки, снятая ДО отправки.
475
+
476
+ Берётся из предполётного чтения страницы диалога - того самого, из которого
477
+ отправка и так берёт токен, имя диалога и позицию. Лишнего запроса нет.
478
+
479
+ Attributes:
480
+ own_href (str): Адрес собственного профиля. Пустая строка означает, что
481
+ узла на странице не нашлось.
482
+ known_ids (frozenset[str]): Идентификаторы сообщений, бывших ДО отправки.
483
+ messages_seen (int): Сколько узлов сообщений насчитало предполётное
484
+ чтение. Отличает ПУСТОЙ диалог от непрочитанной опоры: в первом
485
+ случае всякое найденное потом своё сообщение вправду новое, во
486
+ втором - неизвестно ничего.
487
+ """
488
+
489
+ own_href: str
490
+ known_ids: frozenset[str]
491
+ messages_seen: int
492
+
493
+
494
+ @dataclass(frozen=True, slots=True)
495
+ class Reconciliation:
496
+ """Чем окончилась сверка.
497
+
498
+ Attributes:
499
+ verdict (ReconcileVerdict): Вердикт. Вердикта отрицания среди них НЕТ.
500
+ reason (str): Машиночитаемая причина.
501
+ found_id (Observed[str]): Идентификатор найденного своего сообщения.
502
+ own_messages_seen (int): Сколько собственных сообщений нашлось всего.
503
+ Ноль означает, что признак опознания себя не показал.
504
+ """
505
+
506
+ verdict: ReconcileVerdict
507
+ reason: str
508
+ found_id: Observed[str]
509
+ own_messages_seen: int
510
+
511
+
512
+ def _normalized(href: str) -> str:
513
+ """Приводит адрес профиля к сравнимому виду.
514
+
515
+ Площадка отдаёт адреса и с завершающей косой чертой, и без неё. Сравнение
516
+ как есть объявило бы разными один и тот же профиль.
517
+
518
+ Args:
519
+ href (str): Адрес.
520
+
521
+ Returns:
522
+ str: Адрес без завершающей косой черты и краевых пробелов.
523
+ """
524
+ return href.strip().rstrip("/")
525
+
526
+
527
+ def take_anchor(html: str) -> Anchor:
528
+ """Снимает опору сверки со страницы ДО отправки.
529
+
530
+ Args:
531
+ html (str): Тело страницы диалога.
532
+
533
+ Returns:
534
+ Anchor: Адрес собственного профиля и множество прежних идентификаторов.
535
+ """
536
+ tree = HTMLParser(html)
537
+
538
+ # Узлов адреса ДВА - настольное меню и мобильное, - и значение в них одно и
539
+ # то же. Взять первый попавшийся значило бы повторить ошибку, на которой уже
540
+ # спотыкались разбор списка продаж, разбор отзывов и чтение своего имени.
541
+ #
542
+ # Здесь строже, чем у имени: по адресу опознаётся СВОЁ сообщение, и ошибка
543
+ # оканчивается вторым сообщением покупателю. Разошлись - адреса нет вовсе, и
544
+ # сверка ответа не даст.
545
+ links = {
546
+ _normalized((one.attributes or {}).get("href") or "") for one in tree.css(_OWN_PROFILE_LINK)
547
+ }
548
+ links.discard("")
549
+ own_href = links.pop() if len(links) == 1 else ""
550
+
551
+ nodes = tree.css(_MESSAGE_ITEM)
552
+ known: set[str] = set()
553
+ for node in nodes:
554
+ value = ((node.attributes or {}).get(_MESSAGE_ID) or "").strip()
555
+ if value:
556
+ known.add(value)
557
+ return Anchor(
558
+ own_href=own_href,
559
+ known_ids=frozenset(known),
560
+ messages_seen=len(nodes),
561
+ )
562
+
563
+
564
+ def reconcile(thread: Thread, anchor: Anchor) -> Reconciliation:
565
+ """Ищет своё новое сообщение в перечитанной истории.
566
+
567
+ ПОРЯДОК ШАГОВ НОРМАТИВЕН и объявлен в spec/protocol/reconciliation.yaml.
568
+
569
+ НИЧЕГО НЕ ОТПРАВЛЯЕТ. Сверка отвечает на вопрос «что вышло из отправки» и
570
+ ничего не предпринимает: решение о повторной отправке принимает вызывающий.
571
+
572
+ ВЕРДИКТА ОТРИЦАНИЯ НЕТ. Отсутствие сообщения в истории - свидетельство
573
+ отрицательное, и объявлять по нему «не отправлено» значило бы подтолкнуть
574
+ вызывающего написать второй раз.
575
+
576
+ Args:
577
+ thread (Thread): Перечитанная история переписки.
578
+ anchor (Anchor): Опора, снятая до отправки.
579
+
580
+ Returns:
581
+ Reconciliation: Вердикт, причина и найденное.
582
+ """
583
+ # Шаг 3. Полнота чтения. Половина истории, принятая за целую, объявила бы
584
+ # неотправленным отправленное.
585
+ if thread.completeness is not Completeness.COMPLETE:
586
+ return Reconciliation(
587
+ verdict=ReconcileVerdict.UNDETERMINED,
588
+ reason="incomplete_read",
589
+ found_id=Observed.missing("incomplete_read"),
590
+ own_messages_seen=0,
591
+ )
592
+
593
+ # Отбор ТОЛЬКО человеческих сообщений - второй замок, и он назван вторым
594
+ # нарочно. Первый стоит в разборе: сообщение с расходящимися признаками -
595
+ # оповещение площадки, несущее ссылку на профиль, - получает неизвестное
596
+ # происхождение, и чтение объявляется неполным. Сюда такое чтение не дойдёт.
597
+ #
598
+ # Замок оставлен потому, что правило разбора о полноте волен изменить кто
599
+ # угодно, а цена ошибки здесь - второе сообщение покупателю.
600
+ mine = [
601
+ one
602
+ for one in thread.messages()
603
+ if one.origin is Origin.HUMAN
604
+ and one.author_href.is_observed
605
+ and _normalized(one.author_href.value) == anchor.own_href
606
+ and anchor.own_href
607
+ ]
608
+
609
+ # Шаг 4. Признак себя показал себя. Без этого условия отсутствие искомого
610
+ # означало бы что угодно - и что его нет, и что опознание не работает.
611
+ if not mine:
612
+ return Reconciliation(
613
+ verdict=ReconcileVerdict.UNDETERMINED,
614
+ reason="self_marker_not_demonstrated",
615
+ found_id=Observed.missing("self_marker_not_demonstrated"),
616
+ own_messages_seen=0,
617
+ )
618
+
619
+ # Опора обязана быть прочитанной. Пустое множество при непустой странице
620
+ # означает, что идентификаторы не прочитались, - и тогда СТАРОЕ своё
621
+ # сообщение выглядит новым, а вызывающий узнаёт «доставлено» о том, что не
622
+ # уходило. Пустой диалог - другое дело: там всякое найденное вправду новое.
623
+ if not anchor.known_ids and anchor.messages_seen > 0:
624
+ return Reconciliation(
625
+ verdict=ReconcileVerdict.UNDETERMINED,
626
+ reason="anchor_not_read",
627
+ found_id=Observed.missing("anchor_not_read"),
628
+ own_messages_seen=len(mine),
629
+ )
630
+
631
+ # Шаг 5. Своё НОВОЕ сообщение.
632
+ fresh = [
633
+ one
634
+ for one in mine
635
+ if one.message_id.is_observed and one.message_id.value not in anchor.known_ids
636
+ ]
637
+ if fresh:
638
+ return Reconciliation(
639
+ verdict=ReconcileVerdict.DELIVERED,
640
+ reason="found_in_history",
641
+ found_id=Observed.present(fresh[-1].message_id.value),
642
+ own_messages_seen=len(mine),
643
+ )
644
+
645
+ # Шаг 6. Не найдено при исправном признаке.
646
+ return Reconciliation(
647
+ verdict=ReconcileVerdict.ABSENT_FROM_HISTORY,
648
+ reason="not_in_history",
649
+ found_id=Observed.missing("not_in_history"),
650
+ own_messages_seen=len(mine),
651
+ )