xNotify is one of the phBot plugins I wrote for Silkroad Online. It watches the game and sends a message to Telegram, Discord or both when something worth knowing happens: the character died, it ran out of potions, a unique spawned, someone sent a private message. The idea is simple. The hard parts were duplicates, spam and one confusing 403. If you have never written a plugin, start with the phBot Python API; this post assumes you know what handle_chat and event_loop are.
Five clients, one message#
Most people bot more than one character, and every phBot client loads its own copy of the plugin. A global chat line or a unique spawn notice reaches all of them at once. Without coordination, five clients means five identical Telegram messages.
The clients share nothing in memory, but they share a disk. Before sending a shared event, each copy tries to create the same marker file, named after a hash of the event and its text:
SHARED_EVENTS = {'global', 'notice', 'unique', 'all_chat'}
SHARED_WINDOW = {'global': 30.0, 'notice': 30.0, 'unique': 120.0, 'all_chat': 15.0}
def _claim_shared(key, window):
h = hashlib.md5(key.encode('utf-8', 'replace')).hexdigest()
path = os.path.join(_DEDUP_DIR, h)
try:
if time.time() - os.stat(path).st_mtime < window:
return False # someone sent this inside the window
except FileNotFoundError:
pass
try:
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.close(fd)
return True # we created it, we send
except FileExistsError:
return False # another client won the raceO_CREAT | O_EXCL is the whole trick. The operating system creates the file only if it does not exist, and does so atomically, so exactly one client gets True even when all five arrive in the same millisecond. No lock server, no sockets between clients. (The real function also re-claims an expired marker by touching it, and returns True on any unexpected error, so a disk problem costs a duplicate, never a lost alert.)
The window is per event type. A unique spawn gets 120 seconds because the same notice repeats; all-chat gets 15 because people really do say the same thing twice. A cleanup pass deletes markers older than an hour, at most once every five minutes, so the folder never grows.
A cooldown for everything else#
Per-character events, like a party invite or a trade request, don't need cross-client dedup, but they can still fire in bursts. Those go through a per-event cooldown on a monotonic clock, 60 seconds by default:
def _throttled(event_type):
now = time.monotonic()
with _throttle_lock:
if now - _last_sent.get(event_type, 0) < config.get('cooldown_sec', 60):
return True
_last_sent[event_type] = now
return Falsetime.monotonic() instead of time.time() means a clock change can't freeze or flood the alerts. Private messages pass force=True and skip the cooldown. Nobody wants the second PM from a friend held back for a minute.
Fire on the edge, not the level#
"HP is 0" stays true for as long as the character lies dead. Checking it on every tick would send a message every tick until the cooldown caught it. The plugin keeps the previous value and fires only on the change from alive to dead:
if _prev_hp is not None and _prev_hp > 0 and hp == 0:
notify('death', 'Character died', extras=[('Last HP', _prev_hp)])
_prev_hp = hpPotions work the same way: one message when the count drops from some to none, not one per check while the bag stays empty. The is not None guard matters on login, when there is no previous value yet and a fresh character at 0 potions shouldn't count as running out.
Never send from the game thread#
phBot calls handle_chat and event_loop on its own thread. A Telegram request that takes three seconds there freezes the bot for three seconds. So notify() only puts a small dict on a queue.Queue, and one daemon thread does the network work, with three tries per channel and a short back-off between them:
def notify(event_type, text, force=False, extras=None):
if not config.get('events', {}).get(event_type, False):
return
if event_type in SHARED_EVENTS:
if not _claim_shared(event_type + '|' + (text or ''), SHARED_WINDOW.get(event_type, 30.0)):
return
elif not force and _throttled(event_type):
return
_send_q.put({'type': event_type, 'text': text or '', 'extras': extras or []})notify is also the public API. Another plugin can import xNotify and call xNotify.notify('trade_start', 'Trade opened') to reuse the same channels, dedup and cooldowns.
Why Discord said 403 and Telegram didn't#
Telegram worked on the first try: a form POST to https://api.telegram.org/bot<token>/sendMessage with chat_id and text. The Discord webhook, with a correct URL, failed with HTTP 403 and Cloudflare error code 1010.
Error 1010 means Cloudflare blocked the request based on the client's browser signature. Python's urllib sends User-Agent: Python-urllib/3.x, and Discord's edge rejects it. Setting any honest custom agent fixed it:
def _http_post(url, data_bytes, headers):
# Cloudflare (Discord) blocks the default urllib User-Agent with 403/1010.
headers = dict(headers)
headers.setdefault('User-Agent', 'xNotify/%s (+phBot plugin)' % pVersion)
req = urllib.request.Request(url, data=data_bytes, headers=headers, method='POST')The plugin uses only the standard library because phBot's embedded Python makes installing requests awkward for users. If your own webhook calls from Python get a 403 with a correct URL, check the User-Agent before the token.
Telling a real unique from a champion#
The first version flagged every champion and giant near the character as a unique, because it matched several rarity codes. phBot's mob rarity field uses 0 for general, 1 champion, 3 giant, 4 party champion, 5 elite and 6 unique. Only 6 is a real unique, so that is the default unique_types, with a name and keyword list as a fallback for server-specific bosses. The plugin README had said 8 until this week. The code was right, the docs weren't.
What I'd keep for any game-bot notifier#
- Dedup across processes with an exclusive file create when the clients share a disk. It's simpler than any IPC.
- Fire on transitions, not states.
- Keep network calls off the thread that runs the game.
- Set a User-Agent on every
urllibrequest you send to a Cloudflare-fronted API.
The code is in Vette1123/phbot-plugins, along with nine other plugins.