shabbot 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.
@@ -0,0 +1,135 @@
1
+ # shabbot — Технический анализ и рекомендации
2
+
3
+ > Дата: 2026-06-05. Полное чтение исходников.
4
+
5
+ ---
6
+
7
+ ## Архитектура
8
+
9
+ ```
10
+ Telegram Update
11
+
12
+ bot.py (handlers)
13
+ ├── text → DumbParser → create_task() → Todoist
14
+ └── voice → download OGG → Whisper (subprocess) → DumbParser → create_task()
15
+ ```
16
+
17
+ **Task dataclass** (`task.py`):
18
+ ```python
19
+ @dataclass
20
+ class Task:
21
+ summary: str
22
+ date: date
23
+ description: str | None
24
+ location: str | None
25
+ reminder_minutes: int | None
26
+ raw_text: str
27
+ ```
28
+
29
+ **DumbParser** — заглушка: весь текст как summary, дата = сегодня. `BaseParser` абстрактный класс существует, но только один наследник.
30
+
31
+ ---
32
+
33
+ ## Проблемы
34
+
35
+ ### 1. Parser — это placeholder
36
+ `DumbParser` не парсит вообще ничего. Фраза "встреча в среду в 3" → summary="встреча в среду в 3", date=сегодня. Google Calendar синхронизация бесполезна без дат.
37
+
38
+ **Следующий шаг:** LLM-парсер. Архитектура (`BaseParser`) уже готова для подключения.
39
+
40
+ Вариант реализации:
41
+ ```python
42
+ class LLMParser(BaseParser):
43
+ def parse(self, text: str) -> Task:
44
+ # Claude API: structured output с date/summary/location
45
+ ...
46
+ ```
47
+
48
+ ### 2. Whisper — хрупкий subprocess
49
+ ```python
50
+ result = subprocess.run([WHISPER_BIN, ogg_path, "--model", WHISPER_MODEL, ...])
51
+ ```
52
+ - Нет обработки отсутствия бинарника
53
+ - Нет обработки timeout кроме Telegram download
54
+ - Модель `large` — тяжёлая, медленно работает без GPU
55
+
56
+ **Варианты улучшения:**
57
+ - Проверять наличие `WHISPER_BIN` при старте
58
+ - Добавить timeout для subprocess
59
+ - Рассмотреть OpenAI Whisper API вместо локального (проще в Docker)
60
+
61
+ ### 3. Нет обработки ошибок Todoist API
62
+ `create_task()` не оборачивается в try/except. При сетевой ошибке бот молчит и не отвечает пользователю.
63
+
64
+ ### 4. Нет тестов
65
+ Parser, todoist client, task dataclass — всё легко тестируемо, но тестов нет.
66
+
67
+ ---
68
+
69
+ ## Релиз + Docker (план)
70
+
71
+ ### 1. Зарелизить
72
+ ```bash
73
+ # pyproject.toml уже готов
74
+ # Добавить [tool.bumpversion]
75
+ # Добавить GitHub Release
76
+ ```
77
+
78
+ ### 2. Dockerfile
79
+ ```dockerfile
80
+ FROM python:3.11-slim
81
+
82
+ # Whisper зависимости
83
+ RUN apt-get install -y ffmpeg
84
+
85
+ # Whisper
86
+ RUN pip install openai-whisper
87
+
88
+ # shabbot
89
+ COPY . /app
90
+ RUN pip install /app
91
+
92
+ CMD ["shabbot"]
93
+ ```
94
+
95
+ ### 3. docker-compose.yml
96
+ ```yaml
97
+ services:
98
+ shabbot:
99
+ build: .
100
+ env_file: shabbot.env
101
+ restart: unless-stopped
102
+ ```
103
+
104
+ **Проблема с Whisper в Docker:** модель `large` = 3GB, нужен volume для кэша:
105
+ ```yaml
106
+ volumes:
107
+ - whisper-cache:/root/.cache/whisper
108
+ ```
109
+
110
+ Альтернатива: использовать OpenAI Whisper API — нет локального бинарника, нет GPU, проще Docker-образ.
111
+
112
+ ---
113
+
114
+ ## LLM-парсер (идея)
115
+
116
+ ```python
117
+ class LLMParser(BaseParser):
118
+ """Парсит естественный язык через Claude API с structured output."""
119
+
120
+ def parse(self, text: str) -> Task:
121
+ # Prompt: "Извлеки задачу: дату, время, суть, место"
122
+ # Response: JSON с полями Task
123
+ # Fallback: date=today если дата не найдена
124
+ ```
125
+
126
+ Это закроет главный gap — Google Calendar синхронизация станет полезной.
127
+
128
+ ---
129
+
130
+ ## Итог по приоритетам
131
+
132
+ 1. **Сейчас:** дорелизить (pyproject.toml, версия, CHANGELOG)
133
+ 2. **Потом:** Docker (Dockerfile + compose)
134
+ 3. **После:** LLM-парсер (значительно увеличит полезность)
135
+ 4. **Всегда:** error handling в Todoist client
@@ -0,0 +1,87 @@
1
+ name: Integration
2
+
3
+ on:
4
+ pull_request:
5
+ branches:
6
+ - master
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ${{ matrix.os }}
11
+ strategy:
12
+ matrix:
13
+ os: [ubuntu-latest, macos-latest, windows-latest]
14
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.x"]
15
+ fail-fast: false
16
+
17
+ steps:
18
+ - uses: actions/checkout@v6
19
+
20
+ - name: Set up Python ${{ matrix.python-version }}
21
+ uses: actions/setup-python@v6
22
+ with:
23
+ python-version: ${{ matrix.python-version }}
24
+
25
+ - name: Install dependencies
26
+ run: pip install -e ".[test]"
27
+
28
+ - name: Run tests
29
+ shell: bash
30
+ run: pytest || [ $? -eq 5 ]
31
+
32
+ integration:
33
+ needs: test
34
+ runs-on: ubuntu-latest
35
+ permissions:
36
+ id-token: write
37
+
38
+ steps:
39
+ - uses: actions/checkout@v6
40
+ with:
41
+ ref: ${{ github.head_ref }}
42
+ fetch-depth: 0
43
+
44
+ - name: Merge master
45
+ run: |
46
+ git config user.name "bot@djachenko[bot]"
47
+ git config user.email "2991967+bot@djachenko[bot]@users.noreply.github.com"
48
+ git merge origin/master --no-edit
49
+
50
+ - name: Set up Python
51
+ uses: actions/setup-python@v6
52
+ with:
53
+ python-version: "3.x"
54
+
55
+ - name: Install dependencies
56
+ run: pip install ".[release]"
57
+
58
+ - name: Bump version (no commit)
59
+ run: semantic-release version --no-commit --no-tag --no-push --no-changelog
60
+
61
+ - name: Patch dev suffix
62
+ run: |
63
+ python -c "
64
+ import tomllib, pathlib, os
65
+ with open('pyproject.toml', 'rb') as f:
66
+ v = tomllib.load(f)['project']['version']
67
+ p = pathlib.Path('pyproject.toml')
68
+ p.write_text(p.read_text().replace(f'version = \"{v}\"', f'version = \"{v}.dev{os.environ[\"GITHUB_RUN_NUMBER\"]}\"'))
69
+ "
70
+
71
+ - name: Build
72
+ run: python -m build
73
+
74
+ - name: Publish to TestPyPI
75
+ uses: pypa/gh-action-pypi-publish@release/v1
76
+ with:
77
+ repository-url: https://test.pypi.org/legacy/
78
+
79
+ - name: Smoke test
80
+ run: |
81
+ VERSION=$(python -c "
82
+ import tomllib
83
+ with open('pyproject.toml', 'rb') as f:
84
+ print(tomllib.load(f)['project']['version'])
85
+ ")
86
+ pip install --index-url https://test.pypi.org/simple/ --no-deps shabbot==$VERSION
87
+ shabbot --help
@@ -0,0 +1,74 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - master
7
+
8
+ jobs:
9
+ release:
10
+ runs-on: ubuntu-latest
11
+ concurrency: release
12
+ permissions:
13
+ id-token: write
14
+ contents: write
15
+
16
+ steps:
17
+ - uses: actions/create-github-app-token@v1
18
+ id: app-token
19
+ with:
20
+ app-id: ${{ secrets.APP_ID }}
21
+ private-key: ${{ secrets.APP_PRIVATE_KEY }}
22
+
23
+ - uses: actions/checkout@v6
24
+ with:
25
+ fetch-depth: 0
26
+ token: ${{ steps.app-token.outputs.token }}
27
+
28
+ - name: Set up Python
29
+ uses: actions/setup-python@v6
30
+ with:
31
+ python-version: "3.x"
32
+
33
+ - name: Install dependencies
34
+ run: pip install ".[release]"
35
+
36
+ - name: Configure git
37
+ run: |
38
+ git config user.name "bot@djachenko[bot]"
39
+ git config user.email "2991967+bot@djachenko[bot]@users.noreply.github.com"
40
+
41
+ - name: Run semantic release
42
+ id: release
43
+ env:
44
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
45
+ run: |
46
+ NEXT=$(semantic-release version --print 2>/dev/null || echo "")
47
+ if [ -n "$NEXT" ]; then
48
+ semantic-release version
49
+ echo "released=true" >> $GITHUB_OUTPUT
50
+ echo "version=$NEXT" >> $GITHUB_OUTPUT
51
+ else
52
+ echo "released=false" >> $GITHUB_OUTPUT
53
+ fi
54
+
55
+ - name: Build
56
+ if: steps.release.outputs.released == 'true'
57
+ run: python -m build
58
+
59
+ - name: Publish to PyPI
60
+ uses: pypa/gh-action-pypi-publish@release/v1
61
+ if: steps.release.outputs.released == 'true'
62
+
63
+ - name: Smoke test (PyPI)
64
+ if: steps.release.outputs.released == 'true'
65
+ run: |
66
+ VERSION=${{ steps.release.outputs.version }}
67
+
68
+ for i in {1..5}; do
69
+ pip install shabbot==$VERSION && break
70
+ echo "Attempt $i failed, retrying in 10s..."
71
+ sleep 10
72
+ done
73
+
74
+ shabbot --help
@@ -0,0 +1,28 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+
6
+ jobs:
7
+ test:
8
+ runs-on: ubuntu-latest
9
+
10
+ steps:
11
+ - uses: actions/checkout@v6
12
+
13
+ - name: Set up Python
14
+ uses: actions/setup-python@v6
15
+ with:
16
+ python-version: "3.x"
17
+
18
+ - name: Install dependencies
19
+ run: pip install -e ".[test]"
20
+
21
+ - name: Run tests
22
+ run: pytest || [ $? -eq 5 ]
23
+
24
+ - name: Lint
25
+ run: ruff check
26
+
27
+ - name: Type check
28
+ run: mypy shabbot/
@@ -0,0 +1,20 @@
1
+ # env
2
+ shabbot.env
3
+ *.env
4
+
5
+ # python
6
+ __pycache__/
7
+ *.pyc
8
+ *.pyo
9
+ *.pyd
10
+ .venv/
11
+ *.egg-info/
12
+ dist/
13
+ build/
14
+
15
+ # ide
16
+ .idea/
17
+ .vscode/
18
+
19
+ # os
20
+ .DS_Store
@@ -0,0 +1,78 @@
1
+ # CHANGELOG
2
+
3
+ <!-- version list -->
4
+
5
+ ## v0.1.0 (2026-06-09)
6
+
7
+ ### Bug Fixes
8
+
9
+ - Add shell: bash to pytest step for Windows compatibility
10
+ ([`500a8f0`](https://github.com/djachenko/shabbot/commit/500a8f070b8e0f6d72fea4c24d19d431f54c875c))
11
+
12
+ - Add test extras and fix mypy path in CI
13
+ ([`738e924`](https://github.com/djachenko/shabbot/commit/738e9244d8bc0660c2941ce0db9c6e5e41e89793))
14
+
15
+ - Configure git identity before merge in integration workflow
16
+ ([`013f6a0`](https://github.com/djachenko/shabbot/commit/013f6a0757b398b99f30a5557825bdeefb6e9c0c))
17
+
18
+ - Disable major bump on 0.x in semantic release config
19
+ ([`66764a3`](https://github.com/djachenko/shabbot/commit/66764a37c1fa052185767cb1e2707ab80f630f75))
20
+
21
+ - Handle --help in main via argparse for smoke test
22
+ ([`196e1ff`](https://github.com/djachenko/shabbot/commit/196e1ff6e707b1dc5df7ba26552c494fe4da839f))
23
+
24
+ - Lower requires-python to 3.10, add release extras, add parser tests
25
+ ([`561b660`](https://github.com/djachenko/shabbot/commit/561b66084295a6f6f4bb591c8e0a7f53c4305f34))
26
+
27
+ - Resolve mypy type errors in bot and todoist client
28
+ ([`52067da`](https://github.com/djachenko/shabbot/commit/52067dacf08ba8cc188063bdea1ab702648e035a))
29
+
30
+ - Retry voice file download on TimedOut
31
+ ([`75e5344`](https://github.com/djachenko/shabbot/commit/75e5344a548d9f16f84f7b97bfc4a4dc476d3575))
32
+
33
+ - Set whisper transcription language to ru
34
+ ([`4974d03`](https://github.com/djachenko/shabbot/commit/4974d03c049132cbbb4f4a2d2fc23d170aeef59a))
35
+
36
+ - Simplify CI matrix, remove windows and python 3.10
37
+ ([`da9beaa`](https://github.com/djachenko/shabbot/commit/da9beaaea90ba64a38659f3ca5c58d27cde0c6ad))
38
+
39
+ - Use bot identity for git merge in integration workflow
40
+ ([`ce516ae`](https://github.com/djachenko/shabbot/commit/ce516aef09ba01d3a9e1e99850dedda023c3bdd7))
41
+
42
+ ### Chores
43
+
44
+ - Add session memory
45
+ ([`6b45358`](https://github.com/djachenko/shabbot/commit/6b45358dffc9e9870f5a3d2ddedcc2bdf0cec557))
46
+
47
+ - Project config
48
+ ([`41f5b14`](https://github.com/djachenko/shabbot/commit/41f5b14dd1ce92be042f336d7a15cbfd9c6a93e1))
49
+
50
+ - Repokit setup
51
+ ([`f9d2e10`](https://github.com/djachenko/shabbot/commit/f9d2e10a735b100cd7ef4df242f99bbd1deca1e9))
52
+
53
+ - Reset version to 0.0.0 for PSR baseline
54
+ ([`8a3189c`](https://github.com/djachenko/shabbot/commit/8a3189c1036781233f6d69773258041a14381e5b))
55
+
56
+ - Update project memory and claude context
57
+ ([`f20db20`](https://github.com/djachenko/shabbot/commit/f20db20d8e4dd0d8401668036d4e25a8196133d4))
58
+
59
+ ### Continuous Integration
60
+
61
+ - Configure release pipeline with GitHub App auth
62
+ ([`a086762`](https://github.com/djachenko/shabbot/commit/a0867624dd4b23a5e04b06056dfb816ce30a9f07))
63
+
64
+ - Switch release to direct PSR CLI, allow zero versions
65
+ ([`6149dd3`](https://github.com/djachenko/shabbot/commit/6149dd3fc03c5ada22a55705f5e6b246d3399a48))
66
+
67
+ ### Documentation
68
+
69
+ - Add README
70
+ ([`725b885`](https://github.com/djachenko/shabbot/commit/725b885bf17e05fccfa9e67524df57e393f83094))
71
+
72
+ - Add whisper install and model pre-download to README
73
+ ([`7285e11`](https://github.com/djachenko/shabbot/commit/7285e110f30957e122b1f4eb41fc77309326f559))
74
+
75
+
76
+ ## v1.0.0 (2026-06-09)
77
+
78
+ - Initial Release
@@ -0,0 +1,59 @@
1
+ # shabbot — Project Guide
2
+
3
+ ## Что это
4
+
5
+ Telegram-бот для захвата задач с минимальным трением. Отправляешь текст или голос — задача появляется в Todoist, синхронизируется в Google Calendar.
6
+
7
+ Личный инструмент продуктивности, не связан с фотопайплайном.
8
+
9
+ ---
10
+
11
+ ## Как работает
12
+
13
+ - **Текст** → задача в Todoist с due date = сегодня
14
+ - **Голос** → транскрипция через Whisper → задача в Todoist
15
+
16
+ ---
17
+
18
+ ## Стек
19
+
20
+ - Python 3.11+, `python-telegram-bot`, `todoist-api-python`
21
+ - Whisper (openai-whisper) — запускается как внешний процесс через subprocess
22
+ - Переменные окружения через `shabbot.env` (не в репо)
23
+
24
+ ---
25
+
26
+ ## Запуск
27
+
28
+ ```bash
29
+ source shabbot.env && shabbot
30
+ ```
31
+
32
+ Переменные:
33
+ - `SHABBOT_TOKEN` — Telegram bot token
34
+ - `TODOIST_TOKEN` — Todoist API token
35
+ - `WHISPER_MODEL` — модель (default: `large`)
36
+ - `WHISPER_BIN` — путь к whisper (default: `~/.local/bin/whisper`)
37
+
38
+ ---
39
+
40
+ ## Текущее состояние
41
+
42
+ - Базовая функциональность работает
43
+ - 1 незакоммиченный файл (`bot.py`) — активная доработка
44
+ - Ещё не зарелизен
45
+
46
+ ---
47
+
48
+ ## Что нужно сделать
49
+
50
+ 1. **Зарелизить**: оформить как нормальный релиз (версия, changelog)
51
+ 2. **Docker**: упаковать в контейнер чтобы запускать на сервере без зависимостей от локальной среды
52
+ 3. Возможно: LLM-парсер для естественного языка в дату (`parser/base.py` — уже есть заготовка `DumbParser`)
53
+
54
+ ---
55
+
56
+ ## Git
57
+
58
+ Semantic commits: `feat:`, `fix:`, `refactor:`, `chore:`
59
+ Ветки: `feat/llm-parser`, `fix/whisper-timeout`, `refactor/todoist-client`
shabbot-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: shabbot
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.10
5
+ Requires-Dist: python-telegram-bot>=21.0
6
+ Requires-Dist: todoist-api-python>=2.0
7
+ Provides-Extra: release
8
+ Requires-Dist: build; extra == 'release'
9
+ Requires-Dist: python-semantic-release; extra == 'release'
10
+ Provides-Extra: test
11
+ Requires-Dist: mypy; extra == 'test'
12
+ Requires-Dist: pytest; extra == 'test'
13
+ Requires-Dist: ruff; extra == 'test'
@@ -0,0 +1,47 @@
1
+ # shabbot
2
+
3
+ Telegram bot for capturing tasks with minimal friction. Send text or voice — task appears in Todoist, syncs to Google Calendar.
4
+
5
+ ## How it works
6
+
7
+ - **Text message** → task in Todoist with due date = today
8
+ - **Voice message** → transcribed via Whisper → task in Todoist
9
+
10
+ ## Requirements
11
+
12
+ - Python 3.11+
13
+ - [openai-whisper](https://github.com/openai/whisper) installed via pipx
14
+ - Telegram bot token from [@BotFather](https://t.me/BotFather)
15
+ - Todoist API token from [Settings → Integrations → Developer](https://todoist.com/app/settings/integrations/developer)
16
+
17
+ ## Setup
18
+
19
+ ```bash
20
+ pipx install openai-whisper
21
+ pipx install -e .
22
+ ```
23
+
24
+ Pre-download the Whisper model (avoids a 1.5 GB download on first voice message):
25
+
26
+ ```bash
27
+ python -c "import whisper; whisper.load_model('large-v3-turbo')"
28
+ ```
29
+
30
+ Create a `shabbot.env` file:
31
+
32
+ ```bash
33
+ export SHABBOT_TOKEN=your_telegram_bot_token
34
+ export TODOIST_TOKEN=your_todoist_api_token
35
+ export WHISPER_MODEL=large-v3-turbo # optional, default: large
36
+ export WHISPER_BIN=~/.local/bin/whisper # optional
37
+ ```
38
+
39
+ ## Run
40
+
41
+ ```bash
42
+ source shabbot.env && shabbot
43
+ ```
44
+
45
+ ## Google Calendar sync
46
+
47
+ Tasks sync to Google Calendar via the native Todoist ↔ Google Calendar integration. To make all-day tasks (no time set) appear in the calendar, enable **Sync all-day tasks** in Todoist → Settings → Integrations → Google Calendar.
@@ -0,0 +1,25 @@
1
+ # decisions — Архитектурные решения capturebot
2
+
3
+ ### Автозапуск: launchd, не Docker (пересмотрено 2026-06-09)
4
+ Docker перебор для личного инструмента на одной машине. launchd — нативный macOS, pipx install достаточно для изоляции. Переносимость на сервер не актуальна — бот работает локально на MacBook. Docker остаётся в списке как опция если понадобится развернуть где-то ещё.
5
+
6
+ ### Whisper модель: large-v3-turbo
7
+ `large` маппится на `large-v3`, которого не было локально — whisper начинал скачивать 2.88 GB в runtime. `large-v3-turbo` уже был скачан (~1.5 GB), качество сопоставимо, быстрее.
8
+
9
+ ### Polling, не webhook
10
+ Нет сервера с постоянным аптаймом. Telegram хранит непрочитанные сообщения и отдаёт при следующем polling — ничего не теряется пока MacBook выключен.
11
+
12
+ ### Todoist, не Google Calendar напрямую
13
+ Todoist ↔ Google Calendar уже синхронизированы. Через Todoist API — один токен. Через Google Calendar API — OAuth consent screen, credentials.json, token refresh.
14
+
15
+ ### DumbParser сейчас, BaseParser interface для будущего
16
+ Текст as-is → summary, date = today. Когда появится Ollama — создаётся OllamaParser(BaseParser), в bot.py меняется одна строка.
17
+
18
+ ### PSR: версия читается из git тегов, не из pyproject.toml
19
+ `version_toml` говорит PSR только **куда писать** результат. Текущую версию PSR читает из последнего git тега. Без тега поведение непредсказуемо (может выдать 1.0.0 вместо 0.1.0). Чтобы PSR знал откуда считать — нужен начальный тег `v0.0.0` на первом коммите. Порядок работы PSR: читает последний тег → считает bump по коммитам → пишет новую версию в pyproject.toml → коммит + тег → push → build → publish.
20
+
21
+ ### Будущее: dotfiles-репо + централизованная Claude memory
22
+ Идея: `~/dotfiles/` с симлинками на `~/.claude/`, `.gitconfig`, `.zshrc` и т.д. Claude memory хранится в `~/dotfiles/claude/memory/<project>/`, в каждом проекте симлинк `memory → ~/dotfiles/claude/memory/<project>`. Не делать пока не закончены текущие приоритеты.
23
+
24
+ ### TimedOut при скачивании голоса: retry 3 раза
25
+ Первый `TimedOut` = временный сбой сети, не ошибка пользователя. Retry 3 попытки с 2s паузой — после этого user-facing error. Не глотать молча (первый баг: сообщение просто пропадало без ответа).
@@ -0,0 +1,16 @@
1
+ # feat-initial-setup — Бот shabbot готов к использованию, незакоммиченные правки в bot.py
2
+
3
+ <!-- Telegram-бот shabbot установлен через pipx, текстовые и голосовые сообщения создают задачи в Todoist. -->
4
+
5
+ ### 2026-06-09 — Переименование, retry на таймаут, язык whisper, git history
6
+
7
+ Проект переименован capturebot → shabbot. В `bot.py` добавлены: retry при `TimedOut` во время загрузки голосового файла (3 попытки, 2s задержка, потом user-facing error), флаг `--language ru` для whisper (по умолчанию определял украинский), pathlib вместо строк для путей, восстановлен `~filters.COMMAND` фильтр. В `client.py` рефактор: `description or None` заменён на `parts: list[str]`. Создан git-репо с semantic commits, README, CLAUDE.md с конвенциями. **Остаток: `shabbot/bot.py` ещё не закоммичен** (retry + language + pathlib).
8
+
9
+ ### 2026-06-03 — Первый запуск capturebot, отладка, проверка голоса
10
+
11
+ Создана вся структура проекта (capturebot/bot.py, parser/base.py, todoist/client.py, pyproject.toml) и установлена через `pipx install -e .`. Токены хранятся в `shabbot.env` с `export` — без этого дочерние процессы не видят переменные. Бот запускается командой `pkill -f capturebot; source shabbot.env && capturebot` (точка с запятой перед source обязательна — pkill возвращает 1 если процесс не найден, и `&&` обрывает цепь). Голосовые сообщения транскрибируются через whisper, модель `large-v3-turbo` (~1.5 GB, уже была скачана); `large` маппится на `large-v3` которого не было и он начинал скачиваться. Добавлен pulse-индикатор в Telegram пока whisper думает. Задачи без времени не появляются в Google Calendar по умолчанию — нужно включить "Sync all-day tasks" в Todoist → Settings → Integrations → Google Calendar.
12
+
13
+ #### Что осталось
14
+ - Автозапуск (решено делать через Docker, не launchd)
15
+ - LLM-парсер для извлечения даты/локации из текста (Ollama, когда появится Mac Mini)
16
+ - Метки/проекты Todoist — накопить задачи, потом разметить
@@ -0,0 +1,15 @@
1
+ # feat-release-v1 — план финального выпуска: install.sh + launchd + пинед версии
2
+
3
+ <!-- Эта строка читается всегда. Остальное — только когда тема релевантна. -->
4
+
5
+ ### 2026-06-09 — спланировали финальный выпуск shabbot v1.0.0
6
+
7
+ Решили не делать Docker — слишком тяжело для личного инструмента на одном MacBook. Вместо него: launchd (нативный macOS) + `install.sh` который делает всё за один запуск. Шаги: создаёт `shabbot.env` с заглушками если его нет, ставит `openai-whisper` через pipx, ставит сам shabbot через pipx, скачивает модель (`python -c "import whisper; whisper.load_model(...)"`) чтобы не было сюрприза при первом голосовом, копирует `.plist` в `~/Library/LaunchAgents/` и регистрирует через `launchctl load`. Итого юзер делает три вещи: `./install.sh` → вписывает токены в `.env` → `launchctl start shabbot`. Ещё нужно запинить зависимости в `pyproject.toml` и поставить тег `v1.0.0`.
8
+
9
+ Осталось сделать:
10
+ - [ ] `install.sh` (создание env-заглушки, pipx, модель, launchd)
11
+ - [ ] `shabbot.plist` для launchd
12
+ - [ ] запинить версии в `pyproject.toml`
13
+ - [ ] обновить README под новый флоу
14
+ - [ ] прогон от начала до конца на чистом окружении
15
+ - [ ] тег `v1.0.0` + GitHub-релиз
@@ -0,0 +1,5 @@
1
+ # memory index
2
+
3
+ - [feat-initial-setup](feat-initial-setup.md) — Бот shabbot готов: rename, retry на таймаут, --language ru, незакоммиченный bot.py
4
+ - [feat-release-v1](feat-release-v1.md) — план финального выпуска: install.sh + launchd + пинед версии (2026-06-09)
5
+ - [decisions](decisions.md) — Почему launchd (не Docker), large-v3-turbo, polling, Todoist вместо GCal, DumbParser
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "shabbot"
3
+ version = "0.1.0"
4
+ requires-python = ">=3.10"
5
+ dependencies = [
6
+ "python-telegram-bot>=21.0",
7
+ "todoist-api-python>=2.0",
8
+ ]
9
+
10
+ [project.optional-dependencies]
11
+ test = ["pytest", "ruff", "mypy"]
12
+ release = ["python-semantic-release", "build"]
13
+
14
+ [project.scripts]
15
+ shabbot = "shabbot.bot:main"
16
+
17
+ [build-system]
18
+ requires = ["hatchling"]
19
+ build-backend = "hatchling.build"
20
+
21
+ [tool.semantic_release]
22
+ version_toml = ["pyproject.toml:project.version"]
23
+ branch = "master"
24
+ major_on_zero = false
25
+ allow_zero_version = true
File without changes
@@ -0,0 +1,128 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import subprocess
5
+ from pathlib import Path
6
+ from tempfile import TemporaryDirectory
7
+
8
+ from telegram import Update
9
+ from telegram.error import TimedOut
10
+ from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, filters
11
+
12
+ from shabbot.parser.base import DumbParser
13
+ from shabbot.todoist.client import create_task
14
+
15
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
16
+ log = logging.getLogger(__name__)
17
+
18
+ WHISPER_BIN = Path(os.environ.get("WHISPER_BIN", "~/.local/bin/whisper")).expanduser()
19
+ WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "large")
20
+
21
+ parser = DumbParser()
22
+
23
+
24
+ async def transcribe_voice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> str | None:
25
+ assert update.message is not None
26
+ voice = update.message.voice
27
+ assert voice is not None
28
+ file = await context.bot.get_file(voice.file_id)
29
+
30
+ with TemporaryDirectory() as tmp_dir:
31
+ tmp = Path(tmp_dir)
32
+
33
+ ogg_path = tmp / "voice.ogg"
34
+ for attempt in range(3):
35
+ try:
36
+ await file.download_to_drive(ogg_path)
37
+ break
38
+ except TimedOut:
39
+ if attempt == 2:
40
+ raise
41
+ log.warning("download timed out, retrying (%d/3)", attempt + 1)
42
+ await asyncio.sleep(2)
43
+
44
+ result = subprocess.run(
45
+ [WHISPER_BIN, ogg_path, "--model", WHISPER_MODEL, "--language", "ru", "--output_format", "txt", "--output_dir", tmp],
46
+ capture_output=True,
47
+ text=True,
48
+ )
49
+
50
+ if result.returncode != 0:
51
+ log.error("whisper error: %s", result.stderr)
52
+
53
+ return None
54
+
55
+ txt_path = tmp / "voice.txt"
56
+
57
+ if not txt_path.exists():
58
+ return None
59
+
60
+ return txt_path.read_text().strip()
61
+
62
+
63
+ async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
64
+ assert update.message is not None
65
+ assert update.effective_user is not None
66
+ log.info("text from %s: %r", update.effective_user.username, update.message.text)
67
+ await _process(update, update.message.text or "")
68
+
69
+
70
+ async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
71
+ assert update.message is not None
72
+ assert update.effective_user is not None
73
+ log.info("voice from %s", update.effective_user.username)
74
+ status = await update.message.reply_text("🎙 Транскрибирую")
75
+
76
+ async def pulse() -> None:
77
+ dots = 1
78
+
79
+ while True:
80
+ await asyncio.sleep(3)
81
+ await status.edit_text("🎙 Транскрибирую" + "." * dots)
82
+
83
+ dots = dots % 5 + 1
84
+
85
+ pulse_task = asyncio.create_task(pulse())
86
+
87
+ try:
88
+ text = await transcribe_voice(update, context)
89
+ except TimedOut:
90
+ pulse_task.cancel()
91
+ log.error("timed out downloading voice file")
92
+ await status.edit_text("❌ Таймаут при загрузке файла, попробуй ещё раз")
93
+ return
94
+ finally:
95
+ pulse_task.cancel()
96
+
97
+ if not text:
98
+ log.error("whisper returned empty result")
99
+ await status.edit_text("❌ Не удалось распознать голос")
100
+
101
+ return
102
+
103
+ log.info("transcribed: %r", text)
104
+ await status.edit_text(f"📝 Распознал: «{text}»")
105
+
106
+ await _process(update, text)
107
+
108
+
109
+ async def _process(update: Update, text: str) -> None:
110
+ assert update.message is not None
111
+ task = parser.parse(text)
112
+
113
+ log.info("creating todoist task: %r", task.summary)
114
+ result = create_task(task)
115
+ log.info("created task id=%s", result.id)
116
+
117
+ await update.message.reply_text(f"✅ Добавил: «{task.summary}»\nhttps://todoist.com/app/task/{result.id}")
118
+
119
+
120
+ def main() -> None:
121
+ import argparse
122
+
123
+ argparse.ArgumentParser(description="Telegram bot for capturing tasks to Todoist.").parse_known_args()
124
+
125
+ app = ApplicationBuilder().token(os.environ["SHABBOT_TOKEN"]).build()
126
+ app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text))
127
+ app.add_handler(MessageHandler(filters.VOICE, handle_voice))
128
+ app.run_polling()
File without changes
@@ -0,0 +1,20 @@
1
+ from abc import ABC, abstractmethod
2
+ from datetime import date
3
+
4
+ from shabbot.task import Task
5
+
6
+
7
+ class BaseParser(ABC):
8
+ @abstractmethod
9
+ def parse(self, text: str) -> Task: ...
10
+
11
+
12
+ class DumbParser(BaseParser):
13
+ """Naive: весь текст → summary, date = today."""
14
+
15
+ def parse(self, text: str) -> Task:
16
+ return Task(
17
+ summary=text.strip(),
18
+ date=date.today(),
19
+ raw_text=text,
20
+ )
@@ -0,0 +1,12 @@
1
+ from dataclasses import dataclass
2
+ from datetime import date
3
+
4
+
5
+ @dataclass
6
+ class Task:
7
+ summary: str
8
+ date: date
9
+ description: str | None = None
10
+ location: str | None = None
11
+ reminder_minutes: int = 30
12
+ raw_text: str | None = None
File without changes
@@ -0,0 +1,25 @@
1
+ import os
2
+
3
+ from todoist_api_python.api import TodoistAPI
4
+ from todoist_api_python.models import Task as TodoistTask
5
+
6
+ from shabbot.task import Task
7
+
8
+
9
+ def create_task(task: Task) -> TodoistTask:
10
+ api = TodoistAPI(os.environ["TODOIST_TOKEN"])
11
+
12
+ parts: list[str] = []
13
+
14
+ if task.description:
15
+ parts.append(task.description)
16
+ if task.location:
17
+ parts.append(f"📍 {task.location}")
18
+ if task.raw_text and task.raw_text != task.summary:
19
+ parts.append(f"🎙 {task.raw_text}")
20
+
21
+ return api.add_task(
22
+ content=task.summary,
23
+ description="\n\n".join(parts) if parts else None,
24
+ due_string="today",
25
+ )
File without changes
@@ -0,0 +1,15 @@
1
+ from datetime import date
2
+
3
+ from shabbot.parser.base import DumbParser
4
+
5
+
6
+ def test_dumb_parser_summary() -> None:
7
+ parser = DumbParser()
8
+ task = parser.parse("Buy milk")
9
+ assert task.summary == "Buy milk"
10
+
11
+
12
+ def test_dumb_parser_date_today() -> None:
13
+ parser = DumbParser()
14
+ task = parser.parse("anything")
15
+ assert task.date == date.today()