#!/usr/bin/env python3

# /// script
# dependencies = ["aiohttp"]
# ///

import itertools as it, operator as op, functools as ft
import contextlib as cl, datetime as dt, pathlib as pl
import collections as cs, collections.abc as cs_abc, urllib.parse as up
import os, sys, errno, io, stat, re, time, secrets, enum, json, zlib, gzip, math
import asyncio, socket, signal, inspect, configparser, tempfile, fnmatch
import pickle, hashlib, base64, random, textwrap, unicodedata, string, ast
import logging, logging.handlers

import aiohttp


class RDIRCDConfigBase:
	# This is a regular ini file with key=value lines under couple sections and ;-comments.
	# String time intervals can be either simple floats (seconds) or stuff like
	#  "30s", "10min", "1h 20m", "1mo3d5h", etc - see parse_duration func for that.
	# For values ending with whitespace (like prefixes) in ini config files,
	#  add backslash ("\") at the end after spaces, for example: prefix-edit = E: \
	# Values starting with spaces/tabs should be prefixed
	#  with a backslash too. Example: prefix-guild-event = \ - ev
	# "<xyz>-tbf" rate-limits are token-bucket specs in "interval[/rate][:burst]" format,
	#  where "interval[/rate]" part can be just interval in seconds (integer or real number),
	#  for example: 1/4:5 (interval=0.25s, rate=4/s, burst=5), 5 (burst=1), 0.5:10, 20:30,
	#  and also special "0" value can be used as "no rate limit", or "inf" for "always block".

	version = '25.01.5' # git-version: py-str

	auth_email = '' # discord account email, used to login there
	auth_password = ''

	auth_token = '' # auto-fetched using email/password, unless mfa/captcha is in the way
	auth_token_manual = False # never fetch/refresh auth token, for captcha/mfa logins

	irc_password_hash = '' # use -H/--conf-pw-scrypt option to generate hash for this
	irc_password = '' # for plaintext password - do not use, use password-hash instead
	irc_port = 6667 # irc listening port, can be set in -i/--irc-bind, see also TLS opts below
	irc_host = '127.0.0.1' # socket bind addr, can be set via -i/--irc-bind cli option
	irc_host_af = 0 # address family - 0=any (auto-picked by getaddrinfo) 2=IPv4 10=IPv6

	irc_uid_seed = '' # determines all generated unique-id tokens, like discord chan prefixes
	irc_uid_len = 4 # can be made longer to avoid clashes in various uid-tokens

	# Path to PEM file with cert and key to use TLS
	#  for all IRC connections, with default python/openssl server-side parameters.
	# If file is specified but missing, it will be auto-generated by default.
	irc_tls_pem_file = ''
	# "openssl req -subj" value to generate
	#  cert PEM file with, if specified path is missing. Empty - disabled.
	irc_tls_pem_gen_subj = '/CN=rdircd'
	irc_tls = None # socket tls context, if enabled

	irc_motd_file_path = '' # file path to read for motd command
	irc_names_timeout = '1d' # time since last activity to "forget" channel /names
	irc_names_join = True # send irc-join msgs, for new channel users within names-timeout
	irc_nick_sys = 'core' # nick used as source for info/error notice-msgs
	irc_auth_tbf = '30:8' # token-bucket login-attempt rate-limiter, rate=1/30s burst=8

	irc_prefix_edit = '[edit] '
	irc_prefix_attachment = '[att] '
	irc_prefix_embed = '[em.{}] '
	irc_prefix_sticker = '[sticker] '
	irc_prefix_uis = '[UIs] '
	irc_prefix_interact = '[cmd] '
	irc_prefix_poll = '[poll.{}] '
	irc_prefix_pinned = '[pin] '
	irc_prefix_guild_event = '--- ' # user bans, friend statuses, scheduled guild events, etc
	irc_prefix_event = '--- ' # user/chan events - reactions, voice-chats, statuses, gifts, etc
	irc_prefix_all = '' # universal prefix to add to all non-notice incoming messages
	irc_prefix_all_private = '' # universal prefix to include in all incoming private-chat msgs

	irc_len_hwm = 450 # split irc lines when they're longer than hwm to lwm length
	irc_len_lwm = 300
	irc_len_dont_split_re = r'\x1b\\(.*?)\x1b]8;;\x1b\\' # terminal links to avoid splitting
	irc_len_topic = 300 # cannot be split, so is truncated instead
	irc_len_monitor = 350 # to tuncate long lines in #rdircd.monitor/leftover channels
	irc_len_monitor_lines = 4 # to limit multiline msgs in #rdircd.monitor/leftover channels

	irc_chan_modes = False # causes needless spam on ZNC reconnects
	irc_chan_auto_join_re = '' # regexp to match against irc chan names to auto-join
	irc_disable_reacts_msgs = False # disables incoming "--- reacts" notifications from users
	irc_inline_reply_quote_len = 90 # limits length of replied-to msgs, 0 = disable it
	irc_ref_quote_len = 70 # len-limit on referenced msgs in reactions and such, 0 = hide those
	irc_ref_allow_disabling_pings = True # don't ping irc nick if replying user disables it

	irc_private_chat_min_others_to_use_id_name = 2 # for #me.chat.user1+user2+... vs #me.chat.<id>
	irc_private_chat_name_len = 120 # long userlist-name will truncate usernames, depends on client
	irc_private_chat_name_user_min_len = 20 # don't truncate usernames in userlist-name beyond that

	irc_thread_chan_name_len = 30 # truncate thread-name to this len in thread-chans, 0 = just id

	irc_chan_sys = 'rdircd.{type}' # name format for type=control and type=debug channels
	# Name format (python str.format) for private chat channels.
	# {names} or {id} can be used instead of {names_or_id} to force e.g. #me.chat.<id> format.
	# Formatting keys allowed here: names, id, names_or_id, chat_name
	irc_chan_private = 'chat.{names_or_id}'
	# Name of catch-all "monitor" channel for all msgs. Empty value - won't be created.
	irc_chan_monitor = 'rdircd.monitor'
	# Name format of per-discord
	#  "monitor" channels for all msgs in there. Empty - disabled.
	irc_chan_monitor_guild = 'rdircd.monitor.{prefix}'

	# Name of "leftover" channel for any discord messages in channels that
	#  IRC client is not connected to. "monitor" channels don't count. Empty - won't be created.
	irc_chan_leftover = 'rdircd.leftover'
	# Name of "leftover" chan for msgs in any
	#  non-joined channels of one specific discord server/guild. Empty - disable.
	irc_chan_leftover_guild = 'rdircd.leftover.{prefix}'
	# Name for a global voice-chat notifications channel. Empty - won't be created.
	irc_chan_voice = 'rdircd.voice'
	# Per-discord voice-mon chans are default-disabled, set e.g. rdircd.voice.{prefix} to enable.
	irc_chan_voice_guild = ''

	# Space-separated list of allowed IRCv3 capabilities, if supported by rdircd and client.
	irc_ircv3_caps = 'message-tags'

	# Interval (seconds) to repeat typing notifications from discord to irc client (if supported).
	# Discord sends typing notifications every ~10s, ircv3 requires these to be every 3-6s.
	# Default is "repeat +1 notification 4.5s after one from discord", so that in
	#  ~10s irc client either assumes that typing stopped or gets new one from discord.
	# Setting interval to 0 hard-disables receiving typing notifications from discord users.
	# Setting timeout to 0 will only proxy notifications from discord as-is.
	irc_typing_interval = 4.5
	irc_typing_timeout = 6.0 # timeout after which typing-notification repeats stop
	# Send typing events from irc client (if it generates them) to a discord channel.
	# Default-disabled for privacy reasons.
	irc_typing_send_enabled = False

	# topic-*: topic templates for all channels, using python str.format templating
	# Following template-vars are available, where they make sense, empty strings otherwise:
	#  guild_id, guild_name, guild_prefix, chan_id, chan_tags, chan_topic
	irc_topic_control = 'rdircd: control channel, type "help" for more info'
	irc_topic_debug = 'rdircd: debug logging channel, type "help" for more info'
	irc_topic_monitor = 'rdircd: read-only catch-all channel with messages from everywhere'
	irc_topic_monitor_guild = 'rdircd: [ {guild_name} ] read-only catch-all channel for discord'
	irc_topic_leftover = ( 'rdircd: read-only channel for any'
		' discord messages in channels that IRC client is not connected to' )
	irc_topic_leftover_guild = ( 'rdircd: [ {guild_name} ]'
		' read-only msgs for non-joined channels of discord' )
	irc_topic_voice = 'rdircd: read-only voice-chat notifications from all discords/channels'
	irc_topic_voice_guild = 'rdircd: [ {guild_name} ] read-only voice-chat events for discord'
	irc_topic_channel = '{guild_name}: {chan_tags}{chan_topic}'

	discord_auto_connect = True
	discord_api_url = 'https://discord.com/api/v{api_ver}/'
	discord_api_user_agent = ( f'rdircd/{version}'
		f' (reliable-discord-irc-client) aiohttp/{aiohttp.__version__}' )
	discord_ws_conn_timeout = 20.0
	discord_ws_heartbeat = 15.0
	discord_ws_auth_timeout = 60.0 # reconnect websocket if auth process lags too badly
	discord_ws_reconnect_min = '70s'
	discord_ws_reconnect_max = '12min'
	discord_ws_reconnect_factor = 1.6
	discord_ws_reconnect_warn_tbf = '1800:6' # rate-limit to filter-out expected reconnects
	discord_ws_reconnect_warn_max_delay = True # log warnings when reconnects reached max delay
	discord_ws_reconnect_warn_always = False # log warning with session info on every reconnect
	discord_ws_reconnect_on_auth_fail = False # useful for discord service disruptions
	discord_http_delay_padding = 10.0 # added to retry_after
	discord_http_timeout_conn = 40.0
	discord_http_timeout_conn_sock = 30.0
	discord_gateway = '' # fetched and stored in last config, region-specific

	# Determines which name is used for discord users on IRC.
	# Following name options are available: login display nick
	# "login" is always used as a fallback, if no other names are set,
	#  but otherwise names above can be listed in order to attempt using them.
	# For example, "nick display" value will try discord/friend nickname,
	#  then name that user set in account settings, and login name if there aren't any of those.
	discord_name_preference_order = 'nick display login'

	# discord_msg_mention_re should match only discord user mentions.
	# "nick" group must be irc nick, to be replaced with
	#  discord user-id tag, all other capturing groups are replaced by "".
	# On match, either unique mention for <nick> will be used, or msg not sent with error.
	# Don't use repeating/overlapping capturing groups (w/o "?:"). Empty value - disable.
	discord_msg_mention_re = r'(?:^|\s)(@)(?P<nick>[^\s,;@+!]+)'
	# discord_msg_mention_re_ignore is matched
	#  against full capture of the regexp above, not full line.
	discord_msg_mention_re_ignore = r'@(?:everyone|here)'
	discord_msg_mention_irc_decode = True # try irc_name_revert on mention-matches from irc

	# Regexp with "emoji" group for discord emojis like :debian: - similar to user mentions.
	# If matches, either guild-emoji for tag is found/translated, or msg sending error returned.
	discord_msg_emoji_re = r'(?:^|\s)(:)(?P<emoji>[-\w+]+)(:)(?=[^\w]|$)'
	# File with a list of generic emojis translated to unicode in any discord.
	discord_msg_emoji_unicode_list_file = 'rdircd.unicode-emojis.txt.gz'

	discord_msg_confirm_timeout = 25.0 # can include extra requests to resolve user-mentions
	discord_user_mention_cache_timeout = '3d' # to remember nicks for user-mentions
	discord_user_query_timeout = 60.0
	discord_user_query_limit = 5 # max results to return for mentions and /who queries
	discord_media_info_timeout = '30m' # ignore no-author attachment updates for old msgs

	discord_msg_ack = True # send ACKs for received (and not filtered-out) msgs in private chans
	discord_msg_ack_delay = '45m' # delay ACKing last-seen msg, can be lowered for short-lived rdircd
	discord_msg_ack_always = False # also ACK blocked msgs or when no IRC clients connected

	discord_msg_old_upd_timestamp = '1d' # add date/time to msg edits older than this, 0 - disable
	discord_msg_old_upd_ignore = '4d' # completely ignore msg edits older than this, 0 - disable

	# discord_msg_edit_re is a regexp to match follow-up last-message edits, e.g. s/aaa/bbb/.
	# "aaa" group is used as a python regexp to match what to replace, "bbb" - replacement.
	# Any msg matched by this regexp is treated as edit for re.sub(), never sent to channel.
	# If re.sub() with these parameters makes no replacement(s), error notice is generated.
	# Default regex matches s/A/B/ or s|A|B| or s:A:B: - sed/perl-like regexp-replace expressions.
	discord_msg_edit_re = r'^\s*s(?P<sep>[/|:])(?P<aaa>.*)(?P=sep)(?P<bbb>.*)(?P=sep)\s*$'
	discord_msg_del_re = r'^\s*//del\s*$' # deletes last-sent msg, if matched, never sent to channel

	# Suppress push notifications for this message and remove regexp-matched part.
	# Default is to match same @silent prefix as is used by the official discord client.
	discord_msg_flag_silent_re = r'^@silent(\s|$)'

	# Prefix for thread-id values, used in thread-chan names
	#  and msg prefixes if propagation from these to parent channel is enabled.
	discord_thread_id_prefix = '='
	# Propagates messages from threads to a parent channel,
	#  adding thread-id prefix, through which response can be redirected as well, if enabled.
	discord_thread_msgs_in_parent_chan = True
	# Enable to see mirrored msgs
	#  in #rdircd.monitor channel(s) too - e.g. to have it all there as-is.
	discord_thread_msgs_in_parent_chan_monitor = False
	# Enable to use full
	#  chan-name prefix instead of shorter thread-id, for IRC clients with easy click-to-join.
	discord_thread_msgs_in_parent_chan_full_prefix = False
	# discord_thread_redirect_prefixed_responses_from_parent_chan allows to send msgs
	#  to threads from parent channel by prepending thread-id prefix to each one of these.
	# For example "=vot5 hi!" will send "hi!" msg to =vot5 thread
	#  sub-channel only, or print an error if such thread-id is not recognized.
	discord_thread_redirect_prefixed_responses_from_parent_chan = True

	# Print info for some embedded youtube, twitter, etc links, if/when discord provides it
	discord_embed_info = True
	discord_embed_info_buffer = 40 # last N links to remember for delayed annotation updates
	discord_embed_info_len = 250 # will truncate long youtube titles and twitter msgs

	# Replace attachment URLs with OSC 8 terminal links, if enabled.
	discord_terminal_links = False
	# Regexp for which parts of the URL to use in OSC 8 hyperlink names.
	# Can have "name" and "hash" capture groups, with only "name" being mandatory.
	# Non-empty "hash" group will be hashed to short tag to tell same-name links apart.
	discord_terminal_links_re = (
		r'^https://cdn\.discordapp\.com/attachments/(?P<hash>[\d/]+/(?P<name>.+?)\?.*)' )
	# Use links for posted emoji-images instead of long CDN URLs, if enabled.
	discord_terminal_links_emojis = True
	# Template for OSC 8 terminal hyperlink. Should have "url" and "name" keys in it.
	# See also corresponding [irc] len-dont-split-re opt to avoid breaking these links in long msgs.
	discord_terminal_links_tpl = '\x1b]8;;{url}\x1b\\{name}\x1b]8;;\x1b\\'

	# Changes discord status on IRC /away and/or IRC client connect/disconnect.
	discord_status_set = True
	# IRC events to set discord status for - back, connect, away, disconnect.
	# Discord statuses - online, invisible, idle, dnd, offline, streaming, unknown.
	discord_status_events = 'back/connect=online away/disconnect=invisible'

	discord_chan_dedup_fmt = '{name}.{id_hash}' # how same-irc-name discord chans get disambiguated
	discord_chan_dedup_hash_len = 4 # length of id_hash part in chan-dedup-fmt

	# In-memory cache(s) to "remember" messages to provide context
	#  for various interactions with those - reactions, some bot-annotations and deletions.
	# Disable this option to not use such caches, and flush them if done at runtime.
	# These caches can hold/share ref to same msgs, any cache keeping the ref will be used.
	# This does not affect edits or replies. See also "cache-stats" in the control channel.
	# Large cache sizes with many joined discords can use significant amount of memory,
	#  e.g. with rough estimate around 200B per cached msg, full default ~10K cache is ~2 MiB.
	discord_msg_interact_cache = True
	discord_msg_interact_cache_hot = 1_000 # for N last-interacted-with msgs
	discord_msg_interact_cache_shared = 10_000 # last N msgs to keep in general
	discord_msg_interact_cache_per_discord = 1_000 # keeps last N msgs per-discord
	discord_msg_interact_cache_per_chan = 100 # last N msgs per-discord-channel buffer
	discord_msg_interact_cache_expire = '2d16h' # forget cached msgs older than this

	# Issue notices in *.vc chans for voice-chats after inactivity.
	# Otherwise notices are only sent when voice chat toggles between empty/non-empty.
	# Can be set to 0 to notify about every voice event (spammy), or to e.g. 1y
	#  (any large timespan) to notify only when voice-chat changes between empty/non-empty.
	discord_voice_notify_after_inactivity = '20m'
	# Token-bucket rate limit on all voice-chat notices in vc-channel.
	# Can be used with after-inactivity=0 to send simple
	#  rate-limited notifications instead of tracking active/inactive state.
	# Value like 300:3 means "token-bucket algorithm with rate=1/300s burst=5".
	discord_voice_notify_rate_limit_tbf = '300:5'
	discord_voice_join_left_cache_expire = '7d' # used to cleanup user-joined/left tracking info

	# Timespan-counters to keep for last per-pattern matches of [unmonitor],
	#  [send-replacements], [recv-regexp-filters] and other rules in similar config sections.
	# Value should be a space-separated list of intervals, or a special "runtime" span.
	# Counters for rule hits within "last N" interval are printed in control-channel rule listings.
	# Example counter values, with a setting like this: match-counters = 2h30m 1d 2w runtime
	#   [ rule hits: 2h30m=505 1d=46,933 2w=679,853 runtime=10,184,461 ]
	# ...where "2h30m=505" means "505 rule hits within last 2 hours 30 minutes" and so on.
	discord_match_counters = ''

	debug_verbose = False # for debug-level stderr and debug channel, same as --debug option
	debug_err_cut = 150 # for various error messages from discord, which can be long html junk
	debug_msg_cut = 50 # message part length in debug logs
	debug_proto_log_shared = True # send protocol logs to normal debug logging and log-file too
	debug_proto_cut = 90 # cut-length for irc/discord protocol msgs in debug logs, if shared
	debug_proto_log_file = '' # log file(s) for all irc/discord protocol messages
	debug_proto_log_file_size = int(1.5e6)
	debug_proto_log_file_count = 9
	# Space-separated received ws events to drop from protocol logs
	debug_proto_log_filter_ws = 'guild_member_list_update typing_start'
	debug_proto_aiohttp = True # log aiohttp request/response info in proto-log
	debug_log_file = '' # debug-level log, not affected by "verbose" option
	debug_log_file_size = int(1.5e6)
	debug_log_file_count = 9
	debug_chan_proto_cut = 230 # limit for printing protocol msgs via debug-channel command
	debug_chan_proto_tail = 50
	debug_asyncio_logs = False # debug= value in asyncio.run()
	debug_dev_cmds = False # enables developer-aid state-save/load cmds in control-channel
	# Randomly segfault-crash every 1<n<2*mmts
	#  minutes, for backwards-compatibility with various legacy C daemons :)
	debug_mean_minutes_to_segfault = 0.0

	_conf_path = '~/.rdircd.ini'
	_conf_sections = 'auth', 'irc', 'discord', 'debug'
	_conf_sections_old = dict( # old -> new section renames
		auth_main='auth', aliases='renames',
		filters='unmonitor', replacements='send-replacements' )
	_conf_keys_old = dict( # new -> list(k-old) for specific value renames
		discord_ws_reconnect_warn_tbf=['ws_reconnect_warn'],
		irc_disable_reacts_msgs=['disable_reactions'] )
	_conf_old_found = dict() # old -> new

	# RDIRCDConfig.read_from_file also parses these attrs from dedicated sections:
	#   renames, send_repls, unmon_filters, recv_filters

	def _re_chk(key, groups, v_def=re.compile('$x')):
		def _conv(self, v):
			if not v: return v_def
			rx = re.compile(v)
			if missing := set(groups).difference(rx.groupindex):
				raise ValueError( 'Missing required regexp capture'
					f' group(s) in regexp for {key} [ {" ".join(missing)} ] {v!r}' )
			return rx
		return _conv
	_parse_td_str = lambda s, v: parse_duration(v)

	def _conv_discord_status_events(self, v):
		ev_status = dict()
		for ev in v.split():
			k, s, v = ev.lower().partition('=')
			ev_status.update((k, v) for k in k.split('/'))
			if not getattr(Discord.c_status_protobuf, v, None):
				raise ValueError(f'Unknown/unsupported discord status: {v} (in {ev!r})')
			if err := set(ev_status).difference('back connect away disconnect'.split()):
				raise ValueError(f'Unrecognized IRC event name: {err.pop()} (in {ev!r})')
		return ev_status

	# Converted values get assigned to attrs like conf._irc_chan_auto_join_re
	_conv_irc_uid_seed = lambda s,v: v if len(v.encode()) <= 32 else str_hash(v, 32)
	_conv_irc_len_dont_split_re = lambda s,v: re.compile(v or '$x') # $x = never match
	_conv_irc_names_timeout = _parse_td_str
	_conv_irc_chan_auto_join_re = lambda s,v: re.compile(v or '$x')
	_conv_discord_name_preference_order = lambda s,v: v.lower().split()
	_conv_discord_msg_mention_re = _re_chk('discord-msg-mention', ['nick'], None)
	_conv_discord_msg_mention_re_ignore = lambda s,v: re.compile(v or '$x')
	_conv_discord_msg_emoji_re = _re_chk('discord-msg-emoji', ['emoji'], None)
	_conv_discord_msg_emoji_unicode_list_file = \
		lambda s,v: v and (pl.Path(__file__).resolve().parent / path_filter(v)).resolve()
	_conv_discord_user_mention_cache_timeout = _parse_td_str
	_conv_discord_media_info_timeout = _parse_td_str
	_conv_discord_terminal_links_re = _re_chk('discord-terminal-links-re', ['name'])
	_conv_discord_msg_ack_delay = _parse_td_str
	_conv_discord_msg_old_upd_timestamp = _parse_td_str
	_conv_discord_msg_old_upd_ignore = _parse_td_str
	_conv_discord_msg_edit_re = _re_chk('discord-msg-edit', ['aaa', 'bbb'])
	_conv_discord_msg_del_re = lambda s,v: re.compile(v or '$x')
	_conv_discord_msg_interact_cache_expire = _parse_td_str
	_conv_discord_voice_notify_after_inactivity = _parse_td_str
	_conv_discord_voice_join_left_cache_expire = _parse_td_str
	_conv_debug_proto_log_filter_ws = lambda s,v: set(v.upper().split())


doc_control_chan_cmds = r'''
Commands:
  status - [alias: st] show whether discord is connected and working.
  connect - [alias: on] connect/login to discord.
  disconnect - [alias: off] disconnect from discord.
  cache-stats - [alias: cs] show statistics on various caches.

  set [-s|--save] [option value] - set config option value,
     saving it to the last ini file if -s/--save is specified.
    Run without args to get full list of options with their current values.
    Not all values changed this way will have an effect without restart.

  reload - re-read configuration from same ini files as on start.
    Updates existing state, so removing values from files won't reset them.
    Same as with "set" command, some changes don't take effect at runtime.
    Sending HUP signal to the script does the same thing.

  rx [-s|--save] [ comment/key... = ] regexp pattern
  rx [-s|--save] {{-r|--rm}} comment/key...
    Add/remove regexp filters to drop messages received from discord.
    Changes [recv-regexp-filters] ini-file section filters on-the-fly,
     to drop received msgs from any channels they'd otherwise appear in,
     saving updates to last ini file if -s/--save option is specified.
    Pattern must be a python regular expression to match against
     a string like "<usernick> #discord.channel-name :: message text",
     like it'd also normally appear in the monitor/leftover channels.
    "comment/key" before pattern (if any) will be used as key in ini file,
     for removal with -r/--rm option, and auto-generated if missing,
     can have "∧ " "∧¬ " "¬ " prefixes to and/not the line (CNF logic).
    Run as just "rx" to print full list of current recv-regexp-filters.
    Usage example: rx temp-mute this bot = ^<spam-bot>

  unmonitor [-s|--save] [ comment/key... = ] [#]channel/pattern
  unmonitor [-s|--save] {{-r|--rm}} comment/key...
    [alias: um] (un-)skip messages from discord channel
     (matched by name/pattern) in all monitor/leftover channels.
    Command works in a similar way to "rx" above.
    Channel-pattern can be an exact channel name (leading # is optional),
     "glob:..." filename-wildcard-like pattern, or "re:..." regexp-pattern.
    Run without args to print a list of current filters for these channels.
    Usage example: um -s ignore all #forum threads = glob:*.forum.=*

  repl [-s|--save] [-r|--rm] [ prefix.comment = re [ -> subst ] ]
    Add/remove regexp-replacements/blocks for outgoing messages.
    Works similar to "rx" and "unmonitor" commands above,
     but adds/removes stuff for [send-replacements] config file section.
    "prefix.comment" should have a discord prefix - same as IRC channels
     have - or a special * value to use that replacement in all discords,
     and "comment" part after that is just any arbitrary/unique key.
    "re"/"subst" patterns work same as in python's re.sub() function.
    If last "-> subst" part is omitted, "<block!>" value will be used.
    Run without arguments to print current contents of that config section.
    Usage example: repl XYZ.kaomoji-smiley-tail = ( ):\)$ ->  ツ'''

doc_control_chan_dev_cmds = '''
Developer-commands for breaking and testing stuff:

  ccx file channel - dump all internal state for a working discord channel to file.
    Resulting YAML file can then be used with "cc" command to load the state
     and test-generate/send messages to that channel without connecting to discord.
    Channel name should be as it is seen on IRC, with #-prefix being optional.
    This is intended for testing various rdircd msg-handling stuff, such as filters.
    YAML can also be inspected or tweaked to understand/test internal state issues.
    Requires "pyaml" module, which is used for human-friendly serialization.

  cc file [nick [msg...]] - load YAML-dump of discord-chan state, send msg to it.
    Same channel as specified with "ccx" command that produced the YAML is used.
    If nick/msg is not specified, some unique placeholder ones are auto-generated.
    Intended use is to rapidly test various internal message-handling code
     without needing an actual discord connection, like msg filtering or processing.
    Requires PyYAML module to parse the YAML file.'''

doc_debug_chan_cmds = '''
This channel is for logging output, with level=warning by default,
  unless --debug is specified on command line, or [debug] verbose=yes in ini.

Status:
  rdircd version: {ver}
  rdircd uptime: {uptime}
  log level: {level}
  log msg counts: {log_msg_counts}
  protocol log: {proto_log_info}
  protocol log shared: {proto_log_shared}

Recognized commands here:
  level warning - (alias: w) only dump warnings and errors here.
  level info - (alias: i) set level=info (default) logging in this channel.
    That mostly adds connection stuff - disconnects, reconnects, auth, etc.
  level debug - (alias: d) enable level=debug logging in this channel.
    Includes protocol info (if shared), events, messages and everything else.
  level error, level critical - more quiet than other levels above.
  proto <file> - enable irc/discord protocol logging to specified file.
  proto off - (alias: px) disable irc/discord protocol logging.
  proto share/unshare - (alias: ps/pu)
    whether to dump protocol logging (level=debug) to regular logs.
  proto tail [n] [cut] - (alias: pt) dump "n" (default={pt_n}) tail lines
    of protocol log file (if enabled), limited to "cut" length (default={pt_cut}).'''

doc_topic_cmds = '''
--- Topic-commands:
  set {topic...} - set topic, as usual irc /topic command would do.
  info - show some internal guild/channel information, like IDs and such for renames.
  info {user-name...} - query info on user name (or part of it) in this discord.
  log [state] - replay history since "state" point (default: last rdircd stop).
    "state" value can be either a number, state-id, relative or iso8601 timestamp.
    Where number indicates last Nth state recorded in the config (0 - current).
    E.g. "log 1" (same as just "log") will replay messages in the channel,
     starting from last ev before last rdircd shutdown (saved under [state] in ini).
    Timestamp examples: 2019-01-05T2:00, 2019-01-08 12:30:00, 2h, 1d 5h 30m, 1mo5d.
    Relative timespan units: y/yr/year, mo/month,
      w/week, d/day, h/hr/hour, m/min/minute, s/sec/second.
  log list - list recorded state ids/timestamps, most recent one last.
---'''

doc_fmt_lines = lambda s,**kws: '\n'.join(
	f'-- {line}'.strip() for line in s.format(**kws).strip().splitlines() )


err_fmt = lambda err: f'[{err.__class__.__name__}] {err}'

class LogMessage:
	def __init__(self, fmt, a, k): self.fmt, self.a, self.k = fmt, a, k
	def __str__(self):
		return ( self.fmt.format(*self.a, **self.k)
			if self.a or self.k else self.fmt ).replace('\n', ' ⏎ ')

class LogStyleAdapter(logging.LoggerAdapter):
	def __init__(self, logger, extra=None): super().__init__(logger, extra or {})
	def log(self, level, msg, *args, **kws):
		if not self.isEnabledFor(level): return
		log_kws = dict((k, kws.pop(k, None)) for k in ['extra', 'exc_info'])
		if not isinstance(log_kws['extra'], dict): log_kws['extra'] = dict(extra=log_kws['extra'])
		msg, kws = self.process(msg, kws)
		self.logger._log(level, LogMessage(msg, args, kws), (), **log_kws)

class LogFuncHandler(logging.Handler):
	def __init__(self, func):
		super().__init__()
		self.func, self.locked = func, False
	def emit(self, record):
		if self.locked: return # to avoid logging-of-logging loops, assuming sync call
		self.locked = True
		try: self.func(self.format(record))
		# except Exception: self.handleError(record) # too noisy
		except Exception as err: log_bak.exception('LogFuncHandler failed - {}', err_fmt(err))
		finally: self.locked = False

class LogEmptyMsgFilter(logging.Filter):
	def filter(self, record):
		msg = record.msg
		return bool(msg if isinstance(msg, str) else msg.fmt)
log_empty_filter = LogEmptyMsgFilter()

class LogProtoDebugFilter(logging.Filter):
	debug_re = re.compile(rb'^:\S+ (PRIVMSG|NOTICE) #rdircd\.debug :')
	def filter(self, record):
		try: st, msg = record.extra
		except: return True
		return not ( st == ' >>'
			and isinstance(msg, bytes)
			and self.debug_re.search(msg) )
log_proto_debug_filter = LogProtoDebugFilter()

class LogProtoFormatter(logging.Formatter):
	last_ts = last_reltime = None
	def format(self, record):
		# LogRecords coming here tend to be duplicated, as handler is
		#  attached to multiple loggers, e.g. irc.conn + proto.irc.conn
		# So to track relative timestamps, such same-time duplicates have to be skipped
		if record.created != self.last_ts: # new (non-dupe) record
			self.last_ts, reltime = ( record.created,
				(record.created - self.last_ts) if self.last_ts else 0 )
			self.last_reltime = '{:>7s}'.format(f'{"+" if reltime >= 0 else ""}{reltime:,.3f}')
		record.reltime = self.last_reltime
		record.asctime = time.strftime(
			'%Y-%m-%dT%H:%M:%S', time.localtime(record.created) )
		record.asctime += f'.{record.msecs:03.0f}'
		if record.name.startswith('proto.'): record.name = record.name[6:]
		try:
			st, msg = record.extra
			if isinstance(msg, bytes): msg = json.dumps(msg.decode())
		except Exception as err:
			st, msg = 'err', err_fmt(err)
			log_bak.exception('LogProtoFormatter failed - {}', msg)
		record.message = f'{st} :: {msg}'
		return self.formatMessage(record)

class LogFileHandler(logging.handlers.RotatingFileHandler):
	def set_file(self, path):
		self.stream, self.baseFilename = None, os.path.abspath(os.fspath(path))
	def get_file(self): return self.baseFilename

class LogLevelCounter(logging.Handler):
	def __init__(self, *args, **kws):
		super().__init__(*args, **kws)
		self.counts = cs.Counter()
	def emit(self, record):
		self.counts['all'] += 1
		if record.levelno <= logging.DEBUG: return
		self.counts[record.levelname.lower()] += 1

get_logger = lambda name: LogStyleAdapter(logging.getLogger(name))
log_bak = get_logger('fallback')
log_proto_root = logging.getLogger('proto')


dd = lambda text: re.sub( r' \t+', ' ',
	textwrap.dedent(text).strip('\n') + '\n' ).replace('\t', '  ')

def pprint(data):
	if not (pp := getattr(pprint, 'module', None)):
		log_bak.critical('--- !!! Debug-pprint was left in the code somewhere !!! ---')
		import pprint as pp; pprint.module = pp
	return pp.pprint(data, width=140, compact=True)

def pyaml_conv_keys(data, enc=True, dd=None):
	'Encodes/decodes tuple/null keys for use in YAML recursively'
	if not dd: dd = dict() # to copy/cache self-recursive structures
	if (data := dd.get(dk := id(d := data), ...)) is not ...: pass
	elif isinstance(d, (tuple, list, set)):
		data = dd[id(d)] = list()
		data.extend(pyaml_conv_keys(d, dd=dd, enc=enc) for d in d)
	elif isinstance(d, cs.abc.Mapping):
		data = dd[id(d)] = dict()
		for k, v in d.items():
			if enc:
				if k is None: k = '\ue173.N'
				elif isinstance(k, tuple): k = '\ue172.' + json.dumps(k)
			else:
				if not isinstance(k, str): pass
				elif k == '\ue173.N': k = None
				elif k.startswith('\ue172.'): k = tuple(json.loads(k[2:]))
			data[k] = pyaml_conv_keys(v, dd=dd, enc=enc)
	else: data = dd[id(d)] = d
	return data

def pyaml_dump(data):
	if not (pyaml := getattr(pyaml_dump, 'module', None)):
		log_bak.critical('--- !!! Debug-pyaml was left in the code somewhere !!! ---')
		import pyaml; pyaml_dump.module = pyaml
	return pyaml.dump( pyaml_conv_keys(data),
		force_embed=False, sort_dicts=pyaml.PYAMLSort.oneline_group )

def pyaml_load(src):
	if not (yaml := getattr(pyaml_load, 'module', None)):
		log_bak.critical('--- !!! Debug-pyaml was left in the code somewhere !!! ---')
		import yaml; pyaml_load.module = yaml
	return pyaml_conv_keys(yaml.safe_load(src), False)

