exteraGram

First Plugin

Running your first plugin

Before we start

It's recommended to review the Plugin Class Reference documentation or keep it open for reference while developing plugins.

Basic plugin structure

All .plugin files must include:

  • Meta variables defined as plain strings (__id__, __name__, __description__, __author__, __version__, __icon__, __min_version__)
  • A single class that inherits from BasePlugin

Here's the most basic plugin template:

__id__ = "weather"
__name__ = "Weather"
__description__ = "Provides current weather information [.wt]"
__author__ = "Your Name"
__version__ = "1.0.0"
__icon__ = "exteraPlugins/1"
__min_version__ = "11.12.0"
 
class WeatherPlugin(BasePlugin):
    pass
You don't need to import BasePlugin as it's implicitly imported.

Creating simple Weather plugin

In this example, we'll create a plugin that provides weather information when a user sends a message prefixed with .wt.

We'll use the wttr.in API to fetch weather data.

Implementing network call and formatting

First, let's implement the functions to fetch and format weather data. They're quite boilerplate, so we won't look deep into it:

import requests
from android_utils import log
 
 
API_BASE_URL = "https://wttr.in"
API_HEADERS = {"User-Agent": "Mozilla/5.0", "Accept": "application/json"}
 
 
def fetch_weather_data(city: str):
    try:
        url = f"{API_BASE_URL}/{city}?format=j1"
        response = requests.get(url, headers=API_HEADERS, timeout=10)
        if response.status_code != 200:
            log(f"Failed to fetch weather data for '{city}' (status code: {response.status_code})")
            return None
        return response.json()
    except Exception as e:
        log(f"Weather API error: {str(e)}")
        return None
 
 
def format_weather_data(data: dict, query_city: str):
    try:
        area_info = data.get("nearest_area", [{}])[0]
        city = area_info.get("areaName", [{}])[0].get("value", query_city)
        region = area_info.get("region", [{}])[0].get("value", "")
        country = area_info.get("country", [{}])[0].get("value", "")
 
        location_parts = [city]
        if region:
            location_parts.append(region)
        if country:
            location_parts.append(country)
        location_str = ", ".join(location_parts)
 
        result_parts = [f"Weather in {location_str}:\n\n"]
        current = data.get("current_condition", [{}])[0]
 
        temp = current.get("temp_C", "N/A")
        feels_like = current.get("FeelsLikeC", "N/A")
        result_parts.append(f"• Temperature: {temp}°С (Feels like: {feels_like}°С)\n")
 
        condition = current.get("weatherDesc", [{}])[0].get("value", "Unknown")
        result_parts.append(f"• Condition: {condition}\n")
 
        humidity = current.get("humidity", "N/A")
        result_parts.append(f"• Humidity: {humidity}%\n")
 
        wind_speed = current.get("windspeedKmph", "N/A")
        wind_dir = current.get("winddir16Point", "N/A")
        result_parts.append(f"• Wind: {wind_speed} km/h ({wind_dir})\n")
 
        local_time = current.get("localObsDateTime", "N/A")
        result_parts.append(f"\nUpdated: {local_time} (local time)")
 
        return "".join(result_parts)
    except Exception as e:
        log(f"Error formatting weather data: {str(e)}")
        return f"Error processing weather data: {str(e)}"

Hooking message send event

To intercept and modify messages, we implement the on_send_message_hook method in our plugin class:

To make your on_send_message_hook method actually get called by the plugin system, you need to register this hook. This is typically done in on_plugin_load by calling self.add_on_send_message_hook().

from base_plugin import BasePlugin, HookResult, HookStrategy
from typing import Any
 
