Migrating my family finance bot from Python to Rust (teloxide) because I am tired of exceptions (part 1)

21 November, 2022

22 min read

Last updated on 21 November, 2022

First, we need to check what's inside my packages.

[package]
name = "telegram-bot"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
env-file-reader = "0.3.0"
log = "0.4.17"
pretty_env_logger = "0.4.0"
teloxide = { version = "0.11.0", features = ["full"]}
tokio = { version = "1.19.2", features = ["macros"] }
Cargo.toml

This is my 'hello world' starter to not write everything from scratch. I copied it from teloxide repository under the version I'm planning to use (0.11.0).

// This example demonstrates how to deal with messages and callback queries
// within a single dialogue.
//
// # Example
// ```
// - /start
// - Let's start! What's your full name?
// - John Doe
// - Select a product:
//   [Apple, Banana, Orange, Potato]
// - <A user selects "Banana">
// - John Doe, product 'Banana' has been purchased successfully!
// ```

use teloxide::{
    dispatching::{dialogue, dialogue::InMemStorage, UpdateHandler},
    prelude::*,
    types::{InlineKeyboardButton, InlineKeyboardMarkup},
    utils::command::BotCommands,
};

type MyDialogue = Dialogue<State, InMemStorage<State>>;
type HandlerResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;

#[derive(Clone, Default)]
pub enum State {
    #[default]
    Start,
    ReceiveFullName,
    ReceiveProductChoice {
        full_name: String,
    },
}

#[derive(BotCommands, Clone)]
#[command(rename_rule = "lowercase", description = "These commands are supported:")]
enum Command {
    #[command(description = "display this text.")]
    Help,
    #[command(description = "start the purchase procedure.")]
    Start,
    #[command(description = "cancel the purchase procedure.")]
    Cancel,
}

#[tokio::main]
async fn main() {
    pretty_env_logger::init();
    log::info!("Starting purchase bot...");

    let bot = Bot::from_env();

    Dispatcher::builder(bot, schema())
        .dependencies(dptree::deps![InMemStorage::<State>::new()])
        .enable_ctrlc_handler()
        .build()
        .dispatch()
        .await;
}

fn schema() -> UpdateHandler<Box<dyn std::error::Error + Send + Sync + 'static>> {
    use dptree::case;

    let command_handler = teloxide::filter_command::<Command, _>()
        .branch(
            case![State::Start]
                .branch(case![Command::Help].endpoint(help))
                .branch(case![Command::Start].endpoint(start)),
        )
        .branch(case![Command::Cancel].endpoint(cancel));

    let message_handler = Update::filter_message()
        .branch(command_handler)
        .branch(case![State::ReceiveFullName].endpoint(receive_full_name))
        .branch(dptree::endpoint(invalid_state));

    let callback_query_handler = Update::filter_callback_query().branch(
        case![State::ReceiveProductChoice { full_name }].endpoint(receive_product_selection),
    );

    dialogue::enter::<Update, InMemStorage<State>, State, _>()
        .branch(message_handler)
        .branch(callback_query_handler)
}

async fn start(bot: Bot, dialogue: MyDialogue, msg: Message) -> HandlerResult {
    bot.send_message(msg.chat.id, "Let's start! What's your full name?").await?;
    dialogue.update(State::ReceiveFullName).await?;
    Ok(())
}

async fn help(bot: Bot, msg: Message) -> HandlerResult {
    bot.send_message(msg.chat.id, Command::descriptions().to_string()).await?;
    Ok(())
}

async fn cancel(bot: Bot, dialogue: MyDialogue, msg: Message) -> HandlerResult {
    bot.send_message(msg.chat.id, "Cancelling the dialogue.").await?;
    dialogue.exit().await?;
    Ok(())
}

async fn invalid_state(bot: Bot, msg: Message) -> HandlerResult {
    bot.send_message(msg.chat.id, "Unable to handle the message. Type /help to see the usage.")
        .await?;
    Ok(())
}

async fn receive_full_name(bot: Bot, dialogue: MyDialogue, msg: Message) -> HandlerResult {
    match msg.text().map(ToOwned::to_owned) {
        Some(full_name) => {
            let products = ["Apple", "Banana", "Orange", "Potato"]
                .map(|product| InlineKeyboardButton::callback(product, product));

            bot.send_message(msg.chat.id, "Select a product:")
                .reply_markup(InlineKeyboardMarkup::new([products]))
                .await?;
            dialogue.update(State::ReceiveProductChoice { full_name }).await?;
        }
        None => {
            bot.send_message(msg.chat.id, "Please, send me your full name.").await?;
        }
    }

    Ok(())
}

async fn receive_product_selection(
    bot: Bot,
    dialogue: MyDialogue,
    full_name: String, // Available from `State::ReceiveProductChoice`.
    q: CallbackQuery,
) -> HandlerResult {
    if let Some(product) = &q.data {
        bot.send_message(
            dialogue.chat_id(),
            format!("{full_name}, product '{product}' has been purchased successfully!"),
        )
        .await?;
        dialogue.exit().await?;
    }

    Ok(())
}
src/main.rs

To make it work, some additional steps are needed.

cargo add dotenv

Add to main function this line:

...

