exteraGram

Plugin Class

Understand the Plugin class structure.

Metadata

Metadata should be defined as plain strings. No concatenation or formatting, since it's parsed using AST.

__name__ = "Better Previews"
__description__ = "Modifies specific URLs (Twitter, TikTok, Reddit, Instagram, Pixiv) for better previews"
__version__ = "1.0.0"
__id__ = "better_previews"
__author__ = "@immat0x1"
__icon__ = "exteraPlugins/1"
__min_version__ = "11.12.0"

Required fields: __id__ and __name__. The engine also validates __min_version__ if it's present.

__id__: Must be 2-32 characters long, start with a letter, and contain only latin letters, numbers, dashes (-) and underscores (_).

__author__: Supports plain text names or Telegram usernames/channel links (e.g., @yourUsername or @yourPluginChannel). These may be displayed as clickable links in the UI.

__description__: Supports basic markdown for formatting.

__version__: If not defined, your plugin will have version 1.0 by default.

__icon__: To fill this field, use the short name of a sticker pack followed by the index of the sticker, separated by a slash (/). The index starts from 0. For example, if your sticker pack's link is https://t.me/addstickers/MyPackName, its short name is MyPackName, and to use the second sticker you would write MyPackName/1.

Settings

You can create a settings screen for your plugin to allow users to configure its behavior. This is done by implementing the create_settings method in your plugin class.

For detailed information on how to create settings, what UI components are available, and how to handle user input, please refer to the dedicated Plugin Settings page.

Plugin events

Load and unload

class DebugPlugin(BasePlugin):
    def on_plugin_load(self):
        # e.g. register hooks, initialize resources
        self.log("Plugin loaded!")
        pass
 
    def on_plugin_unload(self):
        # e.g. unregister hooks, clean up resources
        self.log("Plugin unloaded!")
        pass
  • on_plugin_load occurs when user enables the plugin or on application startup.
  • on_plugin_unload occurs when user disables the plugin or on application shutdown.

Application events

from base_plugin import AppEvent
 
class DebugPlugin(BasePlugin):
    def on_app_event(self, event_type: AppEvent):
        if event_type == AppEvent.START:
            self.log("App is starting")
        elif event_type == AppEvent.STOP:
            self.log("App is stopping")
        elif event_type == AppEvent.PAUSE:
            self.log("App is being paused")
        elif event_type == AppEvent.RESUME:
            self.log("App is resuming")

The AppEvent enum provides the following events:

  • START - Application is starting
  • STOP - Application is stopping
  • PAUSE - Application is paused (e.g., backgrounded)
  • RESUME - Application is resumed (e.g., brought to foreground)

You can add custom actions to various menus within the application, such as the context menu for messages or the action menu in a user's profile. This is done by adding a MenuItemData object.

from base_plugin import BasePlugin, MenuItemData, MenuItemType
from typing import Dict, Any
 
class MyMenuPlugin(BasePlugin):
    def on_plugin_load(self):
        self.log("Adding custom menu items...")
        self.add_menu_item(
            MenuItemData(
                menu_type=MenuItemType.MESSAGE_CONTEXT_MENU,
                text="Log Message Info",
                on_click=self.handle_message_click,
                icon="msg_info" # Example icon
            )
        )
        self.add_menu_item(
            MenuItemData(
                menu_type=MenuItemType.PROFILE_ACTION_MENU,
                text="Log User Info",
                on_click=self.handle_profile_click,
                icon="user_search" # Example icon
            )
        )
 
    def on_plugin_unload(self):
        # Menu items are removed automatically, no need for manual cleanup.
        self.log("MyMenuPlugin unloaded.")
 
    def handle_message_click(self, context: Dict[str, Any]):
        self.log(f"Message menu item clicked! Context keys: {list(context.keys())}")
 
        message = context.get("message")
        if message:
            self.log(f"Clicked on message ID: {message.getId()} from user: {message.getSenderId()}")
            self.log(f"Message text: {message.messageText}")
 
    def handle_profile_click(self, context: Dict[str, Any]):
        self.log(f"Profile menu item clicked! Context keys: {list(context.keys())}")
 
        user = context.get("user")
        if user:
            self.log(f"Profile menu clicked for user: {user.first_name} (ID: {user.id})")