def path_names(*paths):
	'Return short and distinct names from last path components, in the same order'
	names, paths = cs.Counter(p.name for p in paths), list(map(str, paths))
	while True:
		name, n = names.most_common(1)[0]
		if n == 1: break
		del names[name]
		upd = sorted((
				'/'.join(filter(None, p.rsplit('/', name.count('/')+2)[1:]))
				for p in paths if p.endswith(f'/{name}') ),
			key=lambda name: name.count('/') )
		if len(upd) <= 1: raise AssertionError(name, upd)
		names.update(upd)
	return list(next(n for n in names if p == n or p.endswith(f'/{n}')) for p in paths)

def path_filter(path):
	'Prevent using/saving files with potentially dangerous filenames'
	# It's not a panacea, esp. with TOCTTOU issue wrt symlinks, just basic sanity-check
	path = os.path.expanduser(path)
	if any(re.search(r'(?i)\.pyc?$', p) for p in [path, os.path.realpath(path)]):
		raise RuntimeError(f'Disallowed using file with .py/.pyc suffix [ {path} ]')
	return path


def setup_aiohttp_trace_logging(log):
	tc = aiohttp.TraceConfig()

	@cl.contextmanager
	def log_req(part, ident=None, t='req'):
		if not log.isEnabledFor(logging.DEBUG): return
		try:
			dt = ' >>' if t == 'req' else '<< '
			if ident: log.debug('', extra=(dt, f'{t} :: {ident}'))
			log.debug('', extra=(dt, f'{t} :: {part} start'))
			yield log
			log.debug('', extra=(dt, f'{t} :: {part} end'))
		except Exception as err:
			err = err_fmt(err)
			log.exception( 'Protocol logging failed: {}',
				err, extra=('xxx', f'{t} :: {part} FAIL :: {err}') )

	# This does not dump all req headers, but should be good enough
	async def on_req_start(s, tc_ctx, ps):
		tc_ctx.req_uid = str_hash(os.urandom(8))
		with log_req('headers', f'[{tc_ctx.req_uid}] {ps.method} {ps.url}') as log:
			for k, v in ps.headers.items(): log.debug('', extra=('  >', f'  {k}: {v}'))
	tc.on_request_start.append(on_req_start)

	async def on_req_chunk(s, tc_ctx, ps):
		a = getattr(tc_ctx, 'pos', 0)
		b = tc_ctx.pos = a + len(ps.chunk)
		with log_req('body', f'[{tc_ctx.req_uid}] {a}-{b}') as log:
			if ps.chunk: log.debug('', extra=('  >', ps.chunk))
	tc.on_request_chunk_sent.append(on_req_chunk)

	async def on_req_done(s, tc_ctx, ps):
		res_info = ( '{0.status} {0.reason}'
			' HTTP/{0.version.major}.{0.version.minor}' ).format(ps.response)
		with log_req('headers', f'[{tc_ctx.req_uid}] {res_info}', 'res') as log:
			for k, v in ps.response.headers.items(): log.debug('', extra=('<  ', f'  {k}: {v}'))
		with log_req('body', f'[{tc_ctx.req_uid}]', 'res') as log:
			log.debug('', extra=('<  ', await ps.response.read()))
	tc.on_request_end.append(on_req_done)

	return tc

def sockopt_resolve(prefix, v):
	prefix = prefix.upper()
	for k in dir(socket):
		if not k.startswith(prefix): continue
		if getattr(socket, k) == v: return k[len(prefix):]
	return v


# str_norm is NOT used in irc, where rfc1459 (ascii letters/chars) casefold is more traditional
str_norm = lambda v: unicodedata.normalize('NFKC', v.strip()).casefold()

def str_part(s, sep, default=None):
	'Examples: str_part("user@host", "<@", "root"), str_part("host:port", ":>")'
	if sep.strip(c := sep.strip('<>')) == '<':
		return (default, s) if c not in s else s.split(c, 1)
	else: return (s, default) if c not in s else s.rsplit(c, 1)

def str_cut(s, max_len, len_bytes=False, repr_fmt=False, ext=' ...[{s_len}]'):
	'''Truncates longer strings to "max_len", adding "ext" suffix.
		Squashes line-breaks to ⏎, unless bytes or repr_fmt are used for full repr() escaping.'''
	if isinstance(s, bytes): s, repr_fmt = s.decode('utf-8', 'replace'), True
	if not isinstance(s, str): s = str(s)
	if repr_fmt: s = repr(s)[1:-1] # for logs and such, to escape all weird chars
	else: s = s.replace('\n', '⏎')
	s_len, ext_tpl = f'{len(s):,d}', ext.format(s_len='12/345')
	if max_len > 0 and len(s) > max_len:
		s_len = f'{max_len}/{s_len}'
		if not len_bytes: s = s[:max_len - len(ext_tpl)] + ext.format(s_len=s_len)
		else:
			n = max_len - len(ext_tpl.encode())
			s = s.encode()[:n].decode(errors='ignore') + ext.format(s_len=s_len)
	return s

def data_repr(data):
	pp = getattr(data_repr, '_pp', None)
	if not pp:
		import pprint as pp
		pp = data_repr._pp = pp.PrettyPrinter(indent=2, width=100, compact=True)
	return pp.pformat(data)

def str_hash(s, c=None, key='rdircd', strip=''):
	s_raw, s = s, base64.urlsafe_b64encode(
		hashlib.blake2s(str(s).encode(), key=key.encode()).digest() ).decode()
	for sc in strip + '-_=': s = s.replace(sc, '')
	if c is None: return s
	for n in range(30): # limit is to avoid unlikely a -> ... -> a loops
		if len(s) < c: s = str_hash(s, c, key, strip)
		if len(s) >= c: break
	else: raise RuntimeError(f'str_hash() failed on: {s_raw!r} {[c, key, strip]}')
	return s[:c]

def pw_hash(pw, hash_str=None, salt=None):
	'Generates scrypt-hash with random salt or checks pw against one'
	scheme = 'rdircd.1'
	if isinstance(pw, str): pw = pw.encode()
	if not hash_str:
		if not salt: salt = os.urandom(16)
		salt_hash = hashlib.blake2s(salt, person=scheme.encode()).digest()
		if scrypt := getattr(hashlib, 'scrypt', None): crypt = salt + scrypt(
			pw, salt=salt_hash, n=2**15, r=8, p=1, maxmem=48*2**20, dklen=32 )
		else:
			try: import scrypt
			except ImportError: raise ImportError( 'python does not have'
				' hashlib.scrypt and "scrypt" module is not available - install the latter' )
			crypt = salt + scrypt.hash(pw, salt_hash, N=2**15, r=8, p=1, buflen=32)
		chk = base64.urlsafe_b64encode(hashlib.blake2s(
			crypt, digest_size=3, person=scheme.encode()).digest() ).decode()
		crypt = base64.urlsafe_b64encode(crypt).decode()
		return f'{scheme}.{crypt}.{chk}'
	elif hash_str.startswith(f'{scheme}.'):
		try:
			crypt, chk = hash_str[9:].split('.', 1)
			crypt = base64.urlsafe_b64decode(crypt)
			chk2 = base64.urlsafe_b64encode(hashlib.blake2s(
				crypt, digest_size=3, person=scheme.encode()).digest() ).decode()
			if not secrets.compare_digest(chk, chk2): raise ValueError
		except: raise ValueError(f'corrupted {scheme} password hash string')
		return secrets.compare_digest(hash_str, pw_hash(pw, salt=crypt[:16]))
	raise ValueError('unrecognized password-hash type')

def tuple_hash(t, c=None, key='rdircd'):
	s = '\0'.join(str(v).replace('\0', '\0\0') for v in t)
	return str_hash(s, c=c, key=key)

def _data_clean(data):
	if isinstance(data, cs.UserDict): data = dict(data)
	if isinstance(data, dict): return dict((k,_data_clean(v)) for k,v in data.items())
	if isinstance(data, list): return list(map(_data_clean, data))
	return data

def data_hash(data, **str_hash_kws):
	data = _data_clean(data)
	if not isinstance(data, bytes):
		data = json.dumps(data, sort_keys=True).encode()
	return str_hash(data)

def data_json(data):
	return json.dumps(_data_clean(data))

def host_uid_seed():
	seed = os.uname().nodename
	for k in '/etc/machine-id', '/var/lib/dbus/machine-id':
		try: seed = pl.Path(k).read_text().strip(); break
		except OSError: pass
	return f'rdircd.{str_hash(seed, 25)}'


@cl.contextmanager
def safe_replacement(path, *open_args, mode=None, **open_kws):
	path = path_filter(path)
	if mode is None:
		try: mode = stat.S_IMODE(os.lstat(path).st_mode)
		except OSError: pass
	open_kws.update( delete=False,
		dir=os.path.dirname(path), prefix=os.path.basename(path)+'.' )
	if not open_args: open_kws['mode'] = 'w'
	with tempfile.NamedTemporaryFile(*open_args, **open_kws) as tmp:
		try:
			if mode is not None: os.fchmod(tmp.fileno(), mode)
			yield tmp
			if not tmp.closed: tmp.flush()
			os.rename(tmp.name, path)
		finally:
			try: os.unlink(tmp.name)
			except OSError: pass

def file_tail(p, n, grep=None, bs=100 * 2**10):
	import mmap
	lines = list()
	with open(p, 'rb') as src:
		a, buff, mm = 1, b'', mmap.mmap(
			src.fileno(), 0, access=mmap.ACCESS_READ )
		while len(lines) < n:
			b = a + bs
			a, buff, end = b, buff + mm[-a:-b:-1], b > len(mm)
			while True:
				try: line, buff = buff.split(b'\n', 1)
				except ValueError:
					if end and buff: line, buff = buff, b''
					else: break
				if line: lines.append(line[::-1].decode())
			if end: break
	return list(reversed(lines[:n]))

def token_bucket(spec, negative_tokens=False):
	'''Spec: { interval_seconds: float | float_a/float_b }[:burst_float]
			Examples: 1/4:5 (interval=0.25s, rate=4/s, burst=5), 5, 0.5:10, 20:30.
		Expects a number of tokens (can be float, def=1),
			and subtracts these, borrowing below zero with negative_tokens set.
		Yields either None if there's enough tokens
			or delay (in seconds, float) until when there will be.
		Simple "0" value means "always None", or "inf" for "infinite delays".'''
	try:
		try: interval, burst = spec.rsplit(':', 1)
		except (ValueError, AttributeError): interval, burst = spec, 1.0
		else: burst = float(burst)
		if isinstance(interval, str):
			try: a, b = interval.split('/', 1)
			except ValueError: interval = float(interval)
			else: interval = float(a) / float(b)
		if min(interval, burst) < 0: raise ValueError
	except: raise ValueError(f'Invalid format for rate-limit: {spec!r}')
	if interval <= 0:
		while True: yield None
	elif interval == math.inf:
		while True: yield 2**31 # safe "infinite delay" value
	tokens, rate, ts_sync = max(0, burst - 1), interval**-1, time.monotonic()
	val = (yield) or 1
	while True:
		ts = time.monotonic()
		ts_sync, tokens = ts, min(burst, tokens + (ts - ts_sync) * rate)
		val, tokens = ( (None, tokens - val) if tokens >= val else
			((val - tokens) / rate, (tokens - val) if negative_tokens else tokens) )
		val = (yield val) or 1


async def aio_await_wrap(res):
	'Wraps coroutine, callable that creates one or any other awaitable.'
	if not inspect.isawaitable(res) and callable(res): res = res()
	if inspect.isawaitable(res): res = await res
	return res

async def aio_task_cancel(task_list):
	'Cancel and await a task or a list of such, which can have empty values mixed-in.'
	if inspect.isawaitable(task_list): task_list = [task_list]
	task_list = list(filter(None, task_list))
	for task in task_list:
		with cl.suppress(asyncio.CancelledError): task.cancel()
	for task in task_list:
		with cl.suppress(asyncio.CancelledError): await task

try: aio_timeout = asyncio.timeout
except: # py < 3.11
	class aio_timeout:
		def __init__(self, timeout):
			loop = asyncio.get_running_loop()
			self._timeout, self._timeout_call = False, loop.call_at(
				loop.time() + timeout, self._timeout_set, asyncio.current_task() )
		def _timeout_set(self, task): self._timeout = task.cancel() or True
		async def __aenter__(self): return self
		async def __aexit__(self, err_t, err, err_tb):
			if err_t is asyncio.CancelledError and self._timeout: raise asyncio.TimeoutError
			self._timeout_call.cancel()


class StacklessContext:
	'''Like AsyncContextStack, but for tracking tasks that
		can finish at any point without leaving stack frames.'''

	def __init__(self, log): self.tasks, self.log = dict(), log
	async def __aenter__(self): return self
	async def __aexit__(self, *err):
		if self.tasks:
			task_list, self.tasks = self.tasks.values(), None
			await aio_task_cancel(task_list)
	async def close(self): await self.__aexit__(None, None, None)

	def add_task(self, coro, run_after=None):
		'Start task eating its own tail, with an optional success-only callback'
		task_id = None
		async def _task_wrapper(coro=coro):
			try:
				await aio_await_wrap(coro)
				if run_after: await aio_await_wrap(coro := run_after())
			except asyncio.CancelledError: pass
			except Exception as err:
				self.log.exception( 'Background task failed: {} - {}',
					coro, str_cut(err_fmt(err), 200, repr_fmt=True) )
			finally:
				assert task_id is not None, task_id
				if self.tasks: self.tasks.pop(task_id, None)
		task = asyncio.create_task(_task_wrapper())
		task_id = id(task)
		self.tasks[task_id] = task
		return task
	add = add_task


def parse_iso8601( spec, tz_default=dt.timezone.utc, validate=False,
		_re=re.compile( r'(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?'
			r'(?::(?P<s>\d{2})(?:\.(?P<us>\d+))?)?\s*(?P<tz>Z|[-+]\d{2}:\d{2})?' ) ):
	if not (m := _re.search(spec)): raise ValueError(spec)
	if validate: return
	if m.group('tz'):
		tz = m.group('tz')
		if tz == 'Z': tz = dt.timezone.utc
		else:
			k = {'+':1,'-':-1}[tz[0]]
			hh, mm = ((int(n) * k) for n in tz[1:].split(':', 1))
			tz = dt.timezone(dt.timedelta(hours=hh, minutes=mm), tz)
	else: tz = tz_default
	ts_list = list(m.groups()[:5])
	if not ts_list[3]: ts_list[3] = ts_list[4] = 0
	ts_list = [ *map(int, ts_list),
		int(m.group('s') or 0), int(m.group('us') or 0) ]
	ts = dt.datetime.strptime(
		'{:04d}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}.{:06d}'.format(*ts_list),
		'%Y-%m-%d %H:%M:%S.%f' )
	return ts.replace(tzinfo=tz).timestamp()

def ts_iso8601(ts, ms=3, to_str=True, human=False, strip_date=None, short=False):
	if not isinstance(ts, dt.datetime):
		ts = ( dt.datetime.fromtimestamp(ts, tz=dt.timezone.utc)
			if not human else dt.datetime.fromtimestamp(ts) )
	if not human: ts = ts.replace(tzinfo=dt.timezone.utc)
	if ts.year > 2030: raise ValueError(ts) # sanity check
	if not to_str: return ts
	if human:
		ts_fmt = '%Y-%m-%d %H:%M:%S'
		if strip_date:
			if strip_date is True: ts_fmt = ts_fmt[9:]
			else: # strip specified redundant or rolled-over (next) date
				tss, d0 = strip_date, ts.strftime('%Y-%m-%d')
				if not isinstance(tss, dt.date): # includes datetime
					tss = dt.datetime.fromtimestamp(tss)
				for tss in [tss, tss + dt.timedelta(days=1)]:
					if tss.strftime('%Y-%m-%d') == d0: ts_fmt = ts_fmt[9:]
		ts = ts.strftime(ts_fmt)
		if short and ts.endswith(':00'): ts = ts[:-3]
		return ts
	if short: ms = 0
	ts_ext = f'{ts.microsecond:06d}'[:ms]
	if ms and int(ts_ext): ts_ext = f'.{ts_ext}'
	return ts.strftime('%Y-%m-%dT%H:%M:%S') + ts_ext + 'Z'

def convert_iso8601_str(ts, human=True, short=True):
	if not ts: return '???'
	try: return ts_iso8601(parse_iso8601(ts), human=human, short=short)
	except Exception as err:
		get_logger('rdircd.time').warning(
			'Failed to convert timestamp [ {!r} ]: {}', ts, err_fmt(err) )
		return ts

_td_days = dict(
	y=365.2422, yr=365.2422, year=365.2422,
	mo=30.5, month=30.5, w=7, week=7, d=1, day=1 )
_td_s = dict( h=3600, hr=3600, hour=3600,
	m=60, min=60, minute=60, s=1, sec=1, second=1 )
_td_usort = lambda d: sorted(
	d.items(), key=lambda kv: (kv[1], len(kv[0])), reverse=True )
_td_re = re.compile('(?i)^[-+]?' + ''.join( fr'(?P<{k}>\d+{k}\s*)?'
	for k, v in [*_td_usort(_td_days), *_td_usort(_td_s)] ) + '$')

def parse_duration(ts_str, to_float=True):
	try: delta = dt.timedelta(seconds=float(ts_str))
	except ValueError:
		if not ((m := _td_re.search(ts_str)) and any(m.groups())):
			raise ValueError(ts_str) from None
		delta = list()
		for units in _td_days, _td_s:
			val = 0
			for k, v in units.items():
				if not m.group(k): continue
				val += v * int(''.join(filter(str.isdigit, m.group(k))) or 1)
			delta.append(val)
		delta = dt.timedelta(*delta)
	return delta if not to_float else delta.total_seconds()

def repr_duration( ts, ts0=None,
		ext=True, units_max=2, units_res=None,
		_units=dict( h=3600, m=60, s=1,
			y=365.2422*86400, mo=30.5*86400, w=7*86400, d=1*86400 ) ):
	delta = ts if ts0 is None else (ts - ts0)
	if ext is True: ext = 'ago' if delta < 0 else 'from now'
	res, s, n_last = list(), abs(delta), units_max - 1
	for unit, unit_s in sorted(_units.items(), key=op.itemgetter(1), reverse=True):
		if not (val := math.floor(s / unit_s)):
			if units_res == unit: break
			continue
		if len(res) == n_last or units_res == unit:
			val, n_last = round(s / unit_s), True
		res.append(f'{val:.0f}{unit}')
		if n_last is True: break
		s -= val * unit_s
	if not res: return '-'
	else:
		if ext: res.append(ext)
		return ' '.join(res)


def force_list(v):
	if not v: v = list()
	elif isinstance(v, cs_abc.ValuesView): v = list(v)
	elif not isinstance(v, list): v = [v]
	return v

def dict_update(d, du_iter=None, sync=True):
	'Update or replace dict contents, returning difference in the latter case.'
	keys_old, du = set(d.keys()), dict()
	if du_iter: du.update(du_iter)
	d.update(du)
	if sync: return dict((k, d.pop(k)) for k in keys_old.difference(du))

def iter_gather(container):
	'Auto-gathers iterator function result into some container.'
	def _cls_wrapper(func):
		def _wrapper(self, *args, **kws):
			return container(func(self, *args, **kws))
		return ft.wraps(func)(_wrapper)
	return _cls_wrapper


class adict(cs.UserDict):
	'dict with simpler access via attrs and update-tracking for cache invalidation.'
	__slots__ = 'data', 'attrs', '_track_keys', '_track_cb'

	@classmethod
	def rec_make(cls, data, kv_filter=lambda k,v: v, dd=None):
		'Make adict tree from a potentially self-recursive data dump'
		# Optional, as it's only intended for loading debug data-dumps
		if not dd: dd = dict() # to copy/cache self-recursive structures
		d, rec = data, ft.partial(cls.rec_make, kv_filter=kv_filter, dd=dd)
		if (data := dd.get(dk := id(d), ...)) is not ...: pass
		elif isinstance(d, list):
			data = dd[id(d)] = list(); data.extend(map(rec, d))
		elif type(d) is dict: # stops at any special dicts
			data = dd[id(d)] = cls()
			data.update( (k, rec(fv))
				for k,v in d.items() if (fv := kv_filter(k, v)) is not ... )
		else: data = dd[id(d)] = d
		return data

	def __init__(self, *args, **kws):
		self.attrs = dict()
		self._track_keys = self._track_cb = None
		super().__init__(*args, **kws)
		self._make(self)

	def _make(self, v):
		if v is self: v.update((k, self._make(v)) for k,v in v.items())
		elif type(v) is dict: v = adict(v)
		elif isinstance(v, (tuple, list)): v = type(v)(map(self._make, v))
		return v

	def _track(self, keys=None, cb=None):
		'Run cb() on specified (or any) key changes in this adict.'
		# Intended to only be used in one place, so overrides any earlier cb
		if keys:
			self._track_keys = set(keys.split())
			for k in self._track_keys:
				if type(d := self.get(k)) is adict: d._track(cb=cb)
		self._track_cb = cb
	def _track_check(self, k):
		if not (cb := self._track_cb): return
		if self._track_keys:
			if k not in self._track_keys: return
			elif type(d := self.get(k)) is adict: d._track(cb=cb)
		cb()

	def __getattr__(self, k):
		if k not in self.__slots__:
			return self[k] if not k.startswith('ø_') else self.attrs[k[2:]]
		else: return self.__getattribute__(k)
	def __setattr__(self, k, v):
		if k not in self.__slots__:
			if not k.startswith('ø_'): self[k] = v
			else: self.attrs[k[2:]] = v
		else: return super().__setattr__(k, v)
	def __delattr__(self, k, v):
		if not k.startswith('ø_'): del self[k]
		else: del self.attrs[k[2:]]

	def __setitem__(self, k, v):
		super().__setitem__(k, v)
		self._track_check(k)
	def __delitem__(self, k):
		super().__delitem__(k)
		self._track_check(k)

class TimedCacheDict(cs.UserDict):
	'dict that has values expire and get removed on timeout'
	__slots__, _no_value = ('data', 'ts', 'ts_min', 'timeout', 'bump_on_get'), object()
	timeout_cleanup_slack = 1.5 # oldest-timeout multiplier to cache cleanup
	def __init__(self, timeout, bump_on_get=True):
		self.ts, self.ts_min = dict(), None
		self.timeout, self.bump_on_get = timeout, bump_on_get
		super().__init__()

	def cache(self, k, v=_no_value):
		ts, get_op = time.monotonic(), v is self._no_value
		if get_op and (ts - self.ts.get(k, 0)) > self.timeout: raise KeyError(k)
		if not get_op or self.bump_on_get: self.ts[k] = ts
		v = super().__getitem__(k) if get_op else super().__setitem__(k, v)
		if not self.ts_min: self.ts_min = ts; return v
		elif get_op or ts - self.ts_min <= self.timeout * self.timeout_cleanup_slack: return v
		for k, ts0 in sorted(self.ts.items(), key=lambda kv: kv[1]):
			if ts - ts0 > self.timeout: self.pop(k, None); del self.ts[k]; continue
			self.ts_min = ts0; break
		else: self.ts_min = None
		return v

	def __iter__(self):
		for k in self.data:
			if k in self: yield k
	def __contains__(self, k):
		return k in self.data and (time.monotonic() - self.ts.get(k, 0)) <= self.timeout
	def __getitem__(self, k): return self.cache(k)
	def __setitem__(self, k, v): return self.cache(k, v)
	def iter_oldest(self):
		'Iterator for all cached (time-delta, key, val) in oldest-first order'
		ts = time.monotonic()
		for k, ts0 in sorted(self.ts.items(), key=lambda kv: kv[1]):
			if (td := ts - ts0) >= self.timeout: continue
			with cl.suppress(KeyError): yield td, k, self.cache(k)

class TimeSeriesBuckets:
	'''Counts events within intervals with up to "interval / buckets" time-precision.
		Special "runtime" bucket can be used to count all-time events.'''

	def __init__(self, intervals='', buckets=30):
		self.spec, td_list = (intervals, buckets), sorted(
			(parse_duration(s) if s.lower() != 'runtime' else math.inf, s)
			for s in intervals.split() )
		self.intervals = dict(
			( s, adict(n=0, td=0, counts=[0]) if td is math.inf else
				adict(n=0, ts=0, td=td / buckets, counts=[0]*buckets) )
			for td, s in td_list )

	def event(self, ts=None, count=1, intervals=None, buckets=30):
		if not ts: ts = time.monotonic()
		if intervals is not None and self.spec != (intervals, buckets):
			self.__init__(intervals, buckets)
		for data in self.intervals.values():
			if data.td:
				if not data.ts: data.ts = ts
				data.ts += (shift := int((ts - data.ts) / data.td)) * data.td
				if shift >= (nc := len(data.counts)): shift, data.counts[:] = 0, [0]*nc
				for n in range(shift): data.n = (data.n + 1) % nc; data.counts[data.n] = 0
			data.counts[data.n] += count

	def get_counts(self, ts=None, **event_kws):
		self.event(ts, count=0, **event_kws) # to shift/clear buckets if necessary
		return dict((td, sum(data.counts)) for td, data in self.intervals.items())

	def get_counts_str(self, *args, **kws):
		out, v_last = list(), None
		for k, v in self.get_counts(*args, **kws).items():
			if v == v_last: out[-1][1] = k
			else: out.append([k, '', v])
			v_last = v
		if not v_last: return ''
		for n, (k, s, v) in enumerate(out):
			if s: k = f'{k}-{s}'
			out[n] = f'{k}={v:,d}'
		return ' '.join(out)


def irc_casefold_rfc1459(name, _irc_rfc1459_table=dict(zip(
		map(ord, string.ascii_uppercase + '\\[]^'), string.ascii_lowercase + '|{}~' ))):
	return name.translate(_irc_rfc1459_table)
irc_name_eq = lambda a, b: irc_casefold_rfc1459(a) == irc_casefold_rfc1459(b)

class irc_name_dict(cs.UserDict):
	'Mapping that uses IRC rfc1459-casefolded keys'
	value_map = classmethod(
		lambda cls, names: cls((v, v) for v in names) )
	def add(self, name): self[name] = name # for ci-name -> name mappings
	def remove(self, name): del self[name]
	def get(self, k, default=None): return super().get(irc_casefold_rfc1459(k), default)
	def __contains__(self, k): return irc_casefold_rfc1459(k) in self.data
	def __getitem__(self, k): return self.data[irc_casefold_rfc1459(k)]
	def __setitem__(self, k, v): self.data[irc_casefold_rfc1459(k)] = v
	def __delitem__(self, k): del self.data[irc_casefold_rfc1459(k)]



class IRCProtocolError(Exception): pass
class IRCProtocolArgsError(IRCProtocolError): pass
class IRCBridgeSignal(Exception): pass

