Rewrite to python

This commit is contained in:
2024-08-09 21:50:58 +02:00
parent 00c3886311
commit 1cdb486423
25 changed files with 961 additions and 4862 deletions

23
src/config.py Normal file
View File

@@ -0,0 +1,23 @@
from pydantic.env_settings import BaseSettings
class Config(BaseSettings):
DISCORD_BOT_TOKEN: str
DISCORD_GUILD_ID: int
DISCORD_CHANNEL_ID: int
DISCORD_BOT_ACTIVITY: str
DISCORD_GAME_LIST_CHANNEL_ID: int
DISCORD_GAME_LIST_MESSAGE_ID: int
TELEGRAM_BOT_TOKEN: str
TELEGRAM_CHANNEL_ID: int
TWITCH_CLIENT_ID: str
TWITCH_CLIENT_SECRET: str
TWITCH_CHANNEL_ID: str
config = Config()

View File

@@ -1,48 +0,0 @@
use once_cell::sync::Lazy;
fn get_env(env: &'static str) -> String {
std::env::var(env).unwrap_or_else(|_| panic!("Cannot get the {} env variable", env))
}
pub struct Config {
pub discord_bot_token: String,
pub discord_guild_id: u64,
pub discord_channel_id: u64,
pub discord_bot_activity: String,
pub discord_game_list_channel_id: u64,
pub discord_game_list_message_id: u64,
pub telegram_bot_token: String,
pub telegram_channel_id: i128,
pub twitch_client_id: String,
pub twitch_client_secret: String,
pub twitch_channel_id: String,
}
impl Config {
pub fn load() -> Config {
Config {
discord_bot_token: get_env("DISCORD_BOT_TOKEN"),
discord_guild_id: get_env("DISCORD_GUILD_ID").parse().unwrap(),
discord_channel_id: get_env("DISCORD_CHANNEL_ID").parse().unwrap(),
discord_bot_activity: get_env("DISCORD_BOT_ACTIVITY"),
discord_game_list_channel_id: get_env("DISCORD_GAME_LIST_CHANNEL_ID").parse().unwrap(),
discord_game_list_message_id: get_env("DISCORD_GAME_LIST_MESSAGE_ID").parse().unwrap(),
telegram_bot_token: get_env("TELEGRAM_BOT_TOKEN"),
telegram_channel_id: get_env("TELEGRAM_CHANNEL_ID").parse().unwrap(),
twitch_client_id: get_env("TWITCH_CLIENT_ID"),
twitch_client_secret: get_env("TWITCH_CLIENT_SECRET"),
twitch_channel_id: get_env("TWITCH_CHANNEL_ID"),
}
}
}
pub static CONFIG: Lazy<Config> = Lazy::new(Config::load);

View File

@@ -1,35 +0,0 @@
use serenity::builder::*;
use serenity::model::prelude::*;
pub fn register() -> CreateCommand {
CreateCommand::new("add")
.description("Добавить игру в список")
.add_option(
CreateCommandOption::new(
CommandOptionType::String, "category", "Раздел"
)
.required(true)
.add_string_choice("Заказ за баллы", "points")
.add_string_choice("Проплачены", "paids")
.add_string_choice("Подарки", "gifts")
)
.add_option(
CreateCommandOption::new(
CommandOptionType::String, "customer", "Кто заказал"
)
.required(true)
)
.add_option(
CreateCommandOption::new(
CommandOptionType::String, "game", "Игра"
)
.required(true)
)
.add_option(
CreateCommandOption::new(
CommandOptionType::String, "date", "Дата заказа"
)
.required(false)
)
}

View File

@@ -1,15 +0,0 @@
use serenity::builder::*;
use serenity::model::prelude::*;
pub fn register() -> CreateCommand {
CreateCommand::new("delete")
.description("Удалить игру из списока")
.add_option(
CreateCommandOption::new(
CommandOptionType::String, "game", "Игра"
)
.required(true)
.set_autocomplete(true)
)
}

View File

@@ -1,2 +0,0 @@
pub mod add_game;
pub mod delete_game;

