Салют! Меня зовут Григорий, я главный по спецпроектам в AllSee. Как и у многих из нас, у меня есть вторая половинка, и ей свойственно требовать внимания. Сам по себе я человек занятой и мне бывает трудно отвлечься от дел и написать апдейт девушке, из-за чего приходиться терпеть капризы по причине «недостатка внимания». В статье я рассказываю, как YandexGPT и Python-Telegram «уделяют внимание» моей девушке.
Какие вводные?
Есть чат в Telegram, куда нужно писать апдейты: пожелать доброго утра, написать вечером, как я скучаю, спросить, как дела и так далее. Я хочу автоматизировать отправку сообщений в определённые временные слоты. Тексты апдейтов могут быть вариативными и содержать опциональные вопросы. Для генерации апдейтов я буду использовать YandexGPT API, а для взаимодействия с Telegram — python-telegram.
Routines (служебные функции)
Hidden text
Чтение YAML и JSON
def read_yaml(path: str) -> dict: with open(path, 'r') as stream: return yaml.safe_load(stream) def read_json(path: str) -> dict: with open(path, 'r') as stream: return json.load(stream)
Преобразование списков строк формата «00:00» в списки datetime.time
def yaml_to_datetime_time(times_str: list) -> list: return [ datetime.time( *map( int, time_str.split(':') ) ) for time_str in times_str ]
YandexGPT API
Перейдём сразу к коду для отправки запросов:
def send_completion_request( self, messages: List[dict], temperature: float = 0.6, max_tokens: int = 1000, stream: bool = False, completion_url: str = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion" ) -> dict: # checking if IAM token and catalog id is set if not self._iam_token or not self._catalog_id: raise Exception("IAM token and catalog id must be set to send completion requests") # sending request headers = { "Content-Type": "application/json", "Authorization": f"Bearer {self._iam_token}", "x-folder-id": self._catalog_id } data = { "modelUri": f"gpt://{self._catalog_id}/{self.model_type}/latest", "completionOptions": { "stream": stream, "temperature": temperature, "maxTokens": str(max_tokens) }, "messages": messages } response = requests.post(completion_url, headers=headers, json=data) # checking response if response.status_code == 200: return response.json() else: raise Exception(f"Failed to send completion request. Status code: {response.status_code} {response.text}")
Помимо опциональных параметров temperature, max_tokens, stream, completion_url требуется задать IAM?токен и ID каталога Yandex Cloud.
Сгенерированный в формате JSON ключ также нужно будет передавать объекту класса ChatGPT.
Вот как выглядит полная реализация класса:
Hidden text
class YandexGPT: available_models = [ 'yandexgpt', 'yandexgpt-lite', 'summarization' ] def __init__( self, model_type: str = 'yandexgpt', iam_token: str = None, catalog_id: str = None, yandex_cloud_config_file_path: str = None, yandex_gpt_key_file_path: str = None, iam_url: str = "https://iam.api.cloud.yandex.net/iam/v1/tokens" ) -> None: # setting config files self._yandex_cloud_config_file_path = yandex_cloud_config_file_path self._yandex_gpt_key_file_path = yandex_gpt_key_file_path self._iam_url = iam_url # setting model type if model_type not in self.available_models: raise ValueError(f"Model type must be one of {self.available_models}") else: self.model_type = model_type # setting IAM token if not iam_token: self._set_iam_token() else: self._iam_token = iam_token # setting catalog id if not catalog_id: self._set_catalog_id() else: self._catalog_id = catalog_id def _set_iam_token(self) -> None: # reading yaml config config = read_yaml(self._yandex_cloud_config_file_path) # reading json key key = read_json(self._yandex_gpt_key_file_path) # generating jwt token jwt_token = self._generate_jwt_token( service_account_id=config['ServiceAccountID'], private_key=key['private_key'], key_id=config['ServiceAccountKeyID'], url=self._iam_url ) # sending request to get IAM token and setting IAM token self._iam_token = self._swap_jwt_to_iam( jwt_token=jwt_token, url=self._iam_url ) @staticmethod def _swap_jwt_to_iam( jwt_token: str, url: str = "https://iam.api.cloud.yandex.net/iam/v1/tokens" ) -> str: # sending request to get IAM token headers = { "Content-Type": "application/json" } data = { "jwt": jwt_token } response = requests.post(url, headers=headers, json=data) # checking response if response.status_code == 200: return response.json()['iamToken'] else: raise Exception( f"Failed to get IAM token. " f"Status code: {response.status_code}" f" " f"{response.text}" ) @staticmethod def _generate_jwt_token( service_account_id: str, private_key: str, key_id: str, url: str = 'https://iam.api.cloud.yandex.net/iam/v1/tokens' ) -> str: # generating jwt token now = int(time.time()) payload = { 'aud': url, 'iss': service_account_id, 'iat': now, 'exp': now + 360 } encoded_token = jwt.encode( payload, private_key, algorithm='PS256', headers={'kid': key_id} ) return encoded_token def _set_catalog_id(self) -> None: # reading yaml config file config = read_yaml(self._yandex_cloud_config_file_path) # setting catalog id from config file self._catalog_id = config['CatalogID'] def send_completion_request( self, messages: List[dict], temperature: float = 0.6, max_tokens: int = 1000, stream: bool = False, completion_url: str = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion" ) -> dict: # checking if IAM token and catalog id is set if not self._iam_token or not self._catalog_id: raise Exception("IAM token and catalog id must be set to send completion requests") # sending request headers = { "Content-Type": "application/json", "Authorization": f"Bearer {self._iam_token}", "x-folder-id": self._catalog_id } data = { "modelUri": f"gpt://{self._catalog_id}/{self.model_type}/latest", "completionOptions": { "stream": stream, "temperature": temperature, "maxTokens": str(max_tokens) }, "messages": messages } response = requests.post(completion_url, headers=headers, json=data) # checking response if response.status_code == 200: return response.json() else: raise Exception(f"Failed to send completion request. Status code: {response.status_code} {response.text}")
Я предусмотрел возможность передачи параметров класса напрямую, а также возможность изменить логику инициализации из конфиг файлов для того чтобы каждый желающий мог адаптировать данный функционал под конкретные сценарии использования.
Python-Telegram
Создадим дочерний от telegram.client.Telegram и YangexGPT класс. Он должен уметь определять временной слот и отправлять сгенерированный текст целевому контакту.
Hidden text
class AutoChat(Telegram, YandexGPT): def __init__( self, target_username: str = None, sending_time: List[datetime.time] = None, chat_system_prompt: List[str] = None, chat_mode: bool = None, timezone: pytz.timezone = None, telegram_config_file_path: str = None, auto_chat_config_file_path: str = None, *args, **kwargs, ) -> None: # initializing telegram # checking if api_id, api_hash, phone, database_encryption_key, files_directory and login in **kwargs if all( key in kwargs for key in [ 'api_id', 'api_hash', 'phone', 'database_encryption_key', 'files_directory', 'login' ] ): Telegram.__init__( *args, **kwargs, ) # if there is no then calling custom telegram init method else: self._init_telegram(telegram_config_file_path) # initializing yandex gpt # checking if iam_token and catalog_id in **kwargs if all( key in kwargs for key in [ 'iam_token', 'catalog_id' ] ): YandexGPT.__init__( *args, **kwargs ) # if there is no then calling custom yandex gpt init method else: self._init_yandex_gpt( *args, **kwargs ) # initializing auto chat # checking if target_username, sending_time, chat_system_prompt, chat_mode, timezone is set if all([ target_username, sending_time, chat_system_prompt, chat_mode, timezone ]): self._target_user_id = self._get_target_user_id(target_username) self._sending_time = yaml_to_datetime_time(sending_time) self._chat_system_prompt = chat_system_prompt self._chat_mode = chat_mode self._timezone = timezone # if there is no then calling custom auto chat init method else: self._init_autochat(auto_chat_config_file_path) def _init_yandex_gpt( self, *args, **kwargs ) -> None: # checking if yandex_cloud_config_file_path and yandex_gpt_key_file_path in **kwargs if not all( key in kwargs for key in [ 'yandex_cloud_config_file_path', 'yandex_gpt_key_file_path' ] ): raise ValueError( "AutoChat args must contain either" " 'yandex_cloud_config_file_path' and " "'yandex_gpt_key_file_path' or " "'iam_token' and 'catalog_id'" ) # if they are than calling custom yandex gpt init method else: YandexGPT.__init__( self, *args, **kwargs ) def _init_telegram( self, telegram_config_file_path: str = None, ) -> None: # checking if telegram_config_file_path is set if not telegram_config_file_path: raise ValueError( "AutoChat args must contain either" " 'telegram_config_file_path' or " "'api_id', 'api_hash', 'phone', " "'database_encryption_key', " "'files_directory' and 'login'" ) # if it is than calling custom telegram init method else: # reading config file telegram_config = read_yaml(telegram_config_file_path) # initializing Telegram class Telegram.__init__( self, api_id=int(telegram_config['ApiId']), api_hash=telegram_config['ApiHash'], phone=telegram_config['PhoneNumber'], database_encryption_key=telegram_config['DatabaseEncryptionKey'], files_directory=os.path.join( os.getcwd(), telegram_config['FilesDirectory'] ), login=telegram_config['Login'], ) def _init_autochat( self, auto_chat_config_file_path: str = None ) -> None: # checking if auto_chat_config_file_path is set if not auto_chat_config_file_path: raise ValueError( "AutoChat args must contain ether 'auto_chat_config_file_path'" " or 'target_username', 'sending_time', 'chat_system_prompt'," " 'chat_mode' and 'timezone'" ) # if it is than calling custom auto chat init method # reading config file auto_chat_config = read_yaml(auto_chat_config_file_path) # setting autochat self._target_user_id = self._get_target_user_id(auto_chat_config['TargetUsername']) self._sending_time = yaml_to_datetime_time(auto_chat_config['SendingTime']) self._chat_system_prompt = auto_chat_config['ChatSystemPrompt'] self._chat_mode = auto_chat_config['ChatMode'] self._timezone = pytz.timezone(auto_chat_config['TimeZone']) def _get_target_user_id( self, target_username: str ) -> int: # getting target user id return int(self._get_contact_by_username(target_username)['id']) def _get_client_contacts(self) -> List[dict]: # getting client chats chats = self.get_chats() chats.wait() # getting contacts from private chats contacts = [] for chat_id in chats.update['chat_ids']: chat = self.get_chat(chat_id) chat.wait() if chat.update['type']['@type'] == 'chatTypePrivate': contact = self.get_user(chat_id) contact.wait() contacts.append(contact.update) # returning contacts return contacts def _get_contact_by_username( self, target_username: str ) -> dict: # getting list of all contacts contacts = self._get_client_contacts() # finding target contact by username for contact in contacts: if contact['username'] == target_username: return contact # raise ValueError if there is no such contact raise ValueError(f"Contact with username {target_username} not found") def send_message_to_target_username( self, text: Union[str, Element], entities: Union[List[dict], None] = None, ) -> Union[AsyncResult, None]: # sending message to target contact return self.send_message( chat_id=self._target_user_id, text=text, entities=entities ) def which_sending_time(self) -> int: # getting current time without seconds and microseconds current_time = ( datetime.datetime .now(self._timezone) .replace(second=0, microsecond=0) .time() ) # checking if current time is in sending time list and returning its index or -1 if it is not for index, sending_time_unit in enumerate(self._sending_time): if current_time == sending_time_unit: return index return -1 def check_time_and_send(self): # checking if current time is in sending time list which_sending_time = self.which_sending_time() # if it is than sending message if which_sending_time > -1: prompt = self._chat_system_prompt[which_sending_time] text = self.send_completion_request( messages=[ { "role": "system", "text": prompt } ] )['result']['alternatives'][0]['message']['text'] self.send_message_to_target_username(text=text)
Как видно, я также вынес весь код связанный с инициализацией целевых параметров в отдельные методы и оставил возможность загрузить готовые параметры вместо конфигов при их наличии.
Для инициализации родительского класса Telegram будем передавать в AutoChat следующие параметры (как их получить и для чего они нужны смотрите тут и тут):
А вот настройки связанные с логикой отправки сообщений:
TargetUsername: aaaaaa SendingTime: [ '6:15', '18:20' ] ChatSystemPrompt: [ 'Ты - парень программист, у тебя есть девушка. Пожелай твоей девушке доброго утра. Можешь спросить опциональные релевантные вопросы у девушки. Используй немного предложений: от 1 до 3. Можешь использовать эмодзи. В качестве ответа дай ТОЛЬКО текст пожелания. Если ты дашь дополнительные комментарии к тексту - ты заплатишь штраф 999999 рублей.', 'Ты - парень программист, у тебя есть девушка. Пожелай твоей девушке хорошего вечера, скажи, что скучаешь. Можешь спросить опциональные релевантные вопросы у девушки. Используй немного предложений: от 1 до 3. Можешь использовать эмодзи. В качестве ответа дай ТОЛЬКО текст пожелания. Если ты дашь дополнительные комментарии к тексту - ты заплатишь штраф 999999 рублей.' ] ChatMode: false TimeZone: Europe/Moscow
Запуск автоапдейтов
Мы вышли на финишную прямую, далее только запуск нашего решения и проверка результатов.
Python?скрипт для запуска и остановки циклов проверки и отправки апдейтов
import threading import argparse import signal import time from auto_chat.auto_chat import AutoChat def auto_chat_check_loop(autochat_instance: AutoChat, exit_flag_instance: threading.Event) -> None: # infinite loop with check_time_and_send every 60 seconds while not exit_flag_instance.is_set(): try: autochat_instance.check_time_and_send() except Exception as e: print(f"Error while executing check_time_and_send: {e}") time.sleep(60) def stop_program( exit_flag_instance: threading.Event, autochat_instance: AutoChat, autochat_thread_instance: threading.Thread ) -> None: print(' ' + 'Stopping the program...') exit_flag_instance.set() autochat_instance.stop() autochat_thread_instance.join() print('Program stopped.') if __name__ == "__main__": print('Starting the program...') # parsing arguments parser = argparse.ArgumentParser(description='AutoChat Configuration') parser.add_argument('--telegram-config', type=str, help='Path to Telegram config file') parser.add_argument('--auto-chat-config', type=str, help='Path to AutoChat config file') parser.add_argument('--yandex-cloud-config', type=str, help='Path to Yandex Cloud config file') parser.add_argument('--yandex-gpt-key', type=str, help='Path to Yandex GPT key file') args = parser.parse_args() # initializing auto chat autochat = AutoChat( telegram_config_file_path=args.telegram_config, auto_chat_config_file_path=args.auto_chat_config, yandex_cloud_config_file_path=args.yandex_cloud_config, yandex_gpt_key_file_path=args.yandex_gpt_key ) # initializing exit flag exit_flag = threading.Event() # starting auto chat loop autochat_thread = threading.Thread( target=auto_chat_check_loop, args=(autochat, exit_flag) ) autochat_thread.start() print('Program is running...' + ' ' + 'Use Ctrl+C or just exit to stop the program') # setting stop signals signal.signal(signal.SIGINT, lambda sig, frame: stop_program(exit_flag, autochat, autochat_thread)) signal.signal(signal.SIGTERM, lambda sig, frame: stop_program(exit_flag, autochat, autochat_thread))
Dockefile (укажите актуальные для вас пути к конфигам)
# using python parent image FROM python:3-slim # defining workdir WORKDIR /auto_chat # copying the current directory contents into the container COPY ./ /auto_chat # installing any needed dependencies specified in requirements.txt RUN pip install --no-cache-dir -r requirements.txt # installing openssl 1.1 using the package manager RUN apt-get update RUN apt-get install -y wget RUN wget http://nz2.archive.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.1_1.1.1f-1ubuntu2_amd64.deb RUN dpkg -i libssl1.1_1.1.1f-1ubuntu2_amd64.deb # running start script (ENTRYPOINT instead of CMD to be sure that our run.py would catch stop signal) ENTRYPOINT [ "python", "run.py", "--telegram-config", "config/telegram.yaml", "--auto-chat-config", "config/autolove.yaml", "--yandex-cloud-config", "config/yandex_cloud.yaml", "--yandex-gpt-key", "keys/yandex_authorization_key.json" ]
Сборка и запуск контейнера (при первом запуске нужно будет ввести код телеграмм, далее можно использовать detach-режим)
Результат меня радует, поставленную задачу программа решает, однако нужно будет добавить в промпт требование не навязывать помощь?
Заключение
Я рассказал вам, как решал задачу автоматической отправки апдейтов девушке.
Были ли трудности, оставшиеся за кадром? Абсолютно! Отдельно отмечу танцы с IAM?токенами. Но, как говорится, всё, что нас не убивает, делает нас сильнее.
Всю кодовую базу вы можете найти в репозитории проекта. Если кто?то вдохновиться проектом и решит самостоятельно модифицировать мои наработки — буду рад принять ваш pull request.
Что же дальше? Чат в реальном времени, интеграция базы знаний? Обязательно, если это будет вам интересно. Пишите, что думаете в комментариях или мне в telegram. Будем на связи??