async fn main() {
    dotenv::dotenv().ok();

    pretty_env_logger::init();
...
src/main.rs

and to .env file some variables

RUST_LOG=info
TELOXIDE_TOKEN=<my token>

Use cargo run to run starter bot. This is how it works for us (users):

Some parts from the starter are particularly interesting for us:

...
Dispatcher::builder(bot, schema())
        .dependencies(dptree::deps![InMemStorage::<State>::new()])
        .enable_ctrlc_handler()
        .build()
        .dispatch()
        .await;
...

This piece of code telling we'd have in-memory hashmap to store our state. For me it's a reasonable choice as i'm building bot only for two people. There are some other places using this crate dptree, which seems to be the main working horse for state changes for this library.

What I want my bot to do? Limit my desires and make me a cheap and frugal human.

In my bot, I want cover those use cases:

  • individual has a check and wants to add items to specific category
  • individual wants to know how much money remain to spend
  • add money to specific category by schedule
  • let me know I saved something and can spend it on useless things

We have already doing that for quite a while with my women and this process helps us to save. By using our bot, we can be fully transparent with money – both me and she know how much remain, but in the same time I don't give her full observability to my bank account because I'm the owner and this is my personal boundaries.

I don't check any her spendings. Bot is our money police. I'm the one who write down the rules we have agreed on and implement them in code.

This is how our family budget works.

Skeleton of the first command. Learning state.

Starting from the first use case: 'human has a check and wants to add items to specific category'.

I have changed state, commands, schema and put my handlers in separate file called 'add_check.rs' so it would be easier for me to navigate the code.

Let me show you all the updates.

pub mod add_check;

use teloxide::{
    dispatching::{dialogue, dialogue::InMemStorage, UpdateHandler},
    prelude::*,
    utils::command::BotCommands,
};

use crate::add_check::{start, receive_category, receive_check};

#[derive(Clone, Default)]
pub enum State {
    #[default]
    Start,
    ReceiveCategory,
    ReceiveCheckWithItems {
        category: String,
    },
}

type MyDialogue = Dialogue<State, InMemStorage<State>>;
type HandlerResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;

#[derive(BotCommands, Clone)]
#[command(
    rename_rule = "lowercase",
    description = "These commands are supported:"
)]
enum Command {
    #[command(description = "display this text.")]
    Help,
    #[command(description = "Додати витрати в певну категорію.")]
    AddCheck,
    #[command(description = "cancel the purchase procedure.")]
    Cancel,
}

#[tokio::main]
async fn main() {
    dotenv::dotenv().ok();

    pretty_env_logger::init();
    log::info!("Starting bot...");

    let bot = Bot::from_env();

    Dispatcher::builder(bot, schema())
        .dependencies(dptree::deps![InMemStorage::<State>::new()])
        .enable_ctrlc_handler()
        .build()
        .dispatch()
        .await;
}

fn schema() -> UpdateHandler<Box<dyn std::error::Error + Send + Sync + 'static>> {
    use dptree::case;

    let command_handler = teloxide::filter_command::<Command, _>()
        .branch(
            case![State::Start]
                .branch(case![Command::Help].endpoint(help))
                .branch(case![Command::AddCheck].endpoint(start)),
        )
        .branch(case![Command::Cancel].endpoint(cancel));

    let message_handler = Update::filter_message()
        .branch(command_handler)
        .branch(case![State::ReceiveCheckWithItems { category }].endpoint(receive_check))
        .branch(dptree::endpoint(invalid_state));

    let callback_query_handler = Update::filter_callback_query().branch(
        case![State::ReceiveCategory].endpoint(receive_category),
    );

    dialogue::enter::<Update, InMemStorage<State>, State, _>()
        .branch(message_handler)
        .branch(callback_query_handler)
}

async fn help(bot: Bot, msg: Message) -> HandlerResult {
    bot.send_message(msg.chat.id, Command::descriptions().to_string())
        .await?;
    Ok(())
}

async fn cancel(bot: Bot, dialogue: MyDialogue, msg: Message) -> HandlerResult {
    bot.send_message(msg.chat.id, "Cancelling the dialogue.")
        .await?;
    dialogue.exit().await?;
    Ok(())
}

async fn invalid_state(bot: Bot, msg: Message) -> HandlerResult {
    bot.send_message(
        msg.chat.id,
        "Unable to handle the message. Type /help to see the usage.",
    )
    .await?;
    Ok(())
}
src/main.rs

And my new handlers.

use teloxide::payloads::SendMessageSetters;
use teloxide::requests::Requester;
use teloxide::types::CallbackQuery;
use teloxide::types::InlineKeyboardButton;
use teloxide::types::InlineKeyboardMarkup;
use teloxide::types::Message;
use teloxide::Bot;

use crate::HandlerResult;
use crate::MyDialogue;
use crate::State;

pub async fn start(bot: Bot, dialogue: MyDialogue, msg: Message) -> HandlerResult {
    let categories = [
        "Поживні речовини",
        "Операційні витрати",
        "Аренда",
        "Українські зобовʼязання",
    ]
    .map(|category| InlineKeyboardButton::callback(category, category));

    bot.send_message(msg.chat.id, "Спочатку виберіть категорію:")
        .reply_markup(InlineKeyboardMarkup::new([categories]))
        .await?;

    dialogue.update(State::ReceiveCategory).await?;

    Ok(())
}

pub async fn receive_check(
    bot: Bot,
    dialogue: MyDialogue,
    category: String,
    msg: Message,
) -> HandlerResult {
    match msg.text().map(ToOwned::to_owned) {
        Some(raw_check) => {
            println!("{} and {}", raw_check, category);
            // process and send check to storage
            bot.send_message(msg.chat.id, "Чек успешно заведено.Історію буде видалено.")
                .await?;
            dialogue.exit().await?;
        }
        None => {
            bot.send_message(msg.chat.id, "Не можу без чека. Чекаю на чек.")
                .await?;
        }
    }

    Ok(())
}

