First, we need to check what's inside my packages.
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).
To make it work, some additional steps are needed.
cargoadd dotenv
Add to main function this line:
and to .env file some variables
RUST_LOG=info
TELOXIDE_TOKEN=<mytoken>
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:
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.
And my new handlers.
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
I've changed handlers to reflect my button logic change.
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.
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.
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.
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.
And when I 'receive check' I want to make sure I have storage id, because who cares about strings?
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.
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.
I've also added regex crate to my project using cargo add regex.
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.
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.
Retrieving how much money left for specific category
Some updates to 'Cargo.toml' first:
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.
We'd use this struct to make client once and then just pass it as dependency to our bot handlers.
Then, we slightly modify our receive_check function so it would be able to use our client.
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
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.
This function I have not put in the post yet (but already implemented ahead of time). Let's see what's inside there.
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.
'spending_document.rs' will be the place where I define my document I am going to post to the database.
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.
In the main module file in newly created 'notion' folder, we currently having that:
I also have slightly modified api for my Check and CheckEntry structs in 'add_check.rs'.
Check would have named fields from now, because I've added spent and want to differentiate them easily.
CheckEntry now has its price in f64 to make match with json_serde type.
FromStr implementation was refactored by me slightly.
'ToString' implementation was added to 'CheckEntry' struct because we'd need it when making bullet points for document to store.
'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.
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".
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.
adding them to the dependency list
Adding this to all my handler functions signatures.
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:
My asynchronous delete function which I've put to '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.