class IRCProtocol:
	'''IRC protocol state and implementation.
		It's unique to each connected IRC client, and they don't ever interact.'''

	# Extensive lists of modes are copied from freenode to make clients happy
	feats_modes = 'DOQRSZaghilopswz CFILMPQSbcefgijklmnopqrstvz'
	feats_support = ( 'AWAYLEN=200 CASEMAPPING=rfc1459'
		' CHANLIMIT=#:512 CHANTYPES=# CHANMODES=eIbq,k,flj,CFLMPQScgimnprstz'
		' CHANNELLEN=80 ELIST=C NETWORK=rdircd NICKLEN=64'
		' PREFIX=(ov)@+ SAFELIST STATUSMSG=@+ TOPICLEN=390 USERLEN=32' ).split()
	nx_chan_warn = ( 'WARNING :: Discord has no channel'
		' linked to this one, nothing will happen here :: WARNING' )

	@classmethod
	def factory_for_bridge(cls, rdircd):
		def _wrapper():
			try: return cls(rdircd)
			except Exception as err:
				log = get_logger('rdircd.irc.factory')
				log.exception('Failed to initialize ircd protocol: {}', err_fmt(err))
				log.critical('Stopping daemon due to unhandled IRC initialization error')
				rdircd.loop.stop()
		return _wrapper

	def __init__(self, rdircd):
		self.bridge, self.conf = rdircd, rdircd.conf
		self.log = get_logger('rdircd.irc.init')
		self.transport, self.buff, self.recv_queue = None, b'', asyncio.Queue()
		self._cmd_cache, self.st_irc = dict(), adict(
			nick=None, user=None, pw=None,
			host=None, auth=False, cap_neg=False, caps=set(), away=None,
			# Discord sends typing notifications every 10s, ircv3 needs <6s,
			#  so these get cached here, and task repeats them on irc_typing_interval.
			typing_repeat=adict( task=None, wakeup=asyncio.Event(),
				active=TimedCacheDict(self.conf.irc_typing_timeout, bump_on_get=False) ),
			# Channels here get initialized on joins and removed on parts
			# There is no need to track them otherwise
			chans=irc_name_dict() )


	def connection_made(self, tr):
		host, port = tr.get_extra_info('peername')[:2]
		conn_id = tuple_hash([host, port], 3)
		self.st_irc.ts = time.monotonic()
		self.log_proto = get_logger(f'proto.irc.{conn_id}')
		self.log_proto.debug( '--- -conn- {} {} {}',
			host, port, conn_id, extra=('---', f'conn {host} {port}') )
		self.log = get_logger(f'rdircd.irc.{conn_id}')
		self.log.debug('Connection from {} {} [{}]', host, port, conn_id)
		self.transport, self.st_irc.host = tr, host
		self.send(0, 'NOTICE * :*** rdircd ready')
		self.bridge.irc_conn_new(self)
		self.bridge.cmd_delay(self.recv_queue_proc)

	def data_received(self, data):
		self.buff += data
		while b'\n' in self.buff:
			line, self.buff = self.buff.split(b'\n', 1)
			if not line.strip(): continue
			line_len, line_repr = self._repr_bin(line, True)
			self.log_proto.debug( '<<  [{} {}] {}',
				self.st_irc.nick or '---', line_len, line_repr, extra=('<< ', line) )
			if not line.strip(): continue
			if self.recv_queue: self.recv_queue.put_nowait(line)
			else: self.log.error('Data after recv queue stopped: {!r}', line)

	def eof_received(self): pass
	def connection_lost(self, err):
		reason = err or 'closed cleanly'
		if isinstance(reason, Exception): reason = err_fmt(reason)
		self.log_proto.debug('--- -close- :: {}', reason, extra=('---', 'close'))
		self.log.debug('Connection lost: {}', reason)
		if self.recv_queue: self.recv_queue.put_nowait(StopIteration)
		self.bridge.irc_conn_lost(self)

	def data_send(self, data):
		data_len, data_repr = self._repr_bin(data, True)
		self.log_proto.debug( ' >> [{} {}] {}',
			self.st_irc.nick or '---', data_len, data_repr, extra=(' >>', data) )
		self.transport.write(data)


	def _repr_bin(self, data, prefix=False, max_len=None, ext=' [{data_len}]'):
		'Binary-only version of str_cut().'
		if isinstance(data, str): data = data.encode()
		if max_len is None: max_len = self.conf.debug_proto_cut
		data_len, data_repr, ext_len = f'{len(data):,d}', repr(data)[2:-1], len(ext)
		if max_len > 0 and len(data_repr) > max_len:
			max_len -= ext_len; data_len = f'{max_len}/{data_len}'
			data_repr = data_repr[:max_len] + ext.format(data_len=data_len)
		return (data_len, data_repr) if prefix else data_repr

	def _parse(self, line, _tag_esc='\\:-;-\\s- -\\r-\r-\\n-\n'.split('-')):
		if isinstance(line, bytes): line = line.decode()
		m = adict(line=line, tags=dict(), src=None, params=list())

		if line.startswith('@'): # tags
			try: tags, line = line[1:].split(' ', 1)
			except ValueError: raise IRCProtocolLineError(line)
			for tag in tags.split(';'):
				try:
					tag, val = tag.split('=', 1)
					if not val: raise ValueError
					for a, b in it.batched(_tag_esc, 2): val = val.replace(a, b)
				except ValueError: val = ''
				m.tags[tag] = val
			line = line.lstrip(' ')

		if line.startswith(':'): # source
			try: m.src, line = line.split(' ', 1)
			except ValueError: raise IRCProtocolLineError(line)
			line = line.lstrip(' ')

		while True:
			if line.startswith(':'):
				m.params.append(line[1:])
				break
			if ' ' in line:
				param, line = line.split(' ', 1)
				line = line.lstrip(' ')
			else: param, line = line, ''
			m.params.append(param)
			if not line: break
		if m.params: m.cmd, m.params = m.params[0].lower(), m.params[1:]
		else: raise IRCProtocolLineError(line)
		return m

	def send(self, line_or_code, *args, max_len=None, auto_split=True):
		'Send msg or command to IRC client on the other end of this connection'
		line = line_or_code
		if isinstance(line, int): # code=0 only adds :host prefix
			code, line = line, f':{self.bridge.server_host}'
			if code: line += f' {code:03d} {self.st_irc.nick or "*"}'
		if args: line += ' ' + ' '.join(map(str, args))
		if isinstance(line, str): line = line.encode()
		if max_len is None: max_len = self.conf.irc_len_hwm
		line = line.rstrip(b'\r\n')
		if b'\n' in line or len(line) > max_len:
			if auto_split:
				m = self._parse(line)
				if m.cmd in ['privmsg', 'notice']: return self.send_split_msg(m)
			if len(line) > max_len:
				self.log.info('Sending line with >{}B: {!r}', max_len, self._repr_bin(line))
		if b'\n' in line: raise IRCProtocolError(f'Line with newlines: {line!r}')
		line += b'\r\n'
		self.data_send(line)

	def send_split_msg(self, m):
		dst, line = m.params
		pre, max_len = f'{m.cmd.upper()} {dst}', self.conf.irc_len_lwm
		if m.src: pre = f'{m.src} {pre}'
		if '\n' in line:
			for line in line.split('\n'): self.send(pre, f':{line.rstrip()}')
			return
		subs, lines = list(), list()
		for m in reversed(list(self.conf._irc_len_dont_split_re.finditer(line))):
			line = line[:m.start()] + '\uf0ff'*len(m[1]) + line[m.end():]
			subs.append(m[0]) # to replace back later
		line, ws = '', re.findall(r'(\S+)(\s*)', line)
		for w, sep in ws:
			if line.strip() and len(line) + len(w) > max_len:
				lines.append(line); line = sep_last
			sep_last, line = sep, line + w + sep
		if line.strip(): lines.append(line)
		for line in lines:
			line = re.sub(r'\uf0ff+', lambda m: subs.pop(), line.rstrip())
			self.send(pre, f':{line}', auto_split=False)

	async def recv_queue_proc(self):
		try:
			while True:
				line = await self.recv_queue.get()
				if line is StopIteration: break
				try: await self.recv(line)
				except Exception as err:
					self.log.exception(f'Failed to parse line: {line}')
		finally: self.recv_queue = None

	async def recv(self, line_raw):
		'Runs on every IRC line received from the client on the other end'
		# Dispatches call to recv_cmd_* funcs below, like recv_cmd_privmsg
		if isinstance(line_raw, str): line = line_raw
		else:
			try: line = line_raw.decode().strip()
			except UnicodeDecodeError:
				return self.log.error('Failed to decode line as utf-8: {!r}', self._repr_bin(line_raw))
		try: m = self._parse(line)
		except IRCProtocolLineError:
			return self.log.error('Line protocol error: {!r}', self._repr_bin(line_raw))
		if cmd_cache := self._cmd_cache.get(m.cmd):
			cmd_func, cmd_ps_n = cmd_cache
		else:
			cmd_func, cmd_ps_n = getattr(self, f'recv_cmd_{m.cmd}', None), 0
			if cmd_func:
				args = list(inspect.signature(cmd_func).parameters.values())
				cmd_ps_n = len(args)
				if cmd_ps_n == 1 and args[0].annotation is adict: cmd_ps_n = None
				else: cmd_ps_n = cmd_ps_n - sum(1 for p in args if p.default is not p.empty), cmd_ps_n
			self._cmd_cache[m.cmd] = cmd_func, cmd_ps_n
		if not cmd_func:
			self.log.error('Unhandled cmd: {!r}', self._repr_bin(line_raw))
			return self.send(421, ':Unknown command')
		if not self.check_access(m.cmd):
			return self.log.error('Out-of-order cmd: {!r}', self._repr_bin(line_raw))
		if cmd_ps_n is None: await aio_await_wrap(cmd_func(m))
		else:
			(a, b), n = cmd_ps_n, len(m.params)
			if not a <= n <= b:
				self.log.error( 'Command/args'
					' mismatch [{} vs {}-{}]: {!r}', n, a, b, self._repr_bin(line) )
				return self.send(461, ':Incorrect command parameters')
			try: await aio_await_wrap(cmd_func(*m.params))
			except Exception as err:
				self.send(400, m.cmd.upper(), f':BUG - Internal Error - {err_fmt(err)}')
				self.log.exception('Error processing message: {}', m)

	def check_access(self, cmd):
		# Blocks IRC commands before st.auth, except for auth, cap-negotiation and pings.
		# "Registration" (auth) does not finish until cap-neg finishes, as per spec.
		# After each potentially-auth-finishing command, irc_auth timer is (re-)started.
		# check_auth_done_delayed() tests all auth requirements, sets st.auth, sends hello.
		if not self.st_irc.auth:
			res = cmd in ['cap', 'user', 'nick', 'pass', 'quit', 'ping']
			if not res: self.send(451, ':You have not registered')
			return res
		elif cmd in ['user', 'pass']:
			self.send(462, ':You may not reregister')
			return False
		return True # any commands allowed outside of phases above

	# chan_spec=#casemap(some-channel), chan_name=casemap(some-channel)
	_csc = lambda c: c.startswith('#')
	chan_spec_check = staticmethod(_csc)
	chan_spec = staticmethod(
		lambda name,_csc=_csc: name if _csc(name) else f'#{name}' )
	chan_name = staticmethod(
		lambda chan,_csc=_csc: chan if not _csc(chan) else chan[1:] )


	def recv_cmd_ping(self, server, server_dst=None):
		# See https://gitlab.com/gitterHQ/irc-bridge/-/issues/34#note_190986152
		self.send(0, f'PONG {self.bridge.server_host} :{server}')

	def recv_cmd_cap(self, sub_raw, caps_str=''):
		caps_allowed = set(self.conf.irc_ircv3_caps.split())
		if (sub := sub_raw.lower()) == 'ls':
			try: ver = caps_str and int(caps_str) or 0 # unused, to filter sent caps
			except ValueError:
				return self.send(410, sub_raw, ':Invalid CAP command')
			self.st_irc.cap_neg = True
			self.send(0, f'CAP * LS :{" ".join(caps_allowed)}')
		elif sub == 'list':
			self.send(0, f'CAP * LIST :{" ".join(self.st_irc.caps)}')
		elif sub == 'req':
			self.st_irc.cap_neg, caps = True, self.st_irc.caps.copy()
			for c in caps_str.split():
				if c.startswith('-'): caps.discard(c[1:])
				else: caps.add(c)
			if caps.difference(caps_allowed):
				return self.send(0, f'CAP * NAK :{caps_str}')
			self.st_irc.caps = caps
			self.send(0, f'CAP * ACK :{caps_str}')
			self.log.debug('Negotiated IRCv3 caps: {}', ' '.join(self.st_irc.caps))
		elif sub == 'end':
			self.st_irc.cap_neg = False
			self.check_auth_done()
		else: self.send(410, sub_raw, ':Invalid CAP command')

	def recv_cmd_pass(self, pw):
		self.st_irc.pw = pw
		self.check_auth_done()
	def recv_cmd_user(self, name, a, b, real_name):
		self.st_irc.update(user=name, real_name=real_name)
		self.check_auth_done()
	def recv_cmd_nick(self, nick):
		if not re.search(r'^[-._a-zA-Z0-9]+$', nick):
			return self.send(432, nick, ':Erroneus nickname')
		if self.bridge.cmd_conn(nick):
			return self.send(433, nick, ':Nickname is already in use')
		self.st_irc.nick = nick
		if self.st_irc.auth and self.st_irc.nick:
			self.send(f':{self.st_irc.nick} NICK {nick}')
		self.check_auth_done()

	def check_auth_done(self):
		# Token-bucket delay is added here is to avoid trivial bruteforcing
		self.bridge.cmd_delay('irc_auth', self.check_auth_done_delayed)
	def check_auth_done_delayed(self):
		if self.st_irc.auth: return
		if self.st_irc.cap_neg: return # delayed until the end of cap-negotiation
		if not (self.st_irc.nick and self.st_irc.user): return
		if self.conf.irc_password_hash:
			if not pw_hash(self.st_irc.pw or '', self.conf.irc_password_hash):
				return self.send(464, ':Password incorrect')
		self.st_irc.auth = True
		self.send(0, 'NOTICE * :*** registration completed')
		self.send(1, f':Welcome to the rdircd discord-irc bridge, {self.st_irc.nick}')
		self.send(2,
			f':Your host is {self.bridge.server_host},'
			f' running rdircd {self.bridge.server_ver}' )
		self.send(3, ':This server was created at {}'.format(
			self.bridge.server_ts.strftime('%Y-%m-%d %H:%M:%S UTC') ))
		self.send(4, f'{self.bridge.server_host} rdircd-{self.bridge.server_ver} {self.feats_modes}')
		self.send_feats()
		self.send_stats()
		self.send_motd()

	def send_feats(self, msg_feats_max=10, msg_len_max=200):
		feat_line, ext = list(), ':are supported by this server'
		for feat in it.chain(self.feats_support, [None]):
			if feat: feat_line.append(feat)
			n, msg_len = len(feat_line), sum((len(f)+1) for f in feat_line)
			if feat_line and (not feat or n >= msg_feats_max or msg_len >= msg_len_max):
				self.send(5, ' '.join(feat_line), ext)
				feat_line.clear()

	def send_stats(self):
		s = self.bridge.irc_conn_stats()
		self.send(251, f':There are {s.auth} users and 0 invisible on {s.servers} server(s)')
		self.send(252, f'{s.op} :IRC Operators online')
		self.send(253, f'{s.unknown} :unknown connection(s)')
		self.send(254, f'{s.chans} :channels formed')
		self.send(255, f':I have {s.total} client(s) and {s.servers} server(s)')
		self.send( 265, f'{s.total} {s.total_max}',
			f':Current local users {s.total}, max {s.total_max}' )
		self.send( 266, f'{s.total} {s.total_max}',
			f':Current global users {s.total}, max {s.total_max}' )

	def send_motd(self):
		if motd := self.conf.irc_motd_file_path:
			try: motd = pl.Path(self.conf.irc_motd_file_path).read_text()
			except FileNotFoundError: motd = ''
		if not motd: return self.send(422, ':MOTD File is missing')
		self.send(375, f':- {self.bridge.server_host} Message of the day -')
		for line in motd.splitlines(): self.send(372, f':- {line}')
		self.send(376, ':End of /MOTD command')

	def recv_cmd_quit(self, reason=None):
		self.send('QUIT :Client quit')
		self.send('ERROR :Closing connection (client quit)')
		self.transport.close()

	def req_chan_info(self, chan, cm=None, check=True):
		if not self.chan_spec_check(chan):
			return self.send(403, chan, ':No such channel')
		if not cm: cm = self.bridge.cmd_chan_map()
		if c := cm.get(self.chan_name(chan)): return c
		if check: self.send(403, chan, ':No such channel')
		if cm.ø_online: return False # confirmed to not exist on discord

	def recv_cmd_join(self, chan, key=None):
		if chan == '0': return self.recv_cmd_part(','.join(self.st_irc.chans))
		chan_list, cm = chan.split(','), self.bridge.cmd_chan_map()
		for chan in chan_list: self.cmd_join(chan, cm=cm)

	def cmd_join(self, chan, cm=None):
		if not cm: cm = self.bridge.cmd_chan_map()
		c = self.req_chan_info(chan, cm=cm, check=False)
		if c is False: return self.send(403, chan, ':No such channel')
		name = c.name if c else self.chan_name(chan)
		self.send(f':{self.st_irc.nick} JOIN {chan}')
		self.send_topic(chan, c=c)
		self.send_names(chan, own=True, c=c)
		self.send(0, f'MODE {chan} +v {self.st_irc.nick}')
		# topic=None will always be updated on cmd_chan_list_sync
		self.st_irc.chans[name] = adict(topic=c and c.topic)

	def recv_cmd_part(self, chan, reason=None):
		chan_list, cm = chan.split(','), self.bridge.cmd_chan_map()
		for chan in chan_list:
			c = self.req_chan_info(chan, cm=cm, check=False)
			name = c.name if c else self.chan_name(chan)
			if name not in self.st_irc.chans:
				self.send(442, chan, ':You are not on that channel')
			else:
				del self.st_irc.chans[name]
				self.send(f':{self.st_irc.nick} PART {chan}')

	async def recv_cmd_topic(self, chan, topic=None):
		if not (c := self.req_chan_info(chan)):
			return self.send(403, chan, ':No such channel')
		if not topic:
			self.send_topic(chan)
			return await self.bridge.irc_cmd_topic(self, c.name)
		try: await self.bridge.irc_cmd_topic(self, c.name, topic)
		except IRCBridgeSignal as err: self.send(482, chan, f':{err}')

	def send_topic(self, chan, c=...):
		if c is ...: c = self.req_chan_info(chan)
		if c and c.topic:
			topic = str_cut(c.topic.strip(), self.conf.irc_len_topic, len_bytes=True)
			self.send(332, chan, f':{topic}')
		elif c is False: self.send(332, chan, f':{self.nx_chan_warn}')
		else: self.send(331, chan, ':No topic is set')

	def send_topic_update(self, chan, topic):
		topic = str_cut(topic.strip(), self.conf.irc_len_topic, len_bytes=True)
		self.send(f':{self.conf.irc_nick_sys} TOPIC {chan} :{topic}')

	def recv_cmd_names(self, chan):
		for chan in chan.split(','): self.send_names(chan)

	def send_names(self, chan, own=False, c=..., msg_len_max=200):
		if c is ...: c = self.req_chan_info(chan)
		name_line, names = list(), self.bridge.cmd_chan_names(c.name) if c else list()
		for name in it.chain(names, [None]):
			if name:
				if irc_name_eq(name, self.st_irc.nick): own = False
				name_line.append(name)
			elif own and self.st_irc.nick: name_line.append(self.st_irc.nick)
			if name_line and (
					not name or sum(len(n)+1 for n in name_line) > msg_len_max ):
				self.send(353, '=', chan, ':' + ' '.join(name_line))
				name_line.clear()
		self.send(366, chan, ':End of /NAMES list')

	def recv_cmd_mode(self, target, mode=None, mode_args=None):
		if self.chan_spec_check(target):
			chan = target
			c = self.req_chan_info(chan, check=False)
			if self.conf.irc_chan_modes: self.send(324, chan, '+cnrt')
			if c: chan_ts = int(c.ts_created)
			else:
				if self.chan_name(chan) not in self.st_irc.chans:
					return self.send(403, chan, ':No such channel')
				chan_ts = int(time.time())
			if self.conf.irc_chan_modes: self.send(329, chan, chan_ts)
		else:
			if not irc_name_eq(target, self.st_irc.nick):
				return self.send(502, ':No access to modes of other users')
			self.send(221, ':+w')

	def recv_cmd_away(self, msg=None):
		away = self.st_irc.away = msg or None
		self.bridge.cmd_away_status('away' if away else 'back')
		if away: self.send(306, ':You have been marked as being away')
		else: self.send(305, ':You are no longer marked as being away')

	def recv_cmd_list(self, chan=None, cond=None):
		self.send(321, 'Channel :Users  Name')
		for c in self.bridge.cmd_chan_map().values():
			names = self.bridge.cmd_chan_names(c.name)
			topic = str_cut(c.topic, self.conf.irc_len_topic, len_bytes=True)
			self.send(322, self.chan_spec(c.name), len(names) or 1, f':{topic}')
		self.send(323, ':End of /LIST')

	def recv_cmd_motd(self, target=None): self.send_motd()

	def recv_cmd_version(self, target=None):
		self.send(351, self.bridge.server_ver, 'rdircd', ':rdircd discord-to-irc bridge')
		self.send_feats()

	def recv_cmd_privmsg(self, target, text):
		self.cmd_msg_from_irc(self.st_irc.nick, target, text, from_self=True)

	def recv_cmd_notice(self, target, text):
		self.cmd_msg_from_irc(self.st_irc.nick, target, text, notice=True, from_self=True)

	def cmd_chan_list_sync(self, cm):
		for name, ch in self.st_irc.chans.items():
			topic = cm[name].topic if name in cm else self.nx_chan_warn
			if topic == ch.topic: continue
			self.log.debug('Client-topic update [ {} ]: {!r} -> {!r}', name, ch.topic, topic)
			self.send_topic_update(chan := self.chan_spec(name), topic)
			ch.topic = topic
			if name in cm: continue
			self.send(f':{self.conf.irc_nick_sys} NOTICE {chan} :{self.nx_chan_warn}')
			self.send(403, chan, ':No such channel')
			# Doesn't actually removes channels, as they get rename-notification there

	def cmd_msg_from_irc(self, src, target, text, notice=False, from_self=False):
		'''Handler for messages posted from IRC.
			With notice=True message is only handled in irc, without proxying it to discord.'''
		msg_type = 'PRIVMSG' if not notice else 'NOTICE'
		if self.chan_spec_check(target):
			chan, name = target, self.chan_name(target)
			if (c := self.req_chan_info(chan, check=not notice)) is False:
				if notice: return # not supposed to generate responses
				self.send( f':{self.conf.irc_nick_sys} NOTICE {chan} :WARNING:'
					' there is no corresponding discord channel, msgs here are discarded' )
			if not c: return
			if from_self and c.bt not in [c.bt.proxy, c.bt.sys]:
				self.send( f':{self.conf.irc_nick_sys} NOTICE {chan} :WARNING:'
					' this is read-only channel, msgs posted here are not proxied to discord' )
			for conn in self.bridge.cmd_chan_conns(name): # mirror to other clients
				if from_self and conn is self: continue
				if name not in conn.st_irc.chans: self.cmd_join(chan) # from chan_auto_join_re
				conn.send(f':{src} {msg_type} {chan} :{text}')
			if not notice: self.bridge.irc_msg(self, chan, text)
		else:
			if not (conn := self.bridge.cmd_conn(target)):
				if not notice: self.send(401, target, ':No such nick/channel')
			else: conn.send(f':{src} {msg_type} {target} :{text}')

	def cmd_msg_self(self, text, src=None, notice=True):
		'Send direct message to this IRC client and nick'
		if not src: src = self.conf.irc_nick_sys
		msg_type = ['PRIVMSG', 'NOTICE'][bool(notice)]
		self.send(f':{src} {msg_type} {self.st_irc.nick} :{text}')

	def cmd_msg_chan(self, src, chan, text, notice=False, joined=False):
		'''Send message to channel that this IRC client is connected to.
			Message is never filtered/dropped here for reliability/sanity reasons.'''
		msg_type = ['PRIVMSG', 'NOTICE'][bool(notice)]
		chan, name = self.chan_spec(chan), self.chan_name(chan)
		if ( name not in self.st_irc.chans and
				self.conf._irc_chan_auto_join_re.search(name) ):
			self.cmd_join(chan)
		if self.conf.irc_names_join and src and joined and not notice:
			if not irc_name_eq(src, self.st_irc.nick): self.send(f':{src} JOIN {chan}')
		self.send(f':{src} {msg_type} {chan} :{text}')
		self.st_irc.typing_repeat.active.pop((src, chan), None)

	def cmd_typing(self, src, chan, repeated=False):
		interval, timeout = self.conf.irc_typing_interval, self.conf.irc_typing_timeout
		if 'message-tags' not in self.st_irc.caps or interval <= 0: return
		chan = self.chan_spec(chan)
		self.send(f'@+typing=active :{src} TAGMSG {chan}')
		if repeated: return
		if timeout and timeout > interval and (
				not (task := (st := self.st_irc.typing_repeat).task) or task.done() ):
			st.task = self.bridge.cmd_delay(self.cmd_typing_repeat_task)
		else: st.wakeup.set()
		st.active[src, chan] = True

	async def cmd_typing_repeat_task(self):
		st, last_delay = self.st_irc.typing_repeat, False
		repeat_last = TimedCacheDict(timeout := self.conf.irc_typing_timeout)
		while (delay := self.conf.irc_typing_interval) > 0:
			td, td_next, ts = None, 0, time.monotonic()
			for td, repeat, _ in st.active.iter_oldest():
				if td < delay: td_next = td; break
				if (td := ts - repeat_last.get(repeat, 0)) < delay:
					if not td_next or td < td_next: td_next = td
					continue
				repeat_last[repeat] = ts
				self.cmd_typing(*repeat, repeated=True)
			if td is None: # no repeats left to run
				if last_delay: break
				last_delay, td_next = True, -delay * 20 # longer wait before stopping
			else: last_delay = False
			st.wakeup.clear()
			with cl.suppress(asyncio.TimeoutError):
				await asyncio.wait_for(st.wakeup.wait(), delay - td_next)

	def recv_cmd_tagmsg(self, m: adict): # should probably be a dispatcher
		if self.conf.irc_typing_send_enabled and (tag := m.tags.get('+typing')):
			typing_stop, chans = False, list(m.params)
			if tag in ['paused', 'done']: typing_stop = True
			elif tag != 'active': chans.clear() # unsupported typing event
			for chan in chans:
				if not (self.chan_spec_check(chan) and self.req_chan_info(chan, check=False)): continue
				self.bridge.irc_typing(self, chan, stop=typing_stop)

	# Some of following user/server/channel-info cmds
	#  can be (ab)used to provide discord user/channel info.
	# Currently this isn't stored or queried anywhere,
	#  so no need for these beyond stubs for what ZNC and such use.

	def recv_cmd_userhost(self, nick):
		if irc_name_eq(nick, self.st_irc.nick):
			self.send(302, f':{nick}=+~{self.st_irc.user}@{self.st_irc.host}')
		else: self.send(401, nick, ':No such nick/channel')

	def recv_cmd_who(self, name=None):
		if not name:
			self.cmd_msg_self('\n'.join([ '--- /who command usage:',
				'/who #123456 - find/describe channel with id=123456.',
				'/who %123456 - server/guild id lookup.', '/who @123456 - user id lookup.',
				'/who @John·Smith - user lookup by IRC nick.',
				'/who *665560022562111489 - translate discord snowflake-id to date/time.',
				'--- /who info end' ]))
		elif name[0] == '@' or re.fullmatch(r'[#%*]\d+', name):
			self.bridge.cmd_delay(self.bridge.irc_cmd_info(self, name[0], name[1:]))
		else:
			if conn := self.bridge.cmd_conn_map().get(name):
				self.send( 352, '*', conn.st_irc.user, conn.st_irc.host,
					self.bridge.server_host,conn.st_irc.nick, 'H', f':0 {conn.st_irc.nick}' )
		self.send(315, name, ':End of /WHO list.')

	def recv_cmd_whois(self, *args):
		self.send( 421, ':IRC /WHOIS command is'
			' NOT SUPPORTED, use "/t info ..." in channels, see README for details' )

	# recv_cmd_whowas
	# recv_cmd_admin
	# recv_cmd_time
	# recv_cmd_stats
	# recv_cmd_info



class DiscordError(Exception): pass
class DiscordAbort(DiscordError): pass
class DiscordSessionError(Exception): pass
class DiscordHTTPError(DiscordError):
	def __init__(self, code, msg): self.code = code; super().__init__(msg)


class Discord:

	class c_msg_flags(enum.IntEnum):
		crossposted = 1 << 0
		is_crosspost = 1 << 1
		suppress_embeds = 1 << 2
		source_message_deleted = 1 << 3
		urgent = 1 << 4
		has_thread = 1 << 5
		ephemeral = 1 << 6
		loading = 1 << 7
		failed_to_mention_some_roles_in_thread = 1 << 8
		suppress_notifications = 1 << 12
		is_voice_message = 1 << 13

	class c_status_protobuf:
		# Base64 protobufs from https://github.com/dolfies/discord-protos/
		# echo 'status { status { value: "online" } }' |
		#  protoc --encode=discord_protos.discord_users.v1.PreloadedUserSettings \
		#  PreloadedUserSettings.proto | base64
		online = 'WgoKCAoGb25saW5l'
		invisible = 'Wg0KCwoJaW52aXNpYmxl'
		idle = 'WggKBgoEaWRsZQ=='
		dnd = 'WgcKBQoDZG5k'
		offline = 'WgsKCQoHb2ZmbGluZQ=='
		streaming = 'Wg0KCwoJc3RyZWFtaW5n'
		unknown = 'WgsKCQoHdW5rbm93bg=='

	def __init__(self, rdircd):
		self.bridge, self.conf = rdircd, rdircd.conf
		self.log = get_logger('rdircd.discord')
		self._repr = ft.partial(str_cut, max_len=self.conf.debug_msg_cut, repr_fmt=True)
		self.st_eris = adict(
			enabled=False, online=False, unicode_emoji_cache=None,
			msg_confirms=dict(), msg_acks=adict(queue=dict(), task=None),
			typing_delays=TimedCacheDict(timeout=6.8, bump_on_get=False),
			user_mentions=adict(cache=cs.defaultdict(irc_name_dict), ts=0) )

	async def __aenter__(self):
		if not ( self.conf.get('auth_email')
				and self.conf.get('auth_password') ):
			self.log.error('Disabling discord due to missing access credentials in config')
			self.session = None
		else:
			s = self.session = DiscordSession(self)
			s.task = asyncio.create_task(s.run_async())
		self.flake_n, self.flake_id = 0, (int.from_bytes(os.urandom(2), 'big') & 0x3ff) << 12
		return self

	async def __aexit__(self, *err):
		if self.session: await aio_task_cancel(self.session.task)

	def connect(self):
		if not self.session: return
		self.session.connect(); self.st_eris.enabled = True
	def disconnect(self):
		if not self.session: return
		self.session.disconnect(); self.st_eris.enabled = False

	def conn_req(self, *req_args, nc=None, **req_kws):
		'Proxy to return REST-API request awaitable when session is connected, nc otherwise'
		if not self.st_eris.enabled: (fut := asyncio.Future()).set_result(nc); return fut
		return self.session.req(*req_args, **req_kws)
	def conn_req_oneway(self, *req_args, **req_kws):
		'Send conn_req one-way without waiting for result or reply'
		self.session.tasks.add(self.conn_req(*req_args, **req_kws))

	def flake_parse(self, flake):
		if not flake: return None
		try: return (int(flake) >> 22)/1e3 + 1420070400
		except ValueError: return None
	def flake_build(self, ts):
		flake = self.flake_n | self.flake_id | int((ts - 1420070400) * 1e3) << 22
		self.flake_n = (self.flake_n + 1) % 0xfff
		return str(flake)

	@property
	def st_da(self):
		try:
			if not self.session: raise KeyError
			return self.session.st_da
		except KeyError: return adict(guilds=dict())

	def irc_chan_name(self, cc):
		return IRCProtocol.chan_spec(self.bridge.st_br.did_chan.get(cc.did, '???'))

	_user_name_uid_fallback = object()
	def user_name(self, u, mentions=None, fallback=_user_name_uid_fallback):
		'''Pick appropriate discord name from user, member or message object(s).
			"mentions" list, if provided, can be used to lookup "member" object.'''
		# Discord has distinct "user" and "member" objects, sent in diff places
		# (e.g. sometimes it's "author", sometimes "user", sometimes ".member.user")
		# "user" object has "username" and "global_name", "member" has "nick"
		if u:
			if mu := u.get('user'): u, m = mu, u
			elif u.get('guild_id') or u.get('channel_id'): # no guild_id in private chats
				if not mentions: mentions = u.get('mentions')
				m = u.get('member') or dict()
				if au := u.get('author'): u = au
				elif uu := m.get('user'): u = uu
			else: m = dict()
		if (uid := u.get('id')) and (
			rename := self.conf.renames.get(('user', f'@{uid}')) ): return rename
		if not u: return fallback
		if not m and mentions:
			for mn in mentions:
				if mn.get('id') != uid: continue
				m = mn.get('member') or dict(); break
		if (username := u.get('username')) and (
			rename := self.conf.renames.get(('user', username)) ): return rename
		for k in (vs := self.conf._discord_name_preference_order):
			if k == 'login':
				if name := username: break
			elif k == 'nick':
				if name := (m.get('nick') or u.get('nick')): break
			elif k == 'display':
				if name := u.get('display_name'): break # returned in member.user sometimes
				if name := u.get('global_name'): break
			else:
				self.log.warning( 'Ignoring unsupported name-type'
					' in discord name-preference-order value: {!r}', k )
				self.conf._discord_name_preference_order = list(v for v in vs if v != k)
		else:
			if name := username: pass
			elif fallback is not self._user_name_uid_fallback: name = fallback
			else: name = f'@{uid}' if uid else '???'
		if name.isdigit(): name += '´' # avoid digit-only names to not confuse with raw IDs
		return name.encode('utf-8', 'replace').decode() # remove surrogate junk, if any

	def msg_ack(self, cid, flake=None):
		'Schedule/cancel ACK for newly-received message from a discord channel'
		if not flake: return self.st_eris.msg_acks.queue.pop(cid, None)
		if cid not in (queue := self.st_eris.msg_acks.queue): queue[cid] = flake
		if not self.st_eris.msg_acks.task or self.st_eris.msg_acks.task.done():
			self.st_eris.msg_acks.task = self.session.tasks.add(self.msg_ack_task_run())

	async def msg_ack_task_run(self):
		while True:
			delay, ts, td = None, time.time(), self.conf._discord_msg_ack_delay
			if not (queue := sorted( (self.flake_parse(flake) + td, cid, flake)
				for cid, flake in self.st_eris.msg_acks.queue.items() )): return
			for ts_ack, cid, flake in queue:
				if ts >= ts_ack:
					try: await self.conn_req(
						f'channels/{cid}/messages/{flake}/ack', m='post',
						json=dict(token=None, last_viewed=self.st_da.read_state_last) )
					except DiscordHTTPError as err:
						if err.code != 404: raise # chan/msg might not exist by the time of ACK
					self.st_eris.msg_acks.queue.pop(cid, None)
				elif delay is None: delay = ts_ack - ts + 1
			if delay: await asyncio.sleep(delay)

	def cmd_online_state(self, st):
		if st == self.st_eris.online: return
		self.st_eris.online = st
		if self.st_eris.online: self.bridge.cmd_chan_map_sync()

	def cmd_chan_map_update(self):
		'Discord channel changes signal, to rebuild various mappings as necessary.'
		return list(self.bridge.cmd_chan_map()) # to trigger re-caching, if needed

	async def cmd_history(self, gg, cc, ts, lwm=70, hwm=90):
		if not self.st_eris.enabled: return list()
		msg_list, flake = list(), self.flake_build(ts)
		while True:
			msg_batch = await self.conn_req(
				f'channels/{cc.id}/messages',
				params=dict(after=flake, limit=hwm), nc=list() )
			flake_last = flake
			for m in map(adict, msg_batch or list()):
				line, tags = self.session.op_msg_parse(m, gg)
				if not line: continue # joins/parts/pins and such
				# Note: parse_iso8601(m.timestamp) == flake_parse(m.id)
				self.cmd_user_cache(gg.id, m.author.id, nick := self.user_name(m.author))
				msg_list.append(adict( nick=nick,
					line=line.strip(), tags=tags, ts=self.flake_parse(m.id) ))
				if int(m.id) > int(flake): flake = m.id
			if flake == flake_last or len(msg_list) < lwm: break
		return sorted(msg_list, key=op.itemgetter('ts'))

	async def cmd_info_dump(self, info_url):
		if not self.st_eris.enabled: return f'No info for {info_url}: no discord session'
		try: info = await self.conn_req(info_url)
		except DiscordHTTPError as err:
			err = str_cut(err, self.conf.debug_err_cut, repr_fmt=True)
			return f'No info for {info_url}: {err}'
		return data_repr(info)

	def cmd_msg_recv( self, cc, nick, line, tags=None,
			new_msg_flake=None, nonce=None, notice=None, skip_monitor=False ):
		'Relay message from Discord to IRC.'
		# new_msg_flake is only passed to update channel ts/acks or confirm own msg
		self.log.debug('MSG: <<  :: {} {} :: {} :: {} {}', cc.gg.id, cc.name, nick, line, tags or '')
		if nonce and new_msg_flake and not ( self.conf.discord_thread_msgs_in_parent_chan
				and tags.get('_prefix', '').startswith(self.conf.discord_thread_id_prefix) ):
			# Thread-prefixed msgs can be mirrored to two chans, hence the check/skip here
			if fut := self.st_eris.msg_confirms.pop(nonce, None):
				self.log.debug('MSG: confirm nonce={} flake={}', nonce, new_msg_flake)
				self.msg_ack(cc.id) # cancels unnecessary ACK to the channel, if any
				return fut.set_result(new_msg_flake)
		ack = self.bridge.cmd_msg_discord( cc, nick, line, tags=tags,
			notice=notice, ts=self.flake_parse(new_msg_flake), skip_monitor=skip_monitor )
		if self.conf.discord_msg_ack and new_msg_flake and cc.private:
			if ack or self.conf.discord_msg_ack_always: self.msg_ack(cc.id, new_msg_flake)

	def _cmd_msg_err_wrap(func):
		@ft.wraps(func)
		async def _wrapper(self, *args, **kws):
			try: return await func(self, *args, **kws)
			except IRCBridgeSignal: raise
			except Exception as err:
				self.log.exception('Failed discord-send: {}', err_str := err_fmt(err))
				raise IRCBridgeSignal(err_str)
		return _wrapper

	@_cmd_msg_err_wrap
	async def cmd_msg_edit_last(self, cc, aaa, bbb):
		if not cc.last_msg_sent: raise IRCBridgeSignal('no-last-msg')
		flake, line_old = cc.last_msg_sent.flake, cc.last_msg_sent.line
		try: line = re.sub(aaa, bbb, line_old)
		except Exception as err: raise IRCBridgeSignal(f'edit-re=[ {err} ]')
		if line == line_old: raise IRCBridgeSignal('edit-changed-nothing')
		self.log.debug(
			'MSG:  E> :: {} {} {} :: {}', cc.gg.id, cc.name, flake, self._repr(line) )
		line_tagged = await self.cmd_msg_mentionify(cc.gg.id, line)
		await self.conn_req(
			f'channels/{cc.id}/messages/{flake}',
			m='patch', json=dict(content=line_tagged) )
		if cc.last_msg_sent.get('flake') == flake: cc.last_msg_sent.line = line

	@_cmd_msg_err_wrap
	async def cmd_msg_del_last(self, cc):
		if not cc.last_msg_sent: raise IRCBridgeSignal('no-last-msg')
		flake, line_old = cc.last_msg_sent.flake, cc.last_msg_sent.line
		self.log.debug(
			'MSG:  D> :: {} {} {} :: {}', cc.gg.id, cc.name, flake, self._repr(line_old) )
		await self.conn_req(
			f'channels/{cc.id}/messages/{flake}', m='delete', raw=True )
		if cc.last_msg_sent.get('flake') == flake: cc.last_msg_sent.clear()

	@_cmd_msg_err_wrap
	async def cmd_msg_send(self, cc, line):
		'Relay message from IRC to Discord.'
		if not self.st_eris.enabled: raise IRCBridgeSignal('no-discord-conn')

		if ( self.conf.discord_thread_redirect_prefixed_responses_from_parent_chan
				and (ls := line.split(None, 1))[0].startswith(self.conf.discord_thread_id_prefix) ):
			c_tid, line = ls
			cc = cc.threads.get(str_norm(c_tid))
			if not cc: raise IRCBridgeSignal(f'no-such-thread {c_tid}')

		nonce = self.flake_build(time.time())
		self.log.debug('MSG:  >> :: {} {} {} :: {}', cc.gg.id, cc.name, nonce, line)
		try:
			res = None
			async with aio_timeout(self.conf.discord_msg_confirm_timeout):
				line, flags = self.cmd_msg_parse_flags(line)
				line_tagged = self.cmd_msg_emojify(cc.gg, line)
				line_tagged = await self.cmd_msg_mentionify(cc.gg.id, line_tagged)
				res = asyncio.create_task(self.conn_req(
					f'channels/{cc.id}/messages', m='post',
					json=dict(content=line_tagged, nonce=nonce, flags=flags) ))
				fut = self.st_eris.msg_confirms[nonce] = asyncio.Future()
				res = asyncio.gather(fut, res)
				flake_gw, res = await res
		except asyncio.TimeoutError:
			if res: await aio_task_cancel(res)
			raise IRCBridgeSignal('msg-confirm-timed-out')
		finally: self.st_eris.msg_confirms.pop(nonce, None)

		try: flake_res = res['id']
		except: raise DiscordError(f'Invalid response: {res}')
		if flake_gw != flake_res:
			self.log.warning( 'Same-nonce sent/received'
				' msg-id mismatch: {} != {}', flake_gw, flake_res )
		self.log.debug('Sending of msg {} confirmed: {}', flake_res, self._repr(line))
		cc.last_msg_sent.update(flake=flake_res, line=line)

	def cmd_msg_parse_flags(self, line, flags=0):
		if m := re.search(self.conf.discord_msg_flag_silent_re, line):
			line = line[:m.start()] + line[m.end():]
			flags |= self.c_msg_flags.suppress_notifications
		return line, flags

	def cmd_msg_emojify_unicode_list(self, _empty=set()):
		if not (src := self.conf._discord_msg_emoji_unicode_list_file): return _empty
		src_chk, mtime_chk, uems = self.st_eris.unicode_emoji_cache or ('', 0, _empty)
		if src_chk != (src_str := str(src)): mtime_chk = 0
		if mtime_chk < (ts := time.time()) - 30*60:
			mtime = ts
			try:
				if (mtime := src.stat().st_mtime) > mtime_chk:
					uems = set(gzip.decompress(src.read_bytes()).decode().split())
			except Exception as err: self.log.warning(
				'Failed to load unicode-emoji list-file: {}', err_fmt(err) )
			self.st_eris.unicode_emoji_cache = src_str, mtime, uems
		return uems

	def cmd_msg_emojify(self, gg, line):
		'''Find/translate :emoji: tags matched by "discord_msg_emoji_re", if any.
			Either returns line with all such matches translated or fails with IRCBridgeSignal.'''
		if not (rx := self.conf._discord_msg_emoji_re): return line
		uems = self.cmd_msg_emojify_unicode_list()
		line_parts, tag_idx = [line], rx.groupindex['emoji']
		for n, m in enumerate(reversed(ms := list(rx.finditer(line))), 1):
			if (en := m.group('emoji').lower()) in uems: em_tag = f':{en}:'
			elif em := gg.get('emojis', dict()).get(en): en = em.name; em_tag = f'<:{en}:{em.id}>'
			else: raise IRCBridgeSignal(f'emoji-not-found={en}')
			self.log.debug( 'Discord emoji match [{}/{}]:'
				' {!r} -> {} [{}-{}]', n, len(ms), m.group(), en, *m.span() )
			subs = ( (a, b, '' if n != tag_idx else em_tag)
				for n, (a, b) in ((n, m.span(n)) for n, group in enumerate(m.groups(), 1)) )
			for a, b, s in sorted(subs, reverse=True):
				line_parts.extend([(p := line_parts.pop())[b:], s, p[:a]])
		return ''.join(reversed(line_parts))

	async def cmd_msg_mentionify(self, gid, line):
		'''Translate IRC nick mentions matched by
				"discord_msg_mention_re" replaced with Discord mention-tags, if any.
			Either returns line with all mentions translated or fails with IRCBridgeSignal.'''
		# Regexp group replacements are made from the end to start,
		#  with line/tag parts lists containing reversed str parts to reassemble.
		# https://discord.com/developers/docs/reference#message-formatting
		if not self.conf._discord_msg_mention_re: return line
		matches = list((rx := self.conf._discord_msg_mention_re).finditer(line))
		line_parts, nick_idx = [line], rx.groupindex['nick']
		for n, m in enumerate(reversed(matches), 1):
			line, tag, (a, b) = line_parts.pop(), m.group(), m.span()
			mx = self.conf._discord_msg_mention_re_ignore.search(tag)
			self.log.debug( 'Discord mention match [{}/{}]: {!r} [{}-{}]{}',
				n, len(matches), tag, a, b, ' + ignore-pattern match' if mx else '' )
			tag_parts = ( self.cmd_msg_mentionify_ignore_strip(tag, mx)
				if mx else await self.cmd_msg_mentionify_translate_tag(gid, m, nick_idx) )
			line_parts.extend([line[b:], *tag_parts, line[:a]])
		return ''.join(reversed(line_parts))

	def cmd_msg_mentionify_ignore_strip(self, tag, mx):
		'Strips all capture groups in match from tag-string'
		tag, subs = [tag], (mx.span(n) for n, s in enumerate(mx.groups(), 1))
		for a, b in sorted(subs, reverse=True):
			part = tag.pop()
			tag.extend([part[b:], part[:a]])
		return tag

	async def cmd_msg_mentionify_translate_tag(self, gid, m, nick_idx) -> [str]:
		'''Returns string-parts of a regexp match,
			with nick_idx capture-group replaced and all others stripped.'''
		tag, (a, b) = m.group(), m.span()
		nick, (na, nb) = m.group(nick_idx), m.span(nick_idx)
		if not (tag and nick): return [tag]
		subs = list( (ga-a, gb-a, '') for ga, gb in
			(m.span(n) for n, group in enumerate(m.groups(), 1) if n != nick_idx) )
		if self.conf.discord_msg_mention_irc_decode:
			# This should make irc nicks usable for mention-tags as-is,
			#  and fix matching e.g. discord names with spaces for default regexp.
			nick = self.bridge.irc_name_revert(nick) or nick
		if uid := self.st_eris.user_mentions.cache[gid].get(nick): # exact match
			nick = f'<@{uid}>'; self.cmd_user_cache(gid, uid) # bump cache timeout
		else:
			users = await self.session.ws_req_users_query(
				gid, nick, 1 + (n := self.conf.discord_user_query_limit) )
			if not (users := users.get(str(gid))): raise IRCBridgeSignal(f'{nick}=no-matches')
			elif len(users) > 1:
				nicks = list(self.user_name(m) for m in users.values())
				nicks = ' '.join(map(repr, nicks[:n])) + (' +' if len(nicks) > n else '')
				raise IRCBridgeSignal(f'{nick}=[{nicks}]')
			else: # successful server-lookup with unique result
				uid = next(iter(users)); nick = f'<@{uid}>'
				self.cmd_user_cache(gid, uid, nick, query=True)
		tag = [tag]
		for sa, sb, s in sorted(subs + [(na-a, nb-a, nick)], reverse=True):
			part = tag.pop()
			tag.extend([part[sb:], s, part[:sa]])
		return tag

	def cmd_user_cache(self, gid, uid, name=None, delete=None, query=False):
		'''Updates user_mentions cache for discord-user-id, as nick_or_query=uid mapping.
			Cache/get IRC nick: cache(gid, uid, nick); nick = cache(gid, uid) # bumps timeout
			To cache/get user for an @somenick query, e.g. partial match, set query=True
			To drop cached entry: cache(gid, uid, delete=True, ...)'''
		users, ts = self.st_eris.user_mentions.cache[gid], time.monotonic()
		uid_key = f'\0{uid}' if not query else f'\t{uid}'
		if not name: return (ut := users.get(uid_key)) and ut[0]
		name_old, ts_old = users.pop(uid_key, (None, 0))
		# There can be two users with exactly same username, esp. with bridge-bots
		if name_old: users.pop(name_old, None)
		if delete: return
		if not query: name = self.bridge.irc_name(name)
		users[name], users[uid_key] = uid, (name, ts)
		if ( (ts - self.st_eris.user_mentions.ts)
				> (td := self.conf._discord_user_mention_cache_timeout) / 2 ):
			ts -= td
			for uid_key in list(users.keys()):
				if uid_key[0] not in '\0\t' or uid_key not in users: continue
				if users[uid_key][1] < ts: users.pop(users.pop(uid_key)[0], None)

	def cmd_chan_rename_func(self, cc, name_old):
		name_old = self.bridge.irc_name(name_old)
		send_func = self.bridge.cmd_msg_rename_func(cc)
		return lambda: send_func(line=(
			f'-----== Discord {"channel" if not cc.tid else "thread"}'
			f' renamed: {name_old} -> {self.irc_chan_name(cc)} ==-----' ))

	def cmd_chan_thread(self, cc):
		# Idea behind tag prefix is to allow sending msgs from parent channel to a prefixed thread
		self.bridge.cmd_msg_discord( cc,
			line=f'[{cc.tid}] --- Thread channel created: {self.irc_chan_name(cc)}' )

	def cmd_guild_event(self, gid, msg, ev_hash=''):
		'Report non-discord-channel event in global/guild monitor channels, e.g. user bans'
		prefix = ( f'%{gid}' if not (gg := self.st_da.guilds.get(gid))
			else ('%' + self.bridge.uid("guild", gg.id, kh=gg.get("kh"))) )
		prefix += f' :: {self.conf.irc_prefix_guild_event}'
		if gg: prefix += f'[{gg.name}] '
		if ev_hash: prefix += f'ev.{ev_hash} ' # to tie multiline stuff and updates together
		if self.bridge.cmd_msg_recv_filter(nick := self.conf.irc_nick_sys, prefix + msg): return
		self.bridge.cmd_msg_monitor(nick, msg, prefix, notice=True, gid=gid)

	def cmd_typing(self, cc, nick):
		self.bridge.cmd_typing(cc, nick)

	def cmd_typing_from_irc(self, cc):
		if cc.id in (delays := self.st_eris.typing_delays): return
		delays[cc.id] = True
		self.conn_req_oneway(f'channels/{cc.id}/typing', m='post', raw=True)

	def cmd_status(self, st_name):
		if not (pb := getattr(self.c_status_protobuf, st_name, None)):
			return self.log.error('Unknown/unsupported discord status: {}', st_name)
		self.log.debug('Setting discord user status: {}', st_name)
		self.conn_req_oneway(up.urljoin( # v10 API fails with "need to update your app"
			self.conf.discord_api_url.format(api_ver=9),
			'users/@me/settings-proto/1' ), m='patch', json=dict(settings=pb))


