initial commit
This commit is contained in:
5
bot/.dockerignore
Normal file
5
bot/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
target/
|
||||
.git/
|
||||
*.db
|
||||
*.db-journal
|
||||
.env
|
||||
6
bot/.gitignore
vendored
Normal file
6
bot/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
*.log
|
||||
.env
|
||||
config.toml
|
||||
*.db
|
||||
22
bot/Cargo.toml
Normal file
22
bot/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "omegle-matrix-client"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-tungstenite = { version = "0.21", features = ["native-tls"] }
|
||||
futures-util = "0.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.30", features = ["bundled"] }
|
||||
toml = "0.8"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
matrix-sdk = { version = "0.7", features = ["e2e-encryption"] }
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
url = "2"
|
||||
log = "0.4"
|
||||
async-trait = "0.1"
|
||||
dashmap = "5"
|
||||
25
bot/Dockerfile
Normal file
25
bot/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM rust:1.85-bookworm AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN apt-get update && apt-get install -y pkg-config libssl-dev
|
||||
|
||||
COPY Cargo.toml ./
|
||||
COPY src ./src
|
||||
|
||||
RUN cargo build --release
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /build/target/release/omegle-matrix-client /app/
|
||||
COPY config.toml /app/config.toml
|
||||
|
||||
RUN mkdir -p /data && chown -R 1000:1000 /data
|
||||
|
||||
ENV RUST_LOG=info
|
||||
|
||||
ENTRYPOINT ["/app/omegle-matrix-client"]
|
||||
56
bot/src/config.rs
Normal file
56
bot/src/config.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub matrix: MatrixConfig,
|
||||
#[serde(default)]
|
||||
pub omegle: OmegleConfig,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Default)]
|
||||
pub struct MatrixConfig {
|
||||
#[serde(default)]
|
||||
pub homeserver: String,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Default)]
|
||||
pub struct OmegleConfig {
|
||||
#[serde(default)]
|
||||
pub websocket_url: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: &str) -> Result<Self> {
|
||||
let homeserver = env::var("MATRIX_HOMESERVER").unwrap_or_default();
|
||||
let username = env::var("MATRIX_USERNAME").unwrap_or_default();
|
||||
let password = env::var("MATRIX_PASSWORD").unwrap_or_default();
|
||||
let websocket_url = env::var("OMEGLE_WEBSOCKET_URL").unwrap_or_default();
|
||||
|
||||
if !homeserver.is_empty()
|
||||
|| !username.is_empty()
|
||||
|| !password.is_empty()
|
||||
|| !websocket_url.is_empty()
|
||||
{
|
||||
return Ok(Config {
|
||||
matrix: MatrixConfig {
|
||||
homeserver,
|
||||
username,
|
||||
password,
|
||||
},
|
||||
omegle: OmegleConfig { websocket_url },
|
||||
});
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(path)?;
|
||||
let config: Config = toml::from_str(&content)?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
25
bot/src/main.rs
Normal file
25
bot/src/main.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
mod config;
|
||||
mod matrix;
|
||||
mod omegle;
|
||||
mod state;
|
||||
mod utils;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use crate::config::Config;
|
||||
use crate::state::db::Db;
|
||||
use crate::matrix::client::MatrixBot;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let config = Config::load("config.toml")?;
|
||||
let db_path = std::env::var("DB_PATH").unwrap_or_else(|_| "omegle.db".to_string());
|
||||
let db = Arc::new(Db::new(&db_path)?);
|
||||
|
||||
let bot = MatrixBot::new(config, db).await?;
|
||||
bot.run().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
532
bot/src/matrix/client.rs
Normal file
532
bot/src/matrix/client.rs
Normal file
@@ -0,0 +1,532 @@
|
||||
use anyhow::Result;
|
||||
use matrix_sdk::{
|
||||
config::SyncSettings,
|
||||
Client,
|
||||
Room,
|
||||
};
|
||||
use matrix_sdk::ruma::{UserId, OwnedEventId};
|
||||
use matrix_sdk::ruma::events::room::message::{
|
||||
SyncRoomMessageEvent, MessageType, RoomMessageEventContent,
|
||||
Relation,
|
||||
};
|
||||
use matrix_sdk::ruma::events::relation::Replacement;
|
||||
use matrix_sdk::ruma::events::room::member::StrippedRoomMemberEvent;
|
||||
use matrix_sdk::ruma::events::typing::SyncTypingEvent;
|
||||
use std::sync::Arc;
|
||||
use dashmap::DashMap;
|
||||
use tokio::sync::mpsc;
|
||||
use crate::config::Config;
|
||||
use crate::state::db::Db;
|
||||
use crate::omegle::client::WsOmegleClient;
|
||||
use crate::omegle::OmegleProvider;
|
||||
use crate::utils::flags::country_code_to_flag;
|
||||
|
||||
pub struct MatrixBot {
|
||||
client: Client,
|
||||
db: Arc<Db>,
|
||||
config: Config,
|
||||
handlers: Arc<DashMap<String, mpsc::Sender<BotCommand>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BotCommand {
|
||||
Connect { msg_id: OwnedEventId },
|
||||
Match { prefer_same_country: bool, user_id: String, msg_id: OwnedEventId },
|
||||
Skip { user_id: String, msg_id: OwnedEventId },
|
||||
Pause,
|
||||
Disconnect,
|
||||
SendMessage(String),
|
||||
SendTyping(bool),
|
||||
}
|
||||
|
||||
// Helper functions for nice messages
|
||||
async fn send_text(room: &Room, text: &str) -> Result<OwnedEventId> {
|
||||
let content = RoomMessageEventContent::text_plain(text);
|
||||
let resp = room.send(content).await?;
|
||||
Ok(resp.event_id)
|
||||
}
|
||||
|
||||
async fn send_info(room: &Room, text: &str) -> Result<OwnedEventId> {
|
||||
let html = format!("<i>{}</i>", text);
|
||||
let content = RoomMessageEventContent::text_html(text, html);
|
||||
let resp = room.send(content).await?;
|
||||
Ok(resp.event_id)
|
||||
}
|
||||
|
||||
async fn edit_info(room: &Room, event_id: OwnedEventId, text: &str) -> Result<OwnedEventId> {
|
||||
let html = format!("<i>{}</i>", text);
|
||||
let mut content = RoomMessageEventContent::text_html(text, html);
|
||||
content.relates_to = Some(Relation::Replacement(Replacement::new(event_id, content.clone().into())));
|
||||
let resp = room.send(content).await?;
|
||||
Ok(resp.event_id)
|
||||
}
|
||||
|
||||
async fn send_status(room: &Room, text: &str, emoji: &str) -> Result<OwnedEventId> {
|
||||
let body = format!("{} {}", emoji, text);
|
||||
let html = format!("<b>{}</b> {}", emoji, text);
|
||||
let content = RoomMessageEventContent::text_html(body, html);
|
||||
let resp = room.send(content).await?;
|
||||
Ok(resp.event_id)
|
||||
}
|
||||
|
||||
async fn edit_status(room: &Room, event_id: OwnedEventId, text: &str, emoji: &str) -> Result<OwnedEventId> {
|
||||
let body = format!("{} {}", emoji, text);
|
||||
let html = format!("<b>{}</b> {}", emoji, text);
|
||||
let mut content = RoomMessageEventContent::text_html(body, html);
|
||||
content.relates_to = Some(Relation::Replacement(Replacement::new(event_id, content.clone().into())));
|
||||
let resp = room.send(content).await?;
|
||||
Ok(resp.event_id)
|
||||
}
|
||||
|
||||
|
||||
impl MatrixBot {
|
||||
pub async fn new(config: Config, db: Arc<Db>) -> Result<Self> {
|
||||
let user_id = UserId::parse(&config.matrix.username)?;
|
||||
let client = Client::builder()
|
||||
.homeserver_url(&config.matrix.homeserver)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
client.matrix_auth().login_username(&user_id, &config.matrix.password).send().await?;
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
db,
|
||||
config,
|
||||
handlers: Arc::new(DashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn run(self) -> Result<()> {
|
||||
let db_inv = self.db.clone();
|
||||
self.client.add_event_handler(move |ev: StrippedRoomMemberEvent, room: Room, client: Client| {
|
||||
let _ = db_inv.clone();
|
||||
async move {
|
||||
if room.state() == matrix_sdk::RoomState::Invited {
|
||||
if let Some(user_id) = client.user_id() {
|
||||
if ev.state_key == user_id.to_string() {
|
||||
let _ = room.join().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let db_msg = self.db.clone();
|
||||
let config_msg = self.config.clone();
|
||||
let handlers_msg = self.handlers.clone();
|
||||
|
||||
self.client.add_event_handler(move |ev: SyncRoomMessageEvent, room: Room, client: Client| {
|
||||
let db = db_msg.clone();
|
||||
let config = config_msg.clone();
|
||||
let handlers = handlers_msg.clone();
|
||||
async move {
|
||||
if room.state() == matrix_sdk::RoomState::Joined {
|
||||
let room_id = room.room_id().to_string();
|
||||
if let matrix_sdk::ruma::events::room::message::SyncRoomMessageEvent::Original(original) = ev {
|
||||
// Skip messages from self
|
||||
if let Some(user_id) = client.user_id() {
|
||||
if original.sender == user_id {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let MessageType::Text(text) = original.content.msgtype {
|
||||
let body = text.body.trim();
|
||||
if body.starts_with('!') {
|
||||
handle_command(body, &original.sender, &room, &db, &config, &handlers, &client).await;
|
||||
} else if let Some(tx) = handlers.get(&room_id) {
|
||||
let _ = tx.send(BotCommand::SendMessage(body.to_string())).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let handlers_typing = self.handlers.clone();
|
||||
self.client.add_event_handler(move |ev: SyncTypingEvent, room: Room, client: Client| {
|
||||
let handlers = handlers_typing.clone();
|
||||
async move {
|
||||
if room.state() == matrix_sdk::RoomState::Joined {
|
||||
let room_id = room.room_id().to_string();
|
||||
if let Some(tx) = handlers.get(&room_id) {
|
||||
let typing = if let Some(user_id) = client.user_id() {
|
||||
ev.content.user_ids.iter().any(|id| id != user_id)
|
||||
} else {
|
||||
!ev.content.user_ids.is_empty()
|
||||
};
|
||||
let _ = tx.send(BotCommand::SendTyping(typing)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
self.client.sync(SyncSettings::default()).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_command(
|
||||
body: &str,
|
||||
sender: &UserId,
|
||||
room: &Room,
|
||||
db: &Db,
|
||||
config: &Config,
|
||||
handlers: &Arc<DashMap<String, mpsc::Sender<BotCommand>>>,
|
||||
client: &Client,
|
||||
) {
|
||||
let parts: Vec<&str> = body.split_whitespace().collect();
|
||||
let cmd = parts[0];
|
||||
let room_id = room.room_id().to_string();
|
||||
let user_id = sender.to_string();
|
||||
|
||||
match cmd {
|
||||
"!help" => {
|
||||
let help_text = "<b>Available commands:</b><br/>\
|
||||
<code>!help</code> - Show this help message<br/>\
|
||||
<code>!connect</code> - Connect to Omgle WebSocket<br/>\
|
||||
<code>!match [--same-country]</code> - Request a new match (uses your interests)<br/>\
|
||||
<code>!skip</code> - Skip current peer and automatch (uses your interests)<br/>\
|
||||
<code>!stop</code> - Skip current peer without automatching<br/>\
|
||||
<code>!disconnect</code> - Disconnect from Omgle WebSocket<br/>\
|
||||
<code>!autoskip add/remove/list <CC></code> - Manage your automatic skipping list (global)<br/>\
|
||||
<code>!interests add/remove/list/clear <interest></code> - Manage your interests (global)";
|
||||
let plain = "Available commands:\n!help, !connect, !match, !skip, !stop, !pause, !disconnect, !autoskip, !interests";
|
||||
let content = RoomMessageEventContent::text_html(plain, help_text);
|
||||
let _ = room.send(content).await;
|
||||
}
|
||||
"!connect" => {
|
||||
if handlers.contains_key(&room_id) {
|
||||
let _ = send_info(room, "Already connected").await;
|
||||
return;
|
||||
}
|
||||
|
||||
let msg_id = match send_info(room, "🔄 Connecting to Omgle...").await {
|
||||
Ok(id) => id,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let (tx, rx) = mpsc::channel(100);
|
||||
handlers.insert(room_id.clone(), tx.clone());
|
||||
let room_clone = room.clone();
|
||||
let config_clone = config.clone();
|
||||
let db_clone = db.conn.clone();
|
||||
let db_struct_clone = Arc::new(Db { conn: db_clone });
|
||||
let handlers_clone = handlers.clone();
|
||||
let client_clone = client.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut omgle_client = WsOmegleClient::new();
|
||||
if let Err(e) = omgle_client.connect(&config_clone.omegle.websocket_url).await {
|
||||
let _ = edit_info(&room_clone, msg_id, &format!("❌ Failed to connect: {}", e)).await;
|
||||
handlers_clone.remove(&room_id);
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = tx.send(BotCommand::Connect { msg_id }).await;
|
||||
let _ = omgle_client.request_people_online().await;
|
||||
|
||||
handle_omgle_session(omgle_client, rx, room_clone, db_struct_clone, room_id.clone(), handlers_clone, client_clone).await;
|
||||
});
|
||||
}
|
||||
"!match" => {
|
||||
if let Some(tx) = handlers.get(&room_id) {
|
||||
let prefer_same_country = parts.contains(&"--same-country");
|
||||
let msg_id = match send_info(room, "🔍 Matching...").await {
|
||||
Ok(id) => id,
|
||||
Err(_) => return,
|
||||
};
|
||||
let _ = tx.send(BotCommand::Match { prefer_same_country, user_id, msg_id }).await;
|
||||
} else {
|
||||
let _ = send_info(room, "❌ Not connected to WebSocket. Use <code>!connect</code> first.").await;
|
||||
}
|
||||
}
|
||||
"!interests" => {
|
||||
if parts.len() < 2 { return; }
|
||||
let mut config = db.get_user_config(&user_id).unwrap();
|
||||
match parts[1] {
|
||||
"add" => {
|
||||
for &i in &parts[2..] {
|
||||
config.interests.push(i.to_string());
|
||||
}
|
||||
db.update_user_config(&config).unwrap();
|
||||
let _ = send_info(room, &format!("✅ Added interests: {:?}", &parts[2..])).await;
|
||||
}
|
||||
"remove" => {
|
||||
config.interests.retain(|i| !parts[2..].contains(&i.as_str()));
|
||||
db.update_user_config(&config).unwrap();
|
||||
let _ = send_info(room, &format!("🗑️ Removed interests: {:?}", &parts[2..])).await;
|
||||
}
|
||||
"list" => {
|
||||
let _ = send_info(room, &format!("📝 Your interests: {:?}", config.interests)).await;
|
||||
}
|
||||
"clear" => {
|
||||
config.interests.clear();
|
||||
db.update_user_config(&config).unwrap();
|
||||
let _ = send_info(room, "✨ Interests cleared").await;
|
||||
}
|
||||
_ => {
|
||||
let _ = send_info(room, "❌ Invalid <code>!interests</code> subcommand.").await;
|
||||
}
|
||||
}
|
||||
}
|
||||
"!skip" => {
|
||||
if let Some(tx) = handlers.get(&room_id) {
|
||||
let msg_id = match send_info(room, "⏩ Skipping...").await {
|
||||
Ok(id) => id,
|
||||
Err(_) => return,
|
||||
};
|
||||
let _ = tx.send(BotCommand::Skip { user_id, msg_id }).await;
|
||||
} else {
|
||||
let _ = send_info(room, "❌ Not connected to WebSocket.").await;
|
||||
}
|
||||
}
|
||||
"!stop" => {
|
||||
if let Some(tx) = handlers.get(&room_id) {
|
||||
let _ = tx.send(BotCommand::Pause).await;
|
||||
} else {
|
||||
let _ = send_info(room, "❌ Not connected to WebSocket.").await;
|
||||
}
|
||||
}
|
||||
"!disconnect" => {
|
||||
if let Some(tx) = handlers.get(&room_id) {
|
||||
let _ = tx.send(BotCommand::Disconnect).await;
|
||||
} else {
|
||||
let _ = send_info(room, "❌ Not connected to WebSocket.").await;
|
||||
}
|
||||
}
|
||||
"!autoskip" => {
|
||||
if parts.len() < 2 { return; }
|
||||
let mut config = db.get_user_config(&user_id).unwrap();
|
||||
match parts[1] {
|
||||
"add" => {
|
||||
for &c in &parts[2..] {
|
||||
config.autoskip_countries.push(c.to_uppercase());
|
||||
}
|
||||
db.update_user_config(&config).unwrap();
|
||||
let _ = send_info(room, &format!("✅ Added to your skip list: {:?}", &parts[2..])).await;
|
||||
}
|
||||
"remove" => {
|
||||
config.autoskip_countries.retain(|c| !parts[2..].contains(&c.as_str()));
|
||||
db.update_user_config(&config).unwrap();
|
||||
let _ = send_info(room, &format!("🗑️ Removed from your skip list: {:?}", &parts[2..])).await;
|
||||
}
|
||||
"list" => {
|
||||
let _ = send_info(room, &format!("📝 Your auto-skip countries: {:?}", config.autoskip_countries)).await;
|
||||
}
|
||||
_ => {
|
||||
let _ = send_info(room, "❌ Invalid <code>!autoskip</code> subcommand.").await;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let _ = send_info(room, &format!("❌ Invalid command: <code>{}</code>. Type <code>!help</code> for a list of commands.", cmd)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_omgle_session(
|
||||
mut omgle: WsOmegleClient,
|
||||
mut rx: mpsc::Receiver<BotCommand>,
|
||||
room: Room,
|
||||
db: Arc<Db>,
|
||||
room_id: String,
|
||||
handlers: Arc<DashMap<String, mpsc::Sender<BotCommand>>>,
|
||||
_client: Client,
|
||||
) {
|
||||
let mut last_typing = std::time::Instant::now();
|
||||
let mut typing_active = false;
|
||||
let mut last_people_online_request = std::time::Instant::now();
|
||||
let mut message_count = 0;
|
||||
let mut local_typing_active = false;
|
||||
let mut active_user_id: Option<String> = None;
|
||||
let mut peer_connected = false;
|
||||
let mut last_prefer_same_country = false;
|
||||
let mut pending_msg_id: Option<OwnedEventId> = None;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
cmd = rx.recv() => {
|
||||
match cmd {
|
||||
Some(BotCommand::Connect { msg_id }) => {
|
||||
let _ = edit_info(&room, msg_id, "✅ Connected to Omgle").await;
|
||||
},
|
||||
Some(BotCommand::Match { prefer_same_country, user_id, msg_id }) => {
|
||||
if peer_connected {
|
||||
let _ = omgle.disconnect_peer().await;
|
||||
}
|
||||
active_user_id = Some(user_id.clone());
|
||||
last_prefer_same_country = prefer_same_country;
|
||||
pending_msg_id = Some(msg_id);
|
||||
let user_config = db.get_user_config(&user_id).unwrap();
|
||||
let _ = omgle.request_match(prefer_same_country, user_config.interests).await;
|
||||
|
||||
peer_connected = false;
|
||||
local_typing_active = false;
|
||||
typing_active = false;
|
||||
let _ = room.typing_notice(false).await;
|
||||
|
||||
let mut room_state = db.get_room_state(&room_id).unwrap();
|
||||
room_state.active_user_id = Some(user_id);
|
||||
room_state.is_connected = true;
|
||||
let _ = db.update_room_state(&room_state);
|
||||
},
|
||||
Some(BotCommand::Skip { user_id, msg_id }) => {
|
||||
if peer_connected {
|
||||
active_user_id = Some(user_id.clone());
|
||||
pending_msg_id = Some(msg_id);
|
||||
let user_config = db.get_user_config(&user_id).unwrap();
|
||||
let _ = omgle.disconnect_peer().await;
|
||||
|
||||
peer_connected = false;
|
||||
local_typing_active = false;
|
||||
typing_active = false;
|
||||
let _ = room.typing_notice(false).await;
|
||||
|
||||
let _ = omgle.request_match(last_prefer_same_country, user_config.interests).await;
|
||||
|
||||
let mut room_state = db.get_room_state(&room_id).unwrap();
|
||||
room_state.active_user_id = Some(user_id);
|
||||
let _ = db.update_room_state(&room_state);
|
||||
} else {
|
||||
let _ = edit_info(&room, msg_id, "❌ No stranger to skip.").await;
|
||||
}
|
||||
},
|
||||
Some(BotCommand::Pause) => {
|
||||
if peer_connected {
|
||||
let _ = send_info(&room, "⏸️ Paused (Skipped peer)").await;
|
||||
let _ = omgle.disconnect_peer().await;
|
||||
peer_connected = false;
|
||||
local_typing_active = false;
|
||||
typing_active = false;
|
||||
let _ = room.typing_notice(false).await;
|
||||
} else {
|
||||
let _ = send_info(&room, "❌ No stranger to pause.").await;
|
||||
}
|
||||
},
|
||||
Some(BotCommand::Disconnect) => {
|
||||
let _ = omgle.disconnect().await;
|
||||
let mut room_state = db.get_room_state(&room_id).unwrap();
|
||||
room_state.is_connected = false;
|
||||
let _ = db.update_room_state(&room_state);
|
||||
let _ = send_info(&room, "🔌 Disconnected from Omgle").await;
|
||||
handlers.remove(&room_id);
|
||||
return;
|
||||
},
|
||||
Some(BotCommand::SendMessage(text)) => {
|
||||
if peer_connected {
|
||||
local_typing_active = false;
|
||||
let _ = omgle.send_message(&text).await;
|
||||
message_count += 1;
|
||||
}
|
||||
},
|
||||
Some(BotCommand::SendTyping(typing)) => {
|
||||
if peer_connected {
|
||||
local_typing_active = typing;
|
||||
let _ = omgle.send_typing(typing).await;
|
||||
}
|
||||
},
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
ev = omgle.next_event() => {
|
||||
match ev {
|
||||
Ok(Some(msg)) => {
|
||||
match msg.channel.as_str() {
|
||||
"connected" => {
|
||||
if let Some(msg_id) = pending_msg_id.take() {
|
||||
let _ = edit_status(&room, msg_id, "Stranger connected", "🎲").await;
|
||||
} else {
|
||||
let _ = send_status(&room, "Stranger connected", "🎲").await;
|
||||
}
|
||||
message_count = 0;
|
||||
peer_connected = true;
|
||||
}
|
||||
"peerCountry" => {
|
||||
if let Ok(data) = serde_json::from_value::<crate::omegle::protocol::CountryData>(msg.data) {
|
||||
let flag = country_code_to_flag(&data.country);
|
||||
let text = format!("Connected to {} {} ({})", flag, data.country_name, &data.country);
|
||||
|
||||
let _ = send_status(&room, &text, "🌍").await;
|
||||
|
||||
if let Some(user_id) = &active_user_id {
|
||||
let config = db.get_user_config(user_id).unwrap();
|
||||
if config.autoskip_countries.contains(&data.country.to_uppercase()) {
|
||||
pending_msg_id = send_info(&room, "⏩ Auto-skipping...").await.ok();
|
||||
let _ = omgle.disconnect_peer().await;
|
||||
|
||||
peer_connected = false;
|
||||
local_typing_active = false;
|
||||
typing_active = false;
|
||||
let _ = room.typing_notice(false).await;
|
||||
|
||||
let _ = omgle.request_match(last_prefer_same_country, config.interests).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"peopleOnline" => {
|
||||
if let Some(count) = msg.data.as_u64() {
|
||||
let topic = format!("🟢 Online: {}", count);
|
||||
let _ = room.set_room_topic(&topic).await;
|
||||
}
|
||||
}
|
||||
"message" => {
|
||||
if let Some(text) = msg.data.as_str() {
|
||||
let _ = send_text(&room, text).await;
|
||||
message_count += 1;
|
||||
}
|
||||
}
|
||||
"typing" => {
|
||||
let typing = msg.data.as_bool().unwrap_or(false);
|
||||
typing_active = typing;
|
||||
last_typing = std::time::Instant::now();
|
||||
let _ = room.typing_notice(typing).await;
|
||||
}
|
||||
"disconnect" => {
|
||||
if peer_connected {
|
||||
let _ = send_status(&room, "Stranger disconnected", "😕").await;
|
||||
peer_connected = false;
|
||||
local_typing_active = false;
|
||||
typing_active = false;
|
||||
let _ = room.typing_notice(false).await;
|
||||
|
||||
if message_count <= 4 {
|
||||
if let Some(user_id) = &active_user_id {
|
||||
pending_msg_id = send_info(&room, "⏩ Automatching...").await.ok();
|
||||
let config = db.get_user_config(user_id).unwrap();
|
||||
let _ = omgle.request_match(last_prefer_same_country, config.interests).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep(tokio::time::Duration::from_millis(500)) => {
|
||||
if typing_active && last_typing.elapsed().as_secs() >= 3 {
|
||||
typing_active = false;
|
||||
let _ = room.typing_notice(false).await;
|
||||
}
|
||||
|
||||
if local_typing_active && peer_connected {
|
||||
let _ = omgle.send_typing(true).await;
|
||||
}
|
||||
|
||||
if last_people_online_request.elapsed().as_secs() >= 60 {
|
||||
let _ = omgle.request_people_online().await;
|
||||
last_people_online_request = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut room_state = db.get_room_state(&room_id).unwrap();
|
||||
room_state.is_connected = false;
|
||||
let _ = db.update_room_state(&room_state);
|
||||
handlers.remove(&room_id);
|
||||
}
|
||||
1
bot/src/matrix/mod.rs
Normal file
1
bot/src/matrix/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod client;
|
||||
106
bot/src/omegle/client.rs
Normal file
106
bot/src/omegle/client.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use async_trait::async_trait;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::json;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
||||
use tokio_tungstenite::tungstenite::protocol::Message;
|
||||
use crate::omegle::protocol::OmegleMessage;
|
||||
use crate::omegle::OmegleProvider;
|
||||
|
||||
pub struct WsOmegleClient {
|
||||
ws: Option<WebSocketStream<MaybeTlsStream<TcpStream>>>,
|
||||
}
|
||||
|
||||
impl WsOmegleClient {
|
||||
pub fn new() -> Self {
|
||||
Self { ws: None }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OmegleProvider for WsOmegleClient {
|
||||
async fn connect(&mut self, url: &str) -> Result<()> {
|
||||
let (ws_stream, _) = connect_async(url).await?;
|
||||
self.ws = Some(ws_stream);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disconnect(&mut self) -> Result<()> {
|
||||
if let Some(mut ws) = self.ws.take() {
|
||||
let _ = ws.close(None).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disconnect_peer(&mut self) -> Result<()> {
|
||||
let ws = self.ws.as_mut().ok_or_else(|| anyhow!("Not connected"))?;
|
||||
let msg = json!({
|
||||
"channel": "disconnect"
|
||||
});
|
||||
ws.send(Message::Text(msg.to_string())).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn request_match(&mut self, prefer_same_country: bool, interests: Vec<String>) -> Result<()> {
|
||||
let ws = self.ws.as_mut().ok_or_else(|| anyhow!("Not connected"))?;
|
||||
let match_msg = json!({
|
||||
"channel": "match",
|
||||
"data": {
|
||||
"data": "text",
|
||||
"params": {
|
||||
"interests": interests,
|
||||
"preferSameCountry": prefer_same_country
|
||||
}
|
||||
}
|
||||
});
|
||||
ws.send(Message::Text(match_msg.to_string())).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_message(&mut self, text: &str) -> Result<()> {
|
||||
let ws = self.ws.as_mut().ok_or_else(|| anyhow!("Not connected"))?;
|
||||
let msg = json!({
|
||||
"channel": "message",
|
||||
"data": text
|
||||
});
|
||||
ws.send(Message::Text(msg.to_string())).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_typing(&mut self, typing: bool) -> Result<()> {
|
||||
let ws = self.ws.as_mut().ok_or_else(|| anyhow!("Not connected"))?;
|
||||
let msg = json!({
|
||||
"channel": "typing",
|
||||
"data": typing
|
||||
});
|
||||
ws.send(Message::Text(msg.to_string())).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn request_people_online(&mut self) -> Result<()> {
|
||||
let ws = self.ws.as_mut().ok_or_else(|| anyhow!("Not connected"))?;
|
||||
let msg = json!({
|
||||
"channel": "peopleOnline"
|
||||
});
|
||||
ws.send(Message::Text(msg.to_string())).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn next_event(&mut self) -> Result<Option<OmegleMessage>> {
|
||||
let ws = self.ws.as_mut().ok_or_else(|| anyhow!("Not connected"))?;
|
||||
while let Some(msg) = ws.next().await {
|
||||
match msg? {
|
||||
Message::Text(text) => {
|
||||
let omegle_msg: OmegleMessage = serde_json::from_str(&text)?;
|
||||
return Ok(Some(omegle_msg));
|
||||
}
|
||||
Message::Binary(_) => {}
|
||||
Message::Ping(_) | Message::Pong(_) => {}
|
||||
Message::Close(_) => return Ok(None),
|
||||
Message::Frame(_) => {}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
17
bot/src/omegle/mod.rs
Normal file
17
bot/src/omegle/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
pub mod protocol;
|
||||
pub mod client;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[async_trait]
|
||||
pub trait OmegleProvider: Send + Sync {
|
||||
async fn connect(&mut self, url: &str) -> Result<()>;
|
||||
async fn disconnect(&mut self) -> Result<()>;
|
||||
async fn disconnect_peer(&mut self) -> Result<()>;
|
||||
async fn request_match(&mut self, prefer_same_country: bool, interests: Vec<String>) -> Result<()>;
|
||||
async fn send_message(&mut self, text: &str) -> Result<()>;
|
||||
async fn send_typing(&mut self, typing: bool) -> Result<()>;
|
||||
async fn request_people_online(&mut self) -> Result<()>;
|
||||
async fn next_event(&mut self) -> Result<Option<protocol::OmegleMessage>>;
|
||||
}
|
||||
15
bot/src/omegle/protocol.rs
Normal file
15
bot/src/omegle/protocol.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct OmegleMessage {
|
||||
pub channel: String,
|
||||
#[serde(default)]
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CountryData {
|
||||
pub country: String,
|
||||
#[serde(rename = "countryName")]
|
||||
pub country_name: String,
|
||||
}
|
||||
113
bot/src/state/db.rs
Normal file
113
bot/src/state/db.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use crate::state::models::{RoomState, UserConfig};
|
||||
use anyhow::Result;
|
||||
use rusqlite::{params, Connection};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub struct Db {
|
||||
pub conn: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn new(path: &str) -> Result<Self> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS room_state (
|
||||
room_id TEXT PRIMARY KEY,
|
||||
is_connected INTEGER DEFAULT 0,
|
||||
active_user_id TEXT
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS user_config (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
autoskip_countries TEXT,
|
||||
interests TEXT
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
Ok(Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_room_state(&self, room_id: &str) -> Result<RoomState> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT room_id, is_connected, active_user_id FROM room_state WHERE room_id = ?",
|
||||
)?;
|
||||
let state = stmt.query_row(params![room_id], |row| {
|
||||
Ok(RoomState {
|
||||
room_id: row.get(0)?,
|
||||
is_connected: row.get::<_, i32>(1)? != 0,
|
||||
active_user_id: row.get(2)?,
|
||||
})
|
||||
});
|
||||
|
||||
match state {
|
||||
Ok(s) => Ok(s),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(RoomState {
|
||||
room_id: room_id.to_string(),
|
||||
is_connected: false,
|
||||
active_user_id: None,
|
||||
}),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_room_state(&self, state: &RoomState) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO room_state (room_id, is_connected, active_user_id) VALUES (?, ?, ?)",
|
||||
params![state.room_id, state.is_connected as i32, state.active_user_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_user_config(&self, user_id: &str) -> Result<UserConfig> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT user_id, autoskip_countries, interests FROM user_config WHERE user_id = ?",
|
||||
)?;
|
||||
let config = stmt.query_row(params![user_id], |row| {
|
||||
let countries_str: String = row.get(1).unwrap_or_default();
|
||||
let autoskip_countries = if countries_str.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
countries_str.split(',').map(|s| s.to_string()).collect()
|
||||
};
|
||||
let interests_str: String = row.get(2).unwrap_or_default();
|
||||
let interests = if interests_str.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
interests_str.split(',').map(|s| s.to_string()).collect()
|
||||
};
|
||||
Ok(UserConfig {
|
||||
user_id: row.get(0)?,
|
||||
autoskip_countries,
|
||||
interests,
|
||||
})
|
||||
});
|
||||
|
||||
match config {
|
||||
Ok(c) => Ok(c),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(UserConfig {
|
||||
user_id: user_id.to_string(),
|
||||
autoskip_countries: Vec::new(),
|
||||
interests: Vec::new(),
|
||||
}),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_user_config(&self, config: &UserConfig) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let countries_str = config.autoskip_countries.join(",");
|
||||
let interests_str = config.interests.join(",");
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO user_config (user_id, autoskip_countries, interests) VALUES (?, ?, ?)",
|
||||
params![config.user_id, countries_str, interests_str],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
2
bot/src/state/mod.rs
Normal file
2
bot/src/state/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod db;
|
||||
pub mod models;
|
||||
15
bot/src/state/models.rs
Normal file
15
bot/src/state/models.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RoomState {
|
||||
pub room_id: String,
|
||||
pub is_connected: bool,
|
||||
pub active_user_id: Option<String>, // User ID whose config is active in this room
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserConfig {
|
||||
pub user_id: String,
|
||||
pub autoskip_countries: Vec<String>,
|
||||
pub interests: Vec<String>,
|
||||
}
|
||||
13
bot/src/utils/flags.rs
Normal file
13
bot/src/utils/flags.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
pub fn country_code_to_flag(code: &str) -> String {
|
||||
if code.len() != 2 {
|
||||
return "🏳".to_string();
|
||||
}
|
||||
|
||||
code.to_uppercase()
|
||||
.chars()
|
||||
.map(|c| {
|
||||
// regional indicator symbols start at 0x1F1E6 ('A')
|
||||
char::from_u32(0x1F1E6 + (c as u32 - 'A' as u32)).unwrap_or('?')
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
1
bot/src/utils/mod.rs
Normal file
1
bot/src/utils/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod flags;
|
||||
Reference in New Issue
Block a user