tg-use 0.1.0__tar.gz
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.
- tg_use-0.1.0/LICENSE +21 -0
- tg_use-0.1.0/PKG-INFO +92 -0
- tg_use-0.1.0/README.md +79 -0
- tg_use-0.1.0/pyproject.toml +29 -0
- tg_use-0.1.0/setup.cfg +4 -0
- tg_use-0.1.0/tg_use/SKILL.md +59 -0
- tg_use-0.1.0/tg_use/__init__.py +632 -0
- tg_use-0.1.0/tg_use.egg-info/PKG-INFO +92 -0
- tg_use-0.1.0/tg_use.egg-info/SOURCES.txt +11 -0
- tg_use-0.1.0/tg_use.egg-info/dependency_links.txt +1 -0
- tg_use-0.1.0/tg_use.egg-info/entry_points.txt +2 -0
- tg_use-0.1.0/tg_use.egg-info/requires.txt +1 -0
- tg_use-0.1.0/tg_use.egg-info/top_level.txt +1 -0
tg_use-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 axisrow
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tg_use-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tg-use
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Руки для агент-харнеса: тестирование Telegram-ботов через web.telegram.org (CLI без LLM)
|
|
5
|
+
Author: axisrow
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/axisrow/tg-use
|
|
8
|
+
Requires-Python: <4.0,>=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: browser-use>=0.13.8
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# tg-use
|
|
15
|
+
|
|
16
|
+
tg-use — тестировщик/копировщик Telegram-ботов через web.telegram.org (клиент `/k/`).
|
|
17
|
+
CLI-«руки» + скилл для агент-харнесов: мозг — модель харнеса, CLI обращений к
|
|
18
|
+
внешним моделям не содержит вовсе.
|
|
19
|
+
|
|
20
|
+
## Два режима работы browser-use
|
|
21
|
+
|
|
22
|
+
browser-use умеет работать двумя способами, и разница принципиальная:
|
|
23
|
+
|
|
24
|
+
1. **Browser Harness CLI + скилл — наш путь, рекомендуемый.** Браузер подключается
|
|
25
|
+
к харнесу (Claude Code, Codex, OpenCode…), и решения принимает модель харнеса.
|
|
26
|
+
Наш `tg-use.py` — только «руки»: чтение состояния, клики по кнопкам, артефакты,
|
|
27
|
+
прогон записанных сценариев. Логика «куда кликать дальше» живёт в скилле
|
|
28
|
+
(`.claude/skills/tg-use/SKILL.md`), а не в коде.
|
|
29
|
+
2. **Встроенный агент `Agent(task=..., llm=...)` со своим LLM-циклом — в этом
|
|
30
|
+
проекте так не делаем.** Этот режим дублирует мозг харнеса вторым (медленным
|
|
31
|
+
и ломким) контуром: ключи, endpoint'ы, таймауты, fallback-модели. Был
|
|
32
|
+
использован в v1 до 2026-09-20 и отвергнут владельцем вместе с z.ai-конфигом.
|
|
33
|
+
|
|
34
|
+
**Правило: использовать `browser-use skill install`, а не писать своего агента.**
|
|
35
|
+
В CLI нет ни одного обращения к внешней модели — проверяется `test_skeleton.py`.
|
|
36
|
+
|
|
37
|
+
## Установка
|
|
38
|
+
|
|
39
|
+
Из клона:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
pip3 install -r requirements.txt # в прокси-окружении: env -u ALL_PROXY -u all_proxy pip3 ...
|
|
43
|
+
python3 -m playwright install chromium
|
|
44
|
+
browser-use skill install # подключить браузер к харнесу
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Из PyPI (Python ≥3.11): `pipx install tg-use` — те же команды без `python3 tg-use.py`:
|
|
48
|
+
`tg-use login`, `tg-use open <@bot>`, `tg-use state`, …; `tg-use skill-install`
|
|
49
|
+
ставит скилл агенту (команды в нём переписываются на `tg-use`).
|
|
50
|
+
|
|
51
|
+
## Логин
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
python3 tg-use.py login # откроется окно с QR — отсканируй телефоном один раз
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Сессия сохраняется в `~/.tg-use-agent-profile` (= пароль от аккаунта) — повторный
|
|
58
|
+
запуск подхватит её без QR. При первом запуске macOS спросит доступ к «Chromium
|
|
59
|
+
Safe Storage»: вводи пароль входа и жми «Разрешать всегда» — это шифрование
|
|
60
|
+
cookies, без него сессия не переживёт перезапуск.
|
|
61
|
+
|
|
62
|
+
Browser Harness из коробки цепляется к обычному Chrome с его родным профилем и
|
|
63
|
+
наш изолированный профиль не даёт, поэтому профильный запуск остаётся за CLI:
|
|
64
|
+
`login` держит Chromium на нашем профиле и пишет его адрес в `~/.tg-use-cdp`;
|
|
65
|
+
`state`/`click`/`save`/`test` подключаются к этому живому браузеру. Харнес может
|
|
66
|
+
работать с тем же окном через browser-use (`BU_CDP_URL=$(cat ~/.tg-use-cdp)`).
|
|
67
|
+
|
|
68
|
+
## Команды (руки; «куда кликать дальше» решает харнес)
|
|
69
|
+
|
|
70
|
+
| Команда | Что делает |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `tg-use.py login` | headed-браузер с persistent-профилем; адрес → `~/.tg-use-cdp` |
|
|
73
|
+
| `tg-use.py open <@bot>` | открыть чат бота: поиск webk по username + клик по результату, guard по peer-id в hash; без отправок |
|
|
74
|
+
| `tg-use.py state` | JSON: текст последнего сообщения бота + подписи кнопок |
|
|
75
|
+
| `tg-use.py click "<label>"` | нажать inline-кнопку, вернуть новое состояние; отсутствие кнопки/реакции = ошибка (exit 1) |
|
|
76
|
+
| `tg-use.py save [--from id --button label] [--bot @name]` | дописать состояние/ребро в `artifacts/flow.json` + `artifacts/flow.md` (Mermaid) |
|
|
77
|
+
| `tg-use.py test scenario.json` | шаги `{"do": {"send"/"click"}, "expect": {"contains"/"regex"}}` → `artifacts/report.json`; падение = exit 1 |
|
|
78
|
+
|
|
79
|
+
Дальше — по скиллу `.claude/skills/tg-use/SKILL.md`: малый обход (глубина ≤2,
|
|
80
|
+
до ~5 состояний) и прогон сценариев. Безопасность: темп 2–3 c между действиями,
|
|
81
|
+
только диалоги с ботами, скраб токенов в выводе, PNG не сохраняем.
|
|
82
|
+
|
|
83
|
+
## Тесты и категории
|
|
84
|
+
|
|
85
|
+
Граница категорий — цена ошибки. `unit`: offline-скрипты, безопасный дефолт —
|
|
86
|
+
`python3 test_skeleton.py && python3 test_crawl.py && python3 test_hands.py`.
|
|
87
|
+
`state`/`open` — live_read (чтение/открытие чата без отправок); `click`/`save` —
|
|
88
|
+
live_write (обратимые действия в диалоге с ботом); `test`/`login` —
|
|
89
|
+
live_write_danger (необратимая отправка боту / полный доступ к аккаунту).
|
|
90
|
+
Гейты — в `.claude/settings.json`: владелец 2026-09-20 разрешил все подкоманды
|
|
91
|
+
целиком (`Bash(python3 tg-use.py:*)`), категории остались описанием цены ошибки,
|
|
92
|
+
а не спросом харнеса.
|
tg_use-0.1.0/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# tg-use
|
|
2
|
+
|
|
3
|
+
tg-use — тестировщик/копировщик Telegram-ботов через web.telegram.org (клиент `/k/`).
|
|
4
|
+
CLI-«руки» + скилл для агент-харнесов: мозг — модель харнеса, CLI обращений к
|
|
5
|
+
внешним моделям не содержит вовсе.
|
|
6
|
+
|
|
7
|
+
## Два режима работы browser-use
|
|
8
|
+
|
|
9
|
+
browser-use умеет работать двумя способами, и разница принципиальная:
|
|
10
|
+
|
|
11
|
+
1. **Browser Harness CLI + скилл — наш путь, рекомендуемый.** Браузер подключается
|
|
12
|
+
к харнесу (Claude Code, Codex, OpenCode…), и решения принимает модель харнеса.
|
|
13
|
+
Наш `tg-use.py` — только «руки»: чтение состояния, клики по кнопкам, артефакты,
|
|
14
|
+
прогон записанных сценариев. Логика «куда кликать дальше» живёт в скилле
|
|
15
|
+
(`.claude/skills/tg-use/SKILL.md`), а не в коде.
|
|
16
|
+
2. **Встроенный агент `Agent(task=..., llm=...)` со своим LLM-циклом — в этом
|
|
17
|
+
проекте так не делаем.** Этот режим дублирует мозг харнеса вторым (медленным
|
|
18
|
+
и ломким) контуром: ключи, endpoint'ы, таймауты, fallback-модели. Был
|
|
19
|
+
использован в v1 до 2026-09-20 и отвергнут владельцем вместе с z.ai-конфигом.
|
|
20
|
+
|
|
21
|
+
**Правило: использовать `browser-use skill install`, а не писать своего агента.**
|
|
22
|
+
В CLI нет ни одного обращения к внешней модели — проверяется `test_skeleton.py`.
|
|
23
|
+
|
|
24
|
+
## Установка
|
|
25
|
+
|
|
26
|
+
Из клона:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pip3 install -r requirements.txt # в прокси-окружении: env -u ALL_PROXY -u all_proxy pip3 ...
|
|
30
|
+
python3 -m playwright install chromium
|
|
31
|
+
browser-use skill install # подключить браузер к харнесу
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Из PyPI (Python ≥3.11): `pipx install tg-use` — те же команды без `python3 tg-use.py`:
|
|
35
|
+
`tg-use login`, `tg-use open <@bot>`, `tg-use state`, …; `tg-use skill-install`
|
|
36
|
+
ставит скилл агенту (команды в нём переписываются на `tg-use`).
|
|
37
|
+
|
|
38
|
+
## Логин
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
python3 tg-use.py login # откроется окно с QR — отсканируй телефоном один раз
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Сессия сохраняется в `~/.tg-use-agent-profile` (= пароль от аккаунта) — повторный
|
|
45
|
+
запуск подхватит её без QR. При первом запуске macOS спросит доступ к «Chromium
|
|
46
|
+
Safe Storage»: вводи пароль входа и жми «Разрешать всегда» — это шифрование
|
|
47
|
+
cookies, без него сессия не переживёт перезапуск.
|
|
48
|
+
|
|
49
|
+
Browser Harness из коробки цепляется к обычному Chrome с его родным профилем и
|
|
50
|
+
наш изолированный профиль не даёт, поэтому профильный запуск остаётся за CLI:
|
|
51
|
+
`login` держит Chromium на нашем профиле и пишет его адрес в `~/.tg-use-cdp`;
|
|
52
|
+
`state`/`click`/`save`/`test` подключаются к этому живому браузеру. Харнес может
|
|
53
|
+
работать с тем же окном через browser-use (`BU_CDP_URL=$(cat ~/.tg-use-cdp)`).
|
|
54
|
+
|
|
55
|
+
## Команды (руки; «куда кликать дальше» решает харнес)
|
|
56
|
+
|
|
57
|
+
| Команда | Что делает |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `tg-use.py login` | headed-браузер с persistent-профилем; адрес → `~/.tg-use-cdp` |
|
|
60
|
+
| `tg-use.py open <@bot>` | открыть чат бота: поиск webk по username + клик по результату, guard по peer-id в hash; без отправок |
|
|
61
|
+
| `tg-use.py state` | JSON: текст последнего сообщения бота + подписи кнопок |
|
|
62
|
+
| `tg-use.py click "<label>"` | нажать inline-кнопку, вернуть новое состояние; отсутствие кнопки/реакции = ошибка (exit 1) |
|
|
63
|
+
| `tg-use.py save [--from id --button label] [--bot @name]` | дописать состояние/ребро в `artifacts/flow.json` + `artifacts/flow.md` (Mermaid) |
|
|
64
|
+
| `tg-use.py test scenario.json` | шаги `{"do": {"send"/"click"}, "expect": {"contains"/"regex"}}` → `artifacts/report.json`; падение = exit 1 |
|
|
65
|
+
|
|
66
|
+
Дальше — по скиллу `.claude/skills/tg-use/SKILL.md`: малый обход (глубина ≤2,
|
|
67
|
+
до ~5 состояний) и прогон сценариев. Безопасность: темп 2–3 c между действиями,
|
|
68
|
+
только диалоги с ботами, скраб токенов в выводе, PNG не сохраняем.
|
|
69
|
+
|
|
70
|
+
## Тесты и категории
|
|
71
|
+
|
|
72
|
+
Граница категорий — цена ошибки. `unit`: offline-скрипты, безопасный дефолт —
|
|
73
|
+
`python3 test_skeleton.py && python3 test_crawl.py && python3 test_hands.py`.
|
|
74
|
+
`state`/`open` — live_read (чтение/открытие чата без отправок); `click`/`save` —
|
|
75
|
+
live_write (обратимые действия в диалоге с ботом); `test`/`login` —
|
|
76
|
+
live_write_danger (необратимая отправка боту / полный доступ к аккаунту).
|
|
77
|
+
Гейты — в `.claude/settings.json`: владелец 2026-09-20 разрешил все подкоманды
|
|
78
|
+
целиком (`Bash(python3 tg-use.py:*)`), категории остались описанием цены ошибки,
|
|
79
|
+
а не спросом харнеса.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tg-use"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Руки для агент-харнеса: тестирование Telegram-ботов через web.telegram.org (CLI без LLM)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.11,<4.0"
|
|
13
|
+
dependencies = ["browser-use>=0.13.8"]
|
|
14
|
+
authors = [{ name = "axisrow" }]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://github.com/axisrow/tg-use"
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
tg-use = "tg_use:main"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools]
|
|
23
|
+
packages = ["tg_use"]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.package-data]
|
|
26
|
+
tg_use = ["SKILL.md"]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.dynamic]
|
|
29
|
+
version = { attr = "tg_use.__version__" }
|
tg_use-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tg-use
|
|
3
|
+
description: Снятие структуры меню Telegram-ботов и прогон сценариев через web.telegram.org CLI tg-use.py (руки) — мозг (ты) решает, куда кликать.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# tg-use: малый обход бота и сценарий
|
|
7
|
+
|
|
8
|
+
Браузерная механика — в скилле `browser-use` (`browser-use skill install`); этот
|
|
9
|
+
скилл — только профильный нюанс и процедура. В CLI нет модели: решения принимаешь
|
|
10
|
+
ты, `tg-use.py` — только руки (login/open/state/click/save/test).
|
|
11
|
+
|
|
12
|
+
## Подготовка
|
|
13
|
+
|
|
14
|
+
1. `python3 tg-use.py login` — headed-браузер с профилем `~/.tg-use-agent-profile`
|
|
15
|
+
(= пароль от аккаунта; не коммитить, не чистить). Адрес живого браузера он
|
|
16
|
+
пишет в `~/.tg-use-cdp`; окно не закрывать до конца работы. Повторный запуск
|
|
17
|
+
QR не требует. Хочешь работать с этим браузером сам через browser-use —
|
|
18
|
+
подключайся по `BU_CDP_URL=$(cat ~/.tg-use-cdp)`, это тот же Chromium.
|
|
19
|
+
2. `python3 tg-use.py open @bot` — CLI сам находит чат по username через поиск
|
|
20
|
+
webk и открывает его, проверяя peer-id (guard от чужого чата); ничего не
|
|
21
|
+
отправляет. Руками в браузере ничего не открываем. Сразу после `login`
|
|
22
|
+
поиск webk бывает глух — CLI сам будит поле настоящей клавишей (#27);
|
|
23
|
+
поиск не ответил — это неисправность: сообщи владельцу, не жди и не повторяй.
|
|
24
|
+
3. `python3 tg-use.py state` — убедись, что чат отвечает. Если бот ещё не
|
|
25
|
+
стартовал (пусто), `/start` уходит одностадийным сценарием — `test` с шагом
|
|
26
|
+
`{"do": {"send": "/start"}}`.
|
|
27
|
+
|
|
28
|
+
## Малый обход (глубина ≤2, до ~5 состояний)
|
|
29
|
+
|
|
30
|
+
Цикл, каждый шаг — вызов CLI; темп задаёшь ты: пауза 2–3 c между вызовами.
|
|
31
|
+
Ошибка «страница webk не смонтирована» — повтори `open @bot`.
|
|
32
|
+
|
|
33
|
+
1. `python3 tg-use.py state` → JSON `{id, text, buttons, reply_buttons}` (reply_buttons —
|
|
34
|
+
нижняя reply-клавиатура бота; click жмёт и её кнопки).
|
|
35
|
+
2. `python3 tg-use.py save --bot @bot` — первое состояние (корень); дальше
|
|
36
|
+
`save --from <id> --button "<label>"`.
|
|
37
|
+
3. Выбери кнопку сам; лучше
|
|
38
|
+
не «API Token» (токен будет замазан скрабом). `python3 tg-use.py click "<label>"` —
|
|
39
|
+
вернёт новое состояние; ошибка = битое ребро, пометь и не повторяй.
|
|
40
|
+
4. Новое состояние → `save --from <id_родителя> --button "<label>"`.
|
|
41
|
+
5. Вложенные уровни: из нового состояния один шаг вглубь (итого ≤2 от корня),
|
|
42
|
+
не более ~5 состояний. Достаточно для карты меню.
|
|
43
|
+
|
|
44
|
+
Артефакты: `artifacts/flow.json` + `artifacts/flow.md` (Mermaid) — CLI
|
|
45
|
+
перезаписывает их после каждого save, прерванный обход оставляет валидный след.
|
|
46
|
+
|
|
47
|
+
## Сценарий
|
|
48
|
+
|
|
49
|
+
Сохрани `artifacts/<bot>.scenario.json`:
|
|
50
|
+
`[{"do": {"send": "/mybots"} | {"click": "<label>"}, "expect": {"contains"|"regex": "..."}}]`
|
|
51
|
+
и прогони: `python3 tg-use.py test artifacts/<bot>.scenario.json` → `artifacts/report.json`
|
|
52
|
+
(pass/fail по шагам + латентность), падение = exit 1. `send` шлёт любой текст
|
|
53
|
+
в диалог бота, не только `/`-команды — свободный ввод опросников проходит им.
|
|
54
|
+
|
|
55
|
+
## Правила безопасности (не упрощать)
|
|
56
|
+
|
|
57
|
+
- Только диалоги с ботами; ни одного сообщения живым людям.
|
|
58
|
+
- PNG не снимаем; текст артефактов и так проходит скраб токенов.
|
|
59
|
+
- Деструктивное не выбирай: любое решение — твоё, в моменте; CLI ничего не запрещает.
|
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
"""tg-use — руки для агент-харнеса: тестировщик Telegram-ботов через web.telegram.org (клиент /k/).
|
|
2
|
+
|
|
3
|
+
В CLI нет ни одного обращения к внешней модели: мозг — модель харнеса, который
|
|
4
|
+
запускает этот CLI (README: два режима browser-use; наш — Browser Harness CLI +
|
|
5
|
+
скилл). browser-use используется как библиотека браузера (BrowserSession):
|
|
6
|
+
навигация, ожидания и клики — его зона. Встроенный агент со своим циклом не
|
|
7
|
+
используется — обход и выбор следующего шага делает харнес (см. SKILL.md).
|
|
8
|
+
|
|
9
|
+
Безопасность: темп 2–3 c между действиями задаёт харнес между вызовами CLI;
|
|
10
|
+
только диалоги с ботами; скраб токенов в каждом выводе;
|
|
11
|
+
~/.tg-use-agent-profile = пароль (полный доступ к аккаунту).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__version__ = '0.1.0'
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import asyncio
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import re
|
|
24
|
+
import time
|
|
25
|
+
import urllib.request
|
|
26
|
+
|
|
27
|
+
from browser_use.browser import BrowserSession
|
|
28
|
+
|
|
29
|
+
logging.getLogger('browser_use').setLevel(logging.WARNING) # event-логи библиотеки — не наш вывод
|
|
30
|
+
|
|
31
|
+
TG_URL = 'https://web.telegram.org/k/'
|
|
32
|
+
PROFILE = os.path.expanduser('~/.tg-use-agent-profile') # = пароль: полный доступ к аккаунту
|
|
33
|
+
CDP_FILE = os.path.expanduser('~/.tg-use-cdp') # login пишет сюда адрес живого браузера
|
|
34
|
+
ART = 'artifacts' # артефакты: flow.json, flow.md, report.json
|
|
35
|
+
POLL = 2.5 # темп 2–3 c между действиями в чате
|
|
36
|
+
WAIT = 12.0 # потолок ожидания реакции бота после клика/отправки
|
|
37
|
+
OPEN_POLL = 2.0 # пауза между пробами монтирования webk в wait_open
|
|
38
|
+
|
|
39
|
+
JS_STATE = '''() => {
|
|
40
|
+
// reply-клавиатура — нижняя панель .reply-keyboard: кнопки там те же
|
|
41
|
+
// button.reply-markup-button (tweb replyKeyboard.tsx). Панель висит в DOM
|
|
42
|
+
// постоянно (display:none без клавиатуры, кнопки остаются после закрытия) —
|
|
43
|
+
// offsetParent берёт только живую видимую панель
|
|
44
|
+
const reply = [...document.querySelectorAll('.reply-keyboard button.reply-markup-button')]
|
|
45
|
+
.filter(b => b.offsetParent !== null)
|
|
46
|
+
.map(b => (b.querySelector('.reply-markup-button-text') || b).innerText.trim());
|
|
47
|
+
const bubbles = [...document.querySelectorAll('.bubble.is-in')];
|
|
48
|
+
const last = bubbles[bubbles.length - 1];
|
|
49
|
+
if (!last) return {text: '', buttons: [], reply_buttons: reply,
|
|
50
|
+
bodyLen: document.body ? document.body.innerText.length : 0};
|
|
51
|
+
const t = last.querySelector('.translatable-message') || last.querySelector('.message');
|
|
52
|
+
const buttons = [...last.querySelectorAll('button.reply-markup-button')]
|
|
53
|
+
.map(b => (b.querySelector('.reply-markup-button-text') || b).innerText.trim());
|
|
54
|
+
return {text: (t ? t.innerText : '').trim(), buttons, reply_buttons: reply, n: bubbles.length};
|
|
55
|
+
}'''
|
|
56
|
+
|
|
57
|
+
# клик той же пробой, что нашла кнопку: между поиском и кликом DOM не пере-запрашивается,
|
|
58
|
+
# так что бот не успеет переписать сообщение и увести клик в чужую кнопку
|
|
59
|
+
JS_CLICK_BUTTON = '''(label) => {
|
|
60
|
+
const bubbles = [...document.querySelectorAll('.bubble.is-in')];
|
|
61
|
+
const last = bubbles[bubbles.length - 1];
|
|
62
|
+
const byLabel = b => (b.querySelector('.reply-markup-button-text') || b).innerText.trim() === label;
|
|
63
|
+
const btn = (last && [...last.querySelectorAll('button.reply-markup-button')].find(byLabel))
|
|
64
|
+
// reply-клавиатура: та же проба без пере-запроса DOM; offsetParent отсекает
|
|
65
|
+
// панель-двойник display:none (кнопки в ней остаются после закрытия)
|
|
66
|
+
|| [...document.querySelectorAll('.reply-keyboard button.reply-markup-button')]
|
|
67
|
+
.filter(b => b.offsetParent !== null).find(byLabel);
|
|
68
|
+
if (!btn) return false;
|
|
69
|
+
btn.scrollIntoView({block: 'center'});
|
|
70
|
+
for (const type of ['mousedown', 'mouseup', 'click']) {
|
|
71
|
+
btn.dispatchEvent(new MouseEvent(type, {bubbles: true, cancelable: true, view: window}));
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}'''
|
|
75
|
+
|
|
76
|
+
JS_TYPE = '''(text) => {
|
|
77
|
+
const field = document.querySelector('.input-message-input');
|
|
78
|
+
if (!field) return 'no-field';
|
|
79
|
+
field.focus();
|
|
80
|
+
return 'typed:' + String(document.execCommand('insertText', false, text));
|
|
81
|
+
}'''
|
|
82
|
+
|
|
83
|
+
# reply-клавиатура webk свёрнута по умолчанию: кнопки попадают в DOM только после
|
|
84
|
+
# клика по тумблеру у поля ввода (наблюдено живьём: drwebbot, manybot)
|
|
85
|
+
JS_TOGGLE_REPLY = '''() => {
|
|
86
|
+
if (document.querySelector('.reply-keyboard')) return 'open';
|
|
87
|
+
const t = document.querySelector('.btn-icon.toggle-reply-markup');
|
|
88
|
+
if (!t) return 'no-toggle';
|
|
89
|
+
for (const type of ['mousedown', 'mouseup', 'click']) {
|
|
90
|
+
t.dispatchEvent(new MouseEvent(type, {bubbles: true, cancelable: true, view: window}));
|
|
91
|
+
}
|
|
92
|
+
return 'toggled';
|
|
93
|
+
}'''
|
|
94
|
+
|
|
95
|
+
# панель reply-клавиатуры переживает смену чата: после open клик схлопывает
|
|
96
|
+
# пережитую, read_state развернёт уже для нового чата (иначе чужие reply_buttons —
|
|
97
|
+
# дефект найден живой приёмкой #31: в чате drwebbot светились кнопки manybot)
|
|
98
|
+
JS_COLLAPSE_REPLY = '''() => {
|
|
99
|
+
const t = document.querySelector('.btn-icon.toggle-reply-markup');
|
|
100
|
+
if (!t) return 'no-toggle';
|
|
101
|
+
for (const type of ['mousedown', 'mouseup', 'click']) {
|
|
102
|
+
t.dispatchEvent(new MouseEvent(type, {bubbles: true, cancelable: true, view: window}));
|
|
103
|
+
}
|
|
104
|
+
return 'toggled';
|
|
105
|
+
}'''
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def scrub(text: str) -> str:
|
|
109
|
+
"""Затереть секреты (токены ботов вида 1234567890:AA...) перед выводом/записью в артефакты."""
|
|
110
|
+
# без хвостового \b: если токен кончается на -/_, границы слова нет и 1–2 символа остались бы видимыми
|
|
111
|
+
return re.sub(r'\b\d{8,12}:[A-Za-z0-9_-]{30,}', '[REDACTED]', text)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def esc(s: str, limit: int = 40) -> str:
|
|
115
|
+
"""Первая строка строки, обрезанная и безопасная для кавычек Mermaid."""
|
|
116
|
+
return s.splitlines()[0][:limit].replace('"', "'") if s else '(нет текста)'
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def state_key(st: dict) -> str:
|
|
120
|
+
"""Ключ дедупа состояния: sha1(текст + отсортированные подписи кнопок)."""
|
|
121
|
+
return hashlib.sha1((st['text'] + str(sorted(st['buttons']))).encode()).hexdigest()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def reaction_key(st: dict) -> str:
|
|
125
|
+
"""Ключ пробы состояния (текст + кнопки + n): для сравнения двух подряд опросов.
|
|
126
|
+
Сам по себе реакцией НЕ считается: список бабблов виртуализирован и n гуляет без бота."""
|
|
127
|
+
return hashlib.sha1((str(st.get('n', 0)) + st['text'] + str(sorted(st['buttons']))).encode()).hexdigest()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def reaction_seen(st: dict, before: dict, prev_rk: str) -> bool:
|
|
131
|
+
"""Реакция бота на действие: сменились текст/кнопки, ИЛИ вырос n — но рост верим
|
|
132
|
+
только после двух подряд одинаковых проб (догрузка истории меняет n без действия бота).
|
|
133
|
+
Граница эвристики: если виртуализация сдула список ниже before.n, дубль бота может
|
|
134
|
+
не дотянуть до before.n — таймаут (FAIL); ложных реакций это не даёт никогда."""
|
|
135
|
+
if state_key(st) != state_key(before):
|
|
136
|
+
return True
|
|
137
|
+
return bool(prev_rk) and st.get('n', 0) > before.get('n', 0) and reaction_key(st) == prev_rk
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def check_expect(text: str, expect: dict | None) -> tuple[bool, str]:
|
|
141
|
+
"""Ожидания шага против текста последнего сообщения бота: contains и/или regex."""
|
|
142
|
+
for kind, pat in (expect or {}).items():
|
|
143
|
+
if kind not in ('contains', 'regex'): # опечатка в сценарии = падение, не ложный PASS
|
|
144
|
+
return False, f'неизвестный expect: {kind!r} (умею contains/regex)'
|
|
145
|
+
if kind == 'contains' and pat not in text:
|
|
146
|
+
return False, f'в тексте нет «{pat}»'
|
|
147
|
+
if kind == 'regex' and not re.search(pat, text):
|
|
148
|
+
return False, f'в тексте нет /{pat}/'
|
|
149
|
+
return True, ''
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def write_artifacts(flow: dict) -> None:
|
|
153
|
+
"""flow.json + flow.md (Mermaid); перезаписывается целиком после каждого save."""
|
|
154
|
+
os.makedirs(ART, exist_ok=True)
|
|
155
|
+
with open(os.path.join(ART, 'flow.json'), 'w') as f:
|
|
156
|
+
json.dump(flow, f, ensure_ascii=False, indent=2)
|
|
157
|
+
lines = ['flowchart TD']
|
|
158
|
+
for st in flow['states']:
|
|
159
|
+
lines.append(f' {st["id"]}["{esc(st["text"])}"]')
|
|
160
|
+
for e in flow['edges']:
|
|
161
|
+
lines.append(f' {e["from"]} -->|"{esc(e["button"], 25)}"| {e["to"]}')
|
|
162
|
+
with open(os.path.join(ART, 'flow.md'), 'w') as f:
|
|
163
|
+
f.write('# flow\n\n```mermaid\n' + '\n'.join(lines) + '\n```\n')
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def install_skill(repo_dir: str, home: str) -> str:
|
|
167
|
+
"""Поставить скилл tg-use агенту: копия SKILL.md в ~/.claude/skills/tg-use/ с
|
|
168
|
+
командами под этот способ установки — репо: абсолютный путь к CLI рядом,
|
|
169
|
+
pip-пакет: консольный скрипт tg-use. → путь установки (переустанавливай
|
|
170
|
+
после обновлений)."""
|
|
171
|
+
src = os.path.join(repo_dir, '.claude', 'skills', 'tg-use', 'SKILL.md')
|
|
172
|
+
if os.path.exists(src): # репо: CLI лежит рядом — команды на абсолютный путь
|
|
173
|
+
with open(src) as f:
|
|
174
|
+
text = f.read()
|
|
175
|
+
text = text.replace('python3 tg-use.py', f'python3 {os.path.join(repo_dir, "tg-use.py")}')
|
|
176
|
+
else: # пакет (pip install): SKILL.md внутри пакета, CLI — скрипт tg-use на PATH
|
|
177
|
+
with open(os.path.join(repo_dir, 'SKILL.md')) as f:
|
|
178
|
+
text = f.read()
|
|
179
|
+
text = text.replace('python3 tg-use.py', 'tg-use')
|
|
180
|
+
dst_dir = os.path.join(home, '.claude', 'skills', 'tg-use')
|
|
181
|
+
os.makedirs(dst_dir, exist_ok=True)
|
|
182
|
+
dst = os.path.join(dst_dir, 'SKILL.md')
|
|
183
|
+
with open(dst, 'w') as f:
|
|
184
|
+
f.write(text)
|
|
185
|
+
return dst
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def cdp_alive() -> str:
|
|
189
|
+
"""Адрес живого браузера из CDP_FILE, если тот отвечает по HTTP, иначе ''."""
|
|
190
|
+
try:
|
|
191
|
+
with open(CDP_FILE) as f:
|
|
192
|
+
cdp = f.read().strip()
|
|
193
|
+
except OSError:
|
|
194
|
+
return ''
|
|
195
|
+
base = cdp.split('/devtools')[0].replace('ws', 'http', 1)
|
|
196
|
+
try:
|
|
197
|
+
with urllib.request.urlopen(base + '/json/version', timeout=3) as r:
|
|
198
|
+
r.read()
|
|
199
|
+
except Exception: # файл протух или браузер умер
|
|
200
|
+
return ''
|
|
201
|
+
return cdp
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def connect() -> BrowserSession:
|
|
205
|
+
"""Подключиться к браузеру, оставленному login (адрес в ~/.tg-use-cdp)."""
|
|
206
|
+
try:
|
|
207
|
+
with open(CDP_FILE) as f:
|
|
208
|
+
cdp = f.read().strip()
|
|
209
|
+
except FileNotFoundError:
|
|
210
|
+
raise SystemExit('нет живого браузера: сначала python3 tg-use.py login')
|
|
211
|
+
browser = BrowserSession(cdp_url=cdp)
|
|
212
|
+
try:
|
|
213
|
+
await browser.start()
|
|
214
|
+
except Exception as e:
|
|
215
|
+
raise SystemExit(f'браузер {cdp} не отвечает ({type(e).__name__}: {e}); перезапусти login')
|
|
216
|
+
return browser
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
async def ensure_reply_keyboard(page) -> None:
|
|
220
|
+
"""Развернуть свёрнутую webk reply-клавиатуру, чтобы state/click видели кнопки;
|
|
221
|
+
панель уже открыта или тумблера нет — no-op."""
|
|
222
|
+
if (await page.evaluate(JS_TOGGLE_REPLY)) == 'toggled':
|
|
223
|
+
await asyncio.sleep(OPEN_POLL) # панель монтируется асинхронно
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def read_state(page) -> dict:
|
|
227
|
+
"""Текст последнего сообщения бота + подписи кнопок (из живого чата).
|
|
228
|
+
Несмонтированная страница ≠ пустой чат: честная ошибка вместо «бот молчит»."""
|
|
229
|
+
await ensure_reply_keyboard(page)
|
|
230
|
+
st = json.loads(await page.evaluate(JS_STATE))
|
|
231
|
+
if not (st.get('bodyLen') or st.get('n')): # нет сообщений И нет body → UI не смонтирован
|
|
232
|
+
raise RuntimeError('страница webk не смонтирована (воркеры залипли?) — оживи: open @bot')
|
|
233
|
+
return st
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
async def wait_reaction(page, before: dict) -> dict:
|
|
237
|
+
"""Опрашивать состояние, пока бот не среагирует (reaction_seen); тишина — ошибка ребра."""
|
|
238
|
+
deadline = time.monotonic() + WAIT
|
|
239
|
+
prev_rk = ''
|
|
240
|
+
while True:
|
|
241
|
+
await asyncio.sleep(POLL)
|
|
242
|
+
st = await read_state(page)
|
|
243
|
+
if reaction_seen(st, before, prev_rk):
|
|
244
|
+
return st
|
|
245
|
+
prev_rk = reaction_key(st)
|
|
246
|
+
if time.monotonic() > deadline:
|
|
247
|
+
raise RuntimeError('реакции бота не последовало (состояние не изменилось)')
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
async def do_click(page, label: str) -> dict:
|
|
251
|
+
"""Нажать кнопку по подписи и вернуть новое состояние; промах — ошибка ребра."""
|
|
252
|
+
before = await read_state(page)
|
|
253
|
+
# evaluate питонизирует голые булевы (JS true → 'True'), это не JSON — json.loads падает
|
|
254
|
+
if (await page.evaluate(JS_CLICK_BUTTON, label)) != 'True':
|
|
255
|
+
raise RuntimeError(f'кнопки «{label}» нет под последним сообщением и на reply-клавиатуре')
|
|
256
|
+
return await wait_reaction(page, before)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
async def do_send(page, text: str) -> dict:
|
|
260
|
+
"""Отправить текст или команду в поле ввода и вернуть новое состояние."""
|
|
261
|
+
before = await read_state(page)
|
|
262
|
+
typed = await page.evaluate(JS_TYPE, text)
|
|
263
|
+
if typed != 'typed:true':
|
|
264
|
+
raise RuntimeError(f'не удалось ввести «{text}» (поле ввода: {typed or "нет ответа"})')
|
|
265
|
+
await page.press('Enter')
|
|
266
|
+
return await wait_reaction(page, before)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def shown(st: dict) -> dict:
|
|
270
|
+
"""Состояние для вывода/артефактов: скраб токенов + короткий id."""
|
|
271
|
+
st = dict(st, text=scrub(st['text']))
|
|
272
|
+
return dict(st, id=state_key(st)[:8])
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def cmd_login() -> None:
|
|
276
|
+
"""Headed-браузер с persistent-профилем; адрес живого браузера — в ~/.tg-use-cdp.
|
|
277
|
+
Повторный login к живому Chromium подключается, а не запускает второй: второй на том
|
|
278
|
+
же профиле молча пересоздаёт браузер в пустом temp-профиле (SingletonLock) — сессия
|
|
279
|
+
терялась при напечатанном «Сессия сохранена»."""
|
|
280
|
+
alive = cdp_alive()
|
|
281
|
+
if alive:
|
|
282
|
+
print(f'живой Chromium уже работает ({alive}); второй не запускаю — переиспользую его.')
|
|
283
|
+
browser = await connect()
|
|
284
|
+
else:
|
|
285
|
+
# ponytail: зомби-браузер без CDP-файла не ловим — после kill() в finally такие
|
|
286
|
+
# не остаются; поймать руками удалённый файл при живом окне можно только так
|
|
287
|
+
if os.path.exists(CDP_FILE):
|
|
288
|
+
os.remove(CDP_FILE) # протухший адрес мёртвого браузера только путает
|
|
289
|
+
browser = BrowserSession(user_data_dir=PROFILE, headless=False)
|
|
290
|
+
await browser.start()
|
|
291
|
+
try:
|
|
292
|
+
page = await browser.must_get_current_page()
|
|
293
|
+
await page.goto(TG_URL)
|
|
294
|
+
if not browser.cdp_url: # явная проверка вместо assert: не отключается python -O
|
|
295
|
+
raise RuntimeError('браузер не отдал CDP-адрес после старта')
|
|
296
|
+
with open(CDP_FILE, 'w') as f:
|
|
297
|
+
f.write(browser.cdp_url)
|
|
298
|
+
print(f'браузер {browser.cdp_url} — адрес записан в {CDP_FILE}; окно не закрывать до конца работы.')
|
|
299
|
+
try:
|
|
300
|
+
input(
|
|
301
|
+
'Если QR — отсканируй телефоном (Telegram → Настройки → Устройства → '
|
|
302
|
+
'Привязать устройство); если список чатов — сессия уже живая. Enter, когда закончишь. '
|
|
303
|
+
)
|
|
304
|
+
except EOFError: # запущено без tty: окно держится открытым до остановки процесса
|
|
305
|
+
print('stdin закрыт: окно держится открытым; останови процесс (Ctrl+C/kill), когда закончишь.')
|
|
306
|
+
await asyncio.Event().wait()
|
|
307
|
+
finally:
|
|
308
|
+
if alive:
|
|
309
|
+
await browser.stop() # ранее запущенный Chromium остаётся живым, адрес в файле
|
|
310
|
+
else:
|
|
311
|
+
await browser.kill() # stop() оставил бы зомби-браузер без CDP-файла: повторный
|
|
312
|
+
if os.path.exists(CDP_FILE):
|
|
313
|
+
os.remove(CDP_FILE) # login считал бы, что браузера нет, хотя он жив
|
|
314
|
+
if alive:
|
|
315
|
+
print(f'браузер остаётся живым; подкоманды подключатся через {CDP_FILE}.')
|
|
316
|
+
else:
|
|
317
|
+
print(f'Сессия сохранена в {PROFILE}; следующий запуск подхватит её без QR.')
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# guard по location.hash: webk пишет туда peerId открытого чата (#8602734479),
|
|
321
|
+
# а .chat-info показывает display name — с username не совпадает (BotFather — редкое совпадение)
|
|
322
|
+
JS_OPEN_INFO = '''() => {
|
|
323
|
+
const el = document.querySelector('.chat-info'); // верхняя плашка открытого чата
|
|
324
|
+
return {hash: location.hash,
|
|
325
|
+
title: el ? el.innerText.trim().split('\\n')[0] : '',
|
|
326
|
+
bodyLen: document.body ? document.body.innerText.length : 0}; // 0 = не смонтирован
|
|
327
|
+
}'''
|
|
328
|
+
|
|
329
|
+
# поиск: панель может быть свёрнута (querySelector хватает скрытого поля-двойника) —
|
|
330
|
+
# берём только видимое поле; если панель закрыта, открывает триггер. После вставки
|
|
331
|
+
# webk реагирует только на input-событие, само значение поля не триггерит выдачу
|
|
332
|
+
# (проверено живьём: текст в поле есть, список не фильтруется); явный диспетч
|
|
333
|
+
# дублирует native input от execCommand — для поискового поля безвредно (дебаунс).
|
|
334
|
+
JS_OPEN_SEARCH = '''(query) => {
|
|
335
|
+
const fields = [...document.querySelectorAll('.input-search-input')]
|
|
336
|
+
.filter(e => e.offsetParent !== null);
|
|
337
|
+
if (!fields.length) return 0;
|
|
338
|
+
fields[0].focus();
|
|
339
|
+
fields[0].value = ''; // без очистки старый запрос глушит новый ввод
|
|
340
|
+
// хвостовой пробел — жертва будки: Backspace в cmd_open стирает только его,
|
|
341
|
+
// в поле остаётся полный query; без него будка съедает последний символ запроса
|
|
342
|
+
document.execCommand('insertText', false, query + ' ');
|
|
343
|
+
fields[0].dispatchEvent(new InputEvent('input', {bubbles: true}));
|
|
344
|
+
return 1;
|
|
345
|
+
}'''
|
|
346
|
+
|
|
347
|
+
JS_SEARCH_TRIGGER = '''() => {
|
|
348
|
+
const t = document.querySelector('.sidebar-header-search-trigger');
|
|
349
|
+
if (t) t.click();
|
|
350
|
+
}'''
|
|
351
|
+
|
|
352
|
+
# строка результата поиска: локальные совпадения — .chatlist-chat, глобальный поиск
|
|
353
|
+
# (бот вне диалогов) — a.rp.row; у обоих есть data-peer-id, а текст содержит display
|
|
354
|
+
# name (Dr.Web | @DrWebBot), не @username. Сначала строка с точным @username в тексте
|
|
355
|
+
# (lookahead отсекает @GameBotNews при поиске gamebot), фолбэк — нормализованная
|
|
356
|
+
# подстрока (не-буквоцифры в мусор) для строк без username в тексте; needle проходит
|
|
357
|
+
# [A-Za-z0-9_] в cmd_open — метасимволов regex в нём нет. Полный набор
|
|
358
|
+
# mousedown/mouseup/click — голый el.click() строку не открывает
|
|
359
|
+
JS_CLICK_FOUND = '''(needle) => {
|
|
360
|
+
const norm = (s) => (s || '').toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
361
|
+
const rows = [...document.querySelectorAll('a.rp.row, .chatlist-chat')]
|
|
362
|
+
.filter(e => e.offsetParent !== null);
|
|
363
|
+
const exact = new RegExp('@' + needle.toLowerCase() + '(?![a-z0-9_])');
|
|
364
|
+
const el = rows.find(e => exact.test(e.innerText.toLowerCase()))
|
|
365
|
+
|| rows.find(e => norm(e.innerText).includes(norm(needle)));
|
|
366
|
+
if (!el) return '';
|
|
367
|
+
const peer = el.getAttribute('data-peer-id') || (el.getAttribute('href') || '').slice(1);
|
|
368
|
+
for (const type of ['mousedown', 'mouseup', 'click']) {
|
|
369
|
+
el.dispatchEvent(new MouseEvent(type, {bubbles: true, cancelable: true, view: window}));
|
|
370
|
+
}
|
|
371
|
+
return peer;
|
|
372
|
+
}'''
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def kill_webk_workers(base: str) -> tuple[int, int]:
|
|
376
|
+
"""Убить shared-воркеры webk по CDP HTTP-базе (например http://127.0.0.1:9222).
|
|
377
|
+
Их залипание = вечная загрузка вкладки; /json — единственная дверь к worker-таргетам.
|
|
378
|
+
Ответ /json/close контролируем живьём (200 + 'Target is closing'): если для
|
|
379
|
+
worker-таргета close не сработал — честная деградация вместо молчаливого «успеха»,
|
|
380
|
+
revive держится и на reload, но счёт закрытых в отчёте правдивый.
|
|
381
|
+
ponytail: причина залипания не выяснена; если webk починят — просто удалить вызов.
|
|
382
|
+
Глубокая дверь (WS к worker-таргету) не нужна, пока revive спасается reload'ом."""
|
|
383
|
+
try:
|
|
384
|
+
with urllib.request.urlopen(base + '/json/list', timeout=5) as r:
|
|
385
|
+
targets = json.load(r)
|
|
386
|
+
ids = [t['id'] for t in targets
|
|
387
|
+
if t.get('type') == 'shared_worker' and 'web.telegram.org' in t.get('url', '')]
|
|
388
|
+
except Exception as e: # браузер висит — CDP HTTP может не ответить: чистый выход без traceback
|
|
389
|
+
raise SystemExit(f'не удалось убить воркеры webk через {base} ({type(e).__name__}: {e}); '
|
|
390
|
+
f'ничего не отправлено')
|
|
391
|
+
closed = 0
|
|
392
|
+
for i in ids:
|
|
393
|
+
try:
|
|
394
|
+
with urllib.request.urlopen(f'{base}/json/close/{i}', timeout=5) as r:
|
|
395
|
+
if r.status == 200 and 'closing' in r.read().decode('utf-8', 'replace').lower():
|
|
396
|
+
closed += 1
|
|
397
|
+
except Exception: # отказ отдельного close не валит revive — остаётся reload; счёт честный
|
|
398
|
+
pass
|
|
399
|
+
return closed, len(ids)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
async def wait_open(page, tries: int, want: tuple = ()) -> dict:
|
|
403
|
+
"""Смонтированность UI; при want — ждать hash из want (чат открыт)."""
|
|
404
|
+
wants = tuple(w.lower() for w in want)
|
|
405
|
+
st = {}
|
|
406
|
+
for _ in range(tries):
|
|
407
|
+
st = json.loads(await page.evaluate(JS_OPEN_INFO))
|
|
408
|
+
if st['bodyLen'] and (not wants or st['hash'].lower() in wants):
|
|
409
|
+
return st
|
|
410
|
+
await asyncio.sleep(OPEN_POLL)
|
|
411
|
+
return st
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
async def cmd_open(bot: str) -> None:
|
|
415
|
+
"""Открыть чат бота в живом браузере: hash-роутинг webk (#@username) + guard «webk съел хэш»; без отправок."""
|
|
416
|
+
name = bot.lstrip('@')
|
|
417
|
+
if not re.fullmatch(r'[A-Za-z0-9_]{4,64}', name): # формат для UX; в JS имя идёт параметром
|
|
418
|
+
raise SystemExit(f'{bot!r}: жду username вида @name (латиница, цифры, _)')
|
|
419
|
+
browser = await connect()
|
|
420
|
+
try:
|
|
421
|
+
page = await browser.must_get_current_page()
|
|
422
|
+
url = await page.evaluate('() => location.href')
|
|
423
|
+
if not url.startswith(TG_URL):
|
|
424
|
+
# пустая/чужая вкладка: полный переход, руками ничего не набираем
|
|
425
|
+
await page.goto(TG_URL)
|
|
426
|
+
if not (await wait_open(page, 15))['bodyLen']: # медленный старт — норма: до ~30 c
|
|
427
|
+
browser, page = await revive_page(browser) # воркеры webk залипли — оживляем
|
|
428
|
+
# локальный поиск — путь username → чат; результат даёт data-peer-id для верификации
|
|
429
|
+
if not json.loads(await page.evaluate(JS_OPEN_SEARCH, name)):
|
|
430
|
+
await page.evaluate(JS_SEARCH_TRIGGER) # панель поиска свёрнута — открыть кликом
|
|
431
|
+
await asyncio.sleep(OPEN_POLL)
|
|
432
|
+
if not json.loads(await page.evaluate(JS_OPEN_SEARCH, name)):
|
|
433
|
+
raise SystemExit('нет поля поиска webk; ничего не отправлено')
|
|
434
|
+
await page.press('Backspace') # будка: webk после старта глух к синтетике
|
|
435
|
+
# (наблюдено 2026-09-24): настоящая клавиша CDP будит поле input-событием
|
|
436
|
+
# от удаления последнего символа; жертва будки — хвостовой пробел из
|
|
437
|
+
# JS_OPEN_SEARCH, в поле остаётся полный query, не найдётся — exit 1
|
|
438
|
+
peer = ''
|
|
439
|
+
for _ in range(10): # ~20 c: индекс/рендер результатов
|
|
440
|
+
peer = (await page.evaluate(JS_CLICK_FOUND, name.lower())) or ''
|
|
441
|
+
if peer:
|
|
442
|
+
break
|
|
443
|
+
await asyncio.sleep(OPEN_POLL)
|
|
444
|
+
if not peer:
|
|
445
|
+
raise SystemExit(f'чат {bot} не найден через поиск webk по username; '
|
|
446
|
+
f'ничего не отправлено')
|
|
447
|
+
# tweb переписывает hash на #peerId (у ботов #<минус>peerId) или #@username
|
|
448
|
+
wants = ('#' + peer, '#-' + peer, '#@' + name.lower())
|
|
449
|
+
await wait_open(page, 10, want=wants)
|
|
450
|
+
await asyncio.sleep(OPEN_POLL) # дать плашке чата устаканиться после закрытия поиска
|
|
451
|
+
st = json.loads(await page.evaluate(JS_OPEN_INFO))
|
|
452
|
+
await page.evaluate(JS_COLLAPSE_REPLY) # пережитая панель: чужие reply_buttons
|
|
453
|
+
finally:
|
|
454
|
+
await browser.stop()
|
|
455
|
+
if not (st['bodyLen'] and st['hash'].lower() in wants):
|
|
456
|
+
raise SystemExit(f'чат {bot} не открылся (hash: {st["hash"] or "пусто"}, '
|
|
457
|
+
f'заголовок: {st["title"] or "пусто"}); ничего не отправлено')
|
|
458
|
+
print(json.dumps({'opened': bot, 'title': st['title'], 'hash': st['hash']}, ensure_ascii=False))
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
async def revive_page(browser) -> tuple:
|
|
462
|
+
"""Оживить залипшую страницу: kill воркеров при отключённом клиенте + reload фреш-сессией.
|
|
463
|
+
Сессия, пережившая залипание, отваливается от таргета и молча не делает reload."""
|
|
464
|
+
await browser.stop()
|
|
465
|
+
base = (browser.cdp_url or '').split('/devtools')[0].replace('ws', 'http', 1)
|
|
466
|
+
closed, total = kill_webk_workers(base)
|
|
467
|
+
browser = await connect()
|
|
468
|
+
try: # SystemExit не Exception: глотаем всё, но фреш-сессию останавливаем и перебрасываем
|
|
469
|
+
page = await browser.must_get_current_page()
|
|
470
|
+
await page.reload()
|
|
471
|
+
if not (await wait_open(page, 15))['bodyLen']: # перемонтаж занимает ~5 c — ждём честно
|
|
472
|
+
raise SystemExit(f'webk не ожил даже после kill {closed}/{total} воркеров + '
|
|
473
|
+
f'перезапуска сессии')
|
|
474
|
+
return browser, page
|
|
475
|
+
except BaseException:
|
|
476
|
+
await browser.stop()
|
|
477
|
+
raise
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
async def live_page() -> tuple:
|
|
481
|
+
"""Подключиться к живому браузеру; залипшую страницу оживить на месте. → (browser, page)."""
|
|
482
|
+
browser = await connect()
|
|
483
|
+
page = await browser.must_get_current_page()
|
|
484
|
+
# ponytail: порог 8 проб ≈ 16 c — компромисс: живой медленный старт (норма до ~30 c)
|
|
485
|
+
# чаще переживает, а мёртвую вкладку не тянем полминуты; убитые живые начнутся — поднять до 15
|
|
486
|
+
if (await wait_open(page, 8))['bodyLen']:
|
|
487
|
+
return browser, page
|
|
488
|
+
return await revive_page(browser)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
async def cmd_state() -> None:
|
|
492
|
+
browser, page = await live_page()
|
|
493
|
+
try:
|
|
494
|
+
print(json.dumps(shown(await read_state(page)), ensure_ascii=False, indent=2))
|
|
495
|
+
finally:
|
|
496
|
+
await browser.stop()
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
async def cmd_click(label: str) -> None:
|
|
500
|
+
browser, page = await live_page()
|
|
501
|
+
try:
|
|
502
|
+
st = shown(await do_click(page, label))
|
|
503
|
+
print(f'клик «{label}» →', flush=True)
|
|
504
|
+
print(json.dumps(st, ensure_ascii=False, indent=2))
|
|
505
|
+
finally:
|
|
506
|
+
await browser.stop()
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
async def cmd_save(from_id: str, button: str, bot: str) -> None:
|
|
510
|
+
"""Дописать текущее состояние (и ребро from --button, если задано) в flow.json/flow.md."""
|
|
511
|
+
if button and not from_id: # иначе кнопка потерялась бы молча
|
|
512
|
+
raise SystemExit('--button без --from: ребро некуда прикрепить')
|
|
513
|
+
try:
|
|
514
|
+
with open(os.path.join(ART, 'flow.json')) as f:
|
|
515
|
+
flow = json.load(f)
|
|
516
|
+
except FileNotFoundError:
|
|
517
|
+
flow = {'bot': bot, 'states': [], 'edges': []}
|
|
518
|
+
except json.JSONDecodeError as e: # битый flow = чистый отказ, не traceback
|
|
519
|
+
raise SystemExit(f'битый {ART}/flow.json: {e}')
|
|
520
|
+
if from_id and from_id[:8] not in {s['id'] for s in flow['states']}:
|
|
521
|
+
# опечатка в id = висячее ребро и битая ссылка в Mermaid; проверяем до подключения к браузеру
|
|
522
|
+
raise SystemExit(f'--from {from_id[:8]}: нет такого состояния в {ART}/flow.json')
|
|
523
|
+
browser, page = await live_page()
|
|
524
|
+
try:
|
|
525
|
+
st = shown(await read_state(page))
|
|
526
|
+
finally:
|
|
527
|
+
await browser.stop()
|
|
528
|
+
new = st['id'] not in {s['id'] for s in flow['states']}
|
|
529
|
+
if new:
|
|
530
|
+
flow['states'].append({k: st[k] for k in ('id', 'text', 'buttons')})
|
|
531
|
+
if from_id:
|
|
532
|
+
edge = {'from': from_id[:8], 'to': st['id'], 'button': button}
|
|
533
|
+
if edge not in flow['edges']:
|
|
534
|
+
flow['edges'].append(edge)
|
|
535
|
+
write_artifacts(flow)
|
|
536
|
+
print(json.dumps({'id': st['id'], 'new': new, 'states': len(flow['states']),
|
|
537
|
+
'edges': len(flow['edges'])}, ensure_ascii=False))
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
async def cmd_test(scenario_path: str) -> None:
|
|
541
|
+
"""Прогнать сценарий [{do, expect}] → artifacts/report.json; падение = exit 1."""
|
|
542
|
+
try:
|
|
543
|
+
with open(scenario_path) as f:
|
|
544
|
+
steps = json.load(f)
|
|
545
|
+
except OSError as e:
|
|
546
|
+
raise SystemExit(f'не удалось открыть {scenario_path}: {e}')
|
|
547
|
+
except json.JSONDecodeError as e:
|
|
548
|
+
raise SystemExit(f'битый {scenario_path}: {e}')
|
|
549
|
+
if not isinstance(steps, list) or not steps: # пустой сценарий — не PASS за 0 шагов
|
|
550
|
+
raise SystemExit('сценарий: жду непустой список шагов [{"do": ..., "expect": ...}]')
|
|
551
|
+
for i, step in enumerate(steps): # кривой сценарий = ошибка до подключения к браузеру
|
|
552
|
+
do = step.get('do', {}) if isinstance(step, dict) else None
|
|
553
|
+
if not isinstance(do, dict) or not ({'click', 'send'} >= set(do)):
|
|
554
|
+
raise SystemExit(f'шаг {i + 1}: жду {{"do": {{"click"/"send": ...}}}} или {{"do": {{}}}}')
|
|
555
|
+
if len(do) > 1: # click+send вместе молча брал click
|
|
556
|
+
raise SystemExit(f'шаг {i + 1}: click и send вместе — жду что-то одно')
|
|
557
|
+
browser, page = await live_page()
|
|
558
|
+
results = []
|
|
559
|
+
try:
|
|
560
|
+
for i, step in enumerate(steps):
|
|
561
|
+
do = step.get('do', {}) # шаг без do — read_state-шаг, как и в предвалидации
|
|
562
|
+
t0 = time.monotonic()
|
|
563
|
+
try:
|
|
564
|
+
if 'click' in do:
|
|
565
|
+
st = shown(await do_click(page, do['click']))
|
|
566
|
+
elif 'send' in do:
|
|
567
|
+
st = shown(await do_send(page, do['send']))
|
|
568
|
+
else:
|
|
569
|
+
st = shown(await read_state(page))
|
|
570
|
+
ok, why = check_expect(st['text'], step.get('expect'))
|
|
571
|
+
except Exception as e:
|
|
572
|
+
ok, st, why = False, {'text': '', 'buttons': []}, f'{type(e).__name__}: {e}'
|
|
573
|
+
results.append({'step': i + 1, 'do': do, 'expect': step.get('expect'),
|
|
574
|
+
'pass': ok, 'ms': round((time.monotonic() - t0) * 1000),
|
|
575
|
+
'text': st['text'], **({'error': why} if why else {})})
|
|
576
|
+
print(f'шаг {i + 1}: {"ok" if ok else "FAIL"} {why}', flush=True)
|
|
577
|
+
if not ok:
|
|
578
|
+
break # дальше сценарий бессмыслен: хрупкие шаги после сломанного врут
|
|
579
|
+
finally:
|
|
580
|
+
await browser.stop()
|
|
581
|
+
passed = all(r['pass'] for r in results)
|
|
582
|
+
if results: # в finally, чтобы report остался и по Ctrl+C; Ctrl+C до первого шага
|
|
583
|
+
os.makedirs(ART, exist_ok=True) # не пишет пустой report с pass=true — самообман
|
|
584
|
+
with open(os.path.join(ART, 'report.json'), 'w') as f:
|
|
585
|
+
json.dump({'scenario': os.path.basename(scenario_path), 'pass': passed,
|
|
586
|
+
'steps': results}, f, ensure_ascii=False, indent=2)
|
|
587
|
+
print(f'итог: {"PASS" if passed else "FAIL"} ({len(results)} шагов) → {ART}/report.json')
|
|
588
|
+
if not passed:
|
|
589
|
+
raise SystemExit(1)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def main() -> None:
|
|
593
|
+
parser = argparse.ArgumentParser(prog='tg-use', description='Руки для харнеса: Telegram-боты через web.telegram.org')
|
|
594
|
+
sub = parser.add_subparsers(dest='cmd', required=True)
|
|
595
|
+
sub.add_parser('login', help='headed-браузер с persistent-профилем (QR сканирует человек)')
|
|
596
|
+
p_open = sub.add_parser('open', help='открыть чат бота: поиск webk по username + клик, guard по peer-id hash, без отправок')
|
|
597
|
+
p_open.add_argument('bot', help='бот в форме @name')
|
|
598
|
+
sub.add_parser('state', help='JSON: последнее сообщение бота + подписи кнопок')
|
|
599
|
+
p_click = sub.add_parser('click', help='нажать кнопку по подписи (inline или reply-клавиатура; пустая реакция = ошибка ребра)')
|
|
600
|
+
p_click.add_argument('label')
|
|
601
|
+
p_save = sub.add_parser('save', help='дописать состояние/ребро в artifacts/flow.json + flow.md')
|
|
602
|
+
p_save.add_argument('--from', dest='from_id', default='', help='id состояния, из которого вышло ребро')
|
|
603
|
+
p_save.add_argument('--button', default='', help='подпись кнопки ребра')
|
|
604
|
+
p_save.add_argument('--bot', default='', help='имя бота (только для первого save)')
|
|
605
|
+
p_test = sub.add_parser('test', help='прогнать сценарий → artifacts/report.json (fail = exit 1)')
|
|
606
|
+
p_test.add_argument('scenario', help='JSON: [{"do": {"click"/"send": ...}, "expect": {"contains"/"regex": ...}}]')
|
|
607
|
+
sub.add_parser('skill-install', help='поставить скилл tg-use агенту (~/.claude/skills/tg-use); переустанавливай после обновлений репо')
|
|
608
|
+
args = parser.parse_args()
|
|
609
|
+
|
|
610
|
+
try:
|
|
611
|
+
if args.cmd == 'login':
|
|
612
|
+
asyncio.run(cmd_login())
|
|
613
|
+
elif args.cmd == 'open':
|
|
614
|
+
asyncio.run(cmd_open(args.bot))
|
|
615
|
+
elif args.cmd == 'state':
|
|
616
|
+
asyncio.run(cmd_state())
|
|
617
|
+
elif args.cmd == 'click':
|
|
618
|
+
asyncio.run(cmd_click(args.label))
|
|
619
|
+
elif args.cmd == 'save':
|
|
620
|
+
asyncio.run(cmd_save(args.from_id, args.button, args.bot))
|
|
621
|
+
elif args.cmd == 'test':
|
|
622
|
+
asyncio.run(cmd_test(args.scenario))
|
|
623
|
+
elif args.cmd == 'skill-install':
|
|
624
|
+
repo = os.path.dirname(os.path.abspath(__file__))
|
|
625
|
+
dst = install_skill(repo, os.path.expanduser('~'))
|
|
626
|
+
print(f'скилл установлен: {dst}; переустанови после обновлений репо')
|
|
627
|
+
except RuntimeError as e: # ошибка руки (промах кнопки, нет реакции) — не traceback
|
|
628
|
+
raise SystemExit(f'ошибка: {e}')
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
if __name__ == '__main__':
|
|
632
|
+
main()
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tg-use
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Руки для агент-харнеса: тестирование Telegram-ботов через web.telegram.org (CLI без LLM)
|
|
5
|
+
Author: axisrow
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/axisrow/tg-use
|
|
8
|
+
Requires-Python: <4.0,>=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: browser-use>=0.13.8
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# tg-use
|
|
15
|
+
|
|
16
|
+
tg-use — тестировщик/копировщик Telegram-ботов через web.telegram.org (клиент `/k/`).
|
|
17
|
+
CLI-«руки» + скилл для агент-харнесов: мозг — модель харнеса, CLI обращений к
|
|
18
|
+
внешним моделям не содержит вовсе.
|
|
19
|
+
|
|
20
|
+
## Два режима работы browser-use
|
|
21
|
+
|
|
22
|
+
browser-use умеет работать двумя способами, и разница принципиальная:
|
|
23
|
+
|
|
24
|
+
1. **Browser Harness CLI + скилл — наш путь, рекомендуемый.** Браузер подключается
|
|
25
|
+
к харнесу (Claude Code, Codex, OpenCode…), и решения принимает модель харнеса.
|
|
26
|
+
Наш `tg-use.py` — только «руки»: чтение состояния, клики по кнопкам, артефакты,
|
|
27
|
+
прогон записанных сценариев. Логика «куда кликать дальше» живёт в скилле
|
|
28
|
+
(`.claude/skills/tg-use/SKILL.md`), а не в коде.
|
|
29
|
+
2. **Встроенный агент `Agent(task=..., llm=...)` со своим LLM-циклом — в этом
|
|
30
|
+
проекте так не делаем.** Этот режим дублирует мозг харнеса вторым (медленным
|
|
31
|
+
и ломким) контуром: ключи, endpoint'ы, таймауты, fallback-модели. Был
|
|
32
|
+
использован в v1 до 2026-09-20 и отвергнут владельцем вместе с z.ai-конфигом.
|
|
33
|
+
|
|
34
|
+
**Правило: использовать `browser-use skill install`, а не писать своего агента.**
|
|
35
|
+
В CLI нет ни одного обращения к внешней модели — проверяется `test_skeleton.py`.
|
|
36
|
+
|
|
37
|
+
## Установка
|
|
38
|
+
|
|
39
|
+
Из клона:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
pip3 install -r requirements.txt # в прокси-окружении: env -u ALL_PROXY -u all_proxy pip3 ...
|
|
43
|
+
python3 -m playwright install chromium
|
|
44
|
+
browser-use skill install # подключить браузер к харнесу
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Из PyPI (Python ≥3.11): `pipx install tg-use` — те же команды без `python3 tg-use.py`:
|
|
48
|
+
`tg-use login`, `tg-use open <@bot>`, `tg-use state`, …; `tg-use skill-install`
|
|
49
|
+
ставит скилл агенту (команды в нём переписываются на `tg-use`).
|
|
50
|
+
|
|
51
|
+
## Логин
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
python3 tg-use.py login # откроется окно с QR — отсканируй телефоном один раз
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Сессия сохраняется в `~/.tg-use-agent-profile` (= пароль от аккаунта) — повторный
|
|
58
|
+
запуск подхватит её без QR. При первом запуске macOS спросит доступ к «Chromium
|
|
59
|
+
Safe Storage»: вводи пароль входа и жми «Разрешать всегда» — это шифрование
|
|
60
|
+
cookies, без него сессия не переживёт перезапуск.
|
|
61
|
+
|
|
62
|
+
Browser Harness из коробки цепляется к обычному Chrome с его родным профилем и
|
|
63
|
+
наш изолированный профиль не даёт, поэтому профильный запуск остаётся за CLI:
|
|
64
|
+
`login` держит Chromium на нашем профиле и пишет его адрес в `~/.tg-use-cdp`;
|
|
65
|
+
`state`/`click`/`save`/`test` подключаются к этому живому браузеру. Харнес может
|
|
66
|
+
работать с тем же окном через browser-use (`BU_CDP_URL=$(cat ~/.tg-use-cdp)`).
|
|
67
|
+
|
|
68
|
+
## Команды (руки; «куда кликать дальше» решает харнес)
|
|
69
|
+
|
|
70
|
+
| Команда | Что делает |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `tg-use.py login` | headed-браузер с persistent-профилем; адрес → `~/.tg-use-cdp` |
|
|
73
|
+
| `tg-use.py open <@bot>` | открыть чат бота: поиск webk по username + клик по результату, guard по peer-id в hash; без отправок |
|
|
74
|
+
| `tg-use.py state` | JSON: текст последнего сообщения бота + подписи кнопок |
|
|
75
|
+
| `tg-use.py click "<label>"` | нажать inline-кнопку, вернуть новое состояние; отсутствие кнопки/реакции = ошибка (exit 1) |
|
|
76
|
+
| `tg-use.py save [--from id --button label] [--bot @name]` | дописать состояние/ребро в `artifacts/flow.json` + `artifacts/flow.md` (Mermaid) |
|
|
77
|
+
| `tg-use.py test scenario.json` | шаги `{"do": {"send"/"click"}, "expect": {"contains"/"regex"}}` → `artifacts/report.json`; падение = exit 1 |
|
|
78
|
+
|
|
79
|
+
Дальше — по скиллу `.claude/skills/tg-use/SKILL.md`: малый обход (глубина ≤2,
|
|
80
|
+
до ~5 состояний) и прогон сценариев. Безопасность: темп 2–3 c между действиями,
|
|
81
|
+
только диалоги с ботами, скраб токенов в выводе, PNG не сохраняем.
|
|
82
|
+
|
|
83
|
+
## Тесты и категории
|
|
84
|
+
|
|
85
|
+
Граница категорий — цена ошибки. `unit`: offline-скрипты, безопасный дефолт —
|
|
86
|
+
`python3 test_skeleton.py && python3 test_crawl.py && python3 test_hands.py`.
|
|
87
|
+
`state`/`open` — live_read (чтение/открытие чата без отправок); `click`/`save` —
|
|
88
|
+
live_write (обратимые действия в диалоге с ботом); `test`/`login` —
|
|
89
|
+
live_write_danger (необратимая отправка боту / полный доступ к аккаунту).
|
|
90
|
+
Гейты — в `.claude/settings.json`: владелец 2026-09-20 разрешил все подкоманды
|
|
91
|
+
целиком (`Bash(python3 tg-use.py:*)`), категории остались описанием цены ошибки,
|
|
92
|
+
а не спросом харнеса.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
tg_use/SKILL.md
|
|
5
|
+
tg_use/__init__.py
|
|
6
|
+
tg_use.egg-info/PKG-INFO
|
|
7
|
+
tg_use.egg-info/SOURCES.txt
|
|
8
|
+
tg_use.egg-info/dependency_links.txt
|
|
9
|
+
tg_use.egg-info/entry_points.txt
|
|
10
|
+
tg_use.egg-info/requires.txt
|
|
11
|
+
tg_use.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
browser-use>=0.13.8
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tg_use
|