bafser-tgapi 1.0.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,34 @@
1
+ name: Publish Python 🐍 distribution 📦 to PyPI
2
+
3
+ on: [push]
4
+
5
+ jobs:
6
+ build:
7
+ name: >-
8
+ Publish Python 🐍 distribution 📦 to PyPI
9
+ if: startsWith(github.ref, 'refs/tags/v') # only publish to PyPI on tag pushes
10
+ runs-on: ubuntu-latest
11
+ environment:
12
+ name: pypi
13
+ url: https://pypi.org/p/bafser_tgapi
14
+ permissions:
15
+ id-token: write # IMPORTANT: mandatory for trusted publishing
16
+
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ with:
20
+ persist-credentials: false
21
+ - name: Set up Python
22
+ uses: actions/setup-python@v5
23
+ with:
24
+ python-version: "3.x"
25
+ - name: Install pypa/build
26
+ run: >-
27
+ python3 -m
28
+ pip install
29
+ build
30
+ --user
31
+ - name: Build a binary wheel and a source tarball
32
+ run: python3 -m build
33
+ - name: Publish distribution 📦 to PyPI
34
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ build/
4
+ dist/
5
+ test/db
6
+ test/logs
7
+ test/secret_key_jwt.txt
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 MixelTe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: bafser_tgapi
3
+ Version: 1.0.0
4
+ Summary: bafser tg api
5
+ Project-URL: Homepage, https://github.com/MixelTe/bafser_tgapi
6
+ Project-URL: Issues, https://github.com/MixelTe/bafser_tgapi/issues
7
+ Author: Mixel Te
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Requires-Python: ==3.12.*
13
+ Requires-Dist: bafser==2.2.11
14
+ Requires-Dist: requests
15
+ Description-Content-Type: text/markdown
16
+
17
+ # bafser tgapi
18
+
19
+
20
+ ## usage
21
+ init project: `bafser init_project`
22
+ set webhook: `bafser configure_webhook set`
23
+ delete webhook: `bafser configure_webhook delete`
24
+
25
+ main.py
26
+ ```py
27
+ import sys
28
+
29
+ from bafser import AppConfig, create_app
30
+ import bafser_tgapi as tgapi
31
+
32
+ from bot.bot import Bot
33
+ from scripts.init_db import init_db
34
+
35
+ app, run = create_app(__name__, AppConfig(DEV_MODE="dev" in sys.argv))
36
+
37
+ tgapi.setup(
38
+ config_path="config_dev.txt" if __name__ == "__main__" else "config.txt",
39
+ botCls=Bot,
40
+ import_folder="bot",
41
+ app=app,
42
+ )
43
+
44
+ run(False, init_db)
45
+
46
+ if __name__ == "__main__":
47
+ tgapi.run_long_polling()
48
+ else:
49
+ tgapi.set_webhook()
50
+
51
+ ```
52
+
53
+ init_db.py
54
+ ```py
55
+ from bafser import AppConfig
56
+ from sqlalchemy.orm import Session
57
+
58
+ from data.user import Roles, User
59
+
60
+
61
+ def init_db(db_sess: Session, config: AppConfig):
62
+ u = User.new(db_sess, 12345, False, "Admin", "", "username", "en")
63
+ u.add_role(u, Roles.admin)
64
+
65
+ db_sess.commit()
66
+
67
+ ```
68
+
69
+ data.user.py
70
+ ```py
71
+ from bafser_tgapi import TgUserBase
72
+
73
+ from data._roles import Roles
74
+
75
+
76
+ class User(TgUserBase):
77
+ _default_role = Roles.user
78
+
79
+ ```
80
+
81
+ data.msg.py
82
+ ```py
83
+ from bafser_tgapi import MsgBase
84
+
85
+ from data._tables import Tables
86
+
87
+
88
+ class Msg(MsgBase):
89
+ __tablename__ = Tables.Msg
90
+
91
+ ```
92
+
93
+ bot.py
94
+ ```py
95
+ from typing import override
96
+
97
+ from bafser import Log
98
+ import bafser_tgapi as tgapi
99
+ from sqlalchemy.orm import Session
100
+
101
+ from data.user import User
102
+
103
+
104
+ class Bot(tgapi.BotWithDB[User]):
105
+ @override
106
+ def get_user(self, db_sess: Session, sender: tgapi.User) -> User:
107
+ user = User.get_by_id_tg(db_sess, sender.id)
108
+ if user is None:
109
+ user = User.new_from_data(db_sess, sender)
110
+ if user.username != sender.username:
111
+ old_username = user.username
112
+ user.username = sender.username
113
+ Log.updated(user, user, [("username", old_username, user.username)])
114
+ return user
115
+ ```
@@ -0,0 +1,99 @@
1
+ # bafser tgapi
2
+
3
+
4
+ ## usage
5
+ init project: `bafser init_project`
6
+ set webhook: `bafser configure_webhook set`
7
+ delete webhook: `bafser configure_webhook delete`
8
+
9
+ main.py
10
+ ```py
11
+ import sys
12
+
13
+ from bafser import AppConfig, create_app
14
+ import bafser_tgapi as tgapi
15
+
16
+ from bot.bot import Bot
17
+ from scripts.init_db import init_db
18
+
19
+ app, run = create_app(__name__, AppConfig(DEV_MODE="dev" in sys.argv))
20
+
21
+ tgapi.setup(
22
+ config_path="config_dev.txt" if __name__ == "__main__" else "config.txt",
23
+ botCls=Bot,
24
+ import_folder="bot",
25
+ app=app,
26
+ )
27
+
28
+ run(False, init_db)
29
+
30
+ if __name__ == "__main__":
31
+ tgapi.run_long_polling()
32
+ else:
33
+ tgapi.set_webhook()
34
+
35
+ ```
36
+
37
+ init_db.py
38
+ ```py
39
+ from bafser import AppConfig
40
+ from sqlalchemy.orm import Session
41
+
42
+ from data.user import Roles, User
43
+
44
+
45
+ def init_db(db_sess: Session, config: AppConfig):
46
+ u = User.new(db_sess, 12345, False, "Admin", "", "username", "en")
47
+ u.add_role(u, Roles.admin)
48
+
49
+ db_sess.commit()
50
+
51
+ ```
52
+
53
+ data.user.py
54
+ ```py
55
+ from bafser_tgapi import TgUserBase
56
+
57
+ from data._roles import Roles
58
+
59
+
60
+ class User(TgUserBase):
61
+ _default_role = Roles.user
62
+
63
+ ```
64
+
65
+ data.msg.py
66
+ ```py
67
+ from bafser_tgapi import MsgBase
68
+
69
+ from data._tables import Tables
70
+
71
+
72
+ class Msg(MsgBase):
73
+ __tablename__ = Tables.Msg
74
+
75
+ ```
76
+
77
+ bot.py
78
+ ```py
79
+ from typing import override
80
+
81
+ from bafser import Log
82
+ import bafser_tgapi as tgapi
83
+ from sqlalchemy.orm import Session
84
+
85
+ from data.user import User
86
+
87
+
88
+ class Bot(tgapi.BotWithDB[User]):
89
+ @override
90
+ def get_user(self, db_sess: Session, sender: tgapi.User) -> User:
91
+ user = User.get_by_id_tg(db_sess, sender.id)
92
+ if user is None:
93
+ user = User.new_from_data(db_sess, sender)
94
+ if user.username != sender.username:
95
+ old_username = user.username
96
+ user.username = sender.username
97
+ Log.updated(user, user, [("username", old_username, user.username)])
98
+ return user
99
+ ```
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["hatchling >= 1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "bafser_tgapi"
7
+ version = "1.0.0"
8
+ authors = [
9
+ { name = "Mixel Te" },
10
+ ]
11
+ description = "bafser tg api"
12
+ readme = "README.md"
13
+ requires-python = "== 3.12.*"
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3.12",
16
+ "Operating System :: OS Independent",
17
+ ]
18
+ license = "MIT"
19
+ license-files = ["LICEN[CS]E*"]
20
+ dependencies = [
21
+ "bafser==2.2.11",
22
+ "requests",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/MixelTe/bafser_tgapi"
27
+ Issues = "https://github.com/MixelTe/bafser_tgapi/issues"
@@ -0,0 +1,7 @@
1
+ from .bot import Bot, BotCmdArgs
2
+ from .bot_with_db import BotWithDB
3
+ from .db.msg import MsgBase
4
+ from .db.user import TgUserBase
5
+ from .methods import *
6
+ from .types import *
7
+ from .utils import check_webhook_token, configure_webhook, get_bot_name, get_url, process_update, run_long_polling, set_webhook, setup
@@ -0,0 +1,312 @@
1
+ import re
2
+ from typing import Callable, Iterable, Self, Type, TypeVar, cast
3
+
4
+ from bafser import ParametrizedLogger, add_file_logger
5
+ from typing_extensions import Protocol
6
+
7
+ from .methods import *
8
+ from .types import *
9
+ from .utils import get_bot_name
10
+
11
+ T = TypeVar("T", bound="Bot")
12
+ type tcmd_dsc_text = str
13
+ type tcmd_dsc_usage = str
14
+ re_param = re.compile("<[a-z]+>", re.IGNORECASE)
15
+
16
+
17
+ class Bot:
18
+ class tcmd_fn[T: "Bot"](Protocol):
19
+ def __call__(self, bot: T, args: "BotCmdArgs", **kwargs: str) -> str | None:
20
+ ...
21
+ type tcmd_dsc = tcmd_dsc_text | tuple[tcmd_dsc_text, tcmd_dsc_usage | list[tcmd_dsc_usage]]
22
+ type tcallback[T: "Bot"] = Callable[[T], None]
23
+ _tcommand = tuple[tcmd_fn[Self], tuple[tcmd_dsc | None, tcmd_dsc | None]]
24
+
25
+ update: Update
26
+ message: Message | None = None
27
+ callback_query: CallbackQuery | None = None
28
+ inline_query: InlineQuery | None = None
29
+ chosen_inline_result: ChosenInlineResult | None = None
30
+
31
+ _commands: dict[str, _tcommand] = {}
32
+ _callback: dict[Callable[..., Any], tcallback[Self]] = {}
33
+ _sender: User | None = None
34
+ chat: Chat | None = None
35
+ TextWrongCommand = "Wrong command"
36
+ TextCmdForAdmin = "Эта команда только для админов"
37
+ logger: "BotLogger"
38
+
39
+ @property
40
+ def sender(self):
41
+ return self._sender
42
+
43
+ @sender.setter
44
+ def sender(self, value: User | None):
45
+ self._sender = value
46
+ self.logger.user = value
47
+
48
+ def init(self):
49
+ fmt = "%(asctime)s;%(levelname)s;%(module)s;%(uid)-10s;%(uname)-15s;%(cmd)-15s;%(message)s"
50
+ self.logger = BotLogger(add_file_logger("logs/bot.csv", "bot", fmt, ["uid", "uname", "cmd"]))
51
+
52
+ def get_desc(v: Bot.tcmd_dsc):
53
+ return v if isinstance(v, str) else v[0]
54
+
55
+ for_all: list[BotCommand] = []
56
+ for_adm: list[BotCommand] = []
57
+ for cmd in self._commands.keys():
58
+ pub = self._commands[cmd][1][0]
59
+ adm = self._commands[cmd][1][1]
60
+ if not adm:
61
+ adm = pub
62
+ cmd = re_param.sub("", cmd)
63
+ if pub:
64
+ for_all.append(BotCommand(command=cmd, description=get_desc(pub)))
65
+ if adm:
66
+ for_adm.append(BotCommand(command=cmd, description=get_desc(adm)))
67
+
68
+ setMyCommands(for_all)
69
+ setMyCommands(for_adm, BotCommandScope.all_chat_administrators())
70
+
71
+ def get_my_commands(self, for_admin: bool = False):
72
+ i = 1 if for_admin else 0
73
+ r: list[tuple[str, Bot.tcmd_dsc]] = []
74
+ for key in self._commands.keys():
75
+ v = self._commands[key][1][i]
76
+ if v:
77
+ r.append((key, v))
78
+ return r
79
+
80
+ @classmethod
81
+ def add_command(cls: Type[T], command: str, *, desc: tcmd_dsc | None = None, desc_adm: tcmd_dsc | None = None):
82
+ def wrapper(fn: Bot.tcmd_fn[T]):
83
+ if "<" in command:
84
+ parts: list[tuple[str, bool]] = []
85
+ param = None
86
+ for ch in command:
87
+ if ch == "<":
88
+ param = ""
89
+ elif ch == ">":
90
+ if param:
91
+ parts.append((param, True))
92
+ param = None
93
+ elif param is not None:
94
+ param += ch
95
+ else:
96
+ if not parts or parts[-1][1]:
97
+ parts.append(("", False))
98
+ parts[-1] = (parts[-1][0] + ch, False)
99
+ res = ""
100
+ varnames: list[str] = []
101
+ for part, isvar in parts:
102
+ if isvar:
103
+ res += "(.*)"
104
+ varnames.append(part)
105
+ else:
106
+ res += part
107
+ reg = re.compile(res, re.IGNORECASE)
108
+ fn.regex = reg # type: ignore
109
+
110
+ def comparer(cmd: str):
111
+ regex = cast(re.Pattern[str], fn.regex) # type: ignore
112
+ m = regex.match(cmd)
113
+ if not m or len(m.groups()) != len(varnames):
114
+ return None
115
+ return dict(zip(varnames, m.groups()))
116
+ fn.comparer = comparer # type: ignore
117
+
118
+ cls._commands[command] = (fn, (desc, desc_adm))
119
+ return fn
120
+ return wrapper
121
+
122
+ @classmethod
123
+ def cmd_for_admin(cls: Type[T], fn: tcmd_fn[T]):
124
+ def wrapped(bot: T, args: BotCmdArgs, **kwargs: str):
125
+ if bot.chat is None or bot.sender is None:
126
+ return "403(500!)"
127
+ ok, r = getChatMember(bot.chat.id, bot.sender.id)
128
+ if not ok:
129
+ return "403(500)"
130
+ if r.status != "creator" and r.status != "administrator":
131
+ return cls.TextCmdForAdmin
132
+ return fn(bot, args, **kwargs)
133
+ return wrapped
134
+
135
+ def _process_update(self, update: Update):
136
+ self.update = update
137
+ self.message = Undefined.default(update.message)
138
+ self.callback_query = Undefined.default(update.callback_query)
139
+ self.inline_query = Undefined.default(update.inline_query)
140
+ self.chosen_inline_result = Undefined.default(update.chosen_inline_result)
141
+ self.sender = None
142
+ self.chat = None
143
+ self.logger._reset()
144
+ if self.message and self.message.text != "":
145
+ self.sender = Undefined.default(self.message.sender)
146
+ self.chat = self.message.chat
147
+ self._on_message()
148
+ if self.callback_query:
149
+ self.sender = self.callback_query.sender
150
+ self.chat = self.callback_query.message.chat if Undefined.defined(self.callback_query.message) else None
151
+ self._on_callback_query()
152
+ if self.inline_query:
153
+ self.sender = self.inline_query.sender
154
+ self._call_callback(self.on_inline_query)
155
+ if self.chosen_inline_result:
156
+ self.sender = self.chosen_inline_result.sender
157
+ self._call_callback(self.on_chosen_inline_result)
158
+
159
+ def _call_callback(self, key: Callable[..., Any]):
160
+ fn = self._callback.get(key)
161
+ if fn:
162
+ fn(self)
163
+
164
+ @classmethod
165
+ def on_message(cls: Type[T], fn: tcallback[T]):
166
+ cls._callback[cls.on_message] = fn
167
+ return fn
168
+
169
+ @classmethod
170
+ def on_inline_query(cls: Type[T], fn: tcallback[T]):
171
+ cls._callback[cls.on_inline_query] = fn
172
+ return fn
173
+
174
+ @classmethod
175
+ def on_chosen_inline_result(cls: Type[T], fn: tcallback[T]):
176
+ cls._callback[cls.on_chosen_inline_result] = fn
177
+ return fn
178
+
179
+ def _on_message(self):
180
+ assert self.message
181
+ if self.message.text.startswith("/"):
182
+ r = self._on_command(self.message.text[1:])
183
+ if r:
184
+ if isinstance(r, str):
185
+ self.sendMessage(r)
186
+ elif r is False and self.message.chat.type == "private":
187
+ self.sendMessage(self.TextWrongCommand)
188
+ else:
189
+ self._call_callback(self.on_message)
190
+
191
+ def _on_command(self, input: str):
192
+ args = BotCmdArgs(input)
193
+ if args.command == "":
194
+ return False
195
+ cmd, kwargs = self._find_command(args.command)
196
+ if not cmd:
197
+ return False
198
+ fn, _ = cmd
199
+ self.logger.cmd = args.command
200
+ r = fn(self, args, **kwargs)
201
+ if r:
202
+ return r
203
+ return True
204
+
205
+ def _find_command(self, cmd: str) -> tuple[_tcommand | None, dict[str, str]]:
206
+ c = self._commands.get(cmd, None)
207
+ if c:
208
+ return c, {}
209
+ for c in self._commands.values():
210
+ if hasattr(c[0], "comparer"):
211
+ kwargs = cast(dict[str, str] | None, c[0].comparer(cmd)) # type: ignore
212
+ if kwargs:
213
+ return c, kwargs
214
+ return None, {}
215
+
216
+ def _on_callback_query(self):
217
+ assert self.callback_query
218
+ r = self._on_command(Undefined.default(self.callback_query.data, ""))
219
+ if r:
220
+ self.answerCallbackQuery(r if isinstance(r, str) else None)
221
+ else:
222
+ self.answerCallbackQuery(self.TextWrongCommand)
223
+
224
+ def sendMessage(self, text: str, message_thread_id: int | None = None, use_markdown: bool = False,
225
+ reply_markup: InlineKeyboardMarkup | None = None, reply_parameters: ReplyParameters | None = None,
226
+ entities: List[MessageEntity] | None = None, chat_id: str | int | None = None):
227
+ if chat_id is None:
228
+ if self.message:
229
+ chat_id = self.message.chat.id
230
+ if message_thread_id is None and self.message.is_topic_message and Undefined.defined(self.message.message_thread_id):
231
+ message_thread_id = self.message.message_thread_id
232
+ elif self.callback_query and Undefined.defined(self.callback_query.message):
233
+ chat_id = self.callback_query.message.chat.id
234
+ if message_thread_id is None and Undefined.defined(self.callback_query.message.message_thread_id):
235
+ message_thread_id = self.callback_query.message.message_thread_id
236
+ else:
237
+ raise Exception("tgapi: cant send message without chat id")
238
+ return sendMessage(chat_id, text, message_thread_id, use_markdown, reply_markup, reply_parameters, entities)
239
+
240
+ def answerCallbackQuery(self, text: str | None = None, show_alert: bool = False, url: str | None = None, cache_time: int = 0):
241
+ if self.callback_query is None:
242
+ raise Exception("tgapi: Bot.answerCallbackQuery is avaible only inside on_callback_query")
243
+ return answerCallbackQuery(self.callback_query.id, text, show_alert, url, cache_time)
244
+
245
+ def answerInlineQuery(self, results: list[InlineQueryResult], cache_time: int = 300, is_personal: bool = False, next_offset: str | None = None):
246
+ if self.inline_query is None:
247
+ raise Exception("tgapi: Bot.answerInlineQuery is avaible only inside on_inline_query")
248
+ return answerInlineQuery(self.inline_query.id, results, cache_time, is_personal, next_offset)
249
+
250
+
251
+ @Bot.on_inline_query
252
+ def _(bot: Bot):
253
+ bot.answerInlineQuery([])
254
+
255
+
256
+ class BotLogger(ParametrizedLogger):
257
+ user: User | None = None
258
+ cmd = ""
259
+
260
+ def _reset(self):
261
+ self.user = None
262
+ self.cmd = ""
263
+
264
+ def _get_args(self) -> dict[str, str | int]:
265
+ return {
266
+ "uid": self.user.id if self.user else -1,
267
+ "uname": self.user.username if self.user else "",
268
+ "cmd": self.cmd
269
+ }
270
+
271
+
272
+ class BotCmdArgs(Iterable[str]):
273
+ input: str
274
+ args: list[str]
275
+ raw_args = ""
276
+ raw_argsI = -1
277
+ command = ""
278
+
279
+ def __init__(self, input: str):
280
+ self.input = input
281
+ self.args = [str.strip(v) for v in input.split()]
282
+
283
+ if len(self.args) == 0:
284
+ return
285
+
286
+ command = self.args[0]
287
+ mention = command.find("@")
288
+ if mention > 0:
289
+ bot_name = command[mention:]
290
+ if bot_name != get_bot_name():
291
+ return
292
+ command = command[:mention]
293
+ self.command = command
294
+
295
+ self.args = self.args[1:]
296
+
297
+ i = input.find(" ")
298
+ if i > 0:
299
+ while i < len(input) and input[i] == " ":
300
+ i += 1
301
+ self.raw_argsI = MessageEntity.len(input[:i])
302
+ self.raw_args = input[i:]
303
+
304
+ def __getitem__(self, i: int):
305
+ return self.args[i]
306
+
307
+ def __len__(self):
308
+ return len(self.args)
309
+
310
+ def __iter__(self):
311
+ for arg in self.args:
312
+ yield arg
@@ -0,0 +1,27 @@
1
+ from typing import Any, Type, TypeVar
2
+
3
+ from bafser import db_session
4
+ from sqlalchemy.orm import Session
5
+
6
+ from .bot import Bot, BotCmdArgs
7
+ from .types import User
8
+
9
+ T = TypeVar("T", bound="BotWithDB[Any]", covariant=True)
10
+
11
+
12
+ class BotWithDB[TUser](Bot):
13
+ db_sess: Session | None = None
14
+ user: TUser | None = None
15
+
16
+ def get_user(self, db_sess: Session, sender: User) -> TUser:
17
+ raise Exception("tgapi: Method BotWithDB.get_user must be implemented in subclass")
18
+
19
+ @classmethod
20
+ def cmd_connect_db(cls: Type[T], fn: Bot.tcmd_fn[T]):
21
+ def wrapped(bot: T, args: BotCmdArgs, **kwargs: str):
22
+ assert bot.sender
23
+ with db_session.create_session() as db_sess:
24
+ bot.db_sess = db_sess
25
+ bot.user = bot.get_user(db_sess, bot.sender)
26
+ return fn(bot, args, **kwargs)
27
+ return wrapped