pisg-autoalias.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. #!/usr/bin/env python3
  2. """Merge nicks that belong to the same person, from what the logs already say.
  3. On networks with account services (Undernet: X), an authenticated user's host is fixed by the
  4. service, for example chatte.users.undernet.org . Nicks that were seen with the same such host
  5. are the same person: nobody can borrow another account's host. This script reads the join, part
  6. and quit lines of your logs, groups nicks by that host, and writes a pisg config file with one
  7. <user nick="..." alias="..."> line per group.
  8. pisg-autoalias.py --logdir ~/eggdrop/logs --prefix example.log. \\
  9. --manual ~/pisg/pisg.cfg --manual ~/pisg/users.cfg --out ~/pisg/aliases.auto.cfg
  10. Then, in pisg.cfg (a config can include other files, but an included file cannot include another):
  11. <include="/home/you/pisg/aliases.auto.cfg">
  12. It is safe to run before every pisg run: the output is rewritten each time.
  13. Rules
  14. * Only hosts matching --hosts (default *.users.undernet.org) count. Shared IPs, gateways and
  15. bouncers are never merged automatically: they prove nothing about who is behind them.
  16. * Anything you define by hand wins. A group that touches one <user nick=...> of yours is added
  17. to that entry (its nick becomes the name); a group that touches two of your entries is
  18. skipped and reported, because you said they are different people.
  19. * The name of a group is its busiest nick (most chat lines in the logs).
  20. * Groups larger than --max-group are skipped and reported (a shared account, most likely).
  21. """
  22. import argparse
  23. import fnmatch
  24. import glob
  25. import os
  26. import re
  27. import sys
  28. import tempfile
  29. import time
  30. from collections import Counter, defaultdict
  31. ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
  32. ap.add_argument("--logdir", action="append", default=[], help="folder of logs (repeatable)")
  33. ap.add_argument("--prefix", default="", help="only files whose name starts with this")
  34. ap.add_argument("--logfile", action="append", default=[], help="a single log file (repeatable)")
  35. ap.add_argument("--manual", action="append", default=[], help="pisg config files whose <user> lines are yours (repeatable)")
  36. ap.add_argument("--out", help="file to write (default: print to stdout)")
  37. ap.add_argument("--hosts", action="append", default=[], help="host pattern that identifies an account (default *.users.undernet.org)")
  38. ap.add_argument("--max-group", type=int, default=30)
  39. ap.add_argument("--report", action="store_true", help="print what was merged, and what was skipped and why")
  40. args = ap.parse_args()
  41. patterns = [h.lower() for h in (args.hosts or ["*.users.undernet.org"])]
  42. PREFIX = "@+%~&"
  43. # eggdrop format: [12:34:56] nick (ident@host) joined #chan. / left #chan. / left irc: reason
  44. HOSTLINE = re.compile(r"^\[[\d:]+\] [" + re.escape(PREFIX) + r"]?([^\s()]+) \(([^)@\s]*)@([^)\s]+)\) (?:joined|left)\b")
  45. CHATLINE = re.compile(r"^\[[\d:]+\] <[" + re.escape(PREFIX) + r"]?([^>\s]+)> ")
  46. def log_files():
  47. files = list(args.logfile)
  48. for d in args.logdir:
  49. d = os.path.expanduser(d)
  50. for f in sorted(glob.glob(os.path.join(d, args.prefix + "*"))):
  51. base = os.path.basename(f)
  52. if os.path.isfile(f) and not base.startswith(".") and not base.endswith(".tmp"):
  53. files.append(f)
  54. return files
  55. def read_manual():
  56. """nick or alias (lower case) -> the manual entry's name, for every <user ...> line you wrote."""
  57. owner, entries = {}, set()
  58. for path in args.manual:
  59. path = os.path.expanduser(path)
  60. if not os.path.exists(path):
  61. continue
  62. for line in open(path, encoding="utf-8", errors="replace"):
  63. if line.lstrip().startswith("#") or "<user" not in line:
  64. continue
  65. m = re.search(r"\bnick=([\"'])(.+?)\1", line)
  66. if not m:
  67. continue
  68. name = m.group(2)
  69. entries.add(name.lower())
  70. owner.setdefault(name.lower(), name)
  71. a = re.search(r"\balias=([\"'])(.+?)\1", line)
  72. for al in (a.group(2).split() if a else []):
  73. if "*" not in al:
  74. owner.setdefault(al.lower(), name)
  75. return owner, entries
  76. def main():
  77. hosts = defaultdict(set) # host -> nicks seen with it
  78. chat = Counter() # nick -> chat lines
  79. seen_join = Counter() # nick -> join/part lines (tie-break)
  80. files = log_files()
  81. if not files:
  82. sys.exit("no log files found")
  83. for f in files:
  84. try:
  85. fh = open(f, encoding="utf-8", errors="replace")
  86. except OSError:
  87. continue
  88. with fh:
  89. for line in fh:
  90. m = CHATLINE.match(line)
  91. if m:
  92. chat[m.group(1)] += 1
  93. continue
  94. m = HOSTLINE.match(line)
  95. if m:
  96. nick, host = m.group(1), m.group(3).lower()
  97. if any(fnmatch.fnmatchcase(host, p) for p in patterns):
  98. hosts[host].add(nick)
  99. seen_join[nick] += 1
  100. manual_owner, manual_entries = read_manual()
  101. written, skipped = [], []
  102. for host in sorted(hosts):
  103. nicks = hosts[host]
  104. if len(nicks) < 2:
  105. continue
  106. acct = host.split(".")[0]
  107. if len(nicks) > args.max_group:
  108. skipped.append((acct, sorted(nicks), f"{len(nicks)} nicks, more than --max-group ({args.max_group})"))
  109. continue
  110. # which of your own entries does this group touch?
  111. touched = {manual_owner[n.lower()] for n in nicks if n.lower() in manual_owner}
  112. if len(touched) > 1:
  113. skipped.append((acct, sorted(nicks), "touches " + " and ".join(sorted(touched)) + ", which you defined as different people"))
  114. continue
  115. if touched:
  116. name = next(iter(touched))
  117. else:
  118. name = max(nicks, key=lambda n: (chat[n], seen_join[n], n.lower() == n, n))
  119. aliases = sorted((n for n in nicks if n.lower() != name.lower() and manual_owner.get(n.lower()) in (None, name)),
  120. key=str.lower)
  121. if not aliases:
  122. continue
  123. written.append((acct, name, aliases, bool(touched)))
  124. lines = [
  125. "# Generated by scripts/pisg-autoalias.py on " + time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()) + ".",
  126. "# Do not edit: this file is rewritten on every run. To change a result, define the nick yourself",
  127. "# in pisg.cfg or users.cfg; a <user> line of yours always wins.",
  128. f"# Nicks seen with the same authenticated host ({', '.join(patterns)}) are one person.",
  129. "",
  130. ]
  131. for acct, name, aliases, joined in written:
  132. lines.append(f'<user nick="{name}" alias="{" ".join(aliases)}">')
  133. text = "\n".join(lines) + "\n"
  134. if args.out:
  135. out = os.path.expanduser(args.out)
  136. d = os.path.dirname(os.path.abspath(out))
  137. fd, tmp = tempfile.mkstemp(dir=d, prefix=".autoalias.")
  138. with os.fdopen(fd, "w", encoding="utf-8") as fh:
  139. fh.write(text)
  140. os.chmod(tmp, 0o644)
  141. os.replace(tmp, out) # atomic: pisg never sees a half-written file
  142. else:
  143. sys.stdout.write(text)
  144. if args.report or not args.out:
  145. print(f"\n{len(files)} log files, {len(hosts)} authenticated hosts, "
  146. f"{len(written)} groups merged ({sum(len(a) for _, _, a, _ in written)} nicks folded in), "
  147. f"{len(skipped)} skipped", file=sys.stderr)
  148. for acct, name, aliases, joined in sorted(written, key=lambda w: -len(w[2]))[:200]:
  149. print(f" {acct:<16} -> {name}{' (your entry)' if joined else ''}: {', '.join(aliases)}", file=sys.stderr)
  150. for acct, nicks, why in skipped:
  151. print(f" SKIPPED {acct}: {why}: {', '.join(nicks[:8])}", file=sys.stderr)
  152. main()