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__, __name__, __description__, __author__, __min_version__.

__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, you can use a link to your sticker pack (e.g., https://t.me/addstickers/MyPackName), then specify the index of the sticker, separated by /. The index starts from 0 (e.g., for the second sticker, use MyPackName/1).

Settings

You may create an activity for plugin settings using create_settings method. This method should return a list of setting control objects.

from ui.settings import Header, Input, Divider, Switch, Selector, Text
 
class DebugPlugin(BasePlugin):
    def _on_advanced_toggle_change(self, new_value: bool):
        self.log(f"Advanced toggle changed to: {new_value}")
 
    def _create_advanced_settings(self):
        return [
            Header(text="Advanced Settings Sub-Page"),
            Switch(
                key="advanced_toggle",
                text="Enable Advanced Feature",
                default=False,
                on_change=self._on_advanced_toggle_change
            ),
            Input(key="advanced_text", text="Advanced Input", default="defaultValue")
        ]
 
    def create_settings(self):
        from org.telegram.messenger import LocaleController
        lang = LocaleController.getInstance().getCurrentLocale().getLanguage()
 
        strings = {
            'ru': {
                'api_title': "Настройки API",
                'api_url_label': "Cobalt API URL",
                'api_key_label': "API ключ",
                'api_desc': "Укажите URL и API (если требуется) вашего Cobalt инстанса. Узнать больше: github.com/imputnet/cobalt",
                'settings_title': "Настройки загрузки",
                'usage_cmd': ".down/.dl [URL] - Скачивает и отправляет медиа\nПример: .dl youtube.com/watch?v=dQw4w9WgXcQ",
                'include_source_text': "Включить ссылку источника",
                'include_source_subtext': "Добавлять исходную ссылку к видео",
                'video_quality': "Качество видео",
                'download_mode': "Режим загрузки",
                'audio_bitrate': "Битрейт аудио",
                'info_text': "Это информационный текст.",
                'action_text': "Нажми на меня!"
            },
            'en': {
                'api_title': "API Settings",
                'api_url_label': "Cobalt API URL",
                'api_key_label': "API Key",
                'api_desc': "Enter URL and API (if needed) for your Cobalt instance. Learn more: github.com/imputnet/cobalt",
                'settings_title': "Download Settings",
                'usage_cmd': "Command: .down/.dl [URL] - Download and send video/audio\nExample: .dl youtube.com/watch?v=dQw4w9WgXcQ",
                'include_source_text': "Include source link",
                'include_source_subtext': "Add original link as caption",
                'video_quality': "Video Quality",
                'download_mode': "Download Mode",
                'audio_bitrate': "Audio Bitrate",
                'info_text': "This is an informational text item.",
                'action_text': "Click me for an action!"
            }
        }
 
        lang_key = 'ru' if lang.startswith('ru') else 'en'
        s = strings[lang_key]
        
        CUSTOM_COBALT_API = ""
        DEFAULT_COBALT_API = "https://co.wuk.sh"
 
 
        def on_info_click(view):
            self.log("Info text clicked!")
            # For example, show a bulletin
            # from ui.bulletin import BulletinHelper
            # BulletinHelper.show_info("You clicked the info text!")
 
        return [
            Header(text=s['api_title']),
            Input(
                key="api_url",
                text=s['api_url_label'],
                default=CUSTOM_COBALT_API or DEFAULT_COBALT_API,
                icon="msg2_devices"
            ),
            Input(
                key="api_key",
                text=s['api_key_label'],
                default="",
                icon="msg_pin_code"
            ),
            Divider(text=s['api_desc']),
            Header(text=s['settings_title']),
            Switch(
                key="include_source",
                text=s['include_source_text'],
                default=True,
                subtext=s['include_source_subtext'],
                icon="msg_link"
            ),
            Selector(
                key="video_quality",
                text=s['video_quality'],
                default=4,
                items=["144", "240", "360", "480", "720", "1080", "1440", "2160", "4320", "max"],
                icon="msg_video"
            ),
            Selector(
                key="download_mode",
                text=s['download_mode'],
                default=0,
                items=["auto", "audio", "mute"],
                icon="msg_gallery"
            ),
            Selector(
                key="audio_bitrate",
                text=s['audio_bitrate'],
                default=2,
                items=["64", "96", "128", "192", "256", "320"],
                icon="input_mic"
            ),
            Text(
                text=s['info_text'],
                icon="msg_info",
                on_click=on_info_click,
                create_sub_fragment=self._create_advanced_settings
            ),
            Divider(text=s['usage_cmd']),
        ]

To access settings from the code, use self.get_setting("KEY", DEFAULT_VALUE) method:

api_url = self.get_setting("api_url", "https://default.api.url")
include_source = self.get_setting("include_source", True)

To save or update a setting's value programmatically, use self.set_setting("KEY", NEW_VALUE) method:

# Example: Update API URL after a successful validation
new_api_url = "https://verified.api.url"
self.set_setting("api_url", new_api_url)
 
# Example: Toggle a boolean setting
current_value = self.get_setting("some_toggle", False)
self.set_setting("some_toggle", not current_value)

The set_setting method will persist the new value, and it will be reflected in the settings UI if the control is bound to that key.

Supported controls: (Note: The type field is usually set automatically by the system and corresponds to PluginsConstants.Settings.TYPE_.... Icon names are examples and depend on available resources.)

from typing import Callable, List, Any
from android.view import View
 
class Switch:
    key: str
    # Unique identifier for this setting. Used with get_setting/set_setting.
    text: str
    # The main display text for the switch.
    default: bool
    # The default state (True for on, False for off) if no value is saved yet.
    subtext: str = None
    # Optional additional text displayed below the main text for more context.
    icon: str = None
    # Optional: Name of a drawable resource for an icon displayed next to the text.
    # These are typically Android drawable resource names (e.g., "ic_settings_24dp").
    # You can find standard Material Design icons or icons within the Telegram app's source code
    # (e.g., in TMessagesProj/src/main/res/drawable-* folders, use the filename without extension).
    on_change: Callable[[bool], None] = None
    # Optional: A function called immediately when the user toggles the switch in the UI.
    # Receives the new boolean state as an argument.
 
class Selector:
    key: str
    # Unique identifier for this setting.
    text: str
    # The main display text for the selector.
    default: int
    # The default selected INDEX from the 'items' list.
    items: List[str]
    # A list of strings representing the options the user can choose from.
    icon: str = None
    # Optional: Icon resource name. See Switch.icon for details.
    on_change: Callable[[int], None] = None
    # Optional: A function called immediately when the user selects a new item.
    # Receives the new selected index as an argument.
 
class Input:
    key: str
    # Unique identifier for this setting.
    text: str
    # The main display text for the input field.
    default: str = ""
    # The default string value if no value is saved yet.
    subtext: str = None
    # Optional additional text displayed below the main text.
    icon: str = None
    # Optional: Icon resource name. See Switch.icon for details.
    on_change: Callable[[str], None] = None
    # Optional: A function called immediately as the user types or changes the text in the UI.
    # Receives the new string value as an argument.
 
class Header:
    text: str
    # Text to be displayed as a section header, visually grouping settings below it.
 
class Divider:
    text: str = None
    # Optional text displayed on the divider line itself. If None, it's just a line.
    # Useful for short notes or separating sections more distinctly than a Header.
 
class Text:
    text: str
    # The main display text.
    icon: str = None
    # Optional: Icon resource name. See Switch.icon for details.
    accent: bool = False
    # If True, the text might be styled using the app's current theme accent color, making it stand out.
    red: bool = False
    # If True, the text might be styled in a standard red color, often used for warnings or destructive action confirmations.
    on_click: Callable[[View], None] = None
    # Optional: A function called when the user clicks on this text item.
    # The 'View' argument is the Android View object that was clicked.
    # This is ignored if 'create_sub_fragment' is also provided.
    create_sub_fragment: Callable[[], List[Any]] = None
    # Optional: A function that, when called, returns a new list of setting items (Switches, Inputs, etc.).
    # If provided, clicking this Text item will navigate the user to a new settings sub-page
    # populated with the items returned by this function. This allows for nested settings.

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: Long-press menu on 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.

from base_plugin import HookResult, HookStrategy
from typing import Any
 
def pre_request_hook(self, request_name: str, account: int, request: Any) -> HookResult:
    # Called before a request is sent to the server
    result = HookResult()
 
    if request_name == "TL_stories_sendReaction":
        self.log(f"Intercepted pre_request_hook for {request_name}")
        # Example: Modify the request
        # if hasattr(request, 'reaction'):
        #     request.reaction = TLRPC.TL_reactionEmoji(emoticon="👍") # Example modification
        #     result.strategy = HookStrategy.MODIFY
        #     result.request = request # Assign the modified request back
        pass
 
    return result
 
def post_request_hook(self, request_name: str, account: int, response: Any, error: Any) -> HookResult:
    # Called after a response (or error) is received from the server
    result = HookResult()
 
    if request_name == "TL_stories_sendReaction":
        self.log(f"Intercepted post_request_hook for {request_name}")
        if error:
            self.log(f"Request failed with error: {error.error_message}")
        elif response:
            self.log(f"Request succeeded with response: {type(response)}")
            # Example: Modify the response
            # if isinstance(response, TLRPC.TL_updates):
            #     # ... modify response ...
            #     result.strategy = HookStrategy.MODIFY
            #     result.response = response # Assign the modified response back
            pass
            
    return result

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