class DiscordSession:

	# See https://discord.com/developers/docs/change-log
	api_ver = 10

	class c(enum.IntEnum):
		dispatch = 0
		heartbeat = 1
		identify = 2
		status_update = 3
		voice_state_update = 4
		resume = 6
		reconnect = 7
		request_guild_members = 8
		invalid_session = 9
		hello = 10
		heartbeat_ack = 11
		request_sync = 12
		client_disconnected = 13
		request_sync_chan = 14

		unknown_error = 4000
		unknown_opcode = 4001
		decode_error = 4002
		not_authenticated = 4003
		authentication_failed = 4004
		already_authenticated = 4005
		invalid_seq = 4007
		rate_limited = 4008
		session_timeout = 4009
		invalid_shard = 4010
		sharding_required = 4011
		invalid_api_ver = 4012
		intent_invalid = 4013
		intent_denied = 4014

		oneshot = 10_000

	class c_chan_type(enum.IntEnum):
		# https://discord.com/developers/docs/resources/channel#channel-object-channel-types
		text = 0
		private = 1
		voice = 2
		private_group = 3
		group = 4 # header for a group of channels
		news = 5 # announcements channels
		store = 6
		thread_news = 10 # threads in news channels
		thread = 11
		thread_private = 12 # invite-only threads
		stage = 13
		forum = 15
		media = 16

	class c_msg_type(enum.IntEnum):
		# Some sequential numbers are unused/reserved/missing
		# https://discord.com/developers/docs/resources/message#message-object-message-types
		default = 0
		recipient_add = 1
		recipient_remove = 2
		call = 3
		channel_name_change = 4
		channel_icon_change = 5
		channel_pinned_message = 6
		guild_member_join = 7
		user_premium_guild_subscription = 8
		user_premium_guild_subscription_tier_1 = 9
		user_premium_guild_subscription_tier_2 = 10
		user_premium_guild_subscription_tier_3 = 11
		channel_follow_add = 12
		guild_discovery_disqualified = 14 # type=13 is missing in docs
		guild_discovery_requalified = 15
		guild_discovery_grace_period_initial_warning = 16
		guild_discovery_grace_period_final_warning = 17
		thread_created = 18
		reply = 19 # inline reply msg, was type=default with old api
		application_command = 20
		thread_starter_message = 21
		guild_invite_reminder = 22
		context_menu_command = 23
		auto_moderation_action = 24
		role_subscription_purchase = 25
		interaction_premium_upsell = 26
		stage_start = 27
		stage_end = 28
		stage_speaker = 29
		stage_topic = 31
		guild_application_premium_subscription = 32
		guild_incident_alert_mode_enabled = 36
		guild_incident_alert_mode_disabled = 37
		guild_incident_report_raid = 38
		guild_incident_report_false_alarm = 39
		purchase_notification = 44
		poll_result = 46
		media = -1 # internal type, same as "default" but can have empty "content"

	class c_rel_type(enum.IntEnum):
		none = 0
		friend = 1
		blocked = 2
		friend_req_in = 3
		friend_req_out = 4
		friend_implicit = 5

	c_msg_tags = enum.Enum('c_msg_tags', 'user chan role emo ts everyone other')
	c_msg_tags_ts_fmts = dict(
		t='%H:%M', T='%H:%M:%S', d='%Y-%m-%d', D='%m %B %Y',
		f='%Y-%m-%d %H:%M:%S', F='%c', R=repr_duration )

	def __init__(self, discord):
		self.discord, self.conf = discord, discord.conf
		self.api_url = self.conf.discord_api_url.format(api_ver=self.api_ver)
		self.log = get_logger(f'rdircd.discord')
		self.log_ws = get_logger(f'proto.ws')
		self.log_http = get_logger(f'proto.http')
		self.log_http_reqres = get_logger(f'proto.http.reqres')
		self._repr = ft.partial(str_cut, max_len=self.conf.debug_proto_cut, repr_fmt=True)

	def get_auth(self, k, default=ValueError):
		try: return self.conf.get(f'auth_{k}')
		except AttributeError as err:
			if default is ValueError: raise
			return default

	async def __aenter__(self):
		if not (self.get_auth('email') and self.get_auth('password')):
			raise DiscordSessionError('Missing account auth credentials')
		self.ctx, self.tasks = cl.AsyncExitStack(), StacklessContext(self.log)
		aiohttp_opts = adict(timeout=aiohttp.ClientTimeout(
			connect=self.conf.discord_http_timeout_conn,
			sock_connect=self.conf.discord_http_timeout_conn_sock ))
		if self.conf.debug_proto_aiohttp:
			aiohttp_opts.trace_configs = [setup_aiohttp_trace_logging(self.log_http_reqres)]
		self.http = await self.ctx.enter_async_context(aiohttp.ClientSession(**aiohttp_opts))
		self.ws_ctx = self.ws = self.ws_tasks = self.ws_handlers = self.ws_nonces = None
		self.ws_closed, self.ws_closed_clean = asyncio.Event(), asyncio.Event()
		self.ws_closed_clean.set()
		self.rate_limits = adict()
		st_guilds = {1: adict(id=1, name='me', ts_joined=0, kh='me', chans=dict(), roles=dict())}
		self.st_da = adict( guilds=st_guilds, me=st_guilds[1],
			embed_info=dict(), icache=adict(), req_action=None )
		self.auth_token, self.auth_token_manual = (
			self.get_auth('token'), self.get_auth('token_manual') )
		self.ws_enabled = False
		return self

	async def __aexit__(self, *err):
		if self.ws_enabled:
			self.ws_enabled.cancel()
			self.ws_enabled = None
		if self.ws_ctx: await self.ws_ctx.aclose()
		if self.ctx: await self.ctx.aclose()
		if self.tasks: await self.tasks.close()

	async def run(self):
		self.log.debug('Initializing discord session...')
		try: await asyncio.Future() # run forever
		except asyncio.CancelledError: pass
		self.log.debug('Finished')

	async def run_async(self):
		async with self: await self.run()

	def connect(self):
		if self.ws_enabled and not self.ws_enabled.done(): return
		task = self.ws_enabled = asyncio.create_task(self.ws_connect_loop())
		return self.tasks.add(task)
	def disconnect(self):
		if self.ws_enabled:
			self.ws_enabled.cancel()
			self.ws_enabled = None
		return self.tasks.add(self.ws_close())

	def state(self, st):
		'''This is purely informative, and should never
			actually be checked - use class flags for that, not strings here.'''
		st_old, self.st_da.state = self.st_da.get('state', 'none'), st
		if st == st_old: return
		self.log_ws.debug( '--- state: {} -> {}',
			st_old, st, extra=('---', f'st {st_old} -> {st}') )
		self.log.info('State: {} -> {}', st_old, st)
		if (online := st == 'ready') or st_old == 'ready':
			self.discord.cmd_online_state(online)


	### Regular HTTP requests and OAuth2 stuff

	async def rate_limit_wrapper(self, route, req_func):
		# Discord also returns proactive X-Rate-Limit headers, but these are
		#  not used here - shouldn't be needed, as simple client is unlikely to bump into them
		req_limit_defaults = 1, None
		while True:
			ts = time.time()
			req_limit, req_limit_ts = self.rate_limits.get(route) or req_limit_defaults
			if req_limit_ts and ts > req_limit_ts: req_limit = 1
			if req_limit <= 0 and req_limit_ts and req_limit_ts > ts:
				delay = req_limit_ts - ts
				self.log.debug('Rate-limiting request on route {!r}: delay={:,.1f}s', route, delay)
				await asyncio.sleep(delay + self.conf.discord_http_delay_padding)
			res = await req_func()
			req_limit_headers = list( res.headers.get(k)
				for k in ['X-RateLimit-Remaining', 'X-RateLimit-Reset'] )
			if any(req_limit_headers):
				warn, req_limit_vals = False, list(req_limit_defaults)
				for n, v in enumerate(req_limit_headers):
					try: req_limit_vals[n] = float(v)
					except ValueError as err: warn = False
				if warn:
					self.log.warning( 'Failed to parse rate-limit http'
						' header value(s), assuming default(s): {!r} / {!r}', *req_limit_headers )
				req_limit, req_limit_ts = self.rate_limits[route] = req_limit_vals
			if res.status == 429:
				m = await res.json()
				if delay := m.get('retry_after'):
					self.log.debug( 'Rate-limiting request on route'
							' {!r}: explicit-retry-after, delay={:,.1f}s, global={}, msg={!r}',
						route, delay, m.get('global'), m.get('message') )
					await asyncio.sleep(float(delay) + self.conf.discord_http_delay_padding)
					continue
				elif req_limit <= 0 and req_limit_ts and req_limit_ts > time.time(): continue
				else:
					raise DiscordSessionError(
						'Failed to get API rate-limiting retry delay for http-429 error' )
			break
		return res

	async def req_auth_token(self):
		while isinstance(self.auth_token, asyncio.Event): await self.auth_token.wait()
		if not self.auth_token:
			if self.auth_token_manual:
				raise DiscordAbort( 'Authentication token is set'
					' to be configured manually, but is not specified' )
			auth_token_ev = self.auth_token = asyncio.Event()
			try:
				email, pw = (self.get_auth(k) for k in ['email', 'password'])
				res = await self.req( 'auth/login', m='post',
					auth=False, json=dict(email=email, password=pw) )
				if res.get('mfa'): raise DiscordAbort(
					'Multi-factor auth requirement detected, but is not supported by rdircd' )
				if acts := res.get('required_actions'): # e.g. update_password
					msg = ( 'Discord account is marked as requiring'
						f' user action(s) - login/check via official client(s): {acts}' )
					self.discord.cmd_guild_event(1, msg); self.log.warning(msg)
				self.conf.set('auth_token', res['token'])
				self.conf.update_file_section('auth', 'token')
				self.auth_token = self.get_auth('token')
			except:
				self.auth_token = None # reset for next attempt
				raise
			finally: auth_token_ev.set()
		return self.auth_token

	async def req( self, url, m='get',
			route=None, auth=True, raw=False, **kws ):
		if not re.search(r'^https?:', url): url = up.urljoin(self.api_url, url.lstrip('/'))
		if route is None: route = url
		kws.setdefault('headers', dict()).setdefault(
			'User-Agent', self.conf.discord_api_user_agent )
		for att in 'normal', 'token_refresh':
			if auth:
				token = await self.req_auth_token()
				kws.setdefault('headers', dict()).update(Authorization=token)
			req_func = ft.partial(self.http.request, m, url, **kws)
			self.log_http.debug(' >> {} {}', m, url, extra=(' >>', f'{m} {url}'))
			res = await self.rate_limit_wrapper(route, req_func)
			if not auth: break
			if res.status == 401:
				res.release()
				if att != 'normal': raise DiscordSessionError('Auth failed')
			break
		if res.status >= 400:
			err = str_cut(await res.text(), self.conf.debug_err_cut, repr_fmt=True)
			raise DiscordHTTPError(res.status, f'[{res.status}] {res.reason} - {err}')
		if not raw:
			res_raw = res
			try: res = await res.json()
			except aiohttp.ContentTypeError:
				err = str_cut(await res.text(), self.conf.debug_err_cut, repr_fmt=True)
				raise DiscordHTTPError( res.status,
					f'[{res.status}] non-JSON data ({res.content_type}) - {err}' ) from None
			finally: res_raw.release()
		res_repr = str(res)
		if '\n' in res_repr: res_repr = (res_repr.strip() + ' ').splitlines()[0]
		self.log_http.debug('<<  {}', self._repr(res_repr), extra=('<< ', res_repr))
		return res


	### Gateway Websocket wrappers

	async def ws_connect_loop(self):
		opts = adict((k, parse_duration(
			self.conf.get(f'discord_ws_reconnect_{k}') )) for k in ['min', 'max'] )
		opts.factor = self.conf.discord_ws_reconnect_factor
		reconn_warn_tb = token_bucket(self.conf.discord_ws_reconnect_warn_tbf)
		interval, loop = opts.min, asyncio.get_running_loop()
		self.log.debug('Starting ws_connect_loop...')
		while self.ws_enabled:
			if next(reconn_warn_tb):
				self.log.warning( 'Reconnecting to discord faster than {},'
						' can be persistent problem, see info/debug/protocol logs for details',
					self.conf.discord_ws_reconnect_warn_tbf )
				# Reset tbf to avoid re-issuing warning on every subsequent reconnect
				reconn_warn_tb = token_bucket(self.conf.discord_ws_reconnect_warn_tbf)
			elif self.conf.discord_ws_reconnect_warn_max_delay and interval == opts.max:
				self.log.warning( 'Detected persistent discord server connection failures'
					' at max delay between attempts - there is likely some persistent problem,'
					' see info/debug/protocol logs for details' )
			try: await self.ws_connect()
			except DiscordSessionError as err:
				self.log.info('Connection failure, retrying in {:.1f}s: {}', interval, err)
				await asyncio.sleep(interval)
				interval = min(opts.max, interval * opts.factor)
				continue
			except Exception as err:
				self.log.exception( 'Unexpected error,'
					' stopping reconnection loop: {}', err_fmt(err) )
				await self.ws_close()
				break
			ts0 = loop.time()
			await self.ws_closed.wait()
			if not self.ws_enabled: break
			ts_diff = loop.time() - ts0
			if ts_diff > interval:
				interval = opts.min
				self.log.info('Disconnected, reconnecting immediately')
			else:
				interval = min(opts.max, interval * opts.factor)
				delay = interval - ts_diff
				self.log.info( 'Disconnected too quickly'
					' ({:.1f}s), reconnecting in {:.1f}s', ts_diff, delay )
				await asyncio.sleep(delay)
		self.log.debug('ws_connect_loop finished')

	async def ws_connect(self):
		if self.ws_ctx: return
		if not self.ws_closed_clean.is_set():
			self.log.warning('BUG: ws_connect issued before old websocket is closed')
			await self.ws_closed_clean.wait()
		self.state('connecting.init')
		self.ws_ctx = ctx = cl.AsyncExitStack()
		self.ws, self.ws_tasks = None, StacklessContext(self.log)
		self.ws_handlers, self.ws_nonces = dict(), dict()
		ctx.push_async_callback(self.ws_close)
		self.ws_closed.clear()
		for cache in True, False:
			if cache:
				if not self.conf.discord_gateway: continue
				self.state('connecting.ws.cached')
			else:
				self.state('connecting.ws.get-url')
				try: self.conf.discord_gateway = (await self.req('gateway', auth=False))['url']
				except aiohttp.ClientError as err:
					self.state('connecting.ws.error')
					self.log.info('Failed to fetch discord gateway URL: {}', err_fmt(err))
					continue # ws_connect will fail
				self.conf.update_file_section('discord', 'gateway')
			parts = adict(up.urlsplit(self.conf.discord_gateway)._asdict())
			query = up.parse_qs(parts.query)
			query.update(v=str(self.api_ver), encoding='json', compress='zlib-stream')
			parts.query = up.urlencode(query)
			ws_url = up.urlunsplit(tuple(parts.values()))
			self.log_ws.debug('--- -conn- {}', ws_url, extra=('---', f'conn {ws_url}'))
			self.state('connecting.ws')
			try: # ws_connect(timeout=...) is NOT a connection timeout!
				async with aio_timeout(self.conf.discord_ws_conn_timeout):
					self.ws = await ctx.enter_async_context(self.http.ws_connect(
						ws_url, headers={'User-Agent': self.conf.discord_api_user_agent},
						heartbeat=self.conf.discord_ws_heartbeat, max_msg_size=20 * 2**20 ))
			except (aiohttp.ClientError, asyncio.TimeoutError) as err:
				self.log_ws.debug( '--- -conn-fail- {}',
					err_str := err_fmt(err), extra=('---', f'conn-fail {err_str}') )
				self.state('connecting.ws.error')
				self.log.info('Gateway connection error: {}', err_str)
				if cache: continue # try fetching new gw url
			else: break
		else: self.ws = None
		if self.ws_closed.is_set():
			self.ws_closed.clear() # to have it do cleanup again
			await self.ws_close()
			raise DiscordSessionError('Close-command issued while connecting')
		if not self.ws:
			self.state('connecting.ws.fail')
			await self.ws_close()
			raise DiscordSessionError('Failed to connect to discord')
		self.state('connected')
		self.ws_add_handler(self.c.dispatch, self.op_track_seq)
		self.ws_add_handler(self.c.reconnect, self.op_reconnect)
		self.ws_add_handler(self.c.hello, self.op_hello)
		self.ws_add_handler(self.c.invalid_session, self.op_invalid_session_retry)
		self.ws_tasks.add(self.ws_poller())
		self.ws_tasks.add(self.ws_auth_timeout())

	_ws_handler = cs.namedtuple('ws_handler', 'op t func')
	def ws_add_handler(self, op=None, func=None, t=None, replace=False, remove=False):
		if replace or remove:
			for k, wsh in list(self.ws_handlers.items()):
				if wsh.op == op and wsh.t == t: del self.ws_handlers[k]
			if remove: return
		if not func: raise ValueError(func)
		wsh = self._ws_handler(op, t, func)
		self.ws_handlers[wsh] = wsh

	async def ws_poller(self):
		try: await self.ws_poller_loop()
		except Exception as err:
			self.log.exception('Unhandled ws handler failure, aborting: {}', err_fmt(err))
		self.ws_close_later()

	async def ws_poller_loop(self):
		# {op=0**, s=**42, d={...}, t=**'GATEWAY_EVENT_NAME'}
		# {op=...[, d={...}]}
		inflator, buff, buff_end = zlib.decompressobj(), bytearray(), b'\x00\x00\xff\xff'
		async for msg in self.ws:
			msg_type, msg_str = msg.type, getattr(msg, 'data', '')
			if msg_type == aiohttp.WSMsgType.binary:
				buff.extend(msg_str or b'')
				if msg_str[-4:] != buff_end: continue # partial data
				msg_str = inflator.decompress(buff).decode()
				msg_type = aiohttp.WSMsgType.text
				buff.clear()
			if msg_type == aiohttp.WSMsgType.text:
				hs_discard, handled = set(), False
				msg_ev = msg_op = None
				try:
					msg_data = adict(json.loads(msg_str))
					msg_ev, msg_op = msg_data.get('t'), msg_data.get('op')
				finally: # logging should work regardless of parser issues
					if msg_ev not in self.conf._debug_proto_log_filter_ws:
						self.log_ws.debug( '<<  {} {}', msg_type.name.lower(),
							self._repr(msg_str), extra=('<< ', f'{msg_type} {msg_str}') )
					if self.conf.ws_dump_file and (chk := self.conf.ws_dump_filter):
						if chk.get('op') in [msg_op, None] and chk.get('t') in [msg_ev, None]:
							with open(path_filter(self.conf.ws_dump_file), 'w') as dst: dst.write(msg_str)
				for k, h in list(self.ws_handlers.items()):
					if h.op is not None and msg_data.get('op') != h.op: continue
					if h.t is not None and (msg_data.get('t') or '').lower() != h.t: continue
					handled = True
					try: status = await aio_await_wrap(h.func(msg_data))
					except Exception as err: # unhandled exc is unlikely to have event info
						self.log.error( 'ws event caused handler exception'
							' [{}]: {}', err.__class__.__name__, self._repr(msg_str) )
						raise
					if status is self.c.oneshot: hs_discard.add(k)
				for k in hs_discard: self.ws_handlers.pop(k, None)
				if not handled:
					err, msg_repr = 'unhandled-text', self._repr(msg_str)
					self.log_ws.debug( 'xxx {} {}', err,
						msg_repr, extra=('xxx', f'{err} {msg_data}') )
					self.log.warning('ws event - unhandled: {}', msg_repr)
			elif msg_type == aiohttp.WSMsgType.closed: break
			elif msg_type == aiohttp.WSMsgType.error:
				self.log_ws.debug('err {}', msg, extra=('err', msg))
				self.log.error('ws protocol error, aborting: {}', msg)
				break
			else: self.log.warning('Unhandled ws msg type {}, ignoring: {}', msg.type, msg)

	async def ws_auth_timeout(self):
		await asyncio.sleep(timeout := self.conf.discord_ws_auth_timeout)
		if self.st_da.state == 'ready': return
		self.log.error( 'Discord gateway auth failed'
			' to complete within {:.1f}s, closing connection', timeout )
		self.ws_close_later()

	async def ws_send_task(self, msg_data):
		try: await self.ws.send_str(msg_data)
		except ConnectionError as err: # not handled by aiohttp in some cases
			self.log.info('Conn error when trying to send last protocol data: {}', err_fmt(err))
			self.state('connection.fail')
			self.ws_close_later()

	def ws_send(self, op, d):
		msg_data = json.dumps(dict(op=op, d=d))
		self.log_ws.debug( ' >> text {}',
			self._repr(msg_data), extra=(' >>', f'text {msg_data}') )
		self.tasks.add(self.ws_send_task(msg_data))

	def ws_close_later(self):
		'Wrapper to schedule ws_close() from one of ws_tasks or synchronously.'
		# Idea here is just to avoid ws_close_task() cancelling itself
		self.tasks.add(self.ws_close())

	def ws_close(self):
		# Makes sure that only one ws_close_task()
		#  is scheduled at a time, and only if needed
		if ( self.ws_closed.is_set()
			or not self.ws_closed_clean.is_set() ): return asyncio.sleep(0)
		self.ws_closed_clean.clear()
		return self.ws_close_task()

	async def ws_close_task(self):
		try:
			if self.ws_closed.is_set():
				return self.log.warning('BUG: ws_close with websocket already closed')
			self.log_ws.debug('--- -close-', extra=('---', 'close'))
			self.state('closing')
			if self.ws_nonces:
				for fut in self.ws_nonces.values(): fut.cancel()
			if self.ws_tasks: await self.ws_tasks.close()
			if self.ws: await self.ws.close()
			self.ws_ctx = self.ws = self.ws_tasks = self.ws_handlers = self.ws_nonces = None
			self.st_da.disconnect_ts = time.monotonic()
			self.state('disconnected')
			self.ws_closed.set()
		finally: self.ws_closed_clean.set()


	### Gateway Websocket request wrappers (rare)

	async def ws_req_users_query(self, gid, query, limit=None):
		'Query/return member objects indexed by gid.uid for specified guild(s)'
		if not limit: limit = self.conf.discord_user_query_limit
		nonce = self.discord.flake_build(time.time())
		fut = self.ws_nonces[nonce] = asyncio.Future()
		if not isinstance(gid, list): gid = [gid]
		fut.results, fut.queries = dict(), dict.fromkeys(gid)
		self.ws_send( self.c.request_guild_members,
			dict(nonce=nonce, guild_id=gid, presences=False, limit=limit, query=query) )
		try: return await asyncio.wait_for(fut, to := self.conf.discord_user_query_timeout)
		except asyncio.TimeoutError:
			self.log.warning( 'User-query results timeout ({:.1f}s):'
				' query={!r} gids={} limit={} missing={}', to, query, gid, limit, fut.queries )
			return fut.results
		finally: del self.ws_nonces[nonce]

	def ws_req_guild_sync(self):
		'Fetches a list of threads and enables message events in large guilds (>10K users)'
		for gid, gg in self.st_da.guilds.items():
			if gid == 1: continue # "me" pseudo-guild
			chan_req = list( (cc.id, [[0, 99]])
				for cc in gg.chans.values() if cc.ct == cc.ct.text )
			if not chan_req: continue
			# It doesn't seem to matter what channels= are sent in request_sync_chan -
			#   all threads are returned regardless, along with GUILD_MEMBER_LIST_UPDATE and such
			# typing=true activities=true is required to get MESSAGE_CREATE in large guilds
			self.ws_send(self.c.request_sync_chan, dict( guild_id=gid,
				threads=True, typing=True, activities=True, channels=dict([chan_req[0]]) ))

	### Gateway Websocket event handlers

	def op_track_seq(self, m): self.st_da.seq = m.s

	def op_reconnect(self, m):
		self.log.info('Received reconnect event - closing connection')
		self.ws_close_later()

	async def op_hello(self, m):
		self.state('hello')
		self.st_da.hb_interval = m.d.heartbeat_interval / 1e3
		await self.op_hello_auth()
		return self.c.oneshot

	async def op_hello_auth(self):
		self.state('hello.auth.token')
		sid = self.st_da.get('session_id')
		token = await self.req_auth_token()
		if not sid:
			self.state('hello.auth.identify')
			self.ws_add_handler( self.c.dispatch,
				t='ready', func=self.op_ready, replace=True )
			# Note: sending API intents seem to strip "contents" from other people's messages!
			self.ws_send(self.c.identify, dict(
				properties={'os': 'Linux', 'browser': 'rdircd', 'device': ''},
				token=token, compress=False ))
		else:
			self.state('hello.auth.resume')
			self.ws_add_handler( self.c.dispatch,
				t='resumed', func=self.op_ready, replace=True )
			self.ws_send(self.c.resume, dict(
				token=token, session_id=sid, seq=self.st_da.get('seq') ))

	async def op_invalid_session_retry(self, m):
		# "expected to wait a random amount of time -
		#  - between 1 and 5 seconds - then send a fresh Opcode 2 Identify"
		self.state('session.error.delay')
		delay = asyncio.create_task(asyncio.sleep(1 + random.random() * 4))
		if not m.get('d') and self.st_da.get('session_id'):
			self.log.info( 'Session/auth rejected (id={}) - trying'
				' to open new session first', self.st_da.get('session_id', '-')[:6] )
			self.st_da.session_id = self.st_da.seq = None
			await delay
		else:
			if self.auth_token_manual:
				self.log.info( 'Auth rejected, but auth token'
					' is set to be manual, so just retrying once more' )
			else:
				self.log.info('Auth rejected - updating auth token')
				self.auth_token = None
				token = await self.req_auth_token()
			await delay
			self.ws_add_handler( self.c.invalid_session,
				self.op_invalid_session_fail, replace=True )
		self.state('session.error')
		await self.op_hello_auth()

	def op_invalid_session_fail(self, m):
		if self.conf.discord_ws_reconnect_on_auth_fail:
			self.state('session.auth-fail-reconnect')
		else:
			self.log.warning('Session/auth rejected unexpectedly - disabling connection')
			self.state('session.fail')
			self.ws_enabled = False
		self.ws_close_later()

	def op_invalid_session_event(self, m):
		self.log.warning('Unexpected "invalid session" event - reconnecting')
		self.state('session.fail')
		self.ws_close_later()

	def op_ready(self, m):
		md, resume, ts = m.get('d'), False, time.monotonic()
		ts_start = tuple(self.st_da.get(k) for k in ['session_ts', 'connect_ts'])
		if md and md.get('session_id'):
			self.st_da.update(
				session_id=md.session_id,
				session_ts=ts,
				user_id=md.user.id,
				user_name=md.user.username,
				user_n=md.user.discriminator,
				read_state_last=1 )
			self.op_ev_guilds(md.get('guilds'), sync=True)
			self.op_ev_chans(self.st_da.me.id, md.get('private_channels'), sync=True)
			self.ws_req_guild_sync()
			self.log.debug( 'New session id: {} gw=[ {} ]',
				self.st_da.session_id, md.get('resume_gateway_url') )
			if isinstance(rs := md.get('read_state'), dict): rs = rs.get('entries')
			for rs in rs or list():
				if not (n := rs.get('last_viewed')): continue
				self.st_da.read_state_last = max(n, self.st_da.read_state_last)
			if (act := md.get('required_action')) != self.st_da.req_action:
				self.st_da.req_action, msg = act, ( 'Discord account is marked'
					' as requiring user action(s) - login/check via official client(s)' )
				self.discord.cmd_guild_event(1, msg); self.log.warning(msg)
		else: resume = True
		if resume_url := md.get('resume_gateway_url'):
			self.conf.discord_gateway = resume_url
		td_sess, td_conn = ((repr_duration(self.st_da.get(
			'disconnect_ts' ) or ts, ts0, ext=None) if ts0 else '-') for ts0 in ts_start)
		level = 'warning' if self.conf.discord_ws_reconnect_warn_always else 'debug'
		ws_addr = ws_ep[0] if (ws_ep := self.ws.get_extra_info('peername')) else '?'
		getattr(self.log, level)( 'Discord session (re-)connected{}: id={} gw-addr={}{}',
			' [resumed]' if resume else ' [new]', self.st_da.session_id[:6], ws_addr,
			'' if not (td_sess + td_conn).strip('-') else ' {}sess-lifetime=[{}]{}'.format(
				'last-' if not resume else '', td_sess,
				'' if td_conn == td_sess else f' last-conn-duration=[{td_conn}]' ) )
		self.st_da.connect_ts, self.st_da.disconnect_ts = ts, None
		self.state('ready')
		self.ws_add_handler(self.c.dispatch, func=self.op_ev)
		self.ws_add_handler( self.c.invalid_session,
			self.op_invalid_session_event, replace=True )
		self.ws_add_handler(self.c.heartbeat, func=self.op_heartbeat_req)
		self.ws_tasks.add(self.op_heartbeat_task(self.st_da.hb_interval))
		return self.c.oneshot

	async def op_heartbeat_task(self, interval):
		loop = asyncio.get_running_loop()
		self.st_da.hb_ts_ack = hb_ts = loop.time() + interval
		self.ws_add_handler(self.c.heartbeat_ack, self.op_heartbeat_ack)
		while not self.ws_closed.is_set():
			self.ws_send(self.c.heartbeat, self.st_da.get('seq'))
			ts = loop.time()
			if self.st_da.hb_ts_ack < ts - interval*2:
				self.log.info('Missing heartbeat ack, reconnecting')
				self.state('heartbeat.fail')
				return self.ws_close_later()
			while hb_ts <= ts: hb_ts += interval
			delay = hb_ts - ts
			await asyncio.sleep(delay)

	def op_heartbeat_ack(self, m):
		self.st_da.hb_ts_ack = asyncio.get_running_loop().time()

	def op_heartbeat_req(self, m):
		self.ws_send(self.c.heartbeat, self.st_da.get('seq'))

	def op_ev(self, m):
		'Dispatcher for op=0 (dispatch) gateway-ws events, which is most of the activity'
		mt = (m.get('t') or '').lower()
		try: o, act = mt.rsplit('_', 1)
		except ValueError: o = act = None

		# Events with some handling needed
		if o == 'guild':
			if act in ['create', 'update']: return self.op_ev_guilds(m.d)
			elif act == 'delete': return self.op_ev_del_guild(m.d)
		elif mt.startswith('guild_member_'):
			act = mt[13:]
			if act == 'list_update': return self.op_ev_member_ops(m.d)
			elif act in ['add', 'update']: return self.op_ev_member(m.d)
			elif act == 'remove': return self.op_ev_member(m.d, delete=True)
		elif mt == 'guild_members_chunk': return self.op_ev_member_chunk(m.d)
		elif o == 'guild_ban':
			if act in ['add', 'remove']: return self.op_ev_ban(m.d, act)
		elif o == 'guild_scheduled_event': return self.op_ev_sched(m.d, act)
		elif o == 'guild_emojis': return self.op_ev_emojis(m.d)
		elif mt == 'thread_list_sync': return self.op_ev_thread_list(m.d)
		elif o in ['channel', 'thread']:
			if act in ['create', 'update']: return self.op_ev_chans(m.d.get('guild_id') or 1, m.d)
			elif act == 'delete': return self.op_ev_del_chan(m.d)
		elif o == 'channel_recipient': return self.op_ev_recipient(m.d, act)
		elif o == 'message':
			if act in ['create', 'update', 'delete']: return self.op_msg(m.d, act)
			elif act == 'ack': return # reading private chats from browser
		elif o == 'relationship': return self.op_ev_rel(m.d, act)
		elif mt == 'message_delete_bulk': # can maybe shorten notice-spam here somehow
			for msg_id in m.d.ids: self.op_msg(adict(id=msg_id, **m.d), 'delete')
			return
		elif mt == 'gift_code_update': return self.op_gift_code(m.d)
		elif mt.startswith('voice_'):
			if mt == 'voice_state_update': return self.op_voice_st_user(m.d)
			elif mt == 'voice_channel_status_update': return self.op_voice_st_chan(m.d)
		elif mt.startswith('message_reaction_'): return self.op_react(m.d, mt[17:])
		elif mt == 'notification_center_item_create': return self.op_ev_note(m.d)
		elif mt == 'typing_start': return self.op_typing(m.d)

		# Known-ignored events
		elif o in { 'integration', 'channel_pins', 'webhooks', 'call',
			'presence', 'presences', 'thread_member', 'thread_members', 'stage_instance',
			'guild_integrations', 'guild_role', 'guild_stickers', 'guild_audit_log_entry',
			'burst_credit_balance', 'message_poll_vote' }: return
		elif re.search( r'^(guild_scheduled_event'
			r'|(guild_)?application_command|embedded_activity)_', mt ): return
		elif re.search( r'^user_(note|'
			r'guild_settings|settings|settings_proto)_update$', mt ): return
		elif mt.endswith('_notification_sent'): return # reaction, generic_push, etc
		elif mt in { 'sessions_replace', 'invite_create', 'guild_feature_ack',
			'user_non_channel_ack', 'channel_topic_update', 'channel_unread_update',
			'conversation_summary_update', 'guild_soundboard_sounds_update',
			'content_inventory_inbox_stale' }: return

		# Known stuff must be explicitly handled above to not generate "Unhandled event"
		self.log.warning('Unhandled event: {}', self._repr(m))

	def op_ev_thread_list(self, d):
		self.op_ev_chans(d.guild_id, d.threads, sync=False)

	def op_ev_member_chunk(self, d):
		for m in (ms := d.get('members', list())): self.op_ev_member(m, d.guild_id)
		if not (fut := self.ws_nonces.get(d.get('nonce'))):
			return self.log.warning( 'Unexpected member-chunk'
				' response: {}', str_cut(d, self.conf.debug_msg_cut) )
		fut.results.setdefault(d.guild_id, dict()).update((m.user.id, m) for m in ms)
		if d.chunk_count == 1: fut.queries.pop(d.guild_id, None)
		else:
			if not (chunks := fut.queries.get(d.guild_id)):
				chunks = fut.queries[d.guild_id] = set(range(d.chunk_count))
			chunks.discard(d.chunk_index)
			if not chunks: del fut.queries[d.guild_id]
		if not fut.queries: fut.set_result(fut.results)

	def op_ev_member_ops(self, d):
		for o in d.get('ops', list()):
			for item in o.get('items') or list():
				m = item.get('member', dict())
				if m: self.op_ev_member(m, d.guild_id, delete=o.op=='DELETE')

	def op_ev_member(self, m, gid=None, delete=False):
		if not gid: gid = m.guild_id
		if delete: return self.discord.cmd_user_cache(gid, m.user.id, delete=True)
		self.discord.cmd_user_cache(gid, m.user.id, self.discord.user_name(m.user))

	def op_ev_del_guild(self, m):
		if not (gg := self.st_da.guilds.get(gid := m.id)): return
		if outage := m.get('unavailable'):
			self.log.debug('Guild outage event: guild={}', gid)
			self.discord.cmd_guild_event(gid, 'Discord outage notification')
		else:
			self.log.debug('Guild delete event: guild={}', gid)
			self.discord.cmd_guild_event(gid, 'Discord was DELETED')
			self.st_da.guilds.pop(gid, None)

	def op_ev_del_chan(self, m):
		if not (gg := self.st_da.guilds.get(gid := m.get('guild_id') or 1)): return
		self.log.debug('Channel delete event: guild={} chan={}', gid, chan := m.id)
		if cc := gg.chans.pop(chan, None): self.discord.cmd_guild_event(
			gid, f'Discord channel deleted: {self.discord.irc_chan_name(cc)}' )

	def op_ev_guilds(self, guilds, sync=False):
		gs_new, gs_chans, gs_vcs = {1: self.st_da.me}, dict(), cs.defaultdict(set)
		for g in force_list(guilds):
			if g.get('unavailable'): continue # can be sent in "ready" event
			if g.id == self.st_da.me.id:
				self.log.error('Skipping guild due to id=1 conflict with "me" guild: {}', g)
				continue
			# "properties" can be separated with some capabilities= bits in op=identify
			if 'properties' in g: g = adict(g.pop('properties'), **g)
			gg = gs_new.setdefault( g.id,
				self.st_da.guilds.get(g.id, adict(id=g.id, chans=dict())) )
			if g.id not in self.st_da.guilds:
				prefix = self.discord.bridge.uid('guild', gg.id, kh=gg.get('kh'))
				self.log.debug('New guild: gid={} prefix={} name={!r}', g.id, prefix, g.name)
			ts_joined = g.get('joined_at') or 0
			if ts_joined: ts_joined = parse_iso8601(ts_joined)
			gg.update( name=g.name, ts_joined=ts_joined,
				roles=dict((r.id, r) for r in g.get('roles', list())),
				emojis=self.op_ev_guilds_emojis(g.get('emojis') or list()) )
			if 'channels' in g: gs_chans[g.id, 'c'] = g.channels # missing in guild_update evs
			if 'threads' in g: gs_chans[g.id, 't'] = g.threads # only "joined" ones are listed here!
			gs_vcs[g.id].update(vs.get('channel_id') for vs in g.get('voice_states') or list())
		dict_update(self.st_da.guilds, gs_new, sync=sync)
		for (gid, sk), chans in sorted(gs_chans.items()):
			self.op_ev_chans(gid, chans, sync=sk == 'c')
			for cid, cc in self.st_da.guilds[gid].chans.items():
				if cid in gs_vcs[gid]: cc.vc_active = True

	def op_ev_guilds_emojis(self, emojis):
		return dict(
			(em.name.lower(), adict(id=em.id, name=em.name))
			for em in emojis if em.get('available') )

	def op_ev_chans(self, gid, chans, sync=None):
		if not (gg := self.st_da.guilds.get(gid)): return
		chans_updated, new_threads = self.op_ev_chans_process(gg, chans)

		rename_list = list() # sent after chan_map is updated
		for cc, name_old in self.op_ev_chans_rename_list(chans_updated, gg.chans):
			rename_list.append(self.discord.cmd_chan_rename_func(cc, name_old))

		for cc in chans_updated.values():
			if cc.id in gg.chans or cc.tid: continue # threads are logged separately below
			self.log.debug( 'New channel [id={} gid={}]:'
				' {!r} ({!r})', cc.id, gg.id, cc.name, cc.topic )

		dict_update(gg.chans, chans_updated, sync=sync)
		self.discord.cmd_chan_map_update()

		if sync is None:
			# "new_threads" is needed to avoid mentioning them on every (re-)connect
			# Notification is also needed in case msgs from these to parent chan are disabled
			for cc in new_threads:
				self.log.debug( 'New thread-channel'
					' [id={} gid={}]: {!r} ({!r})', cc.id, gg.id, cc.name, cc.topic )
				self.discord.cmd_chan_thread(cc)
		for ren_func in rename_list: ren_func() # these msgs go to old chans

	def op_ev_chans_process(self, gg, chans):
		'''Process channel data from discord into internal information.
			Earlier information from gg.chans is used as a base and for diffs, if it is there.'''
		_gg_prefix = lambda: self.discord.bridge.uid('guild', gg.id, kh=gg.get('kh'))
		ct, chans_updated, new_threads = self.c_chan_type, dict(), list()
		for c in force_list(chans):
			name, topic = c.get('name'), c.get('topic') or ''
			cc = gg.chans.get(c.id, adict(
				tid=None, names=adict(), threads=adict(), last_msg_sent=adict(),
				users=TimedCacheDict(self.conf._irc_names_timeout, bump_on_get=False) ))
			cc.names.update(raw=name or '', old=cc.get('name', '')) # for various renames

			if c.type in [ct.group, ct.store]: continue
			try: cc_type = ct(c.type)
			except ValueError:
				self.log.error( 'BUG - ignoring unknown channel'
						' type={}: guild={!r} (id={} prefix={}) name={!r} topic={!r} id={}',
					c.type, gg.name, gg.id, _gg_prefix(), name, topic, c.id )
				continue
			# Voice chats often have same name as text ones, so disambiguate via suffix
			if c.type in [ct.voice, ct.stage]: name = f'{name}.vc'
			if c.type in [ct.thread, ct.thread_news, ct.thread_private]:
				cc_parent = gg.chans.get(c.parent_id) or chans_updated.get(c.parent_id)
				if not cc_parent:
					self.log.error( 'BUG - ignoring thread sub-channel with'
							' unknown parent-chan={}: guild={!r} (id={} prefix={}) name={!r} id={}',
						c.parent_id, gg.name, gg.id, _gg_prefix(), name, c.id )
					continue
				c_tid = self.conf.discord_thread_id_prefix
				c_tid += str_norm(str_hash( c.id, 4,
					strip=(c_tid.lower() + c_tid.upper()) if len(c_tid) == 1 else '' ))
				name = self.op_ev_chans_thread_name(c_tid, name, cc_parent.name)
			else: c_tid = None
			cc_private = c.type in [ct.private, ct.private_group]

			if c_tid:
				cc.tid, cc.parent, cc_parent.threads[c_tid] = c_tid, cc_parent, cc
				if not cc.names.old: new_threads.append(cc)
			elif cc_private:
				cc.users.timeout = 2**32 # persistent list of users here
				for n, u in enumerate(users := c.get('recipients') or list()):
					self.discord.cmd_user_cache(gg.id, u.id, nick := self.discord.user_name(u))
					users[n] = nick, None # gets resolved to irc nick on first use
				dict_update(cc.users, users, sync=True)
				name_kws = dict(
					id=(name_hash := str_hash(c.id, 8)),
					chat_name=(chat_name := (c.get('name') or '').strip()) )
				if any(f'{{{k}}}' in self.conf.irc_chan_private for k in ['names', 'names_or_id']):
					# {id} hash-name is not descriptive, but should stay the same for group chats
					# Caveat: channel names are case-insensitive, even if hash is not
					name_kws['names_or_id'] = name_kws['id']
					if '{names}' in self.conf.irc_chan_private or (
							len(cc.users) < self.conf.irc_private_chat_min_others_to_use_id_name ):
						name_kws['names'] = self.op_ev_chans_priv_name(cc.users)
						name_kws['names_or_id'] = name_kws['names']
				name = self.conf.irc_chan_private.format(**name_kws)
				if not topic:
					topic = chat_name and f' :: {chat_name} ::'
					topic = ( f'private chat <{name_hash}>'
						f'{topic} [{len(cc.users)}] ' + ', '.join(cc.users) )

			if not (rename := self.conf.renames.get(('chan', f'@{c.id}'))):
				rename = self.conf.renames.get(('chan', self.discord.bridge.irc_name(name)))
			if rename:
				self.log.debug('Renaming channel/thread: {} -> {}', name, rename)
				name = rename
			cc.names.base = name # deduped later, or reverted back to this from hashed one

			cc.update(
				id=c.id, gg=gg, did=f'#{gg.id}-{c.id}', name=name, topic=topic,
				ct=cc_type, private=cc_private, pos=c.get('position', -1)+1, nsfw=c.get('nsfw'),
				last_msg=c.get('last_message_id'), last_pin=c.get('last_pin_timestamp') )
			chans_updated[c.id] = cc
		return chans_updated, new_threads

	def op_ev_chans_rename_list(self, cs_new, cs_old):
		'''Detect discord channels within same guild that need renames to not clash on IRC.
			Yields (cc, name_old) tuples to report via IRC notices, updates cc.name after that.'''
		## Renames that happened on the discord side, between cs_old -> cs_new
		# cs_old should contain exactly same cc objects as cs_new for same ids
		# threads are skipped here - should be unique already, renamed with parent chans
		chans = dict((cc.id, cc) for cc in cs_old.values())
		# names restored to discord originals, to detect when hash in them is no longer needed
		names = dict(( cc.id, cc.names.base
			if cc.names.base != cc.name else cc.name ) for cc in chans.values())
		for cc in cs_new.values(): chans[cc.id], names[cc.id] = cc, cc.name

		## Find where renames need to happen to make names unique
		chan_names, chan_names_unique = irc_name_dict(), irc_name_dict()
		for cc in chans.values():
			name = names[cc.id]
			if ccs := chan_names.get(name): ccs[cc.id] = cc # name conflict
			else: chan_names[name] = {cc.id: cc}
		for name, ccs in list(chan_names.items()):
			if len(ccs) > 1: continue
			del chan_names[name]
			chan_names_unique.add(name)

		## Resolve found ambiguities by appending id-hash to names
		chan_names_hashed = irc_name_dict()
		name_fmt, name_hlen = (getattr(
			self.conf, f'discord_chan_dedup_{k}' ) for k in ['fmt', 'hash_len'])
		for name, ccs in chan_names.items():
			for cc in ccs.values():
				id_hash = cc.id
				for n in range(100):
					id_hash = str_hash(id_hash, name_hlen)
					name_hashed = name_fmt.format(name=names[cc.id], id_hash=id_hash)
					if ( name_hashed not in chan_names_hashed
						and name_hashed not in chan_names_unique ): break
				else: raise RuntimeError(f'str_hash() loop on: {id_hash!r} [len={name_hlen}]')
				names[cc.id] = name_hashed
				chan_names_hashed.add(name_hashed)

		## Detect name changes from cc.names.old, yield as renames, update cc.name/s.old
		for cc in sorted(chans.values(), key=lambda cc: bool(cc.tid)):
			src, dst = cc.name, names[cc.id]
			if dst is None: continue # thread already processed on chan rename
			thread_info = ( f' [+ {len(cc.threads)} thread]'
				if cc.threads else (' [thread]' if cc.tid else '') )
			if not irc_name_eq(src_old := cc.names.old or src, dst):
				self.log.debug('Channel rename{}: {!r} -> {!r}', thread_info, src_old, dst)
				for c_tid, cct in cc.threads.items(): # can be skipped here due to manual [renames]
					dst_thread = self.op_ev_chans_thread_name(c_tid, cct.names.base, dst)
					if not irc_name_eq(src_thread := cct.names.old or cct.name, dst_thread):
						yield cct, src_thread
				yield cc, src_old
			if not irc_name_eq(src, dst):
				self.log.debug('Channel name dedup-update{}: {!r} -> {!r}', thread_info, src, dst)
				for c_tid, cct in cc.threads.items():
					dst_thread = self.op_ev_chans_thread_name(c_tid, cct.names.raw, dst)
					if not irc_name_eq(cct.name, dst_thread): cct.name = cct.names.old = dst_thread
					names[cct.id] = None # skips dup rename msg if cct is also in chans
				cc.name = cc.names.old = dst

	def op_ev_chans_thread_name(self, c_tid, name, name_chan):
		topic, name = f'[thread {c_tid}] {name}', name[:self.conf.irc_thread_chan_name_len]
		if name: name = f'.{name}'
		return f'{name_chan}.{c_tid}{name}'

	def op_ev_chans_priv_name(self, user_names):
		'Makes channel name of limited length from possibly-truncated usernames'
		n = max_len_chan = self.conf.irc_private_chat_name_len
		min_len_user = self.conf.irc_private_chat_name_user_min_len
		while True:
			name = '+'.join(sorted(name.replace('+', '')[:n] for name in user_names))
			if n <= min_len_user or len(name) <= max_len_chan: return name[:max_len_chan]
			n -= 1

	def op_ev_recipient(self, m, act):
		# Sometimes this gets sent instead of c_msg_type.recipient_add, didn't check why
		# No guild_id passed here (always "me"), only user + channel_id
		cc = self.st_da.me.chans.get(m.channel_id)
		nick = (u := m.get('user')) and self.discord.user_name(u)
		if u: self.discord.cmd_user_cache(1, u.id, nick)
		self.discord.cmd_msg_recv(cc, nick, f'recipient {act.lower()}', dict(_ev=True))

	def op_ev_ban(self, m, act):
		name = self.discord.user_name(m)
		self.discord.cmd_guild_event(m.guild_id, f'Discord user ban: {act} {name}')

	def op_ev_rel(self, m, act):
		t, name = m.get('type'), m.get('user')
		try: t = self.c_rel_type(t).name
		except ValueError: t = f'unknown[{t}]'
		if name: name = self.discord.user_name(name)
		uid, name = m.get('id', '?'), '' if not name else f' name={name}'
		self.discord.cmd_guild_event(1, f'Relationship: [uid={uid}{name}] {act} {t}')

	def op_ev_sched(self, m, act, _sts={2: 'started', 3: 'ended'}):
		ev_st, ev_hash = m.get('status'), str_hash(m.id, 4)
		ts0, ts1 = (m.get(f'scheduled_{k}_time') for k in ['start', 'end'])
		ev_info = [m.get('name')]
		if ts0:
			ts0, ts1 = ((v and parse_iso8601(v)) for v in [ts0, ts1])
			ts_ext, ts_ext_span = f'{ts_iso8601(ts0, human=True, short=True)}', list()
			if ts1: ts_ext += f' - {ts_iso8601(ts1, human=True, short=True, strip_date=ts0)}'
			if abs(ts0 - time.time()) > 30: ts_ext_span.append(repr_duration(ts0, time.time()))
			if ts1: ts_ext_span.append(f'lasts {repr_duration(ts1, ts0, ext=False)}')
			if ts_ext_span: ts_ext += f' [{", ".join(ts_ext_span)}]'
			ev_info.append(ts_ext)
		ev_info.extend([m.get('description', ''), ' '.join(
			f'{k}=[ {v} ]' for k,v in (m.get('entity_metadata') or dict()).items() if v )])
		ev_info = ' :: '.join(filter(None, ev_info))
		if not ev_info: # dunno how to translate it, so print a kind of debug info
			ev_info = f'type={m.get("entity_type", "?")} status={m.get("status", "?")}'
		if act == 'update' and ev_st in _sts: act = f' {_sts[ev_st]}'
		else: act = f' {act}' if act != 'create' else ''
		self.discord.cmd_guild_event( m.guild_id,
			f'Scheduled event{act} :: {ev_info}', ev_hash )

	def op_ev_note(self, m):
		self.discord.cmd_guild_event(m.guild_id or 1, ' :: '.join( str(v)
			for v in ['Notification', m.get('type'), m.get('body') or m.get('message')] if v ))

	def op_ev_emojis(self, m):
		if not (gg := self.st_da.guilds.get(m.get('guild_id'))): return
		gg.emojis = self.op_ev_guilds_emojis(m.get('emojis') or list())

	def op_msg(self, m, act, cc=None, tid_prefix=''):
		nick, ts = m.get('author'), time.time()
		msg_ev, msg_ts = None, self.discord.flake_parse(m.id)
		if msg_is_update := act == 'update':
			# Sometimes discord sends completely empty msg-updates
			if not set(m).difference(['id', 'flags', 'channel_id', 'guild_id']): return
			if (td := self.conf._discord_msg_old_upd_ignore) and ts - msg_ts > td:
				return self.log.debug( 'Ignoring too-old msg'
					' update [{}]: id={}', ts_iso8601(msg_ts, short=True), m.id )
		gg = self.st_da.guilds.get(m.get('guild_id', 1))
		if not cc:
			if gg: cc = gg.chans.get(m.channel_id)
			if not cc: return self.log.warning( 'Dropped unknown guild/channel msg event:'
				' msg_id={} guild_id={} channel_id={}', m.id, m.get('guild_id'), m.channel_id )

		if cc.tid and self.conf.discord_thread_msgs_in_parent_chan:
			prefix = not self.conf.discord_thread_msgs_in_parent_chan_full_prefix
			prefix = cc.tid if prefix else self.discord.irc_chan_name(cc)
			self.op_msg(m, act, cc=cc.parent, tid_prefix=f'{tid_prefix}{prefix} :: ')
		if act == 'delete':
			ext = f'[{ts_iso8601(msg_ts, human=True)}]'
			if ref := self.op_msg_ref_get(m.id, cc): ext = f':: {ext} <{ref.nick}> {ref.line}'
			return self.discord.cmd_msg_recv(cc, None, f'message was deleted {ext}', dict(_ev=True))
		if not m.get('content') and m.get('call'):
			msg_ev, m.content = 'vc', 'voice-chat: incoming audio/video call notification'

		ref, line_cache, media_info, media_tags = None, None, list(), dict()
		if not nick:
			# No-author update msgs are embed-annotations or status updates for earlier ones
			# E.g. YT video or twitter link info follow-up after posted link, call status update
			nick = self.st_da.embed_info.get(m.id)
			media_info = ( msg_is_update and
				self.conf.discord_embed_info and self.op_msg_media_info(m) or list() )
			if media_info: act = 'media' # will be processed/added to msg below
			elif not nick: # some bots-changes msgs have no author - try ref-cache
				if ref := self.op_msg_ref_get(m.id, cc): nick, line_cache = ref.nick, ref.line
				elif msg_is_update and ts - msg_ts > self.conf._discord_media_info_timeout:
					return self.log.debug( 'Ignoring no-author update (media/embeds)'
						' of an old msg [{}]: id={}', ts_iso8601(msg_ts, short=True), m.id )
				else: return self.log.warning('Unhandled no-author msg [{}]: {}', act, self._repr(m))
		else: self.discord.cmd_user_cache(gg.id, nick.id, nick := self.discord.user_name(m))
		if snapshots := m.get('message_reference') and m.get('message_snapshots'):
			# Msg-snapshots here appear as embedded messages, so put into media_info
			ext = self.op_msg_parse_snapshots(snapshots, gg)
			for msg in ext.warn: self.log.warning('{}: {}', msg, self._repr(m))
			media_info.extend(ext.lines); media_info.extend(ext.media); media_tags.update(ext.tags)

		# "line" here can be multiline, and have attachments/stickers/buttons and such
		# Link/media info ("embeds") is parsed/cached/added to it separately, if enabled
		line, tags = self.op_msg_parse(m, gg, is_update=msg_is_update, can_be_empty=media_info)
		tags.update(media_tags)

		media_expected = tags.pop('_embed-info', False)
		if msg_ev: tags.update(_ev=msg_ev)
		if line_cache and msg_is_update: line = f'{line_cache}\n{line}'
		if not (line or media_info or media_expected): return # joins/parts/pins events and such
		if line: self.op_msg_ref(m.id, cc, nick, line, ref=ref)
		if msg_is_update:
			tags['_prefix'] = tags.get('_prefix', '') + self.conf.irc_prefix_edit
			if (td := self.conf._discord_msg_old_upd_timestamp) and ts - msg_ts > td:
				tags['_prefix'] += f'[old-msg {ts_iso8601(msg_ts, human=True, short=True)}] '
		elif reply := self.op_msg_parse_reply(m, gg):
			reply_line, reply_tags = reply
			line = f'{reply_line}\n{line.strip()}'; tags.update(reply_tags)

		if self.conf.discord_embed_info:
			if act != 'media' and re.search(
					r'\b(https?://|(www\.|m\.)?youtube\.|youtu\.be)\b', line ): # URL or YT-domain
				# Some embeds can come in both original msg and edits, deduped via embed_info
				while len(self.st_da.embed_info) >= self.conf.discord_embed_info_buffer:
					del self.st_da.embed_info[next(iter(self.st_da.embed_info.keys()))]
				self.st_da.embed_info[m.id], em_suffs = nick, (el.strip() for el in line.split('\n'))
				self.st_da.embed_info[f'lines.{m.id}'] = list(filter(None, em_suffs))
			if media_info := media_info or self.op_msg_media_info(m, force_meta=media_expected):
				tag = self.conf.irc_prefix_embed.format(str_hash(m.id, 3))
				if tag not in (pre := tags.get('_prefix', '')): tags['_prefix'] = pre + tag
				if em_suffs := self.st_da.embed_info.get(f'lines.{m.id}'):
					for el, suff in it.product(list(media_info), em_suffs):
						if el.endswith(suff) and el in media_info: media_info.remove(el)
				line = '\n'.join([line, *(str_cut( line,
					self.conf.discord_embed_info_len ) for line in media_info)]).strip()
			if not line: return self.log.warning( 'Expected msg media'
				' info parsed to empty line [ {} ]: {}', m.id, self._repr(m) )
		if not (nick and line): return

		if tid_prefix: tags['_prefix'] = tags.get('_prefix', '') + tid_prefix
		skip_mirrored_msg_in_monitor = (
			tid_prefix and not self.conf.discord_thread_msgs_in_parent_chan_monitor )
		self.discord.cmd_msg_recv( cc, nick, line.strip(), tags,
			new_msg_flake=not msg_is_update and m.id,
			nonce=m.get('nonce'), skip_monitor=skip_mirrored_msg_in_monitor )

	def op_msg_media_info(self, m, force_meta=False):
		'Parse and return "embeds" objects as text lines to prefix and append to msg'
		lines, meta_skip, embeds = list(), dict(), m.get('embeds')
		if not embeds: return
		for n, em in enumerate(embeds):
			emt, title, desc = ((em.get(k) or '') for k in ['type', 'title', 'description'])
			meta_add, em_src = dict(), em.get('author', dict()).get('name') or ''
			if emt == 'rich': # there are many types of these
				if em.get('footer', dict()).get('text') == 'Twitter':
					media = dict()
					for mt in 'image', 'video':
						url = em.get(mt)
						if url: url = url.get('url') or url.get('proxy_url')
						if url: media[mt] = url
					if em_src:
						if media and re.search(r'^\s*https://t\.co/\S+\s*$', desc):
							for mt, url in media.items():
								lines.append(f'Twitter {mt} :: {em_src} :: {url}')
							media.clear()
						else: lines.append(f'Twitter msg :: {em_src} :: {desc}')
					elif title: lines.append(f'Twitter acc :: {title} [ {desc} ]')
					for mt, url in media.items():
						meta_skip[mt] = None; lines.append(f' {mt} :: {url}')
				elif ( (url := em.get(meta_skip.get('image', 'image')))
						and (url := url.get('url') or url.get('proxy_url')) ):
					lines.append(f'Image :: {url}'); meta_add['title'] = 'title'
				elif (url := em.get('url') or '') and (m := re.match(
						r'https?://(' r'github\.com|gitlab\.com|bitbucket\.org'
						r'|codeberg\.org|git\.kernel\.org|git\.sr\.ht' r')/', url )):
					commit_desc = desc and re.fullmatch(
						r'\s*\[[^\]]+\]\([^) ]+\)\s+(?P<msg>\S.*)', desc ) or ''
					if commit_desc: commit_desc = ' ' + commit_desc['msg'].strip()
					lines.append(f'SCM {m[1]} :: {em_src or "-"} :: {title}' + commit_desc)
				elif not {'video', 'thumbnail', 'provider'}.difference(em.keys()): emt = 'video'
				else: lines.append(' :: '.join(filter(None, [em_src, title, desc])))
			if emt in ['video', 'gifv']:
				host = (em.get('provider') or dict()).get('name')
				if host == 'YouTube' and not em_src: info = ['-clip-', title, desc]
				else: info = [em_src, title or desc]
				if not any(info) or emt == 'gifv':
					info.append((em.get('video') or dict()).get('url') or em.get('url'))
				if info := ' :: '.join(filter(None, info)):
					host = '' if not host else f' ({host})'
					lines.append(f'{emt.title()}{host} :: {info}')
			elif emt == 'image' and (url := em.get('url')):
				if ( re.match(r'https?://(.*\.)?discord(app)?\.com/emojis/', url)
						and (emoji := dict(up.parse_qsl(up.urlparse(url).query)).get('name')) ):
					if self.conf.discord_terminal_links and self.conf.discord_terminal_links_emojis:
						emoji = self.conf.discord_terminal_links_tpl.format(url=url, name=emoji)
					else: emoji = f'{emoji} :: {url}'
					lines.append(f'Emoji :: {emoji}')
				else: lines.append(f'Image :: {url}')
			elif emt in ['article', 'link']: lines.append(
				f'{emt.title()} :: ' + (' :: '.join(filter(None, [ title,
					title != desc and desc ])) or em.get('url') or '-no-useful-info-') )
			elif emt == 'poll_result':
				pre = self.conf.irc_prefix_poll.format(str_hash(
					m.get('message_reference', adict()).message_id or m.id, 3 ))
				info = adict((ef.name, ef.value) for ef in em.get('fields') or list())
				vs, vs_total = map(int, [info.victor_answer_votes, info.total_votes])
				vs_perc = f' = {round(100 * vs / vs_total):.0f}%' if vs_total else ''
				lines.append(f'{pre}Question: {info.poll_question_text} [ result ]')
				lines.append( f'{pre}Top ' +
					(f'A#{aid}' if (aid := str(info.get('victor_answer_id', ''))) else 'A')
					+ f' [ {vs:,d} / {vs_total:,d}{vs_perc} votes ]: ' + ((
						((em := info.get('victor_answer_emoji_name', '')) and f':{em}: ')
						+ info.get('victor_answer_text', '') ).strip() or '???') )
			if meta_add or force_meta:
				if not meta_add: # some unrecognized embed-type, added on force_meta
					info = ' '.join(k for k in ['image', 'thumbnail', 'video', 'provider'] if em.get(k))
					if info: lines.append(f'Media :: [{info}]')
				meta_add.update(meta_skip)
				if info := em.get(meta_add.get('title')): lines.append(f'Title :: {info}')
				if info := em.get(meta_add.get('author')):
					info = ' '.join(info.get(k, '') for k in ['name', 'url']).strip()
					if info: lines.append(f'Author :: {info}')
				if info := em.get(meta_add.get('description')):
					info = filter(None, (dl.strip() for dl in info.splitlines()))
					for line in info: lines.append(f'Desc :: {line}')
		if not lines: return
		return list(re.sub(r'\s*\n+\s*', ' // ', line) for line in lines)

	def op_msg_parse_snapshots(self, snapshots, gg):
		res = adict(lines=list(), tags=dict(), media=list(), warn=list())
		for n, msg_ref in enumerate(snapshots):
			msg = msg_ref.pop('message', None)
			if msg_ref: res.warn.append(
				f'Extra keys in msg-ref #{n} [ {" ".join(msg_ref.keys())} ]' )
			if not msg: continue
			msg_line, msg_tags = self.op_msg_parse(msg, gg, can_be_empty=True)
			res.tags.update(msg_tags)
			if msg_line: res.lines.extend(msg_line.split('\n'))
			if lines := self.op_msg_media_info(msg): res.media.extend(lines)
		return res

	def op_msg_parse(self, m, gg, is_update=None, can_be_empty=False):
		# Must produce non-empty message for any relevant msg type
		# Most message types are protocol notifications that have their "content" discarded
		# "embeds" (info for links/media/etc) are handled separately, if enabled in config
		# Also used for parsing history query responses, not just live events
		tags, mt, mtc = dict(), m.get('type', ''), self.c_msg_type
		line, author = (m.get('content') or '').strip(), m.get('author', dict())
		line = line.replace('\u200b', '') # unicode zero-width-space junk

		if not mt:
			if author.get('bot'): # bots adding/removing media-info annotations
				if m.get('embeds'): mt, tags['_embed-info'] = None, True
				elif is_update and not line: mt = None
			# Attachments added/removed on earlier msg w/o type/author info
			# Sometimes identical updates like this get spammed every hour, not sure why
			if is_update and not set(k for k, v in m.items() if v).difference([
					'id', 'guild_id', 'channel_id', 'attachments', 'embeds', 'components', 'flags' ]):
				mt, ts = None, self.discord.flake_parse(m.get('id'))
				if ts < time.time() - self.conf._discord_media_info_timeout: return '', dict()

		if mt is not None:
			try: mt = mtc(int(mt))
			except ValueError:
				if not mt and is_update and can_be_empty: mt = mtc.media
				else: # new unknown msg type - check how to handle
					self.log.warning('Unhandled msg type [{!r}]: {}', mt, self._repr(m))
					if line: tags['_prefix'] = f'[msg-type={mt!r}] '
			if mt is mtc.application_command:
				pre = self.conf.irc_prefix_interact
				if cmd := m.get('interaction'):
					cmd_nick, cmd = self.discord.user_name(cmd), cmd.get('name') or '???'
					cmd, line = line, f'{pre}<{cmd_nick}> used /{cmd}'
				else: cmd, line = line, '{pre}<some-interaction-command>'
				if cmd: line += f' :: {cmd}'.rstrip()
			elif mt in [mtc.stage_start, mtc.stage_end, mtc.stage_speaker, mtc.stage_topic]:
				tags['_ev'], line = 'vc', f'{mt.name.replace("_", "-")}: {line!r}'
				if ts := m.get('timestamp'): line += f' [ {convert_iso8601_str(ts)} ]'
			elif mt not in [mtc.default, mtc.reply, mtc.media, mtc.channel_name_change]:
				if mt is mtc.thread_created: line = '' # handled separately elsewhere
				if line: self.log.warning('Unexpected msg text [{!r}]: {}', mt, self._repr(m))
				if m.get('embeds'): tags['_embed-info'] = True
		if line: tags.update(self.op_msg_parse_tags(line, m, gg.chans, gg.get('roles')))

		for att in m.get('attachments') or list():
			if not (url := att.get('url')):
				self.log.warning('Unhandled msg attachment type: {}', self._repr(att))
				continue
			if ( self.conf.discord_terminal_links and
					(ma := self.conf._discord_terminal_links_re.search(url)) ):
				name = ma['name']
				if mh := ma.groupdict().get('hash'): name += f' {str_hash(mh, 4)}'
				url = self.conf.discord_terminal_links_tpl.format(url=url, name=name)
			line += f'\n{self.conf.irc_prefix_attachment}{url}'

		for stk in m.get('stickers') or m.get('sticker_items') or list():
			name, desc = stk.get('name'), stk.get('description')
			line += f'\n{self.conf.irc_prefix_sticker}{name}'
			if desc: line += f' ({desc})'

		if uis := m.get('components'):
			def _tags(uis, types=dict(enumerate(['row', 'btn', 'menu'], 1))):
				line = list()
				for ui in uis:
					tag = types.get(ui.type, f'ui{ui.type}')
					if ext := ui.get('label'): tag += f' {repr(ext)}'
					if ext := ui.get('placeholder'): tag += f' {repr(ext)}'
					if ext := ui.get('emoji'): tag += f' :{ext.name}:'
					if ext := ui.get('url'): tag += f' url={ext}'
					if ext := ui.get('options'):
						for opt in ext: tag += ' ({})'.format(
							opt.get('label', '').replace(' ', '_') or
							f":{opt.get('emoji', dict()).get('name', 'x')}:" )
					line.append( f'<{tag}/>' if not
						(ext := ui.get('components')) else f'<{tag}>' + _tags(ext) + f'</{tag}>' )
				return ' '.join(line)
			line += f'\n{self.conf.irc_prefix_uis}' + _tags(m.components)

		if poll := m.get('poll'):
			pre = self.conf.irc_prefix_poll.format(str_hash(m.id, 3))
			ext, closed, line = list(), False, line and f'{line}\n'
			if qa := poll.get('question'):
				line += f'{pre}Question: ' + str(qa.get('text') or qa).strip()
			if poll.get('allow_multiselect'): ext.append('multi-select')
			if res := poll.get('results'):
				if closed := res.get('is_finalized'): ext.append('closed')
				for qa in res.get('answer_counts') or list(): res[str(qa.get('id'))] = qa
			if not closed and (ts := poll.get('expiry')):
				ext.append(f'until {convert_iso8601_str(ts)}')
			if ext: line = (line or f'{pre}-no-question-') + f' [ {", ".join(ext)} ]'
			for qa in poll.get('answers') or list():
				if aid := str(qa.pop('answer_id', '')):
					if res and (ext := res.get(aid)):
						aid += f' = {ext.get("count") or 0:,d}'
						if ext.get('me_voted'): aid += '++'
					ext = f'A#{aid}: '
				else: ext = 'A: '
				if ans := qa.pop('poll_media', None):
					if e := (ans.pop('emoji', None) or dict()).get('name'): ext += f':{e}: '
					ext += str(ans.get('text', ans)).strip()
				else: ext += str(qa or '')
				line += f'\n{pre}{ext.rstrip()}'

		if m.get('pinned'):
			tags['_prefix'] = tags.get('_prefix', '') + self.conf.irc_prefix_pinned

		if mt is not None and not line: # represent some non-text/embed events
			if mt == mtc.recipient_add: line = '[+recipient]'
			elif mt == mtc.recipient_remove: line = '[-recipient]'
			elif mt == mtc.channel_name_change:
				if name := m.get('content'): line = f'[channel renamed to] {name}'
				else: line = '[channel renamed]'
			elif mt == mtc.context_menu_command: pass # "Loading..." placeholder
			elif mt == mtc.default:
				if m.get('activity') and m.get('timestamp'):
					pass # discord embedded-app activities don't display channel msgs anyway
				elif not can_be_empty: self.log.warning( 'Discarded basic'
					' text-msg without contents (processing bug?): {}', self._repr(m) )
		return line.strip(), tags

	def op_msg_parse_tags(self, line, m, chans, roles):
		'Returns string-replacement pairs for discord tags detected within message'
		# https://discord.com/developers/docs/reference#message-formatting
		tags, tags_raw = dict(), list(re.finditer(r'<(@!?|#|a?:[^:]+:|@&|t:)(\d+)(:\w)?>', line))
		m_chans = dict((c.id, f'#{c.name}') for c in (m.get('mention_channels') or list()))
		if tags_raw:
			mt, ts_fmts = self.c_msg_tags, self.c_msg_tags_ts_fmts
			users = dict((mn.id, self.discord.user_name(mn)) for mn in force_list(m.get('mentions')))
			for m in tags_raw:
				k_src, t, k = m.group(0), m.group(1), m.group(2)
				if t in ['@', '@!']: v = mt.user, users.get(k, f'[{k}]')
				elif t == '@&':
					v = mt.role, ( f'[{roles[k].name}]'
						if roles and k in roles else '[role:{}]'.format(str_hash(k, 3)) )
				elif t == '#': v = mt.chan, chans[k].did if k in chans else (m_chans.get(k) or f'#[{k}]')
				elif t.lstrip('a').startswith(':'): v = mt.emo, t.lstrip('a')
				elif t == 't:':
					ts, ts_fmt = float(k), ts_fmts.get(m.group(3)) or ts_fmts['f']
					v = mt.ts, '[' + str( ts_fmt(ts) if callable(ts_fmt)
						else dt.datetime.fromtimestamp(ts).strftime(ts_fmt) ) + ']'
				else: v = mt.other, f'{t}{k}'
				tags[k_src] = v
		return tags

	def op_msg_parse_reply(self, m, gg):
		if not ( self.conf.irc_inline_reply_quote_len > 0
			and (ref := m.get('referenced_message'))
			and (ua := ref.get('author')) and ( ref_nick :=
				self.discord.bridge.irc_name(self.discord.user_name(ua, m.get('mentions'))) ) ): return
		if ( self.conf.irc_ref_allow_disabling_pings
				and (ref_nick in self.discord.bridge.irc_conn_names())
				and not any(u.username == ua.username for u in m.get('mentions') or list()) ):
			ref_nick = f'{ref_nick[:-1]}.no-@'
		ref_line, ref_tags = self.op_msg_parse(ref, gg, can_be_empty=True)
		if not ref_line and ( snapshots :=
				ref.get('message_reference') and ref.get('message_snapshots') ):
			ext = self.op_msg_parse_snapshots(snapshots, gg)
			for msg in ext.warn: self.log.warning('{}: {}', msg, self._repr(m))
			ref_line, ref_tags = '\n'.join(ext.lines), ext.tags
		if ref_line := str_cut(ref_line, self.conf.irc_inline_reply_quote_len):
			return ( f'-- re:<{ref_nick}> {ref_line}',
				dict((k,v) for k,v in ref_tags.items() if not k.startswith('_')) )

	def op_msg_ref(self, msg_id, cc, nick, line, ref=None):
		'Cache message to reference later in reacts and such'
		# If empty line is passed, an earlier cached line for this msg_id
		#  is used, if any (or if "ref" is passed), to avoid follow-up
		#  no-content embed-info updates replacing meaningful cached line
		if not self.conf.discord_msg_interact_cache:
			if self.st_da.icache: self.st_da.icache.clear()
			return
		elif not (c := self.st_da.icache):
			self.st_da.icache.update(hot=dict(), shared=dict(), gid=dict(), chan=dict())
		if not line and ref: line = ref.line
		caches, ts = list(), time.monotonic()
		for ck, cx, cxn in (
				(None, c.shared, self.conf.discord_msg_interact_cache_shared),
				(cc.gg.id, c.gid, self.conf.discord_msg_interact_cache_per_discord),
				(cc.id, c.chan, self.conf.discord_msg_interact_cache_per_chan) ):
			if cxn <= 0 and cx: cx.clear()
			elif cxn > 0:
				if ck: cxx, cx = cx, cx.setdefault(ck, dict())
				if cx:
					if e := cx.pop(msg_id, None):
						if not line: nick, line = pickle.loads(e[1])
					else:
						for kn, k in list(zip(range(len(cx) - cxn + 1), cx)): del cx[k]
						if ck and not cx: cxx.pop(ck)
				self.op_msg_ref_ts_cleanup(cx, ts)
				caches.append(cx)
		if caches:
			if not (n := self.conf.irc_ref_quote_len): return
			# Remember to update _cmd_chan_sys_cache on changing cache tuples
			e = ts, pickle.dumps((nick, str_cut(line, n)))
			for cx in caches: cx[msg_id] = e
			if msg_id in c.hot: c.hot[msg_id] = e

	def op_msg_ref_get(self, msg_id, cc):
		'Return None or adict(nick, line) reference for msg-id/chan'
		if not (c := self.st_da.icache): return
		for ck, cx in ( (False, c.hot),
				(None, c.shared), (cc.gg.id, c.gid), (cc.id, c.chan) ):
			if ck: cx = cx.get(ck, dict())
			if msg_id not in cx: continue
			ts, data = cx[msg_id]
			if ck is False: # refresh entry in hot-cache
				cx.pop(msg_id, None)
				cx[msg_id] = (ts := time.monotonic()), data
				self.op_msg_ref_ts_cleanup(cx, ts)
			elif (n := self.conf.discord_msg_interact_cache_hot) > 0: # hot-cache++
				for kn, k in list(zip(range(len(c.hot) - n + 1), c.hot)): del c.hot[k]
				c.hot[msg_id] = ts, data
			nick, ref = pickle.loads(data)
			return adict(nick=nick, line=ref)

	def op_msg_ref_ts_cleanup(self, cx, ts=None):
		if ts is None: ts = time.monotonic()
		ks, ts_cutoff = list(), ts - self.conf._discord_msg_interact_cache_expire
		for k, (ts_k, e) in cx.items():
			if ts_k >= ts_cutoff: break
			ks.append(k)
		for k in ks: del cx[k]

	def op_react(self, m, act):
		if self.conf.irc_disable_reacts_msgs: return
		if not ( (gg := self.st_da.guilds.get(m.get('guild_id', 1)))
			and (cc := gg.chans.get(m.get('channel_id'))) ): return
		try: # emo_id seem to be only passed if it's not unicode emoji
			emo_id, emo = m.emoji.get('id'), m.emoji.get('name') or ''
			if emo_id and len(emo) != 1: emo = f'[{emo_id}]' if not emo else f':{emo}:'
		except KeyError: emo = ''
		if act == 'remove_all': act, emo = '', f'-all'
		elif emo:
			if act == 'add': act, emo = '', f'+{emo}'
			elif act == 'remove': act, emo = '', f'-{emo}'
			else: emo = f' {emo}'
		try: u = m.member.user
		except KeyError: nick = None # notice from irc_nick_sys
		else: self.discord.cmd_user_cache(gg.id, u.id, nick := self.discord.user_name(m))
		ext = f'[{ts_iso8601(self.discord.flake_parse(m.message_id), human=True)}]'
		if ref := self.op_msg_ref_get(m.message_id, cc): ext = f':: {ext} <{ref.nick}> {ref.line}'
		self.discord.cmd_msg_recv(cc, nick, f'reacts: {act}{emo} {ext}', dict(_ev=True))

	def op_gift_code(self, m):
		if not ( (gg := self.st_da.guilds.get(m.get('guild_id', 1)))
			and (cc := gg.chans.get(m.get('channel_id'))) ): return
		self.discord.cmd_msg_recv( cc, None,
			f'gift-code: uses={m.uses} sku={m.sku_id} code={m.code}', dict(_ev=True) )

	def op_voice_st_tbf_check(self, cc):
		if not (tbf := cc.get('vc_tbf')):
			tbf = cc.vc_tbf = token_bucket(self.conf.discord_voice_notify_rate_limit_tbf)
		return not next(tbf)

	def op_voice_st_chan(self, m):
		if not ( (gg := self.st_da.guilds.get(m.get('guild_id', 1)))
			and (cc := gg.chans.get(m.get('id'))) ): return
		# status here seem to always be null, indicating empty channel
		if ( bool(st := m.get('status')) == bool(cc.get('vc_active'))
			or not self.op_voice_st_tbf_check(cc) ): return
		cc.vc_active = bool(st)
		self.discord.cmd_msg_recv( cc, None,
			'voice-chat status: ' + str(st or 'empty'), dict(_ev='vc'), notice=True )

	def op_voice_st_user(self, m):
		if not (gg := self.st_da.guilds.get(m.get('guild_id', 1))): return

		ev, vcs, user_id = set(), gg.get('vcs', dict()), gg.get('user_id')
		if not (cc := gg.chans.get(m.get('channel_id'))): # "user left" event
			if cc := gg.chans.get(vcs.pop(user_id, None)): ev.add('user-left')
			else: return # can't tell where user was if wasn't cached
		elif not vcs: vcs = gg.vcs = \
			TimedCacheDict(self.conf._discord_voice_join_left_cache_expire)
		cc_old, cc_old_id = None, vcs.get(user_id)
		if cc_old_id and cc_old_id != cc.id: cc_old = gg.chans.get(cc_old_id)
		if cc_old_id: ev.add('user-joined')
		vcs[user_id] = cc.id

		ts, td = time.monotonic(), self.conf._discord_voice_notify_after_inactivity
		if cc.get('vc_active') and td and cc.get('vc_ts', -td) >= ts - td: return
		if not self.op_voice_st_tbf_check(cc): return
		ev.update(k for k in 'mute deaf suppress'.split() if m.get(k))
		ev.update(k for k in 'mute deaf video stream'.split() if m.get(f'self_{k}'))
		if m.get('request_to_speak_timestamp'): ev.add('req-to-speak')
		if not ev: ev.add('voice')

		nick = self.discord.user_name(m, fallback='') or None
		cc.vc_active, cc.vc_ts, msg_base = True, ts, 'voice-chat activity: '
		if cc_old: self.discord.cmd_msg_recv(
			cc_old, nick, msg_base + 'user-left', dict(_ev='vc'), notice=True )
		self.discord.cmd_msg_recv( cc, nick,
			msg_base + ' '.join(sorted(ev)), dict(_ev='vc'), notice=True )

	def op_typing(self, m):
		if gg := self.st_da.guilds.get(m.get('guild_id', 1)): cc = gg.chans.get(m.channel_id)
		if not cc: return self.log.warning( 'Dropped typing event with'
			' unknown guild/channel id: guild_id={} channel_id={}', gg, m.channel_id )
		# These messages always have "user_id", but "member" only in non-private chats
		# ws_req_users_query() can be used to get non-cached uid, but seems unnecessary
		if nick := m.get('member'): nick = self.discord.user_name(nick)
		else: nick = self.discord.cmd_user_cache(gg.id, m.user_id)
		if m.user_id == self.st_da.user_id: return # echo of own typing msgs
		self.discord.cmd_typing(cc, nick or '???')