View File

@@ -1,123 +0,0 @@
use serenity::prelude::*;
use serenity::all::{AutocompleteChoice, CreateAutocompleteResponse, CreateInteractionResponse, CreateInteractionResponseMessage, EditMessage, GuildId, Interaction};
use serenity::async_trait;
use serenity::model::channel::Message;
use chrono::offset::FixedOffset;
use crate::config;
use crate::utils::{add_game, delete_game, format_games_list, parse_games_list};
pub mod commands;
pub struct Handler;
#[async_trait]
impl EventHandler for Handler {
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
if let Interaction::Command(command) = interaction {
if command.channel_id != config::CONFIG.discord_game_list_channel_id {
return;
}
match command.data.name.as_str() {
"add" => {
let mut message = command.channel_id.message(&ctx.http, config::CONFIG.discord_game_list_message_id).await.unwrap();
let utc_offset = FixedOffset::east_opt(3 * 3600); // UTC+3 offset in seconds
let current_time = chrono::Local::now().with_timezone(&utc_offset.unwrap());
let mut categories = parse_games_list(&message.content).await;
categories = add_game(
categories,
command.data.options[0].value.as_str().unwrap(),
&format!(
"* {} ({}) | {}",
command.data.options[2].value.as_str().unwrap(),
command.data.options[1].value.as_str().unwrap(),
match command.data.options.get(3) {
Some(v) => v.value.as_str().unwrap().to_string(),
None => format!("{}", current_time.format("%d.%m.%Y")),
},
)
).await;
let new_content = format_games_list(categories).await;
message.edit(&ctx.http, EditMessage::new().content(new_content)).await.unwrap();
let data = CreateInteractionResponseMessage::new().content("Игра добавлена!").ephemeral(true);
let builder = CreateInteractionResponse::Message(data);
if let Err(why) = command.create_response(&ctx.http, builder).await {
println!("Cannot respond to slash command: {why}");
}
},
"delete" => {
let mut message = command.channel_id.message(&ctx.http, config::CONFIG.discord_game_list_message_id).await.unwrap();
let mut categories = parse_games_list(&message.content).await;
categories = delete_game(
categories,
command.data.options[0].value.as_str().unwrap()
).await;
let new_content = format_games_list(categories).await;
message.edit(&ctx.http, EditMessage::new().content(new_content)).await.unwrap();
let data = CreateInteractionResponseMessage::new().content("Игра удалена!").ephemeral(true);
let builder = CreateInteractionResponse::Message(data);
if let Err(why) = command.create_response(&ctx.http, builder).await {
println!("Cannot respond to slash command: {why}");
}
},
_ => (),
};
} else if let Interaction::Autocomplete(interaction) = interaction {
if interaction.channel_id != config::CONFIG.discord_game_list_channel_id {
return;
}
if interaction.data.name.as_str() == "delete" {
let message = interaction.channel_id.message(&ctx.http, config::CONFIG.discord_game_list_message_id).await.unwrap();
let categories = parse_games_list(&message.content).await;
let games = categories.iter().flat_map(|category| category.games.iter()).collect::<Vec<&String>>();
let query = interaction.data.options[0].value.as_str().unwrap();
let autocompolete_response = CreateAutocompleteResponse::new().set_choices(
games
.iter()
.filter(|game| game.to_lowercase().contains(&query.to_lowercase()))
.take(25)
.map(|game| {
AutocompleteChoice::new(game.to_string(), game.to_string())
})
.collect()
);
let _ = interaction.create_response(&ctx.http, serenity::builder::CreateInteractionResponse::Autocomplete(autocompolete_response)).await.unwrap();
};
}
}
async fn message(&self, _ctx: Context, _msg: Message) {
}
async fn ready(&self, ctx: Context, _ready: serenity::model::gateway::Ready) {
let guild_id = GuildId::new(config::CONFIG.discord_guild_id);
let _ = guild_id
.set_commands(
&ctx.http,
vec![
commands::add_game::register(),
commands::delete_game::register(),
]
).await.unwrap();
}
}

