xc-bot 1.0.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.
- xc_bot/__init__.py +42 -0
- xc_bot/api.py +175 -0
- xc_bot/bot.py +156 -0
- xc_bot/exceptions.py +33 -0
- xc_bot/grpc_client.py +59 -0
- xc_bot/handlers/__init__.py +4 -0
- xc_bot/handlers/handler.py +84 -0
- xc_bot/pb/__init__.py +0 -0
- xc_bot/pb/bot_service_pb2.py +44 -0
- xc_bot/pb/bot_service_pb2_grpc.py +145 -0
- xc_bot/types/__init__.py +14 -0
- xc_bot/types/message.py +98 -0
- xc_bot/types/update.py +46 -0
- xc_bot/types/user.py +42 -0
- xc_bot/webhook/__init__.py +4 -0
- xc_bot/webhook/server.py +99 -0
- xc_bot-1.0.0.dist-info/METADATA +138 -0
- xc_bot-1.0.0.dist-info/RECORD +21 -0
- xc_bot-1.0.0.dist-info/WHEEL +5 -0
- xc_bot-1.0.0.dist-info/licenses/LICENSE +21 -0
- xc_bot-1.0.0.dist-info/top_level.txt +1 -0
xc_bot/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""xc-bot - Python SDK for XaneoConnect Bot API"""
|
|
2
|
+
|
|
3
|
+
__version__ = "1.0.0"
|
|
4
|
+
|
|
5
|
+
from .bot import Bot
|
|
6
|
+
from .grpc_client import GrpcBotClient
|
|
7
|
+
from .exceptions import (
|
|
8
|
+
XcBotError,
|
|
9
|
+
ApiError,
|
|
10
|
+
NetworkError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
WebhookConflictError,
|
|
13
|
+
)
|
|
14
|
+
from .types import (
|
|
15
|
+
User,
|
|
16
|
+
BotAccount,
|
|
17
|
+
Message,
|
|
18
|
+
Chat,
|
|
19
|
+
ChatType,
|
|
20
|
+
Update,
|
|
21
|
+
UpdateType,
|
|
22
|
+
)
|
|
23
|
+
from .webhook import WebhookServer
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Bot",
|
|
27
|
+
"GrpcBotClient",
|
|
28
|
+
"WebhookServer",
|
|
29
|
+
"XcBotError",
|
|
30
|
+
"ApiError",
|
|
31
|
+
"NetworkError",
|
|
32
|
+
"RateLimitError",
|
|
33
|
+
"WebhookConflictError",
|
|
34
|
+
"User",
|
|
35
|
+
"BotAccount",
|
|
36
|
+
"Message",
|
|
37
|
+
"Chat",
|
|
38
|
+
"ChatType",
|
|
39
|
+
"Update",
|
|
40
|
+
"UpdateType",
|
|
41
|
+
]
|
|
42
|
+
|
xc_bot/api.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Async API client for XaneoConnect Bot API"""
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Optional, TypeVar, Union
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
|
|
8
|
+
from .exceptions import ApiError, NetworkError, RateLimitError, WebhookConflictError
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("xc_bot")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ApiClient:
|
|
16
|
+
DEFAULT_BASE_URL = "https://xaneo.com/bot"
|
|
17
|
+
DEFAULT_TIMEOUT = 60
|
|
18
|
+
MAX_RETRIES = 3
|
|
19
|
+
RETRY_DELAYS = [1, 2, 4]
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
token: str,
|
|
24
|
+
base_url: Optional[str] = None,
|
|
25
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
26
|
+
):
|
|
27
|
+
self.token = token
|
|
28
|
+
self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
|
|
29
|
+
self.timeout = aiohttp.ClientTimeout(total=timeout)
|
|
30
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
31
|
+
self._user_agent = "xc-bot-python/1.0"
|
|
32
|
+
|
|
33
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
34
|
+
if self._session is None or self._session.closed:
|
|
35
|
+
self._session = aiohttp.ClientSession(
|
|
36
|
+
timeout=self.timeout,
|
|
37
|
+
headers={
|
|
38
|
+
"Authorization": f"Bot {self.token}",
|
|
39
|
+
"User-Agent": self._user_agent,
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
return self._session
|
|
44
|
+
|
|
45
|
+
async def close(self) -> None:
|
|
46
|
+
if self._session and not self._session.closed:
|
|
47
|
+
await self._session.close()
|
|
48
|
+
|
|
49
|
+
async def request(
|
|
50
|
+
self,
|
|
51
|
+
method: str,
|
|
52
|
+
data: Optional[dict] = None,
|
|
53
|
+
params: Optional[dict] = None,
|
|
54
|
+
) -> Any:
|
|
55
|
+
url = f"{self.base_url}/{method}"
|
|
56
|
+
session = await self._get_session()
|
|
57
|
+
|
|
58
|
+
last_error: Optional[Exception] = None
|
|
59
|
+
|
|
60
|
+
for attempt in range(self.MAX_RETRIES):
|
|
61
|
+
try:
|
|
62
|
+
if data is not None:
|
|
63
|
+
response = await session.post(url, json=data)
|
|
64
|
+
elif params:
|
|
65
|
+
response = await session.get(url, params=params)
|
|
66
|
+
else:
|
|
67
|
+
response = await session.get(url)
|
|
68
|
+
|
|
69
|
+
async with response:
|
|
70
|
+
result = await self._parse_response(response, method)
|
|
71
|
+
|
|
72
|
+
if result is not None:
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
except ApiError:
|
|
76
|
+
raise
|
|
77
|
+
except aiohttp.ClientError as e:
|
|
78
|
+
last_error = NetworkError(str(e))
|
|
79
|
+
logger.warning(f"Network error on {method} (attempt {attempt + 1}): {e}")
|
|
80
|
+
except Exception as e:
|
|
81
|
+
last_error = NetworkError(str(e))
|
|
82
|
+
logger.warning(f"Unexpected error on {method} (attempt {attempt + 1}): {e}")
|
|
83
|
+
|
|
84
|
+
if attempt < self.MAX_RETRIES - 1:
|
|
85
|
+
delay = self.RETRY_DELAYS[min(attempt, len(self.RETRY_DELAYS) - 1)]
|
|
86
|
+
logger.debug(f"Retrying {method} in {delay}s...")
|
|
87
|
+
await asyncio.sleep(delay)
|
|
88
|
+
|
|
89
|
+
raise last_error or NetworkError("Unknown error occurred")
|
|
90
|
+
|
|
91
|
+
async def _parse_response(self, response: aiohttp.ClientResponse, method: str) -> Any:
|
|
92
|
+
try:
|
|
93
|
+
json_data = await response.json()
|
|
94
|
+
except aiohttp.ContentTypeError:
|
|
95
|
+
text = await response.text()
|
|
96
|
+
raise NetworkError(f"Invalid JSON response: {text[:200]}")
|
|
97
|
+
|
|
98
|
+
if json_data.get("ok"):
|
|
99
|
+
return json_data.get("result")
|
|
100
|
+
|
|
101
|
+
error_code = json_data.get("error_code", response.status)
|
|
102
|
+
description = json_data.get("description") or json_data.get("detail") or "Unknown error"
|
|
103
|
+
|
|
104
|
+
if error_code == 429:
|
|
105
|
+
retry_after = response.headers.get("Retry-After")
|
|
106
|
+
try:
|
|
107
|
+
retry_after_seconds = float(retry_after) if retry_after else 1.0
|
|
108
|
+
except ValueError:
|
|
109
|
+
retry_after_seconds = 1.0
|
|
110
|
+
raise RateLimitError(description, retry_after_seconds)
|
|
111
|
+
if error_code == 409:
|
|
112
|
+
raise WebhookConflictError(description)
|
|
113
|
+
raise ApiError(error_code, description)
|
|
114
|
+
|
|
115
|
+
async def get_me(self) -> dict:
|
|
116
|
+
return await self.request("getMe")
|
|
117
|
+
|
|
118
|
+
async def get_updates(
|
|
119
|
+
self,
|
|
120
|
+
offset: int = 0,
|
|
121
|
+
limit: int = 100,
|
|
122
|
+
timeout: int = 30,
|
|
123
|
+
) -> list:
|
|
124
|
+
return await self.request("getUpdates", {"offset": offset, "limit": limit, "timeout": timeout})
|
|
125
|
+
|
|
126
|
+
async def send_message(
|
|
127
|
+
self,
|
|
128
|
+
chat_id: str,
|
|
129
|
+
text: str,
|
|
130
|
+
reply_to_message_id: Optional[int] = None,
|
|
131
|
+
) -> dict:
|
|
132
|
+
data: dict = {"chat_id": chat_id, "text": text}
|
|
133
|
+
if reply_to_message_id:
|
|
134
|
+
data["reply_to_message_id"] = reply_to_message_id
|
|
135
|
+
return await self.request("sendMessage", data)
|
|
136
|
+
|
|
137
|
+
async def edit_message(self, message_id: int, text: str) -> bool:
|
|
138
|
+
return await self.request("editMessage", {"message_id": message_id, "text": text})
|
|
139
|
+
|
|
140
|
+
async def delete_message(self, message_id: int) -> bool:
|
|
141
|
+
return await self.request("deleteMessage", {"message_id": message_id})
|
|
142
|
+
|
|
143
|
+
async def set_webhook(
|
|
144
|
+
self,
|
|
145
|
+
url: str,
|
|
146
|
+
secret: str = "",
|
|
147
|
+
allowed_updates: Optional[list] = None,
|
|
148
|
+
) -> bool:
|
|
149
|
+
data: dict = {"url": url}
|
|
150
|
+
if secret:
|
|
151
|
+
data["secret"] = secret
|
|
152
|
+
if allowed_updates:
|
|
153
|
+
data["allowed_updates"] = allowed_updates
|
|
154
|
+
return await self.request("setWebhook", data)
|
|
155
|
+
|
|
156
|
+
async def delete_webhook(self) -> bool:
|
|
157
|
+
return await self.request("deleteWebhook", {})
|
|
158
|
+
|
|
159
|
+
async def get_webhook_info(self) -> dict:
|
|
160
|
+
return await self.request("getWebhookInfo")
|
|
161
|
+
|
|
162
|
+
async def get_chat(self, chat_id: str) -> dict:
|
|
163
|
+
return await self.request("getChat", params={"chat_id": chat_id})
|
|
164
|
+
|
|
165
|
+
async def get_chat_members(self, chat_id: str) -> list:
|
|
166
|
+
return await self.request("getChatMembers", params={"chat_id": chat_id})
|
|
167
|
+
|
|
168
|
+
async def leave_chat(self, chat_id: str) -> bool:
|
|
169
|
+
return await self.request("leaveChat", {"chat_id": chat_id})
|
|
170
|
+
|
|
171
|
+
async def set_commands(self, commands: list) -> bool:
|
|
172
|
+
return await self.request("setCommands", {"commands": commands})
|
|
173
|
+
|
|
174
|
+
async def get_commands(self) -> list:
|
|
175
|
+
return await self.request("getCommands")
|
xc_bot/bot.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Main Bot class for xc-bot"""
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
import signal
|
|
5
|
+
from typing import Any, Callable, List, Optional, Union
|
|
6
|
+
|
|
7
|
+
from .api import ApiClient
|
|
8
|
+
from .exceptions import RateLimitError, XcBotError
|
|
9
|
+
from .handlers import HandlerRegistry
|
|
10
|
+
from .types import BotAccount, Chat, Message, Update
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("xc_bot")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Bot:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
token: str,
|
|
19
|
+
base_url: Optional[str] = None,
|
|
20
|
+
):
|
|
21
|
+
self.token = token
|
|
22
|
+
self._api = ApiClient(token, base_url)
|
|
23
|
+
self._handlers = HandlerRegistry()
|
|
24
|
+
self._running = False
|
|
25
|
+
self._me: Optional[BotAccount] = None
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def api(self) -> ApiClient:
|
|
29
|
+
return self._api
|
|
30
|
+
|
|
31
|
+
async def get_me(self) -> BotAccount:
|
|
32
|
+
data = await self._api.get_me()
|
|
33
|
+
self._me = BotAccount.from_dict(data)
|
|
34
|
+
return self._me
|
|
35
|
+
|
|
36
|
+
async def send_message(
|
|
37
|
+
self,
|
|
38
|
+
chat_id: str,
|
|
39
|
+
text: str,
|
|
40
|
+
reply_to_message_id: Optional[int] = None,
|
|
41
|
+
) -> Message:
|
|
42
|
+
data = await self._api.send_message(chat_id, text, reply_to_message_id)
|
|
43
|
+
author = {
|
|
44
|
+
"id": self._me.id if self._me else 0,
|
|
45
|
+
"username": self._me.username if self._me else None,
|
|
46
|
+
"first_name": self._me.display_name if self._me else None,
|
|
47
|
+
}
|
|
48
|
+
return Message.from_dict(
|
|
49
|
+
{"id": data["message_id"], "chat_id": data["chat_id"], "text": text, "author": author},
|
|
50
|
+
bot=self,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
async def edit_message(self, message_id: int, text: str) -> bool:
|
|
54
|
+
return await self._api.edit_message(message_id, text)
|
|
55
|
+
|
|
56
|
+
async def delete_message(self, message_id: int) -> bool:
|
|
57
|
+
return await self._api.delete_message(message_id)
|
|
58
|
+
|
|
59
|
+
async def get_chat(self, chat_id: str) -> Chat:
|
|
60
|
+
data = await self._api.get_chat(chat_id)
|
|
61
|
+
return Chat.from_dict(data)
|
|
62
|
+
|
|
63
|
+
async def get_chat_members(self, chat_id: str) -> List[dict]:
|
|
64
|
+
return await self._api.get_chat_members(chat_id)
|
|
65
|
+
|
|
66
|
+
async def leave_chat(self, chat_id: str) -> bool:
|
|
67
|
+
return await self._api.leave_chat(chat_id)
|
|
68
|
+
|
|
69
|
+
async def set_commands(self, commands: List[dict]) -> bool:
|
|
70
|
+
return await self._api.set_commands(commands)
|
|
71
|
+
|
|
72
|
+
async def get_commands(self) -> List[dict]:
|
|
73
|
+
return await self._api.get_commands()
|
|
74
|
+
|
|
75
|
+
async def set_webhook(self, url: str, secret: str = "", allowed_updates: Optional[List[str]] = None) -> bool:
|
|
76
|
+
return await self._api.set_webhook(url, secret, allowed_updates)
|
|
77
|
+
|
|
78
|
+
async def delete_webhook(self) -> bool:
|
|
79
|
+
return await self._api.delete_webhook()
|
|
80
|
+
|
|
81
|
+
async def get_webhook_info(self) -> dict:
|
|
82
|
+
return await self._api.get_webhook_info()
|
|
83
|
+
|
|
84
|
+
def command(self, commands: Union[str, List[str]]) -> Callable:
|
|
85
|
+
def decorator(func: Callable) -> Callable:
|
|
86
|
+
self._handlers.add_command(commands, func)
|
|
87
|
+
return func
|
|
88
|
+
return decorator
|
|
89
|
+
|
|
90
|
+
def hears(self, pattern: Union[str, Any]) -> Callable:
|
|
91
|
+
def decorator(func: Callable) -> Callable:
|
|
92
|
+
self._handlers.add_regex(pattern, func)
|
|
93
|
+
return func
|
|
94
|
+
return decorator
|
|
95
|
+
|
|
96
|
+
def message(self, func: Callable) -> Callable:
|
|
97
|
+
self._handlers.add_message(func)
|
|
98
|
+
return func
|
|
99
|
+
|
|
100
|
+
async def _process_update(self, update_data: dict) -> None:
|
|
101
|
+
update = Update.from_dict(update_data, bot=self)
|
|
102
|
+
if update.message:
|
|
103
|
+
update.message._bot = self
|
|
104
|
+
try:
|
|
105
|
+
await self._handlers.process(update.message)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.exception(f"Error processing update {update.update_id}: {e}")
|
|
108
|
+
|
|
109
|
+
async def polling(self, timeout: int = 30, limit: int = 100) -> None:
|
|
110
|
+
self._running = True
|
|
111
|
+
offset = 0
|
|
112
|
+
|
|
113
|
+
def signal_handler(sig, frame):
|
|
114
|
+
nonlocal self
|
|
115
|
+
logger.info("Received shutdown signal")
|
|
116
|
+
self._running = False
|
|
117
|
+
|
|
118
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
119
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
120
|
+
|
|
121
|
+
logger.info("Starting polling...")
|
|
122
|
+
|
|
123
|
+
while self._running:
|
|
124
|
+
try:
|
|
125
|
+
updates = await self._api.get_updates(offset=offset, limit=limit, timeout=timeout)
|
|
126
|
+
|
|
127
|
+
if not updates:
|
|
128
|
+
await asyncio.sleep(1.1)
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
for update_data in updates:
|
|
132
|
+
if not self._running:
|
|
133
|
+
break
|
|
134
|
+
await self._process_update(update_data)
|
|
135
|
+
offset = update_data.get("update_id", 0) + 1
|
|
136
|
+
|
|
137
|
+
except RateLimitError as e:
|
|
138
|
+
logger.info(f"Rate limited: {e.description}")
|
|
139
|
+
await asyncio.sleep(max(e.retry_after, 1.0) + 0.25)
|
|
140
|
+
except XcBotError as e:
|
|
141
|
+
logger.error(f"Bot error: {e}")
|
|
142
|
+
await asyncio.sleep(5)
|
|
143
|
+
except Exception as e:
|
|
144
|
+
logger.exception(f"Unexpected error in polling: {e}")
|
|
145
|
+
await asyncio.sleep(5)
|
|
146
|
+
|
|
147
|
+
logger.info("Polling stopped")
|
|
148
|
+
|
|
149
|
+
def run(self, timeout: int = 30, limit: int = 100) -> None:
|
|
150
|
+
try:
|
|
151
|
+
asyncio.run(self.polling(timeout=timeout, limit=limit))
|
|
152
|
+
except KeyboardInterrupt:
|
|
153
|
+
logger.info("Bot stopped by user")
|
|
154
|
+
|
|
155
|
+
async def close(self) -> None:
|
|
156
|
+
await self._api.close()
|
xc_bot/exceptions.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""xc-bot exceptions"""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class XcBotError(Exception):
|
|
5
|
+
"""Base exception for xc-bot"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ApiError(XcBotError):
|
|
9
|
+
"""API returned an error"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, error_code: int, description: str):
|
|
12
|
+
self.error_code = error_code
|
|
13
|
+
self.description = description
|
|
14
|
+
super().__init__(f"API error {error_code}: {description}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NetworkError(XcBotError):
|
|
18
|
+
"""Network-related error"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RateLimitError(ApiError):
|
|
22
|
+
"""Rate limit exceeded (429)"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, description: str = "Too many requests", retry_after: float = 1.0):
|
|
25
|
+
self.retry_after = retry_after
|
|
26
|
+
super().__init__(429, description)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WebhookConflictError(ApiError):
|
|
30
|
+
"""Cannot use getUpdates while webhook is active (409)"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, description: str = "Cannot use getUpdates while webhook is active"):
|
|
33
|
+
super().__init__(409, description)
|
xc_bot/grpc_client.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import logging
|
|
3
|
+
import grpc
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from .pb import bot_service_pb2 as pb2
|
|
7
|
+
from .pb import bot_service_pb2_grpc as pb2_grpc
|
|
8
|
+
except ImportError:
|
|
9
|
+
import bot_service_pb2 as pb2
|
|
10
|
+
import bot_service_pb2_grpc as pb2_grpc
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GrpcBotClient:
|
|
16
|
+
"""
|
|
17
|
+
gRPC High-Performance Client for Xaneo Bot SDK (xc-bot).
|
|
18
|
+
Allows real-time streaming of events and sub-millisecond message dispatching.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, bot_token: str, server_address: str = "localhost:50052"):
|
|
22
|
+
self.bot_token = bot_token
|
|
23
|
+
self.server_address = server_address
|
|
24
|
+
self.channel = None
|
|
25
|
+
self.stub = None
|
|
26
|
+
|
|
27
|
+
def connect(self):
|
|
28
|
+
"""Establish gRPC connection to Xaneo Bot Platform."""
|
|
29
|
+
logger.info(f"Connecting to Xaneo Bot gRPC Engine at {self.server_address}...")
|
|
30
|
+
self.channel = grpc.insecure_channel(self.server_address)
|
|
31
|
+
self.stub = pb2_grpc.BotEventServiceStub(self.channel)
|
|
32
|
+
logger.info("✅ gRPC Bot Client Connected Successfully!")
|
|
33
|
+
|
|
34
|
+
def send_message(self, chat_id: str, text: str, reply_to_id: str = ""):
|
|
35
|
+
"""Send message via gRPC Unary RPC."""
|
|
36
|
+
if not self.stub:
|
|
37
|
+
self.connect()
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
req = pb2.BotSendMessageRequest(
|
|
41
|
+
bot_token=self.bot_token,
|
|
42
|
+
chat_id=str(chat_id),
|
|
43
|
+
text=text,
|
|
44
|
+
reply_to_id=reply_to_id
|
|
45
|
+
)
|
|
46
|
+
response = self.stub.SendMessage(req)
|
|
47
|
+
return {
|
|
48
|
+
"success": response.success,
|
|
49
|
+
"message_id": response.message_id,
|
|
50
|
+
"timestamp": response.timestamp,
|
|
51
|
+
"error": response.error_message
|
|
52
|
+
}
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logger.error(f"Error sending message over gRPC: {e}")
|
|
55
|
+
return {"success": False, "error": str(e)}
|
|
56
|
+
|
|
57
|
+
def close(self):
|
|
58
|
+
if self.channel:
|
|
59
|
+
self.channel.close()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Handler system for xc-bot"""
|
|
2
|
+
import re
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Callable, Dict, List, Optional, Pattern, Union
|
|
5
|
+
|
|
6
|
+
from xc_bot.types import Message
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class HandlerEntry:
|
|
11
|
+
handler: Callable
|
|
12
|
+
priority: int = 0
|
|
13
|
+
pattern: Optional[Pattern] = None
|
|
14
|
+
commands: Optional[List[str]] = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HandlerRegistry:
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self._command_handlers: Dict[str, List[HandlerEntry]] = {}
|
|
20
|
+
self._regex_handlers: List[HandlerEntry] = []
|
|
21
|
+
self._message_handlers: List[HandlerEntry] = []
|
|
22
|
+
|
|
23
|
+
def add_command(self, commands: Union[str, List[str]], handler: Callable) -> None:
|
|
24
|
+
if isinstance(commands, str):
|
|
25
|
+
commands = [commands]
|
|
26
|
+
|
|
27
|
+
for cmd in commands:
|
|
28
|
+
cmd_clean = cmd.lstrip("/").lower()
|
|
29
|
+
if cmd_clean not in self._command_handlers:
|
|
30
|
+
self._command_handlers[cmd_clean] = []
|
|
31
|
+
self._command_handlers[cmd_clean].append(HandlerEntry(handler=handler))
|
|
32
|
+
|
|
33
|
+
def add_regex(self, pattern: Union[str, Pattern], handler: Callable) -> None:
|
|
34
|
+
if isinstance(pattern, str):
|
|
35
|
+
compiled = re.compile(pattern)
|
|
36
|
+
else:
|
|
37
|
+
compiled = pattern
|
|
38
|
+
self._regex_handlers.append(HandlerEntry(handler=handler, pattern=compiled))
|
|
39
|
+
|
|
40
|
+
def add_message(self, handler: Callable) -> None:
|
|
41
|
+
self._message_handlers.append(HandlerEntry(handler=handler))
|
|
42
|
+
|
|
43
|
+
async def process(self, message: Message) -> bool:
|
|
44
|
+
text = message.text or ""
|
|
45
|
+
|
|
46
|
+
if text.startswith("/"):
|
|
47
|
+
parts = text.split(None, 1)
|
|
48
|
+
command = parts[0].lstrip("/").lower()
|
|
49
|
+
|
|
50
|
+
if "@" in command:
|
|
51
|
+
command = command.split("@")[0]
|
|
52
|
+
|
|
53
|
+
handlers = self._command_handlers.get(command, [])
|
|
54
|
+
for entry in handlers:
|
|
55
|
+
try:
|
|
56
|
+
result = entry.handler(message)
|
|
57
|
+
if result is not None and hasattr(result, "__await__"):
|
|
58
|
+
await result
|
|
59
|
+
return True
|
|
60
|
+
except Exception:
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
for entry in self._regex_handlers:
|
|
64
|
+
if entry.pattern:
|
|
65
|
+
match = entry.pattern.search(text)
|
|
66
|
+
if match:
|
|
67
|
+
try:
|
|
68
|
+
result = entry.handler(message, match)
|
|
69
|
+
if result is not None and hasattr(result, "__await__"):
|
|
70
|
+
await result
|
|
71
|
+
return True
|
|
72
|
+
except Exception:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
for entry in self._message_handlers:
|
|
76
|
+
try:
|
|
77
|
+
result = entry.handler(message)
|
|
78
|
+
if result is not None and hasattr(result, "__await__"):
|
|
79
|
+
await result
|
|
80
|
+
return True
|
|
81
|
+
except Exception:
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
return False
|
xc_bot/pb/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
3
|
+
# NO CHECKED-IN PROTOBUF GENCODE
|
|
4
|
+
# source: bot_service.proto
|
|
5
|
+
# Protobuf Python Version: 7.35.0
|
|
6
|
+
"""Generated protocol buffer code."""
|
|
7
|
+
from google.protobuf import descriptor as _descriptor
|
|
8
|
+
from google.protobuf import descriptor_pool as _descriptor_pool
|
|
9
|
+
from google.protobuf import runtime_version as _runtime_version
|
|
10
|
+
from google.protobuf import symbol_database as _symbol_database
|
|
11
|
+
from google.protobuf.internal import builder as _builder
|
|
12
|
+
_runtime_version.ValidateProtobufRuntimeVersion(
|
|
13
|
+
_runtime_version.Domain.PUBLIC,
|
|
14
|
+
7,
|
|
15
|
+
35,
|
|
16
|
+
0,
|
|
17
|
+
'',
|
|
18
|
+
'bot_service.proto'
|
|
19
|
+
)
|
|
20
|
+
# @@protoc_insertion_point(imports)
|
|
21
|
+
|
|
22
|
+
_sym_db = _symbol_database.Default()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x11\x62ot_service.proto\x12\x08xaneo.v1\"v\n\x15\x42otSendMessageRequest\x12\x11\n\tbot_token\x18\x01 \x01(\t\x12\x0f\n\x07\x63hat_id\x18\x02 \x01(\t\x12\x0c\n\x04text\x18\x03 \x01(\t\x12\x13\n\x0breply_to_id\x18\x04 \x01(\t\x12\x16\n\x0einline_buttons\x18\x05 \x03(\t\"g\n\x16\x42otSendMessageResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x12\n\nmessage_id\x18\x02 \x01(\t\x12\x15\n\rerror_message\x18\x03 \x01(\t\x12\x11\n\ttimestamp\x18\x04 \x01(\x03\"\xb5\x01\n\x08\x42otEvent\x12\x10\n\x08\x65vent_id\x18\x01 \x01(\t\x12\x12\n\nevent_type\x18\x02 \x01(\t\x12\x0e\n\x06\x62ot_id\x18\x03 \x01(\t\x12\x0f\n\x07\x63hat_id\x18\x04 \x01(\t\x12\x11\n\tsender_id\x18\x05 \x01(\t\x12\x17\n\x0fsender_username\x18\x06 \x01(\t\x12\x0c\n\x04text\x18\x07 \x01(\t\x12\x15\n\rcallback_data\x18\x08 \x01(\t\x12\x11\n\ttimestamp\x18\t \x01(\x03\"q\n\tBotAction\x12\x11\n\tbot_token\x18\x01 \x01(\t\x12\x10\n\x08\x65vent_id\x18\x02 \x01(\t\x12\x13\n\x0b\x61\x63tion_type\x18\x03 \x01(\t\x12\x12\n\nreply_text\x18\x04 \x01(\t\x12\x16\n\x0einline_buttons\x18\x05 \x03(\t2\xa0\x01\n\x0f\x42otEventService\x12;\n\x0cStreamEvents\x12\x13.xaneo.v1.BotAction\x1a\x12.xaneo.v1.BotEvent(\x01\x30\x01\x12P\n\x0bSendMessage\x12\x1f.xaneo.v1.BotSendMessageRequest\x1a .xaneo.v1.BotSendMessageResponseb\x06proto3')
|
|
28
|
+
|
|
29
|
+
_globals = globals()
|
|
30
|
+
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
|
31
|
+
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'bot_service_pb2', _globals)
|
|
32
|
+
if not _descriptor._USE_C_DESCRIPTORS:
|
|
33
|
+
DESCRIPTOR._loaded_options = None
|
|
34
|
+
_globals['_BOTSENDMESSAGEREQUEST']._serialized_start=31
|
|
35
|
+
_globals['_BOTSENDMESSAGEREQUEST']._serialized_end=149
|
|
36
|
+
_globals['_BOTSENDMESSAGERESPONSE']._serialized_start=151
|
|
37
|
+
_globals['_BOTSENDMESSAGERESPONSE']._serialized_end=254
|
|
38
|
+
_globals['_BOTEVENT']._serialized_start=257
|
|
39
|
+
_globals['_BOTEVENT']._serialized_end=438
|
|
40
|
+
_globals['_BOTACTION']._serialized_start=440
|
|
41
|
+
_globals['_BOTACTION']._serialized_end=553
|
|
42
|
+
_globals['_BOTEVENTSERVICE']._serialized_start=556
|
|
43
|
+
_globals['_BOTEVENTSERVICE']._serialized_end=716
|
|
44
|
+
# @@protoc_insertion_point(module_scope)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
|
2
|
+
"""Client and server classes corresponding to protobuf-defined services."""
|
|
3
|
+
import grpc
|
|
4
|
+
import warnings
|
|
5
|
+
|
|
6
|
+
import bot_service_pb2 as bot__service__pb2
|
|
7
|
+
|
|
8
|
+
GRPC_GENERATED_VERSION = '1.82.1'
|
|
9
|
+
GRPC_VERSION = grpc.__version__
|
|
10
|
+
_version_not_supported = False
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from grpc._utilities import first_version_is_lower
|
|
14
|
+
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
|
|
15
|
+
except ImportError:
|
|
16
|
+
_version_not_supported = True
|
|
17
|
+
|
|
18
|
+
if _version_not_supported:
|
|
19
|
+
raise RuntimeError(
|
|
20
|
+
f'The grpc package installed is at version {GRPC_VERSION},'
|
|
21
|
+
+ ' but the generated code in bot_service_pb2_grpc.py depends on'
|
|
22
|
+
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
|
|
23
|
+
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
|
|
24
|
+
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BotEventServiceStub:
|
|
29
|
+
"""Service for high-performance Bot Platform real-time communication
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, channel):
|
|
33
|
+
"""Constructor.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
channel: A grpc.Channel.
|
|
37
|
+
"""
|
|
38
|
+
self.StreamEvents = channel.stream_stream(
|
|
39
|
+
'/xaneo.v1.BotEventService/StreamEvents',
|
|
40
|
+
request_serializer=bot__service__pb2.BotAction.SerializeToString,
|
|
41
|
+
response_deserializer=bot__service__pb2.BotEvent.FromString,
|
|
42
|
+
_registered_method=True)
|
|
43
|
+
self.SendMessage = channel.unary_unary(
|
|
44
|
+
'/xaneo.v1.BotEventService/SendMessage',
|
|
45
|
+
request_serializer=bot__service__pb2.BotSendMessageRequest.SerializeToString,
|
|
46
|
+
response_deserializer=bot__service__pb2.BotSendMessageResponse.FromString,
|
|
47
|
+
_registered_method=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BotEventServiceServicer:
|
|
51
|
+
"""Service for high-performance Bot Platform real-time communication
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def StreamEvents(self, request_iterator, context):
|
|
55
|
+
"""Bi-directional stream for bot events and real-time bot responses
|
|
56
|
+
"""
|
|
57
|
+
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
|
58
|
+
context.set_details('Method not implemented!')
|
|
59
|
+
raise NotImplementedError('Method not implemented!')
|
|
60
|
+
|
|
61
|
+
def SendMessage(self, request, context):
|
|
62
|
+
"""Unary call for fast message sending by bots
|
|
63
|
+
"""
|
|
64
|
+
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
|
65
|
+
context.set_details('Method not implemented!')
|
|
66
|
+
raise NotImplementedError('Method not implemented!')
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def add_BotEventServiceServicer_to_server(servicer, server):
|
|
70
|
+
rpc_method_handlers = {
|
|
71
|
+
'StreamEvents': grpc.stream_stream_rpc_method_handler(
|
|
72
|
+
servicer.StreamEvents,
|
|
73
|
+
request_deserializer=bot__service__pb2.BotAction.FromString,
|
|
74
|
+
response_serializer=bot__service__pb2.BotEvent.SerializeToString,
|
|
75
|
+
),
|
|
76
|
+
'SendMessage': grpc.unary_unary_rpc_method_handler(
|
|
77
|
+
servicer.SendMessage,
|
|
78
|
+
request_deserializer=bot__service__pb2.BotSendMessageRequest.FromString,
|
|
79
|
+
response_serializer=bot__service__pb2.BotSendMessageResponse.SerializeToString,
|
|
80
|
+
),
|
|
81
|
+
}
|
|
82
|
+
generic_handler = grpc.method_handlers_generic_handler(
|
|
83
|
+
'xaneo.v1.BotEventService', rpc_method_handlers)
|
|
84
|
+
server.add_generic_rpc_handlers((generic_handler,))
|
|
85
|
+
server.add_registered_method_handlers('xaneo.v1.BotEventService', rpc_method_handlers)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# This class is part of an EXPERIMENTAL API.
|
|
89
|
+
class BotEventService:
|
|
90
|
+
"""Service for high-performance Bot Platform real-time communication
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def StreamEvents(request_iterator,
|
|
95
|
+
target,
|
|
96
|
+
options=(),
|
|
97
|
+
channel_credentials=None,
|
|
98
|
+
call_credentials=None,
|
|
99
|
+
insecure=False,
|
|
100
|
+
compression=None,
|
|
101
|
+
wait_for_ready=None,
|
|
102
|
+
timeout=None,
|
|
103
|
+
metadata=None):
|
|
104
|
+
return grpc.experimental.stream_stream(
|
|
105
|
+
request_iterator,
|
|
106
|
+
target,
|
|
107
|
+
'/xaneo.v1.BotEventService/StreamEvents',
|
|
108
|
+
bot__service__pb2.BotAction.SerializeToString,
|
|
109
|
+
bot__service__pb2.BotEvent.FromString,
|
|
110
|
+
options,
|
|
111
|
+
channel_credentials,
|
|
112
|
+
insecure,
|
|
113
|
+
call_credentials,
|
|
114
|
+
compression,
|
|
115
|
+
wait_for_ready,
|
|
116
|
+
timeout,
|
|
117
|
+
metadata,
|
|
118
|
+
_registered_method=True)
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def SendMessage(request,
|
|
122
|
+
target,
|
|
123
|
+
options=(),
|
|
124
|
+
channel_credentials=None,
|
|
125
|
+
call_credentials=None,
|
|
126
|
+
insecure=False,
|
|
127
|
+
compression=None,
|
|
128
|
+
wait_for_ready=None,
|
|
129
|
+
timeout=None,
|
|
130
|
+
metadata=None):
|
|
131
|
+
return grpc.experimental.unary_unary(
|
|
132
|
+
request,
|
|
133
|
+
target,
|
|
134
|
+
'/xaneo.v1.BotEventService/SendMessage',
|
|
135
|
+
bot__service__pb2.BotSendMessageRequest.SerializeToString,
|
|
136
|
+
bot__service__pb2.BotSendMessageResponse.FromString,
|
|
137
|
+
options,
|
|
138
|
+
channel_credentials,
|
|
139
|
+
insecure,
|
|
140
|
+
call_credentials,
|
|
141
|
+
compression,
|
|
142
|
+
wait_for_ready,
|
|
143
|
+
timeout,
|
|
144
|
+
metadata,
|
|
145
|
+
_registered_method=True)
|
xc_bot/types/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""xc-bot types"""
|
|
2
|
+
from .user import User, BotAccount
|
|
3
|
+
from .message import Message, Chat, ChatType
|
|
4
|
+
from .update import Update, UpdateType
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"User",
|
|
8
|
+
"BotAccount",
|
|
9
|
+
"Message",
|
|
10
|
+
"Chat",
|
|
11
|
+
"ChatType",
|
|
12
|
+
"Update",
|
|
13
|
+
"UpdateType",
|
|
14
|
+
]
|
xc_bot/types/message.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Message and Chat types"""
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from xc_bot.bot import Bot
|
|
9
|
+
|
|
10
|
+
from .user import User
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ChatType(Enum):
|
|
14
|
+
PRIVATE = "private"
|
|
15
|
+
GROUP = "group"
|
|
16
|
+
CHANNEL = "channel"
|
|
17
|
+
UNKNOWN = "unknown"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Chat:
|
|
22
|
+
id: str
|
|
23
|
+
type: ChatType
|
|
24
|
+
title: Optional[str] = None
|
|
25
|
+
member_count: Optional[int] = None
|
|
26
|
+
subscriber_count: Optional[int] = None
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_dict(cls, data: dict) -> "Chat":
|
|
30
|
+
chat_type = data.get("type", "unknown")
|
|
31
|
+
try:
|
|
32
|
+
chat_type_enum = ChatType(chat_type)
|
|
33
|
+
except ValueError:
|
|
34
|
+
chat_type_enum = ChatType.UNKNOWN
|
|
35
|
+
|
|
36
|
+
return cls(
|
|
37
|
+
id=data["chat_id"],
|
|
38
|
+
type=chat_type_enum,
|
|
39
|
+
title=data.get("title"),
|
|
40
|
+
member_count=data.get("member_count"),
|
|
41
|
+
subscriber_count=data.get("subscriber_count"),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Message:
|
|
47
|
+
id: int
|
|
48
|
+
chat_id: str
|
|
49
|
+
author: User
|
|
50
|
+
text: str
|
|
51
|
+
created_at: datetime
|
|
52
|
+
reply_to: Optional[int] = None
|
|
53
|
+
is_bot_message: bool = False
|
|
54
|
+
_bot: Optional["Bot"] = field(default=None, repr=False)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_dict(cls, data: dict, bot: Optional["Bot"] = None) -> "Message":
|
|
58
|
+
author_data = data.get("author", {})
|
|
59
|
+
if isinstance(author_data, dict):
|
|
60
|
+
author = User.from_dict(author_data)
|
|
61
|
+
else:
|
|
62
|
+
author = User(id=data.get("author_id", 0), username=data.get("author_username"))
|
|
63
|
+
|
|
64
|
+
created_at = data.get("created_at")
|
|
65
|
+
if isinstance(created_at, str):
|
|
66
|
+
created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
|
67
|
+
elif isinstance(created_at, (int, float)):
|
|
68
|
+
created_at = datetime.fromtimestamp(created_at)
|
|
69
|
+
else:
|
|
70
|
+
created_at = datetime.now()
|
|
71
|
+
|
|
72
|
+
return cls(
|
|
73
|
+
id=data["id"],
|
|
74
|
+
chat_id=data["chat_id"],
|
|
75
|
+
author=author,
|
|
76
|
+
text=data.get("text", ""),
|
|
77
|
+
created_at=created_at,
|
|
78
|
+
reply_to=data.get("reply_to_id") or data.get("reply_to"),
|
|
79
|
+
is_bot_message=data.get("is_bot_message", False),
|
|
80
|
+
_bot=bot,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
async def reply(self, text: str, **kwargs) -> "Message":
|
|
84
|
+
if self._bot is None:
|
|
85
|
+
raise RuntimeError("Message object not linked to Bot instance")
|
|
86
|
+
return await self._bot.send_message(
|
|
87
|
+
self.chat_id, text, reply_to_message_id=self.id, **kwargs
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
async def edit(self, text: str) -> bool:
|
|
91
|
+
if self._bot is None:
|
|
92
|
+
raise RuntimeError("Message object not linked to Bot instance")
|
|
93
|
+
return await self._bot.edit_message(self.id, text)
|
|
94
|
+
|
|
95
|
+
async def delete(self) -> bool:
|
|
96
|
+
if self._bot is None:
|
|
97
|
+
raise RuntimeError("Message object not linked to Bot instance")
|
|
98
|
+
return await self._bot.delete_message(self.id)
|
xc_bot/types/update.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Update types"""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any, Optional, TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from xc_bot.bot import Bot
|
|
8
|
+
|
|
9
|
+
from .message import Message
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UpdateType(Enum):
|
|
13
|
+
MESSAGE = "message"
|
|
14
|
+
EDITED_MESSAGE = "edited_message"
|
|
15
|
+
DELETED_MESSAGE = "deleted_message"
|
|
16
|
+
CALLBACK_QUERY = "callback_query"
|
|
17
|
+
CHAT_MEMBER = "chat_member"
|
|
18
|
+
UNKNOWN = "unknown"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Update:
|
|
23
|
+
update_id: int
|
|
24
|
+
update_type: UpdateType
|
|
25
|
+
message: Optional[Message] = None
|
|
26
|
+
data: dict = None
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_dict(cls, data: dict, bot: Optional["Bot"] = None) -> "Update":
|
|
30
|
+
update_type_str = data.get("update_type", "unknown")
|
|
31
|
+
try:
|
|
32
|
+
update_type = UpdateType(update_type_str)
|
|
33
|
+
except ValueError:
|
|
34
|
+
update_type = UpdateType.UNKNOWN
|
|
35
|
+
|
|
36
|
+
message = None
|
|
37
|
+
message_data = data.get("message")
|
|
38
|
+
if message_data:
|
|
39
|
+
message = Message.from_dict(message_data, bot=bot)
|
|
40
|
+
|
|
41
|
+
return cls(
|
|
42
|
+
update_id=data["update_id"],
|
|
43
|
+
update_type=update_type,
|
|
44
|
+
message=message,
|
|
45
|
+
data=data,
|
|
46
|
+
)
|
xc_bot/types/user.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""User types"""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class User:
|
|
9
|
+
id: int
|
|
10
|
+
username: Optional[str] = None
|
|
11
|
+
first_name: Optional[str] = None
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def from_dict(cls, data: dict) -> "User":
|
|
15
|
+
return cls(
|
|
16
|
+
id=data["id"],
|
|
17
|
+
username=data.get("username"),
|
|
18
|
+
first_name=data.get("first_name"),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class BotAccount:
|
|
24
|
+
id: int
|
|
25
|
+
username: str
|
|
26
|
+
display_name: str
|
|
27
|
+
description: str = ""
|
|
28
|
+
can_join_groups: bool = True
|
|
29
|
+
can_read_all_messages: bool = False
|
|
30
|
+
inline_mode: bool = False
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_dict(cls, data: dict) -> "BotAccount":
|
|
34
|
+
return cls(
|
|
35
|
+
id=data["id"],
|
|
36
|
+
username=data["username"],
|
|
37
|
+
display_name=data.get("display_name", ""),
|
|
38
|
+
description=data.get("description", ""),
|
|
39
|
+
can_join_groups=data.get("can_join_groups", True),
|
|
40
|
+
can_read_all_messages=data.get("can_read_all_messages", False),
|
|
41
|
+
inline_mode=data.get("inline_mode", False),
|
|
42
|
+
)
|
xc_bot/webhook/server.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Webhook server for xc-bot"""
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Callable, Optional
|
|
7
|
+
|
|
8
|
+
from aiohttp import web
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("xc_bot.webhook")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WebhookServer:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
bot,
|
|
17
|
+
path: str = "/webhook",
|
|
18
|
+
host: str = "0.0.0.0",
|
|
19
|
+
port: int = 8443,
|
|
20
|
+
secret: Optional[str] = None,
|
|
21
|
+
):
|
|
22
|
+
self.bot = bot
|
|
23
|
+
self.path = path
|
|
24
|
+
self.host = host
|
|
25
|
+
self.port = port
|
|
26
|
+
self.secret = secret
|
|
27
|
+
self._app: Optional[web.Application] = None
|
|
28
|
+
self._runner: Optional[web.AppRunner] = None
|
|
29
|
+
|
|
30
|
+
def _verify_signature(self, payload: bytes, signature: str) -> bool:
|
|
31
|
+
if not self.secret:
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
if not signature.startswith("sha256="):
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
expected_sig = signature[7:]
|
|
38
|
+
computed_sig = hmac.new(
|
|
39
|
+
self.secret.encode(),
|
|
40
|
+
payload,
|
|
41
|
+
hashlib.sha256,
|
|
42
|
+
).hexdigest()
|
|
43
|
+
|
|
44
|
+
return hmac.compare_digest(expected_sig, computed_sig)
|
|
45
|
+
|
|
46
|
+
async def _handle_webhook(self, request: web.Request) -> web.Response:
|
|
47
|
+
signature = request.headers.get("X-XC-Signature", "")
|
|
48
|
+
|
|
49
|
+
payload = await request.read()
|
|
50
|
+
|
|
51
|
+
if not self._verify_signature(payload, signature):
|
|
52
|
+
logger.warning("Invalid webhook signature")
|
|
53
|
+
return web.Response(status=401, text="Unauthorized")
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(payload)
|
|
57
|
+
except json.JSONDecodeError:
|
|
58
|
+
logger.warning("Invalid JSON in webhook payload")
|
|
59
|
+
return web.Response(status=400, text="Bad Request")
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
await self.bot._process_update(data)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.exception(f"Error processing webhook update: {e}")
|
|
65
|
+
return web.Response(status=500, text="Internal Server Error")
|
|
66
|
+
|
|
67
|
+
return web.Response(text="OK")
|
|
68
|
+
|
|
69
|
+
async def start(self) -> None:
|
|
70
|
+
self._app = web.Application()
|
|
71
|
+
self._app.router.add_post(self.path, self._handle_webhook)
|
|
72
|
+
|
|
73
|
+
self._runner = web.AppRunner(self._app)
|
|
74
|
+
await self._runner.setup()
|
|
75
|
+
|
|
76
|
+
site = web.TCPSite(self._runner, self.host, self.port)
|
|
77
|
+
await site.start()
|
|
78
|
+
|
|
79
|
+
logger.info(f"Webhook server started on http://{self.host}:{self.port}{self.path}")
|
|
80
|
+
|
|
81
|
+
async def stop(self) -> None:
|
|
82
|
+
if self._runner:
|
|
83
|
+
await self._runner.cleanup()
|
|
84
|
+
await self.bot.close()
|
|
85
|
+
|
|
86
|
+
def run(self) -> None:
|
|
87
|
+
import asyncio
|
|
88
|
+
|
|
89
|
+
loop = asyncio.new_event_loop()
|
|
90
|
+
asyncio.set_event_loop(loop)
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
loop.run_until_complete(self.start())
|
|
94
|
+
loop.run_forever()
|
|
95
|
+
except KeyboardInterrupt:
|
|
96
|
+
pass
|
|
97
|
+
finally:
|
|
98
|
+
loop.run_until_complete(self.stop())
|
|
99
|
+
loop.close()
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xc-bot
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python SDK for XaneoConnect Bot API
|
|
5
|
+
Author: Xaneo Team
|
|
6
|
+
Author-email: Xaneo Team <ltpddwk@gmail.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/saneome2/xc-bot
|
|
9
|
+
Project-URL: Documentation, https://xaneo.com/bot-api
|
|
10
|
+
Project-URL: Repository, https://github.com/saneome2/xc-bot
|
|
11
|
+
Keywords: xaneo,bot,api,sdk,async
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Framework :: AsyncIO
|
|
22
|
+
Requires-Python: >=3.8
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: aiohttp>=3.8.0
|
|
26
|
+
Requires-Dist: grpcio>=1.60.0
|
|
27
|
+
Requires-Dist: protobuf>=4.25.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
31
|
+
Requires-Dist: aioresponses>=0.7.0; extra == "dev"
|
|
32
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
33
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
34
|
+
Dynamic: author
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
Dynamic: requires-python
|
|
37
|
+
|
|
38
|
+
# xc-bot
|
|
39
|
+
|
|
40
|
+
Python SDK for XaneoConnect Bot API
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install xc-bot
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from xc_bot import Bot
|
|
52
|
+
|
|
53
|
+
bot = Bot("xbot_12345_abc...")
|
|
54
|
+
|
|
55
|
+
@bot.command('start')
|
|
56
|
+
async def start(message):
|
|
57
|
+
await message.reply("Hello! I'm a bot.")
|
|
58
|
+
|
|
59
|
+
bot.run()
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Features
|
|
63
|
+
|
|
64
|
+
- Async-only API (Python 3.8+)
|
|
65
|
+
- Decorator-based handlers: `@bot.command()`, `@bot.hears()`, `@bot.message()`
|
|
66
|
+
- Message object with `.reply()`, `.edit()`, `.delete()` methods
|
|
67
|
+
- Webhook server with HMAC signature verification
|
|
68
|
+
- Automatic retry with exponential backoff
|
|
69
|
+
|
|
70
|
+
## API Methods
|
|
71
|
+
|
|
72
|
+
| Method | Description |
|
|
73
|
+
| -------------------------------- | --------------------- |
|
|
74
|
+
| `get_me()` | Get bot information |
|
|
75
|
+
| `send_message(chat_id, text)` | Send a message |
|
|
76
|
+
| `edit_message(message_id, text)` | Edit a message |
|
|
77
|
+
| `delete_message(message_id)` | Delete a message |
|
|
78
|
+
| `get_chat(chat_id)` | Get chat info |
|
|
79
|
+
| `get_chat_members(chat_id)` | Get chat members |
|
|
80
|
+
| `leave_chat(chat_id)` | Leave a chat |
|
|
81
|
+
| `set_commands(commands)` | Register bot commands |
|
|
82
|
+
| `set_webhook(url)` | Set webhook URL |
|
|
83
|
+
| `delete_webhook()` | Remove webhook |
|
|
84
|
+
|
|
85
|
+
## Handlers
|
|
86
|
+
|
|
87
|
+
### Commands
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
@bot.command(['start', 'help'])
|
|
91
|
+
async def start_handler(message):
|
|
92
|
+
await message.reply("Welcome!")
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Regex
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
@bot.hears(r"^/echo (.+)$")
|
|
99
|
+
async def echo_handler(message, match):
|
|
100
|
+
await message.reply(match.group(1))
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### All Messages
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
@bot.message
|
|
107
|
+
async def log_message(message):
|
|
108
|
+
print(f"[{message.chat_id}] {message.text}")
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Webhook
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from xc_bot import Bot, WebhookServer
|
|
115
|
+
|
|
116
|
+
bot = Bot("xbot_token")
|
|
117
|
+
|
|
118
|
+
@bot.command("start")
|
|
119
|
+
async def start(message):
|
|
120
|
+
await message.reply("Hello!")
|
|
121
|
+
|
|
122
|
+
# Set webhook
|
|
123
|
+
await bot.set_webhook("https://your-server.com/webhook", secret="your-secret")
|
|
124
|
+
|
|
125
|
+
# Start webhook server
|
|
126
|
+
server = WebhookServer(bot, port=8443, secret="your-secret")
|
|
127
|
+
server.run()
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Getting a Token
|
|
131
|
+
|
|
132
|
+
1. Open chat with @BotConstructor in XaneoConnect
|
|
133
|
+
2. Send `/newbot`
|
|
134
|
+
3. Follow the instructions
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
xc_bot/__init__.py,sha256=ZSSt2PXe3PLlHGtJDIP7A2sDnmE2LhmqEg-wYIqJdFU,684
|
|
2
|
+
xc_bot/api.py,sha256=n7U2yO1J_HNEtatSbMw0V8d1j0bwO_tBOTAJofKqNAw,6141
|
|
3
|
+
xc_bot/bot.py,sha256=wp56nehOjbGO7SlaxpmvKgEAhlYn9vFNJ3DV3wHvrWQ,5328
|
|
4
|
+
xc_bot/exceptions.py,sha256=VF4jxgmOkaZfK3t4gGLmR-KNLpZp0FmeKgHZSnAzQik,903
|
|
5
|
+
xc_bot/grpc_client.py,sha256=-soGiJCAfydRqeIph5Cq7o3TwREOhJ7qhIHABTFdW-4,1952
|
|
6
|
+
xc_bot/handlers/__init__.py,sha256=TDZzC9e2gUF-HE6rWtPzw49k3VgA64U3KoSQXMz8leQ,121
|
|
7
|
+
xc_bot/handlers/handler.py,sha256=UCnHqj7zCoODRP302rtu2fwKPm5eHPKthWbsZ--IOlc,2895
|
|
8
|
+
xc_bot/pb/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
xc_bot/pb/bot_service_pb2.py,sha256=UUcNanfVAVIqmQwNsZT-MCakmopbFbTauTyhZhmKOe8,2896
|
|
10
|
+
xc_bot/pb/bot_service_pb2_grpc.py,sha256=PqrfJ03HlmBceMUSepnKG2rDH_R6cRoG4xQ5sZ4f8tA,5291
|
|
11
|
+
xc_bot/types/__init__.py,sha256=LBLd1t9fQBpOKpjGokBdZBsp7xfKW2aUvGkIzqsYI3k,258
|
|
12
|
+
xc_bot/types/message.py,sha256=_4Ctfeoazn3a0X9SbGdUUinNaXi9YOiIgrLx1X1BknA,2985
|
|
13
|
+
xc_bot/types/update.py,sha256=dH4NYwBw8Qt6x-h5Uklak8UrWB5Eqbm3im01KFpHWfc,1170
|
|
14
|
+
xc_bot/types/user.py,sha256=B5BOce7wOIO3vSsSJSN64z__Oggyjzn9rKSqI_A92Qg,1110
|
|
15
|
+
xc_bot/webhook/__init__.py,sha256=v2mgclMAvMPJh6VD5AxaRiT6Qwgc6v59uviIPWdnfcU,85
|
|
16
|
+
xc_bot/webhook/server.py,sha256=1jvAyFH3_VNFdDph1pY24UV42wvjfamc4aZvP0ebubE,2829
|
|
17
|
+
xc_bot-1.0.0.dist-info/licenses/LICENSE,sha256=7B4ez0s_T_yYbtXe5iqdQUkU2WYkEvRavLj4QFJMjrA,1067
|
|
18
|
+
xc_bot-1.0.0.dist-info/METADATA,sha256=TPhwkpUssAChuqJrI71eu_dL3lZscgCbdcPz8oSUD6I,3569
|
|
19
|
+
xc_bot-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
20
|
+
xc_bot-1.0.0.dist-info/top_level.txt,sha256=x1LO3Yess6VFuhcPEQ4VJcGVlHEnYx4cF2EwI3F-EbY,7
|
|
21
|
+
xc_bot-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Xaneo Team
|
|
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 @@
|
|
|
1
|
+
xc_bot
|