""" Sopel plugin: osterman - anti-idle Warns idle users via NOTICE, kicks if they stay idle past the kick threshold. Idle state is tracked per channel. Thresholds are in minutes; set to 0 to disable warn or kick. Degrades gracefully by privilege level: halfop or op - warn and kick none - warn only (cannot kick) !afk - reset your own idle timer !idle on|off - enable/disable idle checking (ACL/owner; per channel if in channel, global if PM) !idle - show how long a nick has been idle in this channel """ import json import os import time from sopel import plugin def _bot_has_halfop(bot, channel): chan = bot.channels.get(channel) if not chan: return False return chan.privileges.get(bot.nick, 0) >= plugin.HALFOP def _chan_cfg(bot, channel): global_cfg = bot.memory.get("os_db", {}).get("config", {}) chan_cfg = (bot.memory.get("os_db", {}) .get("channels", {}) .get(channel.lower(), {}) .get("config", {})) return {**global_cfg, **chan_cfg} @plugin.interval(60) def _idle_tick(bot): now = time.time() last = bot.memory.get("os_last", {}) for channel_name, channel in list(bot.channels.items()): cfg = _chan_cfg(bot, channel_name) warn_min = cfg.get("idle_warn_min", 45) kick_min = cfg.get("idle_kick_min", 60) if warn_min <= 0 and kick_min <= 0: continue can_kick = _bot_has_halfop(bot, channel_name) for nick in list(channel.users): if nick == bot.nick: continue idle_sec = now - last.get((channel_name, nick), now) idle_min = idle_sec / 60 if kick_min > 0 and idle_min >= kick_min: if can_kick: bot.kick(channel_name, nick, f"idle for {int(idle_min)}m") last.pop((channel_name, nick), None) else: bot.notice(f"you have been idle for {int(idle_min)}m in {channel_name} (cannot kick without halfop)", nick) elif warn_min > 0 and idle_min >= warn_min: bot.notice(f"you have been idle for {int(idle_min)}m in {channel_name} - say something or get kicked", nick) @plugin.command("afk") @plugin.require_chanmsg def cmd_afk(bot, trigger): channel = str(trigger.sender) bot.memory.setdefault("os_last", {})[(channel, trigger.nick)] = time.time() bot.notice("idle timer reset", trigger.nick) def _is_owner(bot, trigger): return trigger.nick == bot.settings.core.owner def _is_acl(bot, trigger): return trigger.nick in bot.memory.get("os_db", {}).get("acl", []) @plugin.command("idle", "id") def cmd_idle(bot, trigger): arg = (trigger.group(2) or "").strip().lower() in_chan = str(trigger.sender).startswith("#") if arg in ("on", "off"): if not _is_owner(bot, trigger) and not _is_acl(bot, trigger): return db = bot.memory.get("os_db", {}) if in_chan: channel = str(trigger.sender) cfg = (db.setdefault("channels", {}) .setdefault(channel.lower(), {}) .setdefault("config", {})) else: cfg = db.setdefault("config", {}) if arg == "off": cfg["idle_warn_min"] = 0 cfg["idle_kick_min"] = 0 bot.notice("idle checking disabled", trigger.nick) else: cfg["idle_warn_min"] = 45 cfg["idle_kick_min"] = 60 bot.notice("idle checking enabled (warn 45m, kick 60m)", trigger.nick) path = bot.memory.get("os_db_path", "/var/osterman/db.json") os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as fp: json.dump(db, fp, indent=2) return target = arg or trigger.nick if not in_chan: bot.notice("use !idle in a channel to check idle time", trigger.nick) return channel = str(trigger.sender) last = bot.memory.get("os_last", {}).get((channel, target)) if last is None: bot.say(f"{target}: no idle data", trigger.sender) return diff = int(time.time() - last) if diff < 60: idle_str = f"{diff}s" elif diff < 3600: idle_str = f"{diff // 60}m" elif diff < 86400: idle_str = f"{diff // 3600}h {(diff % 3600) // 60}m" else: idle_str = f"{diff // 86400}d {(diff % 86400) // 3600}h" bot.say(f"{target} idle: {idle_str}", trigger.sender)