pub async fn receive_category(
    bot: Bot,
    dialogue: MyDialogue,
    q: CallbackQuery,
) -> HandlerResult {
    if let Some(category) = &q.data {
        bot.send_message(dialogue.chat_id(), "А зараз мені потрібен твій чек...")
            .await?;
        dialogue
            .update(State::ReceiveCheckWithItems {
                category: category.to_string(),
            })
            .await?;
    }

    Ok(())
}
src/add_check.rs

Hope you won't mind some Ukrainian there.

How does that look like in real life?

Buttons certainly should be better... But the skeleton of the first user flow is covered. We are receiving some input with a button, then asking for a check, receiving a message and temporary putting all of that to console. As our storage I use Notion Database because we already using it and I don't want to change.

Towards better buttons and user experience

I decided to change the look of buttons because it's hard to read.

This is the new look I made.

Now buttons are visible, but callback doesn't work, because those buttons are not making callback.

The code for buttons is that

...

pub async fn start(bot: Bot, dialogue: MyDialogue, msg: Message) -> HandlerResult {
    let categories_buttons = [
        "Поживні речовини",
        "Операційні витрати",
        "Аренда",
        "Українські зобовʼязання",
    ]
    .map(|category| KeyboardButton::new(category));

    bot.send_message(msg.chat.id, "Спочатку виберіть категорію:")
        .reply_markup(KeyboardMarkup::new([categories_buttons]).one_time_keyboard(true))
        .await?;

    dialogue.update(State::ReceiveCategory).await?;

    Ok(())
}

...
src/add_check.rs

I've changed handlers to reflect my button logic change.

...

let message_handler = Update::filter_message()
        .branch(command_handler)
        .branch(case![State::ReceiveCategory].endpoint(receive_category))
        .branch(case![State::ReceiveCheckWithItems { category }].endpoint(receive_check))
        .branch(dptree::endpoint(invalid_state));


dialogue::enter::<Update, InMemStorage<State>, State, _>()
        .branch(message_handler)
 
 ...
src/main.rs

Signature of 'receive_category' function should be changed as well because we don't handle callback there, we're receiving an actual message from our user.

...

pub async fn receive_category(bot: Bot, dialogue: MyDialogue, msg: Message) -> HandlerResult {
    if let Some(category) = msg.text().map(ToOwned::to_owned) {
        bot.send_message(dialogue.chat_id(), "А зараз мені потрібен твій чек...")
            .await?;
        dialogue
            .update(State::ReceiveCheckWithItems {
                category: category.to_string(),
            })
            .await?;
    }

    Ok(())
}

...
src/add_check.rs

Now it works more like I want.

Input validation with enums – I don't trust strings and you should not either

So far I used strings for my categories, but it's not reliable, because user can enter any string they want while I'm prompting for it and break my app with that. I use Rust enums and String traits to enforce validation and stability for my categories. This is my new 'Category' enum.

Note: I derive 'Debug' and 'Clone' not because it should be done every time you use some struct or enum. 'Copy' trait is needed for 'State' as it's trait bounded. 'Debug' is for my personal usage.

...

#[derive(Debug, Clone)]
pub enum Category {
    FoodAndDrinks,
    OperationalSpends,
    Rent,
    UkrReponsibilities,
}

impl ToString for Category {
    fn to_string(&self) -> String {
        let string_literal = match self {
            Category::FoodAndDrinks => "Поживні речовини",
            Category::OperationalSpends => "Операційні витрати",
            Category::Rent => "Аренда",
            Category::UkrReponsibilities => "Українські зобовʼязання",
        };
        string_literal.to_owned()
    }
}

impl FromStr for Category {
    type Err = ();

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "Поживні речовини" => Ok(Category::FoodAndDrinks),
            "Операційні витрати" => Ok(Category::OperationalSpends),
            "Аренда" => Ok(Category::Rent),
            "Українські зобовʼязання" => Ok(Category::UkrReponsibilities),
            _ => Err(()),
        }
    }
}

impl Category {
    pub fn to_storage_id(&self) -> &str {
        match self {
            Category::FoodAndDrinks => "de702077-821a-4b59-9ab0-949ca954c4a6",
            Category::OperationalSpends => "3e392471-bd4a-4cde-befb-e52e8f31b26d",
            Category::Rent => "278a679f-cde4-44aa-be5a-2876b67d080b",
            Category::UkrReponsibilities => "M?[|"
        }
    }
}

...
src/add_check.rs

As you may see, I didn't care much about error types. I don't need it right now.

Let's update our 'receive_category' function.

...

pub async fn receive_category(bot: Bot, dialogue: MyDialogue, msg: Message) -> HandlerResult {
    if let Some(category) = msg.text() {
        bot.send_message(dialogue.chat_id(), "А зараз мені потрібен твій чек...")
            .await?;
        let category_variant = Category::from_str(category);
        if let Ok(variant) = category_variant {
            dialogue
                .update(State::ReceiveCheckWithItems { category: variant })
                .await?;
        }
    }

    Ok(())
}

...
src/add_check.rs

I again don't do much about errors. If you are writing something more serious than me, you should not follow this pattern.

I change my 'State' to contain 'Category' enum.

...

#[derive(Clone, Default)]
pub enum State {
    #[default]
    Start,
    ReceiveCategory,
    ReceiveCheckWithItems {
        category: Category,
    },
}

...
src/main.rs

