Writing

How to write a phBot plugin: the Python API, hooks and a working example

By Mohamed Gado · 11 Nov 2026 · 4 min read

On this page6 sections
  • What does a minimal phBot plugin look like?
  • Which functions does phBot call in a plugin?
  • How do I read the character and the world?
  • How do I make the bot do something?
  • How do I run plugin code from a walk script?
  • What goes wrong most often?

A phBot plugin is a single Python file dropped into phBot's Plugins/ folder. phBot imports it at startup. Your plugin talks to the bot through two modules, phBot and QtBind, and phBot calls functions in your file by name when something happens: a packet arrives, a chat message lands, half a second passes. That is the whole model. Everything below comes from the ten phBot plugins I maintain, so each call is one I have seen work in a live client.

What does a minimal phBot plugin look like?#

Here is a complete plugin. It adds a tab with a checkbox, and when it is ticked, it answers "ping" in party chat with "pong".

from phBot import *
import QtBind
 
pName = 'xHello'
pVersion = '1.0.0'
 
gui = QtBind.init(__name__, pName)
QtBind.createLabel(gui, 'Replies "pong" when a party member types "ping".', 10, 10)
cbxEnabled = QtBind.createCheckBox(gui, 'cbxEnabled_clicked', 'Enabled', 10, 40)
enabled = False
 
def cbxEnabled_clicked(checked):
    global enabled
    enabled = checked
 
def handle_chat(t, player, msg):
    if enabled and t == 4 and msg.strip().lower() == 'ping':
        phBotChat.Party('pong')
 
log('Plugin: ' + pName + ' v' + pVersion + ' loaded')

Save it as xHello.py in Plugins/, then restart phBot or use Reload Plugins. A new xHello tab appears.

Three things in it apply to every plugin:

  • pName and pVersion are module-level names. My plugins also set pAuthor and pUrl, the raw GitHub URL of the file.
  • QtBind.init(__name__, pName) creates the plugin's tab and returns a handle. Every UI call takes that handle first.
  • Callbacks are passed by name, as a string: 'cbxEnabled_clicked'. phBot looks the function up in your module when the checkbox changes, so the name has to match exactly.

Which functions does phBot call in a plugin?#

You opt in to an event by defining a function with the right name. These are the ones my plugins use:

Function When phBot calls it
handle_joymax(opcode, data) Every packet from the game server. Return True to pass it on to the client, False to drop it.
handle_silkroad(opcode, data) Every packet from the game client. Same return rule, towards the server.
handle_chat(t, player, msg) Every chat message. t is the chat type.
handle_event(t, data) Bot events with a text payload. My Jupiter hunter reads it for the kill-credit message that names a unique.
event_loop() Every 500 ms, as long as the plugin is loaded.
joined_game() Once the character is in the game world.
connected() / disconnected() When the connection to the server opens or drops.
teleported() After every teleport.

The return value of the two packet hooks is easy to get wrong. Every hook in my plugins ends with an explicit return True; a hook that falls off the end returns None, and you should not have to find out what phBot does with that.

The chat types in handle_chat are numbers. xControl maps them like this: 1 all, 2 private, 3 guild, 4 party, 5 global, 6 note, 7 stall, 11 union. Union messages arrive with the guild name in front, so xControl strips everything up to the first ": " before parsing.

How do I read the character and the world?#

The phBot module exposes getters that return plain dicts and lists:

  • get_character_data(): name, server, region, position, HP, MP and more. My xNotify plugin reads hp, mp, name, region and x/y from it.
  • get_inventory(), get_pets(), get_pet_inventory() and get_item(model_id) for items.
  • get_party(), get_monsters() and get_position() for the world around you.
  • get_config_dir(): the folder where phBot keeps its configs. Plugins store their own JSON there, for example get_config_dir() + pName + '.json'.

Treat every getter as able to return nothing. Right after login, or during a teleport, get_character_data() can be empty, so xNotify reads it as get_character_data() or {} inside a try.

How do I make the bot do something?#

  • log(text) writes to phBot's log tab. It is the first debugging tool you have.
  • start_bot() and stop_bot() toggle botting. set_training_position(region, x, y, z) moves the training area, and set_training_script(path) swaps the walk script.
  • move_to(x, y, z) walks the character. xControl's follow command calls it every loop tick.
  • phBotChat.All, .Party, .Guild, .Union, .Global and .Stall send chat. phBotChat.Private(name, msg) and phBotChat.Note(name, msg) take a recipient first.
  • inject_joymax(opcode, data, encrypted) sends a raw packet to the server, and inject_silkroad sends one to the client. data is a bytearray.

Packet injection is how most of the interesting plugins work. My Magic Pop spinner, the lightstone crafter and the Jupiter hunter all build packets by hand and send them with inject_joymax. To find the opcodes in the first place, I use xPacketTool, which logs client and server packets with an opcode filter.

How do I run plugin code from a walk script?#

Any plugin function that takes one args list can be called from a phBot training script by its name. xPacketTool defines inject(args), so a script line like this greets:

inject,3091,0

phBot splits the line on commas and passes the list, so args[0] is the command name itself, and the values start at args[1]. The function returns 0, as every script command in my plugins does. xControl uses the same mechanism for its chat,party,<message> command.

What goes wrong most often?#

  • Blocking the loop. event_loop runs on phBot's own thread. A time.sleep or a slow HTTP call there freezes the bot. xNotify, which sends Telegram and Discord alerts, queues them for a background threading.Thread instead.
  • Reacting to your own messages. handle_chat also fires for what your character says. xControl remembers each message it sent and skips it once when it comes back.
  • UI method names that differ between builds. Some QtBind methods are not present on every phBot build. My plugins look a method up with getattr and skip it when it is missing, rather than crashing the whole plugin on load.

The full plugins, including xControl's party commands and the Caravan runner with its own dashboard, are on the phBot plugins page, with the source on GitHub.

More writing

  • Clerk auth in Next.js: from authMiddleware to proxy.ts and auth.protect()
  • Lemon Squeezy subscription statuses: who should keep access, and the two bugs I shipped
  • Why a CSS rotate with will-change: transform makes images and text blurry
See the projects these came from
  • What does a minimal phBot plugin look like?
  • Which functions does phBot call in a plugin?
  • How do I read the character and the world?
  • How do I make the bot do something?
  • How do I run plugin code from a walk script?
  • What goes wrong most often?