class WeatherPlugin(BasePlugin):
    def on_plugin_load(self):
        self.add_on_send_message_hook()
 
    def on_send_message_hook(self, account: int, params: Any) -> HookResult:
        if not isinstance(params.message, str) or not params.message.startswith(".wt"):
            return HookResult()
 
        try:
            # Split message into two parts. For example:
            # ".wt" -> [".wt"]
            # ".wt Moscow" -> [".wt", "Moscow"]
            # ".wt New York" -> [".wt", "New York"]
            parts = params.message.strip().split(" ", 1)
 
            # Fallback to "Moscow" if city is not specified
            city = parts[1].strip() if len(parts) > 1 else "Moscow"
            if not city:
                params.message = "Usage: .wt [city]"
                return HookResult(strategy=HookStrategy.MODIFY, params=params)
 
            # Fetch weather data using previously defined function
            data = fetch_weather_data(city)
            if not data:
                params.message = f"Failed to fetch weather data for '{city}'"
                return HookResult(strategy=HookStrategy.MODIFY, params=params)
 
            # Format weather using previously defined function
            formatted_weather = format_weather_data(data, city)
 
            # Modify message content
            params.message = formatted_weather
            return HookResult(strategy=HookStrategy.MODIFY, params=params)
        except Exception as e:
            log(f"Weather plugin error: {str(e)}")
            params.message = f"Error: {str(e)}"
            return HookResult(strategy=HookStrategy.MODIFY, params=params)

The on_send_message_hook method returns a HookResult with a MODIFY strategy, which means the message will be modified before sending. An empty HookResult won't modify the message.

Complete example (Initial)

Here's the complete implementation of the Weather plugin before performance enhancements:

import requests
from android_utils import log
from base_plugin import BasePlugin, HookResult, HookStrategy
from typing import Any
 
__id__ = "weather"
__name__ = "Weather"
__description__ = "Provides current weather information [.wt]"
__author__ = "exteraDev"
__version__ = "1.0.0"
__icon__ = "exteraPlugins/1"
__min_version__ = "11.12.0"
 
API_BASE_URL = "https://wttr.in"
API_HEADERS = {"User-Agent": "Mozilla/5.0", "Accept": "application/json"}
 
 
def format_weather_data(data, query_city):
    try:
        area_info = data.get("nearest_area", [{}])[0]
        city = area_info.get("areaName", [{}])[0].get("value", query_city)
        region = area_info.get("region", [{}])[0].get("value", "")
        country = area_info.get("country", [{}])[0].get("value", "")
 
        location_parts = [city]
        if region:
            location_parts.append(region)
        if country:
            location_parts.append(country)
        location_str = ", ".join(location_parts)
 
        result_parts = [f"Weather in {location_str}:\n\n"]
        current = data.get("current_condition", [{}])[0]
 
        temp = current.get("temp_C", "N/A")
        feels_like = current.get("FeelsLikeC", "N/A")
        result_parts.append(f"• Temperature: {temp}°С (Feels like: {feels_like}°С)\n")
 
        condition = current.get("weatherDesc", [{}])[0].get("value", "Unknown")
        result_parts.append(f"• Condition: {condition}\n")
 
        humidity = current.get("humidity", "N/A")
        result_parts.append(f"• Humidity: {humidity}%\n")
 
        wind_speed = current.get("windspeedKmph", "N/A")
        wind_dir = current.get("winddir16Point", "N/A")
        result_parts.append(f"• Wind: {wind_speed} km/h ({wind_dir})\n")
 
        local_time = current.get("localObsDateTime", "N/A")
        result_parts.append(f"\nUpdated: {local_time} (local time)")
 
        return "".join(result_parts)
    except Exception as e:
        log(f"Error formatting weather data: {str(e)}")
        return f"Error processing weather data: {str(e)}"
 
 
def fetch_weather_data(city):
    try:
        url = f"{API_BASE_URL}/{city}?format=j1"
        response = requests.get(url, headers=API_HEADERS, timeout=10)
        if response.status_code != 200:
            log(f"Failed to fetch weather data for '{city}' (status code: {response.status_code})")
            return None
        return response.json()
    except Exception as e:
        log(f"Weather API error: {str(e)}")
        return None
 
 
