Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
9c213f8
feat(`user`): remove `LastGame`s (#51)
emptybutton Sep 15, 2025
ce02fc2
style: fix `ruff` errors (#51)
emptybutton Sep 15, 2025
7b95ae0
update: use latest `ruff` and `mypy`
emptybutton Sep 15, 2025
af00f3a
chore(`ci`): lighten `jobs`
emptybutton Sep 15, 2025
f644454
chore(`ci`): remove `source /home/sunny/.local/bin/env`
emptybutton Sep 15, 2025
976f351
ref(`entities`): extract `StarsPurchase` from `User` (#52)
emptybutton Sep 15, 2025
bcc20f3
ref(`adapters`): remove `chat_id` from logs
emptybutton Sep 15, 2025
5d3c8f3
ref(`User`): remove aggregation data (#59)
emptybutton Sep 16, 2025
cd76b0f
ref(`user`): remove `UserGameLocation`
emptybutton Sep 16, 2025
a0d726d
ref(`Game`): remove `number_of_unfilled_cells`
emptybutton Sep 16, 2025
f372f37
fix(`tests`): up-to-date
emptybutton Sep 16, 2025
160397c
ref(`entities`): rename `MatchmakingQueue` to `Matchmaking`
emptybutton Sep 17, 2025
db55176
ref(`entities`): merge `matchmaking_queue` and `user` (#58)
emptybutton Sep 17, 2025
afcf57b
chore(`structlog`): use `rich` instead of `better-exceptions`
emptybutton Sep 18, 2025
d56d86c
fix(`Matchmake`): fix `entities` io (#58)
emptybutton Sep 18, 2025
7c4e1d7
ref(`adapters`): remove unused `shared_matchmaking` (#58)
emptybutton Sep 18, 2025
10d2312
feat: add `matchmaking` waiting cancellation (#45)
emptybutton Sep 18, 2025
6a76fa2
ref: make infinite interactors not infinite (#61)
emptybutton Sep 19, 2025
bd6a9e4
chore(`infrastructure`): don't index `null`s
emptybutton Sep 19, 2025
f0ab80a
feat: add strongest user rank (#57)
emptybutton Sep 20, 2025
0a12c0a
fix(`application`): use `ports` for serializable transactions (#62)
emptybutton Sep 21, 2025
a1d4ea8
fix(`adapters`): implement new `transaction`s (#62)
emptybutton Sep 21, 2025
137da13
fix: implement new io (#62)
emptybutton Sep 22, 2025
8664e90
fix: add `SerializationError` retrying (#62)
emptybutton Sep 23, 2025
8be8d86
style: fix `ruff` errors (#62)
emptybutton Sep 23, 2025
208c219
fix: fix `mypy` errors (#62)
emptybutton Sep 23, 2025
4e66648
ref: use `Envs` for `Retrier` providing (#62)
emptybutton Sep 23, 2025
0d24ac4
fix: make it launchable (#62)
emptybutton Sep 24, 2025
10b0814
fix(`infrastructure`): fix `postgres` io details (#62)
emptybutton Sep 25, 2025
470761b
chore(`deploy`): use `NATS_URL` env for `nats_streams`
emptybutton Sep 25, 2025
6249a20
fix: use custom integrations for context-independent use (#62)
emptybutton Sep 25, 2025
c9e076d
fix: remove `ruff` errors (#62)
emptybutton Sep 25, 2025
ecd4795
ref: make `processors` from `tasks` (#62)
emptybutton Sep 25, 2025
4bde5a6
fix(`infrastructure`): replace `taskiq` with `remote_funcs` (#62)
emptybutton Sep 26, 2025
fb067ec
fix(`di`): fix `ruff` errors (#62)
emptybutton Sep 26, 2025
3d7f345
fix(`start_tg_bot`): fix `mypy` errors (#62)
emptybutton Sep 26, 2025
28d6546
feat(`invitation_to_game`): use `username`s (#53)
emptybutton Oct 5, 2025
e3adf71
fix(`transaction`): rollback broken transactions on `commit` (#53)
emptybutton Oct 5, 2025
6833beb
style(`presentation`): fix `ruff` errors (#53)
emptybutton Oct 5, 2025
3af4b8c
fix(`deploy`): use custom `natscli` image
emptybutton Oct 5, 2025
673a3a0
ref(`nats_streams`): remove the unused `entrypoint`
emptybutton Oct 5, 2025
6b460ff
chore(`di`): add dialog ttl (#63)
emptybutton Oct 6, 2025
3408ec7
feat(`error_handling`): handle intent and chat errors (#63)
emptybutton Oct 6, 2025
44a5374
chore(`pyproject`): `0.5.0`v
emptybutton Oct 6, 2025
3e246a9
fix(`add_streams.sh`): add from `streams` dir
emptybutton Oct 6, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 8 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,24 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: add secrets
run: |
echo "bot_token: ${{ secrets.BOT_TOKEN }}" > deploy/dev/ttt/secrets.yaml
echo "payments_token: ${{ secrets.PAYMENTS_TOKEN }}" >> deploy/dev/ttt/secrets.yaml
echo "gemini_api_key: ${{ secrets.GEMINI_API_KEY }}" >> deploy/dev/ttt/secrets.yaml
echo "sentry_dsn: ${{ secrets.SENTRY_DSN }}" >> deploy/dev/ttt/secrets.yaml
- name: install ruff
run: curl -LsSf https://astral.sh/ruff/0.13.0/install.sh | sh

- name: ruff
run: docker compose -f deploy/dev/docker-compose.yaml run ttt ruff check src tests
run: ruff check src tests

mypy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: add secrets
- name: install dependencies
run: |
echo "bot_token: ${{ secrets.BOT_TOKEN }}" > deploy/dev/ttt/secrets.yaml
echo "payments_token: ${{ secrets.PAYMENTS_TOKEN }}" >> deploy/dev/ttt/secrets.yaml
echo "gemini_api_key: ${{ secrets.GEMINI_API_KEY }}" >> deploy/dev/ttt/secrets.yaml
echo "sentry_dsn: ${{ secrets.SENTRY_DSN }}" >> deploy/dev/ttt/secrets.yaml
curl -LsSf https://astral.sh/uv/install.sh | sh
uv sync

- name: mypy
run: docker compose -f deploy/dev/docker-compose.yaml run ttt mypy src tests
run: uv run mypy src tests

pytest:
runs-on: ubuntu-latest
Expand All @@ -44,6 +38,7 @@ jobs:
echo "payments_token: ${{ secrets.PAYMENTS_TOKEN }}" >> deploy/dev/ttt/secrets.yaml
echo "gemini_api_key: ${{ secrets.GEMINI_API_KEY }}" >> deploy/dev/ttt/secrets.yaml
echo "sentry_dsn: ${{ secrets.SENTRY_DSN }}" >> deploy/dev/ttt/secrets.yaml
echo "sentry_dsn: ${{ secrets.ADMIN_TOKEN }}" >> deploy/dev/ttt/secrets.yaml

- name: pytest
run: docker compose -f deploy/dev/docker-compose.yaml run ttt pytest tests --cov --cov-report=xml
Expand Down
15 changes: 13 additions & 2 deletions deploy/dev/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ services:
TTT_NATS_URL: nats://nats:4222

TTT_GEMINI_URL: https://my-openai-gemini-sigma-sandy.vercel.app

TTT_DIALOG_TTL: 259200 # 3 days

TTT_MATCHMAKING_MAX_WORKERS: 4
TTT_MATCHMAKING_WORKER_MAX_USERS: 100
TTT_MATCHMAKING_WORKER_CREATION_INTERVAL_SECONDS: 0.5

TTT_AUTO_CANCEL_INVITATIONS_TO_GAME_INTERVAL_SECONDS: 1

TTT_SERIALIZATION_ERROR_MAX_RETRIES: 10
secrets:
- secrets
command: ttt-dev
Expand Down Expand Up @@ -84,15 +94,16 @@ services:
interval: 3s

nats_streams:
image: bitnami/natscli:0.2.3-debian-12-r4
image: n255/natscli:0.3.0-bookworm-slim
container_name: ttt-nats-streams
depends_on:
nats:
condition: service_healthy
volumes:
- ./nats:/mnt
entrypoint: [""]
command: ["bash", "/mnt/add_streams.sh"]
environment:
NATS_URL: nats://nats:4222

volumes:
backend-data:
Expand Down
4 changes: 3 additions & 1 deletion deploy/dev/nats/add_streams.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#!/bin/bash

nats -s nats://nats:4222 stream add --config /mnt/streams/user.json
for stream_config_file in `ls -lx /mnt/streams`; do
nats stream add --config /mnt/streams/$stream_config_file
done
14 changes: 14 additions & 0 deletions deploy/dev/nats/streams/game.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "GAME",
"subjects": ["game.>"],
"retention": "limits",
"max_consumers": -1,
"max_msgs": -1,
"max_bytes": -1,
"max_age": 31536000000000000,
"max_msg_size": -1,
"storage": "file",
"discard": "old",
"num_replicas": 1,
"duplicate_window": 0
}
14 changes: 14 additions & 0 deletions deploy/dev/nats/streams/stars_purchase.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "STARS_PURCHASE",
"subjects": ["stars_purchase.>"],
"retention": "limits",
"max_consumers": -1,
"max_msgs": -1,
"max_bytes": -1,
"max_age": 31536000000000000,
"max_msg_size": -1,
"storage": "file",
"discard": "old",
"num_replicas": 1,
"duplicate_window": 0
}
14 changes: 12 additions & 2 deletions deploy/prod/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ services:
TTT_NATS_URL: nats://${NATS_TOKEN}@nats:4222

TTT_GEMINI_URL: ${GEMINI_URL}

TTT_DIALOG_TTL: 259200 # 3 days

TTT_MATCHMAKING_MAX_WORKERS: 4
TTT_MATCHMAKING_WORKER_MAX_USERS: 100
TTT_MATCHMAKING_WORKER_CREATION_INTERVAL_SECONDS: 0.5

TTT_AUTO_CANCEL_INVITATIONS_TO_GAME_INTERVAL_SECONDS: 1

TTT_SERIALIZATION_ERROR_MAX_RETRIES: 10
secrets:
- secrets
networks:
Expand Down Expand Up @@ -138,7 +148,7 @@ services:
interval: 3s

nats_streams:
image: bitnami/natscli:0.2.3-debian-12-r4
image: n255/natscli:0.3.0-bookworm-slim
container_name: ttt-nats-streams
depends_on:
nats:
Expand All @@ -148,8 +158,8 @@ services:
networks:
- nats
environment:
NATS_URL: nats://nats:4222
NATS_TOKEN: ${NATS_TOKEN}
entrypoint: [""]
command: ["bash", "/mnt/add_streams.sh"]

volumes:
Expand Down
4 changes: 3 additions & 1 deletion deploy/prod/nats/add_streams.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#!/bin/bash

nats -s nats://nats:4222 stream add --config /mnt/streams/user.json
for stream_config_file in `ls -lx /mnt/streams`; do
nats stream add --config /mnt/streams/$stream_config_file
done
14 changes: 14 additions & 0 deletions deploy/prod/nats/streams/game.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "GAME",
"subjects": ["game.>"],
"retention": "limits",
"max_consumers": -1,
"max_msgs": -1,
"max_bytes": -1,
"max_age": 31536000000000000,
"max_msg_size": -1,
"storage": "file",
"discard": "old",
"num_replicas": 1,
"duplicate_window": 0
}
14 changes: 14 additions & 0 deletions deploy/prod/nats/streams/stars_purchase.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "STARS_PURCHASE",
"subjects": ["stars_purchase.>"],
"retention": "limits",
"max_consumers": -1,
"max_msgs": -1,
"max_bytes": -1,
"max_age": 31536000000000000,
"max_msg_size": -1,
"storage": "file",
"discard": "old",
"num_replicas": 1,
"duplicate_window": 0
}
9 changes: 5 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "ttt"
version = "0.4.2"
version = "0.5.0"
description = "Tic-Tac-Toe Telegram Bot"
authors = [
{name = "Alexander Smolin", email = "88573504+emptybutton@users.noreply.github.com"}
Expand Down Expand Up @@ -29,13 +29,13 @@ dependencies = [

[dependency-groups]
dev = [
"mypy[faster-cache]==1.16.0",
"ruff==0.11.13",
"mypy[faster-cache]==1.18.1",
"ruff==0.13.0",
"pytest==8.4.0",
"pytest-cov==6.1.1",
"pytest-asyncio==1.0.0",
"dirty-equals==0.9.0",
"better-exceptions==0.3.3",
"rich==14.1.0",
]

[project.urls]
Expand Down Expand Up @@ -111,6 +111,7 @@ ignore = [
"EM101",
"N807",
"FURB118",
"DOC402",
]

[tool.ruff.lint.per-file-ignores]
Expand Down
1 change: 1 addition & 0 deletions src/ttt/application/common/errors/serialization_error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
class SerializationError(Exception): ...
1 change: 1 addition & 0 deletions src/ttt/application/common/ports/map.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ async def __call__(
"""
:raises ttt.application.common.ports.map.NotUniqueUserIdError:
:raises ttt.application.common.ports.map.NotUniqueActiveInvitationToGameUserIdsError:
:raises ttt.application.common.errors.serialization_error.SerializationError:
""" # noqa: E501
6 changes: 6 additions & 0 deletions src/ttt/application/common/ports/retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from abc import ABC, abstractmethod


class Retry(ABC):
@abstractmethod
def __bool__(self) -> bool: ...
30 changes: 29 additions & 1 deletion src/ttt/application/common/ports/transaction.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
from abc import ABC, abstractmethod
from contextlib import AbstractAsyncContextManager
from types import TracebackType
from typing import Any


Transaction = AbstractAsyncContextManager[Any]
class SerializableTransaction(AbstractAsyncContextManager[Any], ABC):
@abstractmethod
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> bool | None:
"""
:raises ttt.application.common.errors.serialization_error.SerializationError:
""" # noqa: E501

return None

@abstractmethod
async def commit(self) -> None:
"""
:raises ttt.application.common.errors.serialization_error.SerializationError:
""" # noqa: E501


class NotSerializableTransaction(AbstractAsyncContextManager[Any], ABC):
@abstractmethod
async def commit(self) -> None: ...


class ReadonlyTransaction(AbstractAsyncContextManager[Any], ABC): ...
51 changes: 18 additions & 33 deletions src/ttt/application/game/game/cancel_game.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
from asyncio import gather
from dataclasses import dataclass

from ttt.application.common.ports.map import Map
from ttt.application.common.ports.transaction import Transaction
from ttt.application.common.ports.transaction import SerializableTransaction
from ttt.application.common.ports.uuids import UUIDs
from ttt.application.game.game.ports.game_log import GameLog
from ttt.application.game.game.ports.game_views import GameViews
from ttt.application.game.game.ports.games import Games
from ttt.entities.core.game.game import AlreadyCompletedGameError
from ttt.entities.core.user.user import User
from ttt.entities.tools.assertion import not_none
from ttt.entities.tools.tracking import Tracking


Expand All @@ -19,50 +16,38 @@ class CancelGame:
games: Games
game_views: GameViews
uuids: UUIDs
transaction: Transaction
transaction: SerializableTransaction
log: GameLog

async def __call__(self, user_id: int) -> None:
"""
:raises ttt.application.common.errors.serialization_error.SerializationError:
""" # noqa: E501

async with self.transaction:
(
game,
user1_last_game_id,
user2_last_game_id,
) = await gather(
self.games.game_with_game_location(user_id),
self.uuids.random_uuid(),
self.uuids.random_uuid(),
)
game = await self.games.current_user_game(user_id)

if game is None:
await self.game_views.no_game_view(user_id)
await self.log.no_current_game_to_cancel_game(user_id)
await self.transaction.commit()
await self.game_views.no_current_game_view(user_id)
return

locations = tuple(
not_none(user.game_location)
for user in (game.player1, game.player2)
if isinstance(user, User)
)

try:
tracking = Tracking()
game.cancel(
user_id,
user1_last_game_id,
user2_last_game_id,
tracking,
)
game.cancel(user_id, tracking)
except AlreadyCompletedGameError:
await self.log.already_completed_game_to_cancel(game, user_id)
await self.transaction.commit()
await self.game_views.game_already_complteted_view(
user_id,
game,
)
return
else:
await self.log.game_cancelled(user_id, game)

await self.log.game_cancelled(user_id, game)
await self.map_(tracking)
await self.transaction.commit()

await self.map_(tracking)
await self.game_views.game_view_with_locations(
locations,
game,
)
await self.game_views.game_view(game)
Loading