And when I 'receive check' I want to make sure I have storage id, because who cares about strings?

...

pub async fn receive_check(
    bot: Bot,
    dialogue: MyDialogue,
    category: Category,
    msg: Message,
) -> HandlerResult {
    match msg.text().map(ToOwned::to_owned) {
        Some(raw_check) => {
            println!("{} and {}", raw_check, category.to_storage_id());
            // process and send check to storage
            bot.send_message(msg.chat.id, "Чек успешно заведено.Історію буде видалено.")
                .await?;
            dialogue.exit().await?;
        }
        None => {
            bot.send_message(msg.chat.id, "Не можу без чека. Чекаю на чек.")
                .await?;
        }
    }

    Ok(())
}

...
src/add_check.rs

Showcase it works.

The first string blob is my 'check' and the second one is my newly upgraded 'Category' in 'storage id' representation.

Modeling Check and Check Items

Let's add structs first. I would use tuple structs there, because there is no special need to name fields. They are self explanatory as for me.

...

#[derive(Debug)]
pub struct Check(Vec<CheckEntry>);

#[derive(Debug)]
pub struct CheckEntry(String, f32);

...
src/add_check.rs

We'd have Check which is essentially wrapper for CheckEntries. Check entry is just a description and price.

For example: 'cheese 2.3' is a check entry. Or 'new year present 3.2'. Description can be few words and price can be integer or float, but I model it as 'f32'.

I would make those from strings, so I would use 'FromStr' Trait once again to cover my needs.

impl FromStr for Check {
    type Err = ();

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let mut check = Check(Vec::new());
        for split in s.split("/n").into_iter() {
            if let Ok(entry) = CheckEntry::from_str(split) {
                check.0.push(entry);
            } else {
                return Err(());
            }
        }
        Ok(check)
    }
}


impl FromStr for CheckEntry {
    type Err = ();

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let content = s
            .split_ascii_whitespace()
            .into_iter()
            .collect::<Vec<&str>>();
        let re = Regex::new(r"[-+]?\d*\.\d+|\d+").unwrap();
        let mat = re.find(content.last().unwrap());
        if let Some(matched) = mat {
            let price = matched.as_str().parse::<f32>().unwrap();
            let description = content
                .iter()
                .take(content.len() - 1)
                .map(|el| el.to_string())
                .collect::<Vec<String>>()
                .join(" ");
            return Ok(CheckEntry(description, price));
        }
        Err(())
    }
}
src/add_check.rs

I've also added regex crate to my project using cargo add regex.

...
regex = "1.7.0"
...
Cargo.toml

You may worry my code is oververbose and you are right. I'm sure there is a better way to do that than me doing now. Hope I'd progress and my next post would contain better Rust code. There is also no error handling for now.

Now, we're changing check processing function now.

...

pub async fn receive_check(
    bot: Bot,
    dialogue: MyDialogue,
    category: Category,
    msg: Message,
) -> HandlerResult {
    match msg.text().map(ToOwned::to_owned) {
        Some(raw_check) => {
            let unchecked_check = Check::from_str(&raw_check);
            if let Ok(check) = unchecked_check {
                println!("{:?} and {}", check, category.to_storage_id());
                // process and send check to storage
                bot.send_message(msg.chat.id, "Чек успешно заведено.Історію буде видалено.")
                    .await?;
                dialogue.exit().await?;
            }
        }
        None => {
            bot.send_message(msg.chat.id, "Не можу без чека. Чекаю на чек.")
                .await?;
        }
    }

    Ok(())
}

...
src/add_check.rs

Let's launch new code and check it works. It should.

Now we have checks and there are check entries inside them. Next step should be to send all of that to my storage which is Notion Database.

Storing data in Notion Database

For the moment of me writing this post, there is no good-quality, convenient Notion Client for Rust. Due to those reasons, I'd use reqwest and talk to Notion API directly. I'd use 'v1' of Notion API because that is what my old Python Bot is using.

But first I'd refresh myself with how my database looks right now. This is my current 'schema'.