5
src/main.py Normal file
View File

@@ -0,0 +1,5 @@
from services.discord import start_discord_sevice
async def main():
await start_discord_sevice()

View File

@@ -1,46 +0,0 @@
use serenity::all::ActivityData;
use serenity::prelude::*;
use twitch_handler::TwitchBot;
use tokio::join;
use rustls;
pub mod config;
pub mod discord_handler;
pub mod twitch_handler;
pub mod utils;
pub mod notifiers;
async fn start_discord_bot() {
println!("Starting Discord bot...");
let intents = GatewayIntents::GUILD_MESSAGES
| GatewayIntents::DIRECT_MESSAGES
| GatewayIntents::MESSAGE_CONTENT;
let mut client =
Client::builder(&config::CONFIG.discord_bot_token, intents)
.event_handler(discord_handler::Handler)
.status(serenity::all::OnlineStatus::Online)
.activity(ActivityData::playing(&config::CONFIG.discord_bot_activity))
.await
.expect("Err creating client");
if let Err(why) = client.start().await {
panic!("Client error: {why:?}");
}
}
async fn start_twitch_bot() {
TwitchBot::start().await;
}
#[tokio::main]
async fn main() {
rustls::crypto::ring::default_provider().install_default().expect("Failed to install rustls crypto provider");
join!(start_discord_bot(), start_twitch_bot());
}

View File

@@ -1,17 +0,0 @@
use reqwest::Url;
use serenity::json::json;
use crate::config;
pub async fn send_to_discord(msg: &str) {
let base_url = format!("https://discord.com/api/v10/channels/{}/messages", config::CONFIG.discord_channel_id);
let url = Url::parse(&base_url.as_ref()).unwrap();
reqwest::Client::new().post(url)
.header("Authorization", format!("Bot {}", config::CONFIG.discord_bot_token))
.json(&json!({
"content": msg
})).send().await.expect("Error sending message to Discord");
}

View File

@@ -1,2 +0,0 @@
pub mod telegram;
pub mod discord;

View File

@@ -1,19 +0,0 @@
use reqwest::Url;
use crate::config;
pub async fn send_to_telegram(msg: &str) {
let base_url = format!("https://api.telegram.org/bot{}/sendMessage", config::CONFIG.telegram_bot_token);
let url = Url::parse_with_params(
base_url.as_ref(),
&[
("chat_id", &config::CONFIG.telegram_channel_id.to_string().as_ref()),
("text", &msg)
]
).unwrap();
reqwest::Client::new().post(url)
.send().await.expect("Error sending message to Telegram");
}

98
src/services/discord.py Normal file
View File

@@ -0,0 +1,98 @@
import discord
from discord.abc import Messageable
from discord import Object
from discord import app_commands
from services.games_list import GameList, GameItem
from config import config
class DiscordClient(discord.Client):
def __init__(self) -> None:
intents = discord.Intents.default()
intents.message_content = True
super().__init__(intents=intents)
self.tree = app_commands.CommandTree(self)
async def on_ready(self):
await self.change_presence(
activity=discord.Game(config.DISCORD_BOT_ACTIVITY),
status=discord.Status.online,
)
client = DiscordClient()
@client.tree.command(guild=Object(id=config.DISCORD_GUILD_ID))
@app_commands.describe(
category="Раздел",
customer="Кто заказал",
game="Игра",
date="Дата заказа"
)
@app_commands.choices(
category=[
app_commands.Choice(name="Заказ за баллы", value="points"),
app_commands.Choice(name="Проплачены", value="paids"),
app_commands.Choice(name="Подарки", value="gifts"),
],
)
async def add(
interaction: discord.Interaction,
category: str,
customer: str,
game: str,
date: str | None = None
):
if interaction.channel is None or interaction.channel.id != config.DISCORD_CHANNEL_ID:
return
if not isinstance(interaction.channel, Messageable):
return
game_list_message = await interaction.channel.fetch_message(config.DISCORD_GAME_LIST_MESSAGE_ID)
game_list = GameList.parse(game_list_message.content)
game_list.add_game(category, GameItem(name=game, customer=customer, date=date))
await game_list_message.edit(content=str(game_list))
async def game_list_autocomplete(
interaction: discord.Interaction,
current: str,
) -> list[app_commands.Choice[str]]:
if not isinstance(interaction.channel, Messageable):
return []
game_list_message = await interaction.channel.fetch_message(config.DISCORD_GAME_LIST_MESSAGE_ID)
game_list = GameList.parse(game_list_message.content)
return game_list.get_choices(current)
@client.tree.command(guild=Object(id=config.DISCORD_GUILD_ID))
@app_commands.describe(game="Игра")
@app_commands.autocomplete(game=game_list_autocomplete)
async def delete(interaction: discord.Interaction, game: str):
if interaction.channel is None or interaction.channel.id != config.DISCORD_CHANNEL_ID:
return
if not isinstance(interaction.channel, Messageable):
return
game_list_message = await interaction.channel.fetch_message(config.DISCORD_GAME_LIST_MESSAGE_ID)
game_list = GameList.parse(game_list_message.content)
game_list.delete_game(game)
await game_list_message.edit(content=str(game_list))
async def start_discord_sevice():
client.run(config.DISCORD_BOT_TOKEN)