class RDIRCDError(Exception): pass

class RDIRCD:

	c_chan_sys_types = 'control', 'debug'
	c_bc_type = enum.Enum('c_bc_type', 'sys mon nc vc proxy')

	def __init__(self, conf):
		self.conf, self.log = conf, get_logger('rdircd.bridge')
		if ' ' in (self.conf.irc_nick_sys or ' '):
			raise RDIRCDError('Invalid irc-nick-sys value: {self.conf.irc_nick_sys!r}')
		self._repr = ft.partial(str_cut, max_len=self.conf.debug_msg_cut, repr_fmt=True)

	async def __aenter__(self):
		self.uptime_ts = time.monotonic()
		self.uptime_dt = dt.datetime.now().astimezone()
		self.server_ver = self.conf.version
		self.server_ts = dt.datetime.now(dt.timezone.utc)
		self.server_host = os.uname().nodename
		self.irc_conns, self.irc_conns_max = adict(), 0
		self.irc_auth_tbf = token_bucket(self.conf.irc_auth_tbf)
		self.irc_msg_queue = asyncio.Queue()
		self.irc_chans_sys = adict() # populated when building channel map
		self.tasks = StacklessContext(self.log)
		self.st_br = adict( chan_map=None, uid=dict(),
			# Xid_chan - lookup caches for monitor/leftover/discord irc chan-names for that id
			# None is used as an id for global monitor/leftover/voice channels
			gid_mon_chan=dict(), gid_nc_chan=dict(), gid_vc_chan=dict(), did_chan=dict(),
			d2i=irc_name_dict(), i2d=irc_name_dict() ) # for all irc <-> discord names
		self.uid_len, self.uid_seed = self.conf.irc_uid_len, self.conf._irc_uid_seed
		try: boot_id = str_hash(pl.Path('/proc/sys/kernel/random/boot_id').read_text().strip(), 3)
		except OSError: boot_id = '---' # can be inaccessible due to sandboxing
		self.uid_start = f'{str_hash(self.uid_seed, 3)}.{boot_id}.{str_hash(os.urandom(6), 6)}'
		self.cmd_delay(self.irc_msg_queue_proc)
		return self

	async def __aexit__(self, *err):
		if self.irc_msg_queue: self.irc_msg_queue.put_nowait(StopIteration)
		if self.tasks: await self.tasks.close()

	async def segfault_after_delay(self, delay):
		'Folks keep mentioning on IRC that daemon is not crashing, this should fix it'
		import ctypes
		await asyncio.sleep(delay)
		ctypes.string_at(0) # should reliably crash the process

	def uid( self, t, v, kh=None,
			hash_len=None, alias_key='{kh}', alias_default=None ):
		'''Return unique id (kh/key-hash) for specific object type-key and value.
			That will be either short hash or a user-assigned alias.
			kh can specify pre-defined hash value to only lookup alias for that.
			alias_key/default is to query other aliases associated with same kh.'''
		ck = t, v, alias_key
		if ck not in self.st_br.uid:
			if kh is None:
				if not (kh := self.st_br.uid.get(tv := f'{t}\0{v}')):
					kh = self.st_br.uid[tv] = str_hash(
						tv, hash_len or self.uid_len, self.uid_seed )
					if kh in self.st_br.uid: # raise irc uid-len if this happens
						raise ValueError( f'Unique-id hash collision [ {t}={v} ]: key={kh}'
							f' len/seed=[ {self.uid_len} {self.uid_seed} ] other={self.st_br.uid[kh]}' )
					self.st_br.uid[kh] = ck
			self.st_br.uid[ck] = self.conf.renames.get(
				(t, str_norm(alias_key.format(kh=kh))), alias_default or kh )
		return self.st_br.uid[ck]

	async def run(self):
		if self.conf.debug_mean_minutes_to_segfault > 0:
			mins_to_segfault = int( 1 + random.random()
				* self.conf.debug_mean_minutes_to_segfault * 2 )
			self.log.debug('Priming segfault timer ({:,d} min)...', mins_to_segfault)
			self.tasks.add(self.segfault_after_delay(mins_to_segfault * 60))

		ircd = await (loop := asyncio.get_running_loop()).create_server(
			IRCProtocol.factory_for_bridge(self),
			self.conf.irc_host, self.conf.irc_port, family=self.conf.irc_host_af,
			start_serving=False, reuse_port=True, ssl=self.conf.irc_tls )
		async with cl.AsyncExitStack() as ctx:
			self.log.debug('Initializing discord...')
			try: self.discord = await ctx.enter_async_context(Discord(self))
			except DiscordAbort as err:
				return self.log.error('Discord init failure - {}', err_fmt(err))
			if self.conf.discord_auto_connect:
				self.log.debug('Auto-connecting discord...')
				loop.call_soon(self.discord.connect)
			else: self.log.debug('Note: discord session auto-connect disabled')

			self.log.debug('Starting ircd...')
			await ctx.enter_async_context(ircd)
			await ircd.start_serving()
			try: await asyncio.Future() # run forever
			except asyncio.CancelledError:
				ircd.close()
				for conn in self.irc_conns.values(): conn.transport.close()
			self.log.debug('Finished')

	async def run_async(self):
		async with self: await self.run()


	def irc_conn_new(self, irc):
		self.irc_conns[id(irc)] = irc
		self.irc_conns_max = max(self.irc_conns_max, len(self.irc_conns))
		self.cmd_away_status('connect')
	def irc_conn_lost(self, irc):
		self.irc_conns.pop(id(irc), None)
		if not self.irc_conns: self.cmd_away_status('disconnect')
	def irc_conn_stats(self):
		stats = adict(
			servers=len(self.discord.st_da.get('guilds', dict())) or 1,
			chans=len(self.cmd_chan_map()),
			total=0, total_max=self.irc_conns_max, unknown=0, auth=0, op=0 )
		for conn in self.irc_conns.values():
			stats.total += 1
			if conn.st_irc.auth: stats.auth += 1
			else: stats.unknown += 1
		return stats
	def irc_conn_names(self):
		names = irc_name_dict()
		for conn in self.irc_conns.values():
			if conn.st_irc.auth: names.add(conn.st_irc.nick)
		return names

	def irc_name(self, name, casefold=False, _irc_remap=dict([
			*((n, f'°{n:02d}') for n in range(32)),
			*((ord(a), b) for a, b in zip(' ,:@!+<>', '·„¦∂¡¨◄►')) ])):
		'Return IRC name for a Discord name'
		# Must be deterministic but ideally not create collisions by stripping too much
		# General idea is to replace all irc-problematic chars by lookalike unicode
		if name not in self.st_br.d2i:
			name_irc, sub_chars = '', '°¨·„∂¦◄►'
			name_clean = re.sub(rf'[{sub_chars}]', '', name)
			if name_clean != name: name = name_clean + '¨' + self.uid('name', name)
			name_irc = name.translate(_irc_remap)
			self.st_br.d2i[name], self.st_br.i2d[name_irc] = name_irc, name
		name = self.st_br.d2i[name]
		if casefold: name = irc_casefold_rfc1459(name)
		return name

	def irc_name_revert(self, name_irc):
		'Return Discord name for an IRC name'
		# Relies on a i2d cache being set by earlier irc_name() call for this name
		# Which is done for every channel/user, notably in cmd_chan_map
		return self.st_br.i2d.get(name_irc)

	def irc_discord_info(self, name):
		'Return info{gg, cc} object for IRC name for discord channel or None'
		if not (c := self.cmd_chan_map().get(name)): return
		if not (cc := c.get('cc')): return # system and monitor channels
		return adict(cc=cc, gg=cc.gg)

	async def _irc_cmd_query_username(self, nick, gid):
		# Getting user-id from user_mentions cache isn't
		#  useful here - need to query nick on all guilds anyway
		info = await self.discord.session.ws_req_users_query(gid, nick, 5)
		guilds = cs.defaultdict(list)
		for gid in list(info):
			if not (users := info.pop(gid)): continue
			guild = dict(id=gid)
			if gg := self.discord.st_da.guilds.get(gid): guild['name'] = gg.name
			for m in users.values():
				m.pop('joined_at', None) # to merge otherwise-same records
				dh = data_hash(m)
				guilds[dh].append(guild)
				info[dh], name = m, self.discord.user_name(m)
				m.update( discord=guilds[dh],
					rdircd_names=dict(discord=name, irc=self.irc_name(name)) )
		return data_repr(info) if (info := list(info.values())) else '-- no results --'

	async def irc_cmd_topic(self, conn, name, line=''):
		chan = conn.chan_spec(name)
		notice_cmd = ft.partial(
			conn.cmd_msg_chan, self.conf.irc_nick_sys, chan, notice=True )
		cmd = line.split(None, 1)
		if not line.strip() or cmd[0] in ['h', 'help']:
			return notice_cmd(doc_topic_cmds.strip())
		try:
			if cmd[0] == 'set':
				raise IRCBridgeSignal('Changing topic is not implemented')
			elif cmd[0] == 'info' and len(cmd) > 1:
				if (nick := ' '.join(cmd[1:])).startswith('@'): nick = nick[1:]
				nick_irc = self.irc_name_revert(nick)
				if not (info := self.irc_discord_info(name)):
					raise IRCBridgeSignal(f'Not a discord channel: {chan}')
				notice_cmd( '--- List of user(s) matching:'
					f' {nick_irc or nick!r} in [ {info.gg.name} ]' )
				info = await self._irc_cmd_query_username(nick_irc or nick, info.gg.id)
				if not nick_irc:
					notice_cmd( f'-- NOTE: searching {nick!r} as-is,'
						' because it\'s not cached as an IRC nick here --' )
				notice_cmd(info)
				notice_cmd(f'--- end of list')
			elif cmd[0] == 'info':
				if not (info := self.irc_discord_info(name)):
					raise IRCBridgeSignal(f'Not a discord channel: {chan}')
				notice_cmd('--- Protocol information on this guild/channel:')
				notice_cmd('Guild:')
				notice_cmd(f'  id: {info.gg.id}')
				notice_cmd(f'  name: {info.gg.name}')
				notice_cmd(f'  joined-at: {ts_iso8601(info.gg.ts_joined)}')
				notice_cmd(f'  existing roles [ {len(info.gg.roles):,d} ]:')
				for role in info.gg.roles.values():
					notice_cmd(f'    id={role.id} name={role.name}')
				notice_cmd('Channel:')
				notice_cmd(f'  id: {info.cc.id}')
				notice_cmd(f'  name on discord: {info.cc.names.raw or ""}')
				notice_cmd(f'  name/alias without irc encoding: {info.cc.name}')
				notice_cmd(f'  name/alias encoded for irc: {self.irc_name(info.cc.name)}')
				notice_cmd(f'  topic: {info.cc.topic or ""}')
				notice_cmd(f'  type: {info.cc.ct.name} [{info.cc.ct.value}]')
				notice_cmd(f'--- end of info')
			elif cmd[0] == 'log':
				state = ('1' if len(cmd) == 1 else cmd[1]) if len(cmd) <= 2 else None
				state_list = sorted((v,k) for k,v in self.conf.state.items())
				if state == 'list':
					if not state_list: notice_cmd('No state timestamps recorded yet.')
					else:
						notice_cmd('Recorded state timestamps:')
						for n, (v, k) in enumerate(state_list):
							n = len(state_list) - n - 1
							notice_cmd(f'  [{n}] {k} = {ts_iso8601(v)}')
					return
				ts = None
				if state.isdigit():
					n = -1 - int(state)
					if not self.conf.state_get(self.uid_start): n += 1
					if not n: return
					try: ts, k = state_list[n]
					except IndexError: raise IRCBridgeSignal(f'No state with index {state}')
				elif state in self.conf.state:
					try: ts = self.conf.state[state]
					except KeyError: raise IRCBridgeSignal(f'No state {state!r}')
				else:
					try: ts = parse_iso8601(state)
					except ValueError:
						try: ts = parse_duration(state)
						except ValueError: pass
						else: ts = time.time() - ts
				if ts:
					if not (info := self.irc_discord_info(name)):
						raise IRCBridgeSignal(f'Not a discord channel: {chan}')
					notice_cmd(f'--- Replaying new messages since {ts_iso8601(ts)}')
					msg_list = await self.discord.cmd_history(info.gg, info.cc, ts)
					if not msg_list: return notice_cmd('--- no new messages')
					for m in msg_list:
						line = f'[{ts_iso8601(m.ts, human=True)}] {m.line}'
						self.cmd_msg_discord(info.cc, m.nick, line, tags=m.tags, conn=conn)
					return notice_cmd(f'--- end of replay [{len(msg_list)}]')
				raise IRCBridgeSignal(f'Invalid log-cmd parameters: {line}')
			else: raise IRCBridgeSignal(f'Unrecognized channel-topic cmd: {line}')
		except IRCBridgeSignal as err:
			notice_cmd(f'topic-cmd-error: {err}')
			raise

	async def irc_cmd_info(self, conn, t, id_str):
		info, t = None, {'#': 'channels', '@': 'users', '%': 'guilds', '*': 'snowflake'}[t]
		if t == 'snowflake':
			if ts := self.discord.flake_parse(id_str):
				ts_dt = ts_iso8601(ts, human=True, short=True)
				ts_rel = repr_duration(ts, time.time())
				info = f'Date/time: {ts_dt} [{ts_rel}]'
			else: info = 'Error: unrecognized snowflake format'
			id_str = f'{t}/{id_str}'
		elif t == 'users' and not id_str.isdigit():
			nick, nick_irc = id_str, self.irc_name_revert(id_str)
			gids = list(gid for gid in self.discord.st_da.guilds if gid != 1) # skip "me" non-guild
			info = await self._irc_cmd_query_username(id_str, gids)
			id_str = f' {nick_irc or nick!r} on {len(gids)} discord(s) '
			if not nick_irc:
				info = ( f'NOTE: specified IRC name [ {nick} ] was not cached,\n   '
					f' will be queried as-is, without reverse IRC-to-Discord translation.\n{info}' )
		else: info = await self.discord.cmd_info_dump(id_str := f'{t}/{id_str}')
		conn.cmd_msg_self(f'--- [{id_str}] info follows')
		conn.cmd_msg_self(info)
		conn.cmd_msg_self(f'--- [{id_str}] end')

	def irc_msg(self, conn, chan, line):
		'Called with a new msg, posted by IRC user in any channel'
		name = conn.chan_name(chan)
		if sys_type := self.irc_chans_sys.get(name):
			return self.cmd_chan_sys(sys_type, conn, chan, line)
		if not (line := self.irc_msg_translate_preq(line)).strip(): return
		if not self.irc_msg_queue:
			conn.cmd_msg_chan( self.conf.irc_nick_sys,
				chan, f'ERR {{irc-msg-queue-failed}}: {line}', notice=True )
		else: self.irc_msg_queue.put_nowait(
			adict(conn=conn, chan=chan, name=name, line=line) )

	def irc_msg_translate_preq(self, line):
		'IRC message processing right after receiving it and before anything else'
		# "/me msg" -> "\1ACTION msg\1" - https://tools.ietf.org/id/draft-oakley-irc-ctcp-01.html
		return re.sub(r'^\x01ACTION ?(.*)\x01$', r'_\1_', line)

	def irc_msg_translate_postq(self, info, line):
		'IRC message processing after send-queue, right before sending to discord'
		if repls := self.conf.send_repls: # apply [send-replacements]
			for s in it.chain( repls.get('*', list()),
					repls.get(self.uid('guild', info.gg.id, kh=info.gg.get('kh')), list()) ):
				if s.sub is not None:
					line, n = s.re.subn(s.sub, line)
					if n: s.tsb.event(intervals=self.conf.discord_match_counters)
				elif s.re.search(line):
					s.tsb.event(intervals=self.conf.discord_match_counters)
					raise IRCBridgeSignal(f'blocked-by-rule[{s.pre}.{s.comm}]')
		return line

	async def irc_msg_queue_proc(self):
		'''Process msgs from IRC in strict order from irc_msg_queue.
			If any of them fails to be confirmed-delivered,
				discards all msgs after it too, signaling errors about each.
			Idea is that it's better to make user re-send messages
				than to deliver only some of them, and/or doing it out of order.
			Special edit/delete message-commands are processed here as well.'''
		try:
			while True:
				m = await self.irc_msg_queue.get()
				if m is StopIteration: break

				try:
					if not (info := self.irc_discord_info(m.name)):
						raise IRCBridgeSignal('no-matching-chan')
					if edit := self.conf._discord_msg_edit_re.search(m.line):
						await self.discord.cmd_msg_edit_last(
							info.cc, edit.group('aaa'), edit.group('bbb') )
					elif self.conf._discord_msg_del_re.search(m.line):
						await self.discord.cmd_msg_del_last(info.cc)
					else:
						line = self.irc_msg_translate_postq(info, m.line)
						await self.discord.cmd_msg_send(info.cc, line)

				except IRCBridgeSignal as err:
					m.conn.cmd_msg_chan( self.conf.irc_nick_sys,
						m.chan, f'ERR {{{err}}}: {self._repr(m.line)}', notice=True )
					while True: # flush queue to ensure ordering
						try: m = self.irc_msg_queue.get_nowait()
						except asyncio.QueueEmpty: break
						if m is StopIteration: break
						m.conn.cmd_msg_chan( self.conf.irc_nick_sys,
							m.chan, f'ERR-flush {{{err}}}: {self._repr(m.line)}', notice=True )
					if m is StopIteration: break

		finally: self.irc_msg_queue = None

	def irc_typing(self, conn, chan, stop=False):
		'Typing event from an IRC client, to rate-limit and send to discord'
		if stop or not (info := self.irc_discord_info(name := conn.chan_name(chan))): return
		self.discord.cmd_typing_from_irc(info.cc)


	def cmd_delay(self, delay, func=None):
		'Runs asyncio task after an optional delay, waiting/monitoring it for errors'
		if func is None: delay, func = 0, delay
		if delay and not isinstance(delay, (int, float)):
			if delay == 'irc_auth': delay = next(self.irc_auth_tbf)
			else: raise ValueError(delay)
		if delay: return self.tasks.add(asyncio.sleep(delay), func)
		else: return self.tasks.add(aio_await_wrap(func))

	@iter_gather(irc_name_dict)
	def cmd_conn_map(self):
		for conn in self.irc_conns.values():
			if conn.st_irc.nick: yield (conn.st_irc.nick, conn)

	def cmd_conn(self, name=None):
		'Get IRC connection for sending a direct message'
		try:
			if not name: return next(iter(self.irc_conns.values()))
			return self.cmd_conn_map()[name]
		except (KeyError, StopIteration): return

	@iter_gather(list)
	def cmd_chan_conns(self, name_or_cc):
		'Get list of irc connections for users in a channel'
		if isinstance(name_or_cc, str): name = name_or_cc
		elif not (name := self.st_br.did_chan.get(name_or_cc.did)): return
		for conn in self.irc_conns.values():
			chan_name = conn.chan_name(name)
			if chan_name not in conn.st_irc.chans:
				if not self.conf._irc_chan_auto_join_re.search(chan_name): continue
				if not conn.st_irc.auth: continue
			yield conn

	def cmd_chan_names(self, name):
		'Returns list of irc-unique nicks for specified channel, for /names and such'
		if not (info := self.irc_discord_info(name)): return list()
		chan_nicks = irc_name_dict.value_map(
			conn.st_irc.nick for conn in self.cmd_chan_conns(name) if conn.st_irc.nick )
		for nick_discord, nick in (users := info.cc.get('users', dict())).items():
			if not nick: nick = users[nick_discord] = self.irc_name(nick_discord)
			chan_nicks.add(nick)
		return sorted(chan_nicks.values())

	def cmd_chan_map(self):
		'''Returns cached adict of {chan-name: {name, topic, did, ts_created}},
			updating misc minor .st_br.* maps used elsewhere in the process.'''
		if cm0 := self.st_br.chan_map: cm = cm0
		else:
			cache_drop = ft.partial(self.st_br.update, chan_map=None)
			cache_track = lambda d, keys=None: d._track(keys, cb=cache_drop)
			cm_list = list(self._cmd_chan_map_build(cache_track))
			cm = self.st_br.chan_map = irc_name_dict(cm_list)
			if len(cm) < len(cm_list):
				cm_repeats = ' '.join( k for k, n in
					cs.Counter(k for k, v in cm_list).items() if n > 1 )
				raise RDIRCDError( 'Duplicate channel-name(s) produced'
					f' by guild/channel naming (mis-)configuration: {cm_repeats}' )
		cm.ø_online = self.discord.st_eris.online
		if not cm0 and cm.ø_online: self.cmd_chan_map_sync(cm)
		return cm

	def cmd_chan_map_sync(self, cm=None):
		'Sends IRC topic updates, marks channels as working/removed'
		if not cm: cm = self.cmd_chan_map()
		for conn in self.irc_conns.values(): conn.cmd_chan_list_sync(cm)

	def _cmd_chan_map_build(self, cache_track):
		# Name-keys returned here are used in irc_name_dict, so don't need casemapping
		cache_track(self.discord.st_da.guilds)
		gg_uid = lambda gg,**kws: self.uid('guild', gg.id, kh=gg.get('kh'), **kws)
		gg_info = list(
			adict( gg=gg, prefix=gg_uid(gg),
				name_fmt=gg_uid(gg, alias_key='{kh}.chan-fmt', alias_default='{prefix}.{name}') )
			for gg in sorted(self.discord.st_da.guilds.values(), key=op.itemgetter('ts_joined')) )
		sys_chan_ts = self.server_ts.timestamp()

		def _topic_fmt(key, gi=None, cid='', tags='', topic=''):
			tpl = adict(guild_id='', guild_name='', chan_id=cid, chan_tags=tags, chan_topic=topic)
			if gi: tpl.update( guild_id=gi.gg.id,
				guild_name=gi.gg.name.strip(), guild_prefix=gi.prefix )
			return getattr(self.conf, f'irc_topic_{key}').format(**tpl).strip()

		# System debug/control channels
		self.irc_chans_sys.clear()
		for sys_type in self.c_chan_sys_types:
			name = self.conf.irc_chan_sys.format(type=sys_type)
			self.irc_chans_sys.update({name: sys_type, f'ø_{sys_type}': name})
			yield (name, adict( bt=self.c_bc_type.sys,
				name=name, ts_created=self.server_ts.timestamp(), topic=_topic_fmt(sys_type) ))

		# Order monitor/leftover channels at the top
		def _agg_chan(k, gi=None, _kc=dict(mon='monitor', nc='leftover', vc='voice')):
			kc = _kc[k]
			name = ( getattr(self.conf, f'irc_chan_{kc}') if not gi else
				getattr(self.conf, f'irc_chan_{kc}_guild').format(prefix=gi.prefix) )
			getattr(self.st_br, f'gid_{k}_chan')[gi and gi.gg.id] = name
			if not name: return
			topic = _topic_fmt(kc) if not gi else _topic_fmt(f'{kc}_guild', gi)
			yield (name, adict( name=name, topic=topic,
				bt=getattr(self.c_bc_type, k), ts_created=sys_chan_ts ))
		yield from _agg_chan('mon')
		yield from _agg_chan('nc')
		yield from _agg_chan('vc')
		for gi in gg_info:
			yield from _agg_chan('mon', gi)
			yield from _agg_chan('nc', gi)
			yield from _agg_chan('vc', gi)

		# Normal channels for each discord-guild
		for gi in gg_info:
			cache_track(gi.gg, 'name chans ts_joined')
			for cc in sorted( gi.gg.chans.values(),
					key=lambda cc: (cc.get('pos') or 999, cc.name) ):
				cache_track(cc, 'name topic pos nsfw')
				tags, name = '', gi.name_fmt.format(
					prefix=gi.prefix, name=self.irc_name(cc.name) )
				if cc.nsfw: tags += '[nsfw] '
				if cc.ct is cc.ct.voice: tags += '[voice] '
				elif cc.ct is cc.ct.stage: tags += '[stage] '
				elif cc.ct is cc.ct.forum: tags += '[forum] '
				elif cc.ct is cc.ct.media: tags += '[media] '
				topic = ' // '.join(filter(None, map(str.strip, (cc.topic or '').splitlines())))
				if not topic: topic = f'<no-topic> {cc.name}'
				topic = _topic_fmt('channel', gi, cid=cc.id, tags=tags, topic=topic)
				self.st_br.did_chan[cc.did] = name
				yield (name, adict(
					bt=self.c_bc_type.proxy, cc=cc, did=cc.did,
					name=name, topic=topic, ts_created=gi.gg.ts_joined ))


	def cmd_chan_sys(self, sys_type, conn, chan, line):
		if sys_type not in self.c_chan_sys_types: return
		getattr(self, f'cmd_chan_sys_{sys_type}')(conn, chan, line)

	def cmd_chan_sys_uptime(self):
		up_td = repr_duration(self.uptime_ts, time.monotonic())
		up_dt = self.uptime_dt.strftime('%Y-%m-%d %H:%M:%S %z')
		return f'since {up_dt} ({up_td})'

	def cmd_chan_sys_log_counts(self):
		counts = self.conf._debug_counts
		counts = (' '.join( '{}={:,d}'.format(k, counts[k]) for n, k in
			sorted(((n, k.lower()) for n, k in logging._levelToName.items()), reverse=True)
			if counts[k] > 0 ) + f' all={counts["all"]:,d}').strip()
		return counts

	def cmd_chan_sys_control(self, conn, chan, line_raw):
		if not (line := line_raw.strip().lower().split()): return
		cmd, send = line[0], ft.partial(conn.cmd_msg_chan, self.conf.irc_nick_sys, chan)
		if cmd in ['h', 'help']:
			return send(doc_fmt_lines(
				self._cmd_chan_sys_status(conn.st_irc.ts) + '\n'
				+ doc_control_chan_cmds
				+ ( '' if not self.conf.debug_dev_cmds
					else ('\n' + doc_control_chan_dev_cmds) )
				+ '\n\nOnly immediate response to sent'
					' commands is logged here - no noise over time.' ))
		elif cmd in ['status', 'st']: return send(self._cmd_chan_sys_status(conn.st_irc.ts))
		elif cmd in ['cache-stats', 'cs']: return send(self._cmd_chan_sys_cache())
		try:
			if cmd in ['connect', 'on']:
				send('discord: connection started')
				self.discord.connect()
			elif cmd in ['disconnect', 'off']:
				send('discord: disconnecting')
				self.discord.disconnect()
			elif cmd in ['unmonitor', 'um']: self._cmd_chan_sys_unmonitor(send, line_raw)
			elif cmd == 'rx': self._cmd_chan_sys_recv_filters(send, line_raw)
			elif cmd == 'repl': self._cmd_chan_sys_repl(send, line_raw)
			elif cmd == 'set': self._cmd_chan_sys_set(send, line, line_raw)
			elif cmd == 'reload':
				try: conf_paths = self.conf.read_from_file()
				except Exception as err:
					return send(f'ERROR: failed to reload config files - {err_fmt(err)}')
				send('Reloaded configuration from files (with overrides in that order):')
				for p in conf_paths: send(f'  {p}')
			else:
				if not self.conf.debug_dev_cmds: pass
				elif cmd == 'ccx':
					dst_file, chan = line[1:]
					chan = IRCProtocol.chan_name(chan)
					return self._cmd_chan_sys_ccx(send, dst_file, chan)
				elif cmd == 'cc':
					nick = msg = None
					if len(line) > 2: nick = line[2]
					if len(line) > 3: msg = line_raw.split(None, 3)[-1]
					return self._cmd_chan_sys_cc(send, line[1], nick, msg)
				send(f'Unknown command: {cmd}')
		except Exception as err:
			send(f'ERROR: BUG - "{cmd}" command failed: {err_fmt(err)}')
			raise

	def _cmd_chan_sys_status(self, conn_ts):
		'Implementation for status display in control-channel'
		st, ts = self.discord.st_da, time.monotonic()
		sess_st, sess_id = st.get('state', 'none'), st.get('session_id', '')
		irc_td, dsess_td, dconn_td = ( (v and repr_duration(ts, v, ext=None))
			for v in [conn_ts, *(st.get(k, '') for k in ('session_ts', 'connect_ts'))] )
		if sess_st != 'ready': conn_td = ''
		up_td = repr_duration(self.uptime_ts, time.monotonic())
		up_dt = self.uptime_dt.strftime('%Y-%m-%d %H:%M:%S %z')
		return '\n'.join([ 'Status:',
			f'  discord-state: {sess_st}',
			f'  discord-session-id: {sess_id}',
			f'  discord-session-time: {dsess_td}',
			f'  discord-connection-time: {dconn_td}',
			f'  discord-connection-gateway: {self.conf.discord_gateway}',
			f'  irc-connection-time: {irc_td}',
			f'  rdircd-version: {self.conf.version}',
			f'  rdircd-uptime: {self.cmd_chan_sys_uptime()}',
			f'  log-msg-counts: {self.cmd_chan_sys_log_counts()}' ])

	def _cmd_chan_sys_cache(self):
		'Implementation for cache-stats display in control-channel'
		def _sz(sz, _units=list(
				reversed(list((u, 2 ** (i * 10))
				for i, u in enumerate('BKMGT'))) )):
			for u, u1 in _units:
				if sz > u1: break
			sz = f'{sz / u1:.1f}'.removesuffix('.0')
			return f'{sz} {u}'
		med = lambda v, n: v / n if n > 0 else 0
		c, stats = self.discord.st_da.get('icache', dict()), list()
		for name, ck, cx, cxn in (
				('Shared', None, 'shared', 'shared'),
				('Interacted-with "hot"', None, 'hot', 'hot'),
				('Per-discord', 'discord', 'gid', 'per_discord'),
				('Per-channel', 'channel', 'chan', 'per_chan') ):
			cx = c.get(cx, dict())
			cxn = getattr(self.conf, f'discord_msg_interact_cache_{cxn}')
			if not ck:
				bs = sum((80 + sys.getsizeof(ct[1])) for ct in cx.values())
				bsa = med(bs, n := len(cx))
				s = [ f'{name} cache: {n:,d} / {cxn:,d} [{100*n/cxn:.0f}%]'
					f' entries, ~{_sz(bs)} total ({_sz(bsa)} mean msg size)' ]
			else:
				nk, n = len(cx), sum(len(cxx) for cxx in cx.values())
				bs = sum( (80 + sys.getsizeof(ct[1]))
					for cxx in cx.values() for ct in cxx.values() )
				na, bska, bsa = med(n, nk), med(bs, nk), med(bs, n)
				s = [
					f'{name} cache: {nk:,d} {ck}(s) with {n:,d} entries,'
						f' {na:,.0f} / {cxn:,d} [{100*(na)/cxn:.0f}%] mean usage,',
					f'  ~{_sz(bs)} total, {_sz(bska)} per {ck}, {_sz(bsa)} msg mean' ]
			stats.extend(f'  {s}' for s in s)
		return '\n'.join([ 'Cache statistics:', *stats,
			'Note: same msgs are shared between caches, so numbers can be inflated' ])

	def _cmd_chan_sys_set(self, send, line, line_raw):
		'Implementation for control-channel "set" command'
		sec_re = re.compile('^({})_(.*)$'.format(
			'|'.join(map(re.escape, self.conf._conf_sections)) ))
		if len(line) == 1:
			send('Current config options/values:')
			val_types = [str, bool, int, float]
			for k in sorted(dir(self.conf)):
				if not sec_re.search(k): continue
				k_ini, v = k.replace('_', '-'), self.conf.get(k, raw=True)
				for vt in val_types:
					if isinstance(v, vt): break
				else: continue
				v = ['no', 'yes'][v] if vt is bool else repr(v)
				if vt is str and k == 'auth' or 'password' in k: v = '<hidden>'
				send(f'  {k_ini} = {v} [{vt.__name__}]')
			send( 'Note: string values must be quoted when setting'
				' them (using python str literal rules), e.g.: set irc-prefix-edit \'[ed] \'' )
			return send( 'Note: not all option changes take effect'
				' without reconnect or full restart, save them to config for latter' )
		conf_save = line[1] in ['-s', '--save']
		line = line_raw.lstrip().split(None, 2 + conf_save)[1 + conf_save:]
		if not 1 <= len(line) <= 2:
			return send('ERROR: "set" command needs "option [value]" arguments')
		if line[-1].startswith('='): line[-1] = line[-1].lstrip('= ') # allows "set key = val"
		v_conf = None
		try:
			k_ini, v_raw = line if len(line) != 1 else (line[0], '""')
			k, v = k_ini.replace('-', '_'), v_raw
			v_conf, sec = self.conf.get(k, raw=True), sec_re.search(k)
			if not sec: raise KeyError(k)
			vt, (sec, k_sec) = type(v_conf), sec.groups()
			if isinstance(v_conf, str):
				v = ast.literal_eval(v)
				if not isinstance(v, str): raise ValueError(v)
			v = self.conf.opt_to_val_func(v_conf)(v)
			if vt is not type(v): raise TypeError(v)
		except Exception as err:
			vt = f' ({vt.__name__})' if v_conf is not None else ''
			return send(f'ERROR: failed to parse {k_ini} = [{v_raw}]{vt} - {err_fmt(err)}')
		self.conf.set(k, v)
		if conf_save:
			save = self.conf.update_file_section(sec, k_sec)
			save = f' [saved to a file: {save}]'
		else: save = ''
		send(f'Updated conf value: {k_ini} = {v!r}{save}')

	def _cmd_chan_sys_unmonitor(self, send, line):
		'Implementation for control-channel "unmonitor" command'
		self._cmd_chan_sys_filters( send, line, 'unmonitor',
			self.conf.unmon_filters, self.conf.make_unmon_pattern )

	def _cmd_chan_sys_recv_filters(self, send, line):
		'Implementation for control-channel "rx" command'
		self._cmd_chan_sys_filters(
			send, line, 'recv-regexp-filters',
			self.conf.recv_filters, self.conf.make_recv_filter )

	def _cmd_chan_sys_filters(self, send, line, sec, conf, conf_make):
		'Generic implementation for cmd to add/remove regexp filters'
		rm = save = False
		for n, opt in enumerate(line.split()[1:], 1):
			if opt in ['-r', '--rm']: rm = True
			elif opt in ['-s', '--save']: save = True
			else: break
		else:
			send(f'Current [{sec}] filters ({len(conf)} in total):')
			for k, mx in conf.items():
				send(f'  {k} = {mx.pat}')
				if tsb := mx.tsb.get_counts_str(): send(f'    [ rule hits: {tsb} ]')
			return
		line = line.split(None, n)[-1]
		if rm: key = line
		else:
			if '=' in (ls := line.split()):
				try:
					n = ls.index('=')
					key = line.rsplit(None, len(ls)-n)[0].strip()
					pat = line.split(None, n+1)[n+1].rstrip()
					if not pat: raise ValueError
				except (ValueError, IndexError):
					return send(f'ERROR: Invalid "[comment... =] pattern" spec: {line!r}')
			else: key, pat = '', line.strip()
			if not key or key == '=': key = f'pattern.{str_hash(pat, 6)}'
		if rm:
			if key not in conf: return send(f'ERROR: No such filter [ {key} ]')
			del conf[key]
		else: conf[key] = conf_make(pat)
		upd = f'{key} = {pat}' if not rm else f'removed [ {key} ]'
		if not save: return send(f'Updated runtime filters (not saved to ini): {upd}')
		try: p = self.conf.update_file_section(sec, {key: None if rm else pat})
		except OSError as err:
			return send(f'ERROR: Failed to update configuration file: {err_fmt(err)}')
		send(f'Updated [{sec}] in config file [ {p.name} ]: {upd}')

	def _cmd_chan_sys_repl(self, send, line_raw):
		'Implementation for control-channel "repl" command'
		repls, rm, save = self.conf.send_repls, False, False
		try:
			line = line_raw.split(None, 1)
			if len(line) == 1: line, opts = '', set()
			else: line, opts = line[1], set(line[1].split()[:2])
			if opts.intersection(['-r', '--rm']): rm, line = line.split(None, 1)
			if opts.intersection(['-s', '--save']): save, line = line.split(None, 1)
			if not line.strip(): key = line = None
			elif rm: key = line.strip()
			else:
				key, line = line.split('=', 1); key, line = key.strip(), line.lstrip()
				if ' -> ' not in line: line += ' -> <block!>'
		except Exception as err:
			return send(f'ERROR: Failed to process repl-line [ {line_raw} ]: {err_fmt(err)}')
		if not key:
			send('Current replacement/block regexps:')
			for pre_repls in repls.values():
				for repl in pre_repls:
					send(f'  {repl.key} = {repl.val}')
					if tsb := repl.tsb.get_counts_str(): send(f'    [ rule hits: {tsb} ]')
			return
		if not rm: repl = self.conf.make_send_repl(key, line)
		for pre_repls in repls.values():
			for repl_chk in list(pre_repls):
				if repl_chk.key == key: pre_repls.remove(repl := repl_chk)
		if not rm: repls[repl.pre].append(repl)
		elif not repl: return send(f'ERROR: No matching replacement [ {key} ]')
		if not save: return send(f'Updated runtime replacement rules [ {repl.key} ]')
		try:
			upd = {repl.key: repl.val if not rm else None}
			p = self.conf.update_file_section('send-replacements', upd)
		except OSError as err:
			return send(f'ERROR: Failed to update configuration file: {err_fmt(err)}')
		return send(f'Updated [send-replacements] section in config file [ {p.name} ]')

	def _cmd_chan_sys_ccx(self, send, dst_file, chan):
		cm = self.cmd_chan_map() # should be in caches too
		cc = ci = cm.get(chan)
		if ci: cc = ci.get('cc', None)
		dump = pyaml_dump(dict(chan_info=ci, chan=cc, caches=self.st_br))
		(p := pl.Path(path_filter(dst_file))).write_text(dump)
		chan_info = cc.name if cc else f'-no-channel-({chan!r})'
		send( f'Stored channel-sim data: file=[ {p} ]'
			f' cc=[ {chan_info} ] data-size=[ {len(dump):,d} B ]' )

	def _cmd_chan_sys_cc(self, send, src_file, nick=None, msg=None):
		dump = pyaml_load((p := pl.Path(src_file)).read_text())
		str_uid = lambda c=4: str_hash(os.urandom(c).hex(), c)
		def enum_rec(k,v):
			if isinstance(v, int):
				if k == 'ct': v = DiscordSession.c_chan_type(v)
				elif k == 'bt': v = self.c_bc_type(v)
			return v
		for k, upd in dump['caches'].items():
			if not isinstance(cache := self.st_br.get(k), cs.abc.Mapping): continue
			if all(type(v) is adict for v in cache.values()):
				upd.update((k, adict.rec_make(v, enum_rec)) for k,v in upd.items())
			cache.clear(); cache.update(upd)
		ci, cc = ( (d := dump[k]) and
			adict.rec_make(d, enum_rec) for k in ['chan_info', 'chan'] )
		if not cc: return send(f'Loaded sim-data: file=[ {p} ] (no channel info)')
		if not nick: nick = f'user-{str_uid(4)}'
		if not msg: msg = f'⚛️ Message Test ⚛️ [{str_uid(8)}]'
		self.cmd_msg_discord(cc, nick, msg)
		chan_name = self.st_br.did_chan.get(
			cc.did, f'<ERR {cc.gg.name!r} {cc.name!r}>' )
		return send(f'Chan-msg sim [ {p} ]: #{chan_name} :: <{nick}> {msg}')

	def cmd_chan_sys_debug(self, conn, chan, line):
		line_src, line = line, line.strip().lower().split()
		pt_n, pt_cut = (self.conf.get(f'debug_chan_proto_{k}') for k in ['tail', 'cut'])
		if not line: return
		send = ft.partial(conn.cmd_msg_chan, self.conf.irc_nick_sys, chan)
		if line[0] in ['h', 'help', 'st', 'status']:
			level = proto_log_info = '???'
			proto_log_shared = ['no', 'yes'][bool(log_proto_root.propagate)]
			if self.conf._debug_chan:
				level = logging.getLevelName(self.conf._debug_chan.level).lower()
			if self.conf._debug_proto:
				proto_log_info = logging.getLevelName(self.conf._debug_proto.level).lower()
				proto_log_info = ( 'disabled' if proto_log_info == 'warning'
					else f'enabled, file={self.conf._debug_proto.get_file()}' )
			return send(doc_fmt_lines( doc_debug_chan_cmds,
				ver=self.conf.version, uptime=self.cmd_chan_sys_uptime(),
				level=level, pt_n=pt_n, pt_cut=pt_cut,
				log_msg_counts=self.cmd_chan_sys_log_counts(),
				proto_log_info=proto_log_info, proto_log_shared=proto_log_shared ))
		with cl.suppress(KeyError):
			line = dict(
				i='level info', d='level debug', w='level warning',
				px='proto off', ps='proto share', pu='proto unshare', pt='proto tail'
			)[line[0]].split() + line[1:]
		cmd, arg = line[0], line[1] if len(line) >= 2 else None
		if cmd == 'level' and len(line) == 2:
			level = getattr(logging, arg.upper(), None)
			if level is not None and self.conf._debug_chan:
				arg_old = logging.getLevelName(self.conf._debug_chan.level)
				self.conf._debug_chan.setLevel(level)
				send(f'-- logging level: {arg_old.lower()} -> {arg.lower()}')
			else: send(f'-- failed to change logging level: level or logger unavailable')
		elif cmd == 'proto':
			if arg == 'off':
				if self.conf._debug_proto:
					self.conf._debug_proto.setLevel(logging.WARNING)
				else: arg = 'unavailable'
				send(f'-- protocol log: {arg}')
			elif arg in ['share', 'unshare']:
				share = arg == 'share'
				send(f'-- protocol log shared: {str(share).lower()}')
				log_proto_root.propagate = share
			elif arg == 'tail':
				if not self.conf._debug_proto: send(f'-- protocol log disabled, nothing to show')
				else:
					if len(line) > 2: pt_n = int(line[2])
					if len(line) > 3: pt_cut = int(line[3])
					lines = file_tail(self.conf._debug_proto.get_file(), pt_n)
					send(f'-- protocol log tail [{len(lines)}/{pt_n}:{pt_cut}]:')
					for line in lines: send(f'--- {str_cut(line, pt_cut)}')
					send(f'-- protocol log tail end')
			elif len(line) >= 2:
				path = line_src.strip().split(None, 1)[-1] # preserve spaces and case
				if self.conf._debug_proto:
					self.conf._debug_proto.set_file(path)
					self.conf._debug_proto.setLevel(logging.DEBUG)
					arg = f'file={path}'
				else: arg = 'unavailable'
				send(f'-- protocol log: {arg}')

	def cmd_log(self, line):
		if not (name := self.irc_chans_sys.get('ø_debug')): return # before chan_map init
		for conn in self.cmd_chan_conns(name):
			conn.cmd_msg_chan(self.conf.irc_nick_sys, name, line)


	def cmd_msg_recv_filter(self, nick, msg):
		if not (recv_rxs := self.conf.recv_filters): return
		m, recv_msg = False, f'<{nick}> {msg}' # can be multiline, unlike in #monitor
		for n, (k, mx) in enumerate(recv_rxs.items()):
			if op_and := re.search(r'^\s*∧+¬*(\s|$)', k[1:]):
				if n and not m: continue # skip &&-tails after mismatch
			elif m: break # standalone match found earlier
			op_not = bool(re.search(r'^[∧∨\s]*¬+(\s|$)', k[1:]))
			if m := bool(mx.rx.search(recv_msg)) ^ op_not:
				m = k, recv_msg
				mx.tsb.event(intervals=self.conf.discord_match_counters)
		if not m: return
		return self.log.debug( 'recv-regexp-filter msg-drop at'
			' [ {} ]: {}', m[0], str_cut(m[1], self.conf.debug_msg_cut) ) or True

	def cmd_msg_monitor( self, nick, msg,
			prefix='', notice=True, gid=None, leftover_skip=None, ev=None ):
		'Sends message to monitor/leftover IRC channel(s)'
		lines_n = len(lines := msg.splitlines())
		m, n = self.conf.irc_len_monitor, self.conf.irc_len_monitor_lines
		if len(lines) > n: lines = list(line for line in lines if line.strip())
		lines = list(f'{prefix}{str_cut(s, m, len_bytes=True)}' for s in lines[:n+1])
		if len(lines) > n:
			lines = lines[:n]
			lines[-1] += f' ... [{len(lines)}/{lines_n} lines]'
		nc_conns_skip = set(leftover_skip or list())
		for ct in 'mon', 'nc', 'vc':
			if ct == 'vc' and ev != 'vc': continue
			chans, cache = list(), getattr(self.st_br, f'gid_{ct}_chan')
			if gid and (name := cache.get(gid)): chans.append(name)
			if name := cache[None]: chans.append(name)
			for name in chans:
				for conn in self.cmd_chan_conns(name):
					if ct == 'nc':
						if conn in nc_conns_skip: continue
						nc_conns_skip.add(conn)
					for line in lines: conn.cmd_msg_chan(nick, name, line, notice=notice)

	def cmd_msg_discord( self, cc, nick=None, line=None, name_irc=None,
			tags=None, ts=None, conn=None, notice=None, skip_monitor=False ) -> bool:
		'''Sends annotated Discord message to all proxy/monitor IRC channels.
			conn= limits sending to proxy-chan of one irc conn, e.g. for history-replay.
			name_irc= is used to set destination irc proxy-channel to use instead of cc.
			Returns True if message can be ACKed to discord, if needed/enabled.'''
		# Must be synchronous wrt all discord data,
		#  as it can be called right before channel delete/rename.
		if ts: self.conf.state_set(self.uid_start, ts)
		prefix, ev, name = '', '', name_irc or self.st_br.did_chan.get(cc.did)
		if conn: skip_monitor, conns_joined = True, [conn] # e.g. log-replay for this irc client
		else:
			conns_joined = self.cmd_chan_conns(name) if name else list()
			if not conns_joined and not self.cmd_conn(): return # no irc clients

		if tags:
			mt, prefix = DiscordSession.c_msg_tags, tags.pop('_prefix', prefix)
			if ev := tags.pop('_ev', None): prefix += self.conf.irc_prefix_event
			for k, (tt, v) in tags.items():
				if tt == mt.user: v = f'@{self.irc_name(v)}'
				elif tt == mt.chan:
					v = IRCProtocol.chan_spec(self.st_br.did_chan.get(v, v))
				line = line.replace(k, v)
		if nick: nick_discord, nick = nick, self.irc_name(nick)
		else:
			nick_discord = nick = self.conf.irc_nick_sys
			if notice is None: notice = True
		if not notice:
			if self.conf.irc_prefix_all_private and cc.private:
				prefix = f'{self.conf.irc_prefix_all_private}{prefix}'
			if self.conf.irc_prefix_all: prefix = f'{self.conf.irc_prefix_all}{prefix}'

		if skip_chan := not name: # fallback pseudo-channel name to use with monitors
			self.log.warning( 'Failed to resolve channel did'
				' {!r} [cc={}] for line: [{}] {}', cc.did, cc, nick, tags, nick, line )
			name = f'ERR [ {cc.gg.name} ] {cc.name}'
		prefix_mon = f'{IRCProtocol.chan_spec(name)} :: {prefix}'
		if self.cmd_msg_recv_filter(nick, prefix_mon + line): return

		# Relay message to direct-proxy IRC channel
		if not skip_chan:
			joined, cc.users[nick_discord] = not cc.users.get(nick_discord), nick
			for conn in conns_joined:
				for n, line in enumerate(line.splitlines()):
					conn.cmd_msg_chan( nick, name,
						f'{prefix}{line}', notice=notice, joined=joined and not n )

		# Relay message to all relevant monitor/leftover channels
		if skip_monitor: return
		for mx in self.conf.unmon_filters.values():
			if not mx.rx.search(name): continue
			return mx.tsb.event(intervals=self.conf.discord_match_counters)
		if cc.tid:
			if ( self.conf.discord_thread_msgs_in_parent_chan
					and not self.conf.discord_thread_msgs_in_parent_chan_monitor ):
				# Skip thread-msg in leftover if it's been sent to joined parent channel
				conns_joined += self.cmd_chan_conns(cc.parent)
		self.cmd_msg_monitor( nick, line, prefix_mon,
			notice=notice, gid=cc.gg.id, leftover_skip=conns_joined, ev=ev )
		return not skip_chan # ack result for discord

	def cmd_typing(self, cc, nick):
		'Incoming typing event from some user in a discord channel'
		if not (name := self.st_br.did_chan.get(cc.did)): return
		for conn in self.cmd_chan_conns(name): conn.cmd_typing(self.irc_name(nick), name)

	def cmd_msg_rename_func(self, cc):
		'''Returns cmd_msg_discord func to send rename-notice to old channel.
			Runs before discord-id cache updates to new channel name to use old name_irc.'''
		return ft.partial(self.cmd_msg_discord, cc, name_irc=self.st_br.did_chan.get(cc.did))

	def cmd_away_status(self, irc_ev_name):
		if not self.conf.discord_status_set: return
		if not (st_name := self.conf._discord_status_events.get(irc_ev_name)): return
		self.discord.cmd_status(st_name)



