├── .gitignore ├── MANIFEST.in ├── README.md ├── docs ├── activity_notifier.md ├── assets │ ├── create-new-telegram-bot.png │ ├── notification-channel.png │ ├── notification-recipients.png │ ├── telegram-user.png │ └── user-login.gif ├── auth.md ├── basic_setup.md ├── dev_setup.md ├── frappe-notifications.md ├── hooks.md ├── login_notifier.md ├── meta_conversation_driver.md └── production.md ├── frappe_telegram ├── __init__.py ├── bot.py ├── client.py ├── commands │ └── __init__.py ├── config │ ├── __init__.py │ ├── desktop.py │ └── docs.py ├── fixtures │ ├── custom_field.json │ └── role.json ├── frappe_telegram │ ├── __init__.py │ ├── doctype │ │ ├── __init__.py │ │ ├── telegram_bot │ │ │ ├── __init__.py │ │ │ ├── telegram_bot.js │ │ │ ├── telegram_bot.json │ │ │ ├── telegram_bot.py │ │ │ └── test_telegram_bot.py │ │ ├── telegram_bot_item │ │ │ ├── __init__.py │ │ │ ├── telegram_bot_item.json │ │ │ └── telegram_bot_item.py │ │ ├── telegram_chat │ │ │ ├── __init__.py │ │ │ ├── telegram_chat.js │ │ │ ├── telegram_chat.json │ │ │ ├── telegram_chat.py │ │ │ └── test_telegram_chat.py │ │ ├── telegram_message │ │ │ ├── __init__.py │ │ │ ├── telegram_message.js │ │ │ ├── telegram_message.json │ │ │ ├── telegram_message.py │ │ │ └── test_telegram_message.py │ │ ├── telegram_message_template │ │ │ ├── telegram_message_template.js │ │ │ ├── telegram_message_template.json │ │ │ ├── telegram_message_template.py │ │ │ └── test_telegram_message_template.py │ │ ├── telegram_message_template_translation │ │ │ ├── __init__.py │ │ │ ├── telegram_message_template_translation.json │ │ │ └── telegram_message_template_translation.py │ │ ├── telegram_user │ │ │ ├── __init__.py │ │ │ ├── telegram_user.js │ │ │ ├── telegram_user.json │ │ │ ├── telegram_user.py │ │ │ └── test_telegram_user.py │ │ └── telegram_user_item │ │ │ ├── __init__.py │ │ │ ├── telegram_user_item.json │ │ │ └── telegram_user_item.py │ └── page │ │ ├── __init__.py │ │ └── telegram_chat_view │ │ ├── __init__.py │ │ ├── chat_list.html │ │ ├── chat_message.html │ │ ├── chat_view.html │ │ ├── chat_view_date_chip.html │ │ ├── telegram_chat_view.js │ │ └── telegram_chat_view.json ├── handlers │ ├── __init__.py │ ├── auth │ │ ├── __init__.py │ │ └── credentials.py │ ├── frappe.py │ ├── logging.py │ └── start.py ├── hooks.py ├── modules.txt ├── override_doctype_class │ ├── __init__.py │ └── notification.py ├── patches.txt ├── setup │ ├── __init__.py │ └── notification.py ├── templates │ ├── __init__.py │ └── pages │ │ └── __init__.py └── utils │ ├── bench.py │ ├── conversation.py │ ├── formatting.py │ ├── nginx.py │ ├── overrides.py │ ├── supervisor.py │ └── test_fixture.py ├── license.txt ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *.egg-info 4 | *.swp 5 | tags 6 | frappe_telegram/docs/current -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | include requirements.txt 3 | include *.json 4 | include *.md 5 | include *.py 6 | include *.txt 7 | recursive-include frappe_telegram *.css 8 | recursive-include frappe_telegram *.csv 9 | recursive-include frappe_telegram *.html 10 | recursive-include frappe_telegram *.ico 11 | recursive-include frappe_telegram *.js 12 | recursive-include frappe_telegram *.json 13 | recursive-include frappe_telegram *.md 14 | recursive-include frappe_telegram *.png 15 | recursive-include frappe_telegram *.py 16 | recursive-include frappe_telegram *.svg 17 | recursive-include frappe_telegram *.txt 18 | recursive-exclude frappe_telegram *.pyc -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Frappe Telegram 2 | 3 | Telegram Bot Manager for Frappe. This is a wrapper around [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) tuned for frappe. Please go through the documentations of `python-telegram-bot` to easily customize your bot. 4 | 5 | ### Features 6 | - Frappe Context for each incoming Updates 7 | - Multi Bot support 8 | - Runs the bot independently from the web server process 9 | - Custom Bot implementations via Hooks 10 | - Frappe User Login 11 | - Frappe User signup 12 | - Tracks Chats and contained messages 13 | - Supports sending messages from bot via frappe hooks / controller methods 14 | - Integration with frappe `Notification` doctype 15 | 16 | ### DocTypes 17 | - `Telegram Bot` 18 | Add all your telegram-bots here with their `api-tokens` 19 | - `Telegram User` 20 | Any telegram-user that interacts with your bot comes here. Authentication is when `Telegram User` is linked to frappe's `User` 21 | - `Telegram Chat` 22 | All private chats, groups where the bot gets notified comes here 23 | - `Telegram Message` 24 | All messages - incoming & outgoing gets logged here 25 | 26 | ### Page 27 | - `Telegram Chat View` 28 | A simple chat-page within frappe just for your telegram-bots 29 | 30 | ### Guides 31 | - [Basic Setup](./docs/basic_setup.md) 32 | - [User Authentication](./docs/auth.md) 33 | - [Running in DEV mode](./docs/dev_setup.md) 34 | - [Supported Hooks for Customization](./docs/hooks.md) 35 | - [Setting up for Production](./docs/production.md) 36 | - [Using Frappe Notifications DocType](./docs/frappe-notifications.md) 37 | - [Meta Conversation Driver](./docs/meta_conversation_driver.md) 38 | - [Example: Login Notifier](./docs/login_notifier.md) 39 | - [Example: Activity Notifier](./docs/activity_notifier.md) 40 | 41 | #### License 42 | 43 | MIT 44 | -------------------------------------------------------------------------------- /docs/activity_notifier.md: -------------------------------------------------------------------------------- 1 | # Example: Activity Notifier 2 | Get notified when any doc-updates are made 3 | 4 | - Add the following to hooks.py of your custom-app 5 | ```py 6 | doc_events = { 7 | "*": { 8 | "after_insert": "botter.activity_notifier.on_activity", 9 | "on_update": "botter.activity_notifier.on_activity", 10 | "on_trash": "botter.activity_notifier.on_activity", 11 | "on_submit": "botter.activity_notifier.on_activity", 12 | "on_cancel": "botter.activity_notifier.on_activity", 13 | } 14 | } 15 | ``` 16 | 17 | - Implement the handler 18 | ```py 19 | import frappe 20 | from frappe_telegram.client import send_message 21 | 22 | 23 | def on_activity(doc, method): 24 | users = set([ 25 | x.parent for x in frappe.get_all( 26 | "Has Role", {"role": "System Manager"}, ["parent"])]) 27 | 28 | if doc.doctype in ["Telegram Chat", "Telegram Message", "Version", "Comment", "Activity Log"]: 29 | return 30 | 31 | message_dict = { 32 | "on_update": "Updated", 33 | "on_submit": "Submitted", 34 | "on_cancel": "Cancelled", 35 | "on_trash": "Deleted", 36 | "after_insert": "Inserted", 37 | } 38 | 39 | for user in users: 40 | if user == frappe.session.user: 41 | continue 42 | 43 | try: 44 | send_message( 45 | message_text=f"{doc.doctype} {doc.name} {message_dict[method]}", 46 | user=user) 47 | except Exception: 48 | frappe.clear_last_message() 49 | 50 | ``` -------------------------------------------------------------------------------- /docs/assets/create-new-telegram-bot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_telegram/b135ac3daac73eb68d2591346a2200ac47ac4c30/docs/assets/create-new-telegram-bot.png -------------------------------------------------------------------------------- /docs/assets/notification-channel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_telegram/b135ac3daac73eb68d2591346a2200ac47ac4c30/docs/assets/notification-channel.png -------------------------------------------------------------------------------- /docs/assets/notification-recipients.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_telegram/b135ac3daac73eb68d2591346a2200ac47ac4c30/docs/assets/notification-recipients.png -------------------------------------------------------------------------------- /docs/assets/telegram-user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_telegram/b135ac3daac73eb68d2591346a2200ac47ac4c30/docs/assets/telegram-user.png -------------------------------------------------------------------------------- /docs/assets/user-login.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_telegram/b135ac3daac73eb68d2591346a2200ac47ac4c30/docs/assets/user-login.gif -------------------------------------------------------------------------------- /docs/auth.md: -------------------------------------------------------------------------------- 1 | # User Authentication 2 | Link `Telegram User` to frappe's `User` for authentication. We provide a default login & signup handler within app. 3 | 4 | - Login flow: 5 | 6 | ![Login](./assets/user-login.gif) 7 | 8 | 9 | - After authentication: 10 | 11 | ![Telegram User](./assets/telegram-user.png) -------------------------------------------------------------------------------- /docs/basic_setup.md: -------------------------------------------------------------------------------- 1 | # Basic Setup 2 | 3 | - Let's start by installing the app in your site 4 | 5 | ```bash 6 | # Get the app 7 | $ bench get-app https://github.com/leam-tech/frappe_telegram 8 | 9 | # Install the app on your site 10 | $ bench --site install-app frappe_telegram 11 | ``` 12 | 13 | - Get your telegram-bot's `api-token` from [@BotFather](https://t.me/botfather) 14 | - Go into your frappe site and create a new `Telegram Bot` and provide API Token and username 15 | 16 | ![Create TelegramBot](./assets/create-new-telegram-bot.png) 17 | 18 | - We're done! Start your bot by executing the following: 19 | 20 | ```bash 21 | # bot-name is the name of the TelegramBot document that we just made 22 | $ bench telegram start-bot '' 23 | ``` -------------------------------------------------------------------------------- /docs/dev_setup.md: -------------------------------------------------------------------------------- 1 | # Developing your Bot 2 | You can launch your bot using the command: 3 | ```bash 4 | $ bench telegram start-bot '' 5 | ``` -------------------------------------------------------------------------------- /docs/frappe-notifications.md: -------------------------------------------------------------------------------- 1 | # Frappe Notification DocType 2 | A new `Telegram` Channel has been introduced to make use of frappe's notifications doctype. 3 | 4 | ![Channel](./assets/notification-channel.png) 5 | 6 | You can specify the target users via the recipients table. 7 | 8 | ![Channel](./assets/notification-recipients.png) -------------------------------------------------------------------------------- /docs/hooks.md: -------------------------------------------------------------------------------- 1 | # Hooks & Customizations 2 | There are mainly 3 hooks available: 3 | - `telegram_bot_handler` 4 | This gets invoked with params `telegram_bot` & `updater` 5 | 6 | - `telegram_update_pre_processors` 7 | These gets invoked before each and every incoming updates. This should be a list of cmds to methods that accepts `update` & `context`, similar to every other handlers you could attach to the bot. 8 | 9 | - `telegram_update_post_processors` 10 | These gets invoked after all the update handlers are executed. 11 | -------------------------------------------------------------------------------- /docs/login_notifier.md: -------------------------------------------------------------------------------- 1 | # Example: Login Notifier 2 | Notifies all System Managers when anybody logs into frappe. 3 | 4 | - Add `on_login` hook to your custom-app 5 | ```python 6 | on_login = "botter.login_notifier.on_login" 7 | ``` 8 | 9 | - Implement the `on_login` handler 10 | ```python 11 | import frappe 12 | from frappe_telegram.client import send_message 13 | 14 | 15 | def on_login(login_manager): 16 | users = set([ 17 | x.parent for x in frappe.get_all( 18 | "Has Role", {"role": "System Manager"}, ["parent"])]) 19 | 20 | for user in users: 21 | if user == login_manager.user: 22 | continue 23 | 24 | try: 25 | send_message( 26 | message_text=f"{login_manager.user} logged in", 27 | user=user) 28 | except Exception: 29 | frappe.clear_last_message() 30 | 31 | ``` -------------------------------------------------------------------------------- /docs/meta_conversation_driver.md: -------------------------------------------------------------------------------- 1 | # Conversation based on Simple Meta 2 | You can make use of `frappe_telegram.utils.conversation.collect_conversation_details` to collect the details from a conversation easily. 3 | 4 | Conversation handlers for `login` & `signup` makes use of this utility function. Sample below: 5 | ```py 6 | def collect_signup_details(update: Update, context: CallbackContext): 7 | details = collect_conversation_details( 8 | key="signup_details", 9 | meta=[ 10 | dict(label="First Name", key="first_name", type="str"), 11 | dict(label="Last Name", key="last_name", type="str"), 12 | dict(key="email", label="Email", type="regex", options=r"^.+\@.+\..+$"), 13 | dict(key="pwd", label="Password", type="password"), 14 | # dict(key="gender", label="Gender", type="select", options="Male\nFemale"), 15 | ], 16 | update=update, 17 | context=context, 18 | ) 19 | if not details.get("_is_complete"): 20 | raise DispatcherHandlerStop(state=ENTERING_SIGNUP_DETAILS) 21 | 22 | user = frappe.get_doc(dict( 23 | doctype="User", 24 | email=details.email, 25 | first_name=details.first_name, 26 | last_name=details.last_name, 27 | enabled=1, 28 | new_password=details.pwd, 29 | send_welcome_email=0, 30 | )).insert(ignore_permissions=True) 31 | 32 | context.telegram_user.db_set("user", user.name) 33 | update.effective_chat.send_message( 34 | frappe._("You have successfully signed up as: {0}").format( 35 | user.name)) 36 | 37 | return ConversationHandler.END 38 | ``` -------------------------------------------------------------------------------- /docs/production.md: -------------------------------------------------------------------------------- 1 | # Setting Up for Production 2 | 3 | We can have the bot running in its own supervisor process, independent from frappe processes. You can update your supervisor configuration with the following commands 4 | 5 | ```bash 6 | $ bench telegram supervisor-add --help 7 | Usage: bench telegram supervisor-add [OPTIONS] TELEGRAM_BOT 8 | 9 | Sets up supervisor process 10 | 11 | Options: 12 | --polling Start bot in Polling Mode 13 | --poll-interval FLOAT Time interval between each poll. Default is 0 14 | --webhook Start Webhook Server 15 | --webhook-port INTEGER The port to listen on for webhook events. Default is 16 | 8080 17 | 18 | --webhook-url TEXT Explicitly specify webhook URL. Useful for NAT, 19 | reverse-proxy etc 20 | 21 | --help Show this message and exit. 22 | ``` 23 | 24 | All the parameters of `start-bot` are available here. Similarly, you can remove the supervisor process with `supervisor-remove`. 25 | 26 | Please do update supervisor after configuration changes are made 27 | ```bash 28 | $ sudo supervisorctl update 29 | ``` 30 | 31 | ## Webhooks & Nginx Guide 32 | Though you can run your telegram-bot-server in polling mode, it is recommended to run them in webhook mode in production. `frappe_telegram` comes with utility commands to easily add webhook location-blocks to your bench-nginx.conf. 33 | 34 | It might be a good idea to backup your existing nginx.conf (bench/config/nginx.conf) before making any changes. 35 | 36 | ```bash 37 | $ bench telegram nginx-add --help 38 | Usage: bench telegram nginx-add [OPTIONS] TELEGRAM_BOT 39 | 40 | Modifies existing nginx-config for telegram-webhook support. You can 41 | specify webhook url, port & nginx_path to override existing value in 42 | TelegramBot Doc 43 | 44 | Args: 45 | webhook_port: Specify the port to override 46 | webhook_url: Specify the url to override existing webhook_url 47 | nginx_path: Use custom path in nginx location block 48 | 49 | Options: 50 | --webhook-port INTEGER The port to listen on for webhook events. Default is 51 | 8080 52 | 53 | --webhook-url TEXT Explicitly specify webhook URL. Useful for NAT, 54 | reverse-proxy etc 55 | 56 | --nginx-path TEXT Use custom nginx path for webhook reverse-proxy 57 | --help Show this message and exit. 58 | ``` 59 | Similarly, you can remove the webhook location block with `nginx-remove`. 60 | Test and reload nginx to have the changes reflected. 61 | 62 | ```bash 63 | # Test nginx config 64 | $ sudo nginx -t 65 | nginx: the configuration file /etc/nginx/nginx.conf syntax is ok 66 | nginx: configuration file /etc/nginx/nginx.conf test is successful 67 | 68 | # Reload nginx 69 | $ sudo service nginx reload 70 | ``` -------------------------------------------------------------------------------- /frappe_telegram/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | __version__ = '0.0.1' 3 | 4 | from telegram import ( # noqa 5 | Update, Message, InlineKeyboardButton, InlineKeyboardMarkup, ParseMode 6 | ) 7 | from telegram.bot import Bot # noqa 8 | from telegram.ext import ( # noqa 9 | Updater, CallbackContext, Handler, 10 | MessageHandler, CommandHandler, CallbackQueryHandler, 11 | DispatcherHandlerStop, ConversationHandler 12 | ) 13 | -------------------------------------------------------------------------------- /frappe_telegram/bot.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | from telegram.ext import Updater 3 | 4 | 5 | import frappe 6 | from frappe_telegram.frappe_telegram.doctype import TelegramBot 7 | from telegram.ext.dispatcher import Dispatcher 8 | from telegram.ext.messagehandler import MessageHandler 9 | 10 | 11 | def start_polling(site: str, telegram_bot: Union[str, TelegramBot], poll_interval: int = 0): 12 | updater = get_bot(telegram_bot=telegram_bot, site=site) 13 | 14 | updater.start_polling(poll_interval=poll_interval) 15 | updater.idle() 16 | 17 | 18 | def start_webhook( 19 | site: str, 20 | telegram_bot: Union[str, TelegramBot], 21 | listen_host: str = "127.0.0.1", 22 | webhook_port: int = 80, 23 | webhook_url: str = None): 24 | updater = get_bot(telegram_bot=telegram_bot, site=site) 25 | updater.start_webhook( 26 | listen=listen_host, 27 | port=webhook_port, 28 | webhook_url=webhook_url 29 | ) 30 | 31 | 32 | def get_bot(telegram_bot: Union[str, TelegramBot], site=None) -> Updater: 33 | if not site: 34 | site = frappe.local.site 35 | 36 | from contextlib import ExitStack 37 | 38 | with frappe.init_site(site) if not frappe.db else ExitStack(): 39 | if not frappe.db: 40 | frappe.connect() 41 | 42 | if isinstance(telegram_bot, str): 43 | telegram_bot = frappe.get_doc("Telegram Bot", telegram_bot) 44 | 45 | updater = make_bot(telegram_bot=telegram_bot, site=site) 46 | # dispatcher = updater.dispatcher 47 | 48 | handlers = frappe.get_hooks("telegram_bot_handler") 49 | if isinstance(handlers, dict): 50 | handlers = handlers[telegram_bot.name] 51 | for cmd in handlers: 52 | frappe.get_attr(cmd)(telegram_bot=telegram_bot, updater=updater) 53 | 54 | attach_update_processors(dispatcher=updater.dispatcher) 55 | 56 | return updater 57 | 58 | 59 | def make_bot(telegram_bot: TelegramBot, site: str) -> Updater: 60 | """ 61 | Returns a custom TelegramUpdater with FrappeTelegramDispatcher 62 | """ 63 | from .utils.overrides import FrappeTelegramDispatcher, FrappeTelegramExtBot 64 | 65 | updater = Updater(token=telegram_bot.get_password("api_token")) 66 | # Override ExtBot 67 | updater.bot = FrappeTelegramExtBot.make(telegram_bot=telegram_bot.name, updater=updater) 68 | 69 | # Override Dispatcher 70 | frappe_dispatcher = FrappeTelegramDispatcher.make( 71 | site=site, updater=updater) 72 | updater.dispatcher = frappe_dispatcher 73 | updater.job_queue.set_dispatcher(frappe_dispatcher) 74 | 75 | return updater 76 | 77 | 78 | def attach_update_processors(dispatcher: Dispatcher): 79 | pre_process_group = dispatcher.groups[0] - 1000 80 | post_process_group = dispatcher.groups[-1] + 1000 81 | 82 | for cmd in frappe.get_hooks("telegram_update_pre_processors"): 83 | dispatcher.add_handler(MessageHandler(None, frappe.get_attr(cmd)), group=pre_process_group) 84 | pre_process_group += 1 85 | 86 | for cmd in frappe.get_hooks("telegram_update_post_processors"): 87 | dispatcher.add_handler(MessageHandler(None, frappe.get_attr(cmd)), group=post_process_group) 88 | post_process_group += 1 89 | -------------------------------------------------------------------------------- /frappe_telegram/client.py: -------------------------------------------------------------------------------- 1 | import os 2 | import frappe 3 | from frappe import _ 4 | from frappe.core.doctype.file.file import File 5 | from frappe.utils.jinja import render_template 6 | from frappe_telegram import Bot, ParseMode 7 | from frappe_telegram.utils.formatting import strip_unsupported_html_tags 8 | from frappe_telegram.frappe_telegram.doctype.telegram_bot import DEFAULT_TELEGRAM_BOT_KEY 9 | from frappe_telegram.handlers.logging import log_outgoing_message 10 | 11 | """ 12 | The functions defined here is provided to invoke the bot 13 | without Updaters & Dispatchers. This is helpful for triggering 14 | bot interactions via Hooks / Controller methods 15 | """ 16 | 17 | 18 | def send_message(message_text: str, parse_mode=None, user=None, telegram_user=None, from_bot=None): 19 | """ 20 | Send a message using a bot to a Telegram User 21 | 22 | message_text: `str` 23 | A text string between 0 and 4096 characters that will be the message 24 | parse_mode: `ParseMode` 25 | Choose styling for your message using a ParseMode class constant. Default is `None` 26 | user: `str` 27 | Can optionally be used to resolve `telegram_user` using a User linked to a Telegram User 28 | telegram_user: `str` 29 | Selects the Telegram User to send the message to. Can be skipped if `user` is set 30 | from_bot: `str` 31 | Explicitly specify a bot name to send message from; the default is used if none specified 32 | """ 33 | 34 | message_text = sanitize_message_text(message_text, parse_mode) 35 | 36 | telegram_user_id = get_telegram_user_id(user=user, telegram_user=telegram_user) 37 | if not from_bot: 38 | from_bot = frappe.db.get_default(DEFAULT_TELEGRAM_BOT_KEY) 39 | 40 | bot = get_bot(from_bot) 41 | message = bot.send_message(telegram_user_id, text=message_text, parse_mode=parse_mode) 42 | log_outgoing_message(telegram_bot=from_bot, result=message) 43 | 44 | 45 | def send_file(file, filename=None, message=None, parse_mode=None, user=None, telegram_user=None, 46 | from_bot=None): 47 | """ 48 | Send a file to the bot 49 | 50 | file: (`str` | `filelike object` | `bytes` | `pathlib.Path` | `telegram.Document` | `frappe.File`) 51 | The file can be an internal file path, a telegram file_id, a URL, a File doc or a file from disk. 52 | internal file path examples: "/files/example.png", "/private/files/example.png" 53 | filename: `str` 54 | Specify custom file name 55 | message: `str` 56 | Small text to show alongside the file. 0-1024 characters 57 | parse_mode: `ParseMode` 58 | Choose styling for your message using a ParseMode class constant. Default is `None` 59 | """ 60 | 61 | message = sanitize_message_text(message, parse_mode) 62 | 63 | telegram_user_id = get_telegram_user_id( 64 | user=user, telegram_user=telegram_user) 65 | if not from_bot: 66 | from_bot = frappe.db.get_default(DEFAULT_TELEGRAM_BOT_KEY) 67 | 68 | if isinstance(file, File): 69 | file = file.file_url 70 | 71 | if isinstance(file, str) and "/files/" in file: 72 | 73 | # If file is string, check that the url is internal 74 | 75 | file_path = frappe.get_site_path( 76 | (("" if "/private/" in file else "/public") + file).strip("/")) 77 | 78 | if os.path.exists(file_path): 79 | file = open(file_path, 'rb') 80 | 81 | bot = get_bot(from_bot) 82 | result = bot.send_document(telegram_user_id, document=file, filename=filename, caption=message, 83 | parse_mode=parse_mode) 84 | log_outgoing_message(telegram_bot=from_bot, result=result) 85 | 86 | 87 | def get_telegram_user_id(user=None, telegram_user=None): 88 | if not user and not telegram_user: 89 | frappe.throw(frappe._("Please specify either frappe-user or telegram-user")) 90 | 91 | telegram_user_id = None 92 | if user and frappe.db.exists("Telegram User", {"user": user}): 93 | telegram_user_id = frappe.db.get_value("Telegram User", {"user": user}, "telegram_user_id") 94 | 95 | if telegram_user and not telegram_user_id: 96 | telegram_user_id = frappe.db.get_value("Telegram User", telegram_user, "telegram_user_id") 97 | 98 | if not telegram_user_id: 99 | frappe.throw(frappe._("Telegram user do not exist")) 100 | 101 | return telegram_user_id 102 | 103 | 104 | def get_bot(telegram_bot) -> Bot: 105 | from telegram.ext import ExtBot 106 | telegram_bot = frappe.get_doc("Telegram Bot", telegram_bot) 107 | 108 | return ExtBot( 109 | token=telegram_bot.get_password("api_token") 110 | ) 111 | 112 | 113 | @frappe.whitelist() 114 | def send_message_from_template(template: str, context: dict = None, lang: str = None, 115 | parse_mode=None, user=None, telegram_user=None, from_bot=None): 116 | """ 117 | Use a Telegram Message Template to send a message 118 | 119 | template: `str` 120 | Name of a Telegram Message Template 121 | context: `dict` 122 | dict of key:values to resolve the tags in the template 123 | lang: `str` 124 | Optionally can be set if an alternative template language is needed 125 | """ 126 | 127 | message = render_message_from_template(template, context=context, lang=lang) 128 | 129 | send_message(message, parse_mode, user, telegram_user, from_bot) 130 | 131 | 132 | def render_message_from_template(template: str, context: dict = None, lang: str = None) -> str: 133 | """ 134 | Use a Telegram Message Template to render a message 135 | 136 | template: `str` 137 | Name of a Telegram Message Template 138 | context: `dict` 139 | dict of key:values to resolve the tags in the template 140 | lang: `str` 141 | Optionally can be set if an alternative template language is needed 142 | """ 143 | dt = "Telegram Message Template" 144 | 145 | templates = frappe.get_all( 146 | dt, 147 | filters=[{"name": template}] 148 | ) 149 | 150 | if not templates: 151 | frappe.throw(_("No template with name '{0}' exists.").format(template)) 152 | 153 | template_doc = frappe.get_doc(dt, templates[0].name) 154 | 155 | if not context: 156 | context = {} 157 | 158 | template = "" 159 | 160 | if lang: 161 | for translation in template_doc.template_translations: 162 | if translation.language == lang: 163 | template = translation.template 164 | 165 | if not template: 166 | template = template_doc.default_template 167 | 168 | return render_template(template, context) 169 | 170 | 171 | def validate_parse_mode(parse_mode: ParseMode) -> None: 172 | """ 173 | Validate the parse_mode is a valid ParseMode class constant 174 | 175 | parse_mode: `ParseMode` 176 | 177 | Raises `ValueError` if the parse_mode is not valid 178 | """ 179 | if parse_mode and parse_mode not in [value for name, value in vars(ParseMode).items() if 180 | not name.startswith('_')]: 181 | raise ValueError("Please use a valid ParseMode constant.") 182 | 183 | 184 | def sanitize_message_text(message_text: str, parse_mode: ParseMode = None) -> str: 185 | """ 186 | Sanitize the message text depending on the parse_mode 187 | 188 | message_text: `str` 189 | The message text to sanitize 190 | 191 | parse_mode: `ParseMode` 192 | The parse_mode to use for sanitizing 193 | 194 | Returns `str` 195 | """ 196 | validate_parse_mode(parse_mode) 197 | 198 | if not parse_mode: 199 | return message_text 200 | 201 | if parse_mode == ParseMode.HTML: 202 | # Telegram API throws error if not formatted properly 203 | return strip_unsupported_html_tags(message_text) 204 | 205 | return message_text 206 | -------------------------------------------------------------------------------- /frappe_telegram/commands/__init__.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | import frappe 4 | import logging 5 | from frappe.commands import pass_context, get_site 6 | 7 | from frappe_telegram.bot import start_polling, start_webhook 8 | from frappe_telegram.utils.supervisor import add_supervisor_entry, remove_supervisor_entry 9 | 10 | 11 | @click.group("telegram") 12 | def telegram(): 13 | pass 14 | 15 | 16 | @click.command("start-bot") 17 | @click.argument("telegram_bot") 18 | @click.option("--polling", is_flag=True, help="Start bot in Polling Mode") 19 | @click.option("--poll-interval", type=float, default=0, 20 | help="Time interval between each poll. Default is 0") 21 | @click.option("--webhook", is_flag=True, help="Start Webhook Server") 22 | @click.option("--webhook-port", type=int, default=8080, 23 | help="The port to listen on for webhook events. Default is 8080") 24 | @click.option("--webhook-url", type=str, 25 | help="Explicitly specify webhook URL. Useful for NAT, reverse-proxy etc") 26 | @pass_context 27 | def start_bot( 28 | context, telegram_bot, 29 | polling=False, poll_interval=0, 30 | webhook=False, webhook_port=8080, webhook_url=None): 31 | """ 32 | Start Telegram Bot 33 | 34 | \b 35 | Args: 36 | telegram_bot: The name of 'Telegram Bot' to start 37 | """ 38 | site = get_site(context) 39 | 40 | if not polling and not webhook: 41 | print("Starting {} in polling mode".format(telegram_bot)) 42 | polling = True 43 | 44 | if webhook and not webhook_port: 45 | webhook_port = 8080 46 | 47 | # Enable logging 48 | logging.basicConfig( 49 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.DEBUG 50 | ) 51 | 52 | if polling: 53 | start_polling(site=site, telegram_bot=telegram_bot, poll_interval=poll_interval) 54 | elif webhook: 55 | start_webhook( 56 | site=site, telegram_bot=telegram_bot, 57 | webhook_port=webhook_port, webhook_url=webhook_url) 58 | 59 | 60 | @click.command("list-bots") 61 | @pass_context 62 | def list_bots(context): 63 | site = get_site(context=context) 64 | frappe.init(site=site) 65 | frappe.connect() 66 | 67 | bots = frappe.get_all("Telegram Bot", fields=["name"]) 68 | print("No. of Telegram Bots:", len(bots)) 69 | for bot in bots: 70 | print("-", bot.name) 71 | 72 | frappe.destroy() 73 | 74 | 75 | @click.command("supervisor-add") 76 | @click.argument("telegram_bot") 77 | @click.option("--polling", is_flag=True, help="Start bot in Polling Mode") 78 | @click.option("--poll-interval", type=float, default=0, 79 | help="Time interval between each poll. Default is 0") 80 | @click.option("--webhook", is_flag=True, help="Start Webhook Server") 81 | @click.option("--webhook-port", type=int, default=0, 82 | help="The port to listen on for webhook events. Default is 8080") 83 | @click.option("--webhook-url", type=str, 84 | help="Explicitly specify webhook URL. Useful for NAT, reverse-proxy etc") 85 | @pass_context 86 | def supervisor_add( 87 | context, telegram_bot, 88 | polling=False, poll_interval=0, 89 | webhook=False, webhook_port=8080, webhook_url=None): 90 | """ 91 | Sets up supervisor process 92 | """ 93 | site = get_site(context) 94 | frappe.init(site=site) 95 | frappe.connect() 96 | 97 | if webhook and not webhook_port: 98 | webhook_port = 8080 99 | 100 | add_supervisor_entry( 101 | telegram_bot=telegram_bot, polling=polling, poll_interval=poll_interval, 102 | webhook=webhook, webhook_port=webhook_port, webhook_url=webhook_url) 103 | 104 | frappe.destroy() 105 | 106 | 107 | @click.command("supervisor-remove") 108 | @click.argument("telegram_bot") 109 | @pass_context 110 | def supervisor_remove(context, telegram_bot): 111 | """ 112 | Removes supervisor entry of specific bot 113 | 114 | \b 115 | Args: 116 | telegram_bot: The name of 'Telegram Bot' to remove 117 | """ 118 | site = get_site(context) 119 | frappe.init(site=site) 120 | frappe.connect() 121 | 122 | remove_supervisor_entry(telegram_bot=telegram_bot) 123 | 124 | frappe.destroy() 125 | 126 | 127 | @click.command("nginx-add") 128 | @click.argument("telegram_bot") 129 | @click.option("--webhook-port", type=int, default=0, 130 | help="The port to listen on for webhook events. Default is 8080") 131 | @click.option("--webhook-url", type=str, 132 | help="Explicitly specify webhook URL. Useful for NAT, reverse-proxy etc") 133 | @click.option("--nginx-path", type=str, 134 | help="Use custom nginx path for webhook reverse-proxy") 135 | @pass_context 136 | def nginx_add(context, telegram_bot, webhook_port=None, webhook_url=None, nginx_path=None): 137 | """ 138 | Modifies existing nginx-config for telegram-webhook support. 139 | You can specify webhook url, port & nginx_path to override existing value in TelegramBot Doc 140 | 141 | \b 142 | Args: 143 | webhook_port: Specify the port to override 144 | webhook_url: Specify the url to override existing webhook_url 145 | nginx_path: Use custom path in nginx location block 146 | """ 147 | from frappe_telegram.utils.nginx import add_nginx_config 148 | 149 | site = get_site(context) 150 | frappe.init(site=site) 151 | frappe.connect() 152 | 153 | add_nginx_config( 154 | telegram_bot=telegram_bot, 155 | webhook_url=webhook_url, 156 | webhook_port=webhook_port, 157 | webhook_nginx_path=nginx_path) 158 | frappe.destroy() 159 | 160 | 161 | @click.command("nginx-remove") 162 | @click.argument("telegram_bot") 163 | @pass_context 164 | def nginx_remove(context, telegram_bot): 165 | """ 166 | Removes nginx-config modifications made for telegram_bot 167 | """ 168 | from frappe_telegram.utils.nginx import remove_nginx_config 169 | 170 | site = get_site(context) 171 | frappe.init(site=site) 172 | frappe.connect() 173 | 174 | remove_nginx_config(telegram_bot=telegram_bot) 175 | frappe.destroy() 176 | 177 | 178 | telegram.add_command(start_bot) 179 | telegram.add_command(list_bots) 180 | telegram.add_command(supervisor_add) 181 | telegram.add_command(supervisor_remove) 182 | telegram.add_command(nginx_add) 183 | telegram.add_command(nginx_remove) 184 | commands = [telegram] 185 | -------------------------------------------------------------------------------- /frappe_telegram/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_telegram/b135ac3daac73eb68d2591346a2200ac47ac4c30/frappe_telegram/config/__init__.py -------------------------------------------------------------------------------- /frappe_telegram/config/desktop.py: -------------------------------------------------------------------------------- 1 | from frappe import _ 2 | 3 | def get_data(): 4 | return [ 5 | { 6 | "module_name": "Frappe Telegram", 7 | "color": "blue", 8 | "icon": "octicon octicon-file-directory", 9 | "type": "module", 10 | "label": _("Frappe Telegram") 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /frappe_telegram/config/docs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration for docs 3 | """ 4 | 5 | # source_link = "https://github.com/[org_name]/frappe_telegram" 6 | # docs_base_url = "https://[org_name].github.io/frappe_telegram" 7 | # headline = "App that does everything" 8 | # sub_heading = "Yes, you got that right the first time, everything" 9 | 10 | def get_context(context): 11 | context.brand_html = "Frappe Telegram" 12 | -------------------------------------------------------------------------------- /frappe_telegram/fixtures/custom_field.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "allow_in_quick_entry": 0, 4 | "allow_on_submit": 0, 5 | "app_name": "frappe_telegram", 6 | "bold": 0, 7 | "collapsible": 0, 8 | "collapsible_depends_on": null, 9 | "columns": 0, 10 | "default": null, 11 | "depends_on": "eval:doc.channel === \"Telegram\"", 12 | "description": "Leave empty to use the bot marked as default", 13 | "docstatus": 0, 14 | "doctype": "Custom Field", 15 | "dt": "Notification", 16 | "fetch_from": null, 17 | "fetch_if_empty": 0, 18 | "fieldname": "bot_to_send_from", 19 | "fieldtype": "Link", 20 | "hidden": 0, 21 | "hide_border": 0, 22 | "hide_days": 0, 23 | "hide_seconds": 0, 24 | "ignore_user_permissions": 0, 25 | "ignore_xss_filter": 0, 26 | "in_global_search": 0, 27 | "in_list_view": 0, 28 | "in_preview": 0, 29 | "in_standard_filter": 0, 30 | "insert_after": "channel", 31 | "label": "Bot to Send From", 32 | "length": 0, 33 | "mandatory_depends_on": null, 34 | "modified": "2021-11-11 10:18:07.781570", 35 | "name": "Notification-bot_to_send_from", 36 | "no_copy": 0, 37 | "non_negative": 0, 38 | "options": "Telegram Bot", 39 | "parent": null, 40 | "parentfield": null, 41 | "parenttype": null, 42 | "permlevel": 0, 43 | "precision": "", 44 | "print_hide": 0, 45 | "print_hide_if_no_value": 0, 46 | "print_width": null, 47 | "read_only": 0, 48 | "read_only_depends_on": null, 49 | "report_hide": 0, 50 | "reqd": 0, 51 | "search_index": 0, 52 | "translatable": 0, 53 | "unique": 0, 54 | "width": null 55 | } 56 | ] -------------------------------------------------------------------------------- /frappe_telegram/fixtures/role.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "bulk_actions": 1, 4 | "chat": 1, 5 | "dashboard": 1, 6 | "desk_access": 1, 7 | "disabled": 0, 8 | "docstatus": 0, 9 | "doctype": "Role", 10 | "form_sidebar": 1, 11 | "home_page": null, 12 | "is_custom": 0, 13 | "list_sidebar": 1, 14 | "modified": "2021-07-24 10:13:33.856623", 15 | "name": "Telegram Bot Manager", 16 | "notifications": 1, 17 | "parent": null, 18 | "parentfield": null, 19 | "parenttype": null, 20 | "restrict_to_domain": null, 21 | "role_name": "Telegram Bot Manager", 22 | "search_bar": 1, 23 | "timeline": 1, 24 | "two_factor_auth": 0, 25 | "view_switcher": 1 26 | }, 27 | { 28 | "bulk_actions": 1, 29 | "chat": 1, 30 | "dashboard": 1, 31 | "desk_access": 1, 32 | "disabled": 0, 33 | "docstatus": 0, 34 | "doctype": "Role", 35 | "form_sidebar": 1, 36 | "home_page": null, 37 | "is_custom": 0, 38 | "list_sidebar": 1, 39 | "modified": "2021-07-24 10:13:41.428529", 40 | "name": "Telegram Bot User", 41 | "notifications": 1, 42 | "parent": null, 43 | "parentfield": null, 44 | "parenttype": null, 45 | "restrict_to_domain": null, 46 | "role_name": "Telegram Bot User", 47 | "search_bar": 1, 48 | "timeline": 1, 49 | "two_factor_auth": 0, 50 | "view_switcher": 1 51 | } 52 | ] -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_telegram/b135ac3daac73eb68d2591346a2200ac47ac4c30/frappe_telegram/frappe_telegram/__init__.py -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/__init__.py: -------------------------------------------------------------------------------- 1 | from .telegram_bot.telegram_bot import TelegramBot # noqa 2 | from .telegram_user.telegram_user import TelegramUser # noqa 3 | from .telegram_chat.telegram_chat import TelegramChat # noqa 4 | from .telegram_message.telegram_message import TelegramMessage # noqa 5 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_bot/__init__.py: -------------------------------------------------------------------------------- 1 | DEFAULT_TELEGRAM_BOT_KEY = "default_telegram_bot" 2 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_bot/telegram_bot.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Leam Technology Systems and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on('Telegram Bot', { 5 | // refresh: function(frm) { 6 | 7 | // } 8 | }); 9 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_bot/telegram_bot.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2021-07-19 15:37:04.024011", 4 | "doctype": "DocType", 5 | "editable_grid": 1, 6 | "engine": "InnoDB", 7 | "field_order": [ 8 | "title", 9 | "username", 10 | "api_token", 11 | "column_break_4", 12 | "mark_as_default", 13 | "section_break_4", 14 | "webhook_url", 15 | "webhook_port", 16 | "column_break_7", 17 | "webhook_nginx_path" 18 | ], 19 | "fields": [ 20 | { 21 | "fieldname": "title", 22 | "fieldtype": "Data", 23 | "in_list_view": 1, 24 | "label": "Title", 25 | "reqd": 1, 26 | "unique": 1 27 | }, 28 | { 29 | "fieldname": "api_token", 30 | "fieldtype": "Password", 31 | "in_list_view": 1, 32 | "label": "API Token", 33 | "reqd": 1 34 | }, 35 | { 36 | "fieldname": "username", 37 | "fieldtype": "Data", 38 | "label": "Username", 39 | "read_only": 1, 40 | "unique": 1 41 | }, 42 | { 43 | "fieldname": "section_break_4", 44 | "fieldtype": "Section Break", 45 | "label": "Webhooks" 46 | }, 47 | { 48 | "fieldname": "webhook_url", 49 | "fieldtype": "Data", 50 | "label": "Webhook URL" 51 | }, 52 | { 53 | "default": "8080", 54 | "fieldname": "webhook_port", 55 | "fieldtype": "Int", 56 | "label": "Webhook Port" 57 | }, 58 | { 59 | "fieldname": "column_break_7", 60 | "fieldtype": "Column Break" 61 | }, 62 | { 63 | "fieldname": "webhook_nginx_path", 64 | "fieldtype": "Data", 65 | "label": "NGINX Path" 66 | }, 67 | { 68 | "fieldname": "mark_as_default", 69 | "fieldtype": "Button", 70 | "label": "Mark as Default", 71 | "options": "mark_as_default" 72 | }, 73 | { 74 | "fieldname": "column_break_4", 75 | "fieldtype": "Column Break" 76 | } 77 | ], 78 | "index_web_pages_for_search": 1, 79 | "links": [], 80 | "modified": "2021-12-10 07:21:41.786444", 81 | "modified_by": "Administrator", 82 | "module": "Frappe Telegram", 83 | "name": "Telegram Bot", 84 | "owner": "Administrator", 85 | "permissions": [ 86 | { 87 | "create": 1, 88 | "delete": 1, 89 | "email": 1, 90 | "export": 1, 91 | "print": 1, 92 | "read": 1, 93 | "report": 1, 94 | "role": "System Manager", 95 | "share": 1, 96 | "write": 1 97 | }, 98 | { 99 | "create": 1, 100 | "delete": 1, 101 | "email": 1, 102 | "export": 1, 103 | "print": 1, 104 | "read": 1, 105 | "report": 1, 106 | "role": "Telegram Bot Manager", 107 | "share": 1, 108 | "write": 1 109 | }, 110 | { 111 | "read": 1, 112 | "role": "Telegram Bot User" 113 | } 114 | ], 115 | "sort_field": "modified", 116 | "sort_order": "DESC", 117 | "track_changes": 1 118 | } -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_bot/telegram_bot.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Leam Technology Systems and contributors 2 | # For license information, please see license.txt 3 | 4 | import frappe 5 | from frappe.model.document import Document 6 | from frappe_telegram.frappe_telegram.doctype.telegram_bot import DEFAULT_TELEGRAM_BOT_KEY 7 | 8 | 9 | class TelegramBot(Document): 10 | def autoname(self): 11 | self.name = self.title.replace(" ", "-") 12 | 13 | def validate(self): 14 | self.validate_api_token() 15 | self.set_nginx_path() 16 | 17 | def after_insert(self): 18 | default_bot = frappe.db.get_default(DEFAULT_TELEGRAM_BOT_KEY) 19 | if not default_bot: 20 | self.mark_as_default() 21 | 22 | def after_delete(self): 23 | default_bot = frappe.db.get_default(DEFAULT_TELEGRAM_BOT_KEY) 24 | 25 | if default_bot == self.name: 26 | new_default_bot = frappe.get_value("Telegram Bot", {}) 27 | frappe.db.set_default(DEFAULT_TELEGRAM_BOT_KEY, new_default_bot) 28 | 29 | if new_default_bot: 30 | frappe.msgprint( 31 | frappe._(f"Set {new_default_bot} as the default bot for notifications.") 32 | ) 33 | 34 | def set_nginx_path(self): 35 | if self.webhook_nginx_path: 36 | return 37 | 38 | if not self.webhook_url: 39 | return 40 | 41 | self.webhook_nginx_path = "/" + self.webhook_url.rstrip("/").split("/")[-1] 42 | 43 | @frappe.whitelist() 44 | def mark_as_default(self): 45 | frappe.db.set_default(DEFAULT_TELEGRAM_BOT_KEY, self.name) 46 | frappe.msgprint(frappe._(f"Set {self.get('title')} as the default bot for notifications.")) 47 | 48 | def validate_api_token(self): 49 | if not self.is_new() and not self.has_value_changed("api_token"): 50 | return 51 | 52 | from telegram.ext import ExtBot 53 | try: 54 | bot = ExtBot( 55 | token=self.api_token 56 | ) 57 | user = bot.get_me() 58 | if not user.is_bot: 59 | raise Exception(frappe._("TelegramUser is not a Bot")) 60 | self.username = "@" + user.username 61 | except BaseException as e: 62 | frappe.throw(msg=frappe._("Error with Bot Token: {0}").format(str(e))) 63 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_bot/test_telegram_bot.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Leam Technology Systems and Contributors 2 | # See license.txt 3 | 4 | import unittest 5 | 6 | import frappe 7 | from frappe_telegram.frappe_telegram.doctype.telegram_bot import DEFAULT_TELEGRAM_BOT_KEY 8 | from frappe_telegram.utils.test_fixture import TestFixture 9 | 10 | 11 | class TelegramBotFixtures(TestFixture): 12 | def __init__(self): 13 | super().__init__() 14 | self.DEFAULT_DOCTYPE = "Customer Document Type" 15 | 16 | def make_fixtures(self): 17 | 18 | # Some fixtures that have not been inserted 19 | 20 | fix1 = frappe.get_doc(frappe._dict( 21 | doctype="Telegram Bot", 22 | title="TestBotFix1", 23 | api_token="randonapitoken" 24 | )) 25 | self.add_document(fix1) 26 | 27 | fix2 = frappe.get_doc(frappe._dict( 28 | doctype="Telegram Bot", 29 | title="TestBotFix2", 30 | api_token="otherrandonapitoken" 31 | )) 32 | self.add_document(fix2) 33 | 34 | fix3 = frappe.get_doc(frappe._dict( 35 | doctype="Telegram Bot", 36 | title="TestBotFix3", 37 | api_token="anotherrandonapitoken" 38 | )) 39 | self.add_document(fix3) 40 | 41 | 42 | class TestTelegramBot(unittest.TestCase): 43 | 44 | telegram_bots = TelegramBotFixtures() 45 | 46 | def setUp(self): 47 | self.telegram_bots.setUp() 48 | 49 | def tearDown(self): 50 | self.telegram_bots.tearDown() 51 | 52 | def test_auto_default_on_first_bot(self): 53 | """ 54 | Test that if a new bot is created, it is marked as default if no other bot exists 55 | Also test that if that default is deleted, another one is marked default 56 | """ 57 | 58 | existing_bots = frappe.get_all("Telegram Bot") 59 | self.assertEqual(len(existing_bots), 0) # Test assumes there are no bots existing 60 | 61 | # Check the current default 62 | self.assertIsNone(frappe.db.get_default(DEFAULT_TELEGRAM_BOT_KEY)) # No bots == no default 63 | 64 | # Load the unsaved fixtures 65 | fixture_bots = self.telegram_bots.fixtures.get("Telegram Bot") 66 | 67 | # Insert one 68 | bot1 = fixture_bots[0] 69 | bot1.insert() 70 | 71 | # Check if it has been made default 72 | self.assertEqual(frappe.db.get_default(DEFAULT_TELEGRAM_BOT_KEY), bot1.title) 73 | 74 | # Insert another 75 | bot2 = fixture_bots[1] 76 | bot2.insert() 77 | 78 | # Check that the default is unchanged 79 | self.assertEqual(frappe.db.get_default(DEFAULT_TELEGRAM_BOT_KEY), bot1.title) 80 | 81 | # Add and remove a bot 82 | bot3 = fixture_bots[2] 83 | bot3.insert() 84 | bot3.reload() 85 | bot3.delete() 86 | 87 | # Check that the default is unchanged 88 | self.assertEqual(frappe.db.get_default(DEFAULT_TELEGRAM_BOT_KEY), bot1.title) 89 | 90 | # Remove the default 91 | bot1.reload() 92 | bot1.delete() 93 | 94 | # Check that the default is updated 95 | self.assertEqual(frappe.db.get_default(DEFAULT_TELEGRAM_BOT_KEY), bot2.title) 96 | 97 | # Remove all bots 98 | bot2.reload() 99 | bot2.delete() 100 | 101 | # Check that default was set to none 102 | self.assertIsNone(frappe.db.get_default(DEFAULT_TELEGRAM_BOT_KEY)) 103 | 104 | def test_mark_as_default(self): 105 | """ 106 | Test the behavior of mark_as_default 107 | """ 108 | 109 | existing_bots = frappe.get_all("Telegram Bot") 110 | self.assertEqual(len(existing_bots), 0) # Test assumes there are no bots existing 111 | 112 | # Check the current default 113 | self.assertIsNone(frappe.db.get_default(DEFAULT_TELEGRAM_BOT_KEY)) 114 | 115 | # Load the unsaved fixtures 116 | fixture_bots = self.telegram_bots.fixtures.get("Telegram Bot") # No bots == no default 117 | 118 | # Insert one 119 | bot1 = fixture_bots[0] 120 | bot1.insert() 121 | 122 | # Check if it has been made default 123 | self.assertEqual(frappe.db.get_default(DEFAULT_TELEGRAM_BOT_KEY), bot1.title) 124 | 125 | # Insert another 126 | bot2 = fixture_bots[1] 127 | bot2.insert() 128 | 129 | # Check that the default is unchanged 130 | self.assertEqual(frappe.db.get_default(DEFAULT_TELEGRAM_BOT_KEY), bot1.title) 131 | 132 | # Run mark_as_default on the second one 133 | bot2.reload() 134 | bot2.mark_as_default() 135 | 136 | # Check that the default is unchanged 137 | self.assertEqual(frappe.db.get_default(DEFAULT_TELEGRAM_BOT_KEY), bot2.title) 138 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_bot_item/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_telegram/b135ac3daac73eb68d2591346a2200ac47ac4c30/frappe_telegram/frappe_telegram/doctype/telegram_bot_item/__init__.py -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_bot_item/telegram_bot_item.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2021-08-31 11:29:23.187432", 4 | "doctype": "DocType", 5 | "editable_grid": 1, 6 | "engine": "InnoDB", 7 | "field_order": [ 8 | "telegram_bot" 9 | ], 10 | "fields": [ 11 | { 12 | "fieldname": "telegram_bot", 13 | "fieldtype": "Link", 14 | "in_list_view": 1, 15 | "label": "Telegram Bot", 16 | "options": "Telegram Bot", 17 | "reqd": 1 18 | } 19 | ], 20 | "index_web_pages_for_search": 1, 21 | "istable": 1, 22 | "links": [], 23 | "modified": "2021-08-31 11:29:23.187432", 24 | "modified_by": "Administrator", 25 | "module": "Frappe Telegram", 26 | "name": "Telegram Bot Item", 27 | "owner": "Administrator", 28 | "permissions": [], 29 | "sort_field": "modified", 30 | "sort_order": "DESC", 31 | "track_changes": 1 32 | } -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_bot_item/telegram_bot_item.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Leam Technology Systems and contributors 2 | # For license information, please see license.txt 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | class TelegramBotItem(Document): 8 | pass 9 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_chat/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_telegram/b135ac3daac73eb68d2591346a2200ac47ac4c30/frappe_telegram/frappe_telegram/doctype/telegram_chat/__init__.py -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_chat/telegram_chat.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Leam Technology Systems and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on('Telegram Chat', { 5 | // refresh: function(frm) { 6 | 7 | // } 8 | }); 9 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_chat/telegram_chat.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "autoname": "field:chat_id", 4 | "creation": "2021-07-24 10:21:40.044883", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "chat_id", 10 | "title", 11 | "column_break_3", 12 | "type", 13 | "last_message_on", 14 | "last_message_content", 15 | "section_break_3", 16 | "users", 17 | "section_break_9", 18 | "bots" 19 | ], 20 | "fields": [ 21 | { 22 | "fieldname": "chat_id", 23 | "fieldtype": "Data", 24 | "in_list_view": 1, 25 | "label": "Chat ID", 26 | "read_only": 1, 27 | "reqd": 1, 28 | "unique": 1 29 | }, 30 | { 31 | "fieldname": "title", 32 | "fieldtype": "Data", 33 | "in_list_view": 1, 34 | "label": "Title", 35 | "read_only": 1, 36 | "reqd": 1 37 | }, 38 | { 39 | "fieldname": "section_break_3", 40 | "fieldtype": "Section Break" 41 | }, 42 | { 43 | "fieldname": "users", 44 | "fieldtype": "Table", 45 | "label": "Users", 46 | "options": "Telegram User Item" 47 | }, 48 | { 49 | "fieldname": "column_break_3", 50 | "fieldtype": "Column Break" 51 | }, 52 | { 53 | "fieldname": "type", 54 | "fieldtype": "Select", 55 | "label": "Type", 56 | "options": "private\ngroup\nsupergroup\nchannel", 57 | "read_only": 1 58 | }, 59 | { 60 | "fieldname": "last_message_on", 61 | "fieldtype": "Datetime", 62 | "label": "Last Message On", 63 | "read_only": 1, 64 | "search_index": 1 65 | }, 66 | { 67 | "fieldname": "last_message_content", 68 | "fieldtype": "Small Text", 69 | "label": "Last Message Content", 70 | "length": 4096, 71 | "read_only": 1 72 | }, 73 | { 74 | "fieldname": "section_break_9", 75 | "fieldtype": "Section Break" 76 | }, 77 | { 78 | "fieldname": "bots", 79 | "fieldtype": "Table", 80 | "label": "Bots", 81 | "options": "Telegram Bot Item" 82 | } 83 | ], 84 | "index_web_pages_for_search": 1, 85 | "links": [], 86 | "modified": "2021-08-31 11:29:44.881803", 87 | "modified_by": "Administrator", 88 | "module": "Frappe Telegram", 89 | "name": "Telegram Chat", 90 | "owner": "Administrator", 91 | "permissions": [ 92 | { 93 | "create": 1, 94 | "delete": 1, 95 | "email": 1, 96 | "export": 1, 97 | "print": 1, 98 | "read": 1, 99 | "report": 1, 100 | "role": "System Manager", 101 | "share": 1, 102 | "write": 1 103 | }, 104 | { 105 | "create": 1, 106 | "delete": 1, 107 | "email": 1, 108 | "export": 1, 109 | "print": 1, 110 | "read": 1, 111 | "report": 1, 112 | "role": "Telegram Bot Manager", 113 | "share": 1, 114 | "write": 1 115 | }, 116 | { 117 | "email": 1, 118 | "export": 1, 119 | "print": 1, 120 | "read": 1, 121 | "report": 1, 122 | "role": "Telegram Bot User", 123 | "share": 1 124 | } 125 | ], 126 | "sort_field": "modified", 127 | "sort_order": "DESC", 128 | "track_changes": 1 129 | } -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_chat/telegram_chat.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Leam Technology Systems and contributors 2 | # For license information, please see license.txt 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | 8 | class TelegramChat(Document): 9 | def validate(self): 10 | pass 11 | 12 | def get_bot(self): 13 | if not len(self.bots): 14 | return None 15 | 16 | from frappe_telegram.client import get_bot 17 | return get_bot(self.bots[0].telegram_bot) 18 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_chat/test_telegram_chat.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Leam Technology Systems and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | import unittest 6 | 7 | class TestTelegramChat(unittest.TestCase): 8 | pass 9 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_message/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_telegram/b135ac3daac73eb68d2591346a2200ac47ac4c30/frappe_telegram/frappe_telegram/doctype/telegram_message/__init__.py -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_message/telegram_message.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Leam Technology Systems and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on('Telegram Message', { 5 | // refresh: function(frm) { 6 | 7 | // } 8 | }); 9 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_message/telegram_message.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2021-07-24 10:31:52.695294", 4 | "doctype": "DocType", 5 | "editable_grid": 1, 6 | "engine": "InnoDB", 7 | "field_order": [ 8 | "chat", 9 | "message_id", 10 | "content", 11 | "column_break_3", 12 | "from_user", 13 | "from_bot" 14 | ], 15 | "fields": [ 16 | { 17 | "fieldname": "chat", 18 | "fieldtype": "Link", 19 | "in_list_view": 1, 20 | "label": "Chat", 21 | "options": "Telegram Chat", 22 | "read_only": 1, 23 | "reqd": 1 24 | }, 25 | { 26 | "fieldname": "content", 27 | "fieldtype": "Small Text", 28 | "label": "Content", 29 | "length": 4096, 30 | "read_only": 1, 31 | "reqd": 0 32 | }, 33 | { 34 | "fieldname": "column_break_3", 35 | "fieldtype": "Column Break" 36 | }, 37 | { 38 | "fieldname": "from_user", 39 | "fieldtype": "Link", 40 | "label": "From User", 41 | "options": "Telegram User", 42 | "read_only": 1 43 | }, 44 | { 45 | "fieldname": "from_bot", 46 | "fieldtype": "Link", 47 | "label": "From Bot", 48 | "options": "Telegram Bot", 49 | "read_only": 1 50 | }, 51 | { 52 | "fieldname": "message_id", 53 | "fieldtype": "Data", 54 | "label": "Message ID", 55 | "read_only": 1, 56 | "reqd": 1 57 | } 58 | ], 59 | "index_web_pages_for_search": 1, 60 | "links": [], 61 | "modified": "2021-08-19 11:50:28.085333", 62 | "modified_by": "Administrator", 63 | "module": "Frappe Telegram", 64 | "name": "Telegram Message", 65 | "owner": "Administrator", 66 | "permissions": [ 67 | { 68 | "create": 1, 69 | "delete": 1, 70 | "email": 1, 71 | "export": 1, 72 | "print": 1, 73 | "read": 1, 74 | "report": 1, 75 | "role": "System Manager", 76 | "share": 1, 77 | "write": 1 78 | } 79 | ], 80 | "sort_field": "modified", 81 | "sort_order": "DESC", 82 | "track_changes": 1 83 | } 84 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_message/telegram_message.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Leam Technology Systems and contributors 2 | # For license information, please see license.txt 3 | 4 | import frappe 5 | from frappe.model.document import Document 6 | from telegram import Bot 7 | 8 | 9 | class TelegramMessage(Document): 10 | def after_insert(self): 11 | self.update_last_message_on() 12 | 13 | def mark_as_password(self): 14 | self.db_set("content", "*" * len(self.content)) 15 | if not getattr(frappe.flags, "in_telegram_update", None): 16 | return 17 | 18 | # Let's delete the message from the User's Chat 19 | chat = frappe.get_doc("Telegram Chat", self.chat) 20 | bot: Bot = chat.get_bot() 21 | try: 22 | bot.delete_message(chat_id=chat.chat_id, message_id=self.message_id) 23 | # Let's also send in ***** the message to the User's Chat 24 | bot.send_message(chat_id=chat.chat_id, text=self.content) 25 | except Exception: 26 | pass 27 | 28 | def update_last_message_on(self): 29 | chat = frappe.get_doc("Telegram Chat", self.chat) 30 | chat.last_message_on = self.creation 31 | chat.last_message_content = self.content 32 | chat.save(ignore_permissions=True, ignore_version=True) 33 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_message/test_telegram_message.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Leam Technology Systems and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | import unittest 6 | 7 | class TestTelegramMessage(unittest.TestCase): 8 | pass 9 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_message_template/telegram_message_template.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Leam Technology Systems and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on('Telegram Message Template', { 5 | // refresh: function(frm) { 6 | 7 | // } 8 | }); 9 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_message_template/telegram_message_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "autoname": "field:template_name", 4 | "creation": "2021-11-15 21:02:22.949987", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "template_name", 10 | "default_template", 11 | "translations_section", 12 | "template_translations" 13 | ], 14 | "fields": [ 15 | { 16 | "allow_in_quick_entry": 1, 17 | "fieldname": "template_name", 18 | "fieldtype": "Data", 19 | "in_list_view": 1, 20 | "label": "Template Name", 21 | "reqd": 1, 22 | "set_only_once": 1, 23 | "unique": 1 24 | }, 25 | { 26 | "allow_in_quick_entry": 1, 27 | "description": "Use Jinja tags to represent dynamic fields, like so: {{variable}}", 28 | "fieldname": "default_template", 29 | "fieldtype": "Code", 30 | "in_list_view": 1, 31 | "label": "Default Template", 32 | "reqd": 1 33 | }, 34 | { 35 | "fieldname": "translations_section", 36 | "fieldtype": "Section Break", 37 | "label": "Translation" 38 | }, 39 | { 40 | "description": "By specifying the Language, an alternative to the Default Template can be used", 41 | "fieldname": "template_translations", 42 | "fieldtype": "Table", 43 | "label": "Template Translations", 44 | "options": "Telegram Message Template Translation" 45 | } 46 | ], 47 | "index_web_pages_for_search": 1, 48 | "links": [], 49 | "modified": "2021-11-15 21:33:46.066390", 50 | "modified_by": "Administrator", 51 | "module": "Frappe Telegram", 52 | "name": "Telegram Message Template", 53 | "owner": "Administrator", 54 | "permissions": [ 55 | { 56 | "create": 1, 57 | "delete": 1, 58 | "email": 1, 59 | "export": 1, 60 | "print": 1, 61 | "read": 1, 62 | "report": 1, 63 | "role": "System Manager", 64 | "share": 1, 65 | "write": 1 66 | } 67 | ], 68 | "quick_entry": 1, 69 | "search_fields": "template_name", 70 | "sort_field": "modified", 71 | "sort_order": "DESC", 72 | "track_changes": 1 73 | } -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_message_template/telegram_message_template.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Leam Technology Systems and contributors 2 | # For license information, please see license.txt 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | 8 | class TelegramMessageTemplate(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_message_template/test_telegram_message_template.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Leam Technology Systems and Contributors 2 | # See license.txt 3 | 4 | import unittest 5 | from re import template 6 | 7 | import frappe 8 | from frappe.exceptions import ValidationError 9 | from frappe_telegram.client import send_message_from_template 10 | from frappe_telegram.frappe_telegram.doctype.telegram_user.test_telegram_user import \ 11 | TelegramUserFixtures 12 | from frappe_telegram.utils.test_fixture import TestFixture 13 | 14 | 15 | class TelegramMessageTemplateFixtures(TestFixture): 16 | 17 | def __init__(self): 18 | super().__init__() 19 | 20 | self.DEFAULT_DOCTYPE = "Telegram Message Template" 21 | self.dependent_fixtures = [ 22 | TelegramUserFixtures 23 | ] 24 | 25 | def make_fixtures(self): 26 | 27 | fix_1 = frappe.get_doc(frappe._dict( 28 | doctype=self.DEFAULT_DOCTYPE, 29 | template_name="Test Template 1", 30 | default_template="This is a test template {{test}}" 31 | )).insert() 32 | self.add_document(fix_1) 33 | 34 | fix_2 = frappe.get_doc(frappe._dict( 35 | doctype=self.DEFAULT_DOCTYPE, 36 | template_name="Test Template 2", 37 | default_template="This is a test template {{test}}", 38 | template_translations=[ 39 | frappe._dict( 40 | language="ja", 41 | template='This is the translation {{test}}' 42 | ) 43 | ] 44 | )).insert() 45 | self.add_document(fix_2) 46 | 47 | 48 | class TestTelegramMessageTemplate(unittest.TestCase): 49 | 50 | templates = TelegramMessageTemplateFixtures() 51 | 52 | def setUp(self) -> None: 53 | self.templates.setUp() 54 | 55 | def tearDown(self) -> None: 56 | self.templates.tearDown() 57 | 58 | def test_send_message_from_template(self): 59 | "Test the send_message_from_template client method" 60 | 61 | templates = self.templates.fixtures.get("Telegram Message Template") 62 | 63 | # Send a message with wrong template name 64 | with self.assertRaises(ValidationError): 65 | send_message_from_template("randomTemplateDNE") 66 | 67 | # Send a message with wrong template name + non existing lang 68 | with self.assertRaises(ValidationError): 69 | send_message_from_template("randomTemplateDNE", lang="randonlang") 70 | 71 | # TODO: How to set up a Telegram User? 72 | # TODO: How to send messages and confirm their output? 73 | 74 | # # Send a message with right name + non existing lang 75 | # with self.assertRaises(ValidationError): 76 | # send_message_from_template( 77 | # templates[1].name, 78 | # {"test": "like this"}, 79 | # lang="randomlang" 80 | # ) 81 | 82 | # # Send a message with right name 83 | # send_message_from_template( 84 | # templates[0].name, 85 | # {"test": "like this"}, 86 | # telegram_user=self.templates.get_dependencies("Telegram User")[0].name 87 | # ) 88 | 89 | # # Send a message with right name + existing lang 90 | # send_message_from_template( 91 | # templates[1].name, 92 | # {"test": "like this"}, 93 | # templates[1].template_translations[0].language, 94 | # telegram_user=self.templates.get_dependencies("Telegram User")[0].name 95 | # ) 96 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_message_template_translation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_telegram/b135ac3daac73eb68d2591346a2200ac47ac4c30/frappe_telegram/frappe_telegram/doctype/telegram_message_template_translation/__init__.py -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_message_template_translation/telegram_message_template_translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_rename": 1, 4 | "creation": "2021-11-15 20:47:24.219471", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "language", 10 | "template" 11 | ], 12 | "fields": [ 13 | { 14 | "fieldname": "language", 15 | "fieldtype": "Link", 16 | "in_list_view": 1, 17 | "label": "Language", 18 | "options": "Language", 19 | "reqd": 1 20 | }, 21 | { 22 | "description": "Use Jinja tags to represent dynamic fields, like so: {{variable}}", 23 | "fieldname": "template", 24 | "fieldtype": "Code", 25 | "in_list_view": 1, 26 | "label": "Template" 27 | } 28 | ], 29 | "index_web_pages_for_search": 1, 30 | "istable": 1, 31 | "links": [], 32 | "modified": "2021-11-22 10:41:03.354394", 33 | "modified_by": "Administrator", 34 | "module": "Frappe Telegram", 35 | "name": "Telegram Message Template Translation", 36 | "owner": "Administrator", 37 | "permissions": [], 38 | "sort_field": "modified", 39 | "sort_order": "DESC", 40 | "track_changes": 1 41 | } -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_message_template_translation/telegram_message_template_translation.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Leam Technology Systems and contributors 2 | # For license information, please see license.txt 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | 8 | class TelegramMessageTemplateTranslation(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_telegram/b135ac3daac73eb68d2591346a2200ac47ac4c30/frappe_telegram/frappe_telegram/doctype/telegram_user/__init__.py -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_user/telegram_user.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Leam Technology Systems and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on('Telegram User', { 5 | // refresh: function(frm) { 6 | 7 | // } 8 | }); 9 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_user/telegram_user.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2021-07-24 10:11:11.274518", 4 | "doctype": "DocType", 5 | "editable_grid": 1, 6 | "engine": "InnoDB", 7 | "field_order": [ 8 | "full_name", 9 | "telegram_user_id", 10 | "telegram_username", 11 | "column_break_4", 12 | "user", 13 | "is_guest" 14 | ], 15 | "fields": [ 16 | { 17 | "fieldname": "full_name", 18 | "fieldtype": "Data", 19 | "label": "Full Name", 20 | "reqd": 1 21 | }, 22 | { 23 | "fieldname": "telegram_user_id", 24 | "fieldtype": "Data", 25 | "in_list_view": 1, 26 | "label": "Telegram User ID", 27 | "reqd": 1 28 | }, 29 | { 30 | "fieldname": "user", 31 | "fieldtype": "Link", 32 | "label": "User", 33 | "options": "User" 34 | }, 35 | { 36 | "fieldname": "telegram_username", 37 | "fieldtype": "Data", 38 | "label": "Telegram Username" 39 | }, 40 | { 41 | "fieldname": "column_break_4", 42 | "fieldtype": "Column Break" 43 | }, 44 | { 45 | "default": "0", 46 | "fieldname": "is_guest", 47 | "fieldtype": "Check", 48 | "label": "Is Guest" 49 | } 50 | ], 51 | "in_create": 1, 52 | "index_web_pages_for_search": 1, 53 | "links": [], 54 | "modified": "2021-08-15 06:29:30.857348", 55 | "modified_by": "Administrator", 56 | "module": "Frappe Telegram", 57 | "name": "Telegram User", 58 | "owner": "Administrator", 59 | "permissions": [ 60 | { 61 | "create": 1, 62 | "delete": 1, 63 | "email": 1, 64 | "export": 1, 65 | "print": 1, 66 | "read": 1, 67 | "report": 1, 68 | "role": "System Manager", 69 | "share": 1, 70 | "write": 1 71 | }, 72 | { 73 | "create": 1, 74 | "delete": 1, 75 | "email": 1, 76 | "export": 1, 77 | "print": 1, 78 | "read": 1, 79 | "report": 1, 80 | "role": "Telegram Bot Manager", 81 | "share": 1, 82 | "write": 1 83 | }, 84 | { 85 | "create": 1, 86 | "email": 1, 87 | "export": 1, 88 | "print": 1, 89 | "read": 1, 90 | "report": 1, 91 | "role": "Telegram Bot User", 92 | "share": 1, 93 | "write": 1 94 | } 95 | ], 96 | "sort_field": "modified", 97 | "sort_order": "DESC", 98 | "track_changes": 1 99 | } -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_user/telegram_user.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Leam Technology Systems and contributors 2 | # For license information, please see license.txt 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | class TelegramUser(Document): 8 | pass 9 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_user/test_telegram_user.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Leam Technology Systems and Contributors 2 | # See license.txt 3 | 4 | import frappe 5 | import unittest 6 | 7 | from frappe_telegram.utils.test_fixture import TestFixture 8 | 9 | 10 | class TelegramUserFixtures(TestFixture): 11 | 12 | def __init__(self): 13 | super().__init__() 14 | self.DEFAULT_DOCTYPE = "Telegram User" 15 | 16 | def make_fixtures(self): 17 | 18 | # TODO: How to make telegram user for testing? Mock values won't work 19 | 20 | # fix_1 = frappe.get_doc( 21 | # doctype="Telegram User", 22 | # telegram_user_id="10101010101010", 23 | # telegram_username="@testfixfullname", 24 | # full_name="test_fix_fullname" 25 | # ).insert(ignore_permissions=True) 26 | # frappe.db.commit() 27 | # self.add_document(fix_1) 28 | 29 | pass 30 | 31 | 32 | class TestTelegramUser(unittest.TestCase): 33 | pass 34 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_user_item/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_telegram/b135ac3daac73eb68d2591346a2200ac47ac4c30/frappe_telegram/frappe_telegram/doctype/telegram_user_item/__init__.py -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_user_item/telegram_user_item.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2021-07-24 10:22:15.939528", 4 | "doctype": "DocType", 5 | "editable_grid": 1, 6 | "engine": "InnoDB", 7 | "field_order": [ 8 | "telegram_user" 9 | ], 10 | "fields": [ 11 | { 12 | "fieldname": "telegram_user", 13 | "fieldtype": "Link", 14 | "in_list_view": 1, 15 | "label": "Telegram User", 16 | "options": "Telegram User", 17 | "reqd": 1 18 | } 19 | ], 20 | "index_web_pages_for_search": 1, 21 | "istable": 1, 22 | "links": [], 23 | "modified": "2021-07-24 10:22:15.939528", 24 | "modified_by": "Administrator", 25 | "module": "Frappe Telegram", 26 | "name": "Telegram User Item", 27 | "owner": "Administrator", 28 | "permissions": [], 29 | "sort_field": "modified", 30 | "sort_order": "DESC", 31 | "track_changes": 1 32 | } -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/doctype/telegram_user_item/telegram_user_item.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Leam Technology Systems and contributors 2 | # For license information, please see license.txt 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | class TelegramUserItem(Document): 8 | pass 9 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/page/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_telegram/b135ac3daac73eb68d2591346a2200ac47ac4c30/frappe_telegram/frappe_telegram/page/__init__.py -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/page/telegram_chat_view/__init__.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | 4 | @frappe.whitelist() 5 | def get_telegram_chat(chat_type, user=None, group=None): 6 | chat_type = chat_type.lower() 7 | if chat_type == "private": 8 | telegram_user_id = frappe.db.get_value("Telegram User", {"user": user}, "telegram_user_id") 9 | if not telegram_user_id: 10 | frappe.throw(frappe._("No TelegramUser account exists for user: {0}").format(user)) 11 | 12 | # Chat ID == Telegram User ID for private chats 13 | return telegram_user_id 14 | elif chat_type == "group": 15 | if not frappe.db.exists("Telegram Chat", group): 16 | frappe.throw(frappe._("Unknown Chat: {0}").format(group)) 17 | 18 | return group 19 | else: 20 | frappe.throw(frappe._("Unknown chat type")) 21 | 22 | 23 | @frappe.whitelist() 24 | def load_chat_rooms(limit_start, limit_page_length): 25 | return frappe.db.sql( 26 | f""" 27 | SELECT 28 | chat_id, title, type, last_message_on, last_message_content 29 | FROM `tabTelegram Chat` 30 | ORDER BY last_message_on DESC 31 | LIMIT {limit_start}, {limit_page_length} 32 | """, 33 | as_dict=1 34 | ) 35 | 36 | 37 | @frappe.whitelist() 38 | def load_chat_messages(chat_id, limit_start, limit_page_length): 39 | return reversed(frappe.db.sql( 40 | f""" 41 | SELECT 42 | name, content, from_user, from_bot, message_id, creation 43 | FROM `tabTelegram Message` 44 | WHERE chat=%(chat)s 45 | ORDER BY modified DESC 46 | LIMIT {limit_start}, {limit_page_length} 47 | """, 48 | {"chat": chat_id}, 49 | as_dict=1 50 | )) 51 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/page/telegram_chat_view/chat_list.html: -------------------------------------------------------------------------------- 1 |
2 | {% for (var i = 0; i < chat_list.length; i++) { var chat = chat_list[i]; %} 3 |
8 |
{{ frappe.avatar(chat.title, "avatar-medium") }}
9 |
10 |
{{ chat.title }}
11 |
{{ chat.last_message_content }}
12 |
13 |
14 | {{ comment_when(chat.last_message_on, true) }} 15 |
16 |
17 | {% } %} 18 |
19 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/page/telegram_chat_view/chat_message.html: -------------------------------------------------------------------------------- 1 |
2 | {% if (msg.from_user) %} 3 |
4 |
{{ frappe.avatar(msg.from_user) }}
5 |
6 | {{ msg.content }} 7 |
8 | {{ comment_when(msg.creation, true) }} 9 |
10 |
11 |
12 | {% } else { %} 13 |
14 |
15 | {{ msg.content }} 16 |
17 | {{ comment_when(msg.creation, true) }} 18 |
19 |
20 |
{{ frappe.avatar(msg.from_user) }}
21 |
22 | {% } %} 23 |
24 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/page/telegram_chat_view/chat_view.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
{{ frappe.avatar(chat.title) }}
4 |
{{ chat.title }}
5 |
6 |
7 | 8 |
9 |
10 | 11 | 12 |
13 |
14 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/page/telegram_chat_view/chat_view_date_chip.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{ date }} 4 |
5 |
6 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/page/telegram_chat_view/telegram_chat_view.js: -------------------------------------------------------------------------------- 1 | frappe.pages["telegram-chat-view"].on_page_load = function (wrapper) { 2 | const telegram_chat_view = new TelegramChatView(wrapper); 3 | $(wrapper).bind("show", () => { 4 | telegram_chat_view.showChatList(); 5 | }); 6 | window.telegram_chat_view = telegram_chat_view; 7 | }; 8 | 9 | class TelegramChatView { 10 | chat_list = []; 11 | chat_list_offset = 0 12 | chat_list_limit_length = 20 13 | chat_list_has_more = true 14 | 15 | currentChat = null 16 | chat_messages = [] 17 | chat_message_offset = 0 18 | chat_message_limit_length = 20 19 | chat_message_has_more = true 20 | 21 | constructor(wrapper) { 22 | this.page = frappe.ui.make_app_page({ 23 | parent: wrapper, 24 | title: "Telegram Chat", 25 | single_column: true, 26 | }); 27 | this.content = $(this.page.body); 28 | } 29 | 30 | async showChatList() { 31 | this.clearPage(); 32 | await this.loadChatList() 33 | this.chat_list_wrapper = $(frappe.render_template("chat_list", { 34 | chat_list: this.chat_list 35 | })).appendTo(this.content) 36 | $(this.chat_list_wrapper).find(".chat-room").click((e) => { 37 | this.openChat($(e.currentTarget).data("chatId")); 38 | }) 39 | } 40 | 41 | async loadChatList() { 42 | const r = await frappe.xcall( 43 | "frappe_telegram.frappe_telegram.page.telegram_chat_view.load_chat_rooms", { 44 | limit_start: this.chat_list_offset, 45 | limit_page_length: this.chat_list_limit_length 46 | }).catch(r => []) 47 | this.chat_list.push(...r); 48 | this.chat_list_offset += this.chat_list_limit_length 49 | } 50 | 51 | async loadChatMessages() { 52 | const r = await frappe.xcall( 53 | "frappe_telegram.frappe_telegram.page.telegram_chat_view.load_chat_messages", { 54 | chat_id: this.currentChat.chat_id, 55 | limit_start: this.chat_message_offset, 56 | limit_page_length: this.chat_message_limit_length 57 | }).catch(r => []) 58 | this.chat_messages.push(...r); 59 | this.chat_message_offset += this.chat_message_limit_length; 60 | return r; 61 | } 62 | 63 | clearPage() { 64 | $(this.content).empty(); 65 | } 66 | 67 | async openChat(chat_id) { 68 | this.chat_message_offset = 0; 69 | this.currentChat = this.chat_list.find(x => x.chat_id == chat_id) 70 | this.chat_messages = [] 71 | this.clearPage(); 72 | 73 | await this.loadChatMessages() 74 | // debugger 75 | this.currentChatView = $(frappe.render_template("chat_view", { chat: this.currentChat })).appendTo(this.content); 76 | const messagesContainer = $(this.currentChatView).find(".chat-messages") 77 | 78 | let date = null; 79 | for (const msg of this.chat_messages) { 80 | if (date != moment(msg.creation).format("YYYY-MM-DD")) { 81 | date = moment(msg.creation).format("YYYY-MM-DD"); 82 | $(frappe.render_template("chat_view_date_chip", { date: date })).appendTo(messagesContainer); 83 | } 84 | $(frappe.render_template("chat_message", { msg: msg })).appendTo(messagesContainer) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /frappe_telegram/frappe_telegram/page/telegram_chat_view/telegram_chat_view.json: -------------------------------------------------------------------------------- 1 | { 2 | "content": null, 3 | "creation": "2021-08-19 06:49:00.208544", 4 | "docstatus": 0, 5 | "doctype": "Page", 6 | "icon": "chat", 7 | "idx": 0, 8 | "modified": "2021-08-19 06:49:00.208544", 9 | "modified_by": "Administrator", 10 | "module": "Frappe Telegram", 11 | "name": "telegram-chat-view", 12 | "owner": "Administrator", 13 | "page_name": "telegram-chat-view", 14 | "roles": [ 15 | { 16 | "role": "Telegram Bot Manager" 17 | }, 18 | { 19 | "role": "Telegram Bot User" 20 | } 21 | ], 22 | "script": null, 23 | "standard": "Yes", 24 | "style": null, 25 | "system_page": 0, 26 | "title": "Telegram Chat" 27 | } -------------------------------------------------------------------------------- /frappe_telegram/handlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_telegram/b135ac3daac73eb68d2591346a2200ac47ac4c30/frappe_telegram/handlers/__init__.py -------------------------------------------------------------------------------- /frappe_telegram/handlers/auth/__init__.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe_telegram import Update, CallbackContext, Updater, MessageHandler 3 | from .credentials import login_handler, attach_conversation_handler 4 | 5 | AUTH_HANDLER_GROUP = -100 6 | 7 | 8 | def setup(telegram_bot, updater: Updater): 9 | attach_conversation_handler(telegram_bot=telegram_bot, updater=updater) 10 | updater.dispatcher.add_handler(MessageHandler(None, authenticate), group=AUTH_HANDLER_GROUP) 11 | 12 | 13 | def authenticate(update: Update, context: CallbackContext): 14 | frappe.set_user("Guest") 15 | # if not update.effective_user: 16 | # raise DispatcherHandlerStop() 17 | 18 | user = update.effective_user 19 | telegram_user = frappe.db.get_value("Telegram User", {"telegram_user_id": user.id}, "*") 20 | 21 | if telegram_user and telegram_user.user: 22 | # update.effective_message.reply_text("Logged in as " + telegram_user.user) 23 | frappe.set_user(telegram_user.user) 24 | return 25 | 26 | if telegram_user and telegram_user.is_guest: 27 | # Guest Telegram User 28 | return 29 | 30 | auth_handlers = frappe.get_hooks("telegram_auth_handlers") 31 | for cmd in reversed(auth_handlers): 32 | r = frappe.get_attr(cmd)(update=update, context=context) 33 | if r is not None: 34 | return r 35 | 36 | login_handler(update=update, context=context) 37 | -------------------------------------------------------------------------------- /frappe_telegram/handlers/auth/credentials.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.utils.password import check_password 3 | from frappe_telegram import ( 4 | Update, CallbackContext, 5 | Updater, DispatcherHandlerStop, CallbackQueryHandler, 6 | InlineKeyboardMarkup, ConversationHandler, MessageHandler, 7 | InlineKeyboardButton) 8 | from frappe_telegram.utils.conversation import collect_conversation_details 9 | from frappe.integrations.doctype.ldap_settings.ldap_settings import LDAPSettings 10 | 11 | LOGIN_CONV_ENTER = frappe.generate_hash() 12 | SIGNUP_CONV_ENTER = frappe.generate_hash() 13 | 14 | ENTERING_LOGIN_CREDENTIALS = frappe.generate_hash() 15 | ENTERING_SIGNUP_DETAILS = frappe.generate_hash() 16 | 17 | 18 | def attach_conversation_handler(telegram_bot, updater: Updater): 19 | from . import AUTH_HANDLER_GROUP 20 | # Login Conversation 21 | updater.dispatcher.add_handler(ConversationHandler( 22 | entry_points=[ 23 | CallbackQueryHandler(collect_login_credentials, pattern=f"^{LOGIN_CONV_ENTER}$"), 24 | CallbackQueryHandler(collect_signup_details, pattern=f"^{SIGNUP_CONV_ENTER}$"), 25 | ], 26 | states={ 27 | ENTERING_LOGIN_CREDENTIALS: [ 28 | MessageHandler(None, collect_login_credentials), ], 29 | ENTERING_SIGNUP_DETAILS: [ 30 | MessageHandler(None, collect_signup_details), ] 31 | }, 32 | fallbacks=[], 33 | ), group=AUTH_HANDLER_GROUP) 34 | 35 | 36 | def login_handler(update: Update, context: CallbackContext): 37 | text = "Hi, please authenticate first before you continue" 38 | buttons = [ 39 | [ 40 | InlineKeyboardButton(text='Login', callback_data=str(LOGIN_CONV_ENTER)), 41 | InlineKeyboardButton(text='Signup', callback_data=str(SIGNUP_CONV_ENTER)), 42 | ] 43 | ] 44 | keyboard = InlineKeyboardMarkup(buttons) 45 | update.effective_message.reply_text(text=text, reply_markup=keyboard) 46 | raise DispatcherHandlerStop() 47 | 48 | 49 | def collect_login_credentials(update: Update, context: CallbackContext): 50 | details = collect_conversation_details( 51 | key="login_details", 52 | meta=[ 53 | dict(key="email", label="Email", type="regex", options=r"^.+\@.+\..+$"), 54 | dict(key="pwd", label="Password", type="password"), ], 55 | update=update, 56 | context=context, 57 | ) 58 | if not details.get("_is_complete"): 59 | raise DispatcherHandlerStop(state=ENTERING_LOGIN_CREDENTIALS) 60 | 61 | user = verify_credentials(details.email, details.pwd) 62 | 63 | if frappe.db.get_value("LDAP Settings", "enabled") and not user.is_authenticated: 64 | ldap: LDAPSettings = frappe.get_doc("LDAP Settings") 65 | user = ldap.authenticate(details.email, details.pwd) 66 | if user: user["is_authenticated"] = True 67 | 68 | if user and user.is_authenticated: 69 | # Authenticated! Lets link FrappeUser & TelegramUser 70 | update.message.reply_text("You have successfully logged in as: " + user.name) 71 | context.telegram_user.db_set("user", user.name) 72 | raise DispatcherHandlerStop(state=ConversationHandler.END) 73 | else: 74 | update.message.reply_text("You have entered invalid credentials. Please try again") 75 | return collect_login_credentials(update, context) 76 | 77 | 78 | def collect_signup_details(update: Update, context: CallbackContext): 79 | details = collect_conversation_details( 80 | key="signup_details", 81 | meta=[ 82 | dict(label="First Name", key="first_name", type="str"), 83 | dict(label="Last Name", key="last_name", type="str"), 84 | dict(key="email", label="Email", type="regex", options=r"^.+\@.+\..+$"), 85 | dict(key="pwd", label="Password", type="password"), 86 | # dict(key="gender", label="Gender", type="select", options="Male\nFemale"), 87 | ], 88 | update=update, 89 | context=context, 90 | ) 91 | if not details.get("_is_complete"): 92 | raise DispatcherHandlerStop(state=ENTERING_SIGNUP_DETAILS) 93 | 94 | user = frappe.get_doc(dict( 95 | doctype="User", 96 | email=details.email, 97 | first_name=details.first_name, 98 | last_name=details.last_name, 99 | enabled=1, 100 | new_password=details.pwd, 101 | send_welcome_email=0, 102 | )) 103 | user.flags.telegram_user_signup = True 104 | user.insert(ignore_permissions=True) 105 | 106 | context.telegram_user.db_set("user", user.name) 107 | update.effective_chat.send_message( 108 | frappe._("You have successfully signed up as: {0}").format( 109 | user.name)) 110 | 111 | return ConversationHandler.END 112 | 113 | 114 | def verify_credentials(email, pwd): 115 | from frappe.core.doctype.user.user import User 116 | 117 | try: 118 | user = User.find_by_credentials(email, pwd) 119 | return user 120 | except AttributeError: 121 | users = frappe.db.get_all( 122 | 'User', fields=['name', 'enabled'], or_filters=[{"name": email}], limit=1) 123 | if not users: 124 | return 125 | 126 | user = users[0] 127 | user['is_authenticated'] = True 128 | try: 129 | check_password(user['name'], pwd) 130 | except frappe.AuthenticationError: 131 | user['is_authenticated'] = False 132 | 133 | return user 134 | -------------------------------------------------------------------------------- /frappe_telegram/handlers/frappe.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe_telegram import Update, CallbackContext 3 | 4 | 5 | def init_frappe(site: str): 6 | def _init(update: Update, context: CallbackContext): 7 | frappe.init(site=site) 8 | frappe.connect() 9 | 10 | return _init 11 | 12 | 13 | def dispose_frappe(update: Update, context: CallbackContext): 14 | frappe.db.commit() 15 | frappe.destroy() -------------------------------------------------------------------------------- /frappe_telegram/handlers/logging.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | import frappe 3 | from frappe_telegram import Update, CallbackContext, Message 4 | 5 | 6 | def handler(update: Update, context: CallbackContext): 7 | if not hasattr(update, "effective_user"): 8 | return 9 | context.telegram_bot = frappe.get_cached_doc("Telegram Bot", context.bot.telegram_bot) 10 | context.telegram_user = get_telegram_user(update) 11 | context.telegram_chat = get_telegram_chat(update, context) 12 | if context.telegram_chat and context.telegram_user: 13 | context.telegram_message = get_telegram_message( 14 | update, context.telegram_chat, context.telegram_user) 15 | 16 | 17 | def log_outgoing_message(telegram_bot: str, result: Union[bool, Message]): 18 | if not isinstance(result, Message): 19 | return 20 | 21 | if len(result.text): 22 | content = result.text 23 | elif result.document: 24 | content = "Sent file: " + result.document.file_name 25 | else: 26 | content = "" 27 | 28 | msg = frappe.get_doc( 29 | doctype="Telegram Message", 30 | chat=frappe.db.get_value("Telegram Chat", {"chat_id": result.chat_id}), 31 | message_id=result.message_id, 32 | content=content, from_bot=telegram_bot) 33 | msg.insert(ignore_permissions=True) 34 | 35 | 36 | def get_telegram_user(update: Update): 37 | telegram_user = update.effective_user 38 | user = frappe.db.get_value("Telegram User", {"telegram_user_id": telegram_user.id}) 39 | if user: 40 | return frappe.get_cached_doc("Telegram User", user) 41 | 42 | full_name = telegram_user.first_name 43 | if telegram_user.last_name: 44 | full_name += " " + telegram_user.last_name 45 | 46 | user = frappe.get_doc( 47 | doctype="Telegram User", 48 | telegram_user_id=telegram_user.id, 49 | telegram_username=telegram_user.username, 50 | full_name=full_name.strip()) 51 | user.insert(ignore_permissions=True) 52 | frappe.db.commit() 53 | 54 | return user 55 | 56 | 57 | def get_telegram_chat(update: Update, context: CallbackContext): 58 | """ 59 | We cannot get all the ChatMembers at once via TelegramBot API 60 | We will have to add new members as we see messages from them. 61 | """ 62 | if not update.effective_chat: 63 | return 64 | 65 | telegram_chat = update.effective_chat 66 | chat = frappe.db.get_value("Telegram Chat", {"chat_id": telegram_chat.id}) 67 | if chat: 68 | chat = frappe.get_cached_doc("Telegram Chat", chat) 69 | 70 | has_new_member = False 71 | for x in (("bot", context.telegram_bot), ("user", context.telegram_user)): 72 | table_df = f"{x[0]}s" 73 | field_df = f"telegram_{x[0]}" 74 | if chat.get(table_df, {field_df: x[1].name}): 75 | continue 76 | chat.append(table_df, {field_df: x[1].name}) 77 | 78 | has_new_member = True 79 | 80 | if has_new_member: 81 | chat.save(ignore_permissions=True) 82 | 83 | else: 84 | chat = frappe.get_doc( 85 | doctype="Telegram Chat", chat_id=telegram_chat.id, 86 | title=telegram_chat.title or telegram_chat.username or telegram_chat.first_name, 87 | type=telegram_chat.type, users=[], bots=[] 88 | ) 89 | chat.append("bots", {"telegram_bot": context.telegram_bot.name}) 90 | chat.append("users", {"telegram_user": context.telegram_user.name}) 91 | 92 | chat.insert(ignore_permissions=True) 93 | 94 | return chat 95 | 96 | 97 | def get_telegram_message(update: Update, telegram_chat, telegram_user): 98 | if not update.effective_message: 99 | return 100 | 101 | telegram_message = update.effective_message 102 | msg = frappe.get_doc( 103 | doctype="Telegram Message", chat=telegram_chat.name, message_id=telegram_message.message_id, 104 | content=telegram_message.text, from_user=telegram_user.name) 105 | msg.insert(ignore_permissions=True) 106 | 107 | return msg 108 | -------------------------------------------------------------------------------- /frappe_telegram/handlers/start.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe_telegram import (Updater, Update, CallbackContext, CommandHandler) 3 | 4 | 5 | def setup(telegram_bot, updater: Updater): 6 | updater.dispatcher.add_handler(CommandHandler("start", start_handler)) 7 | 8 | 9 | def start_handler(update: Update, context: CallbackContext): 10 | if frappe.get_hooks("telegram_start_handler"): 11 | return frappe.get_attr(frappe.get_hooks("telegram_start_handler")[-1])( 12 | update=update, context=context 13 | ) 14 | 15 | if frappe.session.user == "Guest": 16 | # Auth Handler will have kicked in and will not reach here 17 | return 18 | 19 | update.effective_chat.send_message(frappe._("Welcome!")) 20 | update.effective_chat.send_message( 21 | frappe._("You are logged in as: {0}").format( 22 | frappe.db.get_value("User", frappe.session.user, "first_name"))) 23 | -------------------------------------------------------------------------------- /frappe_telegram/hooks.py: -------------------------------------------------------------------------------- 1 | from . import __version__ as app_version # noqa 2 | 3 | app_name = "frappe_telegram" 4 | app_title = "Frappe Telegram" 5 | app_publisher = "Leam Technology Systems" 6 | app_description = "Telegram Bot Manager for Frappe" 7 | app_icon = "octicon octicon-file-directory" 8 | app_color = "blue" 9 | app_email = "info@leam.ae" 10 | app_license = "MIT" 11 | 12 | 13 | after_install = "frappe_telegram.setup.after_install" 14 | after_migrate = "frappe_telegram.setup.after_migrate" 15 | 16 | override_doctype_class = { 17 | "Notification": "frappe_telegram.override_doctype_class.TelegramNotification" 18 | } 19 | 20 | fixtures = [ 21 | { 22 | "dt": "Role", 23 | "filters": [["name", "in", ["Telegram Bot Manager", "Telegram Bot User"]]] 24 | }, 25 | { 26 | "dt": "Custom Field", 27 | "filters": [["app_name", "=", "frappe_telegram"]] 28 | }, 29 | ] 30 | 31 | telegram_bot_handler = [ 32 | "frappe_telegram.handlers.start.setup", 33 | "frappe_telegram.handlers.auth.setup", 34 | ] 35 | 36 | telegram_update_pre_processors = [ 37 | "frappe_telegram.handlers.logging.handler", 38 | ] 39 | 40 | # Includes in 41 | # ------------------ 42 | 43 | # include js, css files in header of desk.html 44 | # app_include_css = "/assets/frappe_telegram/css/frappe_telegram.css" 45 | # app_include_js = "/assets/frappe_telegram/js/frappe_telegram.js" 46 | 47 | # include js, css files in header of web template 48 | # web_include_css = "/assets/frappe_telegram/css/frappe_telegram.css" 49 | # web_include_js = "/assets/frappe_telegram/js/frappe_telegram.js" 50 | 51 | # include custom scss in every website theme (without file extension ".scss") 52 | # website_theme_scss = "frappe_telegram/public/scss/website" 53 | 54 | # include js, css files in header of web form 55 | # webform_include_js = {"doctype": "public/js/doctype.js"} 56 | # webform_include_css = {"doctype": "public/css/doctype.css"} 57 | 58 | # include js in page 59 | # page_js = {"page" : "public/js/file.js"} 60 | 61 | # include js in doctype views 62 | # doctype_js = {"doctype" : "public/js/doctype.js"} 63 | # doctype_list_js = {"doctype" : "public/js/doctype_list.js"} 64 | # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} 65 | # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} 66 | 67 | # Home Pages 68 | # ---------- 69 | 70 | # application home page (will override Website Settings) 71 | # home_page = "login" 72 | 73 | # website user home page (by Role) 74 | # role_home_page = { 75 | # "Role": "home_page" 76 | # } 77 | 78 | # Generators 79 | # ---------- 80 | 81 | # automatically create page for each record of this doctype 82 | # website_generators = ["Web Page"] 83 | 84 | # Installation 85 | # ------------ 86 | 87 | # before_install = "frappe_telegram.install.before_install" 88 | # after_install = "frappe_telegram.install.after_install" 89 | 90 | # Desk Notifications 91 | # ------------------ 92 | # See frappe.core.notifications.get_notification_config 93 | 94 | # notification_config = "frappe_telegram.notifications.get_notification_config" 95 | 96 | # Permissions 97 | # ----------- 98 | # Permissions evaluated in scripted ways 99 | 100 | # permission_query_conditions = { 101 | # "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions", 102 | # } 103 | # 104 | # has_permission = { 105 | # "Event": "frappe.desk.doctype.event.event.has_permission", 106 | # } 107 | 108 | # DocType Class 109 | # --------------- 110 | # Override standard doctype classes 111 | 112 | # override_doctype_class = { 113 | # "ToDo": "custom_app.overrides.CustomToDo" 114 | # } 115 | 116 | # Document Events 117 | # --------------- 118 | # Hook on document methods and events 119 | 120 | # doc_events = { 121 | # "*": { 122 | # "on_update": "method", 123 | # "on_cancel": "method", 124 | # "on_trash": "method" 125 | # } 126 | # } 127 | 128 | # Scheduled Tasks 129 | # --------------- 130 | 131 | # scheduler_events = { 132 | # "all": [ 133 | # "frappe_telegram.tasks.all" 134 | # ], 135 | # "daily": [ 136 | # "frappe_telegram.tasks.daily" 137 | # ], 138 | # "hourly": [ 139 | # "frappe_telegram.tasks.hourly" 140 | # ], 141 | # "weekly": [ 142 | # "frappe_telegram.tasks.weekly" 143 | # ] 144 | # "monthly": [ 145 | # "frappe_telegram.tasks.monthly" 146 | # ] 147 | # } 148 | 149 | # Testing 150 | # ------- 151 | 152 | # before_tests = "frappe_telegram.install.before_tests" 153 | 154 | # Overriding Methods 155 | # ------------------------------ 156 | # 157 | # override_whitelisted_methods = { 158 | # "frappe.desk.doctype.event.event.get_events": "frappe_telegram.event.get_events" 159 | # } 160 | # 161 | # each overriding function accepts a `data` argument; 162 | # generated from the base implementation of the doctype dashboard, 163 | # along with any modifications made in other Frappe apps 164 | # override_doctype_dashboards = { 165 | # "Task": "frappe_telegram.task.get_dashboard_data" 166 | # } 167 | 168 | # exempt linked doctypes from being automatically cancelled 169 | # 170 | # auto_cancel_exempted_doctypes = ["Auto Repeat"] 171 | 172 | 173 | # User Data Protection 174 | 175 | # Authentication and authorization 176 | # -------------------------------- 177 | 178 | # auth_hooks = [ 179 | # "frappe_telegram.auth.validate" 180 | # ] 181 | -------------------------------------------------------------------------------- /frappe_telegram/modules.txt: -------------------------------------------------------------------------------- 1 | Frappe Telegram -------------------------------------------------------------------------------- /frappe_telegram/override_doctype_class/__init__.py: -------------------------------------------------------------------------------- 1 | from .notification import TelegramNotification # noqa: F401 2 | -------------------------------------------------------------------------------- /frappe_telegram/override_doctype_class/notification.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.email.doctype.notification.notification import Notification, get_context 3 | from frappe_telegram.client import send_file, send_message 4 | from frappe_telegram.frappe_telegram.doctype.telegram_bot import DEFAULT_TELEGRAM_BOT_KEY 5 | 6 | 7 | """ 8 | Extending Frappe's Notification Channels. 9 | If you would like to extend the list of channels with your own custom channel in your custom app, 10 | Please do not forget to use the module function `send_telegram_notification` to not miss out on 11 | Telegram Notifications. 12 | """ 13 | 14 | 15 | class TelegramNotification(Notification): 16 | def send(self, doc): 17 | if self.channel != "Telegram": 18 | return super().send(doc) 19 | 20 | return send_telegram_notification( 21 | notification=self, 22 | doc=doc, 23 | ) 24 | 25 | 26 | def send_telegram_notification(notification, doc): 27 | if notification.channel != "Telegram": 28 | return 29 | 30 | context = get_context(doc) 31 | context = {"doc": doc, "alert": notification, "comments": None} 32 | if doc.get("_comments"): 33 | context["comments"] = frappe.parse_json(doc.get("_comments")) 34 | 35 | if notification.is_standard: 36 | notification.load_standard_properties(context) 37 | 38 | users = get_recipients( 39 | notification=notification, doc=doc, context=context 40 | ) 41 | 42 | message_text = frappe.render_template(notification.message, context) 43 | 44 | from_bot = notification.bot_to_send_from 45 | if not from_bot: 46 | from_bot = frappe.db.get_default(DEFAULT_TELEGRAM_BOT_KEY) 47 | 48 | if notification.attach_print: 49 | attachment = notification.get_attachment(doc)[0] 50 | attachment.pop("print_format_attachment") 51 | print_file = frappe.attach_print(**attachment) 52 | 53 | for user in users: 54 | if not frappe.db.exists("Telegram User", {"user": user}): 55 | continue 56 | 57 | frappe.enqueue( 58 | method=send_message, 59 | queue="short", 60 | message_text=message_text, 61 | user=user, 62 | from_bot=from_bot, 63 | parse_mode="HTML", 64 | enqueue_after_commit=True 65 | ) 66 | 67 | if notification.attach_print: 68 | frappe.enqueue( 69 | method=send_file, 70 | queue="short", 71 | file=print_file.get("fcontent"), 72 | filename=print_file.get("fname"), 73 | user=user, 74 | from_bot=from_bot, 75 | enqueue_after_commit=True 76 | ) 77 | 78 | 79 | def get_recipients(notification, doc, context): 80 | recipients = [] 81 | 82 | for recipient in notification.recipients: 83 | if recipient.condition: 84 | if not frappe.safe_eval(recipient.condition, None, context): 85 | continue 86 | 87 | if recipient.receiver_by_document_field: 88 | fields = recipient.receiver_by_document_field.split(',') 89 | # fields from child table 90 | if len(fields) > 1: 91 | for d in doc.get(fields[1]): 92 | user = d.get(fields[0]) 93 | if frappe.db.exists("User", user): 94 | recipients.append(user) 95 | # field from parent doc 96 | else: 97 | user = doc.get(fields[0]) 98 | if frappe.db.exists("User", user): 99 | recipients.append(user) 100 | 101 | if recipient.receiver_by_role: 102 | users = [x.parent for x in frappe.get_all( 103 | "Has Role", 104 | filters={"role": recipient.receiver_by_role, "parenttype": "User"}, 105 | fields=["parent"])] 106 | recipients.extend(users) 107 | 108 | return list(set(recipients)) 109 | -------------------------------------------------------------------------------- /frappe_telegram/patches.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_telegram/b135ac3daac73eb68d2591346a2200ac47ac4c30/frappe_telegram/patches.txt -------------------------------------------------------------------------------- /frappe_telegram/setup/__init__.py: -------------------------------------------------------------------------------- 1 | from .notification import add_telegram_notification_channel 2 | 3 | 4 | def after_install(): 5 | add_telegram_notification_channel() 6 | 7 | 8 | def after_migrate(): 9 | add_telegram_notification_channel() 10 | -------------------------------------------------------------------------------- /frappe_telegram/setup/notification.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | 4 | def add_telegram_notification_channel(): 5 | """ 6 | This will add Telegram to existing list of Channels. 7 | This will not overwrite other custom channels that came in via custom-apps 8 | """ 9 | meta = frappe.get_meta('Notification') 10 | channels = meta.get_field("channel").options.split("\n") 11 | if "Telegram" in channels: 12 | return 13 | 14 | channels.append("Telegram") 15 | frappe.get_doc({ 16 | "doctype": "Property Setter", 17 | "doctype_or_field": "DocField", 18 | "doc_type": "Notification", 19 | "field_name": "channel", 20 | "property": "options", 21 | "value": "\n".join(channels), 22 | "property_type": "Small Text" 23 | }).insert() 24 | -------------------------------------------------------------------------------- /frappe_telegram/templates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_telegram/b135ac3daac73eb68d2591346a2200ac47ac4c30/frappe_telegram/templates/__init__.py -------------------------------------------------------------------------------- /frappe_telegram/templates/pages/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leam-tech/frappe_telegram/b135ac3daac73eb68d2591346a2200ac47ac4c30/frappe_telegram/templates/pages/__init__.py -------------------------------------------------------------------------------- /frappe_telegram/utils/bench.py: -------------------------------------------------------------------------------- 1 | import os 2 | from frappe.utils import get_bench_path, get_site_path # noqa 3 | 4 | 5 | def get_bench_name(): 6 | return os.path.basename(os.path.abspath(get_bench_path())) 7 | -------------------------------------------------------------------------------- /frappe_telegram/utils/conversation.py: -------------------------------------------------------------------------------- 1 | import re 2 | from telegram import ReplyKeyboardMarkup, KeyboardButton 3 | 4 | import frappe 5 | from frappe_telegram import Update, CallbackContext 6 | 7 | 8 | def collect_conversation_details(key, meta, update: Update, context: CallbackContext): 9 | """ 10 | A conversation-utility function to collect a set of details that 11 | conform to specific validations 12 | 13 | Meta [ 14 | { key: "email", label: "Email", type: "email", reqd: True }, 15 | { key: "pwd", label: "Password", type: "password", prompt: "Please provide your password" }, 16 | { key: "mobile_no", type: "mobile_no" }, 17 | { key: "num_cars", type: "int" | "integer" }, 18 | { key: "num_kg", type: "flt" | "float" }, 19 | { key: "gender", type: "select", options: "Male\nFemale", reqd: False }, 20 | { key: "custom-id", type: "regex", options: r"^[0-6]{5,8}$" } 21 | ] 22 | 23 | Supported Meta Types: 24 | - str | string 25 | - password (automatically masks the user input) 26 | - int | integer 27 | - flt | float 28 | - select (provide options delimited by \n) 29 | - regex 30 | 31 | > All details are assumed to be reqd unless explicitly stated as optional 32 | > You can specify a prompt for the user to enter the value 33 | 34 | Args: 35 | key: A unique name for the set of details being collected 36 | meta: A list of individual detail to collect. 37 | update: Current TelegramUpdate 38 | context: Current TelegramContext 39 | 40 | Returns: 41 | An object with collected details and key _is_complete=1 to determine completion 42 | """ 43 | 44 | # Initialize 45 | if key not in context.user_data: 46 | context.user_data[key] = frappe._dict( 47 | _is_complete=False, 48 | _last_detail_asked=None, 49 | _next_detail_to_ask=meta[0].get("key"), 50 | ) 51 | 52 | meta_order = [None] + [m.get("key") for m in meta] + [None] 53 | meta_dict = { 54 | m.get("key"): frappe._dict(m).update(dict( 55 | _next_detail_to_ask=meta_order[meta_order.index(m.get("key")) + 1])) 56 | for m in meta} 57 | details = context.user_data[key] 58 | 59 | if details._is_complete: 60 | return details 61 | 62 | # Collect 63 | if details._last_detail_asked: 64 | detail_meta = meta_dict[details._last_detail_asked] 65 | 66 | validation_info = _validate_conversation_detail(detail_meta, update, context) 67 | if not validation_info.validated: 68 | update.effective_chat.send_message(validation_info.err_message) 69 | update.effective_chat.send_message(frappe._("Please try again")) 70 | return details 71 | 72 | details[detail_meta.key] = validation_info.value 73 | details._next_detail_to_ask = detail_meta._next_detail_to_ask 74 | if not details._next_detail_to_ask: 75 | details._is_complete = True 76 | 77 | if details._next_detail_to_ask: 78 | detail_meta = meta_dict[details._next_detail_to_ask] 79 | prompt = detail_meta.get("prompt") or "Please provide your {}".format(detail_meta.label) 80 | reply_markup = None 81 | 82 | if detail_meta.type == "select": 83 | buttons = [] 84 | # Custom Keyboards are better than Inline-Keyboards in this scenario 85 | for option in detail_meta.options.split("\n"): 86 | buttons.append(KeyboardButton(option)) 87 | reply_markup = ReplyKeyboardMarkup([buttons], one_time_keyboard=True) 88 | 89 | update.effective_chat.send_message(frappe._(prompt), reply_markup=reply_markup) 90 | 91 | details._last_detail_asked = details._next_detail_to_ask 92 | details._next_detail_to_ask = None 93 | 94 | # Return 95 | if details._is_complete: 96 | # The handler should carry this information safely from now on 97 | del context.user_data[key] 98 | 99 | return details 100 | 101 | 102 | def _validate_conversation_detail(detail_meta, update, context): 103 | info = frappe._dict( 104 | validated=True, 105 | value=None, 106 | err_message=None, 107 | ) 108 | 109 | # Check select 110 | if detail_meta.type == "select": 111 | options = detail_meta.options.split("\n") 112 | if update.message.text not in options: 113 | info.err_message = frappe._("Please select from the given options") 114 | return info.update(dict(validated=False)) 115 | 116 | return info.update(dict(value=str(update.message.text))) 117 | 118 | # Check reqd 119 | if not update.message.text and detail_meta.reqd and detail_meta.type != "select": 120 | info.err_message = frappe._("This is a required field") 121 | return info.update(dict(validated=False)) 122 | 123 | # The types left all come under update.message.text 124 | if not update.message.text: 125 | return info.update(dict(validated=True)) 126 | 127 | text = update.message.text 128 | # Check str | string 129 | if detail_meta.type in ["str", "string"]: 130 | return info.update(dict(value=str(text))) 131 | # Check int | integer 132 | elif detail_meta.type in ["int", "integer"]: 133 | try: 134 | return info.update(dict(value=int(text))) 135 | except ValueError: 136 | info.err_message = frappe._("Please enter a valid integer") 137 | return info.update(dict(validated=False)) 138 | # Check flt | float 139 | elif detail_meta.type in ["flt", "float"]: 140 | try: 141 | return info.update(dict(value=float(text))) 142 | except ValueError: 143 | info.err_message = frappe._("Please enter a valid float") 144 | return info.update(dict(validated=False)) 145 | # Check regex 146 | elif detail_meta.type == "regex": 147 | if re.match(detail_meta.options, text): 148 | return info.update(dict(value=text)) 149 | else: 150 | info.err_message = frappe._("Please enter a valid {}").format(detail_meta.label) 151 | return info.update(dict(validated=False)) 152 | # Check Password 153 | elif detail_meta.type == "password": 154 | context.telegram_message.mark_as_password() 155 | return info.update(dict(value=text)) 156 | else: 157 | info.err_message = frappe._("Invalid type") 158 | 159 | return info.update(dict(validated=False)) 160 | -------------------------------------------------------------------------------- /frappe_telegram/utils/formatting.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def strip_unsupported_html_tags(txt: str) -> str: 5 | """ 6 | Only a set of formatting options are supported 7 | https://core.telegram.org/bots/api#formatting-options 8 | """ 9 | tags_supported = [ 10 | "b", "strong", # Bold 11 | "i", "em", # Italics 12 | "u", "ins", # Underline 13 | "s", "strike", "del", # Strikethrough 14 | "a", # Links 15 | "pre", "code", # Code 16 | ] 17 | 18 | # Replace Unsupported Tags 19 | """ 20 | < /? # Permit closing tags 21 | (?! 22 | (?: em | strong ) # List of tags to avoid matching 23 | \b # Word boundary avoids partial word matches 24 | ) 25 | [a-z] # Tag name initial character must be a-z 26 | (?: [^>"'] # Any character except >, ", or ' 27 | | "[^"]*" # Double-quoted attribute value 28 | | '[^']*' # Single-quoted attribute value 29 | )* 30 | > 31 | 32 | https://www.oreilly.com/library/view/regular-expressions-cookbook/9781449327453/ch09s04.html 33 | """ 34 | r = """"']|"[^"]*"|'[^']*')*>""".format("|".join(tags_supported)) 35 | txt = re.sub( 36 | pattern=r, 37 | repl="", 38 | string=txt) 39 | 40 | # & & 41 | txt = txt.replace("&", "&") 42 | 43 | # < < 44 | txt = re.sub( 45 | pattern=r"<(?!(?:[a-z]+|\/[a-z]+)\b)", 46 | repl="<", 47 | string=txt, 48 | ) 49 | 50 | # > $gt; 51 | # Seems to go through well 52 | 53 | return txt 54 | -------------------------------------------------------------------------------- /frappe_telegram/utils/nginx.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | import subprocess 5 | import crossplane 6 | 7 | import frappe 8 | from .bench import get_bench_path, get_bench_name 9 | 10 | 11 | def add_nginx_config( 12 | telegram_bot: str, 13 | webhook_url=None, 14 | webhook_port=None, 15 | webhook_nginx_path=None): 16 | if not frappe.db.exists("Telegram Bot", telegram_bot): 17 | frappe.throw("TelegramBot: {} not found".format(telegram_bot)) 18 | 19 | telegram_bot = frappe.get_doc("Telegram Bot", telegram_bot) 20 | if not webhook_url: 21 | webhook_url = telegram_bot.webhook_url 22 | if not webhook_port: 23 | webhook_port = telegram_bot.webhook_port 24 | if not webhook_nginx_path: 25 | webhook_nginx_path = telegram_bot.webhook_nginx_path 26 | 27 | config = get_parsed_bench_nginx_config() 28 | 29 | remove_upstream(config, telegram_bot=telegram_bot.name) 30 | remove_location(config, telegram_bot=telegram_bot.name) 31 | add_upstream(config, telegram_bot=telegram_bot.name, port=webhook_port) 32 | add_location(config, telegram_bot=telegram_bot.name, path=webhook_nginx_path) 33 | 34 | nginx_raw = crossplane.build(config["parsed"]) 35 | nginx_raw = re.sub(r"}\n([^\n])", r"}\n\n\1", nginx_raw) 36 | write_config(nginx_raw) 37 | 38 | 39 | def remove_nginx_config(telegram_bot: str): 40 | config = get_parsed_bench_nginx_config() 41 | 42 | remove_upstream(config, telegram_bot=telegram_bot) 43 | remove_location(config, telegram_bot=telegram_bot) 44 | 45 | nginx_raw = crossplane.build(config["parsed"]) 46 | nginx_raw = re.sub(r"}\n([^\n])", r"}\n\n\1", nginx_raw) 47 | write_config(nginx_raw) 48 | 49 | 50 | def add_upstream(config: dict, telegram_bot: str, port: int): 51 | directive = dict( 52 | directive="upstream", args=[get_telegram_upstream_name(telegram_bot=telegram_bot)], 53 | block=[ 54 | dict(directive="#", comment=f" TelegramBot: {telegram_bot}", line=1), 55 | dict(directive="server", args=["127.0.0.1:" + str(port), "fail_timeout=0"]) 56 | ] 57 | ) 58 | 59 | # Assuming we have two upstream blocks already (gunicorn, socketio) 60 | insert_at = config["parsed"].index( 61 | list(filter(lambda x: x["directive"] == "upstream", config["parsed"]))[-1]) + 1 62 | config["parsed"].insert(insert_at, directive) 63 | 64 | 65 | def remove_upstream(config: dict, telegram_bot: str): 66 | upstream_name = get_telegram_upstream_name(telegram_bot=telegram_bot) 67 | config["parsed"] = list(filter( 68 | lambda x: x["directive"] != "upstream" or x["args"][0] != upstream_name, config["parsed"])) 69 | 70 | 71 | def add_location(config, telegram_bot: str, path: str): 72 | directive = dict( 73 | directive="location", args=[path], 74 | block=[ 75 | dict(directive="#", comment=f" TelegramBot: {telegram_bot}", line=1), 76 | dict(directive="proxy_pass", 77 | args=[f"http://{get_telegram_upstream_name(telegram_bot=telegram_bot)}"]) 78 | ] 79 | ) 80 | 81 | server_directive = next(filter(lambda x: x["directive"] == "server", config["parsed"])) 82 | webserver_location = next( 83 | filter(lambda x: x["args"][0] == "@webserver", 84 | server_directive["block"])) 85 | insert_at = server_directive["block"].index(webserver_location) + 1 86 | server_directive["block"].insert(insert_at, directive) 87 | 88 | 89 | def remove_location(config, telegram_bot: str): 90 | upstream_loc = "http://" + get_telegram_upstream_name(telegram_bot=telegram_bot) 91 | server_directive = next(filter(lambda x: x["directive"] == "server", config["parsed"])) 92 | location_directives = filter(lambda x: x["directive"] == "location", server_directive["block"]) 93 | for directive in location_directives: 94 | if not len(directive.get("block", [])): 95 | continue 96 | 97 | for block in directive["block"]: 98 | if block["directive"] == "proxy_pass" and block["args"][0] == upstream_loc: 99 | server_directive["block"].remove(directive) 100 | break 101 | 102 | 103 | def write_config(content): 104 | local_file = get_nginx_config_path() 105 | with open(local_file, "w") as f: 106 | f.write(content) 107 | 108 | 109 | def get_parsed_bench_nginx_config(): 110 | """ 111 | Returns a JSON representation of local bench nginx conf if it exists 112 | """ 113 | root_path = get_nginx_root_config_path() 114 | local_path = os.path.normpath(get_nginx_config_path()) 115 | 116 | nginx_parsed = crossplane.parse( 117 | filename=root_path, 118 | comments=True, # we need the comments to be intact 119 | ) 120 | if nginx_parsed["status"] != "ok": 121 | print(nginx_parsed) 122 | print("Please fix your current nginx-config") 123 | return 124 | 125 | # Now, we parsed the whole nginx-config on the system. 126 | # We have to narrow down to our bench-nginx config file 127 | for parsed_file in nginx_parsed["config"]: 128 | file_path = parsed_file["file"] 129 | if os.path.islink(file_path): 130 | file_path = os.readlink(file_path) 131 | 132 | if os.path.normpath(file_path) == local_path: 133 | # Found it! 134 | return parsed_file 135 | 136 | print("We were not able to find the current bench's nginx file" + 137 | " included within {}".format(root_path)) 138 | print("Please setup production to have it done automatically by bench") 139 | frappe.throw("Failed getting parsed nginx config") 140 | 141 | 142 | def get_nginx_root_config_path() -> str: 143 | """ 144 | Returns the main nginx-config path 145 | This is usually /etc/nginx/nginx.conf 146 | It is read by executing `nginx -V` and checking the value of --conf-path 147 | """ 148 | 149 | nginx_verbose = subprocess.check_output( 150 | ["nginx", "-V"], stderr=subprocess.STDOUT).decode(sys.stdout.encoding).strip() 151 | match = re.match(r"^.*(--conf-path=([\.\/a-zA-Z]+)).*$", nginx_verbose, flags=re.S) 152 | if match: 153 | return match.group(2) 154 | 155 | frappe.throw( 156 | "Please make sure you nginx installed" + 157 | " and is accessible by current user to execute `nginx -V`") 158 | 159 | 160 | def get_nginx_config_path() -> str: 161 | """ 162 | Returns the nginx-config path of current bench 163 | """ 164 | nginx_conf = os.path.join(get_bench_path(), "config", "nginx.conf") 165 | if not os.path.exists(nginx_conf): 166 | frappe.throw( 167 | "Please generate nginx file using 'bench setup nginx' before this action") 168 | 169 | return nginx_conf 170 | 171 | 172 | def get_telegram_upstream_name(telegram_bot): 173 | """ 174 | Gets the nginx-upstream name for telegram-bot 175 | eg: frappe-bench-random-chat 176 | """ 177 | return f"{get_bench_name()}-{frappe.scrub(telegram_bot).replace('_', '-')}" 178 | -------------------------------------------------------------------------------- /frappe_telegram/utils/overrides.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from telegram.ext import Dispatcher, ExtBot, Updater 3 | from frappe_telegram.handlers.logging import log_outgoing_message 4 | 5 | 6 | """ 7 | For each incoming Update, we will have frappe initialized. 8 | We will override Dispatcher and Bot instance 9 | - Dispatcher is overridden for initializing frappe for each incoming Update 10 | - Bot is overridden for loggign outgoing messages 11 | NOTE: 12 | Class attributes that starts with __ is Mangled 13 | """ 14 | 15 | 16 | class FrappeTelegramExtBot(ExtBot): 17 | 18 | # The name of the active FrappeTelegramBot 19 | telegram_bot: str 20 | 21 | @classmethod 22 | def make(cls, telegram_bot: str, updater: Updater): 23 | bot = updater.bot 24 | new_bot = cls( 25 | bot.token, 26 | bot.base_url, 27 | request=updater._request, 28 | defaults=bot.defaults, 29 | arbitrary_callback_data=bot.arbitrary_callback_data, 30 | ) 31 | new_bot.base_url = bot.base_url 32 | new_bot.base_file_url = bot.base_file_url 33 | new_bot.private_key = bot.private_key 34 | new_bot.telegram_bot = telegram_bot 35 | return new_bot 36 | 37 | def _message(self, *args, **kwargs): 38 | result = super()._message(*args, **kwargs) 39 | log_outgoing_message(self.telegram_bot, result) 40 | return result 41 | 42 | 43 | class FrappeTelegramDispatcher(Dispatcher): 44 | 45 | # The Frappe Site 46 | site: str 47 | 48 | @classmethod 49 | def make(cls, site, updater): 50 | dispatcher = updater.dispatcher 51 | return cls( 52 | site, 53 | updater.bot, 54 | updater.update_queue, 55 | job_queue=updater.job_queue, 56 | workers=dispatcher.workers, 57 | # Class attributes that starts with __ is Mangled 58 | exception_event=updater._Updater__exception_event, 59 | persistence=dispatcher.persistence, 60 | use_context=dispatcher.use_context, 61 | context_types=dispatcher.context_types, 62 | ) 63 | 64 | def __init__(self, site, *args, **kwargs): 65 | self.site = site 66 | print("Using Patched Frappe Telegram Dispatcher ✅") 67 | return super().__init__(*args, **kwargs) 68 | 69 | def process_update(self, update: object) -> None: 70 | try: 71 | frappe.init(site=self.site) 72 | frappe.flags.in_telegram_update = True 73 | frappe.connect() 74 | super().process_update(update=update) 75 | except BaseException: 76 | frappe.log_error(title="Telegram Process Update Error", message=frappe.get_traceback()) 77 | finally: 78 | frappe.db.commit() 79 | frappe.destroy() 80 | -------------------------------------------------------------------------------- /frappe_telegram/utils/supervisor.py: -------------------------------------------------------------------------------- 1 | import os 2 | import configparser 3 | 4 | import frappe 5 | from .bench import get_bench_path, get_bench_name, get_site_path 6 | 7 | """ 8 | supervisor.conf follows configparser format (Win-INI style) 9 | We will have a new process group for all the telegram-bots 10 | """ 11 | 12 | 13 | def add_supervisor_entry( 14 | telegram_bot, polling=False, poll_interval=0, 15 | webhook=False, webhook_port=0, webhook_url=None): 16 | 17 | # Validate telegram_bot exists 18 | if not frappe.db.exists("Telegram Bot", telegram_bot): 19 | frappe.throw("TelegramBot: {} do not exist".format(telegram_bot)) 20 | 21 | config = get_supervisor_config() 22 | 23 | # Program 24 | program_name, program = get_bot_program( 25 | config=config, telegram_bot=telegram_bot, polling=polling, poll_interval=poll_interval, 26 | webhook=webhook, webhook_port=webhook_port, webhook_url=webhook_url) 27 | 28 | config[program_name] = program 29 | 30 | # Bot Group 31 | group_name = get_bot_group_name() 32 | bot_programs = [] 33 | if group_name in config: 34 | bot_programs = config[group_name]["programs"].split(",") 35 | 36 | group_program_name = program_name.replace("program:", "") 37 | if group_program_name not in bot_programs: 38 | bot_programs.append(group_program_name) 39 | config[group_name] = {"programs": ",".join(bot_programs)} 40 | 41 | write_supervisor_config(config) 42 | 43 | 44 | def remove_supervisor_entry(telegram_bot): 45 | config = get_supervisor_config() 46 | 47 | # Remove Program Entry 48 | program_name = get_bot_program_name(telegram_bot) 49 | if program_name in config: 50 | del config[program_name] 51 | 52 | # Remove Group Entry 53 | group_name = get_bot_group_name() 54 | bot_programs = [] 55 | if group_name in config: 56 | bot_programs = config[group_name]["programs"].split(",") 57 | 58 | group_program_name = program_name.replace("program:", "") 59 | if group_program_name in bot_programs: 60 | bot_programs.remove(group_program_name) 61 | 62 | if len(bot_programs): 63 | config[group_name] = {"programs": ",".join(bot_programs)} 64 | elif group_name in config: 65 | del config[group_name] 66 | 67 | write_supervisor_config(config) 68 | 69 | 70 | def get_bot_program(config, telegram_bot, **kwargs): 71 | program_name = get_bot_program_name(telegram_bot) 72 | logs = get_bot_log_paths(telegram_bot) 73 | 74 | command = f"bench --site {frappe.local.site} telegram start-bot " + telegram_bot 75 | for k, v in kwargs.items(): 76 | if not v: 77 | continue 78 | k = k.replace("_", "-") 79 | if isinstance(v, bool): 80 | command += f" --{k}" 81 | else: 82 | command += f" --{k} {v}" 83 | 84 | program = { 85 | "command": command, 86 | "priority": 1, 87 | "autostart": "true", 88 | "autorestart": "true", 89 | "stdout_logfile": logs[0], 90 | "stderr_logfile": logs[1], 91 | "user": guess_user_from_web_program(config=config), 92 | "directory": os.path.abspath(get_site_path("..")) 93 | } 94 | 95 | return program_name, program 96 | 97 | 98 | def get_supervisor_config() -> configparser.ConfigParser(): 99 | supervisor_conf = os.path.join(get_bench_path(), "config", "supervisor.conf") 100 | if not os.path.exists(supervisor_conf): 101 | raise Exception( 102 | "Please generate supervisor file using 'bench setup supervisor' before this action") 103 | 104 | config = configparser.ConfigParser() 105 | config.read(supervisor_conf) 106 | 107 | return config 108 | 109 | 110 | def write_supervisor_config(config: configparser.ConfigParser): 111 | supervisor_conf = os.path.join(get_bench_path(), "config", "supervisor.conf") 112 | with open(supervisor_conf, 'w') as configfile: 113 | config.write(configfile) 114 | 115 | # TODO: Ask to restart supervisorctl or do automatically 116 | 117 | 118 | def guess_user_from_web_program(config: configparser.ConfigParser): 119 | web_program_name = f"program:{get_bench_name()}-frappe-web" 120 | if web_program_name not in config: 121 | return 122 | 123 | return config[web_program_name]["user"] 124 | 125 | 126 | def get_bot_log_paths(telegram_bot): 127 | logs_path = os.path.abspath(os.path.join("..", "logs")) 128 | stdout = os.path.join(logs_path, f"bot-{telegram_bot}.log") 129 | stderr = os.path.join(logs_path, f"bot-{telegram_bot}.error.log") 130 | 131 | return stdout, stderr 132 | 133 | 134 | def get_bot_program_name(telegram_bot): 135 | return f"program:{get_bench_name()}-telegram-bot-{telegram_bot}" 136 | 137 | 138 | def get_bot_group_name(): 139 | return f"group:{get_bench_name()}-telegram-bots" 140 | -------------------------------------------------------------------------------- /frappe_telegram/utils/test_fixture.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | 4 | class TestFixture(): 5 | """ 6 | A simple and responsible Fixture Manager 7 | :param DEFAULT_DOCTYPE: The doctype that will be used as default 8 | :param dependent_fixtures: A list of classes that will be used as dependent fixtures 9 | :param fixtures: A dict of already generated fixtures 10 | :param duplicate: A flag to indicate if the fixture is already set up 11 | """ 12 | 13 | def __init__(self): 14 | self.DEFAULT_DOCTYPE = None 15 | self.TESTER_USER = frappe.session.user 16 | self.dependent_fixtures = [] 17 | self.fixtures = frappe._dict() 18 | self.duplicate = False 19 | 20 | def setUp(self, skip_fixtures=False, skip_dependencies=False): 21 | """ 22 | Set up the fixtures. Fixture will not be duplicated if already set up. 23 | 24 | Args: 25 | skip_fixtures (bool): Skip the fixture creation 26 | skip_dependencies (bool): Skip the dependency creation 27 | 28 | Returns: 29 | None 30 | """ 31 | 32 | if frappe.session.user != self.TESTER_USER: 33 | frappe.set_user(self.TESTER_USER) 34 | 35 | if self.isSetUp(): 36 | self.duplicate = True 37 | og: TestFixture = self.get_locals_obj()[self.__class__.__name__] 38 | self.fixtures = getattr(og, "fixtures", frappe._dict()) 39 | self._dependent_fixture_instances = getattr( 40 | og, "_dependent_fixture_instances", []) 41 | return 42 | if not skip_dependencies: 43 | self.make_dependencies() 44 | 45 | if not skip_fixtures: 46 | self.make_fixtures() 47 | self.get_locals_obj()[self.__class__.__name__] = self 48 | 49 | def make_dependencies(self): 50 | """ 51 | Invokes setUp on dependent fixture classes 52 | """ 53 | if not self.dependent_fixtures or not len(self.dependent_fixtures): 54 | return 55 | self._dependent_fixture_instances = [] 56 | 57 | for d_class in self.dependent_fixtures: 58 | d = d_class() 59 | d.setUp() 60 | self._dependent_fixture_instances.append(d) 61 | 62 | def destroy_dependencies(self): 63 | """ 64 | Invokes tearDown on dependent fixture classes 65 | """ 66 | if not self.dependent_fixtures or not len(self.dependent_fixtures): 67 | return 68 | 69 | # Reverse teardown 70 | 71 | for i in range(len(getattr(self, "_dependent_fixture_instances", []))): 72 | d = self._dependent_fixture_instances[len( 73 | self._dependent_fixture_instances) - i - 1] 74 | d.tearDown() 75 | 76 | self._dependent_fixture_instances = [] 77 | 78 | def get_dependencies(self, doctype): 79 | """ 80 | Get documents of specific doctype that this fixture depends on 81 | """ 82 | if not self._dependent_fixture_instances: 83 | return [] 84 | 85 | for d in self._dependent_fixture_instances: 86 | if doctype in d.fixtures: 87 | return d.fixtures[doctype] 88 | 89 | return [] 90 | 91 | def make_fixtures(self): 92 | """ 93 | Please override this function to make your own make_fixture implementation 94 | And call self.add_document to keep track of the created fixtures for cleaning up later 95 | """ 96 | pass 97 | 98 | def delete_fixtures(self): 99 | """ 100 | Goes through each fixture generated and deletes it 101 | """ 102 | for dt, docs in self.fixtures.items(): 103 | meta = frappe.get_meta(dt) 104 | for doc in docs: 105 | if not frappe.db.exists(dt, doc.name) or doc is None: 106 | continue 107 | 108 | doc.reload() 109 | if doc.docstatus == 1: 110 | doc.docstatus = 2 111 | doc.save(ignore_permissions=True) 112 | 113 | frappe.delete_doc( 114 | dt, 115 | doc.name, 116 | force=not meta.is_submittable, 117 | ignore_permissions=True 118 | ) 119 | 120 | self.fixtures = frappe._dict() 121 | 122 | def __getitem__(self, doctype_idx): 123 | if isinstance(doctype_idx, int): 124 | if not self.DEFAULT_DOCTYPE: 125 | raise Exception("DEFAULT_DOCTYPE is not defined") 126 | return self.fixtures[self.DEFAULT_DOCTYPE][doctype_idx] 127 | 128 | return self.fixtures[doctype_idx] 129 | 130 | def __len__(self): 131 | if not self.DEFAULT_DOCTYPE: 132 | raise Exception("DEFAULT_DOCTYPE is not defined") 133 | 134 | return len(self.fixtures.get(self.DEFAULT_DOCTYPE, [])) 135 | 136 | def tearDown(self): 137 | """ 138 | Tear Down all generated fixtures 139 | """ 140 | if frappe.session.user != self.TESTER_USER: 141 | frappe.set_user(self.TESTER_USER) 142 | 143 | if self.duplicate: 144 | self.fixtures = frappe._dict() 145 | self._dependent_fixture_instances = [] 146 | self.duplicate = False 147 | return 148 | self.delete_fixtures() 149 | self.destroy_dependencies() 150 | self.get_locals_obj()[self.__class__.__name__] = None 151 | 152 | def add_document(self, doc): 153 | """ 154 | Call this after creation of every fixture to keep track of it for deletion later 155 | """ 156 | if doc.doctype not in self.fixtures: 157 | self.fixtures[doc.doctype] = [] 158 | 159 | self.fixtures[doc.doctype].append(doc) 160 | 161 | def isSetUp(self): 162 | """ 163 | Checks if another instance of the same fixture class is already set up 164 | """ 165 | class_name = self.__class__.__name__ 166 | return not not self.get_locals_obj().get(class_name, 0) 167 | 168 | def get_locals_obj(self): 169 | if "test_fixtures" not in frappe.flags: 170 | frappe.flags.test_fixtures = frappe._dict() 171 | 172 | return frappe.flags.test_fixtures 173 | -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | License: MIT -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # frappe -- https://github.com/frappe/frappe is installed via 'bench init' 2 | python-telegram-bot==13.7 3 | crossplane==0.5.7 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | import ast 3 | from setuptools import setup, find_packages 4 | 5 | with open('requirements.txt') as f: 6 | install_requires = f.read().strip().split('\n') 7 | 8 | # get version from __version__ variable in frappe_telegram/__init__.py 9 | _version_re = re.compile(r'__version__\s+=\s+(.*)') 10 | 11 | with open('frappe_telegram/__init__.py', 'rb') as f: 12 | version = str(ast.literal_eval(_version_re.search( 13 | f.read().decode('utf-8')).group(1))) 14 | 15 | setup( 16 | name='frappe_telegram', 17 | version=version, 18 | description='Telegram Bot Manager for Frappe', 19 | author='Leam Technology Systems', 20 | author_email='info@leam.ae', 21 | packages=find_packages(), 22 | zip_safe=False, 23 | include_package_data=True, 24 | install_requires=install_requires 25 | ) 26 | --------------------------------------------------------------------------------