spoonbot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of spoonbot might be problematic. Click here for more details.

Files changed (63) hide show
  1. spoonbot/__init__.py +25 -0
  2. spoonbot/config/ConstantsConfig.py +21 -0
  3. spoonbot/constants.py +12 -0
  4. spoonbot/controller/SpoonBotController.py +56 -0
  5. spoonbot/controller/SpoonBotWebController.py +52 -0
  6. spoonbot/controller/SpoonOAuthController.py +76 -0
  7. spoonbot/db/DBType.py +6 -0
  8. spoonbot/db/DatabaseClient.py +75 -0
  9. spoonbot/db/SpoonUserRepository.py +278 -0
  10. spoonbot/db/config/MySQLConfig.py +11 -0
  11. spoonbot/db/dto/TokenDataDto.py +23 -0
  12. spoonbot/db/dto/UpdateResult.py +7 -0
  13. spoonbot/db/mysql/MySQLClient.py +147 -0
  14. spoonbot/db/sqlite3/Sqlite3Client.py +111 -0
  15. spoonbot/db/types.py +1 -0
  16. spoonbot/dto/AuthorizeUrl.py +7 -0
  17. spoonbot/dto/ChatBody.py +12 -0
  18. spoonbot/dto/DonationBody.py +12 -0
  19. spoonbot/dto/DonationData.py +23 -0
  20. spoonbot/dto/FanData.py +13 -0
  21. spoonbot/dto/FollowBody.py +10 -0
  22. spoonbot/dto/LikeBody.py +21 -0
  23. spoonbot/dto/ListenerData.py +23 -0
  24. spoonbot/dto/LiveRoom.py +16 -0
  25. spoonbot/dto/PresenceBody.py +19 -0
  26. spoonbot/dto/RateLimit.py +11 -0
  27. spoonbot/dto/SpoonAuthorization.py +35 -0
  28. spoonbot/dto/User.py +6 -0
  29. spoonbot/dto/event/ChatEvent.py +9 -0
  30. spoonbot/dto/event/ConnectEvent.py +8 -0
  31. spoonbot/dto/event/DonationEvent.py +9 -0
  32. spoonbot/dto/event/EndEvent.py +7 -0
  33. spoonbot/dto/event/FollowEvent.py +9 -0
  34. spoonbot/dto/event/JoinEvent.py +9 -0
  35. spoonbot/dto/event/LikeEvent.py +9 -0
  36. spoonbot/enums/Country.py +6 -0
  37. spoonbot/enums/SpoonPermission.py +16 -0
  38. spoonbot/enums/TokenStatus.py +7 -0
  39. spoonbot/exception/BackoffRetryException.py +2 -0
  40. spoonbot/exception/BurstRequestException.py +11 -0
  41. spoonbot/exception/ChatRoomCrowdedException.py +5 -0
  42. spoonbot/exception/DailyRequestLimitException.py +12 -0
  43. spoonbot/exception/MessageSendUnavailableException.py +5 -0
  44. spoonbot/exception/NonBackoffRetryException.py +2 -0
  45. spoonbot/exception/NonRetryException.py +2 -0
  46. spoonbot/exception/TokenRefreshException.py +2 -0
  47. spoonbot/httpclient/APIClient.py +113 -0
  48. spoonbot/httpclient/SpoonApiClient.py +148 -0
  49. spoonbot/httpclient/dto/APIResponse.py +10 -0
  50. spoonbot/httpclient/dto/ChatSendRequestBody.py +24 -0
  51. spoonbot/httpclient/dto/DonationResponseDto.py +20 -0
  52. spoonbot/httpclient/dto/FanResponseDto.py +13 -0
  53. spoonbot/httpclient/dto/ListenersResponseDto.py +16 -0
  54. spoonbot/httpclient/dto/LiveRoomDto.py +16 -0
  55. spoonbot/httpclient/dto/TokenResponse.py +9 -0
  56. spoonbot/httpclient/dto/UserDataDto.py +5 -0
  57. spoonbot/manager/SpoonEventStreamManager.py +295 -0
  58. spoonbot/py.typed +0 -0
  59. spoonbot/spoon_bot.py +473 -0
  60. spoonbot/types/callback_types.py +17 -0
  61. spoonbot-0.1.0.dist-info/METADATA +209 -0
  62. spoonbot-0.1.0.dist-info/RECORD +63 -0
  63. spoonbot-0.1.0.dist-info/WHEEL +4 -0