To add a menu item, you call self.add_menu_item() with a MenuItemData object, which has the following properties:

  • menu_type: MenuItemType: Required. Specifies which menu to add the item to. The available types are:
    • MenuItemType.MESSAGE_CONTEXT_MENU: Menu when pressing a message.
    • MenuItemType.DRAWER_MENU: The main navigation drawer (hamburger menu).
    • MenuItemType.CHAT_ACTION_MENU: The three-dot menu inside a chat screen.
    • MenuItemType.PROFILE_ACTION_MENU: The three-dot menu on a user, bot, or channel profile screen.
  • text: str: Required. The text displayed for the menu item.
  • on_click: Callable[[Dict[str, Any]], None]: Required. A function that will be called when the user taps the item. It receives a dictionary containing context-specific data.
  • item_id: str: Optional. A unique ID for this item. Useful if you need to remove it later with remove_menu_item(). If not provided, a unique ID is generated.
  • icon: str: Optional. The name of a drawable resource to use as an icon for the item (e.g., "msg_info", "msg_delete").
  • subtext: str: Optional. Additional text displayed below the main text.
  • condition: str: Optional. A MVEL expression to conditionally show the item. (e.g., "message.isOut()").
  • priority: int: Optional. A number to influence the item's position in the menu. Higher numbers appear first.

The on_click Context

The on_click callback receives a dictionary with data relevant to the context where the menu was opened. The available keys depend on the MenuItemType and the specific situation. For example, a message context menu will provide a message object, while a profile menu will provide a user object.

It's best practice to check for the existence of a key before using it. You can log the dictionary's keys to discover what's available: self.log(f"Context keys: {list(context.keys())}").

Here are some of the possible keys you might find in the context dictionary:

  • account: int: The current user account instance number.
  • context: android.content.Context: The Android application context.
  • fragment: org.telegram.ui.ActionBar.BaseFragment: The current UI fragment.
  • dialog_id: long: The dialog ID for the current chat.
  • user: TLRPC.User: The User object (e.g., in a profile menu).
  • userId: long: The ID of the user.
  • userFull: TLRPC.UserFull: The UserFull object with more details.
  • chat: TLRPC.Chat: The Chat object for a basic group or channel.
  • chatId: long: The ID of the chat.
  • chatFull: TLRPC.ChatFull: The ChatFull object with more details.
  • encryptedChat: TLRPC.EncryptedChat: The object for a secret chat.
  • message: org.telegram.messenger.MessageObject: The MessageObject that was clicked on.
  • groupedMessages: org.telegram.messenger.MessageObject.GroupedMessages: Information about grouped media (albums).
  • botInfo: TL_bots.BotInfo: Information about a bot.

Removing Menu Items

If you provided a custom item_id when adding a menu item, you can remove it programmatically using self.remove_menu_item(item_id). However, in most cases, this is not necessary, as all of a plugin's menu items are automatically removed when the plugin is unloaded.

self.remove_menu_item("my_unique_item_id")

Hooks

To intercept network requests, responses, or client-side events, you first need to register a hook.

You can register hooks for specific Telegram API requests using their TL-schema name: self.add_hook("TL_messages_readHistory", match_substring: bool = False, priority: int = 0)

  • name: The name of the event or request (e.g., "TL_messages_readHistory").
  • match_substring: If True, the hook will trigger if name is a substring of the actual event/request name. Defaults to False.
  • priority: Hooks with higher priority are executed first. Defaults to 0.

Examples:

  • self.add_hook("TL_messages_readHistory")
  • self.add_hook("requestCall")
  • self.add_hook("TL_channels_readHistory")

The list of names for requests could be found here.

For the common case of hooking message sending, you can use a helper: self.add_on_send_message_hook(priority: int = 0)

API Request Hooks

These hooks allow you to inspect or modify outgoing requests and incoming responses.

Here is a practical example of a "Ghost Mode" plugin that blocks the "typing" status and forces the user to appear offline.

from base_plugin import BasePlugin, HookResult, HookStrategy
from ui.settings import Switch
from typing import Any
 
# A list of request names that indicate the user is typing.
TYPING_REQUESTS = ["TL_messages_setTyping", "TL_messages_setEncryptedTyping"]
 