{
  "object": "database",
  "id": "<redacted>",
  "cover": null,
  "icon": {
    "type": "emoji",
    "emoji": "💲"
  },
  "created_time": "2021-04-02T11:34:00.000Z",
  "created_by": {
    "object": "user",
    "id": "<redacted>"
  },
  "last_edited_by": {
    "object": "user",
    "id": "<redacted>"
  },
  "last_edited_time": "2022-11-20T21:03:00.000Z",
  "title": [
    {
      "type": "text",
      "text": {
        "content": "<redacted>",
        "link": null
      },
      "annotations": {
        "bold": false,
        "italic": false,
        "strikethrough": false,
        "underline": false,
        "code": false,
        "color": "default"
      },
      "plain_text": "<redacted>",
      "href": null
    }
  ],
  "description": [],
  "is_inline": false,
  "properties": {
    "Income": {
      "id": "%3EDKI",
      "name": "Income",
      "type": "number",
      "number": {
        "format": "number"
      }
    },
    "Currency": {
      "id": "EiX%40",
      "name": "Currency",
      "type": "select",
      "select": {
        "options": [
          {
            "id": "0d93814b-1597-49ac-9f3e-966b70f236f2",
            "name": "Euro",
            "color": "gray"
          }
        ]
      }
    },
    "For current period": {
      "id": "ORK%3C",
      "name": "For current period",
      "type": "number",
      "number": {
        "format": "number"
      }
    },
    "Expected": {
      "id": "Oy%5Dk",
      "name": "Expected",
      "type": "number",
      "number": {
        "format": "number"
      }
    },
    "Date": {
      "id": "ZBp%3B",
      "name": "Date",
      "type": "date",
      "date": {}
    },
    "Tags": {
      "id": "_osv",
      "name": "Tags",
      "type": "multi_select",
      "multi_select": {
        "options": [
          {
            "id": "M?[|",
            "name": "Українські зобовʼязання",
            "color": "green"
          },
          {
            "id": "de702077-821a-4b59-9ab0-949ca954c4a6",
            "name": "Поживні речовини",
            "color": "gray"
          },
          {
            "id": "3e392471-bd4a-4cde-befb-e52e8f31b26d",
            "name": "Операційні витрати",
            "color": "yellow"
          },
          {
            "id": "ab08b183-ee2e-4d34-bee3-772e5a6a069a",
            "name": "медрасходы",
            "color": "orange"
          },
          {
            "id": "7f39e4c2-db17-42d9-91c1-8c7a7be224f6",
            "name": "Экстренные расходы",
            "color": "brown"
          },
          {
            "id": "c2a445a7-fee4-4e1e-a7af-6d1401e5d6d7",
            "name": "Кредиты",
            "color": "pink"
          },
          {
            "id": "8b88c40f-78be-4b44-a955-ea8365a70ae4",
            "name": "Приход",
            "color": "blue"
          },
          {
            "id": "fb488835-f2fa-4246-bde3-7f9baea30005",
            "name": "Приоритетные расходы",
            "color": "purple"
          },
          {
            "id": "278a679f-cde4-44aa-be5a-2876b67d080b",
            "name": "Аренда",
            "color": "red"
          },
          {
            "id": "b01d729c-6451-46ef-ab07-bcd0ad0fef27",
            "name": "Из сэкономленного",
            "color": "default"
          }
        ]
      }
    },
    "Spent": {
      "id": "wcC%3A",
      "name": "Spent",
      "type": "number",
      "number": {
        "format": "number"
      }
    },
    "Remain": {
      "id": "ynYN",
      "name": "Remain",
      "type": "formula",
      "formula": {
        "expression": "prop(\"For current period\") + prop(\"Spent\")"
      }
    },
    "Name": {
      "id": "title",
      "name": "Name",
      "type": "title",
      "title": {}
    }
  },
  "parent": {
    "type": "page_id",
    "page_id": "<redacted>"
  },
  "url": "<redacted>",
  "archived": false
}

You can retrieve that information with that command. I took it from Notion Official Documentation. jq would nicely format your output.

curl 'https://api.notion.com/v1/databases/<db id>' \
  -H 'Authorization: Bearer '"<redacted secret>"'' \
  -H 'Notion-Version: 2022-06-28' | jq . 

Retrieving how much money left for specific category

Some updates to 'Cargo.toml' first:

...
reqwest = "0.11.13"
serde = "1.0.147"
serde_json = "1.0.88"
...
Cargo.toml

Serde and serde_json would be really useful when we'd write jsons in Rust. It would be fun.

I'll make a file called notion.rs and put some new things there. Don't forget to define a new module in main.rs.

...

const DATABASE_ID: &str = <my db api, redacted>;
const NOTION_HOST: &str = "https://api.notion.com/v1/";

#[derive(Debug, Clone)]
pub struct NotionApi {
    secret: String,
    client: Client,
}

impl NotionApi {
    pub fn from_env() -> Self {
        NotionApi {
            secret: std::env::var("NOTION_SECRET").unwrap(),
            client: Client::new(),
        }
    }
}

...
src/notion.rs

We'd use this struct to make client once and then just pass it as dependency to our bot handlers.

...

let bot = Bot::from_env();
let notion_api = NotionApi::from_env();

Dispatcher::builder(bot, schema())
        .dependencies(dptree::deps![InMemStorage::<State>::new(), notion_api])
        .enable_ctrlc_handler()
        .build()
        .dispatch()
        .await;

...
src/main.rs

Then, we slightly modify our receive_check function so it would be able to use our client.

...

pub async fn receive_check(
    bot: Bot,
    dialogue: MyDialogue,
    category: Category,
    client: NotionApi,
    msg: Message,
) -> HandlerResult {
    match msg.text().map(ToOwned::to_owned) {
        Some(raw_check) => {
            let unchecked_check = Check::from_str(&raw_check);
            if let Ok(check) = unchecked_check {
                get_how_much_money_remain(&client, category.to_string()).await;
                println!("{:?} and {}", check, category.to_storage_id());
                // process and send check to storage
                bot.send_message(msg.chat.id, "Чек успешно заведено.Історію буде видалено.")
                    .await?;
                dialogue.exit().await?;
            }
        }
        None => {
            bot.send_message(msg.chat.id, "Не можу без чека. Чекаю на чек.")
                .await?;
        }
    }

    Ok(())
}
...
src/add_check.rs

As you may see Notion client is being passed to handler and it's reflected in function signature. To not add a lot of details there, it's because of this line in Dispatcher::builder

...
.dependencies(dptree::deps![InMemStorage::<State>::new(), notion_api])
...
src/main.rs

You may read more about that in dptree crate.

Except the signature you would see there is a new function call inside receive_check function.

...
get_how_much_money_remain(&client, category.to_string()).await;
...
src/add_check.rs

This function I have not put in the post yet (but already implemented ahead of time). Let's see what's inside there.

...

