Search the database
Search forum topics
Search members
Search for trades
diablo2.io is supported by ads
diablo2.io is supported by ads
2 replies   263 views
2

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/
5

Can be used to make Runewords:

7
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/
7
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.
9

Advertisment

Hide ads
999

Greetings stranger!

You don't appear to be logged in...

No matches
 

 

 

 

You haven't specified which diablo2.io user you completed this trade with. This means that you will not be able to exchange trust.

Are you sure you want to continue?

Yes, continue without username
No, I will specify a username
Are you sure you want to delete your entire Holy Grail collection? This action is irreversible.

Are you sure you want to continue?

Yes, delete my entire collection
No, I want to keep my collection
Choose which dclone tracking options you want to see in this widget:
Version:
Value:
Hide ads forever by supporting the site with a donation.

Greetings adblocker...

Warriv asks that you consider disabling your adblocker when using diablo2.io

Ad revenue helps keep the servers going and supports me, the site's creator :)

A one-time donation hides all ads, forever:
Make a donation