Compare commits

...

41 Commits

Author SHA1 Message Date
Akulij
09950579da delete files left after diesel orm 2025-05-03 16:50:35 +03:00
Akulij
caca9e354d change ProjectPage callback's keyboard generation 2025-05-03 16:39:47 +03:00
Akulij
38c38cec8e accept into iterator in buttons_markup macro isntead of limiting to slices 2025-05-02 17:57:23 +03:00
Akulij
f3497726a1 create test for buttons_markup 2025-05-02 17:47:37 +03:00
Akulij
8e93173cba create tests mod inside utils.rs 2025-05-02 17:45:53 +03:00
Akulij
f09fc42546 create buttons_markup macro 2025-05-02 17:45:33 +03:00
Akulij
e33ccc48b3 add support for telegrams ?start=... hidden tag pushing them into user.metas 2025-05-02 17:21:46 +03:00
Akulij
56f1ee41fc add LeaveApplication and AskQuestion callback buttons to /start keyboard 2025-05-02 17:20:33 +03:00
Akulij
955dde825b create AskQuestion callback 2025-05-02 17:20:09 +03:00
Akulij
aa32d73046 create LeaveApplication callback 2025-05-02 17:19:44 +03:00
Akulij
f1ecd0d1db create send_application_to_chat function 2025-05-02 17:15:44 +03:00
Akulij
ac0833a9f6 create notify_admin command 2025-05-02 17:13:28 +03:00
Akulij
adac0155b9 create /setchat command 2025-05-02 17:11:01 +03:00
Akulij
da1940cf23 create /setliteral command 2025-05-02 17:10:35 +03:00
Akulij
5bc6a8343d create Application 2025-05-02 17:07:35 +03:00
Akulij
fdfc8a8270 add metas field to user to store /start meta links 2025-05-02 17:03:48 +03:00
Akulij
3ba56d488e fix: admin_id should be set from ADMIN_ID 2025-05-02 16:14:55 +03:00
Akulij
14f05e5213 require ADMIN_ID in config
reason: there should be fallback function that notifies admin about when
something went wrong
2025-05-02 15:45:43 +03:00
Akulij
d3c8b7605d clippy: fix warnings 2025-05-02 14:50:27 +03:00
Akulij
abc26d7be0 make ProjectPage callback to actually display some projects information 2025-05-02 14:30:50 +03:00
Akulij
1b9a6dce81 fix: fix stacked_buttons_markup macro 2025-05-02 14:30:07 +03:00
Akulij
ed379cc418 change literal in MoreInfo to more_info_msg to avoid collision with button 2025-05-02 13:36:48 +03:00
Akulij
d447fe0b19 replace EditTextOnly state with Edit in button_edit_callback function 2025-05-02 13:33:35 +03:00
Akulij
e1eb94a030 delete EditTextOnly state
reason: just use Edit state with user warning that you will accept only text
2025-05-02 13:31:57 +03:00
Akulij
8326e819c7 change create_callback_button to accept Callback instead of CallbackInfo
reason: its more convinient for this function and and ability to create
CallbackInfo with literal
2025-05-02 13:23:47 +03:00
Akulij
a3c9cd1bb8 catch EditButton state 2025-05-02 13:21:05 +03:00
Akulij
1ff3f704c1 create button_edit_callback function 2025-05-02 13:19:16 +03:00
Akulij
509f767b1f create CallbackInfo.new_with_literal method
reason: CallbackInfo with literal and without created in completly
differrent usecases, so it will be easier to code with differrent
functions instead of passing Option each time
2025-05-01 14:53:14 +03:00
Akulij
28cffdde16 create literal field in CallbackInfo
reason: to get used literal of button,
for e.g. to edit literals by clicking buttons
2025-05-01 14:50:34 +03:00
Akulij
2b037e0eaa create MsgTooOld bot error variant 2025-05-01 14:46:43 +03:00
Akulij
5ec8e2201c create GoHome callback 2025-05-01 14:45:53 +03:00
Akulij
7ec9c540e5 replace message on MoreInfo callback instead of new one 2025-05-01 14:45:00 +03:00
Akulij
2aaa2b7469 create replace_message function 2025-05-01 14:43:05 +03:00
Akulij
4474188655 create EditTextOnly state
reason: Edit state already exists, but it accepts any message,
but there is need in ability to accept only text, for example for
button editing
2025-05-01 14:35:17 +03:00
Akulij
fed5d8f5b7 create /editbutton cmd for admin 2025-05-01 14:11:34 +03:00
Akulij
c6468269e7 use create_callback_button to create more info button
reason: localization and follow pattern
2025-04-30 21:20:18 +03:00
Akulij
c56b3bf4eb change start buttons 2025-04-30 21:17:46 +03:00
Akulij
88417441ac create public stacked_buttons_markup macro 2025-04-30 21:06:41 +03:00
Akulij
710c1bda8d make single_button_markup public 2025-04-30 21:06:14 +03:00
Akulij
0fc96b17a6 move single_button_markup macro to utils file 2025-04-30 21:04:54 +03:00
Akulij
f58607a883 create single_button_markup macro 2025-04-30 21:01:58 +03:00
47 changed files with 517 additions and 365 deletions