pub async fn get_how_much_money_remain(api: &NotionApi, tag_name: String) {
    let endpoint = format!("databases/{}/query", DATABASE_ID);
    let kwargs = json!({
        "filter": {
            "property": "Tags",
            "multi_select": {
                "contains": tag_name
            }
        },
        "sorts": [{
            "property": "Date",
            "timestamp": "created_time",
            "direction": "descending"
        }]
    });
    let assembled_url = format!("{}{}", NOTION_HOST, endpoint);

    let response = api
        .client
        .post(assembled_url)
        .header("Notion-Version", "2022-06-28")
        .bearer_auth(&api.secret)
        .json(&kwargs)
        .send()
        .await;

    if let Ok(response) = response {
        let json_resp = response.json::<Value>().await.unwrap();
        let remain = json_resp.get("results").and_then(|value| {
            value.get(0).and_then(|value| {
                value.get("properties").and_then(|value| {
                    value.get("Remain").and_then(|value| {
                        value.get("formula").and_then(|value| value.get("number"))
                    })
                })
            })
        });

        if let Some(remain) = remain {
            println!("{}", remain.as_f64().unwrap());
        }
    }
}
src/notion.rs

Basically, this is just a request from Notion Documentation for query Notion Database. I retrieve how much money remain for specific category. I do sorting so I can retrieve latest remaining value and I filter by tag name.

If we launch the bot and pass all /addcheck flow, we'd see magic number in our console meaning we have remaining money.

Now, when we know how much remain, we can add a new document to our improvised 'database' and fill it with check details.

Creating new document in Notion Database. Showcasing trait-bounded payload constructor

I decided to make a module called 'notion' and put several files there because I don't want to keep my notion.rs file too long. Now it looks like that.

.
├── Cargo.lock
├── Cargo.toml
└── src
    ├── add_check.rs
    ├── main.rs
    └── notion
        ├── mod.rs
        └── spending_document.rs

2 directories, 6 files

'spending_document.rs' will be the place where I define my document I am going to post to the database.

use chrono::Local;
use serde::Serialize;
use serde_json::json;
use serde_json::Value;

use crate::add_check::Category;
use crate::notion::DATABASE_ID;

#[derive(Serialize)]
pub struct SpendingDocument {
    parent: Value,
    properties: Value,
    children: Vec<Value>,
}

struct SpendingDocumentBulletPoints(Vec<Value>);

impl SpendingDocument {
    pub fn new<T: ToString>(
        title: String,
        category: &Category,
        spent: f64,
        remain: f64,
        entries: &Vec<T>,
    ) -> Self {
        let properties = json!({
            "Name": {
                "type": "title",
                "title": [
                    {
                        "plain_text": title,
                        "text": {
                            "content": title,
                            "link": serde_json::Value::Null},
                        "type": "text"
                    }
                ]
            },
            "Tags": {
                "multi_select": [{
                    "id": category.to_storage_id(),
                }],
                "type": "multi_select"
            },
            "Date": {"date": {"start": Local::now().to_rfc3339()}, "type": "date"},
            "Spent": {"number": spent, "type": "number"},
            "For current period": {"number": remain, "type": "number"},
        });
        let bullets = SpendingDocumentBulletPoints::new(entries);
        SpendingDocument {
            parent: json!({ "database_id": DATABASE_ID }),
            properties,
            children: bullets.0,
        }
    }
}

impl SpendingDocumentBulletPoints {
    pub fn new<T: ToString>(entries: &Vec<T>) -> Self {
        SpendingDocumentBulletPoints(
            entries
                .into_iter()
                .map(|line| {
                    json!({
                        "object": "block",
                        "type": "bulleted_list_item",
                        "bulleted_list_item":
                            {
                                "rich_text": [{
                                    "type": "text",
                                    "text": {
                                        "content": line.to_string(),
                                    }
                                }]
                            }
                    })
                })
                .collect::<Vec<Value>>(),
        )
    }
}
src/notion/spending_document.rs

I use trait bounded generics because I know that only one thing is needed from type there – is to be converted to a string. I find it convenient. serde_json also helps a lot with unstructured json types, like we see above.

Everything you'd see there is taked from the official Notion Documentation. I don't want you to wonder from which source I have taken this schema.

Also, if you noticed, I added chrono crate. It simplifies work with datetime types.

...
[dependencies]
chrono = "0.4.23"
...
Cargo.toml

In the main module file in newly created 'notion' folder, we currently having that:

mod spending_document;

use reqwest::Client;
use serde::Serialize;
use serde_json::json;
use serde_json::Value;

use crate::add_check::Category;
use crate::add_check::Check;
use crate::notion::spending_document::SpendingDocument;

const DATABASE_ID: &str = <redacted>;
const NOTION_HOST: &str = "https://api.notion.com/v1/";

#[derive(Debug, Clone)]
pub struct NotionApi {
    secret: String,
    client: Client,
}

impl NotionApi {
    pub fn from_env() -> Self {
        NotionApi {
            secret: std::env::var("NOTION_SECRET").unwrap(),
            client: Client::new(),
        }
    }
}

pub async fn make_new_notion_entry_for_check(
    api: NotionApi,
    category: &Category,
    check: &Check
) {
    let remain = get_how_much_money_remain(&api, category.to_string()).await;

    if let Ok(remain) = remain {
        let spending_document =
            SpendingDocument::new(category.to_string(), category, check.spent, remain, &check.entries);
        post_new_notion_document(&api, spending_document).await;
    }
}

