bemyai 0.1.8__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.
bemyai-0.1.8/PKG-INFO ADDED
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.3
2
+ Name: bemyai
3
+ Version: 0.1.8
4
+ Summary: Be My AI API
5
+ Author: Alexey
6
+ Author-email: aleks-samos@yandex.ru
7
+ Requires-Python: >3.8
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.9
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Dist: aiofiles (>=23.2.1,<24.0.0)
15
+ Requires-Dist: aiohttp (>=3.9.1,<4.0.0)
16
+ Requires-Dist: loguru (>=0.7.2,<0.8.0)
17
+ Requires-Dist: pillow (>=10.2.0,<11.0.0)
18
+ Requires-Dist: pydantic (>=2.5.3,<3.0.0)
19
+ Description-Content-Type: text/markdown
20
+
21
+ ### Be My AI
22
+
23
+ #### Console
24
+ ```bash
25
+ bm login -l ru "your_email@domain.tld "1yourpassword3"
26
+ bm -l ru recognize "path/to/photo.jpg"
27
+ ```
28
+
29
+ ##### Example
30
+ ```
31
+ # building
32
+ E:\bemyai>poetry build
33
+
34
+ # installing
35
+ E:\bemyai>pip install .\dist\bemyai-0.1.8-py3-none-any.whl
36
+
37
+ # I'm already logged in
38
+
39
+ # Recognizing...
40
+ E:\bemyai>bm -l ru recognize E:\aribook1_b.jpg
41
+ На фотографии изображена обложка книги с изображением Арианы Гранде. В верхней части обложки написано "100% НЕОФИЦИАЛЬНО" на фоне розового прямоугольника. Ниже следует текст:
42
+
43
+ Ариана — не просто очередная эстрадная принцесса, она —
44
+ одна из крупнейших мировых звезд
45
+ и самая популярная женщина в Инстаграме.
46
+
47
+ Далее идет желтый прямоугольник с текстом:
48
+
49
+ Любой альбом и любой сингл всегда занимает верхние строки в чартах,
50
+ Ариана является иконой стиля, достойной защитницей прав женщин,
51
+ добровольцем, актрисой и выжившей жертвой теракта.
52
+ В своей жизни она ставит на первое место семью, включая
53
+ семерых спасенных ею собак!
54
+
55
+ С помощью четырех октав Ариана, вероятно, может считаться пушкой
56
+ в мире поп-музыке.
57
+ И если кто-то думает
58
+ что способен приблизиться
59
+ к ее коронованной особе,
60
+ то пусть еще раз послушает
61
+ песню Арианы Гранде
62
+ Thank You, Next!
63
+
64
+ В нижней части обложки на розовом фоне написано "100% ИДОЛ". Также присутствует штрих-код с ISBN номером 978-5-04-114282-3 и ссылка на веб-сайт www.vk.com/eskmo_kids.
65
+
66
+ # You can ask GPT about this image in the chat
67
+ # Type Q for exit
68
+ # Or pass `-ni` to return the response and exit
69
+ # in this case, you can ask a question with an ask command
70
+ # e.g.
71
+ # bm ask "Your question"
72
+ E:\bemyai>
73
+ ```
74
+
75
+ #### Python
76
+ ```python
77
+ import sys
78
+ import asyncio
79
+ from bemyai import BeMyAI
80
+ from loguru import logger
81
+ logger.remove()
82
+ logger.add(sys.stderr, format="{message}", level="INFO")
83
+
84
+ async def main():
85
+ # you can specify another language,
86
+ # for example, Russian
87
+
88
+ # get token
89
+ bm = BeMyAI(response_language="en")
90
+ result = await bm.login(
91
+ "test@example.com",
92
+ "yourpassword"
93
+ )
94
+ # and save result.token for future requests
95
+
96
+ # authorization by token
97
+ bm = BeMyAI("your_token", response_language="en")
98
+
99
+ # recognize photo
100
+ sid, chat_id = await bm.take_photo("pic.jpg")
101
+ for i in range(2):
102
+ async for bm_response in bm.receive_messages(sid):
103
+ message = bm_response
104
+ if message.user:
105
+ continue
106
+ print( message.data )
107
+ if i == 0:
108
+ # We ask a question
109
+ sid, chat_id, _message = await bm.send_text_message(
110
+ chat_id,
111
+ "Describe it in more detail"
112
+ )
113
+
114
+ if __name__ == "__main__":
115
+ asyncio.run(main())
116
+
117
+ ```
118
+
119
+ ##### Example output
120
+
121
+ I saved the python example,
122
+ specified my token and the Russian language,
123
+ specified the path to the image and got this result:
124
+
125
+ ```
126
+ dl folder already exists
127
+ get app user config from internet
128
+ recognizing new photo: JPEG, 1215x2160, RGB
129
+ create new chat
130
+ resizing image
131
+ image processed: jpeg, 1125x2000
132
+ requested upload image config
133
+ get app user config from cache
134
+ Starting upload image to Amazon
135
+ Uploaded successfully
136
+ removeing processed image
137
+ upload image finished
138
+ Got new message
139
+ Got new message
140
+ На фотографии изображена книга с названием "Ариана Гранде. Главная книга фаната". На обложке множество фотографий Арианы Гранде в разных образах. В верхнем левом углу написано "100% неофициально". В нижнем правом углу обложки есть маркировка "18+".
141
+ Got new message
142
+ Got new message
143
+ На обложке книги изображено семь различных фотографий певицы Арианы Гранде. Она показана в разных нарядах и прическах, в том числе с её знаменитым высоким хвостом. На одной из фотографий она в большом белом банте на голове. На другой - в черном топе и с кепкой. Есть изображение, где она поет в микрофон, закрыв глаза и подняв голову вверх. Цвета обложки - розовый, белый и черный. На обложке также присутствуют розовые и белые геометрические элементы, а также надписи розового цвета.
144
+
145
+ E:\bemyai>
146
+ ```
147
+
bemyai-0.1.8/README.md ADDED
@@ -0,0 +1,126 @@
1
+ ### Be My AI
2
+
3
+ #### Console
4
+ ```bash
5
+ bm login -l ru "your_email@domain.tld "1yourpassword3"
6
+ bm -l ru recognize "path/to/photo.jpg"
7
+ ```
8
+
9
+ ##### Example
10
+ ```
11
+ # building
12
+ E:\bemyai>poetry build
13
+
14
+ # installing
15
+ E:\bemyai>pip install .\dist\bemyai-0.1.8-py3-none-any.whl
16
+
17
+ # I'm already logged in
18
+
19
+ # Recognizing...
20
+ E:\bemyai>bm -l ru recognize E:\aribook1_b.jpg
21
+ На фотографии изображена обложка книги с изображением Арианы Гранде. В верхней части обложки написано "100% НЕОФИЦИАЛЬНО" на фоне розового прямоугольника. Ниже следует текст:
22
+
23
+ Ариана — не просто очередная эстрадная принцесса, она —
24
+ одна из крупнейших мировых звезд
25
+ и самая популярная женщина в Инстаграме.
26
+
27
+ Далее идет желтый прямоугольник с текстом:
28
+
29
+ Любой альбом и любой сингл всегда занимает верхние строки в чартах,
30
+ Ариана является иконой стиля, достойной защитницей прав женщин,
31
+ добровольцем, актрисой и выжившей жертвой теракта.
32
+ В своей жизни она ставит на первое место семью, включая
33
+ семерых спасенных ею собак!
34
+
35
+ С помощью четырех октав Ариана, вероятно, может считаться пушкой
36
+ в мире поп-музыке.
37
+ И если кто-то думает
38
+ что способен приблизиться
39
+ к ее коронованной особе,
40
+ то пусть еще раз послушает
41
+ песню Арианы Гранде
42
+ Thank You, Next!
43
+
44
+ В нижней части обложки на розовом фоне написано "100% ИДОЛ". Также присутствует штрих-код с ISBN номером 978-5-04-114282-3 и ссылка на веб-сайт www.vk.com/eskmo_kids.
45
+
46
+ # You can ask GPT about this image in the chat
47
+ # Type Q for exit
48
+ # Or pass `-ni` to return the response and exit
49
+ # in this case, you can ask a question with an ask command
50
+ # e.g.
51
+ # bm ask "Your question"
52
+ E:\bemyai>
53
+ ```
54
+
55
+ #### Python
56
+ ```python
57
+ import sys
58
+ import asyncio
59
+ from bemyai import BeMyAI
60
+ from loguru import logger
61
+ logger.remove()
62
+ logger.add(sys.stderr, format="{message}", level="INFO")
63
+
64
+ async def main():
65
+ # you can specify another language,
66
+ # for example, Russian
67
+
68
+ # get token
69
+ bm = BeMyAI(response_language="en")
70
+ result = await bm.login(
71
+ "test@example.com",
72
+ "yourpassword"
73
+ )
74
+ # and save result.token for future requests
75
+
76
+ # authorization by token
77
+ bm = BeMyAI("your_token", response_language="en")
78
+
79
+ # recognize photo
80
+ sid, chat_id = await bm.take_photo("pic.jpg")
81
+ for i in range(2):
82
+ async for bm_response in bm.receive_messages(sid):
83
+ message = bm_response
84
+ if message.user:
85
+ continue
86
+ print( message.data )
87
+ if i == 0:
88
+ # We ask a question
89
+ sid, chat_id, _message = await bm.send_text_message(
90
+ chat_id,
91
+ "Describe it in more detail"
92
+ )
93
+
94
+ if __name__ == "__main__":
95
+ asyncio.run(main())
96
+
97
+ ```
98
+
99
+ ##### Example output
100
+
101
+ I saved the python example,
102
+ specified my token and the Russian language,
103
+ specified the path to the image and got this result:
104
+
105
+ ```
106
+ dl folder already exists
107
+ get app user config from internet
108
+ recognizing new photo: JPEG, 1215x2160, RGB
109
+ create new chat
110
+ resizing image
111
+ image processed: jpeg, 1125x2000
112
+ requested upload image config
113
+ get app user config from cache
114
+ Starting upload image to Amazon
115
+ Uploaded successfully
116
+ removeing processed image
117
+ upload image finished
118
+ Got new message
119
+ Got new message
120
+ На фотографии изображена книга с названием "Ариана Гранде. Главная книга фаната". На обложке множество фотографий Арианы Гранде в разных образах. В верхнем левом углу написано "100% неофициально". В нижнем правом углу обложки есть маркировка "18+".
121
+ Got new message
122
+ Got new message
123
+ На обложке книги изображено семь различных фотографий певицы Арианы Гранде. Она показана в разных нарядах и прическах, в том числе с её знаменитым высоким хвостом. На одной из фотографий она в большом белом банте на голове. На другой - в черном топе и с кепкой. Есть изображение, где она поет в микрофон, закрыв глаза и подняв голову вверх. Цвета обложки - розовый, белый и черный. На обложке также присутствуют розовые и белые геометрические элементы, а также надписи розового цвета.
124
+
125
+ E:\bemyai>
126
+ ```
@@ -0,0 +1,534 @@
1
+ """
2
+ unofficial API BeMyAI from Android app
3
+ """
4
+ from .langs import LANGS, LOCALES
5
+ from .schema import (
6
+ LoginResponseModel,
7
+ AppConfigUserModel,
8
+ ChatModel,
9
+ ChatConfigModel,
10
+ ChatUploadImageConfigModel,
11
+ ChatMessagesModel,
12
+ )
13
+ import os
14
+ import asyncio
15
+ from functools import partial
16
+ import json
17
+ import shutil
18
+ from uuid import uuid4
19
+ from datetime import datetime, timezone, timedelta
20
+ import os.path
21
+ from math import floor
22
+ from typing import AsyncIterator, Optional
23
+ import aiohttp
24
+ import aiofiles # type: ignore
25
+ import aiofiles.os # type: ignore
26
+ from loguru import logger # type: ignore
27
+ from PIL import Image
28
+
29
+ my_debug = os.environ.get("my_debug", None) is not None
30
+ if my_debug:
31
+ os.environ["HTTP_PROXY"] = "http://127.0.0.1:8888"
32
+ os.environ["HTTPS_PROXY"] = "http://127.0.0.1:8888"
33
+
34
+
35
+ def get_image_info(filename: str) -> tuple[Optional[str], tuple[int, int], str]:
36
+ with Image.open(filename) as im:
37
+ return (im.format, im.size, im.mode)
38
+
39
+
40
+ def compute_image_size(width: int, height: int, max_dimension=2000) -> tuple[int, int]:
41
+ width_changed, height_changed = (0, 0)
42
+ if (width <= max_dimension and height <= max_dimension) or (width == height):
43
+ return (width, height)
44
+ max_number: int = max(width, height)
45
+ divided_number: int = max_number / max_dimension
46
+ min_number: int = min(width, height)
47
+ min_side: int = floor(min_number / divided_number)
48
+ if width > height:
49
+ width_changed = max_dimension
50
+ height_changed = min_side
51
+ else:
52
+ width_changed = min_side
53
+ height_changed = max_dimension
54
+ return (width_changed, height_changed)
55
+
56
+
57
+ def process_image(
58
+ path_to_image: str,
59
+ path_to_processed_image: str,
60
+ max_dimension: int = 2000,
61
+ format: str = "JPEG",
62
+ optimize: bool = True,
63
+ jpeg_compress: int = 80,
64
+ ) -> tuple[int, int]:
65
+ """
66
+ Process the image for API requirements.
67
+ processed image will be saved in `path_to_processed_image`
68
+ as new file in the specified format (default JPEG) with compression 80% by default.
69
+ Returns the resolution of the image.
70
+ """
71
+ logger.debug("start image process")
72
+ with open(path_to_image, "rb") as fp:
73
+ im = Image.open(fp)
74
+ width, height = im.size
75
+ if im.mode != "RGB":
76
+ logger.info(f"converting from {im.mode} to RGB mode")
77
+ im = im.convert("RGB")
78
+ if width > max_dimension or height > max_dimension:
79
+ logger.info("resizing image")
80
+ im.thumbnail(
81
+ compute_image_size(
82
+ width=width, height=height, max_dimension=max_dimension
83
+ )
84
+ )
85
+ im.save(
86
+ path_to_processed_image,
87
+ format.upper(),
88
+ optimize=optimize,
89
+ quality=jpeg_compress,
90
+ )
91
+ width, height = im.size
92
+ logger.info("image processed: %s, %dx%d" % (format, width, height))
93
+ im.close()
94
+ assert os.path.isfile(path_to_processed_image), "The processed file not found"
95
+ return (width, height)
96
+
97
+
98
+ dl_folder = os.path.join(os.path.abspath("."), "downloads")
99
+
100
+
101
+ class BeMyAIError(Exception):
102
+ msg = None
103
+ response = None
104
+
105
+ def __init__(self, msg, response=None):
106
+ self.response = response
107
+ super().__init__(msg)
108
+
109
+
110
+ class EmailVerificationRequired(BeMyAIError):
111
+ msg = None
112
+ response = None
113
+
114
+ def __init__(self, msg="email verification required", response=None):
115
+ self.response = response
116
+ super().__init__(msg, response)
117
+
118
+
119
+ class PasswordChangeRequired(BeMyAIError):
120
+ msg = None
121
+ response = None
122
+
123
+ def __init__(self, msg="password change required", response=None):
124
+ self.response = response
125
+ super().__init__(msg, response)
126
+
127
+
128
+ class BeMyAI:
129
+ def __init__(
130
+ self, token: str = "", response_language="en-US", trust_env: bool = True
131
+ ):
132
+ if not os.path.isdir(dl_folder):
133
+ os.mkdir(dl_folder)
134
+ logger.info("created dl folder")
135
+ else:
136
+ logger.info("dl folder already exists")
137
+ self.token = token
138
+ if response_language in LANGS or response_language in LOCALES:
139
+ self.response_language = response_language
140
+ else:
141
+ raise ValueError(
142
+ "the language must be a two-letter code or a country separated by a hyphen"
143
+ )
144
+ self.trust_env: bool = trust_env
145
+ self.bemyeyes_app_secret = (
146
+ "55519e815ff7b09ab971de5564baa282eca53af1eb528385fb98a34f2010e8c7"
147
+ )
148
+ self.User_Agent = "okhttp/3.14.9"
149
+ self.gateway_url = "https://gateway.bemyeyes.com/api/v2/"
150
+ self.lp_delimeter = chr(30)
151
+ self.api_url = "https://api.bemyeyes.com/api/v2/"
152
+ self.sid = ""
153
+ self.extra = {
154
+ "device_type": "android",
155
+ "screen_reader_enabled": True,
156
+ "content_size": "1.15",
157
+ "accesibility_content_size_enabled": False,
158
+ }
159
+ self.app_config_user_cache: Optional[AppConfigUserModel] = None
160
+
161
+ @staticmethod
162
+ def get_error_messages(r):
163
+ error_messages = []
164
+ for m in r.items():
165
+ k, v = m
166
+ if isinstance(v, list):
167
+ v = ", ".join(v)
168
+ elif not isinstance(v, str):
169
+ v = str(v)
170
+ error_messages.append(": ".join([k, v]))
171
+ if len(error_messages) == 0:
172
+ return r
173
+ return "\n".join(error_messages)
174
+
175
+ @property
176
+ def headers(self):
177
+ h = {
178
+ "User-Agent": self.User_Agent,
179
+ "bemyeyes-app-secret": self.bemyeyes_app_secret,
180
+ "Accept-Language": self.response_language,
181
+ }
182
+ if self.token and len(self.token) > 3:
183
+ h.update(Authorization="Token " + self.token)
184
+ return h
185
+
186
+ @property
187
+ def terms_accepted_at(self) -> str:
188
+ "For signup"
189
+ shanghai_offset = timedelta(hours=8)
190
+ utc_now = datetime.now(timezone.utc)
191
+ shanghai_now = utc_now.astimezone(timezone(shanghai_offset))
192
+ logger.debug("date for signup: " + str(shanghai_now))
193
+ return shanghai_now.isoformat()
194
+
195
+ async def request(
196
+ self, method, url, params=None, data=None, json=None, headers=None
197
+ ):
198
+ """
199
+ Make a request to the API.
200
+ In the response from the gateway server you Get the text,
201
+ since the server returns data in different formats and with different headers,
202
+ and from the api server get a dictionary,
203
+ since all interaction is strictly in JSON format with the correct headers
204
+ Thanks to Django REST framework on their side.
205
+ """
206
+ j = None
207
+ if headers is None:
208
+ headers = self.headers
209
+ logger.debug(f"making {method} request to {url}...")
210
+ async with aiohttp.ClientSession(
211
+ trust_env=self.trust_env, raise_for_status=False, headers=headers
212
+ ) as session:
213
+ async with session.request(
214
+ method=method, url=url, params=params, data=data, json=json
215
+ ) as resp:
216
+ if "json" not in resp.headers.get("Content-Type").lower():
217
+ if resp.ok:
218
+ return await resp.text()
219
+ else:
220
+ raise BeMyAIError(message=resp.text, response=resp)
221
+ else:
222
+ if resp.ok:
223
+ return await resp.json()
224
+ else:
225
+ j = await resp.json()
226
+ if j.get("code", 0) != 1:
227
+ raise BeMyAIError(
228
+ msg=self.get_error_messages(j), response=resp
229
+ )
230
+ else:
231
+ logger.debug("There will be a new session next time")
232
+ await self.get_chat_config()
233
+ await self.authinticate(self.sid)
234
+ await self.enable_chat(self.sid)
235
+ return await self.request(
236
+ method=method,
237
+ url=url,
238
+ params=params,
239
+ data=data,
240
+ json=json,
241
+ headers=headers,
242
+ )
243
+
244
+ async def refresh_token(self) -> LoginResponseModel:
245
+ "Check the user (for example, to find out if the email address is verified)"
246
+ resp = await self.request(
247
+ "POST",
248
+ self.api_url + "auth/refresh-token",
249
+ json={"timezone": "Asia/Shanghai", "extra": self.extra},
250
+ )
251
+ result = LoginResponseModel.model_validate(resp)
252
+ self.token = result.token
253
+ return result
254
+
255
+ async def resend_verify_email(self) -> None:
256
+ "Send a confirmation email again (only after signup)"
257
+ result = await self.refresh_token()
258
+ if not result.email_verification_required:
259
+ raise BeMyAIError("The email has already been verified")
260
+ await self.request("POST", self.api_url + "auth/resend-verify-email")
261
+
262
+ async def send_reset_password(self, email: str) -> None:
263
+ "Recover a forgotten password. A link to the password change form will be sent to your email"
264
+ self.token = ""
265
+ await self.request(
266
+ "POST", self.api_url + "auth/send-reset-password", json={"email": email}
267
+ )
268
+
269
+ async def signup(
270
+ self, first_name: str, last_name: str, email: str, password: str
271
+ ) -> LoginResponseModel:
272
+ "Signup by mail and password (auth/signup-email) and get user object"
273
+ self.token = ""
274
+ resp = await self.request(
275
+ "POST",
276
+ self.api_url + "auth/signup-email",
277
+ json={
278
+ "first_name": first_name,
279
+ "last_name": last_name,
280
+ "email": email,
281
+ "password": password,
282
+ "user_type": "bvi",
283
+ "timezone": "Asia/Shanghai",
284
+ "terms_accepted_at": self.terms_accepted_at,
285
+ "extra": self.extra,
286
+ },
287
+ )
288
+ result = LoginResponseModel.model_validate(resp)
289
+ self.token = result.token
290
+ return result
291
+
292
+ async def login(self, email, password):
293
+ "Login by mail and password (auth/login-email) and get token"
294
+ self.token = ""
295
+ resp = await self.request(
296
+ "POST",
297
+ self.api_url + "auth/login-email",
298
+ json={"email": email, "password": password, "extra": self.extra},
299
+ )
300
+ result = LoginResponseModel.model_validate(resp)
301
+ self.token = result.token
302
+ if result.email_verification_required:
303
+ raise EmailVerificationRequired(response=result)
304
+ if result.password_change_required:
305
+ raise PasswordChangeRequired(response=result)
306
+ return result
307
+
308
+ async def app_config_user(self) -> AppConfigUserModel:
309
+ """
310
+ Get the application configuration for the user.
311
+ The results will be cached in the class instance.
312
+ """
313
+ if self.app_config_user_cache:
314
+ logger.info("get app user config from cache")
315
+ return self.app_config_user_cache
316
+ resp = await self.request("GET", self.api_url + "app-config/user")
317
+ self.app_config_user_cache = AppConfigUserModel.model_validate(resp) # type: ignore
318
+ logger.info("get app user config from internet")
319
+ return self.app_config_user_cache # type: ignore
320
+
321
+ async def send_text_message(
322
+ self, chat_id: int, text: str
323
+ ) -> tuple[str, int, ChatMessagesModel]:
324
+ "Send a text message to the chat"
325
+ if not isinstance(chat_id, int):
326
+ raise TabError("chat_id must be int")
327
+ if not isinstance(text, str):
328
+ raise TabError("text must be str")
329
+ await self.get_chat_config()
330
+ await self.authinticate(self.sid)
331
+ await self.enable_chat(self.sid)
332
+ resp = await self.request(
333
+ "POST",
334
+ f"{self.api_url}chats/{chat_id}/messages",
335
+ json={"role": "user", "type": "text", "data": text},
336
+ )
337
+ result = ChatMessagesModel.model_validate(resp)
338
+ return (self.sid, result.session, result)
339
+
340
+ async def chats(self, context="chat") -> ChatModel:
341
+ """
342
+ Create new chat
343
+ """
344
+ logger.info("create new chat")
345
+ resp = await self.request(
346
+ "POST", self.api_url + "chats", json={"context": context}
347
+ )
348
+ return ChatModel.model_validate(resp)
349
+
350
+ async def chat_messages(self, chat_id: int, image_id: int) -> ChatMessagesModel:
351
+ resp = await self.request(
352
+ "POST",
353
+ f"{self.api_url}chats/{chat_id}/messages",
354
+ json={"role": "user", "type": "text", "chat_image_id": image_id},
355
+ )
356
+ return ChatMessagesModel.model_validate(resp)
357
+
358
+ async def chat_request_image_upload(
359
+ self, chat_id: int, width: int, height: int, format: str = "jpeg"
360
+ ) -> ChatUploadImageConfigModel:
361
+ """
362
+ Request parameters for upload image to the chat
363
+ """
364
+ logger.info("requested upload image config")
365
+ cnf = await self.app_config_user()
366
+ if width < 16 or height < 16:
367
+ raise ValueError("The image is too small")
368
+ if (
369
+ width > cnf.chat_image_max_dimension
370
+ or height > cnf.chat_image_max_dimension
371
+ ):
372
+ raise ValueError(
373
+ f"width or height must be less than or equal to {cnf.chat_image_max_dimension}"
374
+ )
375
+ resp = await self.request(
376
+ "POST",
377
+ f"{self.api_url}chats/{chat_id}/request-image-upload",
378
+ json={"format": format, "width": width, "height": height},
379
+ )
380
+ return ChatUploadImageConfigModel.model_validate(resp)
381
+
382
+ async def take_photo(self, filename: str) -> tuple[str, int]:
383
+ path_to_image = None
384
+ if not isinstance(filename, str):
385
+ path_to_image = os.path.join(dl_folder, f"image_{str(uuid4())}.tmp")
386
+ with open(path_to_image, "wb") as newfp:
387
+ shutil.copyfileobj(filename, newfp)
388
+ filename = path_to_image # type: ignore
389
+ loop = asyncio.get_running_loop()
390
+ cnf = await self.app_config_user()
391
+ format, size, mode = await loop.run_in_executor(
392
+ None, partial(get_image_info, filename=filename)
393
+ )
394
+ logger.info(
395
+ "recognizing new photo: %s, %s, %s"
396
+ % (format, str(size[0]) + "x" + str(size[1]), mode)
397
+ )
398
+ chat_config = await self.get_chat_config()
399
+ await self.authinticate(chat_config.sid)
400
+ await self.enable_chat(chat_config.sid)
401
+ chat = await self.chats()
402
+ path_to_processed_image = os.path.join(
403
+ dl_folder, f"processed_image_{str(uuid4())}.{cnf.chat_image_type}"
404
+ )
405
+ width, height = await loop.run_in_executor(
406
+ None,
407
+ partial(
408
+ process_image,
409
+ path_to_image=filename,
410
+ path_to_processed_image=path_to_processed_image,
411
+ max_dimension=cnf.chat_image_max_dimension,
412
+ format=cnf.chat_image_type,
413
+ optimize=(cnf.chat_image_type.upper() == "JPEG"),
414
+ jpeg_compress=cnf.chat_image_jpeg_compression,
415
+ ),
416
+ )
417
+ upload_config = await self.chat_request_image_upload(
418
+ chat_id=chat.id,
419
+ width=width,
420
+ height=height,
421
+ format=cnf.chat_image_type,
422
+ )
423
+ logger.info("Starting upload image to Amazon")
424
+ fd = aiohttp.FormData(quote_fields=False)
425
+ fields = dict(
426
+ {
427
+ "Content-Type": upload_config.fields.Content_Type,
428
+ "key": upload_config.fields.key,
429
+ "x-amz-algorithm": upload_config.fields.x_amz_algorithm,
430
+ "x-amz-credential": upload_config.fields.x_amz_credential,
431
+ "x-amz-date": upload_config.fields.x_amz_date,
432
+ "policy": upload_config.fields.policy,
433
+ "x-amz-signature": upload_config.fields.x_amz_signature,
434
+ }
435
+ )
436
+ for field in fields.items():
437
+ fd.add_field(*field, content_type=None, content_transfer_encoding=None)
438
+ with open(path_to_processed_image, "rb") as fp:
439
+ fd.add_field(
440
+ "file",
441
+ fp,
442
+ content_type=upload_config.fields.Content_Type,
443
+ filename="file",
444
+ )
445
+ async with aiohttp.ClientSession(
446
+ trust_env=self.trust_env,
447
+ raise_for_status=True,
448
+ headers={"User-Agent": self.User_Agent},
449
+ ) as session:
450
+ async with session.post(str(upload_config.url), data=fd) as resp:
451
+ if resp.status >= 100 <= 206:
452
+ logger.info("Uploaded successfully")
453
+ else:
454
+ logger.error("Upload faild")
455
+ _ = await resp.read()
456
+ logger.info("removeing processed image")
457
+ await aiofiles.os.remove(path_to_processed_image)
458
+ if path_to_image:
459
+ logger.info("removeing tmp image")
460
+ await aiofiles.os.remove(path_to_image)
461
+ upload_result = await self.chat_messages(
462
+ chat_id=chat.id, image_id=upload_config.chat_image_id
463
+ )
464
+ if not upload_result.images[0].upload_finished:
465
+ logger.error("The image has not been uploaded")
466
+ raise BeMyAIError("The image could not be uploaded to Amazon")
467
+ else:
468
+ logger.info("upload image finished")
469
+ return (chat_config.sid, chat.id)
470
+
471
+ async def send_raw_events(self, sid: str, data: str) -> str:
472
+ params = {"EIO": "4", "transport": "polling", "sid": sid}
473
+ text = await self.request(
474
+ "POST", self.gateway_url + "socket/", params=params, data=data
475
+ )
476
+ return text
477
+
478
+ async def authinticate(self, sid: str = "") -> bool:
479
+ if not sid:
480
+ sid = self.sid
481
+ text = await self.send_raw_events(sid, "40")
482
+ if text != "ok":
483
+ raise BeMyAIError(f"An unexpected response was received. {text}")
484
+ text = await self.receive_raw_events(sid)
485
+ if "AUTHENTICATED" not in text:
486
+ raise BeMyAIError(f"An unexpected response was received. {text}")
487
+ return True
488
+
489
+ async def get_chat_config(self) -> ChatConfigModel:
490
+ "Get sid and other settings"
491
+ logger.debug("Getting chat config...")
492
+ text = await self.receive_raw_events(get_new_sid=True)
493
+ result = ChatConfigModel.model_validate_json(text[1::])
494
+ if self.sid != result.sid:
495
+ logger.debug("received new session id")
496
+ self.sid = result.sid
497
+ return result
498
+
499
+ async def enable_chat(self, sid: str = "") -> str:
500
+ logger.debug("enabling the chat")
501
+ if not sid:
502
+ sid = self.sid
503
+ return await self.send_raw_events(sid, '42["ENABLE_CHAT","{}"]')
504
+
505
+ async def receive_raw_events(self, sid: str = "", get_new_sid=False) -> str:
506
+ params = {"EIO": "4", "transport": "polling"}
507
+ if not sid:
508
+ sid = self.sid
509
+ if sid and not get_new_sid:
510
+ params["sid"] = sid
511
+ text = await self.request("GET", self.gateway_url + "socket/", params=params)
512
+ return text
513
+
514
+ async def receive_messages(self, sid: str = "") -> AsyncIterator[ChatMessagesModel]:
515
+ logger.debug("Starting receive GPT messages")
516
+ if not sid:
517
+ sid = self.sid
518
+ for i in range(3):
519
+ text = await self.receive_raw_events(sid)
520
+ if "42" not in text:
521
+ logger.debug("unexpected response from polling: " + str(text))
522
+ continue
523
+ for response_part in text.split(self.lp_delimeter):
524
+ if '"NEW_CHAT_MESSAGE"' in response_part:
525
+ message = ChatMessagesModel.model_validate(
526
+ json.loads(response_part[2:])[1]
527
+ )
528
+ logger.info("Got new message")
529
+ yield message
530
+ if not message.user:
531
+ break
532
+ else:
533
+ logger.debug(response_part[2:-1])
534
+ logger.debug("messages receiveing completed")
@@ -0,0 +1,169 @@
1
+ import sys
2
+ import argparse
3
+ import asyncio
4
+ import os
5
+ import os.path
6
+ from loguru import logger
7
+ from bemyai import BeMyAI # type: ignore
8
+
9
+
10
+ async def _messages_receiver(p, bm, sid, chat_id):
11
+ while True:
12
+ async for bm_response in bm.receive_messages(sid):
13
+ message = bm_response
14
+ if message.user:
15
+ continue
16
+ print(message.data)
17
+ break
18
+ if p.ni:
19
+ break
20
+ print("Type Q and press enter for exit")
21
+ text = input("Message:")
22
+ if text.strip().lower() in ["q", "quit", "exit"]:
23
+ break
24
+ sid, chat_id, _message = await bm.send_text_message(chat_id, text)
25
+ await asyncio.sleep(0.1)
26
+
27
+
28
+ async def a_main(args: list) -> None:
29
+ parser = argparse.ArgumentParser(prog="bm")
30
+ parser.add_argument(
31
+ "--lang",
32
+ "-l",
33
+ dest="lang",
34
+ default="en",
35
+ metavar="en",
36
+ help="Response language",
37
+ )
38
+ parser.add_argument(
39
+ "--tokenfile",
40
+ "-tf",
41
+ dest="tokenfile",
42
+ default=os.path.expanduser(os.path.join("~", "bm_token.txt")),
43
+ metavar="bm_token.txt",
44
+ help="the path to the file for storing the token from the account",
45
+ type=argparse.FileType("a+b"),
46
+ )
47
+ parser.add_argument(
48
+ "--sessionfile",
49
+ "-sf",
50
+ dest="sessionfile",
51
+ default=os.path.expanduser(os.path.join("~", "bm_session.txt")),
52
+ metavar="bm_session.txt",
53
+ help="the path to the file for storing the session id",
54
+ type=argparse.FileType("a+b"),
55
+ )
56
+ parser.add_argument(
57
+ "--not-interactive",
58
+ "-ni",
59
+ dest="ni",
60
+ action="store_true",
61
+ help="Do not ask for user input",
62
+ )
63
+ parser.add_argument(
64
+ "-v", "--verbose", action="store_true", help="Whether to enable logging?"
65
+ )
66
+
67
+ subparsers = parser.add_subparsers(dest="sub", help="Action")
68
+ parser_login = subparsers.add_parser(
69
+ "login", help="Log in to your account and get a token"
70
+ )
71
+ parser_login.add_argument("email", metavar="E-mail address")
72
+ parser_login.add_argument("password", metavar="Password")
73
+
74
+ parser_signup = subparsers.add_parser("signup", help="Create a new account")
75
+ parser_signup.add_argument("first_name", metavar="First name")
76
+ parser_signup.add_argument("last_name", metavar="Last name")
77
+
78
+ parser_signup.add_argument("email", metavar="E-mail address")
79
+ parser_signup.add_argument("password", metavar="Password")
80
+
81
+ parser_reset_password = subparsers.add_parser(
82
+ "reset-password",
83
+ help="Forgot your password? Send a link to the password change form by email",
84
+ )
85
+ parser_reset_password.add_argument("email", metavar="E-mail address")
86
+
87
+ subparsers.add_parser(
88
+ "resend-verify-email", help="Send the confirmation email again"
89
+ )
90
+
91
+ parser_recognize = subparsers.add_parser(
92
+ "recognize", help="Get a description for an image"
93
+ )
94
+ parser_recognize.add_argument("photo", type=argparse.FileType("rb"))
95
+
96
+ parser_ask = subparsers.add_parser(
97
+ "ask", help="Ask a question about recognized photo"
98
+ )
99
+ parser_ask.add_argument("text", help="message")
100
+
101
+ p = parser.parse_args()
102
+ if p.verbose:
103
+ logger.remove()
104
+ logger.add(sys.stderr, format="{time} {level} {message}", level="DEBUG")
105
+ else:
106
+ logger.remove()
107
+ if p.sub == "login":
108
+ bm = BeMyAI("", response_language=p.lang)
109
+ result = await bm.login(email=p.email, password=p.password)
110
+ p.tokenfile.seek(0)
111
+ p.tokenfile.truncate()
112
+ p.tokenfile.write(result.token.encode("UTF-8"))
113
+ p.tokenfile.flush()
114
+
115
+ if p.sub == "signup":
116
+ bm = BeMyAI("", response_language=p.lang)
117
+ result = await bm.signup(
118
+ first_name=p.first_name,
119
+ last_name=p.last_name,
120
+ email=p.email,
121
+ password=p.password,
122
+ )
123
+ p.tokenfile.seek(0)
124
+ p.tokenfile.truncate()
125
+ p.tokenfile.write(result.token.encode("UTF-8"))
126
+ p.tokenfile.flush()
127
+
128
+ sys.stderr.write("Don't forget to confirm your email address")
129
+
130
+ if p.sub == "reset-password":
131
+ bm = BeMyAI("", response_language=p.lang)
132
+ await bm.send_reset_password(email=p.email)
133
+
134
+ if p.sub == "resend-verify-email":
135
+ p.tokenfile.seek(0)
136
+ token = p.tokenfile.read().decode("UTF-8")
137
+ bm = BeMyAI(token, response_language=p.lang)
138
+ await bm.resend_verify_email()
139
+
140
+ if p.sub == "recognize":
141
+ p.tokenfile.seek(0)
142
+ token = p.tokenfile.read().decode("UTF-8")
143
+ bm = BeMyAI(token, response_language=p.lang)
144
+ sid, chat_id = await bm.take_photo(p.photo)
145
+ p.sessionfile.seek(0)
146
+ p.sessionfile.truncate()
147
+ p.sessionfile.write(str(chat_id).encode("UTF-8"))
148
+ p.sessionfile.flush()
149
+ await _messages_receiver(p, bm, sid, chat_id)
150
+ if p.sub == "ask":
151
+ p.sessionfile.seek(0)
152
+ try:
153
+ chat_id = int(p.sessionfile.read())
154
+ except ValueError:
155
+ sys.stderr.write("session id not found")
156
+ sys.exit(2)
157
+ p.tokenfile.seek(0)
158
+ token = p.tokenfile.read().decode("UTF-8")
159
+ bm = BeMyAI(token, response_language=p.lang)
160
+ sid, chat_id, _message = await bm.send_text_message(chat_id, p.text)
161
+ await _messages_receiver(p, bm, sid, chat_id)
162
+
163
+
164
+ def main(args: list = sys.argv) -> None:
165
+ asyncio.run(a_main(args))
166
+
167
+
168
+ if __name__ == "__main__":
169
+ main(sys.argv)
@@ -0,0 +1,219 @@
1
+ __all__ = ["LOCALES", "LANGS"]
2
+
3
+ LOCALES: list = [
4
+ "lv-LV",
5
+ "es-BO",
6
+ "ar-SA",
7
+ "mt-MT",
8
+ "hi-IN",
9
+ "bn-IN",
10
+ "bs-BA",
11
+ "ca-ES",
12
+ "ka-GE",
13
+ "kn-IN",
14
+ "si-LK",
15
+ "en-TZ",
16
+ "ms-MY",
17
+ "en-ZA",
18
+ "it-IT",
19
+ "gu-IN",
20
+ "pl-PL",
21
+ "ga-IE",
22
+ "bg-BG",
23
+ "lo-LA",
24
+ "ar-SY",
25
+ "af-ZA",
26
+ "ar-AE",
27
+ "da-DK",
28
+ "mr-IN",
29
+ "es-VE",
30
+ "es-EC",
31
+ "en-KE",
32
+ "pt-BR",
33
+ "ru-RU",
34
+ "mn-MN",
35
+ "en-GB",
36
+ "sl-SI",
37
+ "en-NG",
38
+ "zh-CN",
39
+ "ar-LY",
40
+ "bn-BD",
41
+ "fr-CH",
42
+ "zu-ZA",
43
+ "ne-NP",
44
+ "es-MX",
45
+ "cy-GB",
46
+ "tr-TR",
47
+ "km-KH",
48
+ "es-DO",
49
+ "es-CL",
50
+ "uz-UZ",
51
+ "ta-MY",
52
+ "fi-FI",
53
+ "el-GR",
54
+ "ar-JO",
55
+ "zh-HK",
56
+ "ar-TN",
57
+ "es-NI",
58
+ "es-PY",
59
+ "ar-IQ",
60
+ "ml-IN",
61
+ "ar-YE",
62
+ "en-AU",
63
+ "fr-BE",
64
+ "es-CR",
65
+ "ur-IN",
66
+ "fr-CA",
67
+ "en-HK",
68
+ "ur-PK",
69
+ "ar-DZ",
70
+ "lt-LT",
71
+ "ar-KW",
72
+ "es-HN",
73
+ "ar-BH",
74
+ "th-TH",
75
+ "ta-IN",
76
+ "id-ID",
77
+ "es-AR",
78
+ "fa-IR",
79
+ "nl-BE",
80
+ "es-UY",
81
+ "en-IE",
82
+ "ps-AF",
83
+ "cs-CZ",
84
+ "su-ID",
85
+ "ko-KR",
86
+ "de-DE",
87
+ "es-PR",
88
+ "es-ES",
89
+ "es-PA",
90
+ "kk-KZ",
91
+ "ar-LB",
92
+ "vi-VN",
93
+ "es-CO",
94
+ "my-MM",
95
+ "sk-SK",
96
+ "et-EE",
97
+ "sw-TZ",
98
+ "sv-SE",
99
+ "es-GQ",
100
+ "es-US",
101
+ "te-IN",
102
+ "fr-FR",
103
+ "es-GT",
104
+ "hr-HR",
105
+ "es-CU",
106
+ "uk-UA",
107
+ "ta-SG",
108
+ "jv-ID",
109
+ "sr-RS",
110
+ "mk-MK",
111
+ "ar-MA",
112
+ "sq-AL",
113
+ "zh-TW",
114
+ "ja-JP",
115
+ "nl-NL",
116
+ "es-PE",
117
+ "am-ET",
118
+ "en-NZ",
119
+ "gl-ES",
120
+ "sw-KE",
121
+ "ro-RO",
122
+ "en-CA",
123
+ "so-SO",
124
+ "es-SV",
125
+ "nb-NO",
126
+ "en-IN",
127
+ "az-AZ",
128
+ "de-AT",
129
+ "he-IL",
130
+ "hu-HU",
131
+ "ar-EG",
132
+ "de-CH",
133
+ "ar-OM",
134
+ "is-IS",
135
+ "fil-PH",
136
+ "en-SG",
137
+ "en-PH",
138
+ "en-US",
139
+ "pt-PT",
140
+ "ta-LK",
141
+ "ar-QA",
142
+ ]
143
+
144
+ LANGS: list = [
145
+ "hu",
146
+ "su",
147
+ "te",
148
+ "kn",
149
+ "pl",
150
+ "mt",
151
+ "ka",
152
+ "fi",
153
+ "de",
154
+ "sv",
155
+ "mn",
156
+ "zh",
157
+ "he",
158
+ "bg",
159
+ "ga",
160
+ "fr",
161
+ "es",
162
+ "th",
163
+ "kk",
164
+ "vi",
165
+ "mr",
166
+ "az",
167
+ "ml",
168
+ "km",
169
+ "ps",
170
+ "hr",
171
+ "ro",
172
+ "fil",
173
+ "bs",
174
+ "lv",
175
+ "lo",
176
+ "af",
177
+ "da",
178
+ "cs",
179
+ "ms",
180
+ "nb",
181
+ "ta",
182
+ "am",
183
+ "ko",
184
+ "so",
185
+ "hi",
186
+ "gl",
187
+ "pt",
188
+ "uz",
189
+ "uk",
190
+ "zu",
191
+ "si",
192
+ "lt",
193
+ "sk",
194
+ "bn",
195
+ "en",
196
+ "it",
197
+ "sw",
198
+ "el",
199
+ "ca",
200
+ "et",
201
+ "sr",
202
+ "mk",
203
+ "sq",
204
+ "ja",
205
+ "gu",
206
+ "nl",
207
+ "is",
208
+ "id",
209
+ "tr",
210
+ "ar",
211
+ "jv",
212
+ "ru",
213
+ "fa",
214
+ "my",
215
+ "sl",
216
+ "ur",
217
+ "cy",
218
+ "ne",
219
+ ]
@@ -0,0 +1,137 @@
1
+ from datetime import datetime
2
+ from typing import Dict, List, Optional, Any
3
+
4
+ from pydantic import (
5
+ BaseModel,
6
+ Field,
7
+ StrictBool,
8
+ PositiveInt,
9
+ HttpUrl,
10
+ )
11
+
12
+ __all__ = [
13
+ "UserModel",
14
+ "LoginResponseModel",
15
+ "AppConfigUserModel",
16
+ "WaitingListModel",
17
+ "ChatConfigModel",
18
+ "ChatModel",
19
+ "ChatUploadImageConfigModel",
20
+ "ChatMessagesModel",
21
+ ]
22
+
23
+
24
+ class UserModel(BaseModel):
25
+ id: int
26
+ uid: str
27
+ created_at: datetime
28
+ first_name: str
29
+ last_name: str
30
+ email: str
31
+ user_type: str
32
+ timezone: str
33
+ primary_language: str
34
+ secondary_languages: List
35
+ country: str
36
+ auth_type: str
37
+ has_usable_password: StrictBool
38
+ has_accepted_latest_terms: StrictBool
39
+ has_accepted_marketing: Optional[StrictBool]
40
+ is_pending_deletion: StrictBool
41
+ has_hidden_email: StrictBool
42
+ extra: Dict[str, Any]
43
+
44
+
45
+ class LoginResponseModel(BaseModel):
46
+ user: UserModel
47
+ token: str
48
+ email_verification_required: StrictBool
49
+ password_change_required: StrictBool
50
+
51
+
52
+ class ChatUploadImageConfigModelFields(BaseModel):
53
+ Content_Type: str = Field(alias="Content-Type")
54
+ key: str
55
+ x_amz_algorithm: str = Field(alias="x-amz-algorithm")
56
+ x_amz_credential: str = Field(alias="x-amz-credential")
57
+ x_amz_date: str = Field(alias="x-amz-date")
58
+ policy: str
59
+ x_amz_signature: str = Field(alias="x-amz-signature")
60
+
61
+
62
+ class ChatUploadImageConfigModel(BaseModel):
63
+ url: HttpUrl
64
+ fields: ChatUploadImageConfigModelFields
65
+ chat_image_id: int
66
+
67
+
68
+ class ChatModel(BaseModel):
69
+ id: int
70
+ created_at: datetime
71
+ context: str
72
+ organization: Optional[str]
73
+ user: int
74
+ language: str
75
+ rating: Optional[int]
76
+ call_button_title: str
77
+
78
+
79
+ class ChatConfigModel(BaseModel):
80
+ sid: str
81
+ upgrades: List[str]
82
+ pingInterval: PositiveInt
83
+ pingTimeout: PositiveInt
84
+ maxPayload: PositiveInt
85
+
86
+
87
+ class AppConfigUserModel(BaseModel):
88
+ groups_enabled: StrictBool
89
+ photos_enabled: StrictBool
90
+ ios_use_community_portal: StrictBool
91
+ organization_calls_enabled: StrictBool
92
+ ios_learning_center_enabled: StrictBool
93
+ android_use_community_portal: StrictBool
94
+ volunteer_label_call_enabled: StrictBool
95
+ chat_enabled: StrictBool
96
+ sh_scan_qr_code_enabled: StrictBool
97
+ chat_image_jpeg_compression: int
98
+ chat_image_max_dimension: int
99
+ chat_image_type: str
100
+ bmai_speech_output_available: StrictBool
101
+ ios_shortcuts_settings_enabled: StrictBool
102
+ wearable_rbm_blocked: StrictBool
103
+ wearable_rbm_enabled: StrictBool
104
+ wearable_rbm_debug_actions_enabled: StrictBool
105
+ wearable_rbm_promo_enabled: StrictBool
106
+
107
+
108
+ class WaitingListModel(BaseModel):
109
+ id: int
110
+ created_at: datetime
111
+ modified_at: datetime
112
+ device_type: str
113
+
114
+
115
+ class Image(BaseModel):
116
+ id: int
117
+ created_at: datetime
118
+ upload_finished: StrictBool
119
+ url: HttpUrl
120
+
121
+
122
+ class ChatMessagesModel(BaseModel):
123
+ id: int
124
+ version: PositiveInt
125
+ created_at: datetime
126
+ role: str
127
+ user: Optional[int]
128
+ session: int
129
+ type: str
130
+ error_code: Optional[int]
131
+ data: Optional[str]
132
+ images: List[Image]
133
+
134
+
135
+ """example
136
+ m = ChatMessagesModel.model_validate_json(message)
137
+ """
@@ -0,0 +1,27 @@
1
+ [tool.poetry]
2
+ name = "bemyai"
3
+ version = "0.1.8"
4
+ description = "Be My AI API"
5
+ authors = ["Alexey <aleks-samos@yandex.ru>"]
6
+ readme = "README.md"
7
+
8
+ [tool.poetry.dependencies]
9
+ python = ">3.8"
10
+ aiohttp = "^3.9.1"
11
+ pydantic = "^2.5.3"
12
+ pillow = "^10.2.0"
13
+ loguru = "^0.7.2"
14
+ aiofiles = "^23.2.1"
15
+
16
+
17
+ [tool.poetry.group.dev.dependencies]
18
+ pytest = "^7.4.4"
19
+ pytest-aiohttp = "^1.0.5"
20
+
21
+ [build-system]
22
+ requires = ["poetry-core"]
23
+ build-backend = "poetry.core.masonry.api"
24
+
25
+ [tool.poetry.scripts]
26
+ bm = 'bemyai.__main__:main'
27
+