View File

@ -1,9 +0,0 @@
# For documentation on how to configure this file,
# see https://diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/db/schema.rs"
custom_type_derives = ["diesel_derive_enum::DbEnum", "diesel::query_builder::QueryId", "Clone"]
[migrations_directory]
dir = "./migrations"

View File

View File

@ -1,6 +0,0 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
DROP FUNCTION IF EXISTS diesel_set_updated_at();

View File

@ -1,36 +0,0 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
-- Sets up a trigger for the given table to automatically set a column called
-- `updated_at` whenever the row is modified (unless `updated_at` was included
-- in the modified columns)
--
-- # Example
--
-- ```sql
-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
--
-- SELECT diesel_manage_updated_at('users');
-- ```
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
BEGIN
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
BEGIN
IF (
NEW IS DISTINCT FROM OLD AND
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
) THEN
NEW.updated_at := current_timestamp;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View File

@ -1 +0,0 @@
DROP TABLE users

View File

@ -1,5 +0,0 @@
CREATE TABLE users (
id SERIAL PRIMARY KEY,
is_admin BOOLEAN NOT NULL DEFAULT FALSE
)

View File

@ -1,2 +0,0 @@
ALTER TABLE users
DROP COLUMN user_id;

View File

@ -1,2 +0,0 @@
ALTER TABLE users
ADD COLUMN user_id BIGINT NOT NULL;

View File

@ -1,4 +0,0 @@
ALTER TABLE users
ALTER COLUMN id TYPE integer;
ALTER TABLE users
ADD COLUMN user_id BIGINT NOT NULL;

View File

@ -1,4 +0,0 @@
ALTER TABLE users
ALTER COLUMN id TYPE BIGINT;
ALTER TABLE users
DROP COLUMN user_id;

View File

@ -1,6 +0,0 @@
<<<<<<< HEAD
=======
ALTER TABLE users
ALTER COLUMN id TYPE BIGINT;
>>>>>>> Snippet

View File

@ -1 +0,0 @@
DROP TABLE IF EXISTS messages;

View File

@ -1,7 +0,0 @@
CREATE TABLE messages (
id SERIAL PRIMARY KEY,
chat_id BIGINT NOT NULL,
message_id BIGINT NOT NULL,
token VARCHAR(255) NOT NULL,
UNIQUE (chat_id, message_id)
);

View File

@ -1 +0,0 @@
DROP TABLE IF EXISTS literals;

View File

@ -1,5 +0,0 @@
CREATE TABLE literals (
id SERIAL PRIMARY KEY,
token VARCHAR(255) UNIQUE NOT NULL,
value TEXT
);

View File

@ -1,2 +0,0 @@
ALTER TABLE literals
ALTER COLUMN value DROP NOT NULL;

View File

@ -1,2 +0,0 @@
ALTER TABLE literals
ALTER COLUMN value SET NOT NULL;

View File

@ -1,5 +0,0 @@
ALTER TABLE users
DROP COLUMN first_name,
DROP COLUMN last_name,
DROP COLUMN username,
DROP COLUMN language_code;

View File

@ -1,5 +0,0 @@
ALTER TABLE users
ADD COLUMN first_name VARCHAR(255),
ADD COLUMN last_name VARCHAR(255),
ADD COLUMN username VARCHAR(255),
ADD COLUMN language_code VARCHAR(10);

View File

@ -1,6 +0,0 @@
-- This file should undo anything in `up.sql`
ALTER TABLE "users" ALTER COLUMN "first_name" DROP NOT NULL;

View File

@ -1,6 +0,0 @@
-- Your SQL goes here
ALTER TABLE "users" ALTER COLUMN "first_name" SET NOT NULL;

View File

@ -1,2 +0,0 @@
DROP TABLE events;

View File

