adiirc2eggdrop.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. #!/usr/bin/env python3
  2. """Convert an AdiIRC channel log to eggdrop log format, so pisg can read it.
  3. adiirc2eggdrop.py mychannel.log OUTDIR --channel '#example' --nick YourNick \\
  4. --tz Europe/Paris --before '2026-01-31 03:00' --prefix example.log.
  5. What it does
  6. * Keeps only PUBLIC channel events (messages, actions, joins, parts, quits,
  7. kicks, nick changes, mode and topic changes). Everything else in a client
  8. log - /whois output, notices, private messages to services/users, server
  9. text - is dropped on purpose: it is not channel history and may contain
  10. private data. This is a whitelist, not a blacklist.
  11. * Converts the client's local time to UTC (eggdrop logs are in server time),
  12. using --tz. Rules such as daylight saving come from the tz database.
  13. * Only writes events strictly before --before (UTC), so the import stops where
  14. eggdrop's own logging starts and nothing is counted twice.
  15. * Splits output into one file per eggdrop log day. Eggdrop rotates at
  16. 03:00 (switch-logfiles-at 300), so file YYYYMMDD holds YYYYMMDD 03:00 up to
  17. the next day 02:59:59, matching the names eggdrop writes with
  18. logfile-suffix ".%Y%m%d".
  19. * Never overwrites: an existing output file is skipped and reported.
  20. pisg reads these with LogDir=... LogPrefix="example.log." in alphabetical
  21. order, which is chronological with the YYYYMMDD suffix.
  22. """
  23. import argparse
  24. import datetime as dt
  25. import re
  26. import sys
  27. import zoneinfo
  28. from collections import Counter
  29. from pathlib import Path
  30. ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
  31. ap.add_argument("infile")
  32. ap.add_argument("outdir")
  33. ap.add_argument("--channel", required=True, help="channel name as eggdrop writes it, e.g. '#example'")
  34. ap.add_argument("--nick", required=True, help="your own nick (used for 'You were kicked')")
  35. ap.add_argument("--tz", required=True, help="timezone the client logged in, e.g. Europe/Paris or America/New_York (IANA name)")
  36. ap.add_argument("--before", required=True, help="UTC cutoff 'YYYY-MM-DD HH:MM'; later events are skipped")
  37. ap.add_argument("--first-date", help="local date of the first line, YYYY-MM-DD. Optional: by default it is "
  38. "worked out from the first 'Day changed' marker in the log")
  39. ap.add_argument("--prefix", default="channel.log.", help="output file name prefix (default %(default)s)")
  40. ap.add_argument("--format", choices=("eggdrop", "znc"), default="eggdrop",
  41. help="eggdrop (default): eggdrop log days, 03:00 rotation. znc: what ZNC's log module writes "
  42. "(energymech format, one YYYY-MM-DD.log per calendar day), for pisg Format=\"energymech\"")
  43. ap.add_argument("--znc-tz", default="UTC", help="timezone ZNC writes its time stamps in (default %(default)s); --format znc only")
  44. ap.add_argument("--dry-run", action="store_true", help="report only, write nothing")
  45. ap.add_argument("--show-actions", action="store_true", help="print the lines treated as /me actions")
  46. args = ap.parse_args()
  47. CH = args.channel
  48. TZ = zoneinfo.ZoneInfo(args.tz)
  49. UTC = dt.timezone.utc
  50. CUTOFF = dt.datetime.strptime(args.before, "%Y-%m-%d %H:%M").replace(tzinfo=UTC)
  51. JITTER = 300 # seconds; smaller backwards steps are clock jitter
  52. ROTATE = dt.timedelta(hours=3) # eggdrop switch-logfiles-at 300
  53. MONTHS = {m: i for i, m in enumerate("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(), 1)}
  54. P = r"[@+%~&!.]?" # channel status prefix on a nick
  55. COLOUR = re.compile(r"\x03\d{0,2}(?:,\d{1,2})?|[\x02\x0f\x16\x1d\x1f]")
  56. LINE = re.compile(r"^\[(\d\d):(\d\d):(\d\d)\] (.*)$")
  57. DAY = re.compile(r"\* Day changed to \w+, (\d+)\. (\w+) (\d+)$")
  58. # Text of "* nick ..." lines that are WHOIS/server output, not a /me action.
  59. NOT_ACTION = re.compile(
  60. r"^\* \S+ (?:is \S+@\S+ \*|on <?[@+%~&]*#|using \S+ |is logged in|has been idle|End of /WHOIS"
  61. r"|has modes|created on|is now known as|sets mode|changes topic|was kicked)"
  62. r"|^\* (?:#\S+ (?:has modes|created on)|Scanning|Topic |Now talking|Rejoin|Disconnected|Attempting|Connect"
  63. r"|You |Joins:|Parts:|Quits:)"
  64. r"|^\* \S+ (?:#\S+ ){2,}"
  65. r"|^\* \S+ (?:invites you|has been invited|is away|is back|is an IRC|is a |End of /NAMES)"
  66. r"|^\* [#/]"
  67. )
  68. PARTICIPANTS = set() # nicks seen speaking/joining/changing nick: only they can /me
  69. def collect_participants(text):
  70. for raw_line in text.split("\n"):
  71. b = COLOUR.sub("", raw_line.rstrip("\r"))
  72. mm = LINE.match(b)
  73. if not mm:
  74. continue
  75. b = mm.group(4)
  76. for rx in (rf"<{P}([^>\s]+)> ", rf"\* Joins: {P}(\S+) ", rf"\* Parts: {P}(\S+) ", rf"\* Quits: {P}(\S+) ",
  77. rf"\* {P}(\S+) is now known as {P}(\S+)$"):
  78. x = re.match(rx, b)
  79. if x:
  80. PARTICIPANTS.update(g for g in x.groups() if g)
  81. break
  82. kept = Counter()
  83. dropped = Counter()
  84. events = [] # (utc datetime, eggdrop text)
  85. actions_seen = []
  86. order_debug = []
  87. ZNC = args.format == "znc"
  88. def convert(body):
  89. """Return (text, kind) for a whitelisted event, else (None, reason). The text is in the chosen output format."""
  90. m = re.match(rf"<{P}([^>\s]+)> (.*)$", body)
  91. if m:
  92. return f"<{m.group(1)}> {m.group(2)}", "message"
  93. m = re.match(rf"\* Joins: {P}(\S+) \((\S+)\)$", body)
  94. if m:
  95. return (f"*** Joins: {m.group(1)} ({m.group(2)})" if ZNC else f"{m.group(1)} ({m.group(2)}) joined {CH}."), "join"
  96. m = re.match(rf"\* Parts: {P}(\S+) \((\S+)\)(?: \((.*)\))?$", body)
  97. if m:
  98. why = f" ({m.group(3)})" if m.group(3) else ""
  99. return (f"*** Parts: {m.group(1)} ({m.group(2)}){why}" if ZNC else f"{m.group(1)} ({m.group(2)}) left {CH}.{why}"), "part"
  100. m = re.match(rf"\* Quits: {P}(\S+) \((\S+)\) \((.*)\)$", body)
  101. if m:
  102. return (f"*** Quits: {m.group(1)} ({m.group(2)}) ({m.group(3)})" if ZNC else f"{m.group(1)} ({m.group(2)}) left irc: {m.group(3)}"), "quit"
  103. m = re.match(rf"\* {P}(\S+) is now known as {P}(\S+)$", body)
  104. if m:
  105. return (f"*** {m.group(1)} is now known as {m.group(2)}" if ZNC else f"Nick change: {m.group(1)} -> {m.group(2)}"), "nick"
  106. m = re.match(rf"\* {P}(\S+) was kicked from (#\S+) by {P}(\S+) \((.*)\)$", body)
  107. if m:
  108. return (f"*** {m.group(1)} was kicked by {m.group(3)} ({m.group(4)})" if ZNC else f"{m.group(1)} kicked from {CH} by {m.group(3)}: {m.group(4)}"), "kick"
  109. m = re.match(rf"\* You were kicked by {P}(\S+) \((.*)\)$", body)
  110. if m:
  111. return (f"*** {args.nick} was kicked by {m.group(1)} ({m.group(2)})" if ZNC else f"{args.nick} kicked from {CH} by {m.group(1)}: {m.group(2)}"), "kick"
  112. m = re.match(rf"\* {P}(\S+) sets mode: (.+)$", body)
  113. if m:
  114. return (f"*** {m.group(1)} sets mode: {m.group(2)}" if ZNC else f"{CH}: mode change '{m.group(2)}' by {m.group(1)}!*@*"), "mode"
  115. m = re.match(rf"\* {P}(\S+) changes topic to: (.*)$", body)
  116. if m:
  117. return (f"*** {m.group(1)} changes topic to '{m.group(2)}'" if ZNC else f"Topic changed on {CH} by {m.group(1)}!*@*: {m.group(2)}"), "topic"
  118. m = re.match(rf"\* {P}(\S+) (.+)$", body)
  119. if m and not NOT_ACTION.match(body) and m.group(1) in PARTICIPANTS:
  120. if args.show_actions:
  121. actions_seen.append(body)
  122. return (f"* {m.group(1)} {m.group(2)}" if ZNC else f"Action: {m.group(1)} {m.group(2)}"), "action"
  123. return None, "other"
  124. prev = None
  125. raw = Path(args.infile).read_text(encoding="utf-8", errors="replace")
  126. collect_participants(raw)
  127. def derive_first_date(text):
  128. """The log only carries dates in 'Day changed' markers. Work the start date back
  129. from the first marker: every backwards step of the clock before it is a new
  130. session on a later day, so start + steps = marker date - 1."""
  131. steps, last = 0, None
  132. for raw_line in text.split("\n"):
  133. mm = LINE.match(COLOUR.sub("", raw_line.rstrip("\r")))
  134. if not mm:
  135. continue
  136. dd = DAY.match(mm.group(4))
  137. if dd:
  138. first_marker = dt.date(int(dd[3]), MONTHS[dd[2]], int(dd[1]))
  139. return first_marker - dt.timedelta(days=1 + steps)
  140. tod = dt.time(int(mm[1]), int(mm[2]), int(mm[3]))
  141. if last is not None and tod < last:
  142. steps += 1
  143. last = tod
  144. sys.exit("no 'Day changed' marker found; pass --first-date")
  145. local_date = dt.date.fromisoformat(args.first_date) if args.first_date else derive_first_date(raw)
  146. print(f"log starts on local date {local_date}")
  147. prev_tod = None # previous line's local time of day
  148. last_line = None
  149. rollovers = 0
  150. marker_conflicts = 0
  151. for ln in raw.split("\n"):
  152. ln = COLOUR.sub("", ln.rstrip("\r"))
  153. m = LINE.match(ln)
  154. if not m:
  155. if ln.strip():
  156. dropped["unparsed (no timestamp)"] += 1
  157. continue
  158. h, mi, s, body = int(m[1]), int(m[2]), int(m[3]), m[4]
  159. d = DAY.match(body)
  160. if d:
  161. new_date = dt.date(int(d[3]), MONTHS[d[2]], int(d[1]))
  162. if new_date < local_date:
  163. marker_conflicts += 1 # our inferred date had already run ahead
  164. local_date = new_date
  165. prev_tod = dt.time(0, 0, 0)
  166. continue
  167. tod = dt.time(h, mi, s)
  168. if prev_tod is not None and tod < prev_tod:
  169. back = (dt.datetime.combine(dt.date.min, prev_tod) - dt.datetime.combine(dt.date.min, tod)).total_seconds()
  170. if back > JITTER:
  171. # Clock jumped backwards with no "Day changed": a new client session on
  172. # a later day (the client only writes the marker when connected at midnight).
  173. local_date += dt.timedelta(days=1)
  174. rollovers += 1
  175. prev_tod = tod
  176. # else: a second or two of jitter between near-simultaneous events; same day
  177. else:
  178. prev_tod = tod
  179. text, kind = convert(body)
  180. if text is not None and kind != "message" and ln == last_line:
  181. dropped["duplicate state event (two windows logging)"] += 1
  182. continue
  183. last_line = ln
  184. if text is None:
  185. last_line = ln
  186. # (text, kind) handled below
  187. if text is None:
  188. dropped[kind] += 1
  189. continue
  190. local = dt.datetime.combine(local_date, dt.time(h, mi, s), tzinfo=TZ)
  191. utc = local.astimezone(UTC)
  192. if prev and utc < prev - dt.timedelta(seconds=JITTER):
  193. dropped["out-of-order (skipped)"] += 1
  194. if len(order_debug) < 12:
  195. order_debug.append(f"local {local:%Y-%m-%d %H:%M:%S} is before previous kept event {prev.astimezone(TZ):%Y-%m-%d %H:%M:%S}: {body[:60]}")
  196. continue
  197. prev = utc if prev is None else max(prev, utc)
  198. if utc >= CUTOFF:
  199. dropped["after cutoff (eggdrop has these)"] += 1
  200. continue
  201. kept[kind] += 1
  202. events.append((utc, text))
  203. # Group into log days and write.
  204. files = {}
  205. if ZNC:
  206. ZTZ = zoneinfo.ZoneInfo(args.znc_tz)
  207. for utc, text in events:
  208. local_z = utc.astimezone(ZTZ)
  209. files.setdefault(local_z.date(), []).append((local_z, text))
  210. else:
  211. # eggdrop log days run 03:00 to 02:59:59
  212. for utc, text in events:
  213. files.setdefault((utc - ROTATE).date(), []).append((utc, text))
  214. out = Path(args.outdir)
  215. out.mkdir(parents=True, exist_ok=True)
  216. written = skipped = 0
  217. for day in sorted(files):
  218. name = f"{day:%Y-%m-%d}.log" if ZNC else f"{args.prefix}{day:%Y%m%d}"
  219. lines = []
  220. if not ZNC:
  221. first_utc = files[day][0][0]
  222. lines.append(f"[03:00:00] --- {first_utc:%a %b %d %Y}") # self-describing header
  223. cur = files[day][0][0].date()
  224. for utc, text in files[day]:
  225. if not ZNC and utc.date() != cur:
  226. cur = utc.date()
  227. lines.append(f"[00:00:00] --- {utc:%a %b %d %Y}") # midnight, like eggdrop
  228. lines.append(f"[{utc:%H:%M:%S}] {text}")
  229. if args.dry_run:
  230. written += 1
  231. continue
  232. target = out / name
  233. if target.exists():
  234. print(f"SKIP existing {target}")
  235. skipped += 1
  236. continue
  237. with open(target, "x", encoding="utf-8", newline="\n") as fh: # "x": refuse to overwrite
  238. fh.write("\n".join(lines) + "\n")
  239. written += 1
  240. print(f"\nkept {sum(kept.values())} events: {dict(kept)}")
  241. print(f"dropped: {dict(dropped)}")
  242. if events:
  243. print(f"range (UTC): {events[0][0]:%Y-%m-%d %H:%M} .. {events[-1][0]:%Y-%m-%d %H:%M}; {len(files)} log days")
  244. print(f"date rollovers inferred from a backwards clock: {rollovers}; "
  245. f"'Day changed' markers that disagreed with the inferred date: {marker_conflicts}")
  246. print(f"{'would write' if args.dry_run else 'wrote'} {written} files, skipped {skipped}")
  247. if order_debug:
  248. print("\nfirst out-of-order events:\n " + "\n ".join(order_debug))
  249. if args.show_actions:
  250. print("\n--- lines treated as actions ---")
  251. print("\n".join(a[:120] for a in actions_seen))