class GhostModePlugin(BasePlugin):
    def on_plugin_load(self):
        # Hook all typing-related requests
        for req_name in TYPING_REQUESTS:
            self.add_hook(req_name)
        
        # Hook the request that updates the user's online status
        self.add_hook("TL_account_updateStatus")
 
    def pre_request_hook(self, request_name: str, account: int, request: Any) -> HookResult:
        # This method is called for every request we've hooked.
 
        # 1. Block "typing..." status
        if request_name in TYPING_REQUESTS:
            if self.get_setting("dont_send_typing", True):
                self.log(f"Blocking request: {request_name}")
                # By returning CANCEL, we prevent the request from being sent.
                return HookResult(strategy=HookStrategy.CANCEL)
 
        # 2. Force offline status
        if request_name == "TL_account_updateStatus":
            if self.get_setting("force_offline", True):
                self.log("Forcing offline status in TL_account_updateStatus request.")
                # Modify the request object directly
                request.offline = True
                # Return MODIFY with the modified request object.
                return HookResult(strategy=HookStrategy.MODIFY, request=request)
 
        # For any other hooked requests we don't handle, do nothing.
        return HookResult(strategy=HookStrategy.DEFAULT)
    
    def post_request_hook(self, request_name: str, account: int, response: Any, error: Any) -> HookResult:
        # You can also intercept responses from the server.
        # For example, you could log when a message is successfully sent.
        if request_name == "TL_messages_sendMessage":
            if not error:
                self.log("Successfully sent a message!")
        return HookResult(strategy=HookStrategy.DEFAULT)
 
    def create_settings(self) -> list:
        return [
            Switch(key="dont_send_typing", text="Don't send typing status", default=True),
            Switch(key="force_offline", text="Always appear offline", default=True)
        ]

Hook results determine the action to take:

  • HookStrategy.DEFAULT: No changes to the flow; proceed as normal.
  • HookStrategy.CANCEL: Cancel the request (for pre_request_hook and on_send_message_hook) or suppress further processing of the response/update.
  • HookStrategy.MODIFY: Modify the request (in pre_request_hook), response (in post_request_hook), update (in on_update_hook), updates (in on_updates_hook), or params (in on_send_message_hook). The modified object must be assigned to the corresponding field in the HookResult (e.g., result.request = modified_request).
  • HookStrategy.MODIFY_FINAL: Same as MODIFY, but no other plugins hooks for this event will be called after this one.

Update Hooks

These hooks are called when the application processes updates received from Telegram.

def on_update_hook(self, update_name: str, account: int, update: Any) -> HookResult:
    # Called when the app receives an individual update (e.g., TL_updateNewMessage)
    result = HookResult()
 
    if update_name == "TL_updateNewMessage":
        self.log(f"Intercepted on_update_hook for {update_name}")
        # Example: Process or modify the update
        # if hasattr(update, 'message') and hasattr(update.message, 'message'):
        #     if "secret" in update.message.message:
        #         update.message.message = "[REDACTED]"
        #         result.strategy = HookStrategy.MODIFY
        #         result.update = update # Assign the modified update back
        pass
 
    return result
 
def on_updates_hook(self, container_name: str, account: int, updates: Any) -> HookResult:
    # Called when the app receives a container of updates (e.g., TL_updates, TL_updatesCombined)
    result = HookResult()
 
    if container_name == "TL_updates" and hasattr(updates, 'updates'):
        self.log(f"Intercepted on_updates_hook for {container_name} with {len(updates.updates)} inner updates.")
        # Example: Filter updates
        # filtered_inner_updates = [upd for upd in updates.updates if not isinstance(upd, TLRPC.TL_updateUserStatus)]
        # if len(filtered_inner_updates) < len(updates.updates):
        #    updates.updates = ArrayList(filtered_inner_updates) # Assuming ArrayList is needed
        #    result.strategy = HookStrategy.MODIFY
        #    result.updates = updates # Assign the modified container back
        pass
 
    return result

Message Sending Hook

This hook is specifically for intercepting messages being sent by the user.

def on_send_message_hook(self, account: int, params: Any) -> HookResult:
    # Called when a message is about to be sent by the client
    # `params` is an object (SendMessagesHelper.SendMessageParams) containing message details
    result = HookResult()
 
    if hasattr(params, 'message') and isinstance(params.message, str):
        self.log(f"Intercepted on_send_message_hook for message: {params.message[:30]}")
        # Example: Modify message parameters
        # if params.message.startswith(".shrug"):
        #     params.message = params.message.replace(".shrug", "¯\\_(ツ)_/¯")
        #     result.strategy = HookStrategy.MODIFY
        #     result.params = params # Assign the modified params object back
        pass
 
    return result

On this page