@ -1,4 +0,0 @@
CREATE TABLE events (
id SERIAL PRIMARY KEY,
time TIMESTAMP UNIQUE NOT NULL
);

View File

@ -1,2 +0,0 @@
DROP TABLE reservations;
DROP TYPE reservation_status;

View File

@ -1,10 +0,0 @@
CREATE TYPE reservation_status AS ENUM ('booked', 'paid');
CREATE TABLE reservations (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
entered_name VARCHAR(255),
booked_time TIMESTAMP NOT NULL,
event_id INTEGER REFERENCES events(id),
status reservation_status NOT NULL
);

View File

@ -1,2 +0,0 @@
CREATE TYPE reservation_status AS ENUM ('booked', 'paid');
ALTER TABLE reservations ALTER COLUMN status TYPE reservation_status USING status::reservation_status;

View File

@ -1,2 +0,0 @@
ALTER TABLE reservations ALTER COLUMN status TYPE VARCHAR;
DROP TYPE reservation_status;

View File

@ -1,2 +0,0 @@
ALTER TABLE reservations
ALTER COLUMN user_id TYPE INTEGER;

View File

@ -1,2 +0,0 @@
ALTER TABLE reservations
ALTER COLUMN user_id TYPE BIGINT;

View File

@ -1,4 +0,0 @@
ALTER TABLE reservations
ALTER COLUMN user_id DROP NOT NULL,
ALTER COLUMN entered_name DROP NOT NULL,
ALTER COLUMN event_id DROP NOT NULL;

View File

@ -1,4 +0,0 @@
ALTER TABLE reservations
ALTER COLUMN user_id SET NOT NULL,
ALTER COLUMN entered_name SET NOT NULL,
ALTER COLUMN event_id SET NOT NULL;

View File

@ -1,2 +0,0 @@
ALTER TABLE events
ALTER COLUMN time TYPE TIMESTAMP;

View File

@ -1,2 +0,0 @@
ALTER TABLE events
ALTER COLUMN time TYPE TIMESTAMPTZ;

View File

@ -1,2 +0,0 @@
DROP TABLE media;

View File

@ -1,6 +0,0 @@
CREATE TABLE media (
id SERIAL PRIMARY KEY,
token VARCHAR NOT NULL,
media_type VARCHAR NOT NULL,
file_id VARCHAR NOT NULL
);

View File

@ -1 +0,0 @@
ALTER TABLE media DROP COLUMN media_group_id;

View File

@ -1,2 +0,0 @@
ALTER TABLE media ADD COLUMN media_group_id VARCHAR NOT NULL;

View File

@ -1 +0,0 @@
ALTER TABLE media ALTER COLUMN media_group_id SET NOT NULL;

View File

@ -1 +0,0 @@
ALTER TABLE media ALTER COLUMN media_group_id DROP NOT NULL;

View File

@ -3,11 +3,11 @@ use teloxide::{
utils::{command::BotCommands, render::RenderMessageTextHelper},
};
use crate::LogMsg;
use crate::{
db::{CallDB, DB},
BotResult,
};
use crate::{BotDialogue, LogMsg, State};
use log::info;
// These are should not appear in /help
@ -27,6 +27,12 @@ pub enum AdminCommands {
Pin,
/// Removes your admin privileges
Deop,
/// Send command and then click button to edits text in it
EditButton,
/// Set specified literal value
SetLiteral { literal: String },
/// Sets chat where this message entered as support's chats
SetChat,
}
pub async fn admin_command_handler(
@ -34,6 +40,7 @@ pub async fn admin_command_handler(
bot: Bot,
msg: Message,
cmd: AdminCommands,
dialogue: BotDialogue,
) -> BotResult<()> {
let tguser = match msg.from.clone() {
Some(user) => user,
@ -69,6 +76,32 @@ pub async fn admin_command_handler(
.await?;
Ok(())
}
AdminCommands::EditButton => {
dialogue.update(State::EditButton).await?;
bot.send_message(msg.chat.id, "Click button which text should be edited")
.await?;
Ok(())
}
AdminCommands::SetLiteral { literal } => {
dialogue
.update(State::Edit {
literal,
lang: "ru".to_string(),
is_caption_set: false,
})
.await?;
bot.send_message(msg.chat.id, "Send message for literal")
.await?;
Ok(())
}
AdminCommands::SetChat => {
dialogue.exit().await?;
db.set_literal("support_chat_id", &msg.chat.id.0.to_string())
.await?;
bot.send_message(msg.chat.id, "ChatId is set!").await?;
Ok(())
}
}
}

39
src/db/application.rs Normal file
View File

@ -0,0 +1,39 @@
use chrono::{DateTime, FixedOffset, Local};
use serde::{Deserialize, Serialize};
use super::DbResult;
use crate::query_call_consume;
use crate::CallDB;
#[derive(Serialize, Deserialize, Default)]
pub struct Application<C>
where
C: Serialize,
{
pub _id: bson::oid::ObjectId,
pub created_at: DateTime<FixedOffset>,
#[serde(flatten)]
pub from: C,
}
impl<C> Application<C>
where
C: Serialize + for<'a> Deserialize<'a> + Send + Sync,
{
pub fn new(from: C) -> Self {
Self {
_id: Default::default(),
created_at: Local::now().into(),
from,
}
}
query_call_consume!(store, self, db, Self, {
let db = db.get_database().await;
let ci = db.collection::<Self>("applications");
ci.insert_one(&self).await?;
Ok(self)
});
}

View File

@ -16,6 +16,7 @@ where
{
pub _id: bson::oid::ObjectId,
pub created_at: DateTime<FixedOffset>,
pub literal: Option<String>,
#[serde(flatten)]
pub callback: C,
}
@ -28,6 +29,16 @@ where
Self {
_id: Default::default(),
created_at: Local::now().into(),
literal: None,
callback,
}
}
pub fn new_with_literal(callback: C, literal: String) -> Self {
Self {
_id: Default::default(),
created_at: Local::now().into(),
literal: Some(literal),
callback,
}
}

