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.
- shabbot-0.1.0/.claude/analysis.md +135 -0
- shabbot-0.1.0/.github/workflows/integration.yml +87 -0
- shabbot-0.1.0/.github/workflows/release.yml +74 -0
- shabbot-0.1.0/.github/workflows/tests.yml +28 -0
- shabbot-0.1.0/.gitignore +20 -0
- shabbot-0.1.0/CHANGELOG.md +78 -0
- shabbot-0.1.0/CLAUDE.md +59 -0
- shabbot-0.1.0/PKG-INFO +13 -0
- shabbot-0.1.0/README.md +47 -0
- shabbot-0.1.0/memory/decisions.md +25 -0
- shabbot-0.1.0/memory/feat-initial-setup.md +16 -0
- shabbot-0.1.0/memory/feat-release-v1.md +15 -0
- shabbot-0.1.0/memory/index.md +5 -0
- shabbot-0.1.0/pyproject.toml +25 -0
- shabbot-0.1.0/shabbot/__init__.py +0 -0
- shabbot-0.1.0/shabbot/bot.py +128 -0
- shabbot-0.1.0/shabbot/parser/__init__.py +0 -0
- shabbot-0.1.0/shabbot/parser/base.py +20 -0
- shabbot-0.1.0/shabbot/task.py +12 -0
- shabbot-0.1.0/shabbot/todoist/__init__.py +0 -0
- shabbot-0.1.0/shabbot/todoist/client.py +25 -0
- shabbot-0.1.0/tests/__init__.py +0 -0
- shabbot-0.1.0/tests/test_parser.py +15 -0
|
@@ -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/
|
shabbot-0.1.0/.gitignore
ADDED
|
@@ -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
|
shabbot-0.1.0/CLAUDE.md
ADDED
|
@@ -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'
|
shabbot-0.1.0/README.md
ADDED
|
@@ -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
|
+
)
|
|
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()
|