async fn post_new_notion_document<T: Serialize>(api: &NotionApi, document: T) {
    let endpoint = "pages";
    let assembled_url = format!("{}{}", NOTION_HOST, endpoint);
    let response = api
        .client
        .post(assembled_url)
        .header("Notion-Version", "2022-06-28")
        .bearer_auth(&api.secret)
        .json(&document)
        .send()
        .await;

    if let Ok(response) = response {
        let json_resp = response.json::<Value>().await.unwrap();
        println!("posted");
    }
}

async fn get_how_much_money_remain(api: &NotionApi, tag_name: String) -> Result<f64, ()> {
    let endpoint = format!("databases/{}/query", DATABASE_ID);
    let kwargs = json!({
        "filter": {
            "property": "Tags",
            "multi_select": {
                "contains": tag_name
            }
        },
        "sorts": [{
            "property": "Date",
            "timestamp": "created_time",
            "direction": "descending"
        }]
    });
    let assembled_url = format!("{}{}", NOTION_HOST, endpoint);

    let response = api
        .client
        .post(assembled_url)
        .header("Notion-Version", "2022-06-28")
        .bearer_auth(&api.secret)
        .json(&kwargs)
        .send()
        .await;

    if let Ok(response) = response {
        let json_resp = response.json::<Value>().await.unwrap();
        let remain = json_resp.get("results").and_then(|value| {
            value.get(0).and_then(|value| {
                value.get("properties").and_then(|value| {
                    value.get("Remain").and_then(|value| {
                        value.get("formula").and_then(|value| value.get("number"))
                    })
                })
            })
        });

        if let Some(remain) = remain {
            return Ok(remain.as_f64().unwrap());
        }
    }
    Err(())
}
src/notion/mod.rs

I also have slightly modified api for my Check and CheckEntry structs in 'add_check.rs'.

...

#[derive(Debug)]
pub struct Check {
    pub entries: Vec<CheckEntry>,
    pub spent: f64,
}

...
src/add_check.rs

Check would have named fields from now, because I've added spent and want to differentiate them easily.

...
pub struct CheckEntry(String, f64);
...

...
let price = matched.as_str().parse::<f64>().unwrap();
...
src/add_check.rs

CheckEntry now has its price in f64 to make match with json_serde type.

...

impl FromStr for Check {
    type Err = ();

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let mut check = Check {
            entries: s
                .split("/n")
                .map(|line| CheckEntry::from_str(line).expect("Line is corrupted"))
                .collect(),
            spent: 0.0,
        };
        check.spent = check.entries.iter().map(|entry| entry.1).sum::<f64>();
        Ok(check)
    }
}

...
src/add_check.rs

FromStr implementation was refactored by me slightly.

...

impl ToString for CheckEntry {
    fn to_string(&self) -> String {
        format!("{} — ціна: {} (в євро)", self.0, self.1)
    }
}

...
src/add_check.rs

'ToString' implementation was added to 'CheckEntry' struct because we'd need it when making bullet points for document to store.

...
 if let Ok(check) = unchecked_check {
                make_new_notion_entry_for_check(client, &category, &check).await;
                // process and send check to storage
                bot.send_message(msg.chat.id, "Чек успешно заведено.Історію буде видалено.")
                    .await?;
                dialogue.exit().await?;
            }
...
src/add_check.rs

'receive_check' has been updated to make a call for Notion API.

How does it look in real life?

Card has been posted to Notion Database.

Have tested with fractional values as well (3.21) — works.

But there are two bugs I've found:

  • remain is increasing, but should be decreasing because we are spending money, not adding
  • when there are several lines, there is only one bullet.

It should be an easy and fast fix.

Fixing bugs with a different string iterator

The first bug ('the remain amount is increasing') I fixed just putting minus in front of amount spent because this is how my formula in the database works.

...
"Spent": {"number": -spent, "type": "number"},
...
src/notion/spending_document.rs

The second one ('check entries are being rendered incorrectly') I've fixed, using different string iterator - lines() instead of split("\n"). Seems like instead of splitting by newlines, it wanted to split by literal "\n".

...

impl FromStr for Check {
    type Err = ();

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let mut check = Check {
            entries: s
                .lines()
                .map(|line| {
                    CheckEntry::from_str(line).expect("Line is corrupted")
                })
                .collect::<Vec<CheckEntry>>(),
            spent: 0.0,
        };
        check.spent = check.entries.iter().map(|entry| entry.1).sum::<f64>();
        Ok(check)
    }
}

...
src/add_check.rs

lines() seemed to me like a more explicit and convenient approach to solve that.

Now, everything works as expected.

Removing messages to not pollute channel history.  Experimenting with asynchronous tokio mutexes and writing ugly locks

My bot is sitting in the our 'family' Telegram channel. I certainly can change the behaviour of bot from channel bot to just a regular bot, which works with human 1 on 1, but for now I thought it's better to keep things as they used to be.

After a check with its entries is stored in Notion, we can remove all messages related to our human-bot communication and keep channel clean.

To do that, I'd introduce a shared state to my bot.

The logic would be that:

  • we're collecting messages ids which are remaining after our human-bot communication
  • when dialogue marked as finished - we clean out all those messages with corresponding ids

Get ready to enter ugly code space because I have not worked with tokio mutexes before.

I'm making my array as global dependency.

...

let message_ids: Arc<Mutex<[Option<MessageId>; 10]>> = Arc::new(Mutex::new([None; 10]));

...
src/main.rs

adding them to the dependency list

...
.dependencies(dptree::deps![
            InMemStorage::<State>::new(),
            notion_api,
            message_ids
        ])
...
src/main.rs

Adding this to all my handler functions signatures.

...