spoonbot/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ from spoonbot.db.config.MySQLConfig import MySQLConfig
2
+ from spoonbot.dto.event.ChatEvent import ChatEvent
3
+ from spoonbot.dto.event.ConnectEvent import ConnectEvent
4
+ from spoonbot.dto.event.DonationEvent import GiftEvent
5
+ from spoonbot.dto.event.EndEvent import EndEvent
6
+ from spoonbot.dto.event.FollowEvent import FollowEvent
7
+ from spoonbot.dto.event.JoinEvent import JoinEvent
8
+ from spoonbot.dto.event.LikeEvent import LikeEvent
9
+ from spoonbot.enums.Country import Country
10
+ from spoonbot.enums.SpoonPermission import SpoonPermission
11
+ from spoonbot.spoon_bot import SpoonBot
12
+
13
+ __all__ = [
14
+ "MySQLConfig",
15
+ "ChatEvent",
16
+ "ConnectEvent",
17
+ "GiftEvent",
18
+ "EndEvent",
19
+ "FollowEvent",
20
+ "JoinEvent",
21
+ "LikeEvent",
22
+ "Country",
23
+ "SpoonPermission",
24
+ "SpoonBot",
25
+ ]
@@ -0,0 +1,21 @@
1
+ from typing import Self
2
+
3
+ from spoonbot.constants import (
4
+ API_BASE_URL_JP,
5
+ API_BASE_URL_KR,
6
+ OAUTH_AUTHORIZE_BASE_URL_JP,
7
+ OAUTH_AUTHORIZE_BASE_URL_KR,
8
+ )
9
+ from spoonbot.enums.Country import Country
10
+
11
+
12
+ class ConstantsConfig:
13
+ def __init__(self, oauth_authorize_base_url: str, api_base_url: str) -> None:
14
+ self.oauth_authorize_base_url = oauth_authorize_base_url
15
+ self.api_base_url = api_base_url
16
+
17
+ @classmethod
18
+ def create(cls, country: Country) -> Self:
19
+ if country == Country.KR:
20
+ return cls(OAUTH_AUTHORIZE_BASE_URL_KR, API_BASE_URL_KR)
21
+ return cls(OAUTH_AUTHORIZE_BASE_URL_JP, API_BASE_URL_JP)
spoonbot/constants.py ADDED
@@ -0,0 +1,12 @@
1
+ OAUTH_AUTHORIZE_BASE_URL_KR = "https://developers.spooncast.net/kr/oauth/authorize"
2
+ OAUTH_AUTHORIZE_BASE_URL_JP = "https://developers.spooncast.net/jp/oauth/authorize"
3
+
4
+ API_BASE_URL_KR = "https://kr-openapi.spooncast.net/v1/"
5
+ API_BASE_URL_JP = "https://jp-openapi.spooncast.net/v1/"
6
+
7
+ SPACE = " "
8
+ # https://developers.spooncast.net/docs/en/api-reference/tokens
9
+ DEFAULT_REFRESH_TOKEN_EXPIRES_IN = 60 * 60 * 24 * 30 # 30 days
10
+ DEFAULT_SESSION_EXPIRES_IN = (
11
+ DEFAULT_REFRESH_TOKEN_EXPIRES_IN - 60 * 15
12
+ ) # 29 days 23 hour 45 minutes
@@ -0,0 +1,56 @@
1
+ from aiohttp.web_exceptions import HTTPForbidden, HTTPUnauthorized
2
+ from aiohttp.web_request import Request
3
+ from aiohttp.web_response import Response, json_response
4
+ from aiohttp.web_routedef import RouteTableDef
5
+
6
+ from spoonbot.spoon_bot import SpoonBot
7
+
8
+ routes = RouteTableDef()
9
+
10
+
11
+ class SpoonBotController:
12
+ spoon_bot: SpoonBot
13
+
14
+ def __init__(self, spoon_bot: SpoonBot) -> None:
15
+ self.spoon_bot = spoon_bot
16
+
17
+ routes.get("/api/v1/csrf-token")(self.csrf_token_function())
18
+ routes.get("/api/v1/bot")(self.bot_information_function())
19
+ routes.post("/api/v1/bot/invite")(self.bot_invite_request_handler())
20
+
21
+ def csrf_token_function(self):
22
+ async def csrf_token_handler(request: Request) -> Response:
23
+ session_id = request.cookies.get("session_id")
24
+ if session_id is None:
25
+ raise HTTPUnauthorized()
26
+ csrf_token = await self.spoon_bot.regenerate_csrf_token(session_id)
27
+ return json_response({"csrf_token": csrf_token})
28
+
29
+ return csrf_token_handler
30
+
31
+ def bot_information_function(self):
32
+ async def bot_information_handler(_: Request) -> Response:
33
+ page = self.spoon_bot.get_bot_operation_page()
34
+ return json_response(
35
+ {"bot_name": self.spoon_bot.get_bot_name(), "page": page}
36
+ )
37
+
38
+ return bot_information_handler
39
+
40
+ def bot_invite_request_handler(self):
41
+ async def bot_invite_request(request: Request) -> Response:
42
+ session_id = request.cookies.get("session_id")
43
+ csrf_token = request.headers.get("X-CSRF-TOKEN")
44
+ if session_id is None:
45
+ raise HTTPUnauthorized()
46
+ if csrf_token is None:
47
+ raise HTTPForbidden(reason="Bad Request. Need X-CSRF-TOKEN")
48
+ result = await self.spoon_bot.start_stream_by_session(
49
+ session_id, csrf_token
50
+ )
51
+ return Response(status=204 if result else 409)
52
+
53
+ return bot_invite_request
54
+
55
+ def get_route(self) -> RouteTableDef:
56
+ return routes
@@ -0,0 +1,52 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ from aiohttp.web_exceptions import HTTPNotFound
5
+ from aiohttp.web_fileresponse import FileResponse
6
+ from aiohttp.web_request import Request
7
+ from aiohttp.web_response import StreamResponse
8
+ from aiohttp.web_routedef import RouteTableDef
9
+
10
+ routes = RouteTableDef()
11
+
12
+
13
+ def _get_base_dir() -> Path:
14
+ # PyInstaller などで実行ファイル化されている場合
15
+ meipass = getattr(sys, "_MEIPASS", None)
16
+ if meipass is not None:
17
+ return Path(meipass)
18
+
19
+ if getattr(sys, "frozen", False):
20
+ return Path(sys.executable).resolve().parent
21
+
22
+ # src/controller/xxx.py
23
+ return Path(__file__).resolve().parents[3]
24
+
25
+
26
+ PAGES = {
27
+ "connect": "connect.html",
28
+ "success": "success.html",
29
+ "fail": "fail.html",
30
+ "invite": "invite.html",
31
+ }
32
+ ASSETS_DIR = _get_base_dir() / "assets"
33
+
34
+
35
+ class SpoonBotWebController:
36
+ def __init__(self) -> None:
37
+ for page in PAGES:
38
+ routes.get(f"/spoonbot/{page}")(self.serve_page)
39
+ routes.static("/assets/", ASSETS_DIR)
40
+
41
+ @staticmethod
42
+ async def serve_page(request: Request) -> StreamResponse:
43
+ filename = PAGES[request.path[len("/spoonbot/") :]]
44
+ filepath = ASSETS_DIR / filename
45
+
46
+ if not filepath.is_file():
47
+ raise HTTPNotFound()
48
+
49
+ return FileResponse(filepath)
50
+
51
+ def get_route(self) -> RouteTableDef:
52
+ return routes
@@ -0,0 +1,76 @@
1
+ import logging
2
+
3
+ from aiohttp.web_exceptions import HTTPFound
4
+ from aiohttp.web_request import Request
5
+ from aiohttp.web_routedef import RouteTableDef
6
+
7
+ from spoonbot.constants import DEFAULT_SESSION_EXPIRES_IN
8
+ from spoonbot.spoon_bot import SpoonBot
9
+
10
+ routes = RouteTableDef()
11
+ SIGNED_STATE_KEY = "signed_state"
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class SpoonOAuthController:
16
+ spoon_bot: SpoonBot
17
+
18
+ def __init__(self, spoon_bot: SpoonBot) -> None:
19
+ self.spoon_bot = spoon_bot
20
+
21
+ routes.get("/oauth")(self.redirect_authorization_url_handler())
22
+ routes.get("/oauth/callback")(self.get_token_via_oauth_handler())
23
+
24
+ def redirect_authorization_url_handler(self):
25
+ async def redirect_authorization_url(_: Request) -> HTTPFound:
26
+ authorize_url = self.spoon_bot.user_authorize_url()
27
+ response = HTTPFound(location=authorize_url.url)
28
+
29
+ response.set_cookie(
30
+ name=SIGNED_STATE_KEY,
31
+ value=authorize_url.state,
32
+ max_age=60 * 3, # 3分有効
33
+ httponly=True,
34
+ samesite="Lax",
35
+ secure=True,
36
+ )
37
+
38
+ return response
39
+
40
+ return redirect_authorization_url
41
+
42
+ def get_token_via_oauth_handler(self):
43
+ async def get_token_via_oauth(request: Request):
44
+ code = request.query.get("code")
45
+ signed_state = request.query.get("state")
46
+ cookie_state = request.cookies.get(SIGNED_STATE_KEY)
47
+
48
+ try:
49
+ auth = await self.spoon_bot.get_authorization(
50
+ code, cookie_state, signed_state
51
+ )
52
+ await self.spoon_bot.save_authorization(auth)
53
+ session_id = await self.spoon_bot.save_session(auth)
54
+
55
+ response = HTTPFound(location="/spoonbot/success")
56
+ response.del_cookie(SIGNED_STATE_KEY)
57
+ response.set_cookie(
58
+ name="session_id",
59
+ value=session_id,
60
+ max_age=DEFAULT_SESSION_EXPIRES_IN,
61
+ httponly=True,
62
+ samesite="Lax",
63
+ secure=True,
64
+ )
65
+
66
+ return response
67
+ except Exception as exception:
68
+ logger.exception(exception)
69
+ response = HTTPFound(location="/spoonbot/fail")
70
+ response.del_cookie(SIGNED_STATE_KEY)
71
+ return response
72
+
73
+ return get_token_via_oauth
74
+
75
+ def get_route(self) -> RouteTableDef:
76
+ return routes
spoonbot/db/DBType.py ADDED
@@ -0,0 +1,6 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class DBType(StrEnum):
5
+ SQLITE3 = "SQLITE3"
6
+ MYSQL = "MYSQL"
@@ -0,0 +1,75 @@
1
+ from abc import ABC, abstractmethod
2
+ from contextlib import AbstractAsyncContextManager
3
+ from typing import Any, LiteralString, TypeVar
4
+
5
+ from spoonbot.db.config.MySQLConfig import MySQLConfig
6
+ from spoonbot.db.DBType import DBType
7
+ from spoonbot.db.types import DBValueType
8
+
9
+ ResponseGenericType = TypeVar("ResponseGenericType")
10
+
11
+
12
+ class DatabaseClient(ABC):
13
+ db_type: DBType
14
+
15
+ def __init__(self, db_type: DBType) -> None:
16
+ self.db_type = db_type
17
+
18
+ @classmethod
19
+ async def create(cls, config: MySQLConfig | None = None) -> "DatabaseClient":
20
+ # lazy import
21
+ from spoonbot.db.mysql.MySQLClient import MySQLClient
22
+ from spoonbot.db.sqlite3.Sqlite3Client import Sqlite3Client
23
+
24
+ if config is None:
25
+ client = Sqlite3Client(DBType.SQLITE3)
26
+ await client.initialize()
27
+ return client
28
+ client = MySQLClient(DBType.MYSQL)
29
+ await client.initialize(config)
30
+ return client
31
+
32
+ @abstractmethod
33
+ async def initialize(self, *args, **kwargs):
34
+ pass
35
+
36
+ @abstractmethod
37
+ async def close(self) -> None:
38
+ pass
39
+
40
+ @abstractmethod
41
+ async def execute_one(
42
+ self,
43
+ query: str,
44
+ args: dict[str, DBValueType] | None = None,
45
+ response_type: type[ResponseGenericType] | None = None,
46
+ ) -> ResponseGenericType | None:
47
+ pass
48
+
49
+ @abstractmethod
50
+ async def execute_many(
51
+ self,
52
+ query: str,
53
+ args: dict[str, DBValueType] | None = None,
54
+ response_type: type[ResponseGenericType] | None = None,
55
+ ) -> list[ResponseGenericType] | None:
56
+ pass
57
+
58
+ @abstractmethod
59
+ def transaction(self) -> AbstractAsyncContextManager[Any]:
60
+ pass
61
+
62
+ @abstractmethod
63
+ async def execute_cud(
64
+ self,
65
+ query: str,
66
+ args: dict[str, DBValueType] | None = None,
67
+ ) -> int:
68
+ pass
69
+
70
+ @abstractmethod
71
+ def placeholder(self, name: LiteralString) -> str:
72
+ pass
73
+
74
+ def get_db_type(self) -> DBType:
75
+ return self.db_type
@@ -0,0 +1,278 @@
1
+ import time
2
+
3
+ from spoonbot.db.config.MySQLConfig import MySQLConfig
4
+ from spoonbot.db.DatabaseClient import DatabaseClient
5
+ from spoonbot.db.DBType import DBType
6
+ from spoonbot.db.dto.TokenDataDto import SessionDataDto, TokenDataDto
7
+
8
+
9
+ class SpoonUserRepository:
10
+ database_client: DatabaseClient
11
+
12
+ async def initialize(self, config: MySQLConfig | None = None):
13
+ self.database_client = await DatabaseClient.create(config)
14
+ await self._create_table()
15
+
16
+ async def close(self):
17
+ await self.database_client.close()
18
+
19
+ async def _create_table(self):
20
+ if self.database_client.get_db_type() == DBType.MYSQL:
21
+ await self._create_table_for_mysql()
22
+ else:
23
+ await self._create_table_for_sqlite()
24
+
25
+ async def _create_table_for_mysql(self):
26
+ await self.database_client.execute_cud(
27
+ query="""-- sql
28
+ CREATE TABLE IF NOT EXISTS authorization_spoon_users (
29
+ id VARCHAR(256) PRIMARY KEY,
30
+ access_token VARCHAR(256) NOT NULL,
31
+ refresh_token VARCHAR(256) NOT NULL,
32
+ expire_at BIGINT,
33
+ refresh_expire_at BIGINT,
34
+ scope VARCHAR(512),
35
+ token_type VARCHAR(32),
36
+ token_status VARCHAR(32) DEFAULT "DEFAULT",
37
+
38
+ created_at BIGINT NOT NULL,
39
+ updated_at BIGINT NOT NULL,
40
+
41
+ INDEX idx_expire_at (expire_at),
42
+ INDEX idx_refresh_expire_at (refresh_expire_at)
43
+ )
44
+ """
45
+ )
46
+ await self.database_client.execute_cud(
47
+ query="""-- sql
48
+ CREATE TABLE IF NOT EXISTS authorization_spoon_user_sessions (
49
+ id BINARY(16) PRIMARY KEY,
50
+ authorization_spoon_user_id VARCHAR(256) NOT NULL,
51
+ encoded_session_id BINARY(32) NOT NULL,
52
+ csrf_token VARCHAR(64),
53
+
54
+ expires_at BIGINT NOT NULL,
55
+ created_at BIGINT NOT NULL,
56
+ updated_at BIGINT NOT NULL,
57
+ FOREIGN KEY (authorization_spoon_user_id)
58
+ REFERENCES authorization_spoon_users(id),
59
+ INDEX idx_encoded_session_id (encoded_session_id),
60
+ INDEX idx_authorization_spoon_user_id (authorization_spoon_user_id)
61
+ )
62
+ """
63
+ )
64
+
65
+ async def _create_table_for_sqlite(self):
66
+ await self.database_client.execute_cud(
67
+ query="""-- sql
68
+ CREATE TABLE IF NOT EXISTS authorization_spoon_users (
69
+ id VARCHAR(256) PRIMARY KEY,
70
+ access_token VARCHAR(256) NOT NULL,
71
+ refresh_token VARCHAR(256) NOT NULL,
72
+ expire_at BIGINT,
73
+ refresh_expire_at BIGINT,
74
+ scope VARCHAR(512),
75
+ token_type VARCHAR(32),
76
+ token_status VARCHAR(32) DEFAULT "DEFAULT",
77
+
78
+ created_at BIGINT NOT NULL,
79
+ updated_at BIGINT NOT NULL
80
+ )
81
+ """
82
+ )
83
+ await self.database_client.execute_cud(
84
+ """-- sql
85
+ CREATE INDEX IF NOT EXISTS idx_expire_at ON authorization_spoon_users(expire_at)
86
+ """ # noqa: E501
87
+ )
88
+ await self.database_client.execute_cud(
89
+ """-- sql
90
+ CREATE INDEX IF NOT EXISTS idx_refresh_expire_at ON authorization_spoon_users(refresh_expire_at)
91
+ """ # noqa: E501
92
+ )
93
+ await self.database_client.execute_cud(
94
+ query="""-- sql
95
+ CREATE TABLE IF NOT EXISTS authorization_spoon_user_sessions (
96
+ id BINARY(16) PRIMARY KEY,
97
+ authorization_spoon_user_id VARCHAR(256) NOT NULL,
98
+ encoded_session_id BINARY(32) NOT NULL,
99
+ csrf_token VARCHAR(64),
100
+
101
+ expires_at BIGINT NOT NULL,
102
+ created_at BIGINT NOT NULL,
103
+ updated_at BIGINT NOT NULL,
104
+ FOREIGN KEY (authorization_spoon_user_id) REFERENCES authorization_spoon_users(id)
105
+ )
106
+ """ # noqa: E501
107
+ )
108
+ await self.database_client.execute_cud(
109
+ """-- sql
110
+ CREATE INDEX IF NOT EXISTS idx_encoded_session_id ON authorization_spoon_user_sessions(encoded_session_id);
111
+ """ # noqa: E501
112
+ )
113
+
114
+ await self.database_client.execute_cud(
115
+ """-- sql
116
+ CREATE INDEX IF NOT EXISTS idx_authorization_spoon_user_id ON authorization_spoon_user_sessions(authorization_spoon_user_id);
117
+ """ # noqa: E501
118
+ )
119
+
120
+ async def insert_token(self, token: TokenDataDto) -> bool:
121
+ epoch_seconds = int(time.time())
122
+ result = await self.database_client.execute_cud(
123
+ query=f"""-- sql
124
+ INSERT INTO authorization_spoon_users(
125
+ id,
126
+ access_token,
127
+ refresh_token,
128
+ expire_at,
129
+ refresh_expire_at,
130
+ scope,
131
+ token_type,
132
+ created_at,
133
+ updated_at
134
+ ) VALUES(
135
+ {self.database_client.placeholder("id")},
136
+ {self.database_client.placeholder("access_token")},
137
+ {self.database_client.placeholder("refresh_token")},
138
+ {self.database_client.placeholder("expire_at")},
139
+ {self.database_client.placeholder("refresh_expire_at")},
140
+ {self.database_client.placeholder("scope")},
141
+ {self.database_client.placeholder("token_type")},
142
+ {self.database_client.placeholder("created_at")},
143
+ {self.database_client.placeholder("updated_at")}
144
+ )
145
+ """,
146
+ args=token.model_dump()
147
+ | {"created_at": epoch_seconds, "updated_at": epoch_seconds},
148
+ )
149
+ return result == 1
150
+
151
+ async def update_token(self, token: TokenDataDto):
152
+ epoch_seconds = int(time.time())
153
+ result = await self.database_client.execute_cud(
154
+ query=f"""-- sql
155
+ UPDATE authorization_spoon_users
156
+ SET access_token = {self.database_client.placeholder("access_token")},
157
+ refresh_token = {self.database_client.placeholder("refresh_token")},
158
+ expire_at = {self.database_client.placeholder("expire_at")},
159
+ refresh_expire_at = {self.database_client.placeholder("expire_at")},
160
+ scope = {self.database_client.placeholder("scope")},
161
+ token_type = {self.database_client.placeholder("token_type")},
162
+ updated_at = {self.database_client.placeholder("updated_at")}
163
+ WHERE id = {self.database_client.placeholder("id")}
164
+ LIMIT 1
165
+ """, # noqa: E501
166
+ args=token.model_dump() | {"updated_at": epoch_seconds},
167
+ )
168
+ return result == 1
169
+
170
+ async def get_token_data(self, id: str) -> TokenDataDto | None:
171
+ return await self.database_client.execute_one(
172
+ query=f"""-- sql
173
+ SELECT
174
+ id,
175
+ access_token,
176
+ refresh_token,
177
+ expire_at,
178
+ refresh_expire_at,
179
+ scope,
180
+ token_type,
181
+ token_status
182
+ FROM authorization_spoon_users
183
+ WHERE id = {self.database_client.placeholder("id")}
184
+ LIMIT 1
185
+ """,
186
+ args={"id": id},
187
+ response_type=TokenDataDto,
188
+ )
189
+
190
+ async def get_token_data_by_session_id(
191
+ self, encoded_session_id: bytes
192
+ ) -> TokenDataDto | None:
193
+ return await self.database_client.execute_one(
194
+ query=f"""-- sql
195
+ SELECT
196
+ user.id as id,
197
+ access_token,
198
+ refresh_token,
199
+ expire_at,
200
+ refresh_expire_at,
201
+ scope,
202
+ token_type,
203
+ token_status,
204
+ csrf_token
205
+ FROM authorization_spoon_users as user
206
+ INNER JOIN authorization_spoon_user_sessions as session_tb
207
+ ON user.id = session_tb.authorization_spoon_user_id
208
+ WHERE session_tb.encoded_session_id = {self.database_client.placeholder("encoded_session_id")}
209
+ LIMIT 1
210
+ """, # noqa: E501
211
+ args={"encoded_session_id": encoded_session_id},
212
+ response_type=TokenDataDto,
213
+ )
214
+
215
+ async def insert_session(self, session_data: SessionDataDto) -> bool:
216
+ epoch_seconds = int(time.time())
217
+ result = await self.database_client.execute_cud(
218
+ query=f"""-- sql
219
+ INSERT INTO authorization_spoon_user_sessions(
220
+ id,
221
+ authorization_spoon_user_id,
222
+ encoded_session_id,
223
+ csrf_token,
224
+ expires_at,
225
+ created_at,
226
+ updated_at
227
+ ) VALUES(
228
+ {self.database_client.placeholder("id")},
229
+ {self.database_client.placeholder("authorization_spoon_user_id")},
230
+ {self.database_client.placeholder("encoded_session_id")},
231
+ {self.database_client.placeholder("csrf_token")},
232
+ {self.database_client.placeholder("expires_at")},
233
+ {self.database_client.placeholder("created_at")},
234
+ {self.database_client.placeholder("updated_at")}
235
+ )
236
+ """,
237
+ args=session_data.model_dump()
238
+ | {"created_at": epoch_seconds, "updated_at": epoch_seconds},
239
+ )
240
+ return result == 1
241
+
242
+ async def get_session_data(
243
+ self, encoded_session_id: bytes
244
+ ) -> SessionDataDto | None:
245
+ return await self.database_client.execute_one(
246
+ query=f"""-- sql
247
+ SELECT
248
+ id,
249
+ authorization_spoon_user_id,
250
+ encoded_session_id,
251
+ csrf_token,
252
+ expires_at
253
+ FROM authorization_spoon_user_sessions
254
+ WHERE encoded_session_id = {self.database_client.placeholder("encoded_session_id")}
255
+ """, # noqa: E501
256
+ args={"encoded_session_id": encoded_session_id},
257
+ response_type=SessionDataDto,
258
+ )
259
+
260
+ async def update_csrf_token(
261
+ self, encoded_session_id: bytes, csrf_token: bytes
262
+ ) -> bool:
263
+ epoch_seconds = int(time.time())
264
+ result = await self.database_client.execute_cud(
265
+ query=f"""-- sql
266
+ UPDATE authorization_spoon_user_sessions
267
+ SET csrf_token = {self.database_client.placeholder("csrf_token")},
268
+ updated_at = {self.database_client.placeholder("updated_at")}
269
+ WHERE encoded_session_id = {self.database_client.placeholder("encoded_session_id")}
270
+ LIMIt 1
271
+ """, # noqa: E501
272
+ args={
273
+ "encoded_session_id": encoded_session_id,
274
+ "csrf_token": csrf_token,
275
+ "updated_at": epoch_seconds,
276
+ },
277
+ )
278
+ return result == 1
@@ -0,0 +1,11 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class MySQLConfig:
6
+ host: str
7
+ port: int
8
+ user: str
9
+ password: str
10
+ database: str = "spoon_bot"
11
+ charset: str = "utf8mb4"
@@ -0,0 +1,23 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class TokenDataDto(BaseModel):
5
+ id: str
6
+ access_token: str
7
+ token_type: str
8
+ expire_at: int
9
+ refresh_token: str
10
+ refresh_expire_at: int
11
+ scope: str
12
+ token_status: str
13
+
14
+ encoded_session_id: bytes | None = None
15
+ csrf_token: bytes | None = None
16
+
17
+
18
+ class SessionDataDto(BaseModel):
19
+ id: bytes
20
+ authorization_spoon_user_id: str
21
+ encoded_session_id: bytes
22
+ csrf_token: bytes | None = None
23
+ expires_at: int
@@ -0,0 +1,7 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class UpdateResult:
6
+ inserted_count: int | None
7
+ updated_count: int | None