class RDIRCDConfig(RDIRCDConfigBase):

	ws_dump_filter = None # hack to dump/inspect specific protocol msg
	# ws_dump_filter = adict(op=0, t='READY')
	ws_dump_file = 'dump.json'

	state_tracked = True
	_state_section = _state_offsets = _state_file = _state_file_ts = None
	_debug_chan = _debug_proto = _debug_counts = None

	def __init__(self):
		self.state, self.renames = dict(), dict()
		self.unmon_filters, self.recv_filters = dict(), dict()
		self.send_repls = cs.defaultdict(list)
		self.log = get_logger('rdircd.state')
		for k in dir(self):
			if k.startswith('_conv_'): k = k[6:]
			else: continue
			self.set(k, self.get(k))

	def __repr__(self): return repr(vars(self))
	def get(self, *k, raw=False):
		k = '_'.join(k).replace('-', '_')
		if not raw:
			with cl.suppress(AttributeError): return getattr(self, f'_{k}')
		return getattr(self, k)
	def set(self, k, v):
		k = k.replace('-', '_')
		k_conv, v_conv = f'_{k}', getattr(self, f'_conv_{k}', None)
		if v_conv: setattr(self, k_conv, v_conv(v))
		if k.endswith('_tbf'): # test token-bucket spec here to report format errors
			try: tbf = token_bucket(v); next(tbf); next(tbf)
			except: raise RDIRCDError(f'Invalid token-bucket rate-limit spec [ {k} ]: {v}')
		setattr(self, k, v)

	def val_to_opt(self, v):
		if isinstance(v, bool): v = ['no', 'yes'][v]
		if v and isinstance(v, str):
			v = v.replace('\x1b', r'\e')
			if v[0] in ' \t': v = f'\\{v}'
			if v[-1] in ' \t': v += '\\'
		return str(v)

	def opt_to_val_func(self, v, conf_get=lambda *a,**k: a[0]):
		if v is str or isinstance(v, str):
			def str_get(*a):
				v = str(conf_get(*a, raw=True))
				if len(v) >= 2 and v[-1] == '\\' and v[-2] in ' \t': v = v[:-1]
				if len(v) >= 2 and v[0] == '\\' and v[1] in ' \t': v = v[1:]
				return v.replace(r'\e', '\x1b') # common terminal-escape character
			return str_get
		elif v is bool or isinstance(v, bool):
			bool_map = {
				'1': True, 'yes': True, 'y': True, 'true': True, 'on': True,
				'0': False, 'no': False, 'n': False, 'false': False, 'off': False }
			def bool_get(*a):
				v = conf_get(*a)
				try: return bool_map[str(v).strip().lower()]
				except KeyError: raise ValueError(v)
			return bool_get
		elif v is int or isinstance(v, int): return lambda *a: int(re.sub(r'[ _]', '', conf_get(*a)))
		elif v is float or isinstance(v, float): return lambda *a: float(conf_get(*a))

	rx_pattern_t = cs.namedtuple('regexp', 'rx pat tsb')
	send_repl_t = cs.namedtuple('repl', 'key val pre re sub comm tsb')

	def make_unmon_pattern(self, pat):
		if ' ' in pat: raise ValueError(f'[unmonitor] pattern cannot have spaces in it: {pat!r}')
		if (rx := pat).startswith('re:'): rx = re.compile(rx[3:], re.I)
		elif rx.startswith('glob:'): rx = re.compile('^'+fnmatch.translate(rx[5:]), re.I)
		else:
			if rx.startswith('#'): rx = rx[1:]
			rx = re.compile(f'^{re.escape(rx)}$', re.I)
		return self.rx_pattern_t(rx, pat, TimeSeriesBuckets())

	def make_recv_filter(self, pat):
		return self.rx_pattern_t(re.compile(pat, re.DOTALL), pat, TimeSeriesBuckets())

	def make_send_repl(self, key, val):
		try:
			pre, comm = key.rsplit('.', 1)
			sub_re, sub = val.split(' -> ', 1)
			sub_re = re.compile(sub_re)
			if sub == '<block!>': sub = None
		except Exception as err:
			raise ValueError(
				'[send-replacements] pattern parsing error'
				f' [ {key!r} = {val!r} ]: {err_fmt(err)}' )
		return self.send_repl_t(key, val, pre, sub_re, sub, comm, TimeSeriesBuckets())

	def read(self, func, section, k, conf_k=None, section_old=None):
		if not conf_k: conf_k = f'{section}_{k}'.replace('-', '_')
		k_old = list(it.chain.from_iterable(
			(ko, ko.replace('_', '-')) for ko in self._conf_keys_old.get(conf_k) or list() ))
		for k in [k, k.replace('-', '_'), k.replace('_', '-')] + k_old:
			try:
				self.set(conf_k, func(section_old or section, k))
				if k in k_old: self._conf_old_found[k] = conf_k
				return True
			except configparser.NoSectionError: pass
			except configparser.NoOptionError: pass

	def pprint(self, title=None, empty_vals=False, comments=None, out=print):
		out = lambda line,_out=out: _out(line.rstrip(' ')) # for empty values
		cat, comms, chk = None, comments or dict(), re.compile(
			'^({})_(.*)$'.format('|'.join(map(re.escape, self._conf_sections))) )
		if title: out('\n'.join(f';; {line}' for line in title.rstrip().split('\n')))
		if '_notes' in comms: out(comms['_notes'])
		conf_sec_order = ' '.join(self._conf_sections)
		conf_opt_order = ''.join(f' {k} ' for k in comms).replace('-', '_')
		for k in sorted( dir(self),
				key=lambda k: (conf_sec_order.find(
					k.split('_', 1)[0] ), conf_opt_order.find(f' {k} '), k) ):
			if not (m := chk.search(k)): continue
			if (v := self.get(k, raw=True)) is None: continue # internal stuff
			if not empty_vals and not v: continue
			if cat_new := (cat != (cat_chk := m.group(1).replace('_', '-'))):
				cat = cat_chk; out(f'\n[{cat}]')
			k = m.group(2).replace('_', '-')
			if (comm := comms.get(k_full := f'{cat}-{k}')) is not None:
				if not cat_new and getattr(comm, 'add_line_break', False): out('')
				if comm.startswith(f'; {k_full}'): comm = f'; {k}' + comm[len(k_full) + 2:]
				if comm: out(comm)
			out(f'{k} = {self.val_to_opt(v)}')
		if sec := self.renames:
			out('\n[renames]')
			for k, v in sec.items(): out(f'{".".join(k)} = {self.val_to_opt(v)}')
		if sec := self.send_repls:
			out('\n[send-replacements]')
			for repl in it.chain.from_iterable(sec.values()):
				out(f'{repl.key} = {self.val_to_opt(repl.val)}')
		if sec := self.unmon_filters:
			out('\n[unmonitor]')
			for k, mx in sec.items(): out(f'{k} = {mx.pat}')
		if sec := self.recv_filters:
			out('\n[recv-regexp-filters]')
			for k, mx in sec.items(): out(f'{k} = {mx.pat}')

	def read_from_file(self, *conf_paths):
		if conf_paths: self._conf_path_list = conf_paths
		else: conf_paths = self._conf_path_list
		conf_file = configparser.ConfigParser(
			delimiters='=', allow_no_value=True, empty_lines_in_values=False )
		conf_file.optionxform = lambda k: k
		conf_file.read(conf_paths)

		self._conf_path = conf_paths[-1] # one to update via set and state stuff
		self._conf_old_found.clear()
		for k, k_new in list(self._conf_sections_old.items()):
			if k_new not in self._conf_sections: continue # special sections
			if self.update_from_file_section(
					conf_file, section=k_new, prefix=f'{k_new}_', section_old=k ):
				# Warnings will be issued for existing keys after logging is configured later
				self._conf_old_found[k] = k_new
		for k in self._conf_sections:
			self.update_from_file_section(conf_file, section=k, prefix=f'{k}_')
		self.read(conf_file.getboolean, 'state', 'tracked')
		self._state_section = conf_file.has_section('state')
		if self.state_tracked and self._state_section:
			for k, v in conf_file['state'].items():
				if k == 'tracked': continue
				self.state[k] = parse_iso8601(v)

		self.renames.clear()
		for new, sk in enumerate(['aliases', sk_new := 'renames']):
			if not conf_file.has_section(sk): continue
			if not new: self._conf_old_found[sk] = sk_new
			self.renames.update(
				(tuple(str_norm(k).split('.', 1)), v) for k, v in conf_file[sk].items() )

		self.send_repls.clear()
		for new, sk in enumerate(['replacements', sk_new := 'send-replacements']):
			if not conf_file.has_section(sk): continue
			if not new: self._conf_old_found[sk] = sk_new
			val_conv = self.opt_to_val_func(str)
			for k, v in conf_file[sk].items():
				repl = self.make_send_repl(k, val_conv(v or ''))
				self.send_repls[repl.pre].append(repl)

		self.unmon_filters.clear(); filters_old = dict()
		if conf_file.has_section('filters'): # deprecated section
			self._conf_old_found['filters'] = 'unmonitor'
			for v in conf_file['filters'].keys():
				if '[' in v:
					v_new = f're:^{re.escape(v)}'
					if v.endswith('+threads'): v_new = v_new[:-9] + r'\.='
					else: v_new += '$'
				else:
					v_new = v.replace('*', '[*]').replace('?', '[?]')
					if v.endswith('+threads'): v_new = 'glob:' + v_new[:-8] + '.=*'
				filters_old[f'deprecated.{str_hash(v_new, 6)}'] = v_new
		if conf_file.has_section('unmonitor'):
			for k, pat in [*filters_old.items(), *conf_file['unmonitor'].items()]:
				if re.search(r'^\s*(glob|re)(:|$)', k, re.I):
					kv = f'{k} = {pat}' if ':' in k else f'{k}:{pat}'
					raise ValueError( '[unmonitor] config line seem to have pattern'
						f' used as a key, not as value in "<some-key> = <pattern>" format: {kv}' )
				if pat: self.unmon_filters[k] = self.make_unmon_pattern(pat)

		self.recv_filters.clear()
		if conf_file.has_section('recv-regexp-filters'):
			for k, pat in conf_file['recv-regexp-filters'].items():
				if pat: self.recv_filters[k] = self.make_recv_filter(pat)

		return conf_paths

	def update_from_file_section(self,
			config, section='default', prefix=None, section_old=None ):
		if section_old: section_old = section_old.replace('_', '-')
		section_old_warn, section = None, section.replace('_', '-')
		for k in dir(self):
			if prefix:
				if not k.startswith(prefix): continue
				conf_k, k = k, k[len(prefix):]
			elif k.startswith('_'): continue
			else: conf_k = k
			v = getattr(self, conf_k)
			get_val = self.opt_to_val_func(v, config.get)
			if not get_val: continue # other types cannot be specified in config
			if self.read( get_val, section, k, conf_k,
				section_old=section_old ) and section_old: section_old_warn = True
		return section_old_warn

	# Saving - intended to replace lines/sections in config w/o removing
	#  comments, or magling stuff otherwise via straight-up serialization.

	def _get_section_updates(self, section, keys, key_set=False):
		'Returns {key: (val_str, line_re)} to replace/add in specified ini section'
		sec_k, sec_prefix = str_norm(section), section.lower().replace('-', '_') + '_'
		if keys is None: keys = list(k for k in vars(self).keys() if k.startswith(sec_prefix))
		elif isinstance(keys, str): keys = keys.split()
		elif callable(getattr(keys, 'items', None)): keys = list(keys.items())
		for n, k in enumerate(keys):
			if isinstance(k, tuple): k, v = k
			else: v = ...
			k = k.replace('_', '-')
			if not k.startswith(sec_prefix): k = sec_prefix + k
			if v is ...: v = self.get(k, raw=True)
			k = k[len(sec_prefix):]
			if v is None: pass
			elif v := self.val_to_opt(v): v = f' = {v}'
			elif not key_set: v = ' ='
			keys[n] = (k, v, re.compile( r'(?i)^' +
				re.escape(k).replace('\\-', '[-_]') + r'\s*(=|$)' ))
		return dict((k, (v, rx)) for k, v, rx in keys)

	def update_file_section(self, section, keys=None, path=None, key_set=False):
		'''Safely replaces last config file, updating some keys in specified section there.
			keys=None or a str/list of keys stores/updates specified section keys to a file.
				None value set for the key removes it from config section.
			key_set=True will print empty-string values as "key" instead of default "key =".'''
		section = section.replace('_', '-')
		sec_k, sec_re = str_norm(section), re.compile(r'(?i)^\[\s*(\S+)\s*\]$')
		if not path: path = self._conf_path
		if isinstance(path, str): path = pl.Path(path)
		keys = self._get_section_updates(section, keys, key_set=key_set)
		with path.open() as src, safe_replacement(path) as dst:
			## Pass-through existing config file, except for target section lines
			lines, sec, sec_parse = list(), list(), False
			for n, line in enumerate(src):
				line = line.rstrip()
				if m := sec_re.search(line.strip()):
					if sec_k == str_norm(m.group(1)):
						sec_parse = True
						if sec: line = '' # drop duplicate headers
					else: sec_parse = False
				if sec_parse: sec.append(line)
				else: lines.append(line)
			while sec and not sec[-1]: sec.pop()
			if not sec: sec.append(f'[{section}]')
			if lines and lines[-1]: lines.append('')
			for line in lines: dst.write(f'{line}\n')
			## Update target section
			for n, line in enumerate(sec): # replace updated key(s)
				for k, (v, rx) in keys.items():
					if not rx.search(line): continue
					if v is None: line = None
					else: line, keys[k] = f'{k}{v}', (None, rx)
					break
				if line is not None: dst.write(f'{line}\n')
			for k, (v, rx) in keys.items(): # dump all other keys as-is
				if v is None: continue
				dst.write(f'{k}{v}\n')
		self._state_source_flush()
		return path

	### Timestamps in [state] section are updated in-place,
	###  overwriting short timestamp values without tmp files.
	### File should be safe to edit manually regardless, due to ctime checks.

	def _state_source_flush(self):
		self._state_file = self._state_offsets = None

	def _state_source_get(self):
		if self._state_file and self._state_file_ts:
			try: ts = os.stat(self._state_file.name).st_ctime
			except OSError: ts = None
			if ts != self._state_file_ts: self._state_source_flush()
		if not self._state_file: self._state_file = open(path_filter(self._conf_path), 'rb+')
		if self._state_offsets is None: self._state_offsets = self._state_offsets_read()
		return self._state_file, self._state_offsets

	def _state_offsets_read(self, section='state'):
		section, src = section.replace('_', '-'), self._state_file
		sec_re, sec_k = re.compile(r'(?i)^\[\s*(\S+)\s*\]$'), str_norm(section)
		src.seek(0)
		offsets, parse = dict(), False
		val_re = re.compile(br'^\s*([\w\d]\S+)\s*=\s*(\S+)\s*$')
		for line in iter(src.readline, b''):
			if m := sec_re.search(line.decode().strip()):
				if sec_k == str_norm(m.group(1)): parse = True
				else: parse = False
				continue
			if parse:
				if not (m := val_re.search(line)): continue
				k, v = m.group(1), m.group(2)
				pos = src.tell() - len(line) + m.start(2)
				offsets[k.decode()] = pos
				# src.seek(pos)
				# v_chk = src.read(len(v))
				# assert v_chk == v, [pos, v_chk, v]
				# src.readline()
		return offsets

	def state_get(self, k, t='last-msg'): return self.state.get(f'{t}.{k}')

	def state_set(self, k, ts, t='last-msg', sync=False):
		if not self.state_tracked: return
		if not self._state_section: self.update_file_section('state', 'tracked')
		k, v = f'{t}.{k}', ts_iso8601(ts)
		if k not in self.state:
			self.update_file_section('state', {k: v})
			self.state[k] = ts
			return self.state_cleanup()
		if self.state[k] > ts: return
		src, offsets = self._state_source_get()
		n, v = offsets[k], v.encode()
		src.seek(n)
		parse_iso8601(src.read(len(v)).decode(), validate=True)
		src.seek(n)
		src.write(v)
		src.flush()
		if sync: os.fdatasync(src.fileno())
		self.state[k], self._state_file_ts = ts, os.fstat(src.fileno()).st_ctime

	def state_cleanup(self, keep_max=10):
		keys = sorted((v,k) for k,v in self.state.items())
		if len(keys) <= keep_max: return
		src, offsets = self._state_source_get()
		offsets = sorted( ((offsets[k], k) for v,k in
			keys[:len(keys) - keep_max]), reverse=True )
		with src, safe_replacement(src.name, 'wb') as dst:
			src.seek(0)
			for line in iter(src.readline, b''):
				if offsets and offsets[-1][0] < src.tell():
					n, k = offsets.pop()
					del self.state[k]
					continue
				dst.write(line)
		self._state_source_flush()