class WeatherPlugin(BasePlugin):
    def on_plugin_load(self):
        self.add_on_send_message_hook()
 
    def on_send_message_hook(self, account: int, params: Any) -> HookResult:
        if not isinstance(params.message, str) or not params.message.startswith(".wt"):
            return HookResult()
 
        try:
            # Split message into two parts. For example:
            # ".wt" -> [".wt"]
            # ".wt Moscow" -> [".wt", "Moscow"]
            # ".wt New York" -> [".wt", "New York"]
            parts = params.message.strip().split(" ", 1)
 
            # Fallback to "Moscow" if city is not specified
            city = parts[1].strip() if len(parts) > 1 else "Moscow"
            if not city:
                params.message = "Usage: .wt [city]"
                return HookResult(strategy=HookStrategy.MODIFY, params=params)
 
            # Fetch weather data using previously defined function
            data = fetch_weather_data(city)
            if not data:
                params.message = f"Failed to fetch weather data for '{city}'"
                return HookResult(strategy=HookStrategy.MODIFY, params=params)
 
            # Format weather using previously defined function
            formatted_weather = format_weather_data(data, city)
 
            # Modify message content
            params.message = formatted_weather
            return HookResult(strategy=HookStrategy.MODIFY, params=params)
        except Exception as e:
            log(f"Weather plugin error: {str(e)}")
            params.message = f"Error: {str(e)}"
            return HookResult(strategy=HookStrategy.MODIFY, params=params)

Testing the Plugin

Try sending message like .wt in any chat. You should get something similar to this:

Weather in Москва, Moscow City, Russia:

• Temperature: 4°С (Feels like: 1°С)
• Condition: Sunny
• Humidity: 35%
• Wind: 13 km/h (W)

Updated: 2025-04-12 05:56 PM (local time)

Performance Considerations

Fixing UI freeze

You may notice that the app freezes for a few seconds when using the plugin. This happens because the network call (requests.get) is a blocking I/O operation running on the UI thread. While the request is processing, the app cannot render anything.

To fix this issue, move blocking calls to a separate thread or queue to avoid blocking the UI thread. We can use client_utils.run_on_queue for the background network request and android_utils.run_on_ui_thread to post results back to the UI thread (e.g., to send the message or dismiss a dialog).

Additionally, we'll show a loading indicator using AlertDialogBuilder from alert.py while fetching data and then use client_utils.send_message to send the processed message.

Here's the improved version:

import requests
from typing import Any, Optional
 
from android_utils import log, run_on_ui_thread
from base_plugin import BasePlugin, HookResult, HookStrategy
from client_utils import run_on_queue, get_last_fragment, send_message
from ui.alert import AlertDialogBuilder
 
__id__ = "weather_v2"
__name__ = "Weather (Async)"
__description__ = "Provides current weather information asynchronously [.wt]"
__author__ = "exteraDev"
__version__ = "1.1.0"
__icon__ = "exteraPlugins/1"
__min_version__ = "11.12.0"
 
API_BASE_URL = "https://wttr.in"
API_HEADERS = {"User-Agent": "Mozilla/5.0", "Accept": "application/json"}
 
 
def format_weather_data(data, query_city):
    try:
        area_info = data.get("nearest_area", [{}])[0]
        city = area_info.get("areaName", [{}])[0].get("value", query_city)
        region = area_info.get("region", [{}])[0].get("value", "")
        country = area_info.get("country", [{}])[0].get("value", "")
 
        location_parts = [city]
        if region:
            location_parts.append(region)
        if country:
            location_parts.append(country)
        location_str = ", ".join(location_parts)
 
        result_parts = [f"Weather in {location_str}:\n\n"]
        current = data.get("current_condition", [{}])[0]
 
        temp = current.get("temp_C", "N/A")
        feels_like = current.get("FeelsLikeC", "N/A")
        result_parts.append(f"• Temperature: {temp}°С (Feels like: {feels_like}°С)\n")
 
        condition = current.get("weatherDesc", [{}])[0].get("value", "Unknown")
        result_parts.append(f"• Condition: {condition}\n")
 
        humidity = current.get("humidity", "N/A")
        result_parts.append(f"• Humidity: {humidity}%\n")
 
        wind_speed = current.get("windspeedKmph", "N/A")
        wind_dir = current.get("winddir16Point", "N/A")
        result_parts.append(f"• Wind: {wind_speed} km/h ({wind_dir})\n")
 
        local_time = current.get("localObsDateTime", "N/A")
        result_parts.append(f"\nUpdated: {local_time} (local time)")
 
        return "".join(result_parts)
    except Exception as e:
        log(f"Error formatting weather data: {str(e)}")
        return f"Error processing weather data: {str(e)}"
 
 