View File

@ -1,3 +1,4 @@
pub mod application;
pub mod callback_info;
use std::time::Duration;
@ -37,6 +38,7 @@ pub struct User {
pub last_name: Option<String>,
pub username: Option<String>,
pub language_code: Option<String>,
pub metas: Vec<String>,
}
#[macro_export]
@ -76,6 +78,23 @@ impl User {
Ok(())
});
pub async fn insert_meta<D: CallDB>(&self, db: &mut D, meta: &str) -> DbResult<()> {
let db_collection = db.get_database().await.collection::<Self>("users");
db_collection
.update_one(
doc! { "_id": self._id },
doc! {
"$push": {
"metas": meta,
}
},
)
.await?;
Ok(())
}
}
#[derive(Serialize, Deserialize)]
@ -122,7 +141,6 @@ impl DB {
}
pub async fn migrate(&mut self) -> DbResult<()> {
let db = self.get_database().await;
/// some migrations doesn't realy need type of collection
type AnyCollection = Event;
let events = self.get_database().await.collection::<Event>("events");
@ -216,7 +234,7 @@ pub trait CallDB {
doc! { "id": userid },
doc! {
"$set": doc! { "first_name": firstname},
"$setOnInsert": doc! { "is_admin": false },
"$setOnInsert": doc! { "is_admin": false, "metas": [] },
},
)
.upsert(true)

View File

@ -1,74 +0,0 @@
// Generated by diesel_ext
#![allow(unused)]
#![allow(clippy::all)]
use crate::db::schema::*;
use chrono::offset::Utc;
use chrono::DateTime;
use chrono::NaiveDateTime;
use diesel::prelude::*;
#[derive(Queryable, Debug, Identifiable)]
#[diesel(table_name = events)]
pub struct Event {
pub id: i32,
pub time: DateTime<Utc>,
}
#[derive(Queryable, Debug, Identifiable)]
#[diesel(table_name = literals)]
pub struct Literal {
pub id: i32,
pub token: String,
pub value: String,
}
#[derive(Queryable, Debug, Identifiable)]
#[diesel(table_name = media)]
pub struct Media {
pub id: i32,
pub token: String,
pub media_type: String,
pub file_id: String,
pub media_group_id: Option<String>,
}
#[derive(Queryable, Debug, Identifiable)]
#[diesel(table_name = messages)]
pub struct Message {
pub id: i32,
pub chat_id: i64,
pub message_id: i64,
pub token: String,
}
#[derive(Queryable, Debug, Identifiable)]
#[diesel(table_name = reservations)]
pub struct Reservation {
pub id: i32,
pub user_id: i64,
pub entered_name: String,
pub booked_time: NaiveDateTime,
pub event_id: i32,
pub status: String,
}
#[derive(Queryable, Debug, Identifiable)]
#[diesel(primary_key(chat_id))]
#[diesel(table_name = teloxide_dialogues)]
pub struct TeloxideDialogue {
pub chat_id: i64,
pub dialogue: Vec<u8>,
}
#[derive(Queryable, Debug)]
#[diesel(table_name = users)]
pub struct User {
pub id: i64,
pub is_admin: bool,
pub first_name: String,
pub last_name: Option<String>,
pub username: Option<String>,
pub language_code: Option<String>,
}

View File

@ -1,84 +0,0 @@
// @generated automatically by Diesel CLI.
diesel::table! {
events (id) {
id -> Int4,
time -> Timestamptz,
}
}
diesel::table! {
literals (id) {
id -> Int4,
#[max_length = 255]
token -> Varchar,
value -> Text,
}
}
diesel::table! {
media (id) {
id -> Int4,
token -> Varchar,
media_type -> Varchar,
file_id -> Varchar,
media_group_id -> Nullable<Varchar>,
}
}
diesel::table! {
messages (id) {
id -> Int4,
chat_id -> Int8,
message_id -> Int8,
#[max_length = 255]
token -> Varchar,
}
}
diesel::table! {
reservations (id) {
id -> Int4,
user_id -> Int8,
#[max_length = 255]
entered_name -> Varchar,
booked_time -> Timestamp,
event_id -> Int4,
status -> Varchar,
}
}
diesel::table! {
teloxide_dialogues (chat_id) {
chat_id -> Int8,
dialogue -> Bytea,
}
}
diesel::table! {
users (id) {
id -> Int8,
is_admin -> Bool,
#[max_length = 255]
first_name -> Varchar,
#[max_length = 255]
last_name -> Nullable<Varchar>,
#[max_length = 255]
username -> Nullable<Varchar>,
#[max_length = 10]
language_code -> Nullable<Varchar>,
}
}
diesel::joinable!(reservations -> events (event_id));
diesel::joinable!(reservations -> users (user_id));
diesel::allow_tables_to_appear_in_same_query!(
events,
literals,
media,
messages,
reservations,
teloxide_dialogues,
users,
);

View File

@ -3,8 +3,9 @@ pub mod db;
pub mod mongodb_storage;
pub mod utils;
use db::application::Application;
use db::callback_info::CallbackInfo;
use log::{info, warn};
use log::{error, info, warn};
use std::time::Duration;
use utils::create_callback_button;
@ -14,15 +15,14 @@ use crate::db::{CallDB, DB};
use crate::mongodb_storage::MongodbStorage;
use chrono::{DateTime, Utc};
use chrono_tz::Asia;
use db::DbError;
use envconfig::Envconfig;
use serde::{Deserialize, Serialize};
use teloxide::dispatching::dialogue::serializer::Json;
use teloxide::dispatching::dialogue::{GetChatId, Serializer};
use teloxide::types::{
InlineKeyboardButton, InlineKeyboardMarkup, InputFile, InputMedia, MediaKind, MessageKind,
ParseMode, ReplyMarkup,
InlineKeyboardButton, InlineKeyboardMarkup, InputFile, InputMedia, MediaKind, MessageId,
MessageKind, ParseMode, ReplyMarkup,
};
use teloxide::{
payloads::SendMessageSetters,
@ -40,13 +40,15 @@ pub struct Config {
pub db_url: String,
#[envconfig(from = "ADMIN_PASS")]
pub admin_password: String,
#[envconfig(from = "ADMIN_ID")]
pub admin_id: u64,
}
#[derive(BotCommands, Clone)]
#[command(rename_rule = "lowercase")]
enum UserCommands {
/// The first message of user
Start,
Start(String),
/// Shows this message.
Help,
}
@ -71,6 +73,7 @@ pub enum State {
lang: String,
is_caption_set: bool,
},
EditButton,
}
#[derive(Serialize, Deserialize)]
@ -79,6 +82,9 @@ pub enum State {
pub enum Callback {
MoreInfo,
ProjectPage { id: u32 },
GoHome,
LeaveApplication,
AskQuestion, // Add this line for the new callback
}
type CallbackStore = CallbackInfo<Callback>;
@ -103,6 +109,7 @@ pub enum BotError {
TeloxideError(#[from] teloxide::RequestError),
// TODO: not a really good to hardcode types, better to extend it later
StorageError(#[from] mongodb_storage::MongodbStorageError<<Json as Serializer<State>>::Error>),
MsgTooOld(String),
}
pub type BotResult<T> = Result<T, BotError>;
@ -143,6 +150,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.inspect(|u: Update| {
info!("{u:#?}"); // Print the update to the console with inspect
})
.branch(
Update::filter_callback_query()
.filter_async(async |q: CallbackQuery, mut db: DB| {
let tguser = q.from.clone();
let user = db
.get_or_init_user(tguser.id.0 as i64, &tguser.first_name)
.await;
user.map(|u| u.is_admin).unwrap_or(false)
})
.enter_dialogue::<CallbackQuery, MongodbStorage<Json>, State>()
.branch(dptree::case![State::EditButton].endpoint(button_edit_callback)),
)
.branch(Update::filter_callback_query().endpoint(callback_handler))
.branch(command_handler(config))
.branch(
@ -186,6 +205,63 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
async fn button_edit_callback(
bot: Bot,
mut db: DB,
dialogue: BotDialogue,
q: CallbackQuery,
) -> BotResult<()> {
bot.answer_callback_query(&q.id).await?;
let id = match q.data {
Some(id) => id,
None => {
bot.send_message(q.from.id, "Not compatible callback to edit text on")
.await?;
return Ok(());
}
};
let ci = match CallbackStore::get(&mut db, &id).await? {
Some(ci) => ci,
None => {
bot.send_message(
q.from.id,
"Can't get button information. Maybe created not by this bot or message too old",
)
.await?;
return Ok(());
}
};
let literal = match ci.literal {
Some(l) => l,
None => {
bot.send_message(
q.from.id,
"This button is not editable (probably text is generated)",
)
.await?;
return Ok(());
}
};
let lang = "ru".to_string();
dialogue
.update(State::Edit {
literal,
lang,
is_caption_set: false,
})
.await?;
bot.send_message(q.from.id, "Send text of button").await?;
Ok(())
}
async fn callback_handler(bot: Bot, mut db: DB, q: CallbackQuery) -> BotResult<()> {
bot.answer_callback_query(&q.id).await?;
@ -208,17 +284,119 @@ async fn callback_handler(bot: Bot, mut db: DB, q: CallbackQuery) -> BotResult<(
match callback {
Callback::MoreInfo => {
answer_message(
let keyboard = Some(single_button_markup!(
create_callback_button("go_home", Callback::GoHome, &mut db).await?
));
replace_message(
&bot,
q.chat_id().map(|i| i.0).unwrap_or(q.from.id.0 as i64),
&mut db,
"more_info",
None as Option<InlineKeyboardMarkup>,
q.chat_id().map(|i| i.0).unwrap_or(q.from.id.0 as i64),
q.message.map_or_else(
|| {
Err(BotError::MsgTooOld(
"Failed to get message id, probably message too old".to_string(),
))
},
|m| Ok(m.id().0),
)?,
"more_info_msg",
keyboard,
)
.await?
}
Callback::ProjectPage { id } => {
bot.send_message(q.from.id, format!("Some project No: {id}"))
let nextproject = match db
.get_literal_value(&format!("project_{}_msg", id + 1))
.await?
.unwrap_or("emptyproject".into())
.as_str()
{
"end" | "empty" | "none" => None,
_ => Some(
create_callback_button(
"next_project",
Callback::ProjectPage { id: id + 1 },
&mut db,
)
.await?,
),
};
let prevproject = match id.wrapping_sub(1) {
0 => None,
_ => Some(
create_callback_button(
"prev_project",
Callback::ProjectPage {
id: id.wrapping_sub(1),
},
&mut db,
)
.await?,
),
};
let keyboard = buttons_markup!(
[prevproject, nextproject].into_iter().flatten(),
[create_callback_button("go_home", Callback::GoHome, &mut db).await?]
);
replace_message(
&bot,
&mut db,
q.chat_id().map(|i| i.0).unwrap_or(q.from.id.0 as i64),
q.message.map_or_else(
|| {
Err(BotError::MsgTooOld(
"Failed to get message id, probably message too old".to_string(),
))
},
|m| Ok(m.id().0),
)?,
&format!("project_{}_msg", id),
Some(keyboard),
)
.await?
}
Callback::GoHome => {
let keyboard = make_start_buttons(&mut db).await?;
replace_message(
&bot,
&mut db,
q.chat_id().map(|i| i.0).unwrap_or(q.from.id.0 as i64),
q.message.map_or_else(
|| {
Err(BotError::MsgTooOld(
"Failed to get message id, probably message too old".to_string(),
))
},
|m| Ok(m.id().0),
)?,
"start",
Some(keyboard),
)
.await?
}
Callback::LeaveApplication => {
let application = Application::new(q.from.clone()).store(&mut db).await?;
send_application_to_chat(&bot, &mut db, &application).await?;
answer_message(
&bot,
q.from.id.0 as i64,
&mut db,
"left_application_msg",
None as Option<InlineKeyboardMarkup>,
)
.await?;
}
Callback::AskQuestion => {
answer_message(
&bot,
q.from.id.0 as i64,
&mut db,
"ask_question_msg",
None as Option<InlineKeyboardMarkup>,
)
.await?;
}
};
@ -226,6 +404,74 @@ async fn callback_handler(bot: Bot, mut db: DB, q: CallbackQuery) -> BotResult<(
Ok(())
}
async fn send_application_to_chat(
bot: &Bot,
db: &mut DB,
app: &Application<teloxide::types::User>,
) -> BotResult<()> {
let chat_id: i64 = match db.get_literal_value("support_chat_id").await? {
Some(strcid) => match strcid.parse() {
Ok(cid) => cid,
Err(err) => {
notify_admin(&format!(
"Support chat_id should be a number. Got: {strcid}, err: {err}.\n\
Anyways, applied user: {:?}",
app.from
))
.await;
return Ok(());
}
},
None => {
notify_admin(&format!(
"support_chat_id is not set!!!\nAnyways, applied user: {:?}",
app.from
))
.await;
return Ok(());
}
};
let msg = match db.get_literal_value("application_format").await? {
Some(msg) => msg
.replace("{user_id}", app.from.id.0.to_string().as_str())
.replace(
"{username}",
app.from
.username
.clone()
.unwrap_or("Username not set".to_string())
.as_str(),
),
None => {
notify_admin("format for support_chat_id is not set").await;
return Ok(());
}
};
bot.send_message(ChatId(chat_id), msg).await?;
Ok(())
}
/// This is an emergent situation function, so it should not return any Result, but handle Results
/// on its own
async fn notify_admin(text: &str) {
let config = match Config::init_from_env() {
Ok(config) => config,
Err(err) => {
error!("notify_admin: Failed to get config from env, err: {err}");
return;
}
};
let bot = Bot::new(&config.bot_token);
match bot.send_message(UserId(config.admin_id), text).await {
Ok(_) => {}
Err(err) => {
error!("notify_admin: Failed to send message to admin, WHATS WRONG???, err: {err}");
}
}
}
async fn edit_msg_cmd_handler(
bot: Bot,
mut db: DB,
@ -432,6 +678,7 @@ fn command_handler(
user.map(|u| u.is_admin).unwrap_or(false)
})
.filter_command::<AdminCommands>()
.enter_dialogue::<Message, MongodbStorage<Json>, State>()
.endpoint(admin_command_handler),
)
}
@ -456,7 +703,10 @@ async fn user_command_handler(
msg.html_text().unwrap_or("|EMPTY_MESSAGE|".into())
);
match cmd {
UserCommands::Start => {
UserCommands::Start(meta) => {
if !meta.is_empty() {
user.insert_meta(&mut db, &meta).await?;
}
let mut db2 = db.clone();
answer_message(
&bot,
@ -603,32 +853,94 @@ async fn answer_message<RM: Into<ReplyMarkup>>(
Ok(())
}
async fn replace_message(
bot: &Bot,
db: &mut DB,
chat_id: i64,
message_id: i32,
literal: &str,
keyboard: Option<InlineKeyboardMarkup>,
) -> BotResult<()> {
let text = db
.get_literal_value(literal)
.await?
.unwrap_or("Please, set content of this message".into());
let media = db.get_media(literal).await?;
let (chat_id, msg_id) = match media.len() {
// just a text
0 => {
let msg = bot.edit_message_text(ChatId(chat_id), MessageId(message_id), text);
let msg = match keyboard {
Some(ref kbd) => msg.reply_markup(kbd.clone()),
None => msg,
};
let msg = msg.parse_mode(teloxide::types::ParseMode::Html);
info!("ENTS: {:?}", msg.entities);
let msg = match msg.await {
Ok(msg) => msg,
Err(teloxide::RequestError::Api(teloxide::ApiError::Unknown(errtext)))
if errtext.as_str()
== "Bad Request: there is no text in the message to edit" =>
{
// fallback to sending message
warn!("Fallback into sending message instead of editing because it contains media");
return answer_message(bot, chat_id, db, literal, keyboard).await;
}
Err(err) => return Err(err.into()),
};
(msg.chat.id.0, msg.id.0)
}
// single media
1 => {
let media = &media[0]; // safe, cause we just checked len
let input_file = InputFile::file_id(media.file_id.to_string());
let media = match media.media_type.as_str() {
"photo" => InputMedia::Photo(teloxide::types::InputMediaPhoto::new(input_file)),
"video" => InputMedia::Video(teloxide::types::InputMediaVideo::new(input_file)),
_ => todo!(),
};
bot.edit_message_media(ChatId(chat_id), MessageId(message_id), media)
.await?;
let msg = bot.edit_message_caption(ChatId(chat_id), MessageId(message_id));
let msg = match text.as_str() {
"" => msg,
text => msg.caption(text),
};
let msg = match keyboard {
Some(kbd) => msg.reply_markup(kbd),
None => msg,
};
let msg = msg.parse_mode(teloxide::types::ParseMode::Html);
let msg = msg.await?;
(msg.chat.id.0, msg.id.0)
}
// >= 2, should use media group
_ => {
unreachable!();
}
};
db.set_message_literal(chat_id, msg_id, literal).await?;
Ok(())
}
async fn make_start_buttons(db: &mut DB) -> BotResult<InlineKeyboardMarkup> {
let mut buttons: Vec<Vec<InlineKeyboardButton>> = db
.get_all_events()
.await?
.iter()
.map(|e| {
vec![InlineKeyboardButton::callback(
e.time.with_timezone(&Asia::Dubai).to_string(),
format!("event:{}", e._id),
)]
})
.collect();
buttons.push(vec![InlineKeyboardButton::callback(
"More info",
CallbackStore::new(Callback::MoreInfo)
.store(db)
.await?
.get_id(),
)]);
let mut buttons: Vec<Vec<InlineKeyboardButton>> = Vec::new();
buttons.push(vec![
create_callback_button(
"show_projects",
CallbackStore::new(Callback::ProjectPage { id: 1 }),
db,
)
.await?,
create_callback_button("show_projects", Callback::ProjectPage { id: 1 }, db).await?,
]);
buttons.push(vec![
create_callback_button("more_info", Callback::MoreInfo, db).await?,
]);
buttons.push(vec![
create_callback_button("leave_application", Callback::LeaveApplication, db).await?,
]);
buttons.push(vec![
create_callback_button("ask_question", Callback::AskQuestion, db).await?,
]);
Ok(InlineKeyboardMarkup::new(buttons))

View File

@ -6,9 +6,44 @@ use crate::{
BotResult,
};
#[macro_export]
macro_rules! single_button_markup {
($button:expr) => {
InlineKeyboardMarkup {
inline_keyboard: vec![vec![$button]],
}
};
}
#[macro_export]
macro_rules! stacked_buttons_markup {
($( $button:expr ),+) => {
InlineKeyboardMarkup {
inline_keyboard: vec![
$(
vec![$button],
)*
],
}
};
}
#[macro_export]
macro_rules! buttons_markup {
($( $buttons:expr ),+) => {
InlineKeyboardMarkup {
inline_keyboard: vec![
$(
$buttons.into_iter().collect::<Vec<_>>(),
)*
],
}
};
}
pub async fn create_callback_button<C, D>(
literal: &str,
ci: CallbackInfo<C>,
callback: C,
db: &mut D,
) -> BotResult<InlineKeyboardButton>
where
@ -19,10 +54,38 @@ where
.get_literal_value(literal)
.await?
.unwrap_or("Please, set content of this message".into());
let ci = ci.store(db).await?;
let ci = CallbackInfo::new_with_literal(callback, literal.to_string())
.store(db)
.await?;
Ok(InlineKeyboardButton::new(
text,
teloxide::types::InlineKeyboardButtonKind::CallbackData(ci.get_id()),
))
}
#[cfg(test)]
mod tests {
use super::*;
use teloxide::types::InlineKeyboardButton;
use teloxide::types::InlineKeyboardMarkup;
#[test]
fn test_buttons_markup() {
let button1 = InlineKeyboardButton::new(
"Button 1",
teloxide::types::InlineKeyboardButtonKind::CallbackData("callback1".into()),
);
let button2 = InlineKeyboardButton::new(
"Button 2",
teloxide::types::InlineKeyboardButtonKind::CallbackData("callback2".into()),
);
let markup = buttons_markup!([button1.clone(), button2.clone()], [button1.clone()]);
assert_eq!(markup.inline_keyboard.len(), 2);
assert_eq!(markup.inline_keyboard[0][0].text, "Button 1");
assert_eq!(markup.inline_keyboard[0][1].text, "Button 2");
assert_eq!(markup.inline_keyboard[1][0].text, "Button 1");
}
}