def parse_conf_code_comments(lines):
	'Parses "class RDIRCDConfigBase" in this script, returns {var: comment} dict'
	class attr_str(str): pass
	def line_proc(line):
		var = line.split(None, 1)[0] if re.search(r'^\S+\s+=', line) else ''
		var, var_code = var.replace('_', '-'), var
		try: pre, comm = line.split('#', 1)
		except ValueError: comm = ''
		if comm.lstrip().startswith('XXX:'): comm = ''
		if comm.startswith(' '): comm = comm[1:]
		return var, var_code, comm
	comms, comm_buffer, var_break = dict(), list(), False
	for n, ls in enumerate(lines):
		var, var_code, comm = line_proc(ls)
		if var_code.startswith('_'): break
		if not var:
			if comm: comm_buffer.append(comm)
			if not ls: var_break = True
		else:
			if comm_buffer:
				comm = ((comm or '') + '\n' + '\n'.join(comm_buffer)).strip()
			if comm:
				if var and re.search('^' + re.escape(var_code) + r'\s+', comm):
					comm = var + comm[len(var_code):]
				if (m := re.search(r'^\w+', comm)) and m[0].istitle():
					word1, *comm = comm.split(None, 1)
					comm = ' '.join([word1.lower(), *comm])
				if not comm.startswith(var) and '*' not in comm.split()[0]:
					comm = f'{var}: {comm}'
					if '\n' not in comm and comm[-1] != '.': comm += '.'
			comms[var] = comm
		if comm_buffer and (not comm or var):
			if not comms: var = '_notes'; comms[var] = '\n'.join(comm_buffer)
			comm_buffer.clear()
		if var:
			if comm := comms.get(var, ''):
				comm = comms[var] = '\n'.join(f'; {line}' for line in comm.split('\n'))
			if var_break:
				(comm := attr_str(comm)).add_line_break = True
				comms[var], var_break = comm, False
	return comms

