commit 26f511535a19887f2e1a66982309ce2391cca7f2 Author: ScuroNeko Date: Tue Jan 2 00:25:23 2024 +0300 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d7b8d27 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.idea/ +**/__pycache__ +/.venv +test/ +*.egg-info +dist/ + +._* \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..26e55c5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,4 @@ +**1.0** +- +--- +Initial release \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5b765c9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b524124 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +**Kuro Core** +- +Требуется Python 3.6-3.11 + + +Ниже возможно работает. + + +***LINKS*** +- +**[Changelog](CHANGELOG.md)** +| +**[MIT License](LICENSE)** +| +**[Author VK](https://vk.com/scur0nek0)** \ No newline at end of file diff --git a/kurocore/__init__.py b/kurocore/__init__.py new file mode 100644 index 0000000..ed22c57 --- /dev/null +++ b/kurocore/__init__.py @@ -0,0 +1,8 @@ +from .config import Config +from .bot import Bot +from .logger import BotLogger, RequestLogger, ClientLogger + +from main.plugins import Plugin, MethodWithPriority + +from main.event import ChatEvent, Event +from main.message import Message, MessageArgs diff --git a/kurocore/bot.py b/kurocore/bot.py new file mode 100644 index 0000000..d86201e --- /dev/null +++ b/kurocore/bot.py @@ -0,0 +1,59 @@ +import sys +from asyncio import WindowsSelectorEventLoopPolicy, set_event_loop_policy +from platform import system + +from .logger import BotLogger, RequestLogger, ClientLogger +from .main.handler import Handler + + +class Bot: + __slots__ = ('config', 'is_running', 'middlewares', 'tasks') + + def __init__(self, config): + BotLogger(config) + RequestLogger(config) + ClientLogger(config) + self.config = config + # Database(self.config) + self.is_running = False + self.middlewares = [] + self.tasks = [] + if sys.version_info >= (3, 8) and sys.platform.lower().startswith("win"): + set_event_loop_policy(WindowsSelectorEventLoopPolicy()) + + def add_plugin(self, plugin): + if self.is_running: + BotLogger.log.error('Bot already running!') + self.config.plugins.append(plugin) + + def add_prefix(self, prefix): + if self.is_running: + BotLogger.log.error('Bot already running!') + self.config.prefixes.append(prefix) + + def add_middleware(self, middleware): + if self.is_running: + BotLogger.log.error('Bot already running!') + self.middlewares.append(middleware) + + def add_task(self, task): + if self.is_running: + BotLogger.log.error('Bot already running!') + if system == 'Windows': + BotLogger.log.warning('Windows don\'t support tasks') + return + self.tasks.append(task) + + def run(self): + handler = Handler(self.config, self.middlewares) + handler.init() + try: + for task in self.tasks: + BotLogger.log.debug(f'registered task: {task}') + handler.loop.create_task(task()) + self.is_running = True + handler.run() + except KeyboardInterrupt: + handler.shutdown() + # if Database.db and not Database.db.is_closed(): + # Database.db.close() diff --git a/kurocore/config.py b/kurocore/config.py new file mode 100644 index 0000000..4cc9304 --- /dev/null +++ b/kurocore/config.py @@ -0,0 +1,73 @@ +from dataclasses import dataclass, field, asdict +from json import loads, dump, dumps +from typing import TextIO + +config_version = 1 + + +@dataclass +class Config: + @dataclass + class Logs: + path: str = 'logs/' + name: str = 'KuroCore' + level: str = 'DEBUG' + requests: bool = False + console: bool = False + + @dataclass + class Database: + driver: str = 'psql' + host: str = '' + name: str = '' + user: str = 'root' + password: str = '' + port: int = 5432 + + @dataclass + class Callback: + enabled: bool = False + code: str = '' + port: int = 9980 + secret: str = '' + + version: int = config_version + tokens: list[str] = field(default_factory=list[str]) + prefixes: list[str] = field(default_factory=list[str]) + sentry_dsn: str = '' + debug: bool = False + logs: Logs = field(default_factory=Logs) + database: Database = field(default_factory=Database) + callback: Callback = field(default_factory=Callback) + + plugins = [] + + @staticmethod + def loads(config_str: str): + json: dict = loads(config_str) + if json.get('version', 0) < config_version: + raise Exception('This config was generated for old version! Move config file and try again.') + if json.get('version', 0) > config_version: + raise Exception('This config was generated for newer version! Move config file and try again.') + + json.update({'callback': Config.Callback(**json['callback'])}) + json.update({'database': Config.Database(**json['database'])}) + json.update({'logs': Config.Logs(**json['logs'])}) + + return Config(**json) + + @staticmethod + def loadp(path: str): + try: + with open(path, 'r') as f: + return Config.loads(f.read()) + except FileNotFoundError: + gen = asdict(Config()) + with open(path, 'w') as f: + dump(gen, f) + return Config.loads(dumps(gen)) + + @staticmethod + def load(file: TextIO): + return Config.loads(file.read()) + diff --git a/kurocore/logger.py b/kurocore/logger.py new file mode 100644 index 0000000..9cf20e0 --- /dev/null +++ b/kurocore/logger.py @@ -0,0 +1,113 @@ +from logging import Formatter, StreamHandler, Logger, DEBUG, INFO, WARNING, ERROR +from logging.handlers import TimedRotatingFileHandler +from os import makedirs +from os.path import dirname + +from aiohttp import ClientSession, TraceRequestStartParams, FormData +from multidict import CIMultiDict + +from .config import Config + + +def mkdir_p(path): + try: + makedirs(dirname(path), exist_ok=True) + except TypeError: + makedirs(dirname(path)) + + +class BotLogger: + log: Logger + + def __init__(self, config: Config): + formatter = Formatter( + "[%(name)s] [%(levelname)s] [%(asctime)s] [%(filename)s:%(lineno)d]: %(message)s", + "%H:%M:%S %d.%m.%Y", + ) + mkdir_p(config.logs.path) + level = DEBUG if config.debug else INFO + + BotLogger.log = Logger(config.logs.name or "KuroCore", level=level) + + console_handler = StreamHandler() + console_handler.setFormatter(formatter) + + file_handler = TimedRotatingFileHandler( + "logs/log.log", when="midnight", encoding="utf-8" + ) + file_handler.suffix = "%d.%m.%Y.log" + file_handler.setFormatter(formatter) + + BotLogger.log.addHandler(console_handler) + BotLogger.log.addHandler(file_handler) + + BotLogger.log.info("Logger initialized") + + +class RequestLogger: + log: Logger + + def __init__(self, config: Config) -> None: + formatter = Formatter( + '[%(name)s] [REQUEST] [%(asctime)s]: %(message)s', "%H:%M:%S %d.%m.%Y" + ) + mkdir_p(config.logs.path) + RequestLogger.log = Logger(config.logs.name, level=DEBUG) + + console_handler = StreamHandler() + console_handler.setFormatter(formatter) + + file_handler = TimedRotatingFileHandler(f'{config.logs.path}/requests.log', 'midnight') + file_handler.suffix = "%d.%m.%Y.log" + file_handler.setFormatter(formatter) + + if config.logs.console: + RequestLogger.log.addHandler(console_handler) + RequestLogger.log.addHandler(file_handler) + + RequestLogger.log.info("Request logger initialized") + + +class ClientLogger: + log: Logger + + def __init__(self, config: Config): + formatter = Formatter( + "[%(name)s] [CLIENT] [%(asctime)s]: %(message)s", + "%H:%M:%S %d.%m.%Y", + ) + mkdir_p(config.logs.path) + + ClientLogger.log = Logger(config.logs.name, level=DEBUG) + + console_handler = StreamHandler() + console_handler.setFormatter(formatter) + + file_handler = TimedRotatingFileHandler("logs/client.log", when="midnight", encoding="utf-8") + file_handler.suffix = "%d.%m.%Y.log" + file_handler.setFormatter(formatter) + + if config.logs.console: + ClientLogger.log.addHandler(console_handler) + ClientLogger.log.addHandler(file_handler) + + ClientLogger.log.info("Client logger initialized") + + @staticmethod + def format_header(headers: CIMultiDict): + out = [] + for k, v in headers.items(): + out.append(f'{k}:{v}') + return f'<{", ".join(out)}>' + + @staticmethod + async def on_request_start(session: ClientSession, context, params: TraceRequestStartParams): + formatted_headers = ClientLogger.format_header(params.headers) + ClientLogger.log.debug(f'request {params.method} {params.url} headers={formatted_headers}') + + +class LoggingClientSession(ClientSession): + async def _request(self, method, url, **kwargs): + ClientLogger.log.debug(f'request <{method}> "{url}"') + ClientLogger.log.debug(f' body: {kwargs}') + return await super()._request(method, url, **kwargs) diff --git a/kurocore/main/__init__.py b/kurocore/main/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kurocore/main/event.py b/kurocore/main/event.py new file mode 100644 index 0000000..76a2a3b --- /dev/null +++ b/kurocore/main/event.py @@ -0,0 +1,42 @@ +from kurocore.utils.vk_utils import generate_random_id + + +class ChatEvent: + __slots__ = ('session', 'api', 'raw', 'type', 'member_id', 'text', 'photo') + + def __init__(self, session, api, raw: dict): + self.session = session + self.api = api + self.raw: dict = raw + + self.type: str = self.raw.get('type', '') + self.member_id: int = self.raw.get('member_id', 0) + self.text: str = self.raw.get('text', '') + self.photo: dict = self.raw.get('photo', {}) + + +class Event: + __slots__ = ('session', 'api', 'raw', 'type') + + def __init__(self, session, api, raw: dict): + self.session = session + self.api = api + + self.raw: dict = raw['object'] + self.type: str = raw['type'] + + async def send_message(self, target_id, text, attachments: (str, list, tuple, set, frozenset) = '', **kwargs): + data = kwargs.copy() + data.update({ + 'peer_id': target_id, + 'random_id': generate_random_id() + }) + + if text: + data.update({'message': text}) + if attachments: + if type(attachments) == str: + data.update({'attachment': attachments}) + else: + data.update({'attachment': ','.join(attachments)}) + await self.api.messages.send(**data) diff --git a/kurocore/main/handler.py b/kurocore/main/handler.py new file mode 100644 index 0000000..6be3f61 --- /dev/null +++ b/kurocore/main/handler.py @@ -0,0 +1,256 @@ +import asyncio +import traceback + +from aiohttp.web import run_app +from sentry_sdk import init as sentry_init, capture_exception, set_user + +from .event import ChatEvent, Event +from kurocore.utils.database.database import Database +from kurocore.utils.vk.longpoll import VKLongPoll, VkBotEventType +from kurocore.utils.vk.vk import VK +from ..logger import BotLogger + + +class Handler: + __slots__ = ( + 'config', 'session', 'api', 'plugins', 'middlewares', 'loop', 'app' + ) + + def __init__(self, config, middlewares: list): + self.config = config + self.session = None + self.api = None + self.app = None + self.plugins = [] + self.middlewares = middlewares + self.loop = asyncio.get_event_loop() + + def init(self): + if not self.config.tokens: + BotLogger.log.error('No access token!') + exit() + self.session = VK(self.config.tokens[0]) + self.api = self.session.get_api() + + if not self.config.debug and self.config.sentry_dsn: + sentry_init( + self.config.sentry_dsn, + traces_sample_rate=1.0 + ) + BotLogger.log.info('Sentry initialized!') + + for p in self.config.plugins: + for init in p.init_methods: + BotLogger.log.debug(f'Init: {p.__class__.__name__}') + init.call() + self.plugins.append(p) + + def shutdown(self): + for p in self.plugins: + for method in p.shutdown_methods: + method.call() + BotLogger.log.info('Bot has been shutdown!') + + async def check_payload(self, msg): + payload = msg.payload['command'] if 'command' in msg.payload else '' + args = msg.payload['args'] if 'args' in msg.payload else [] + + for p in self.plugins: + if p.custom_checker: + try: + for before_process in p.before_process_methods: + before_process() + await p.custom_checker(msg, p) + return True + except Exception as e: + if self.config.debug: + BotLogger.log.error(traceback.format_exc()) + else: + capture_exception(e) + + if payload in p.payloads.keys(): + try: + msg.meta.update({'args': args}) + + for before_process in p.before_process_methods: + before_process.call() + + valid, args = await p._validate_payload_args(payload, args) + + if not valid: + await msg.answer('Неверное количество или тип аргументов!') + return False + + for before_process in p.before_process_methods: + before_process.call() + + if valid and args is not None: + await p._process_payload_with_args(payload, msg, args) + else: + await p._process_payload(payload, msg) + return True + except Exception as e: + if self.config.debug: + BotLogger.log.error(traceback.format_exc()) + else: + capture_exception(e) + + else: + return False + + async def check_command(self, msg): + text = msg.text + + for prefix in self.config.prefixes: + if text.startswith(prefix): + text = text[len(prefix):] + msg.meta['prefix'] = prefix + break + else: + if msg.is_chat: + return + + for p in self.plugins: + if p.custom_checker: + try: + for before_process in p.before_process_methods: + before_process() + await p.custom_checker(msg, p) + return + except Exception as e: + if self.config.debug: + BotLogger.log.error(traceback.format_exc()) + else: + capture_exception(e) + + for command in p.commands: + if text.startswith(command): + # if p._is_vip_command(command): + # user = await get_user_or_none(msg.from_id) + # if not user or not user.group.is_vip: + # return msg.answer('Для доступа к этой команде требует VIP доступ!') + # + # if p._is_admin_command(command): + # user = await get_user_or_none(msg.from_id) + # if not user or not user.group.is_admin: + # return msg.answer('Данная комманда доступна только для администраторов!') + try: + msg.meta['cmd'] = command + args = text[len(command) + 1:].split() + msg.meta['args'] = args + args_valid, args = await p._validate_command_args(command, args) + if not args_valid: + return await msg.answer('Неверное количество или тип аргументов!') + + for before_process in p.before_process_methods: + before_process.call() + + await p._process_command(command, msg, args) + return + except Exception as e: + if self.config.debug: + BotLogger.log.error(traceback.format_exc()) + else: + capture_exception(e) + + async def check(self, msg): + db = Database.db + + if db: + if not db.is_closed(): + BotLogger.log.debug('Connection reused!') + else: + db.connect() + BotLogger.log.debug('Connection reopened!') + else: + BotLogger.log.debug('No database') + + for m in self.middlewares: + await m(msg) + + for plugin in self.plugins: + for method in plugin.before_check_methods: + await method(msg) + + if not await self.check_payload(msg): + await self.check_command(msg) + + # if db: + # db.close() + # BotLogger.log.debug('Connection closed!') + + async def check_event(self, event: (ChatEvent, Event), msg): + event_type = event.type + + for plugin in self.plugins: + if event_type in plugin.chat_events.keys(): + try: + for before_process in plugin.before_process_methods: + before_process.call() + return await plugin.chat_events[event_type](event, msg) + except Exception as e: + if self.config.debug: + BotLogger.log.error(traceback.format_exc()) + else: + capture_exception(e) + + elif event_type in plugin.events.keys(): + try: + for before_process in plugin.before_process_methods: + before_process.call() + return await plugin.events[event_type](event) + except Exception as e: + if self.config.debug: + BotLogger.log.error(traceback.format_exc()) + else: + capture_exception(e) + + def run(self): + if self.config.callback.enabled: + run_app(self.app, port=self.config.port) + try: + # Register all plugins tasks + for p in self.config.plugins: + for task in p.tasks: + BotLogger.log.debug(f'registered task: {task.__name__}') + self.loop.create_task(task()) + self.loop.run_until_complete(self._run()) + except KeyboardInterrupt: + self.session.shutdown() + # self.shutdown() + + async def handle_event(self, event): + from kurocore import Message + if ((event.type == VkBotEventType.MESSAGE_NEW and 'action' not in event.obj) + or event.type == VkBotEventType.MESSAGE_EVENT): + msg = Message(self.session, self.api, event.obj) + if msg.user_id > 0: + set_user({'id': msg.user_id}) + await self.check(msg) + + elif event.type == VkBotEventType.MESSAGE_NEW and 'action' in event.obj: + e = ChatEvent(self.session, self.api, event.obj['action']) + msg = Message(self.session, self.api, event.obj) + if msg.user_id > 0: + set_user({'id': msg.user_id}) + await self.check_event(e, msg) + + else: + e = Event(self.session, self.api, event.raw) + await self.check_event(e, None) + + async def _run(self): + group = (await self.api.groups.getById())['groups'][0] + BotLogger.log.info(f'Login as {group["name"]} (https://vk.com/public{group["id"]})') + + lp = VKLongPoll(self.config, self.session) + await lp.init_lp() + + async for event in lp.listen(): + try: + await self.loop.create_task(self.handle_event(event)) + except Exception as e: + if self.config.debug: + BotLogger.log.error(traceback.format_exc()) + else: + capture_exception(e) diff --git a/kurocore/main/message.py b/kurocore/main/message.py new file mode 100644 index 0000000..682f691 --- /dev/null +++ b/kurocore/main/message.py @@ -0,0 +1,331 @@ +import json +from enum import Enum +from typing import Union, Type, Tuple + +from kurocore.utils.vk.keyboard import VkKeyboard +from kurocore.utils.vk.vk import VKApiException +from kurocore.utils.vk_utils import generate_random_id + + +class MessageArgs(dict): + def __init__(self, args: dict): + self.__args: dict = args + super().__init__(args) + + def __getattr__(self, item): + try: + return self.__args[item] + except KeyError: + return None + + +class VkObject: + __slots__ = ( + 'raw', + ) + + def __init__(self, raw): + self.raw = raw + + +class PhotoSize: + __slots__ = ( + 'type', 'url', 'width', 'height' + ) + + def __init__(self, raw: dict): + self.type: str = raw['type'] + self.url: str = raw['url'] + self.width: int = raw['width'] + self.height: int = raw['height'] + + +class StickerSize: + __slots__ = ( + 'url', 'width', 'height' + ) + + def __init__(self, raw: dict): + self.url: str = raw['url'] + self.width: int = raw['width'] + self.height: int = raw['height'] + + +class Photo(VkObject): + __slots__ = ( + 'id', 'album_id', 'owner_id', 'access_key', + 'user_id', 'text', 'date', 'sizes', + 'type' + ) + + def __init__(self, raw: dict): + super().__init__(raw) + self.type = 'photo' + self.raw: dict = raw['photo'] + + self.id: int = self.raw['id'] + self.album_id: int = self.raw['album_id'] + self.owner_id: int = self.raw['owner_id'] + self.access_key: str = self.raw.get('access_key', '') + self.user_id: int = self.raw.get('user_id', 0) + + self.text: str = self.raw['text'] + self.date: int = self.raw['date'] # unix time + self.sizes = [PhotoSize(r) for r in self.raw['sizes']] + + def __repr__(self): + return f'photo{self.owner_id}_{self.id}' + + +class Sticker(VkObject): + __slots__ = ( + 'type', 'images', 'images_with_background', + 'animation_url', 'is_allowed' + ) + + def __init__(self, raw: dict): + super().__init__(raw) + + self.type = 'sticker' + self.raw: dict = raw['sticker'] + + self.images = [StickerSize(img) for img in self.raw['images']] + self.images_with_background = [StickerSize(img) for img in self.raw['images_with_background']] + + self.animation_url = self.raw.get('animation_url', '') + self.is_allowed = self.raw.get('is_allowed') + + def __repr__(self): + return f'sticker{self}' + + +class DocumentType(Enum): + TEXT = 1 + ARCHIVE = 2 + GIF = 3 + PHOTO = 4 + AUDIO = 5 + VIDEO = 6 + BOOKS = 7 + UNKNOWN = 8 + + +class Document(VkObject): + __slots__ = ( + 'id', 'album_id', 'owner_id', + 'title', 'size', 'ext', 'url', + 'date', 'type' + ) + + def __init__(self, raw: dict): + super().__init__(raw) + self.raw: dict = raw['doc'] + self.id: int = self.raw['id'] + self.owner_id: int = self.raw['owner_id'] + + self.title: str = self.raw['title'] + self.size: int = self.raw['size'] # размер в байтах + self.ext: str = self.raw['ext'] # расширение + self.url: str = self.raw['url'] + self.date: int = self.raw['date'] # unix time + self.type: DocumentType = self.raw['type'] + + def __repr__(self): + return f'doc{self.owner_id}_{self.id}' + + +Attachment = Type[Photo], Type[Document], Type[Sticker] + + +def load_attachments(raw: list[dict]) -> list[Attachment]: + attachments = [] + for attachment in raw: + match attachment['type']: + case 'photo': + attachments.append(Photo(attachment)) + case 'doc': + attachments.append(Document(attachment)) + case 'sticker': + attachments.append(Sticker(attachment)) + case _: + attachments.append(VkObject(attachment)) + return attachments + + +def dump_attachments(raw_attachments: list[Attachment]) -> str: + return ','.join(map(repr, raw_attachments)) + + +class EventActions: + SHOW_SNACKBAR = 'show_snackbar' + OPEN_LINK = 'open_link' + OPEN_APP = 'open_app' + + +class MessageID: + def __init__(self, body): + if type(body) is dict: + self.message_id = body.get('message_id', 0) + self.cmid = body.get('cmid', 0) + else: + self.message_id = body + self.cmid = body + + +class Message(VkObject): + __slots__ = ( + 'vk', 'api', 'raw', 'id', 'conversation_message_id', 'cmid', + 'date', 'peer_id', 'from_id', 'user_id', 'chat_id', 'is_chat', + 'original_text', 'text', 'attachments', 'payload', + 'event_id', 'forwarded_messages', 'reply_message', + 'meta' + ) + + def __init__(self, vk, api, raw): + super().__init__(raw) + + self.vk = vk + self.api = api + + if type(raw) is Message: + self.raw = raw.raw + else: + self.raw = raw.get('message', raw) + + self.id: int = self.raw.get('id', 0) + self.conversation_message_id: int = self.raw.get('conversation_message_id', 0) + self.cmid: int = self.conversation_message_id + self.date: int = self.raw.get('date', 0) + + self.peer_id: int = self.raw.get('peer_id', 0) + self.from_id: int = self.raw.get('from_id', 0) + self.user_id: int = self.raw.get('user_id', self.from_id) + + self.chat_id: int = self.peer_id - 2000000000 + self.is_chat: bool = self.chat_id > 0 + + self.original_text: str = self.raw.get('text', '') + self.text: str = self.original_text.lower() + + self.attachments: list[Attachment] = load_attachments(self.raw.get('attachments', [])) + + raw_payload = self.raw.get('payload', '{}') + if type(raw_payload) is dict: + self.payload: dict = raw_payload + else: + self.payload: dict = json.loads(raw_payload) + + self.event_id: str = self.raw.get('event_id', '') + + self.forwarded_messages: list = self.raw.get('fwd_messages', []) + self.reply_message = Message(self.vk, self.api, self.raw['reply_message']) \ + if 'reply_message' in self.raw else None + + self.meta: dict = {} + + async def send(self, peer_id, text: str = '', attachments: (Tuple[Attachment], Attachment, str) = '', + keyboard: Union[VkKeyboard, dict] = None, **kwargs): + data = kwargs.copy() + data.update({ + 'peer_id': peer_id, + 'random_id': generate_random_id() + }) + + if text: + data.update({'message': text}) + + if keyboard: + if type(keyboard) is VkKeyboard: + data.update({'keyboard': keyboard.get_keyboard()}) + else: + data.update({'keyboard': keyboard}) + + if attachments: + if type(attachments) is str: + data.update({'attachment': attachments}) + elif type(attachments) in (Photo, Document): + data.update({'attachment': str(attachments)}) + else: + data.update({'attachment': dump_attachments(attachments)}) + + return MessageID(await self.api.messages.send(**data)) + + async def answer_event(self, event_data): + data = { + 'peer_id': self.peer_id, + 'event_id': self.event_id, + 'user_id': self.user_id, + 'event_data': json.dumps(event_data), + } + + try: + return await self.api.messages.sendMessageEventAnswer(**data) + except VKApiException: + ... + + async def answer_event_hide_keyboard(self, event_data): + await self.answer_event(event_data) + + await self.api.messages.edit( + peer_id=self.peer_id, + conversation_message_id=self.conversation_message_id, + keyboard=VkKeyboard.get_empty_keyboard() + ) + + async def answer(self, text: str = '', attachments: (list[Attachment], Attachment, str) = '', **kwargs): + return await self.send(self.peer_id, text, attachments, **kwargs) + + async def edit(self, text='', attachments: list[Attachment] = '', keyboard: Union[VkKeyboard, dict] = None, + **kwargs): + data: dict = kwargs.copy() + data.update({ + 'peer_id': self.peer_id + }) + + if text: + data.update({'message': text}) + + if keyboard: + if type(keyboard) is VkKeyboard: + data.update({'keyboard': keyboard.get_keyboard()}) + else: + data.update({'keyboard': keyboard}) + + if attachments: + if type(attachments) is str: + data.update({'attachment': attachments}) + elif type(attachments) in (Photo, Document): + data.update({'attachment': str(attachments)}) + elif type(attachments) is list: + if type(attachments[0]) is dict: + data.update({'attachment': dump_attachments(load_attachments(attachments))}) + else: + data.update({'attachment': dump_attachments(attachments)}) + + if 'cmid' not in kwargs and 'conversation_message_id' not in kwargs: + data.update({ + 'conversation_message_id': self.conversation_message_id + }) + if 'cmid' in kwargs: + data.update({ + 'conversation_message_id': kwargs['cmid'] + }) + kwargs.pop('cmid') + + try: + message_id = await self.api.messages.edit(**data) + except VKApiException: + message_id = await self.api.messages.send(**data, random_id=generate_random_id()) + + return MessageID(message_id) + + async def get_by_cmid(self, cmid: int): + data = { + 'peer_id': self.peer_id, + 'conversation_message_ids': [cmid] + } + res = await self.api.messages.getByConversationMessageId(**data) + return Message(self.vk, self.api, res['items'][0]) + + def __repr__(self): + return str(self.raw) diff --git a/kurocore/main/plugins.py b/kurocore/main/plugins.py new file mode 100644 index 0000000..69e4660 --- /dev/null +++ b/kurocore/main/plugins.py @@ -0,0 +1,249 @@ +import inspect +import re + + +class MethodWithPriority: + __slots__ = ('priority', 'method') + + def __init__(self, method, priority): + self.priority = priority + self.method = method + + def call(self): + self.method() + + +class Plugin: + __slots__ = ('custom_checker', 'custom_processor', + 'commands', 'commands_args', 'commands_help', + 'args_help', 'before_check_methods', + 'vip_commands', 'admin_commands', + 'payloads', 'payloads_args', + 'events', 'chat_events', 'init_methods', + 'before_process_methods', 'shutdown_methods', + 'tasks') + + def __init__(self, custom_checker=None, custom_processor=None): + self.custom_checker = custom_checker + self.custom_processor = custom_processor + + self.before_check_methods: list = [] + + self.commands: dict = {} + self.commands_args: dict = {} + self.commands_help: dict = {} + self.args_help: dict = {} + + self.vip_commands: list = [] + self.admin_commands: list = [] + + self.payloads: dict = {} + self.payloads_args: dict = {} + + self.events: dict = {} + self.chat_events: dict = {} + + self.init_methods: list = [] + self.before_process_methods: list = [] + + self.shutdown_methods: list = [] + + self.tasks: list = [] + + def __repr__(self): + return str({ + 'custom_checker': self.custom_checker, + 'custom_processor': self.custom_processor, + + 'before_check_commands': self.before_check_methods, + + 'commands': list(self.commands.keys()), + 'commands_args': list(self.commands_args.keys()), + 'commands_help': list(self.commands_help.keys()), + 'args_help': list(self.args_help.keys()), + + 'vip_commands': self.vip_commands, + 'admin_commands': self.admin_commands, + + 'payloads': list(self.payloads.keys()), + 'payloads_args': list(self.payloads_args.keys()), + + 'events': list(self.events.keys()), + 'chat_events': list(self.chat_events.keys()), + + 'init_methods': self.init_methods, + 'before_process_methods': self.before_process_methods, + + 'shutdown_methods': self.shutdown_methods, + + 'tasks': self.tasks + }) + + def init(self, priority: int = 0): + def wrapper(f): + self.init_methods.append(MethodWithPriority(f, priority)) + self.init_methods.sort(key=lambda method: method.priority, reverse=True) + return f + + return wrapper + + def before_process(self, priority: int = 0): + def wrapper(f): + self.before_process_methods.append(MethodWithPriority(f, priority)) + self.before_process_methods.sort(key=lambda method: method.priority, reverse=True) + return f + + return wrapper + + def on_command(self, *commands, args='', h=tuple(), is_admin: bool = False): + def wrapper(f): + self.commands.update(map(lambda cmd: (cmd, f), commands)) + if is_admin: + self.admin_commands.append(*commands) + + if args: + self.commands_args.update(map(lambda cmd: (cmd, args), commands)) + + if h: + self.commands_help.update({commands[0]: h[0]}) + if len(h) > 1: + self.args_help.update({commands[0]: h[1:]}) + + return f + + return wrapper + + def before_check(self, f): + self.before_check_methods.append(f) + # print(self.before_check_methods) + return f + + def vip_command(self, f): + for k in self.commands.keys(): + self.vip_commands.append(k) + return f + + def admin_command(self, f): + for k in self.commands.keys(): + self.admin_commands.append(k) + return f + + def on_payload(self, *payloads: str, args=''): + def wrapper(f): + if args: + self.payloads_args.update(map(lambda cmd: (cmd, args), payloads)) + self.payloads.update(dict(map(lambda payload: (payload, f), payloads))) + return f + + return wrapper + + def on_event(self, *events): + def wrapper(f): + self.chat_events.update( + map(lambda event: (event, f), filter(lambda event: event.startswith('chat'), events))) + self.events.update(map(lambda event: (event, f), + filter(lambda event: not event.startswith('chat'), events))) + return f + + return wrapper + + def on_shutdown(self, priority: int = 0): + def wrapper(f): + self.shutdown_methods.append(MethodWithPriority(f, priority)) + self.shutdown_methods.sort(key=lambda method: method.priority, reverse=True) + return f + return wrapper + + """Decorator for task + """ + def task(self, f): + self.tasks.append(f) + return f + + async def _process_command(self, command: str, msg, args): + sig = inspect.signature(self.commands[command]) + if len(sig.parameters) == 1: + await self.commands[command](msg) + elif len(sig.parameters) == 2: + await self.commands[command](msg, args) + + async def _process_payload(self, payload: str, msg): + await self.payloads[payload](msg) + + async def _process_payload_with_args(self, payload: str, msg, args): + await self.payloads[payload](msg, args) + + def _is_vip_command(self, command: str) -> bool: + return command in self.vip_commands + + def _is_admin_command(self, command: str) -> bool: + return command in self.admin_commands + + async def _validate_command_args(self, command: str, cmd_args: tuple): + from kurocore import MessageArgs + commands_args = self.commands_args + if command not in commands_args: + return True, MessageArgs({}) + + args = commands_args[command].split() + + if not cmd_args and not tuple(filter(lambda x: '?' not in x, args)): + return True, MessageArgs({}) + + if len(cmd_args) < len(tuple(filter(lambda x: '?' not in x, args))): + return False, None + + args_map = [] + for arg in args: + name, arg_type = arg.split(':') + if name.endswith('?'): + name = name[:-1] + + arg_type = arg_type.replace('str', r'.').replace('int', r'\d') + args_map.append((name, re.compile(arg_type))) + + args = dict() + + for index in range(len(cmd_args)): + if len(args_map) == index: + break + name, expression = args_map[index] + if not expression.match(cmd_args[index]): + return False, None + args.update({name: cmd_args[index]}) + + return True, MessageArgs(args) + + async def _validate_payload_args(self, payload: str, msg_args: dict): + from kurocore import MessageArgs + payloads_args = self.payloads_args + if payload not in payloads_args: + return True, None + + args = payloads_args[payload].split() + if len(msg_args) < len(tuple(filter(lambda x: '?' not in x, args))): + return False, None + + args_map = [] + for arg in args: + name, arg_type = arg.split(':') + if name.endswith('?'): + name = name[:-1] + arg_type = arg_type.replace('str', r'.').replace('int', r'\d') + args_map.append((name, re.compile(arg_type))) + + args = dict() + + for index in range(len(msg_args)): + name, expression = args_map[index] + if not expression.match(str(tuple(msg_args.values())[index])): + return False, None + args.update({name: tuple(msg_args.values())[index]}) + + return True, MessageArgs(args) + + async def _process_event(self, event_type: str, event): + await self.events[event_type](event) + + async def _process_chat_event(self, event_type: str, event, msg): + await self.chat_events[event_type](event, msg) diff --git a/kurocore/utils/__init__.py b/kurocore/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kurocore/utils/database/__init__.py b/kurocore/utils/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kurocore/utils/database/database.py b/kurocore/utils/database/database.py new file mode 100644 index 0000000..2c7d312 --- /dev/null +++ b/kurocore/utils/database/database.py @@ -0,0 +1,29 @@ +import logging +from asyncio import get_event_loop +from peewee_async import PostgresqlDatabase, Manager + +from kurocore.logger import BotLogger + + +class Database: + db: PostgresqlDatabase = PostgresqlDatabase(None) + manager: Manager + + def __init__(self, settings): + db_settings = settings.database + + driver_name = db_settings.driver + host = db_settings.host + name = db_settings.name + user = db_settings.user + password = db_settings.password + port = db_settings.port + + if driver_name.lower() not in ('postgres', 'postgresql', 'psql'): + raise + + Database.db.init(name, user=user, password=password, host=host, port=port) + loop = get_event_loop() + Database.manager = Manager(Database.db, loop=loop) + Database.manager.allow_sync = logging.ERROR + BotLogger.log.info('Connected to database') diff --git a/kurocore/utils/database/models.py b/kurocore/utils/database/models.py new file mode 100644 index 0000000..20726a2 --- /dev/null +++ b/kurocore/utils/database/models.py @@ -0,0 +1,173 @@ +from peewee import Model, IntegerField, BigIntegerField, DecimalField, TextField, ForeignKeyField, DateTimeField, \ + BooleanField, CompositeKey, CharField, AutoField + +# from plugins import models +from utils.database.database import Database + + +class BaseModel(Model): + class Meta: + database = Database.db + + +class Groups(BaseModel): + id = IntegerField(null=False) + name = TextField(default='') + is_vip = BooleanField(default=False) + is_admin = BooleanField(default=False) + is_tester = BooleanField(default=False) + multiplier = DecimalField(default=1) + sale = DecimalField(default=1) + max_multigen = IntegerField(default=3) + + +class Works(BaseModel): + id = IntegerField(null=False) + name = TextField(default='') + required_lvl = IntegerField(default=1) + money_income = DecimalField(default=0) + min_exp = IntegerField(default=0) + max_exp = IntegerField(default=0) + + +class Fractions(BaseModel): + id = IntegerField() + name = TextField(default='Без названия', null=False) + owner_id = IntegerField(null=False) + money = DecimalField() + exp = IntegerField() + level = IntegerField() + + +class Auto(BaseModel): + id = IntegerField() + name = TextField() + price = DecimalField() + + class Meta: + table_name = 'shop_auto' + + +class Business(BaseModel): + id = IntegerField() + name = TextField() + price = DecimalField() + income = DecimalField() + + class Meta: + table_name = 'shop_business' + + +class Maid(BaseModel): + id = IntegerField() + name = TextField() + price = DecimalField() + income = DecimalField() + + class Meta: + table_name = 'shop_maid' + + +class Miner(BaseModel): + id = IntegerField() + name = TextField() + price = DecimalField() + income = DecimalField() + + class Meta: + table_name = 'shop_miner' + + +class Users(BaseModel): + id = IntegerField() + user_id = BigIntegerField() + + name = TextField() + greeting = TextField() + pair = IntegerField() + donat = IntegerField() + + balance = DecimalField(default=10000, max_digits=20) + invested = DecimalField(default=0, max_digits=20) + btc = DecimalField(default=0, max_digits=20, decimal_places=6) + level = IntegerField() + exp = DecimalField(default=0) + + group_id = IntegerField() + group = ForeignKeyField(Groups) + fraction = ForeignKeyField(Fractions, null=True, lazy_load=True) + + work = ForeignKeyField(Works) + work_time = DateTimeField() + + income_time = DateTimeField() + + auto = ForeignKeyField(Auto) + business = ForeignKeyField(Business) + maid = ForeignKeyField(Maid) + miner = ForeignKeyField(Miner) + + subscribed = BooleanField() + + +class Reports(BaseModel): + id = IntegerField(unique=True, null=False, primary_key=True) + text = TextField(null=False) + admin_answer = TextField(null=True) + from_id = BigIntegerField(null=False) + date = DateTimeField() + status = CharField(null=False) + attachments = TextField(null=True) + + +class FractionMember(BaseModel): + fraction = ForeignKeyField(Fractions) + user = ForeignKeyField(Users) + is_moderator = BooleanField() + is_admin = BooleanField() + + class Meta: + table_name = 'fraction_member' + primary_key = CompositeKey('fraction', 'user') + + +# WD +class WDModels(BaseModel): + model_id = IntegerField(null=False) + name = TextField(null=False) + image = TextField(null=False) + description = TextField(null=False) + + class Meta: + table_name = 'wd_models' + + +class WDUsers(BaseModel): + user_id = IntegerField(null=False) + model = ForeignKeyField(WDModels) + model_id = IntegerField(null=False, default=1) + orientation = TextField(null=False, default='PORTRAIT') + + class Meta: + table_name = 'wd_users' + primary_key = CompositeKey('user_id', 'model_id') + + +class WDLoras(BaseModel): + id = IntegerField(null=False) + name = TextField(null=False, default='NOT SET') + trigger_prompt = TextField(null=False, default='') + prompt_mixin = TextField(null=False, default='') + + class Meta: + table_name = 'wd_loras' + primary_key = CompositeKey('id', 'trigger_prompt') + + +class WDPrompts(BaseModel): + id = AutoField(null=False) + user_id = IntegerField(null=False) + prompt = TextField(null=False) + + class Meta: + table_name = 'wd_prompts' diff --git a/kurocore/utils/utils.py b/kurocore/utils/utils.py new file mode 100644 index 0000000..c7e1262 --- /dev/null +++ b/kurocore/utils/utils.py @@ -0,0 +1,42 @@ +import re + + +def chunk_array(array, chunk_number) -> list: + if len(array) <= chunk_number: + return [array] + out, tmp, index = [], [], 0 + for el in array: + tmp.append(el) + index += 1 + if index == chunk_number: + out.append(tmp) + tmp = [] + index = 0 + out.append(tmp) + return out + + +def parse_attachments(attachments) -> tuple: + out = [] + for attach in attachments: + t = attach['type'] + if t == 'wall': + out.append(f'{t}{attach[t]["from_id"]}_{attach[t]["id"]}') + else: + out.append(f'{t}{attach[t]["owner_id"]}_{attach[t]["id"]}') + return tuple(out) + + +def parse_mention(mention: (str, None)) -> int: + if not mention: + return 0 + reg = r'\[id(\d+)\|.+\]' + match = re.match(reg, mention) + if not match: + return 0 + return int(match.group(1)) + + +async def get_user_sex(user_id, api): + query = await api.users.get(user_ids=user_id, fields='sex') + return query[0]['sex'] if len(query) > 0 else 2 diff --git a/kurocore/utils/vk/__init__.py b/kurocore/utils/vk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kurocore/utils/vk/keyboard.py b/kurocore/utils/vk/keyboard.py new file mode 100644 index 0000000..b8bffdb --- /dev/null +++ b/kurocore/utils/vk/keyboard.py @@ -0,0 +1,184 @@ +import json +from enum import Enum +from json import JSONEncoder + + +class EnumEncoder(JSONEncoder): + def default(self, obj): + return obj.value + + +class VkKeyboardColor(Enum): + PRIMARY = 'primary' + SECONDARY = 'secondary' + NEGATIVE = 'negative' + POSITIVE = 'positive' + + +class VkKeyboard: + def __init__(self, one_time=False, inline=False): + self.inline = inline + self.lines = [[]] + + self.keyboard = { + 'inline': self.inline, + 'buttons': self.lines + } + if not inline: + self.keyboard['one_time'] = one_time + + def __load_payload(self, payload) -> str: + if isinstance(payload, str): + return payload + elif isinstance(payload, dict): + return json.dumps(payload) + elif isinstance(payload, Payload): + return json.dumps(payload.get()) + + def add_text_button(self, text, payload=None, color=VkKeyboardColor.PRIMARY): + current_line = self.lines[-1] + if len(current_line) == 5: + raise TypeError('max elements in line: 5') + + action = { + 'type': 'text', + 'label': text + } + if payload: + action.update({'payload': self.__load_payload(payload)}) + button = { + 'color': color, + 'action': action + } + current_line.append(button) + + def add_link_button(self, text, link, payload=None): + current_line = self.lines[-1] + if len(current_line) == 5: + raise TypeError('max elements in line: 5') + + action = { + 'type': 'open_link', + 'link': link, + 'label': text + } + if payload: + action.update({'payload': self.__load_payload(payload)}) + button = { + 'action': action + } + current_line.append(button) + + def add_location_button(self, payload=None): + current_line = self.lines[-1] + if len(current_line) == 5: + raise TypeError('max elements in line: 5') + + action = { + 'type': 'location' + } + + if payload: + action.update({'payload': self.__load_payload(payload)}) + + button = { + 'action': action + } + current_line.append(button) + + def add_vk_pay_button(self, hash): + current_line = self.lines[-1] + if len(current_line) == 5: + raise TypeError('max elements in line: 5') + + action = { + 'type': 'vkpay', + 'hash': hash + } + button = { + 'action': action + } + current_line.append(button) + + def add_vk_apps_button(self, label, app_id, owner_id=None, hash=None, payload=None): + current_line = self.lines[-1] + if len(current_line) == 5: + raise TypeError('max elements in line: 5') + + action = { + 'type': 'open_app', + 'label': label, + 'app_id': app_id + } + if owner_id: + action.update({'owner_id': owner_id}) + if hash: + action.update({'hash': hash}) + if payload: + action.update({'payload': self.__load_payload(payload)}) + + button = { + 'action': action + } + current_line.append(button) + + def add_callback_button(self, label, payload=None, color=VkKeyboardColor.PRIMARY): + current_line = self.lines[-1] + if len(current_line) == 5: + raise TypeError('max elements in line: 5') + + action = { + 'type': 'callback', + 'label': label + } + + if payload: + action.update({'payload': self.__load_payload(payload)}) + + button = { + 'action': action, + 'color': color + } + current_line.append(button) + + def add_line(self): + if len(self.lines) == 10: + if self.inline: + raise TypeError('max lines: 6') + else: + raise TypeError('max lines: 10') + self.lines.append([]) + + def get_current_line(self): + return self.lines[-1] + + def get_keyboard(self): + keyboard = self.keyboard.copy() + if 'buttons' not in keyboard: + keyboard.update({'buttons': self.lines}) + return json.dumps(keyboard, cls=EnumEncoder) + + @classmethod + def get_empty_keyboard(cls): + keyboard = cls(True) + keyboard.keyboard['buttons'] = [] + return keyboard.get_keyboard() + + @classmethod + def get_empty_inline(cls): + keyboard = cls(False, True) + keyboard.keyboard['buttons'] = [] + return keyboard.get_keyboard() + + +class Payload: + def __init__(self, cmd, **kwargs): + self.cmd = cmd + self.args: dict = kwargs + self.value = {'command': cmd} + + def get(self): + if self.args: + return {'command': self.cmd, 'args': self.args} + else: + return {'command': self.cmd} diff --git a/kurocore/utils/vk/longpoll.py b/kurocore/utils/vk/longpoll.py new file mode 100644 index 0000000..0495cb3 --- /dev/null +++ b/kurocore/utils/vk/longpoll.py @@ -0,0 +1,170 @@ +from enum import Enum +from logging import Logger + +from kurocore import Config +from kurocore.logger import RequestLogger +from kurocore.utils.vk.vk import VK + + +class DotDict(dict): + __getattr__ = dict.get + __setattr__ = dict.__setitem__ + __delattr__ = dict.__delitem__ + + +class VKLongPoll: + __slots__ = ('vk', 'api', 'config', 'server', 'key', 'ts') + + def __init__(self, config: Config, vk: VK): + self.vk = vk + self.api = vk.get_api() + self.config = config + + self.server = '' + self.key = '' + self.ts = 0 + + async def init_lp(self): + group = (await self.api.groups.getById())['groups'][0] + lp = await self.api.groups.getLongPollServer(group_id=group['id']) + + self.server = lp['server'] + self.key = lp['key'] + self.ts = lp['ts'] + + async def check(self): + async with self.vk.session.get(f'{self.server}?act=a_check&key={self.key}&ts={self.ts}&wait=25') as res: + body = await res.json() + + if 'failed' in body: + code = body['failed'] + if code == 1: + self.ts = body['ts'] + if code == 2 or code == 3: + await self.init_lp() + else: + self.ts = body['ts'] + + if self.config.logs.requests: + RequestLogger.log.info(body) + + for event in body['updates']: + yield VkBotEvent(event) + + async def listen(self): + while True: + async for event in self.check(): + Logger.log.debug(f'new event: {event.raw}') + yield event + + +class VkBotEvent(object): + __slots__ = ( + 'raw', + 't', 'type', + 'obj', 'object', + 'client_info', 'message', + 'group_id' + ) + + def __init__(self, raw): + self.raw = raw + + try: + self.type = VkBotEventType(raw['type']) + except ValueError: + self.type = raw['type'] + + self.t = self.type # shortcut + + self.object = DotDict(raw['object']) + try: + self.message = DotDict(raw['object']['message']) + except KeyError: + self.message = None + self.obj = self.object + try: + self.client_info = DotDict(raw['object']['client_info']) + except KeyError: + self.client_info = None + + self.group_id = raw['group_id'] + + def __repr__(self): + return '<{}({})>'.format(type(self), self.raw) + + +class VkBotEventType(Enum): + MESSAGE_NEW = 'message_new' + MESSAGE_REPLY = 'message_reply' + MESSAGE_EDIT = 'message_edit' + MESSAGE_EVENT = 'message_event' + + MESSAGE_TYPING_STATE = 'message_typing_state' + + MESSAGE_ALLOW = 'message_allow' + + MESSAGE_DENY = 'message_deny' + + PHOTO_NEW = 'photo_new' + + PHOTO_COMMENT_NEW = 'photo_comment_new' + PHOTO_COMMENT_EDIT = 'photo_comment_edit' + PHOTO_COMMENT_RESTORE = 'photo_comment_restore' + + PHOTO_COMMENT_DELETE = 'photo_comment_delete' + + AUDIO_NEW = 'audio_new' + + VIDEO_NEW = 'video_new' + + VIDEO_COMMENT_NEW = 'video_comment_new' + VIDEO_COMMENT_EDIT = 'video_comment_edit' + VIDEO_COMMENT_RESTORE = 'video_comment_restore' + + VIDEO_COMMENT_DELETE = 'video_comment_delete' + + WALL_POST_NEW = 'wall_post_new' + WALL_REPOST = 'wall_repost' + + WALL_REPLY_NEW = 'wall_reply_new' + WALL_REPLY_EDIT = 'wall_reply_edit' + WALL_REPLY_RESTORE = 'wall_reply_restore' + + WALL_REPLY_DELETE = 'wall_reply_delete' + + BOARD_POST_NEW = 'board_post_new' + BOARD_POST_EDIT = 'board_post_edit' + BOARD_POST_RESTORE = 'board_post_restore' + + BOARD_POST_DELETE = 'board_post_delete' + + MARKET_COMMENT_NEW = 'market_comment_new' + MARKET_COMMENT_EDIT = 'market_comment_edit' + MARKET_COMMENT_RESTORE = 'market_comment_restore' + + MARKET_COMMENT_DELETE = 'market_comment_delete' + + GROUP_LEAVE = 'group_leave' + GROUP_JOIN = 'group_join' + + USER_BLOCK = 'user_block' + USER_UNBLOCK = 'user_unblock' + + POLL_VOTE_NEW = 'poll_vote_new' + + GROUP_OFFICERS_EDIT = 'group_officers_edit' + GROUP_CHANGE_SETTINGS = 'group_change_settings' + GROUP_CHANGE_PHOTO = 'group_change_photo' + + VKPAY_TRANSACTION = 'vkpay_transaction' + + APP_PAYLOAD = 'app_payload' + + DONUT_SUBSCRIPTION_CREATE = 'donut_subscription_create' + DONUT_SUBSCRIPTION_PROLONGED = 'donut_subscription_prolonged' + DONUT_SUBSCRIPTION_EXPIRED = 'donut_subscription_expired' + DONUT_SUBSCRIPTION_CANCELLED = 'donut_subscription_cancelled' + DONUT_SUBSCRIPTION_PRICE_CHANGED = 'donut_subscription_price_changed' + DONUT_SUBSCRIPTION_WITHDRAW = 'donut_money_withdraw' + DONUT_SUBSCRIPTION_WITHDRAW_ERROR = 'donut_money_withdraw_error' diff --git a/kurocore/utils/vk/upload.py b/kurocore/utils/vk/upload.py new file mode 100644 index 0000000..e6735ec --- /dev/null +++ b/kurocore/utils/vk/upload.py @@ -0,0 +1,64 @@ +import json + +from aiohttp import FormData + +from handler.message import load_attachments +from kurocore import LoggingClientSession +from utils.vk.vk import VK, VkApiMethod + + +class JsonParser: + @staticmethod + def dumps(data): + return json.dumps(data, ensure_ascii=False, separators=(",", ":")) + + @staticmethod + def loads(string): + return json.loads(string) + + +class VkUpload(object): + __slots__ = ('vk',) + + def __init__(self, vk): + if not isinstance(vk, (VK, VkApiMethod)): + raise TypeError('The arg should be VK or VkApiMethod instance') + + if isinstance(vk, VkApiMethod): + self.vk = vk + else: + self.vk = vk.get_api() + + async def photo_messages(self, photo, pid=0): + upload_info = await self.vk.photos.getMessagesUploadServer(peer_id=pid) + + data = FormData() + data.add_field( + 'photo', photo, + content_type='multipart/form-data', + filename='a.png', + ) + + async with LoggingClientSession() as session, session.post(upload_info['upload_url'], data=data) as response: + response = await response.text() + response = json.loads(response) + + photos = await self.vk.photos.saveMessagesPhoto(**response) + photos = [{'type': 'photo', 'photo': photo} for photo in photos] + return load_attachments(photos) + + async def doc_message(self, doc, pid): + upload_info = await self.vk.docs.getMessagesUploadServer(peer_id=pid) + + data = FormData() + data.add_field( + 'file', doc, + content_type='multipart/form-data', + filename=f'a.png', + ) + + async with LoggingClientSession() as session, session.post(upload_info['upload_url'], data=data) as response: + response = await response.text() + response = json.loads(response) + + return load_attachments([await self.vk.docs.save(**response)]) diff --git a/kurocore/utils/vk/utils.py b/kurocore/utils/vk/utils.py new file mode 100644 index 0000000..fe5381f --- /dev/null +++ b/kurocore/utils/vk/utils.py @@ -0,0 +1,6 @@ +from json import JSONEncoder + + +class EnumEncoder(JSONEncoder): + def default(self, obj): + return obj.value diff --git a/kurocore/utils/vk/vk.py b/kurocore/utils/vk/vk.py new file mode 100644 index 0000000..17ed811 --- /dev/null +++ b/kurocore/utils/vk/vk.py @@ -0,0 +1,117 @@ +import asyncio +from time import time +from typing import Union + +from aiohttp import ClientSession, FormData + +from kurocore.logger import RequestLogger + + +class VkToken: + __slots__ = ( + 'cur_requests', 'max_rps', + '__token', '__last_req' + ) + + def __init__(self, token): + self.cur_requests = 0 + self.max_rps = 20 + self.__token = token + self.__last_req = 0 + + def __call__(self): + # if self.cur_requests >= self.max_rps: + # raise TypeError('too many requests') + # self.cur_requests += 1 + # self.__last_req = int(time()) + # if self.__last_req < int(time()): + # self.cur_requests = 0 + return self.__token + + +class VkTokenProvider: + __slots__ = ( + 'tokens', + ) + + def __init__(self, tokens: (Union[VkToken], VkToken)): + if type(tokens) is str: + self.tokens = [VkToken(tokens)] + else: + self.tokens = [VkToken(t) for t in tokens] + + def obtain_token(self): + return self.tokens[0]() + # for t in self.tokens: + # if t.cur_requests < t.max_rps: + # return t() + # else: + # raise ValueError('no free tokens!') + + +class VK: + __slots__ = ( + 'token_provider', + 'v', 'session' + ) + + def __init__(self, tokens: (str, list, tuple, set, frozenset), v='5.220'): + self.token_provider = VkTokenProvider(tokens) + self.v = v + self.session = ClientSession() + + def shutdown(self): + asyncio.get_event_loop().run_until_complete(self.session.close()) + + async def call_method(self, method, **params): + params.update({'v': self.v}) + + params.update({'access_token': self.token_provider.obtain_token()}) + + async with self.session.post( + f'https://api.vk.com/method/{method}', + data=FormData(params) + ) as res: + j = await res.json() + + if 'error' in j: + error = j['error'] + raise VKApiException(error['error_msg'], error['error_code']) + + params.update({"access_token": "[MASKED]"}) + RequestLogger.log.debug(f'method: {method} {params}') + + if 'response' in j: + return j['response'] + + return j + + def get_api(self): + return VkApiMethod(self) + + +class VkApiMethod(object): + __slots__ = ('_vk', '_method') + + def __init__(self, vk, method=None): + self._vk: VK = vk + self._method = method + + def __getattr__(self, method): + if '_' in method: + m = method.split('_') + method = m[0] + ''.join(i.title() for i in m[1:]) + + return VkApiMethod( + self._vk, + (self._method + '.' if self._method else '') + method + ) + + async def __call__(self, **kwargs): + return await self._vk.call_method(self._method, **kwargs) + + +class VKApiException(Exception): + def __init__(self, msg, code): + self.msg = msg + self.code = code diff --git a/kurocore/utils/vk_utils.py b/kurocore/utils/vk_utils.py new file mode 100644 index 0000000..202d7cc --- /dev/null +++ b/kurocore/utils/vk_utils.py @@ -0,0 +1,32 @@ +import io +from random import randint + +from aiohttp import ClientSession + + +def get_self_id(api): + return api.groups.getById()[0]['id'] + + +def generate_random_id(): + return randint(-9 ** 99, 9 ** 99) + + +async def get_user_name(id, api, name_case='nom'): + user = (await api.users.get(user_ids=id, name_case=name_case))[0] + return f'{user["first_name"]} {user["last_name"]}' + + +async def reupload_attachments(attachments, upload): + new_attachments = [] + for a in attachments: + t = a['type'] + if t != 'photo': + continue + url = a[t]['sizes'][-1]['url'] + + async with ClientSession() as session, session.get(url) as response: + file = io.BytesIO(await response.content.read()) + attachment = upload.photo_messages(file)[0] + new_attachments.append(f'photo{attachment["owner_id"]}_{attachment["id"]}') + return new_attachments diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..997f55e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "kurocore" +version = "1.0" +authors = [ + { name="ScuroNeko", email="author@example.com" }, +] +description = "A small example package" +readme = "README.md" +requires-python = ">=3.6" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] + +[project.urls] +Homepage = "https://git.nix13.pw/ScuroNeko/KuroCore" +Issues = "https://git.nix13.pw/ScuroNeko/KuroCore/issues" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bf0a36a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +asyncio +aiohttp +aiopg +psycopg2-binary +peewee +peewee-async +sentry-sdk + +py-aiovk \ No newline at end of file