View File

@@ -0,0 +1,88 @@
from typing import Self
from datetime import datetime
import re
from discord import app_commands
from pydantic import BaseModel
class GameItem(BaseModel):
name: str
customer: str
date: str | None
def __str__(self) -> str:
# set timezone to Moscow
_date = self.date or datetime.now().strftime("%d.%m.%Y")
return f"* {self.name} ({self.customer}) | {_date}"
@classmethod
def parse(cls, line: str) -> Self:
regex_result = re.search(r"^\* (.+) \((.+)\) \| (.+)$", line)
if regex_result is None:
raise ValueError(f"Invalid game item: {line}")
name, customer, date = regex_result.groups()
return cls(name=name, customer=customer, date=date)
class Category(BaseModel):
name: str
games: list[GameItem]
class GameList:
def __init__(self, data: list[Category]):
self.data = data
@classmethod
def parse(cls, message: str) -> Self:
categories = []
for line in message.split("\n"):
if line == "".strip():
continue
if not line.startswith("*"):
name = line.replace(":", "")
categories.append(Category(name=name, games=[]))
else:
categories[-1].games.append(GameItem.parse(line.strip()))
return cls(data=categories)
def add_game(self, category: str, game_item: GameItem):
for category_item in self.data:
if category_item.name == category:
category_item.games.append(game_item)
def delete_game(self, game_name: str):
for category in self.data:
for game in category.games:
if game.name.startswith(game_name):
category.games.remove(game)
def __str__(self) -> str:
result = ""
for category in self.data:
result += f"{category.name}:\n"
for game in category.games:
result += f"{game}\n"
result += "\n\n"
return result
def get_choices(self, query: str) -> list[app_commands.Choice[str]]:
choices = []
for category in self.data:
for game in category.games:
if query.lower() in game.name.lower():
choices.append(app_commands.Choice(name=game.name, value=game.name))
return choices[:25]

0
src/services/twitch.py Normal file
View File

View File

