mirror of
https://github.com/kurbezz/discord-bot.git
synced 2025-12-06 15:15:37 +01:00
Rewrite to python
This commit is contained in:
23
src/config.py
Normal file
23
src/config.py
Normal 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()
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod add_game;
|
||||
pub mod delete_game;
|
||||
@@ -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
5
src/main.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from services.discord import start_discord_sevice
|
||||
|
||||
|
||||
async def main():
|
||||
await start_discord_sevice()
|
||||
46
src/main.rs
46
src/main.rs
@@ -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());
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod telegram;
|
||||
pub mod discord;
|
||||
@@ -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
98
src/services/discord.py
Normal 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)
|
||||
88
src/services/games_list.py
Normal file
88
src/services/games_list.py
Normal 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
0
src/services/twitch.py
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
75
src/utils.rs
75
src/utils.rs
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user