def main_test(cmd, conf):
	help_msg = dd('''
		Supported --test=<command> helper/tool commands:

			msg-parse[=<json-file-path>]
				Parse full MESSAGE_CREATE even from a JSON file with it (or stdin), using op_msg.
				Prints resulting parsed lines and any warnings logged during that.
				Intended to test pasing new/complicated message structures after code tweaks.

			msg-parse-base[=<json-file-path>]
				Same as msg-parse above, but to parse "message" payload object, using op_msg_parse.
			msg-parse-media[=<json-file-path>]
				Same as msg-parse above, but to parse media/embed info only, using op_msg_media_info.''')
	if not (cmd := cmd.strip()) or cmd in ['help', '?']: return print(help_msg)
	cmd, s, arg = cmd.partition('=')

	if cmd in ['msg-parse', 'msg-parse-base', 'msg-parse-media']:
		class DiscordStub(RDIRCD, Discord, DiscordSession):
			st_da = st_br = msg_recv = None
			def cmd_user_cache(self, gid, uid, name=None): pass
			def op_msg_ref(self, msg_id, cc, nick, line, ref=None): pass
			def cmd_msg_recv( self, cc, nick, line,
					tags=None, new_msg_flake=None, nonce=None, notice=None, skip_monitor=False ):
				self.msg_recv = cc, nick, line, tags, new_msg_flake, nonce, notice, skip_monitor
			def __init__(self, conf, st):
				self.st_da = self.st_br = st
				self.conf, self.log = conf, get_logger('xxx')
				self.discord = self.bridge = self # all-in-one mock object

		ev = adict(json.loads(pl.Path(arg).read_bytes() if arg else sys.stdin.read()))
		guild_id, chan_id, msg_id = ev.d.guild_id, ev.d.channel_id, ev.d.id
		author = ( ev.d.get('author') or
			dict(username='test', public_flags=0, id='298467229970726912', discriminator='8418') )
		embed_info = {msg_id: author}
		chan = adict(
			name='somechan', topic='somechan.topic',
			ct=DiscordStub.c_chan_type.text, did=f'#{guild_id}-{chan_id}',
			id=chan_id, private=False, threads=dict(), tid=None, users=dict() )
		gg = adict(name='test', id=guild_id, chans={chan_id: chan})
		discord = DiscordStub(conf, adict(
			d2i=dict(), i2d=dict(), embed_info=embed_info, guilds={guild_id: gg} ))

		tags = ''
		if cmd.endswith('-base'): lines, tags = discord.op_msg_parse(ev.d, gg)
		elif cmd.endswith('-media'): lines = '\n'.join(discord.op_msg_media_info(ev.d) or '')
		else:
			mt = (ev.get('t') or '').lower()
			try: o, act = mt.rsplit('_', 1)
			except ValueError: o = act = None
			discord.op_msg(ev.d, act)
			if discord.msg_recv:
				cc, nick, lines, tags, flake, nonce, notice, skip_monitor = discord.msg_recv
			else: return print('--- msg discarded')

		if not lines: return
		lines = (lines or '').split('\n')
		print('\nIRC lines as-is:')
		for line in lines: print('  ' + line)
		print('\nIRC lines repr:')
		for line in lines: print('  ' + repr(line))
		if tags: print('\nTags:', tags)
		if em_info := discord.st_da.embed_info: print('\nEm-info:', em_info)
		print()

	else: return print(help_msg, file=sys.stderr) or 1

def main(args=None, conf=None):
	if not conf: conf = RDIRCDConfig()

	import argparse
	parser = argparse.ArgumentParser(
		usage='%(prog)s [options]',
		formatter_class=argparse.RawTextHelpFormatter,
		description='Reliable personal discord-client <-> irc-server translation daemon.')

	group = parser.add_argument_group('Configuration file(s)')
	group.add_argument('-c', '--conf', metavar='file', action='append', help=dd(f'''
		Path to configuration file to use.
		It will get updated with OAuth2 credentials ([auth] section)
			and some state info ([state] section), so has to be writable.
		Can be specified mutliple times to use multiple config files,
			in which case only last one will be updated and has to be writable.
		Default: {conf._conf_path}'''))
	group.add_argument('--conf-dump', action='store_true', help=dd('''
		Print all configuration settings, which will be used with
			currently detected (and/or specified) configuration file(s), and exit.'''))
	group.add_argument('--conf-dump-defaults', action='store_true', help=dd('''
		Print all default settings which would be used if no configuration
			file(s) were overriding these, with some extra comments, and exit.'''))
	group.add_argument('--conf-dump-state', action='store_true', help=dd('''
		Print "state" section from the config file,
			with keys corresponding to app startups and iso8601
			time values of last received message for that run.'''))
	group.add_argument('-H', '--conf-pw-scrypt', action='store_true', help=dd('''
		Prompt/read password from stdin, generate and print
			scrypt-hashed string of it to stdout for use in config file(s) and exit.
		Such hash should be used with "password-hash" option under [irc] section.
		If stdin is not a terminal, only first line is read from there as a password.
		scrypt uses random 16B salt and N=2^15 r=8 p=1 parameters (min 32M memory).'''))
	group.add_argument('--conf-init', action='store_true', help=dd('''
		Create initial config file(s) with default options in specified path(s) and exit.
		This will never change or overwrite any existing files.
		If multiple -c/--conf files are used, all files after first one are created empty.'''))

	group = parser.add_argument_group('Interfaces')
	group.add_argument('-i', '--irc-bind', metavar='host(:port)', help=dd(f'''
		Address/host (to be resolved via gai) and port to bind IRC server to.
		When specifying port after raw IPv6 address,
			enclose the latter in [], for example - [::]:6667.
		Default: {conf.irc_host}:{conf.irc_port} or whatever is in --conf file.'''))
	group.add_argument('-s', '--irc-tls-pem-file', metavar='file', help=dd('''
		Path to a file with TLS certificate and key
			to use tls-wrapped connection(s) instead of plain irc protocol.
		Same as IRC "tls-pem-file" config option (default-disabled).
		If file is specified but missing, it will be auto-generated by default.'''))
	group.add_argument('-S', '--irc-tls-pem-gen-subj',
		metavar='subject', default=conf.irc_tls_pem_gen_subj, help=dd('''
			Generate self-signed TLS cert using "openssl req" with specified subject line.
			Will never overwrite -c/--irc-tls-pem file, if it exists already.
			Use empty value to disable this. Default subject line: %(default)s'''))

	group = parser.add_argument_group('Logging and debug opts')
	group.add_argument('-d', '--debug',
		action='store_true', help='Verbose operation mode.')
	group.add_argument('--debug-asyncio',
		action='store_true', help='Run asyncio event loop in debug mode. Very verbose.')
	group.add_argument('-t', '--proto-cut', type=int, metavar='n', help=dd('''
		Truncate long strings in protocol dumps to specified length.
		Set to <=0 to disable truncation (default).'''))
	group.add_argument('-p', '--proto-log', metavar='file', help=dd('''
		File to dump full non-truncated protocol logs to.
		Max file size and rotation options can be configured via --conf file.'''))
	group.add_argument('-l', '--debug-log', metavar='file', help=dd('''
		Separate file for debug-level logging, to use regardless of any verbosity options.
		Max file size and rotation options can be configured via --conf file.'''))
	group.add_argument('--test', nargs='?', const='', metavar='test-to-run', help=dd('''
		Various code development/testing helpers and tools to run and exit.
		Specify without arguments (or --test=help --test=?) to get more info.'''))

	opts = parser.parse_args(sys.argv[1:] if args is None else args)

	if opts.conf_dump_defaults or opts.conf_init:
		try: # parse config comments from this script
			lines = list()
			if __file__.endswith('.pyc'): raise OSError
			with open(__file__, errors='replace') as src:
				if src.read(3) != '#!/': raise OSError
				for line in src:
					ls = line.strip()
					if not (lines or ls == 'class RDIRCDConfigBase:'): continue
					if lines and ls and not line.startswith('\t'): break
					lines.append(ls)
		except OSError: comms = None
		else: comms = parse_conf_code_comments(lines)
		if opts.conf_dump_defaults:
			return conf.pprint(
				'Default configuration options', empty_vals=True, comments=comms )

	if opts.conf_pw_scrypt:
		if sys.stdin.isatty():
			import getpass
			pw = getpass.getpass().encode()
		else: pw = sys.stdin.buffer.readline().rstrip(b'\r\n')
		return print(pw_hash(pw))

	conf_user_paths = list(path_filter(p) for p in opts.conf or [conf._conf_path])

	if opts.conf_init:
		exit_code, conf.irc_uid_seed = 0, host_uid_seed()
		for n, p in enumerate(conf_user_paths):
			try: fd = os.open(path_filter(p), os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o600)
			except OSError as err:
				if err.errno != errno.EEXIST:
					print( 'Failed to create initial configuration'
						f' file [ {p} ]: {err_fmt(err)}', file=sys.stderr )
					exit_code = 1
				continue
			with os.fdopen(fd, 'r+') as dst:
				if n: continue # all files after first one are created empty
				req_keys = ['email', 'password', 'password-hash', 'uid-seed']
				def _store_commented_line(line): # skips storing defaults
					if not line.lstrip().startswith('['):
						key, sep, rest = line.partition(' ')
						if key in req_keys: req_keys.remove(key)
						elif line.strip(): line = '\n'.join(f';{line}' for line in line.split('\n'))
					print(line, file=dst)
				conf.pprint(
					'Configuration file template with ALL supported options'
						f' and their default values.\nAs of rdircd {conf.version}.'
						' Uncomment values to override defaults and cleanup as needed.',
					empty_vals=True, comments=comms, out=_store_commented_line )
		if not exit_code:
			print('\nAll specified configuration files do exist or were created:\n')
			for p in map(pl.Path, conf_user_paths):
				if str(p.resolve()) == str(p): print(f' - {p}')
				else: print(f' - {p} [ {p.resolve()} ]')
			print('\nMake sure to at least set required auth options there, if not done already.')
			print( 'Exiting due to --conf-init option.'
				' Remove it to run script normally with these configs.\n' )
		return exit_code

	for n, p in enumerate(conf_user_paths):
		mode, w = os.R_OK, ''
		if n == len(conf_user_paths) - 1:
			mode |= os.W_OK; w = ' (for write access)'
		if not os.access(p, mode):
			parser.error(f'Specified config file missing or inaccessible{w}: {p}')
	conf_user_paths = conf.read_from_file(*conf_user_paths)

	if opts.conf_dump: return conf.pprint('Current configuration options')
	if opts.conf_dump_state:
		print('state_timestamps:')
		for v, k in sorted((v,k) for k,v in conf.state.items()):
			print(f'  {k}: {ts_iso8601(v)}')
		return

	if opts.test is not None: return main_test(opts.test, conf)
	if opts.debug: conf.debug_verbose = True
	if opts.debug_asyncio: conf.debug_asyncio_logs = True
	if opts.debug_log: conf.debug_log_file = opts.debug_log
	if opts.proto_cut: conf.debug_proto_cut = opts.proto_cut
	if opts.proto_log: conf.debug_proto_log_file = opts.proto_log

	log_fmt = '{name} {levelname:5} :: {message}'
	if conf.debug_verbose: log_fmt = '{asctime} :: ' + log_fmt
	log_fmt = logging.Formatter(log_fmt, style='{')
	log_handler = logging.StreamHandler(sys.stderr)
	log_handler.setLevel( logging.DEBUG
		if conf.debug_verbose else logging.WARNING )
	log_handler.setFormatter(log_fmt)
	log_handler.addFilter(log_empty_filter)
	logging.root.addHandler(log_handler)
	logging.root.setLevel(0)
	log = get_logger('main')

	log_handler = LogLevelCounter()
	logging.root.addHandler(log_handler)
	conf._debug_counts = log_handler.counts

	if conf.debug_log_file:
		log_handler = LogFileHandler(
			conf.debug_log_file,
			maxBytes=conf.debug_log_file_size,
			backupCount=conf.debug_log_file_count )
		log_handler.setLevel(logging.DEBUG)
		log_handler.setFormatter(logging.Formatter(
			'{asctime}.{msecs:03.0f} :: {name} {levelname:5} :: {message}',
			datefmt='%Y-%m-%dT%H:%M:%S', style='{' ))
		logging.root.addHandler(log_handler)

	log_proto_root.propagate = conf.debug_proto_log_shared
	log_handler = conf._debug_proto = LogFileHandler(
		conf.debug_proto_log_file or '/dev/null',
		maxBytes=conf.debug_proto_log_file_size,
		backupCount=conf.debug_proto_log_file_count )
	log_handler.setLevel( logging.DEBUG
		if conf.debug_proto_log_file else logging.WARNING )
	log_handler.setFormatter(LogProtoFormatter(
		'%(asctime)s :: %(reltime)s :: %(name)s :: %(message)s' ))
	log_handler.addFilter(log_proto_debug_filter)
	log_proto_root.addHandler(log_handler)

	sys.excepthook = ( lambda err_t, err, err_tb:
		log.error('Unhandled error: {}', err_fmt(err), exc_info=(err_t, err, err_tb)) )
	log.debug('Starting rdircd {}', conf.version)

	for p in conf_user_paths: log.debug('Loaded configuration file: {}', p)
	for k_old, k_new in conf._conf_old_found.items():
		if k_old in conf._conf_sections_old: kt, k_old = 'section', repr(k_old)
		else:
			k_new = k_new.split('_', 1)
			kt, k_old, k_new = 'value', f'[{k_new[0]}] {k_old!r}', k_new[-1]
		k_old, k_new = (k.replace('_', '-') for k in [k_old, k_new])
		log.warning( 'Old and deprecated {} in config'
			' file(s) - {} - replace it with {!r}', kt, k_old, k_new )

	if conf.irc_password:
		if conf.irc_password_hash:
			parser.error( 'Both password= and password-hash= config-file'
				' options cannot be used at the same time, use latter one only.' )
		log.warning( 'Plaintext [irc] password= option is used in'
			' configuration file(s) - use password-hash= instead, if possible' )
		conf.irc_password_hash = pw_hash(conf.irc_password)
		conf.irc_password = ''
	if conf.irc_password_hash:
		try:
			if pw_hash('hunter2', conf.irc_password_hash):
				parser.error('Using "hunter2" as [irc] password is explicitly prohibited, sorry.')
		except ValueError as err: parser.error(f'Invalid [irc] password-hash value - {err}')

	host, port, family = opts.irc_bind or conf.irc_host, conf.irc_port, conf.irc_host_af
	if host.count(':') > 1: host, port = str_part(host, ']:>', port)
	else: host, port = str_part(host, ':>', port)
	if '[' in host: family = socket.AF_INET6
	host, port = host.strip('[]'), int(port)
	try:
		addrinfo = socket.getaddrinfo( host, str(port),
			family=family, type=socket.SOCK_STREAM, proto=socket.IPPROTO_TCP )
		if not addrinfo: raise socket.gaierror(f'No addrinfo for host: {host}')
	except (socket.gaierror, socket.error) as err:
		parser.error( 'Failed to resolve irc socket parameters (address, family)'
			' via getaddrinfo: {!r} - [{}] {}'.format((host, port), err.__class__.__name__, err) )
	sock_af, sock_t, sock_p, _, sock_addr = addrinfo[0]
	log.debug(
		'Resolved irc host:port {!r}:{!r} to endpoint: {} (family: {}, type: {}, proto: {})',
		host, port, sock_addr, *(sockopt_resolve(pre, n)
			for pre, n in [('af_', sock_af), ('sock_', sock_t), ('ipproto_', sock_p)]) )
	assert ( sock_t == socket.SOCK_STREAM
		and sock_p == socket.IPPROTO_TCP ), [sock_t, sock_p]
	conf.irc_host_af, (conf.irc_host, conf.irc_port) = sock_af, sock_addr[:2]

	for k in 'file', 'gen_subj':
		if (v := getattr(opts, k := f'irc_tls_pem_{k}')) is not None: setattr(conf, k, v)
	if conf.irc_tls_pem_file:
		import ssl
		p_pem = pl.Path(path_filter(conf.irc_tls_pem_file))
		if conf.irc_tls_pem_gen_subj and not p_pem.exists():
			import subprocess as sp
			p_key, p_crt = (path_filter(p_pem.with_name(
				p_pem.name + f'.new.{k}' )) for k in ['key', 'crt'])
			try:
				log.debug('Generating new TLS cert/key file: {}', p_pem)
				sp.run([ 'openssl', 'req', '-new', '-x509', '-nodes', '-keyout',
					p_key, '-out', p_crt, '-subj', conf.irc_tls_pem_gen_subj ], check=True)
				p_pem.touch(0o600)
				try: p_pem.write_text('\n'.join([p_key.read_text(), p_crt.read_text()]))
				except: p_pem.unlink(); raise
			finally: p_key.unlink(missing_ok=True); p_crt.unlink(missing_ok=True)
		conf.irc_tls = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
		conf.irc_tls.load_cert_chain(p_pem, p_pem)

	if not conf.irc_uid_seed: # stored to keep it stable in e.g. docker containers
		if any( re.search(r'^\s*[^\[;#]', line) # old config with non-commented-out values
				for line in pl.Path(conf._conf_path).read_text().splitlines() ):
			# Compatibility: pre-23.09.07 had a bug that always used hostname like this
			# >32B hostname couldn't have started rdircd with legacy uid, use new one there
			uid_seed = f'rdircd.{os.uname().nodename}'
			if len(uid_seed.encode()) <= 32: conf.irc_uid_seed = uid_seed
		if not conf.irc_uid_seed: conf.irc_uid_seed = host_uid_seed()
		conf.update_file_section('irc', 'uid_seed')
		log.debug('Stored irc-uid-seed value in config file: {}', conf._conf_path)

	def _conf_sighup_reload():
		try: conf_paths = conf.read_from_file()
		except Exception as err:
			return log.error('Failed to reload config(s) on SIGHUP: {}', err_fmt(err))
		conf_paths = ' '.join(path_names(*map(pl.Path, conf_paths)))
		log.debug('Reloaded config file(s) on SIGHUP [ {} ]', conf_paths)

	async def _run_rdircd():
		loop, rdircd = asyncio.get_running_loop(), RDIRCD(conf)

		log_handler = conf._debug_chan = LogFuncHandler(rdircd.cmd_log)
		log_handler.setLevel(logging.DEBUG if conf.debug_verbose else logging.WARNING)
		log_handler.setFormatter(logging.Formatter(
			'{name} {levelname:5} :: {message}', style='{' ))
		log_handler.addFilter(log_empty_filter)
		log_handler.addFilter(log_proto_debug_filter)
		logging.root.addHandler(log_handler)

		rdircd_task = asyncio.create_task(rdircd.run_async())
		for sig in signal.SIGINT, signal.SIGTERM:
			loop.add_signal_handler(sig, rdircd_task.cancel)
		loop.add_signal_handler(signal.SIGHUP, _conf_sighup_reload)
		with cl.suppress(asyncio.CancelledError): return await rdircd_task

	log.debug('Starting eventloop...')
	return asyncio.run(_run_rdircd(), debug=conf.debug_asyncio_logs)
	log.debug('Finished')

if __name__ == '__main__': sys.exit(main())
