2
replies
263 views
Description
I’m trying to find (or confirm if it exists) a Discord bot for Diablo that tracks Terror Zones in a very minimal way? The best would be a bot that would just update it's own name about what zone is terrorized and for how long. Similar to this one that someone has made, but for 3-day instance reset timers for world of wracraft:
https://www.reddit.com/r/classicwow/com ... et_timers/
https://www.reddit.com/r/classicwow/com ... et_timers/
Can be used to make Runewords:
I’m trying to find (or confirm if it exists) a Discord bot for Diablo that tracks Terror Zones in a very minimal way? The best would be a bot that would just update it's own name about what zone is terrorized and for how long. Similar to this one that someone has made, but for 3-day instance reset timers for world of wracraft:
https://www.reddit.com/r/classicwow/com ... et_timers/
https://www.reddit.com/r/classicwow/com ... et_timers/
You could build your own using this free API: https://www.d2tz.info/api
This is a quick and basic python discord bot that does what you want.
I took the liberty to also add the TZ time remaining, the Imunities displayed as emojis in the name, and also the loot/exp tier for said zones.
As a needed side-effect, I also abreviated the zone names when the bot changes his nick, since there is a hard 32 char limit on the nick a user can have on discord.
You will need to run it locally and invite it to your server.
It needs an .env file located in the same directory as the python file, with the following format:
DISCORD_BOT_TOKEN=discord_bot_token
D2TZ_TOKEN=your_d2tz_token ( TAKE CARE IT EXPIRES EVERY MONTH )
GUILD_ID=discord_server_id
ANNOUNCE_CHANNEL_ID=discord_chat_channel_id
CHECK_EVERY_SECONDS=60 ( refresh duration in seconds )
The following is the python code needed to run the bot:
import asyncio
import json
import os
import time
from typing import Any, Optional, Tuple
import aiohttp
import discord
from discord.ext import commands, tasks
from dotenv import load_dotenv
load_dotenv()
# =========================================================
# CONFIG
# =========================================================
BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
D2TZ_TOKEN = os.getenv("D2TZ_TOKEN")
GUILD_ID_RAW = os.getenv("GUILD_ID")
ANNOUNCE_CHANNEL_ID_RAW = os.getenv("ANNOUNCE_CHANNEL_ID")
CHECK_EVERY_SECONDS = int(os.getenv("CHECK_EVERY_SECONDS", "60"))
MAX_NICKNAME_LEN = 32
D2TZ_API_URL = "https://api.d2tz.info/public/tz"
if not BOT_TOKEN:
raise RuntimeError("DISCORD_BOT_TOKEN is not set in .env")
if not D2TZ_TOKEN:
raise RuntimeError("D2TZ_TOKEN is not set in .env")
if not GUILD_ID_RAW:
raise RuntimeError("GUILD_ID is not set in .env")
if not ANNOUNCE_CHANNEL_ID_RAW:
raise RuntimeError("ANNOUNCE_CHANNEL_ID is not set in .env")
try:
GUILD_ID = int(GUILD_ID_RAW)
ANNOUNCE_CHANNEL_ID = int(ANNOUNCE_CHANNEL_ID_RAW)
except ValueError as e:
raise RuntimeError("GUILD_ID and ANNOUNCE_CHANNEL_ID must be integers") from e
# =========================================================
# DISCORD SETUP
# =========================================================
intents = discord.Intents.default()
intents.message_content = True
bot = commands.Bot(command_prefix="!", intents=intents)
http_session: Optional[aiohttp.ClientSession] = None
last_announced_zone_key: Optional[str] = None
# =========================================================
# ABBREVIATION MAP
# =========================================================
ZONE_ABBREVIATIONS = {
" Abaddon": "Ab",
" Ancient Tunnels": "AT",
" Arachnid Lair": "AL",
" Arcane Sanctuary": "AS",
" Arreat Plateau": "AP",
" Arreat Summit": "ASM",
" Barracks": "Bar",
" Black Marsh": "BM",
" Blood Moor": "BldM",
" Bloody Foothills": "BldF",
" Burial Grounds": "BG",
" Canyon of the Magi": "CotM",
"Catacombs": "Cat",
" Cathedral": "Cath",
"Cave": "CV",
" City of the Damned": "CotD",
" Claw Viper Temple": "CVT",
" Cold Plains": "CP",
" Crypt": " Crypt",
" Crystalline Passage": "CrP",
" Dark Wood": "DW",
" Den of Evil": "DoE",
" Disused Fane": "DF",
" Disused Reliquary": "DR",
" Drifter Cavern": "DC",
" Dry Hills": "DH",
"Durance Of Hate": "DoH",
" Far Oasis": "FO",
"Flayer Dungeon": "FD",
" Flayer Jungle": "FJ",
" Forgotten Temple": "FTe",
"Forgotten Tower": "FT",
" Forgotten Reliquary": "FRe",
" Forgotten Sands": "FS",
" Frigid Highlands": "FH",
" Frozen River": "FrR",
" Frozen Tundra": "FrT",
" Furnace of Pain": "FoP",
" Glacial Trail": "GT",
" Great Marsh": "GM",
" Halls of Anguish": "HoA",
" Halls of Pain": "HoP",
"Halls Of The Dead": "HotD",
" Halls of Vaught": "HoV",
"Harem": "Harm",
" Harrogath": "Har",
"Hole": "Hole",
" Icy Cellar": "ICel",
" Infernal Pit": "IPit",
" Inner Cloister": "IC",
"Jail": "Jail",
" Kurast Bazaar": "KB",
" Kurast Docks": "KD",
"Kurast Sewers": "KS",
" Kurast Causeway": "KC",
" Lost City": "LC",
" Lower Kurast": "LK",
" Lut Gholein": "LG",
" Lut Gholein Sewers": "LGS",
"Maggot Lair": "ML",
"Marsh Of Pain": "MoP",
" Matron's Den": "MD",
" Mausoleum": "Maus",
"Moo Moo Farm": "MMF",
" Monastery Gate": "MG",
" Nihlathak's Temple": "NT",
" Outer Cloister": "OC",
" Outer Steppes": "OS",
"Palace Cellar": "PC",
" Pit of Acheron": "PoA",
" Plains of Despair": "PoD",
"Pit": "Pit",
" River of Flame": "RoF",
" Rocky Waste": "RW",
" Rogue Encampment": "RE",
" Ruined Fane": "RF",
" Ruined Temple": "RT",
"Sewers": "Sew",
" Spider Cavern": "SC",
" Spider Forest": "SF",
" Stony Field": "StF",
"Stony Tomb": "ST",
"Swampy Pit": "SwP",
" Tal Rasha's Tomb": "TRT",
" Tal Rasha's Chamber": "TRC",
" Tamoe Highland": "TH",
" The Ancients' Way": "TAW",
"The Chaos Sanctuary": "ChSan",
"The Crypt": " Crypt",
"The Hole": "Hole",
"The Pandemonium Fortress": "TPF",
"The Pit": "Pit",
" The Secret Cow Level": "Cows",
"The Worldstone Chamber": "TWSC",
" Throne of Destruction": "ToD",
"Tower Cellar": "TC",
" Travincal": "Trav",
" Tristram": "Trist",
"Underground Passage": "UP",
" Upper Kurast": "UK",
" Valley of Snakes": "VoS",
"Worldstone Keep": "WSK",
}
IMMUNITY_EMOJIS = {
"f": "🔥", # fire
"c": "❄️", # cold
"l": "⚡", # lightning
"p": "☠️", # poison
"ph": "🟣", # physical / placeholder
"m": "🧪", # magic / placeholder if it appears
}
IMMUNITY_LABELS = {
"f": "Fire",
"c": "Cold",
"l": "Lightning",
"p": "Poison",
"ph": "Physical",
"m": "Magic",
}
# =========================================================
# HTTP
# =========================================================
async def get_http_session() -> aiohttp.ClientSession:
global http_session
if http_session is None or http_session.closed:
timeout = aiohttp.ClientTimeout(total=15)
http_session = aiohttp.ClientSession(timeout=timeout)
return http_session
# =========================================================
# D2TZ API
# =========================================================
async def fetch_current_zone_data() -> Any:
session = await get_http_session()
params = {"token": D2TZ_TOKEN}
async with session.get(D2TZ_API_URL, params=params) as resp:
text = await resp.text()
if resp.status != 200:
raise RuntimeError(f"D2TZ API returned HTTP {resp.status}: {text[:300]}")
try:
data = json.loads(text)
except json.JSONDecodeError as e:
raise RuntimeError(f"D2TZ API did not return valid JSON: {text[:300]}") from e
if isinstance(data, dict) and data.get("status") == "error":
raise RuntimeError(f"D2TZ API error: {data.get('message', 'unknown error')}")
return data
def normalize_zone_name(name: str) -> str:
return name.replace("_", " ").strip()
def format_zone_name(zone_names: Any) -> Optional[str]:
if isinstance(zone_names, list) and zone_names:
cleaned = [normalize_zone_name(str(z)) for z in zone_names if str(z).strip()]
if cleaned:
return " / ".join(cleaned)
if isinstance(zone_names, str) and zone_names.strip():
return normalize_zone_name(zone_names)
return None
def format_immunities(immunities: Any) -> str:
if not isinstance(immunities, list):
return ""
out = []
for code in immunities:
code_str = str(code).strip().lower()
emoji = IMMUNITY_EMOJIS.get(code_str)
if emoji:
out.append(emoji)
else:
out.append(code_str.upper())
return "".join(out)
def format_tiers(entry: dict) -> str:
exp_tier = str(entry.get("tier-exp", "")).strip()
loot_tier = str(entry.get("tier-loot", "")).strip()
if exp_tier and loot_tier:
return f"{exp_tier}/{loot_tier}"
if exp_tier:
return f"{exp_tier}/?"
if loot_tier:
return f"?/{loot_tier}"
return ""
def make_zone_key(zone_name: str, end_time: Optional[int]) -> str:
return f"{zone_name}|{end_time}"
def extract_zone_info(data: Any) -> Tuple[Optional[str], Optional[int], Optional[int], str, str, Optional[str]]:
"""
Returns:
- zone_name
- minutes_left
- end_time
- immunity_icons
- tier_text
- zone_key
Selects the active entry using:
time <= now < end_time
"""
now = int(time.time())
def parse_entry(entry: dict) -> Tuple[Optional[str], Optional[int], Optional[int], str, str, Optional[str], Optional[int]]:
zone_name = format_zone_name(entry.get("zone_name"))
start_time = entry.get("time")
end_time = entry.get("end_time")
start_ts = int(start_time) if isinstance(start_time, (int, float)) else None
end_ts = int(end_time) if isinstance(end_time, (int, float)) else None
minutes_left = None
if end_ts is not None:
seconds_left = max(0, end_ts - now)
minutes_left = seconds_left // 60
immunity_icons = format_immunities(entry.get("immunities"))
tier_text = format_tiers(entry)
zone_key = make_zone_key(zone_name, end_ts) if zone_name else None
return zone_name, minutes_left, end_ts, immunity_icons, tier_text, zone_key, start_ts
if isinstance(data, list) and data:
parsed_entries = []
for item in data:
if isinstance(item, dict):
parsed_entries.append(parse_entry(item))
for zone_name, minutes_left, end_ts, immunity_icons, tier_text, zone_key, start_ts in parsed_entries:
if zone_name and start_ts is not None and end_ts is not None:
if start_ts <= now < end_ts:
return zone_name, minutes_left, end_ts, immunity_icons, tier_text, zone_key
future_entries = [
(zone_name, minutes_left, end_ts, immunity_icons, tier_text, zone_key, start_ts)
for zone_name, minutes_left, end_ts, immunity_icons, tier_text, zone_key, start_ts in parsed_entries
if zone_name and start_ts is not None and end_ts is not None and end_ts > now
]
if future_entries:
future_entries.sort( Key=lambda x: x[6])
zone_name, minutes_left, end_ts, immunity_icons, tier_text, zone_key, _start_ts = future_entries[0]
return zone_name, minutes_left, end_ts, immunity_icons, tier_text, zone_key
for zone_name, minutes_left, end_ts, immunity_icons, tier_text, zone_key, _start_ts in parsed_entries:
if zone_name:
return zone_name, minutes_left, end_ts, immunity_icons, tier_text, zone_key
if isinstance(data, dict):
zone_name, minutes_left, end_ts, immunity_icons, tier_text, zone_key, _start_ts = parse_entry(data)
return zone_name, minutes_left, end_ts, immunity_icons, tier_text, zone_key
return None, None, None, "", "", None
async def get_current_zone_info() -> Tuple[str, Optional[int], Optional[int], str, str, Optional[str]]:
data = await fetch_current_zone_data()
zone_name, minutes_left, end_time, immunity_icons, tier_text, zone_key = extract_zone_info(data)
if not zone_name:
raise RuntimeError(f"Could not find current zone in API response: {data}")
return zone_name, minutes_left, end_time, immunity_icons, tier_text, zone_key
# =========================================================
# ABBREVIATION
# =========================================================
def abbreviate_single_zone(zone_name: str) -> str:
normalized = normalize_zone_name(zone_name)
if normalized in ZONE_ABBREVIATIONS:
return ZONE_ABBREVIATIONS[normalized]
words = [w for w in normalized.replace("'", "").split() if w]
if not words:
return normalized
if len(words) == 1:
return words[0][:4]
return "".join(word[0].upper() for word in words)
def abbreviate_zone_string(zone_string: str) -> str:
parts = [part.strip() for part in zone_string.split("/")]
abbreviated_parts = [abbreviate_single_zone(part) for part in parts if part.strip()]
return " / ".join(abbreviated_parts)
def build_nickname(zone_name: str, minutes_left: Optional[int], immunity_icons: str, tier_text: str) -> str:
time_part = f" ({minutes_left}m)" if minutes_left is not None else ""
extras = []
if immunity_icons:
extras.append(immunity_icons)
if tier_text:
extras.append(tier_text)
extra_part = f" {' '.join(extras)}" if extras else ""
full_name = f"{zone_name}{extra_part}{time_part}"
if len(full_name) <= MAX_NICKNAME_LEN:
return full_name
abbreviated_zone = abbreviate_zone_string(zone_name)
abbreviated_full = f"{abbreviated_zone}{extra_part}{time_part}"
if len(abbreviated_full) <= MAX_NICKNAME_LEN:
return abbreviated_full
if immunity_icons and tier_text:
reduced_extra = f" {immunity_icons}"
reduced_name = f"{abbreviated_zone}{reduced_extra}{time_part}"
if len(reduced_name) <= MAX_NICKNAME_LEN:
return reduced_name
tiny_parts = []
for part in [p.strip() for p in zone_name.split("/")]:
abbr = abbreviate_single_zone(part)
tiny_parts.append(abbr[:3] if len(abbr) > 3 else abbr)
tiny_zone = " / ".join(tiny_parts)
tiny_full = f"{tiny_zone}{extra_part}{time_part}"
if len(tiny_full) <= MAX_NICKNAME_LEN:
return tiny_full
tiny_reduced = f"{tiny_zone}{(' ' + immunity_icons) if immunity_icons else ''}{time_part}"
if len(tiny_reduced) <= MAX_NICKNAME_LEN:
return tiny_reduced
absolute_fallback = f"{tiny_zone}{time_part}"
if len(absolute_fallback) <= MAX_NICKNAME_LEN:
return absolute_fallback
return absolute_fallback[:MAX_NICKNAME_LEN]
# =========================================================
# DISCORD HELPERS
# =========================================================
async def get_target_guild() -> discord.Guild:
guild = bot.get_guild(GUILD_ID)
if guild is None:
guild = await bot.fetch_guild(GUILD_ID)
if guild is None:
raise RuntimeError("Could not access target guild")
return guild
async def get_bot_member(guild: discord.Guild) -> discord.Member:
me = guild.me
if me is None:
if bot.user is None:
raise RuntimeError("Bot user is not available")
me = await guild.fetch_member(bot.user.id)
if me is None:
raise RuntimeError("Could not fetch bot member in guild")
return me
async def update_bot_nickname(zone_name: str, minutes_left: Optional[int], immunity_icons: str, tier_text: str) -> None:
guild = await get_target_guild()
me = await get_bot_member(guild)
nickname = build_nickname(zone_name, minutes_left, immunity_icons, tier_text)
if me.nick != nickname:
await me.edit(nick=nickname, reason="Updating nickname with current TZ, immunities, tiers, and time left")
print(f"Nickname updated to: {nickname}")
else:
print(f"Nickname already correct: {nickname}")
async def get_announce_channel() -> discord.TextChannel:
channel = bot.get_channel(ANNOUNCE_CHANNEL_ID)
if channel is None:
channel = await bot.fetch_channel(ANNOUNCE_CHANNEL_ID)
if not isinstance(channel, discord.TextChannel):
raise RuntimeError("ANNOUNCE_CHANNEL_ID is not a text channel")
return channel
async def announce_zone_change(old_zone: Optional[str], new_zone: str, minutes_left: Optional[int], immunity_icons: str, tier_text: str) -> None:
channel = await get_announce_channel()
bits = []
if immunity_icons:
bits.append(immunity_icons)
if tier_text:
bits.append(f"EXP/LOOT {tier_text}")
if minutes_left is not None:
bits.append(f"{minutes_left}m left")
suffix = f" ({', '.join(bits)})" if bits else ""
if old_zone is None:
message = f"Current zone detected: **{new_zone}**{suffix}"
else:
message = f"Zone changed: **{old_zone}** → **{new_zone}**{suffix}"
await channel.send(message)
print(f"Announcement sent: {message}")
# =========================================================
# LOOP
# =========================================================
@tasks.loop(seconds=CHECK_EVERY_SECONDS)
async def zone_watcher() -> None:
global last_announced_zone_key
try:
zone_name, minutes_left, _end_time, immunity_icons, tier_text, zone_key = await get_current_zone_info()
await update_bot_nickname(zone_name, minutes_left, immunity_icons, tier_text)
if zone_key != last_announced_zone_key:
old_zone = None
if last_announced_zone_key:
old_zone = last_announced_zone_key.split("|", 1)[0]
await announce_zone_change(old_zone, zone_name, minutes_left, immunity_icons, tier_text)
last_announced_zone_key = zone_key
except Exception as e:
print(f"zone_watcher error: {e}")
@zone_watcher.before_loop
async def before_zone_watcher() -> None:
await bot.wait_until_ready()
# =========================================================
# COMMANDS
# =========================================================
@bot.command()
async def zone(ctx: commands.Context) -> None:
try:
zone_name, minutes_left, end_time, immunity_icons, tier_text, _zone_key = await get_current_zone_info()
nickname_preview = build_nickname(zone_name, minutes_left, immunity_icons, tier_text)
msg = f"Current zone: **{zone_name}**"
if immunity_icons:
msg += f"\nImmunities: {immunity_icons}"
if tier_text:
msg += f"\nEXP/LOOT tier: `{tier_text}`"
if minutes_left is not None:
msg += f"\nTime left: `{minutes_left}m`"
msg += f"\nNickname preview: `{nickname_preview}`"
if end_time is not None:
msg += f"\nEnd time (unix): `{end_time}`"
await ctx.send(msg)
except Exception as e:
await ctx.send(f"Error reading current zone: `{e}`")
@bot.command()
async def zonedebug(ctx: commands.Context) -> None:
try:
data = await fetch_current_zone_data()
pretty = json.dumps(data, indent=2, ensure_ascii=False)
if len(pretty) > 3500:
pretty = pretty[:3500] + "\n... (truncated)"
await ctx.send(f"```json\n{pretty}\n```")
except Exception as e:
await ctx.send(f"Debug failed: `{e}`")
@bot.command()
async def legend(ctx: commands.Context) -> None:
lines = ["Zone abbreviation legend:"]
for full_name in sorted(ZONE_ABBREVIATIONS):
lines.append(f"{ZONE_ABBREVIATIONS[full_name]} = {full_name}")
lines.append("")
lines.append("Immunity legend:")
for code in ["f", "c", "l", "p", "ph", "m"]:
if code in IMMUNITY_EMOJIS:
lines.append(f"{IMMUNITY_EMOJIS[code]} = {IMMUNITY_LABELS.get(code, code)}")
message = "\n".join(lines)
if len(message) <= 1900:
await ctx.send(f"```text\n{message}\n```")
return
chunks = []
current = ""
for line in lines:
if len(current) + len(line) + 1 > 1900:
chunks.append(current)
current = line + "\n"
else:
current += line + "\n"
if current:
chunks.append(current)
for chunk in chunks:
await ctx.send(f"```text\n{chunk}\n```")
@bot.command()
@commands.has_permissions(administrator=True)
async def forcezone(ctx: commands.Context) -> None:
global last_announced_zone_key
try:
zone_name, minutes_left, _end_time, immunity_icons, tier_text, zone_key = await get_current_zone_info()
await update_bot_nickname(zone_name, minutes_left, immunity_icons, tier_text)
old_zone = last_announced_zone_key.split("|", 1)[0] if last_announced_zone_key else None
await announce_zone_change(old_zone, zone_name, minutes_left, immunity_icons, tier_text)
last_announced_zone_key = zone_key
await ctx.send(
f"Forced update complete: **{build_nickname(zone_name, minutes_left, immunity_icons, tier_text)}**"
)
except Exception as e:
await ctx.send(f"Force update failed: `{e}`")
# =========================================================
# EVENTS
# =========================================================
@bot.event
async def on_ready() -> None:
print(f"Logged in as {bot.user} ({bot.user.id})")
await get_http_session()
if not zone_watcher.is_running():
zone_watcher.start()
@bot.event
async def on_command_error(ctx: commands.Context, error: Exception) -> None:
if isinstance(error, commands.MissingPermissions):
await ctx.send("You do not have permission to use that command.")
elif isinstance(error, commands.CommandNotFound):
return
else:
await ctx.send(f"Command error: `{error}`")
# =========================================================
# MAIN
# =========================================================
async def main() -> None:
try:
await bot.start(BOT_TOKEN)
finally:
global http_session
if http_session and not http_session.closed:
await http_session.close()
if __name__ == "__main__":
asyncio.run(main())
It also sends a text message when the zone changes, but you can disable that if you want to ( I would keep it since it transmits the message without abreviations ).
A practical note: because nicknames are short, this code uses a priority order:
full zone names + immunities + tiers + time
abbreviated zone names + immunities + tiers + time
abbreviated zone names + immunities + time
abbreviated zone names + time
So if the nickname gets too long, the tiers are the first thing it drops, then it keeps the immunities and timer. That keeps the most useful info visible.
I took the liberty to also add the TZ time remaining, the Imunities displayed as emojis in the name, and also the loot/exp tier for said zones.
As a needed side-effect, I also abreviated the zone names when the bot changes his nick, since there is a hard 32 char limit on the nick a user can have on discord.
You will need to run it locally and invite it to your server.
It needs an .env file located in the same directory as the python file, with the following format:
DISCORD_BOT_TOKEN=discord_bot_token
D2TZ_TOKEN=your_d2tz_token ( TAKE CARE IT EXPIRES EVERY MONTH )
GUILD_ID=discord_server_id
ANNOUNCE_CHANNEL_ID=discord_chat_channel_id
CHECK_EVERY_SECONDS=60 ( refresh duration in seconds )
The following is the python code needed to run the bot:
import asyncio
import json
import os
import time
from typing import Any, Optional, Tuple
import aiohttp
import discord
from discord.ext import commands, tasks
from dotenv import load_dotenv
load_dotenv()
# =========================================================
# CONFIG
# =========================================================
BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
D2TZ_TOKEN = os.getenv("D2TZ_TOKEN")
GUILD_ID_RAW = os.getenv("GUILD_ID")
ANNOUNCE_CHANNEL_ID_RAW = os.getenv("ANNOUNCE_CHANNEL_ID")
CHECK_EVERY_SECONDS = int(os.getenv("CHECK_EVERY_SECONDS", "60"))
MAX_NICKNAME_LEN = 32
D2TZ_API_URL = "https://api.d2tz.info/public/tz"
if not BOT_TOKEN:
raise RuntimeError("DISCORD_BOT_TOKEN is not set in .env")
if not D2TZ_TOKEN:
raise RuntimeError("D2TZ_TOKEN is not set in .env")
if not GUILD_ID_RAW:
raise RuntimeError("GUILD_ID is not set in .env")
if not ANNOUNCE_CHANNEL_ID_RAW:
raise RuntimeError("ANNOUNCE_CHANNEL_ID is not set in .env")
try:
GUILD_ID = int(GUILD_ID_RAW)
ANNOUNCE_CHANNEL_ID = int(ANNOUNCE_CHANNEL_ID_RAW)
except ValueError as e:
raise RuntimeError("GUILD_ID and ANNOUNCE_CHANNEL_ID must be integers") from e
# =========================================================
# DISCORD SETUP
# =========================================================
intents = discord.Intents.default()
intents.message_content = True
bot = commands.Bot(command_prefix="!", intents=intents)
http_session: Optional[aiohttp.ClientSession] = None
last_announced_zone_key: Optional[str] = None
# =========================================================
# ABBREVIATION MAP
# =========================================================
ZONE_ABBREVIATIONS = {
" Abaddon": "Ab",
" Ancient Tunnels": "AT",
" Arachnid Lair": "AL",
" Arcane Sanctuary": "AS",
" Arreat Plateau": "AP",
" Arreat Summit": "ASM",
" Barracks": "Bar",
" Black Marsh": "BM",
" Blood Moor": "BldM",
" Bloody Foothills": "BldF",
" Burial Grounds": "BG",
" Canyon of the Magi": "CotM",
"Catacombs": "Cat",
" Cathedral": "Cath",
"Cave": "CV",
" City of the Damned": "CotD",
" Claw Viper Temple": "CVT",
" Cold Plains": "CP",
" Crypt": " Crypt",
" Crystalline Passage": "CrP",
" Dark Wood": "DW",
" Den of Evil": "DoE",
" Disused Fane": "DF",
" Disused Reliquary": "DR",
" Drifter Cavern": "DC",
" Dry Hills": "DH",
"Durance Of Hate": "DoH",
" Far Oasis": "FO",
"Flayer Dungeon": "FD",
" Flayer Jungle": "FJ",
" Forgotten Temple": "FTe",
"Forgotten Tower": "FT",
" Forgotten Reliquary": "FRe",
" Forgotten Sands": "FS",
" Frigid Highlands": "FH",
" Frozen River": "FrR",
" Frozen Tundra": "FrT",
" Furnace of Pain": "FoP",
" Glacial Trail": "GT",
" Great Marsh": "GM",
" Halls of Anguish": "HoA",
" Halls of Pain": "HoP",
"Halls Of The Dead": "HotD",
" Halls of Vaught": "HoV",
"Harem": "Harm",
" Harrogath": "Har",
"Hole": "Hole",
" Icy Cellar": "ICel",
" Infernal Pit": "IPit",
" Inner Cloister": "IC",
"Jail": "Jail",
" Kurast Bazaar": "KB",
" Kurast Docks": "KD",
"Kurast Sewers": "KS",
" Kurast Causeway": "KC",
" Lost City": "LC",
" Lower Kurast": "LK",
" Lut Gholein": "LG",
" Lut Gholein Sewers": "LGS",
"Maggot Lair": "ML",
"Marsh Of Pain": "MoP",
" Matron's Den": "MD",
" Mausoleum": "Maus",
"Moo Moo Farm": "MMF",
" Monastery Gate": "MG",
" Nihlathak's Temple": "NT",
" Outer Cloister": "OC",
" Outer Steppes": "OS",
"Palace Cellar": "PC",
" Pit of Acheron": "PoA",
" Plains of Despair": "PoD",
"Pit": "Pit",
" River of Flame": "RoF",
" Rocky Waste": "RW",
" Rogue Encampment": "RE",
" Ruined Fane": "RF",
" Ruined Temple": "RT",
"Sewers": "Sew",
" Spider Cavern": "SC",
" Spider Forest": "SF",
" Stony Field": "StF",
"Stony Tomb": "ST",
"Swampy Pit": "SwP",
" Tal Rasha's Tomb": "TRT",
" Tal Rasha's Chamber": "TRC",
" Tamoe Highland": "TH",
" The Ancients' Way": "TAW",
"The Chaos Sanctuary": "ChSan",
"The Crypt": " Crypt",
"The Hole": "Hole",
"The Pandemonium Fortress": "TPF",
"The Pit": "Pit",
" The Secret Cow Level": "Cows",
"The Worldstone Chamber": "TWSC",
" Throne of Destruction": "ToD",
"Tower Cellar": "TC",
" Travincal": "Trav",
" Tristram": "Trist",
"Underground Passage": "UP",
" Upper Kurast": "UK",
" Valley of Snakes": "VoS",
"Worldstone Keep": "WSK",
}
IMMUNITY_EMOJIS = {
"f": "🔥", # fire
"c": "❄️", # cold
"l": "⚡", # lightning
"p": "☠️", # poison
"ph": "🟣", # physical / placeholder
"m": "🧪", # magic / placeholder if it appears
}
IMMUNITY_LABELS = {
"f": "Fire",
"c": "Cold",
"l": "Lightning",
"p": "Poison",
"ph": "Physical",
"m": "Magic",
}
# =========================================================
# HTTP
# =========================================================
async def get_http_session() -> aiohttp.ClientSession:
global http_session
if http_session is None or http_session.closed:
timeout = aiohttp.ClientTimeout(total=15)
http_session = aiohttp.ClientSession(timeout=timeout)
return http_session
# =========================================================
# D2TZ API
# =========================================================
async def fetch_current_zone_data() -> Any:
session = await get_http_session()
params = {"token": D2TZ_TOKEN}
async with session.get(D2TZ_API_URL, params=params) as resp:
text = await resp.text()
if resp.status != 200:
raise RuntimeError(f"D2TZ API returned HTTP {resp.status}: {text[:300]}")
try:
data = json.loads(text)
except json.JSONDecodeError as e:
raise RuntimeError(f"D2TZ API did not return valid JSON: {text[:300]}") from e
if isinstance(data, dict) and data.get("status") == "error":
raise RuntimeError(f"D2TZ API error: {data.get('message', 'unknown error')}")
return data
def normalize_zone_name(name: str) -> str:
return name.replace("_", " ").strip()
def format_zone_name(zone_names: Any) -> Optional[str]:
if isinstance(zone_names, list) and zone_names:
cleaned = [normalize_zone_name(str(z)) for z in zone_names if str(z).strip()]
if cleaned:
return " / ".join(cleaned)
if isinstance(zone_names, str) and zone_names.strip():
return normalize_zone_name(zone_names)
return None
def format_immunities(immunities: Any) -> str:
if not isinstance(immunities, list):
return ""
out = []
for code in immunities:
code_str = str(code).strip().lower()
emoji = IMMUNITY_EMOJIS.get(code_str)
if emoji:
out.append(emoji)
else:
out.append(code_str.upper())
return "".join(out)
def format_tiers(entry: dict) -> str:
exp_tier = str(entry.get("tier-exp", "")).strip()
loot_tier = str(entry.get("tier-loot", "")).strip()
if exp_tier and loot_tier:
return f"{exp_tier}/{loot_tier}"
if exp_tier:
return f"{exp_tier}/?"
if loot_tier:
return f"?/{loot_tier}"
return ""
def make_zone_key(zone_name: str, end_time: Optional[int]) -> str:
return f"{zone_name}|{end_time}"
def extract_zone_info(data: Any) -> Tuple[Optional[str], Optional[int], Optional[int], str, str, Optional[str]]:
"""
Returns:
- zone_name
- minutes_left
- end_time
- immunity_icons
- tier_text
- zone_key
Selects the active entry using:
time <= now < end_time
"""
now = int(time.time())
def parse_entry(entry: dict) -> Tuple[Optional[str], Optional[int], Optional[int], str, str, Optional[str], Optional[int]]:
zone_name = format_zone_name(entry.get("zone_name"))
start_time = entry.get("time")
end_time = entry.get("end_time")
start_ts = int(start_time) if isinstance(start_time, (int, float)) else None
end_ts = int(end_time) if isinstance(end_time, (int, float)) else None
minutes_left = None
if end_ts is not None:
seconds_left = max(0, end_ts - now)
minutes_left = seconds_left // 60
immunity_icons = format_immunities(entry.get("immunities"))
tier_text = format_tiers(entry)
zone_key = make_zone_key(zone_name, end_ts) if zone_name else None
return zone_name, minutes_left, end_ts, immunity_icons, tier_text, zone_key, start_ts
if isinstance(data, list) and data:
parsed_entries = []
for item in data:
if isinstance(item, dict):
parsed_entries.append(parse_entry(item))
for zone_name, minutes_left, end_ts, immunity_icons, tier_text, zone_key, start_ts in parsed_entries:
if zone_name and start_ts is not None and end_ts is not None:
if start_ts <= now < end_ts:
return zone_name, minutes_left, end_ts, immunity_icons, tier_text, zone_key
future_entries = [
(zone_name, minutes_left, end_ts, immunity_icons, tier_text, zone_key, start_ts)
for zone_name, minutes_left, end_ts, immunity_icons, tier_text, zone_key, start_ts in parsed_entries
if zone_name and start_ts is not None and end_ts is not None and end_ts > now
]
if future_entries:
future_entries.sort( Key=lambda x: x[6])
zone_name, minutes_left, end_ts, immunity_icons, tier_text, zone_key, _start_ts = future_entries[0]
return zone_name, minutes_left, end_ts, immunity_icons, tier_text, zone_key
for zone_name, minutes_left, end_ts, immunity_icons, tier_text, zone_key, _start_ts in parsed_entries:
if zone_name:
return zone_name, minutes_left, end_ts, immunity_icons, tier_text, zone_key
if isinstance(data, dict):
zone_name, minutes_left, end_ts, immunity_icons, tier_text, zone_key, _start_ts = parse_entry(data)
return zone_name, minutes_left, end_ts, immunity_icons, tier_text, zone_key
return None, None, None, "", "", None
async def get_current_zone_info() -> Tuple[str, Optional[int], Optional[int], str, str, Optional[str]]:
data = await fetch_current_zone_data()
zone_name, minutes_left, end_time, immunity_icons, tier_text, zone_key = extract_zone_info(data)
if not zone_name:
raise RuntimeError(f"Could not find current zone in API response: {data}")
return zone_name, minutes_left, end_time, immunity_icons, tier_text, zone_key
# =========================================================
# ABBREVIATION
# =========================================================
def abbreviate_single_zone(zone_name: str) -> str:
normalized = normalize_zone_name(zone_name)
if normalized in ZONE_ABBREVIATIONS:
return ZONE_ABBREVIATIONS[normalized]
words = [w for w in normalized.replace("'", "").split() if w]
if not words:
return normalized
if len(words) == 1:
return words[0][:4]
return "".join(word[0].upper() for word in words)
def abbreviate_zone_string(zone_string: str) -> str:
parts = [part.strip() for part in zone_string.split("/")]
abbreviated_parts = [abbreviate_single_zone(part) for part in parts if part.strip()]
return " / ".join(abbreviated_parts)
def build_nickname(zone_name: str, minutes_left: Optional[int], immunity_icons: str, tier_text: str) -> str:
time_part = f" ({minutes_left}m)" if minutes_left is not None else ""
extras = []
if immunity_icons:
extras.append(immunity_icons)
if tier_text:
extras.append(tier_text)
extra_part = f" {' '.join(extras)}" if extras else ""
full_name = f"{zone_name}{extra_part}{time_part}"
if len(full_name) <= MAX_NICKNAME_LEN:
return full_name
abbreviated_zone = abbreviate_zone_string(zone_name)
abbreviated_full = f"{abbreviated_zone}{extra_part}{time_part}"
if len(abbreviated_full) <= MAX_NICKNAME_LEN:
return abbreviated_full
if immunity_icons and tier_text:
reduced_extra = f" {immunity_icons}"
reduced_name = f"{abbreviated_zone}{reduced_extra}{time_part}"
if len(reduced_name) <= MAX_NICKNAME_LEN:
return reduced_name
tiny_parts = []
for part in [p.strip() for p in zone_name.split("/")]:
abbr = abbreviate_single_zone(part)
tiny_parts.append(abbr[:3] if len(abbr) > 3 else abbr)
tiny_zone = " / ".join(tiny_parts)
tiny_full = f"{tiny_zone}{extra_part}{time_part}"
if len(tiny_full) <= MAX_NICKNAME_LEN:
return tiny_full
tiny_reduced = f"{tiny_zone}{(' ' + immunity_icons) if immunity_icons else ''}{time_part}"
if len(tiny_reduced) <= MAX_NICKNAME_LEN:
return tiny_reduced
absolute_fallback = f"{tiny_zone}{time_part}"
if len(absolute_fallback) <= MAX_NICKNAME_LEN:
return absolute_fallback
return absolute_fallback[:MAX_NICKNAME_LEN]
# =========================================================
# DISCORD HELPERS
# =========================================================
async def get_target_guild() -> discord.Guild:
guild = bot.get_guild(GUILD_ID)
if guild is None:
guild = await bot.fetch_guild(GUILD_ID)
if guild is None:
raise RuntimeError("Could not access target guild")
return guild
async def get_bot_member(guild: discord.Guild) -> discord.Member:
me = guild.me
if me is None:
if bot.user is None:
raise RuntimeError("Bot user is not available")
me = await guild.fetch_member(bot.user.id)
if me is None:
raise RuntimeError("Could not fetch bot member in guild")
return me
async def update_bot_nickname(zone_name: str, minutes_left: Optional[int], immunity_icons: str, tier_text: str) -> None:
guild = await get_target_guild()
me = await get_bot_member(guild)
nickname = build_nickname(zone_name, minutes_left, immunity_icons, tier_text)
if me.nick != nickname:
await me.edit(nick=nickname, reason="Updating nickname with current TZ, immunities, tiers, and time left")
print(f"Nickname updated to: {nickname}")
else:
print(f"Nickname already correct: {nickname}")
async def get_announce_channel() -> discord.TextChannel:
channel = bot.get_channel(ANNOUNCE_CHANNEL_ID)
if channel is None:
channel = await bot.fetch_channel(ANNOUNCE_CHANNEL_ID)
if not isinstance(channel, discord.TextChannel):
raise RuntimeError("ANNOUNCE_CHANNEL_ID is not a text channel")
return channel
async def announce_zone_change(old_zone: Optional[str], new_zone: str, minutes_left: Optional[int], immunity_icons: str, tier_text: str) -> None:
channel = await get_announce_channel()
bits = []
if immunity_icons:
bits.append(immunity_icons)
if tier_text:
bits.append(f"EXP/LOOT {tier_text}")
if minutes_left is not None:
bits.append(f"{minutes_left}m left")
suffix = f" ({', '.join(bits)})" if bits else ""
if old_zone is None:
message = f"Current zone detected: **{new_zone}**{suffix}"
else:
message = f"Zone changed: **{old_zone}** → **{new_zone}**{suffix}"
await channel.send(message)
print(f"Announcement sent: {message}")
# =========================================================
# LOOP
# =========================================================
@tasks.loop(seconds=CHECK_EVERY_SECONDS)
async def zone_watcher() -> None:
global last_announced_zone_key
try:
zone_name, minutes_left, _end_time, immunity_icons, tier_text, zone_key = await get_current_zone_info()
await update_bot_nickname(zone_name, minutes_left, immunity_icons, tier_text)
if zone_key != last_announced_zone_key:
old_zone = None
if last_announced_zone_key:
old_zone = last_announced_zone_key.split("|", 1)[0]
await announce_zone_change(old_zone, zone_name, minutes_left, immunity_icons, tier_text)
last_announced_zone_key = zone_key
except Exception as e:
print(f"zone_watcher error: {e}")
@zone_watcher.before_loop
async def before_zone_watcher() -> None:
await bot.wait_until_ready()
# =========================================================
# COMMANDS
# =========================================================
@bot.command()
async def zone(ctx: commands.Context) -> None:
try:
zone_name, minutes_left, end_time, immunity_icons, tier_text, _zone_key = await get_current_zone_info()
nickname_preview = build_nickname(zone_name, minutes_left, immunity_icons, tier_text)
msg = f"Current zone: **{zone_name}**"
if immunity_icons:
msg += f"\nImmunities: {immunity_icons}"
if tier_text:
msg += f"\nEXP/LOOT tier: `{tier_text}`"
if minutes_left is not None:
msg += f"\nTime left: `{minutes_left}m`"
msg += f"\nNickname preview: `{nickname_preview}`"
if end_time is not None:
msg += f"\nEnd time (unix): `{end_time}`"
await ctx.send(msg)
except Exception as e:
await ctx.send(f"Error reading current zone: `{e}`")
@bot.command()
async def zonedebug(ctx: commands.Context) -> None:
try:
data = await fetch_current_zone_data()
pretty = json.dumps(data, indent=2, ensure_ascii=False)
if len(pretty) > 3500:
pretty = pretty[:3500] + "\n... (truncated)"
await ctx.send(f"```json\n{pretty}\n```")
except Exception as e:
await ctx.send(f"Debug failed: `{e}`")
@bot.command()
async def legend(ctx: commands.Context) -> None:
lines = ["Zone abbreviation legend:"]
for full_name in sorted(ZONE_ABBREVIATIONS):
lines.append(f"{ZONE_ABBREVIATIONS[full_name]} = {full_name}")
lines.append("")
lines.append("Immunity legend:")
for code in ["f", "c", "l", "p", "ph", "m"]:
if code in IMMUNITY_EMOJIS:
lines.append(f"{IMMUNITY_EMOJIS[code]} = {IMMUNITY_LABELS.get(code, code)}")
message = "\n".join(lines)
if len(message) <= 1900:
await ctx.send(f"```text\n{message}\n```")
return
chunks = []
current = ""
for line in lines:
if len(current) + len(line) + 1 > 1900:
chunks.append(current)
current = line + "\n"
else:
current += line + "\n"
if current:
chunks.append(current)
for chunk in chunks:
await ctx.send(f"```text\n{chunk}\n```")
@bot.command()
@commands.has_permissions(administrator=True)
async def forcezone(ctx: commands.Context) -> None:
global last_announced_zone_key
try:
zone_name, minutes_left, _end_time, immunity_icons, tier_text, zone_key = await get_current_zone_info()
await update_bot_nickname(zone_name, minutes_left, immunity_icons, tier_text)
old_zone = last_announced_zone_key.split("|", 1)[0] if last_announced_zone_key else None
await announce_zone_change(old_zone, zone_name, minutes_left, immunity_icons, tier_text)
last_announced_zone_key = zone_key
await ctx.send(
f"Forced update complete: **{build_nickname(zone_name, minutes_left, immunity_icons, tier_text)}**"
)
except Exception as e:
await ctx.send(f"Force update failed: `{e}`")
# =========================================================
# EVENTS
# =========================================================
@bot.event
async def on_ready() -> None:
print(f"Logged in as {bot.user} ({bot.user.id})")
await get_http_session()
if not zone_watcher.is_running():
zone_watcher.start()
@bot.event
async def on_command_error(ctx: commands.Context, error: Exception) -> None:
if isinstance(error, commands.MissingPermissions):
await ctx.send("You do not have permission to use that command.")
elif isinstance(error, commands.CommandNotFound):
return
else:
await ctx.send(f"Command error: `{error}`")
# =========================================================
# MAIN
# =========================================================
async def main() -> None:
try:
await bot.start(BOT_TOKEN)
finally:
global http_session
if http_session and not http_session.closed:
await http_session.close()
if __name__ == "__main__":
asyncio.run(main())
It also sends a text message when the zone changes, but you can disable that if you want to ( I would keep it since it transmits the message without abreviations ).
A practical note: because nicknames are short, this code uses a priority order:
full zone names + immunities + tiers + time
abbreviated zone names + immunities + tiers + time
abbreviated zone names + immunities + time
abbreviated zone names + time
So if the nickname gets too long, the tiers are the first thing it drops, then it keeps the immunities and timer. That keeps the most useful info visible.
Similar pages
Advertisment
Hide ads
Greetings stranger!
You don't appear to be logged in...
99
Who is online
Users browsing Forums:
666bender,
Canvas,
Cracume,
DotNetDotCom.org [Bot],
Duo,
ghostpos,
Google Feedfetcher,
human_being,
JJJJ_TheJapanese,
KOKUSHO0735,
mastasorc,
mhlg,
MysticRanger200,
NaglFarfar,
PetalBot [Bot],
ShadowHeart,
Sogou [Spider],
Timecourse23,
xuehang1985,
Zaseknutej and 98 guests.
No matches
Bismillah
5