pub async fn start(
    bot: Bot,
    dialogue: MyDialogue,
    msg: Message,
    message_ids: Arc<Mutex<[Option<MessageId>; 10]>>,
) -> HandlerResult {
...

pub async fn receive_check(
    bot: Bot,
    dialogue: MyDialogue,
    category: Category,
    client: NotionApi,
    msg: Message,
    message_ids: Arc<Mutex<[Option<MessageId>; 10]>>,
) -> HandlerResult {
...

pub async fn receive_category(
    bot: Bot,
    dialogue: MyDialogue,
    msg: Message,
    message_ids: Arc<Mutex<[Option<MessageId>; 10]>>,
) -> HandlerResult {
...

async fn help(
    bot: Bot,
    msg: Message,
    message_ids: Arc<Mutex<[Option<MessageId>; 10]>>,
) -> HandlerResult {
...


async fn cancel(
    bot: Bot,
    dialogue: MyDialogue,
    msg: Message,
    message_ids: Arc<Mutex<[Option<MessageId>; 10]>>,
) -> HandlerResult {
...

async fn invalid_state(
    bot: Bot,
    msg: Message,
    message_ids: Arc<Mutex<[Option<MessageId>; 10]>>,
) -> HandlerResult {
...

Manually add messages ids to specific indexes...

...

pub async fn start(
    bot: Bot,
    dialogue: MyDialogue,
    msg: Message,
    message_ids: Arc<Mutex<[Option<MessageId>; 10]>>,
) -> HandlerResult {
    message_ids.lock().await[0].insert(msg.id);
    let categories_buttons = [
        Category::FoodAndDrinks.to_string(),
        Category::OperationalSpends.to_string(),
        Category::Rent.to_string(),
        Category::UkrReponsibilities.to_string(),
    ]
    .map(|category| KeyboardButton::new(category));

    let sent_id = bot
        .send_message(msg.chat.id, "Спочатку виберіть категорію:")
        .reply_markup(KeyboardMarkup::new([categories_buttons]).one_time_keyboard(true))
        .await?
        .id;

    message_ids.lock().await[1].insert(sent_id);
    dialogue.update(State::ReceiveCategory).await?;

    Ok(())
}

...

pub async fn receive_category(
    bot: Bot,
    dialogue: MyDialogue,
    msg: Message,
    message_ids: Arc<Mutex<[Option<MessageId>; 10]>>,
) -> HandlerResult {
    if let Some(category) = msg.text() {
        message_ids.lock().await[2].insert(msg.id);
        let sent_id = bot
            .send_message(dialogue.chat_id(), "А зараз мені потрібен твій чек...")
            .await?
            .id;
        message_ids.lock().await[3].insert(sent_id);
        let category_variant = Category::from_str(category);
        if let Ok(variant) = category_variant {
            dialogue
                .update(State::ReceiveCheckWithItems { category: variant })
                .await?;
        }
    }

    Ok(())
}

...
src/add_check.rs

And in the last state handler 'receive_check' I'm adding sleep(Duration::from_millis(1000)).await; so user would have some time to read success message before it would be deleted and asynchronous delete function. It looks like that:

...

pub async fn receive_check(
    bot: Bot,
    dialogue: MyDialogue,
    category: Category,
    client: NotionApi,
    msg: Message,
    message_ids: Arc<Mutex<[Option<MessageId>; 10]>>,
) -> HandlerResult {
    match msg.text().map(ToOwned::to_owned) {
        Some(raw_check) => {
            message_ids.lock().await[4].insert(msg.id);
            let unchecked_check = Check::from_str(&raw_check);
            if let Ok(check) = unchecked_check {
                make_new_notion_entry_for_check(client, &category, &check).await;
                let sent_id = bot
                    .send_message(msg.chat.id, "Чек успешно заведено.Історію буде видалено.")
                    .await?
                    .id;
                message_ids.lock().await[5].insert(sent_id);
                sleep(Duration::from_millis(1000)).await;
                clean_out_messages_in_chat(&bot, msg.chat.id, message_ids).await;
                dialogue.exit().await?;
            }
        }
        None => {
            bot.send_message(msg.chat.id, "Не можу без чека. Чекаю на чек.")
                .await?;
        }
    }

    Ok(())
}
...
src/add_check.rs

My asynchronous delete function which I've put to 'main.rs':

...

pub async fn clean_out_messages_in_chat(
    bot: &Bot,
    chat_id: ChatId,
    message_ids: Arc<Mutex<[Option<MessageId>; 10]>>,
) {
    for idx in 0..10 {
        let message_id = message_ids.lock().await[idx];
        message_ids.lock().await[idx] = delete_message(message_id, bot, chat_id.clone()).await;
    }
}

async fn delete_message(
    message_id: Option<MessageId>,
    bot: &Bot,
    chat_id: ChatId,
) -> Option<MessageId> {
    match message_id {
        Some(message_id) => {
            let _ = bot.delete_message(chat_id, message_id).await;
        }
        None => (),
    };
    None::<MessageId>
}
...
src/main.rs

I've added this call to 'invalid_state', 'cancel' and 'help' handler in case user would choose to use those commands in the middle of dialogue or enter an invalid state.

This is hard to make screenshot of, but messages are successfully deleting from telegram chat.

What to expect next?

  • Scheduling and cronjobs
  • Google Cloud Run deployment with alpine image

You can subscribe on my newsletters

I don't do emails now, but you can subscribe for the future.

Check also related posts

Troy Köhler

TwitterYouTubeInstagramLinkedin