@@ -1,186 +0,0 @@
use helix::Client;
use helix::User;
use anyhow::Result;
use async_trait::async_trait;
use chrono::DateTime;
use chrono::Utc;
use serde::Deserialize;
use serde::Serialize;
use super::helix;
#[async_trait]
pub trait TokenStorage {
async fn save(&mut self, token: &Token) -> Result<()>;
}
#[derive(Debug, Default, Clone, PartialEq)]
pub enum TokenType {
#[default]
UserAccessToken,
AppAccessToken,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Token {
#[serde(skip)]
pub token_type: TokenType,
#[serde(default)]
pub refresh_token: String,
pub access_token: String,
pub expires_in: i64,
#[serde(default = "Utc::now")]
pub created_at: DateTime<Utc>,
#[serde(skip)]
pub user: Option<User>,
}
#[derive(Debug, Clone)]
pub struct VoidStorage {}
#[async_trait]
impl TokenStorage for VoidStorage {
async fn save(&mut self, _token: &Token) -> Result<()> {
Ok(())
}
}
#[derive(Deserialize)]
struct ValidateToken {
pub expires_in: i64,
}
impl<T: TokenStorage> Client<T> {
pub async fn validate_token(&mut self) -> Result<()> {
let token = match self
.get::<ValidateToken>("https://id.twitch.tv/oauth2/validate".to_string())
.await
{
Ok(r) => r,
Err(..) => {
self.refresh_token().await?;
return Ok(());
}
};
if token.expires_in < 3600 {
self.refresh_token().await?;
}
Ok(())
}
pub async fn refresh_token(&mut self) -> Result<()> {
if self.token.token_type == TokenType::AppAccessToken {
self.get_app_token().await?;
return Ok(());
}
let res = self
.http_request::<()>(
reqwest::Method::POST,
"https://id.twitch.tv/oauth2/token".to_string(),
None,
Some(format!(
"client_id={0}&client_secret={1}&grant_type=refresh_token&refresh_token={2}",
self.client_id, self.client_secret, self.token.refresh_token
)),
)
.await?;
self.token = res.json::<Token>().await?;
self.token_storage.save(&self.token).await?;
Ok(())
}
pub fn from_token_no_validation(
client_id: String,
client_secret: String,
token_storage: T,
token: Token,
) -> Client<T> {
Client {
client_id: client_id,
client_secret: client_secret,
token: token,
http_client: reqwest::Client::new(),
token_storage: token_storage,
}
}
pub async fn from_token(
client_id: String,
client_secret: String,
token_storage: T,
token: Token,
) -> Result<Client<T>> {
let mut client =
Self::from_token_no_validation(client_id, client_secret, token_storage, token);
client.token.user = Some(client.get_user().await?);
Ok(client)
}
async fn get_app_token(&mut self) -> Result<()> {
let token = self
.http_client
.post("https://id.twitch.tv/oauth2/token")
.body(format!(
"client_id={0}&client_secret={1}&grant_type=client_credentials",
self.client_id, self.client_secret
))
.send()
.await?
.json::<Token>()
.await?;
self.token = token;
self.token.token_type = TokenType::AppAccessToken;
self.token_storage.save(&self.token).await?;
Ok(())
}
pub async fn from_get_app_token(
client_id: String,
client_secret: String,
token_storage: T,
) -> Result<Client<T>> {
let http_client = reqwest::Client::new();
let mut client = Client {
client_id: client_id,
client_secret: client_secret,
http_client: http_client,
token_storage: token_storage,
token: Token::default(),
};
client.get_app_token().await?;
Ok(client)
}
pub async fn from_authorization(
client_id: String,
client_secret: String,
token_storage: T,
code: String,
redirect_uri: String,
) -> Result<Client<T>> {
let http_client = reqwest::Client::new();
let token = http_client.post("https://id.twitch.tv/oauth2/token")
.body(format!("client_id={client_id}&client_secret={client_secret}&code={code}&grant_type=authorization_code&redirect_uri={redirect_uri}"))
.send()
.await?
.json::<Token>()
.await?;
let mut client = Client {
client_id: client_id,
client_secret: client_secret,
token: token,
http_client: http_client,
token_storage: token_storage,
};
client.token.user = Some(client.get_user().await?);
client.token_storage.save(&client.token).await?;
Ok(client)
}
}

View File

@@ -1,293 +0,0 @@
use anyhow::bail;
use anyhow::Result;
use futures::Future;
use futures::Sink;
use futures::StreamExt;
use serde::Deserialize;
use futures::Stream;
use std::pin::Pin;
use std::task::Context;
use std::task::Poll;
use super::auth;
use super::helix;
#[derive(Debug, Deserialize)]
pub struct MessageMetadata {
pub message_id: String,
pub message_timestamp: String,
pub message_type: String,
pub subscription_type: Option<String>,
pub subscription_version: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct Message {
pub metadata: MessageMetadata,
pub payload: serde_json::Value,
}
#[derive(Debug, Deserialize)]
pub struct SessionWelcomeSession {
pub id: String,
pub connected_at: String,
pub status: String,
pub reconnect_url: Option<String>,
pub keepalive_timeout_seconds: i64,
}
#[derive(Debug, Deserialize)]
pub struct SessionWelcome {
pub session: SessionWelcomeSession,
}
#[derive(Debug, Deserialize)]
pub struct Notification {
pub subscription: serde_json::Value,
pub event: serde_json::Value,
}
#[derive(Debug, Deserialize)]
pub struct ChannelUpdate {
pub broadcaster_user_id: String,
pub broadcaster_user_login: String,
pub broadcaster_user_name: String,
pub title: String,
pub language: String,
pub category_id: String,
pub category_name: String,
pub content_classification_labels: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct CustomRewardRedemptionAddReward {
pub id: String,
}
#[derive(Debug, Deserialize)]
pub struct CustomRewardRedemptionAdd {
pub id: String,
pub user_login: String,
pub user_input: String,
pub reward: CustomRewardRedemptionAddReward,
}
#[derive(Debug, Deserialize)]
pub struct StreamOnline {
pub id: String,
pub broadcaster_user_id: String,
pub broadcaster_user_login: String,
pub broadcaster_user_name: String,
pub r#type: String,
pub started_at: String,
}
#[derive(Debug, Deserialize)]
pub struct StreamOffline {
pub broadcaster_user_id: String,
pub broadcaster_user_login: String,
pub broadcaster_user_name: String,
}
#[derive(Debug, Deserialize)]
pub enum NotificationType {
ChannelUpdate(ChannelUpdate),
CustomRewardRedemptionAdd(CustomRewardRedemptionAdd),
StreamOnline(StreamOnline),
StreamOffline(StreamOffline),
}
pub struct Client {
inner_stream: Pin<
Box<
tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
>,
>,
ping_sleep: Pin<Box<tokio::time::Sleep>>,
}
impl Stream for Client {
type Item = NotificationType;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.get_mut();
let mut inner_stream = this.inner_stream.as_mut();
match this.ping_sleep.as_mut().poll(cx) {
Poll::Pending => {}
Poll::Ready(..) => {
this.ping_sleep
.as_mut()
.reset(tokio::time::Instant::now() + tokio::time::Duration::from_secs(30));
match inner_stream.as_mut().start_send(
tokio_tungstenite::tungstenite::protocol::Message::Ping(vec![]),
) {
Err(..) => return Poll::Ready(None),
_ => {}
};
}
};
loop {
match inner_stream.as_mut().poll_next(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(v) => match v {
Some(Ok(tokio_tungstenite::tungstenite::protocol::Message::Ping(..))) => {
match inner_stream.as_mut().start_send(
tokio_tungstenite::tungstenite::protocol::Message::Pong(vec![]),
) {
Ok(()) => continue,
Err(..) => break,
};
}
Some(Ok(tokio_tungstenite::tungstenite::protocol::Message::Text(text))) => {
let message: Message = match serde_json::from_str(&text) {
Ok(v) => v,
Err(..) => break,
};
match message.metadata.message_type.as_str() {
"notification" => {
let subtype = match &message.metadata.subscription_type {
Some(v) => v,
None => break,
};
let notification: Notification =
match serde_json::from_value(message.payload.clone()) {
Ok(v) => v,
Err(..) => break,
};
match subtype.as_str() {
"channel.update" => {
let event: ChannelUpdate =
match serde_json::from_value(notification.event) {
Ok(v) => v,
Err(..) => break,
};
return Poll::Ready(Some(NotificationType::ChannelUpdate(
event,
)));
}
"channel.channel_points_custom_reward_redemption.add" => {
let event: CustomRewardRedemptionAdd =
match serde_json::from_value(notification.event) {
Ok(v) => v,
Err(..) => break,
};
return Poll::Ready(Some(
NotificationType::CustomRewardRedemptionAdd(event),
));
}
"stream.online" => {
let event: StreamOnline =
match serde_json::from_value(notification.event) {
Ok(v) => v,
Err(..) => break,
};
return Poll::Ready(Some(NotificationType::StreamOnline(
event,
)));
}
"stream.offline" => {
let event: StreamOffline =
match serde_json::from_value(notification.event) {
Ok(v) => v,
Err(..) => break,
};
return Poll::Ready(Some(NotificationType::StreamOffline(
event,
)));
}
_ => return Poll::Pending,
}
}
_ => continue,
}
}
Some(..) => continue,
None => break,
},
}
}
Poll::Ready(None)
}
}
impl<T: auth::TokenStorage> helix::Client<T> {
pub async fn connect_eventsub(&mut self, topics: Vec<(String, String)>, broadcaster_id: String) -> Result<Client> {
let (mut ws_stream, _) =
match tokio_tungstenite::connect_async("wss://eventsub.wss.twitch.tv/ws").await {
Ok(v) => v,
Err(e) => return Err(e.into()),
};
let welcome = loop {
let msg = ws_stream.next().await;
match msg {
Some(Ok(tokio_tungstenite::tungstenite::protocol::Message::Text(text))) => {
let message: Message = match serde_json::from_str(&text) {
Ok(v) => v,
Err(e) => return Err(e.into()),
};
if message.metadata.message_type.as_str() != "session_welcome" {
bail!("No session welcome");
}
let welcome: SessionWelcome =
match serde_json::from_value(message.payload.clone()) {
Ok(v) => v,
Err(e) => return Err(e.into()),
};
break welcome;
}
Some(Err(e)) => return Err(e.into()),
Some(..) => {}
None => bail!("WebSocket dropped"),
}
};
for (subtype, version) in topics.into_iter() {
match self
.create_eventsub_subscription(&helix::EventSubCreate {
r#type: subtype,
version: version,
condition: helix::EventSubCondition {
broadcaster_user_id: Some(broadcaster_id.clone()),
..Default::default()
},
transport: helix::EventSubTransport {
method: "websocket".to_string(),
session_id: Some(welcome.session.id.clone()),
..Default::default()
},
})
.await
{
Ok(..) => {}
Err(err) => {
bail!("create_eventsub_subscription failed {:?}", err);
}
};
}
Ok(Client {
inner_stream: Pin::new(Box::new(ws_stream)),
ping_sleep: Box::pin(tokio::time::sleep(tokio::time::Duration::from_secs(30))),
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,172 +0,0 @@
pub mod eventsub;
pub mod helix;
pub mod auth;
use chrono::{DateTime, Utc};
use futures::StreamExt;
use async_trait::async_trait;
use auth::Token;
use crate::{config, notifiers::{discord::send_to_discord, telegram::send_to_telegram}};
pub struct TokenStorage {
pub filepath: String,
}
#[async_trait]
impl auth::TokenStorage for TokenStorage {
async fn save(&mut self, token: &Token) -> anyhow::Result<()> {
let token_json = serde_json::to_string(&token).unwrap();
std::fs::write(&self.filepath, token_json).unwrap();
Ok(())
}
}
impl TokenStorage {
pub async fn load(&self) -> anyhow::Result<Token> {
let token_json = std::fs::read_to_string(&self.filepath).unwrap();
let token: Token = serde_json::from_str(&token_json).unwrap();
Ok(token)
}
}
#[derive(Clone)]
pub struct State {
pub title: String,
pub game: String,
pub updated_at: DateTime<Utc>
}
pub struct TwitchBot {}
pub async fn notify_game_change(title: String, _old_game: String, new_game: String) {
let msg = format!("HafMC сменил игру на {} ({})! \nПрисоединяйся: https://twitch.tv/hafmc", new_game, title);
send_to_discord(&msg).await;
send_to_telegram(&msg).await;
}
pub async fn notify_stream_online(title: String, game: String) {
let msg = format!("HafMC сейчас стримит {} ({})! \nПрисоединяйся: https://twitch.tv/hafmc", title, game);
send_to_discord(&msg).await;
send_to_telegram(&msg).await;
}
impl TwitchBot {
pub async fn start_watch(mut client: helix::Client<TokenStorage>) {
let mut current_state: Option<State> = {
let stream = client.get_stream(config::CONFIG.twitch_channel_id.clone()).await;
match stream {
Ok(stream) => {
Some(State {
title: stream.title,
game: stream.game_name,
updated_at: chrono::offset::Utc::now()
})
},
Err(_) => {
None
}
}
};
loop {
println!("Checking Twitch events...");
let mut eventsub_client = client.connect_eventsub(
vec![
("stream.online".to_string(), "1".to_string()),
("stream.offline".to_string(), "1".to_string()),
("channel.update".to_string(), "2".to_string())
],
config::CONFIG.twitch_channel_id.clone()
).await.unwrap();
if let Some(event) = eventsub_client.next().await {
match event {
eventsub::NotificationType::CustomRewardRedemptionAdd(_) => todo!(),
eventsub::NotificationType::StreamOffline(_) => {},
eventsub::NotificationType::ChannelUpdate(data) => {
if let Some(state) = current_state {
if state.game != data.category_name {
notify_game_change(
data.title.clone(),
state.game.clone(),
data.category_name.clone()
).await;
}
}
current_state = Some(State {
title: data.title,
game: data.category_name.clone(),
updated_at: chrono::offset::Utc::now()
});
},
eventsub::NotificationType::StreamOnline(_) => {
if (chrono::offset::Utc::now() - current_state.as_ref().unwrap().updated_at).num_seconds() > 15 * 60 || current_state.is_none() {
let new_state: Option<State> = {
let stream = client.get_stream(config::CONFIG.twitch_channel_id.clone()).await;
match stream {
Ok(stream) => {
Some(State {
title: stream.title,
game: stream.game_name,
updated_at: chrono::offset::Utc::now()
})
},
Err(_) => {
None
}
}
};
match new_state {
Some(state) => {
notify_stream_online(state.title.clone(), state.game.clone()).await;
current_state = Some(state);
},
None => {}
}
}
},
}
}
client.validate_token().await.unwrap();
}
}
pub async fn start() {
println!("Starting Twitch bot...");
let token_storage = TokenStorage {
filepath: "/secrets/twitch_token.json".to_string()
};
let token = token_storage.load().await.unwrap();
let mut client = match helix::Client::from_token(
config::CONFIG.twitch_client_id.clone(),
config::CONFIG.twitch_client_secret.clone(),
token_storage,
token
).await {
Ok(v) => v,
Err(err) => panic!("{:?}", err),
};
client.validate_token().await.unwrap();
}
}

View File

@@ -1,75 +0,0 @@
#[derive(Clone)]
pub struct Category {
pub name: String,
pub games: Vec<String>
}
pub async fn parse_games_list(text: &str) -> Vec<Category> {
let mut categories = vec![];
for line in text.lines() {
if line.is_empty() {
continue;
}
if !line.starts_with("* ") {
let category_name = line;
let category = Category {
name: category_name.to_string(),
games: vec![]
};
categories.push(category);
} else {
let game_line = line.trim();
let last_category = categories.last_mut().unwrap();
last_category.games.push(game_line.to_string());
}
}
categories
}
pub async fn add_game(
mut categories: Vec<Category>,
category: &str,
game_line: &str
) -> Vec<Category> {
let category_number = ["points", "paids", "gifts"]
.iter()
.position(|&x| x == category)
.unwrap();
categories[category_number].games.push(game_line.to_string());
categories
}
pub async fn delete_game(
mut categories: Vec<Category>,
game_name: &str
) -> Vec<Category> {
for category in categories.iter_mut() {
category.games.retain(|game| !game.starts_with(game_name));
}
categories
}
pub async fn format_games_list(categories: Vec<Category>) -> String {
let mut result = String::new();
for category in categories.iter() {
result.push_str(&format!("{}\n", category.name));
for game in category.games.iter() {
result.push_str(&format!("{}\n", game));
}
result.push_str("\n\n");
}
result
}