Салют! Меня зовут Григорий, я главный по спецпроектам в 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}\n{response.text}")
Помимо опциональных параметров temperature, max_tokens, stream, completion_url требуется задать IAM‑токен и ID каталога Yandex Cloud.
Я автоматизировал генерацию IAM‑токена, поэтому для работы будет достаточно передать YAML‑конфиг с указанием следующих полей (как создать ключ авторизации и где взять ID сервисного аккаунта):
ServiceAccountID: 1111111111111
ServiceAccountKeyID: 11111111111
CatalogID: 11111111
Сгенерированный в формате 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"\n"
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}\n{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 следующие параметры (как их получить и для чего они нужны смотрите тут и тут):
ApiId: 1111111
ApiHash: 1111111111
DatabaseEncryptionKey: 11111111
FilesDirectory: data
PhoneNumber: '+11111111'
Login: true
А вот настройки связанные с логикой отправки сообщений:
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('\n' + '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...' + '\n' + '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-режим)
docker build -t auto_chat .
docker run -i --name auto_chat-container auto_chat
Результат
Результат меня радует, поставленную задачу программа решает, однако нужно будет добавить в промпт требование не навязывать помощь😅
Заключение
Я рассказал вам, как решал задачу автоматической отправки апдейтов девушке.
Были ли трудности, оставшиеся за кадром? Абсолютно! Отдельно отмечу танцы с IAM‑токенами. Но, как говорится, всё, что нас не убивает, делает нас сильнее.
Всю кодовую базу вы можете найти в репозитории проекта. Если кто‑то вдохновиться проектом и решит самостоятельно модифицировать мои наработки — буду рад принять ваш pull request.
Что же дальше? Чат в реальном времени, интеграция базы знаний? Обязательно, если это будет вам интересно. Пишите, что думаете в комментариях или мне в telegram. Будем на связи✌️