If you are running Windows or Linux, you can set up the project and install dependencies using setup.py
:
$ cd ${rootDir}
$ python setup.py
Please enter your python keyword:
# Enter your python keyword
# For example use python3 in your system if that points to python 3
If setup fails, please refer to 1.1.2) Building manually and follow the steps for where the setup failed.
If you are running any other OS such as MacOS, you will have to build the project manually.
-
Go to your project root directory:
cd ../project_name/
-
Create the following directories in your root directory:
- ctf/challenges/
- exports/
- logs/
-
Create python virtual environment,
venv
:$ python -m venv venv
-
Install project dependencies into venv:
# Unix $ venv/bin/python -m pip install -r requirements.txt # Windows $ .\venv\Scripts\python.exe -m pip install -r requirements.txt # -- # You can also activate the virtual environment beforehand: $ source venv/bin/activate # Unix $ .\venv\Scripts\activate.bat # Windows # And then just use pip # You can use which pip or Get-Command pip to verify $ pip install -r requirements.txt
-
Create
config.yaml
:# ---------------------------------- RUNTIME --------------------------------- # RUNTIME: LIVE_MODE: false FRESH_START: true # -------------------------------- BOT CONFIG -------------------------------- # BOT_TOKENS: LIVE: BOT_TOKEN TEST: BOT_TOKEN BOT: REMOVE_INLINE_KEYBOARD_MARKUP: True # ------------------------------ USER PASSCODES ------------------------------ # MAKE_ANONYMOUS: false USER_PASSCODES: {} # START_OF_PASSCODES_MARKER # END_OF_PASSCODES_MARKER # ------------------------------- ADMIN CHATIDS ------------------------------ # ADMIN_CHATIDS: [] # -------------------------------- LOG CONFIG -------------------------------- # LOG_USER_TO_APP_LOGS: false
For this program to run correctly, the config.yaml
file has to be first configured.
config.yaml
is similar to an env
file if you are familiar with that.
I decided to bundle my the project's secrets, environment variables as well as config values into one file.
I have done my best to document each value and its signifance below.
# ../${rootDir}/config.yaml
# ---------------------------------- RUNTIME --------------------------------- #
RUNTIME:
LIVE_MODE: false
FRESH_START: true
# -------------------------------- BOT CONFIG -------------------------------- #
BOT_TOKENS:
LIVE: BOT_TOKEN
TEST: BOT_TOKEN
BOT:
REMOVE_INLINE_KEYBOARD_MARKUP: True
# ------------------------------ USER PASSCODES ------------------------------ #
MAKE_ANONYMOUS: false
USER_PASSCODES:
# START_OF_PASSCODES_MARKER
A1234: John Smith
# END_OF_PASSCODES_MARKER
# ------------------------------- ADMIN CHATIDS ------------------------------ #
ADMIN_CHATIDS: []
# -------------------------------- LOG CONFIG -------------------------------- #
LOG_USER_TO_APP_LOGS: false
-
RUNTIME
:If RUNTIME:LIVE_MODE is set to
true
then the bot will use BOT_TOKENS:LIVE else it will use BOT_TOKENS:TEST.If RUNTIME:FRESH_START is set to
true
then the bot will clear previous log and user files every time it is restarted.
For safety purposes, RUNTIME:FRESH_START will be ignored if RUNTIME:LIVE_MODE istrue
. -
BOT_TOKENS
:BOT_TOKENS:LIVE is the token to connect to the Telegram Bot used for release day.
BOT_TOKENS:TEST is the token to connect to the Telegram Bot used during development.We have two bot tokens to allow for testing of the bot on a test Telegram bot and only use the actual Telegram bot on the desired day. This prevents misuse or unexpected outcomes of previous users using the bot at a time of testing.
You may choose to ignore this feature by leavingLIVE_MODE
to eithertrue
orfalse
permanently (Note:FRESH_START
does not work whenLIVE_MODE
istrue
as a safety procedure). -
BOT
:If REMOVE_INLINE_KEYBOARD_MARKUP is set to
true
then the bot will remove InlineKeyboardMarkup for its previous message everytime an InlineKeyboardButton is pressed (handled through the query.answer callback that is called after the event is triggered). This is to prevent users from using old menu buttons however can cause visual confusion to user due to multiple updates to dislay messages (first Remove keyboard-markup then update message content to new text). -
MAKE_ANONYMOUS
:Note: This field is only used with
Stage:Authenticate
. If you are not using the stage, you can ignore this field.If set to
true
then the passcodes is used only to allow users access to the bot and not for indentifcation. They will not be prompted of the type or name associated with the passcode.If set to
false
then passcodes are also used to identify users. Users will be prompted to confirm that their identity matches the one registered for the passcode. -
USER_PASSCODES
:Note: This field is only used with
Stage:Authenticate
. If you are not using the stage, you can ignore this field.Each PASSCODE is an entry:
PASSCODE: - USER NAME - USER GROUP
USER GROUP can be omitted and will default to "none":
PASSCODE: USER NAME
Here is an example with more passcodes:
# ------------------------------ USER PASSCODES ------------------------------ # USER_PASSCODES: # START_OF_PASSCODES_MARKER #------ # Generated at 16/05/2022 13:09:13 # Refer to scripts/generate_passcodes.py for more details. T3026: Sonya Anhak # Here we can omit User Group entirely # It will be defaulted to none T3026: - Derek Eng - none # We can also explicitly define User Group to be none X4853: - Rock Lee - member E9468: - Samantha Tan - guest E4739: - John Smith - guest #------ # END_OF_PASSCODES_MARKER
-
ADMIN_CHATIDS
:Note: This field is only used with
Stage:Admin
. If you are not using the stage, you can ignore this field.Every user with their chatid here will have access to the Admin Console when using the bot assuming that the Admin stage is in use (look through admin.py for more details).
To obtain your own chatid, you can run the bot in development mode, and use the bot alone. This ensures the only one user created is yours. Alternatively, you can also follow this guide.
Each CHATID is an entry:
ADMIN_CHATIDS: - 102391029
Here is an example with more chatids:
ADMIN_CHATIDS: - 1203910239 - 12032190391 - 40329103192
If you do not wish to have any admin chatids:
ADMIN_CHATIDS: []
-
LOG_USER_TO_APP_LOGS
:If LOG_USER_TO_APP_LOGS is set to
true
then user logs will be appended as part of the bot logs too.Bot log file can be found at:
../${rootDir}/logs/${log_file}.log
User specific log files can be found at:../${rootDir}/users/${userId}/${userId}.log
For more information about logging go to 3) Logging.
The default directory for CTF challenges is at ${rootDir}/ctf/challenges
.
Each challenge is a subdirectory with the following name format:
{NUMBER}-CHALLENGE_NAME
The number preceeding the challenge name determines the order of display of challenges to the user on Telegram.
Each challenge directory is expected to contain a challenge.yaml
file of the following format:
# ../${rootDir}/ctf/challenges/1-challenge/challenge.yaml
description: "Can you find the flag in this file?"
additional_info: null
answer: "flag@answer"
points: 40
difficulty: 1
multiple_choices: null
one_try: false
time_based: null
hints: []
files: []
-
description
: Required [string]Challenge text displayed when viewing challenge.
description: "Can you find the flag in this file?"
-
additional_info
: Optional [string, null]Additional info displayed when viewing the challenge.
It will be displayed under theNotes:
section of the challenge view.If you wish to have additional information displayed:
additional_info: "Please do not execute the files from this challenge with admin rights."
Else:
additional_info: null
-
answer
: Required [string]The accepted answer of the challenge. Casing will be ignored when validating users answers.
Please ensure the answer contains only the following characters:alphanumeric _ @
.# If answer is preceeded by "flag@..." then a warning will be given # to user when their answer does not begin with flag@. answer: "flag@answer"
-
points
: Required [integer]The total score for the challenge before deductions. This should reflect the difficulty of the challenge.
points: 40
-
difficulty
: Required [integer]The difficulty rating for the challenge. This will be represented by a star icon '✯' per difficulty.
To display difficulty rating of
X
:difficulty: X
If you wish to hide the difficulty rating of the challenge:
difficulty: 0
-
multiple_choices
: Optional [list, null]Whether your challenge is a mutliple choice challenge.
If it, you have to provide the possible OPTIONS:
# Points is calculated by: # challenge_points_before_hints_deduction / number_of_attempts # Ensure that the correct option matches the CHALLENGE:answer (answer checking ignores casing). # You can have as many options as you want. # Options will be displayed as rows of 2 when possible. multiple_choices: - Option One - Option Two - Option Three - Option Four
Else:
multiple_choices: null
-
one_try
: Required [bool]Whether to only allow one attempt for the challenge.
If you wish to only allow the user to attempt the challenge once:
one_try: true
Else:
one_try: false
-
time_based
: Optional [integer, null]Whether to calculate the score based on time taken to complete challenge.
If you wish to set the time limit to 300
seconds
:# Points is calculated by: # (max(time_taken, time_based) / time_based) * challenge_points_after_hints_deduction # If time taken to complete challenge exceeds time_based, then a score of 0 is awarded. time_based: 300
If you don't wish to enable this:
time_based: null
-
hints
: Required [list]The list of hints provided for your challenge.
hints: - text: "Hint here" deduction: 5
You can have as many hints as needed:
hints: - text: "Hint One" deduction: 5 - text: "Hint Two" deduction: 5 - text: "Hint Three" deduction: 5
Or none at all:
hints: []
-
files
: Required [list]The list of file links to download the files needed for your challenge.
files: - "https://url-to-file.com"
You can have as many file links as needed:
files: - "https://first-file.com" - "https://second-file.com" - "https://third-file.com"
Or none at all:
files: []
-
Ensure
config.yaml
is configured correctly.Please refer to this if you have not done so.
-
Run the project.
The entry point of the chatbot is
main.py
.With
venv
activated for your terminal session:-
Activating venv:
# cd ${rootDir} # Linux: $ source venv/bin/activate #Windows: $ venv\Scripts\activate.bat
-
Running main.py:
# cd ${rootDir} # Linux: $(venv) python main.py # Windows: $(venv) python .\main.py
Or if you don't wish or can't get venv activated:
# cd ${rootDir} # Linux: $ venv/bin/python main.py # Windows: $ .\venv\Scripts\python.exe .\main.py
-
Warning: Please refer and read through what each script does before using it.
Some scripts can cause irrevisble changes to your project.
Helper scripts are scripts that are designed to be used either before, during or after a session of running the Telegram Bot.
They help to make your life easier by executing common tasks or tasks that are repetitive and can be automated or even help you out in testing and debugging of the bot.
Scripts that are ran before a session include:
Scripts that are ran during a session include:
Scripts that are ran after a session include:
Below is a brief description of what each script does and how to use them.
-
This script will get all the existing users found in the users directory and add their chatids to the banned_users.yaml file. It will NOT DELETE their files. If you wish to do that, you will have to do it manually or use the
reset_project
helper script.It is useful for preventing users from an earlier session from joining in on a later session.
Usage:
$ python scripts/ban_all_users.py
-
This script will generate fake users that will "attempt" and "complete" some of the existing CTF challenges.
It is useful for populating the leaderboard during testing and also for creating test export log files (more on that below).
Currently the fake users are only configured to attempt CTF Challenges and not any other stages such as Guardian.
Warning: This script WILL DELETE ALL current user files found in the ${rootDir}/users/.
Arguments:
$ python scripts/create_fake_users.py -h usage: create_fake_users.py [-h] [-n N] [-c C] optional arguments: -h, --help show this help message and exit -n N Number of users to generate. Max is 26. If set to 0 then max will be taken. -c C Number of challenges to attempt per user.
Usage:
# Create 20 fake users each attempting MIN 4 challenges $ python scripts/create_fake_users.py -n 20 -c 4
# Create MAX (24) fake users $ python scripts/create_fake_users.py -n 0
-n
argument is for the number of fake users to create. It is capped at 26.-c
argument is for the number of challenges to attempt per user. It will be capped at the number of current existing CTF challenges. -
create_placeholder_challenges
:This script will create placeholder challenges either based on the in-built challenge.yaml template or using a provided file.
It is useful for testing purposes and removing the need to update challenges manually everytime when testing a new feature.
Warning: This script WILL DELETE ALL current challenges found in the ${rootDir}/ctf/challenges.
Arguments:
$ python scripts/create_placeholder_challenges.py -h usage: create_placeholder_challenges.py [-h] [-i I] [-n N] optional arguments: -h, --help show this help message and exit -i I challenge.yaml input file as template -n N Number of challenges to generate. If omitted, defaults to 4.
Usage:
# Create 10 placeholder challenges $ python scripts/create_placeholder_challenges.py -n 10
# Create 10 placeholder challenges from custom template file $ python scripts/create_placeholder_challenges.py -n 10 -i path/to/challenge.yaml
-i
argument is for providing a custom challenge.yaml to use as template when creating the placeholder challenges.-n
argument is for the number of placeholder challenges to create. -
This script will generate a CSV file that can be imported into Excel for visualization of users data.
It will only extract from the user logs, actions that are relevant to their scoring such as:
- Viewing of hints
- Attempting the challenge
- Completing the challenge
To include custom data fields such as username etc, modify the
RELEVANT_DATA_FIELDS
found in the script:RELEVANT_DATA_FIELDS = { # "data-field-label" : [constructor, default_value] "name": [str, "anonymous"], "username": [str, ""], "group": [str, "no-group"] }
It will try and get the data_field from
userdata
(user.yaml) and if not found will use the default value provided.Arguments:
$ python scripts/export_logs.py -h usage: export_logs.py [-h] [-o O] [-u U] [-g G] optional arguments: -h, --help show this help message and exit -o O File name to output exported logs to. Defaults to exported_logs. -u U Specify a chatid to export logs from. -g G Specify a group to export logs from.
Usage:
$ python scripts/export_logs.py -o "example_export"
-o
argument is for the exported csv filename. The ".csv" file extension should not be included in the argument-u
argument is for if you want to export logs from only one user (provide the chatid here).-g
argument is for if you want to export logs from only one user group (provide group name here). -
This script will generate a list of passcodes from a list of user (names) and append the passcodes into config.yaml.
This script is useful for getting unique passcodes for a list of users in bulk as doing it by-hand is time consuming and prone to mistakes (might have duplicate passcodes).
Note: These passcodes are used by the stage Authenticate to grant users access to the bot hence it is assumed that the stage is in use for the bot session.
Arguments:
$ python scripts/generate_passcodes.py -h usage: generate_passcodes.py [-h] -i I optional arguments: -h, --help show this help message and exit -i I Input file with users list.
Usage:
$ python scripts/generate_passcodes.py -i path/to/userlist.txt
-i
argument is the input file with the names and groups of users to be added. -
This script will read user files and generate a leaderboard rankings from their scores. It will output the rankings to two files:
-
../leaderboard.json
This is used by Chatbot-Leaderboard Website hence if you are running Chatbot-Leaderboard ensure that you provide a path to the webpage root directory (see Arguments and Usage below).
If you do not have the website enabled, you can pass the disable-webpage flag to not create this file (see Arguments and Usage).
-
${rootDir}/exports/exported_leaderboard.csv
This is a
CSV
file that you can use to view in realtime the scoring and rankings of the current users. The list is ordered and be configured to include any other relevant userdata collected or generated (such as email etc).
While the stage CTF does have an inbuilt leaderboard functionality, this allows you have a second independent leaderboard running on a separate thread.
Arguments:
$ python scripts/leaderboard.py -h usage: leaderboard.py [-h] [-n N] [-o O] [--disable_webpage_leaderboard_file DISABLE_WEBPAGE_LEADERBOARD_FILE] options: -h, --help show this help message and exit -n N Limit leaderboard up to a certain placing. Defaults to no limit (all users will be ranked). -o O Path to output the leaderboard JSON file to. Defaults to root directory: ${rootDir} / leaderboard.json --disable_webpage_leaderboard_file DISABLE_WEBPAGE_LEADERBOARD_FILE If set to any value, the leaderboard.json file will not be generated.
Usage:
$ python scripts/leaderboard.py -o ../../Chatbot-Leaderboard/
# Limit number of displayed top users to X $ $ python scripts/leaderboard.py -o ../../Chatbot-Leaderboard/ -n X
# Without webpage enabled: $ python scripts/leaderboard.py --disable_webpage_leaderboard_file true
-n
argument is the number of top users to display. If not set, it will display all users.-o
argument is where to create the outputleaderboard.json
to.--disable_webpage_leaderboard_file
argument is whether to enable the webpage. If set, thenleaderboard.json
will not be created. -
-
notify_winners
. This script will notify the top 3 users (considers users and not placings meaning that users who are tied may not be considered, first to attain score basis) via a message sent through the bot. The message will not be successfully delivered if the user has stopped and blocked the bot after use.This script is useful if you do not have any means of contact to the users such as their phone numbers or email addresses as it allows you to contact them via the bot using only their chatid.
Usage:
$ python scripts/notify_winners.py
-
This script will delete any existing log and user files.
This script is useful for resetting the state of the bot between runs especailly during testing phases.
Warning: This script WILL DELETE ALL user files found in the users directory and log files found in the logs directory.
Usage:
$ python scripts/reset_project.py
Throughout this project, you will see a lot of references to stages and states.
Simply said, a state
is the smallest unit of "building block" while a stage
is a group of states (or nested stages even) with its inner functionality abstracted away for either reduced code duplication or means of organization.
With the use of stages you can reuse common functionality between stages, see in-built stages.
You can also create custom stages to have unique functionality, see custom stages.
Below is a more in-depth description however you might find it still insufficient. It is best to dive in and try it yourself.
-
A
state
is a condition of outcome that theUser
is in.A state can identified as a unique
integer
hence the user can only be ONE state at any given time.
The actual integral value of the state has no meaning other than to signify the sequence of instantiation (order of which we defined the states).When creating a state, we define a list of
callback handlers
to handle the input provided by the user while they are in the state (USERSTATE) and decide on an outcome. There are two types of callback handlers:-
CallbackQueryHandler
- called when user presses a givenInlineKeyboardButton
This handler is specific to the button meaning that thepattern
of the CallbackQueryHandler must match thecallback_data
of the InlineKeyboardButton.Each
CallbackQueryHandler
has aCallbackQuery
which needs to be answered by the handler callback:def callback(update: Update, context: CallbackContext) -> USERSTATE: query = update.callback_query if query: query.answer()
It is good practice to always check if there is a CallbackQuery that can be answered and answer it.
Only time you can omit this check is if you are sure that the callback handler cannot be called by a CallbackQueryHandler update.
-
MessageHandler
- called when user sends a message
This handler is non-specific and will be called for every message the user sends. Proper steps must be taken to to filter repeated user input (debounce check etc..).
-
-
A
stage
is essentially a collection of states with added functionality to allow the creation of complex logic. Stages can also be nested within stages allowing for the use of inbuilt stages in your custom stages.Each stage has:
-
An
__init__
dunder method.You can initialize any additional attributes your stage needs here.
Remember to still call the abstract classStage
:__init__
as it handles initializing core attributes:-
bot : Bot
-
user_manager : UserManager
-
stage_id : str
-
next_stage_id : str
-
states = {}
def __init__(self, stage_id: str, next_stage_id: str, bot): self.some_attribute = [] self.other_attribute = {} super().__init__(stage_id, next_stage_id, bot)
-
-
A
setup
method.def setup(self) -> None: self.init_users_data() self.states = { "SOME_STATE": [ CallbackQueryHandler(callback0, pattern=f"^some_state_0S"), CallbackQueryHandler(callback1, pattern=f"^some_state_1$") ], "SOME_OTHER_STATE": [ MessageHandler(Filters.all, callback2) ] } self.bot.register_stage(self) # USERSTATES self.SOME_STATE, self.SOME_OTHER_STATE = self.unpacked_states self.INPUT_FOO_STAGE = self.bot.get_user_input( stage_id="input_foo", input_text="Please enter something:", input_handler=callback3, exitable=True )
self.states
is a dictionary of states that the stage can be in.
Each state has callback handlers to handle any action done in that state.self.states = { "STATE_NAME" : [ CallbackQueryHandler(callback_function, pattern=state_pattern), MessageHandler(Filters.all, callback_function) ], ... }
After the stage has been registered,
self.bot.register_stage(self)
You can retrieve the registered
USERSTATES
by using:self.SOME_STATE, self.SOME_OTHER_STATE = self.unpack_states
Note that order of states unpacked is the same as the order when initializing
self.states
.The type of each of the state is
USERSTATE
:self.SOME_STATE : USERSTATE self.SOME_OTHER_STATE : USERSTATE
You can then set the state of the bot by returning the respective
USERSTATE
in your callback handlers:def callback(self, update: Update, context: CallbackContext): query = update.callback_query if query: query.answer() # do something here # maybe: # # self.bot.edit_or_reply_message( # update, context, # text="You are in some state now..", # reply_markup=...) return self.SOME_STATE
-
An
init_users_data
method.You are required to call the
super-class init_users_data
method as well.
This is so as the super-class will set a hidden property_users_data_initialized
to indicate that the stage has its user_data initialized.def init_users_data(self) -> None: self.user_manager.add_data_field("some-data", "") return super().init_users_data()
-
A
stage_entry
method.This is the function called when loading the stage from another stage in
bot.proceed_next_stage
.
You may include any logic to decide what happens on entry here.Make sure to check and answer the
callback_query
if applicable.def stage_entry(self, update: Update, context: CallbackContext) -> USERSTATE: query = update.callback_query if query: query.answer() return self.load_menu(update, context)
-
A
stage_exit
function.This is where you can have wrap-up logic for your stage before proceeding to the next stage.
If you stage_exit hasdefault behavior
, then you may just call the super-class stage_exit method instead.def stage_exit(self, update: Update, context: CallbackContext) -> USERSTATE: query = update.callback_query if query: query.answer() return self.bot.proceed_next_stage( current_stage_id=self.stage_id, next_stage_id=self.next_stage_id, update=update, context=context )
Equivalent to:
def stage_exit(self, update: Update, context: CallbackContext) -> USERSTATE: return super().stage_exit(update, context)
Exiting the stage from within itself,
def some_state_handler(self, update: Update, context: CallbackContext) -> USERSTATE: query = update.callback_query if query: query.answer() return self.stage_exit(update, context)
See more practical examples here:
- ExampleStage: Contains a nested
GetUserInput
- FavoriteFruits: Contains a nested
LetUserChoose
stage - EchoTen: Contains a nested
GetUserInput
stage
-
Presents a variable number of choices to the user. The choices are in the form of buttons (ReplyMarkupButton).
The code below shows a brief implementation example for the stage.
You may also view more examples here:
- let_user_choose: Complete Basic Implementation
- let_user_choose: Dynamic Implementation
- let_user_choose: Persistent Data Implementation
- Custom Stage + let_user_choose: With Persistent Data
bot: Bot = Bot()
bot.init(token=BOT_TOKEN,
logger=logger,
config=CONFIG)
STAGE_FAV_FRUIT = "choose_favorite_fruit"
def handle_user_choice(choice_selected: str, update: Update, context: CallbackContext) -> USERSTATE:
print("Choice selected was:", choice_selected)
return bot.proceed_next_stage(
current_stage_id=STAGE_FAV_FRUIT,
next_stage_id=NEXT_STAGE_ID,
update=update, context=context
)
choose_fav_fruit_stage : Stage = bot.let_user_choose(
stage_id=STAGE_FAV_FRUIT,
choice_text="Which is your favorite fruit?",
choices=[
{
"text": "🍎",
"callback": lambda update, context: handle_user_choice("apple", update, context)
},
{
"text": "🍐",
"callback": lambda update, context: handle_user_choice("pear", update, context)
},
{
"text": "🍊",
"callback": lambda update, context: handle_user_choice("orange", update, context)
},
{
"text": "🍇",
"callback": lambda update, context: handle_user_choice("grape", update, context)
},
],
choices_per_row=2
)
assert choose_fav_fruit_stage.stage_id == STAGE_FAV_FRUIT
# --
# Proceeding to the stage:
def some_state_in_a_stage(self: Stage, update: Update, context: CallbackContext) -> USERSTATE:
query = update.callback_query
if query:
query.answer()
return bot.proceed_next_stage(
current_stage_id=self.stage_id,
next_stage_id=STAGE_FAV_FRUIT,
update=update, context=context
)
Presents an input field to the user. Input is captured through the next valid message sent from input prompt.
Invalid input types such stickers, GIFs and file uploads are ignored. Incorrect input forms such as editing previously sent messages are also disregarded.
You may also view more examples here:
bot: Bot = Bot()
bot.init(token=BOT_TOKEN,
logger=logger,
config=CONFIG)
STAGE_EXAMPLE_ANY_INPUT = "input_example_any"
def callback(input_given: str, update: Update, context: CallbackContext) -> USERSTATE:
print("Input given was:", input_given)
return Bot.proceed_next_stage(
current_stage_id=STAGE_EXAMPLE_ANY_INPUT,
next_stage_id=NEXT_STAGE_ID,
update=update, context=context
)
input_example_any_stage: Stage = bot.get_user_input(
stage_id=STAGE_EXAMPLE_ANY_INPUT,
input_text="Please input your \_\_\_\_:",
input_handler=callback,
exitable=True
)
assert input_example_any_stage.stage_id == STAGE_EXAMPLE_ANY_INPUT
# --
# Proceeding to the stage:
def some_state_in_a_stage(self: Stage, update: Update, context: CallbackContext) -> USERSTATE:
query = update.callback_query
if query:
query.answer()
return bot.proceed_next_stage(
current_stage_id=self.stage_id,
next_stage_id=STAGE_EXAMPLE_ANY_INPUT,
update=update, context=context
)
Similar to GetUserInput
except that the input is a user information (string) and is stored globally in the userdata. No additional logic implementation is required.
You may also view more examples here:
bot: Bot = Bot()
bot.init(token=BOT_TOKEN,
logger=logger,
config=CONFIG)
STAGE_COLLECT_PHONE_NUMBER = "info_collect_phone_number"
def format_number_input(input_str: Union[str, bool]):
if input_str is True:
return "91234567"
else:
if input_str.find("+65") >= 0:
input_str = input_str[3:]
input_str = utils.format_input_str(input_str, False, "0123456789")
return input_str if (
len(input_str) == 8 and "689".find(input_str[0]) >= 0
) else False
info_collect_phone_number_stage = bot.get_user_info(
stage_id=STAGE_COLLECT_PHONE_NUMBER,
next_stage_id=NEXT_STAGE_ID,
data_label="phone number",
input_formatter=format_number_input,
additional_text="We will not use this to contact you.",
use_last_saved=True,
allow_update=True
)
assert info_collect_phone_number_stage.stage_id == STAGE_COLLECT_PHONE_NUMBER
# --
# Proceeding to the stage: (for get_user_info this is usually done at the start of the chatbot flow path)
def some_state_in_a_stage(self: Stage, update: Update, context: CallbackContext) -> USERSTATE:
query = update.callback_query
if query:
query.answer()
return bot.proceed_next_stage(
current_stage_id=self.stage_id,
next_stage_id=STAGE_COLLECT_PHONE_NUMBER,
update=update, context=context
)
python-telegram-bot
had just released their lastest version V20.X
. The new changes are not compatible with the current state of this project. Manual updating is required if you wish to upgrade to V20.X.
You can refer to the transition guides listed on their wiki here.
python-telegram-bot
. To view its older version, please use githistory.xyz
.
Creating custom stages will require you to have knowledge about working with python-telegram-bot
Do read more and understand the examples before continuing.
The relevant examples are: ConversationBot and ConversationBot2.
In our application, USERSTATE
is a created data-type with an integral value to signify the state of the CallbackHandler.
GENDER, PHOTO, LOCATION, BIO
(found in ConversationBot are examples of USERSTATE in our application.
- Basic menu demonstration
examples/stages/basic_stage.py
- Menu with more features
- Persistent data
- Uses user input
examples/stages/example_stage.py
Note: For the purposes of this code example, we will take it that the custom stage python file is under src/stages/
.
#!/usr/bin/env python3
import sys
sys.path.append("src")
import logging
import os
from bot import Bot
from user import UserManager
import utils.utils as utils
from utils.log import Log
from stages.example_stage import Example
LOG_FILE = os.path.join("logs", f"main.log")
CONFIG = utils.load_yaml_file(os.path.join("config.yaml"))
assert CONFIG, "Failed to load config.yaml. Fatal error, please remedy."\
"\n\nLikely an invalid format."
LIVE_MODE = CONFIG["RUNTIME"]["LIVE_MODE"]
FRESH_START = CONFIG["RUNTIME"]["FRESH_START"] if not LIVE_MODE else False
BOT_TOKEN = CONFIG["BOT_TOKENS"]["LIVE"] if LIVE_MODE else CONFIG["BOT_TOKENS"]["TEST"]
def main():
setup()
logger = Log(
name=__name__,
stream_handle=sys.stdout,
file_handle=LOG_FILE,
log_level=logging.DEBUG
)
user_manager = UserManager()
user_manager.init(
logger=logger,
log_user_logs_to_app_logs=("LOG_USER_TO_APP_LOGS" in CONFIG
and CONFIG["LOG_USER_TO_APP_LOGS"]))
bot = Bot()
bot.init(token=BOT_TOKEN,
logger=logger,
config=CONFIG)
STAGE_EXAMPLE = "example"
STAGE_END = "end"
# ------------------------------ Stage: example ------------------------------ #
example: Example = Example(
stage_id=STAGE_EXAMPLE,
next_stage_id=STAGE_END,
bot=bot
)
example.setup()
bot.set_first_stage(STAGE_EXAMPLE)
# ---------------------------------------------------------------------------- #
# -------------------------------- Stage: end -------------------------------- #
bot.make_end_stage(
stage_id=STAGE_END,
goodbye_message="You have exited the conversation. \n\nUse /start to begin a new one.",
reply_message=True
)
# ---------------------------------------------------------------------------- #
# Start Bot
bot.start(live_mode=LIVE_MODE)
def setup():
utils.get_dir_or_create(os.path.join("logs"))
if FRESH_START:
# Remove runtime files (logs, users, etc)
pass
if __name__ == "__main__":
main()
Any CallbackQueryHandler
will have a CallbackQuery
attribute as part of its Update
.
Ensure that you answer it the query. If you are unsure whether the action before it contains a callbackquery,
def callback_handler(update: Update, context: CallbackContext) -> USERSTATE:
query: CallbackQuery = update.callback_query
if query:
query.answer()
The @CallbackQuery.answer
method has been over-ridden. You can see the implementation details in bot.py as part of the @Bot.init
method.
The new method now accepts two new parameters:
- keep_message:
any string (empty string too)
orTrue
orFalse
- do_nothing:
True
orFalse
If do_nothing
is True
, then all changes below are ignored and nothing happens.
If keep_message
is True
, then the previous message remains unchanged but any reply_markup
is removed (for example any InlineKeybaordButtonMarkup
).
If keep_message
is False
, then the previous message is replaced with a placeholder text "💭 Loading..."
and any reply_markup
is also removed.
If keep_message
is a non-empty string
, then the previous message is replaced with the non-empty string and any reply_markup
is also removed.
If keep_message
is an empty string
, then the previous message is deleted completely.
Example of usage:
-
Have the default "💭 Loading..." transition on update:
def callback_handler(update: Update, context: CallbackContext) -> USERSTATE: query: CallbackQuery = update.callback_query if query: query.answer()
-
Delete any previous message:
def callback_handler(update: Update, context: CallbackContext) -> USERSTATE: query: CallbackQuery = update.callback_query if query: query.answer(keep_message="")
-
Have a custom transition message on update:
def callback_handler(update: Update, context: CallbackContext) -> USERSTATE: query: CallbackQuery = update.callback_query if query: query.answer(keep_message="Custom loading message here")
-
No transition, just remove any
reply_markup
:def callback_handler(update: Update, context: CallbackContext) -> USERSTATE: query: CallbackQuery = update.callback_query if query: query.answer(keep_message=True)
-
No change at all:
def callback_handler(update: Update, context: CallbackContext) -> USERSTATE: query: CallbackQuery = update.callback_query if query: query.answer(do_nothing=True)
If you need a more relevant example, you can refer to let_user_choose4
.