def fetch_weather_data(city):
    try:
        url = f"{API_BASE_URL}/{city}?format=j1"
        response = requests.get(url, headers=API_HEADERS, timeout=10)
        if response.status_code != 200:
            log(f"Failed to fetch weather data for '{city}' (status code: {response.status_code})")
            return None
        return response.json()
    except Exception as e:
        log(f"Weather API error: {str(e)}")
        return None
 
 
class WeatherPlugin(BasePlugin):
    def __init__(self):
        super().__init__()
        self.progress_dialog_builder: Optional[AlertDialogBuilder] = None
 
    def on_plugin_load(self):
        self.add_on_send_message_hook()
 
    def _process_weather_request(self, city: str, peer_id: Any):
        data = fetch_weather_data(city)
        
        if not data:
            message_content = f"Failed to fetch weather data for '{city}'."
        else:
            message_content = format_weather_data(data, city)
 
        message_params = {
            "message": message_content,
            "peer": peer_id
        }
 
        def _send_message_and_dismiss_dialog():
            if self.progress_dialog_builder:
                self.progress_dialog_builder.dismiss()
                self.progress_dialog_builder = None
            send_message(message_params)
 
        run_on_ui_thread(_send_message_and_dismiss_dialog)
 
    def on_send_message_hook(self, account: int, params: Any) -> HookResult:
        if not isinstance(params.message, str) or not params.message.startswith(".wt"):
            return HookResult()
 
        try:
            # Split message into two parts. For example:
            # ".wt" -> [".wt"]
            # ".wt Moscow" -> [".wt", "Moscow"]
            # ".wt New York" -> [".wt", "New York"]
            parts = params.message.strip().split(" ", 1)
 
            # Fallback to "Moscow" if city is not specified
            city = parts[1].strip() if len(parts) > 1 else "Moscow"
            
            if not city:
                params.message = "Usage: .wt [city_name]"
                return HookResult(strategy=HookStrategy.MODIFY, params=params)
 
            current_fragment = get_last_fragment()
            if not current_fragment:
                 log("WeatherPlugin: Could not get current fragment to show dialog.")
                 return HookResult(strategy=HookStrategy.CANCEL)
            
            current_activity = current_fragment.getParentActivity()
            if not current_activity:
                log("WeatherPlugin: Could not get current activity to show dialog.")
                return HookResult(strategy=HookStrategy.CANCEL)
 
            self.progress_dialog_builder = AlertDialogBuilder(
                current_activity,
                AlertDialogBuilder.ALERT_TYPE_SPINNER
            )
            self.progress_dialog_builder.set_cancelable(False)
            self.progress_dialog_builder.show()
 
            run_on_queue(lambda: self._process_weather_request(city, params.peer))
 
            return HookResult(strategy=HookStrategy.CANCEL)
 
        except Exception as e:
            log(f"Weather plugin error: {str(e)}")
            params.message = f"Error processing weather command: {str(e)}"
            if self.progress_dialog_builder:
                run_on_ui_thread(lambda: self.progress_dialog_builder.dismiss())
                self.progress_dialog_builder = None
            return HookResult(strategy=HookStrategy.MODIFY, params=params)

In this improved version:

  1. We import AlertDialogBuilder from alert.
  2. The __init__ method initializes self.progress_dialog_builder. The on_plugin_load method is used to call self.add_on_send_message_hook().
  3. When .wt is detected, we create and show() an AlertDialogBuilder of ALERT_TYPE_SPINNER.
  4. The actual work (_process_weather_request) is dispatched to a background queue using run_on_queue.
  5. _process_weather_request performs the network call. After getting the result, it schedules _send_message_and_dismiss_dialog on the UI thread using run_on_ui_thread.
  6. _send_message_and_dismiss_dialog dismisses the progress dialog and then uses client_utils.send_message to send the weather information as a new message.
  7. The original message sending is cancelled by returning HookResult(strategy=HookStrategy.CANCEL).

This approach ensures the UI remains responsive while fetching data.

What's next?

To further develop your plugin skills, explore community-made plugins:

On this page