7676 import os
7777 import re
7878 import signal
79+ import sqlite3
7980 import socket
8081 import subprocess
8182 import tempfile
169170 returncode = process.wait()
170171
171172 output = ANSI_ESCAPE.sub("", output_path.read_text(errors="replace"))
173+ output = output.removeprefix("# Final LLBC before serialization:\n\n")
172174 truncated = output_path.stat().st_size >= MAX_OUTPUT_BYTES
173175 if timed_out:
174176 output += f"\nCharon was stopped after {timeout} seconds."
180182
181183
182184 def fenced(content):
183- longest_ticks = max((len(run) for run in re.findall(r"`+", content)), default=0)
184- fence = "`" * max(3, longest_ticks + 1)
185+ # Tilde fences keep the Rust block from colliding with the surrounding
186+ # backtick-delimited Zulip spoiler.
187+ longest_tildes = max((len(run) for run in re.findall(r"~+", content)), default=0)
188+ fence = "~" * max(3, longest_tildes + 1)
185189 return f"{fence}rust\n{content}\n{fence}"
186190
187191
192+ def spoiler(content):
193+ return f"```spoiler Charon result\n{content}\n```"
194+
195+
188196 def main():
189197 parser = argparse.ArgumentParser(
190198 description="Run Charon on Rust snippets sent to a Zulip bot."
199207 parser.add_argument(
200208 "--cooldown",
201209 type=int,
202- default=10,
203- help="minimum time between requests by one user in seconds (default: 10)",
210+ default=5,
211+ help="minimum time between requests by one user in seconds (default: 5)",
212+ )
213+ parser.add_argument(
214+ "--database",
215+ type=Path,
216+ help=(
217+ "path to the response database (default: responses.sqlite3 in "
218+ "$STATE_DIRECTORY, $XDG_STATE_HOME/charon-zulip-bot, or "
219+ "~/.local/state/charon-zulip-bot)"
220+ ),
204221 )
205222 args = parser.parse_args()
206223 if args.timeout <= 0:
207224 parser.error("--timeout must be positive")
208225 if args.cooldown < 0:
209226 parser.error("--cooldown must be non-negative")
210227
228+ if args.database is None:
229+ if state_directory := os.environ.get("STATE_DIRECTORY"):
230+ database_directory = Path(state_directory.split(":", 1)[0])
231+ elif xdg_state_home := os.environ.get("XDG_STATE_HOME"):
232+ database_directory = Path(xdg_state_home) / "charon-zulip-bot"
233+ else:
234+ database_directory = Path.home() / ".local/state/charon-zulip-bot"
235+ args.database = database_directory / "responses.sqlite3"
236+ args.database.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
237+ database = sqlite3.connect(args.database)
238+ database.execute("""
239+ CREATE TABLE IF NOT EXISTS responses (
240+ source_message_id INTEGER PRIMARY KEY,
241+ response_message_id INTEGER NOT NULL,
242+ sender_id INTEGER NOT NULL
243+ )
244+ """)
245+ database.commit()
246+ args.database.chmod(0o600)
247+
211248 logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
249+ logging.info("using response database %s", args.database)
212250 client = ZulipClientWithSystemdNotification(config_file=str(args.zuliprc), client="CharonZulipBot")
213251 profile = client.get_profile()
214252 if profile.get("result") != "success":
@@ -228,61 +266,131 @@ in
228266 result = client.send_message(reply_request(message, content))
229267 if result.get("result") != "success":
230268 logging.error("failed to send Zulip reply: %s", result.get("msg", result))
269+ return None
270+ return result["id"]
271+
272+ def is_admin(sender_id):
273+ user_result = client.get_user_by_id(sender_id)
274+ if user_result.get("result") != "success":
275+ raise RuntimeError(user_result.get("msg", "unknown Zulip API error"))
276+ user = user_result["user"]
277+ return user.get("is_active") and user.get("role") in ADMIN_ROLES
278+
279+ def response_content(content):
280+ try:
281+ source = extract_source(content)
282+ except ValueError as error:
283+ return str(error)
284+
285+ returncode, output = run_charon(source, args.timeout)
286+ heading = None if returncode == 0 else f"Charon failed (exit status {returncode}):"
287+ if len(output) <= MAX_INLINE_OUTPUT:
288+ content = fenced(output)
289+ return content if heading is None else f"{heading}\n\n{content}"
290+
291+ with tempfile.NamedTemporaryFile(
292+ mode="w+", encoding="utf-8", suffix="-charon-output.txt"
293+ ) as output_file:
294+ output_file.write(output)
295+ output_file.flush()
296+ output_file.seek(0)
297+ upload = client.upload_file(output_file)
298+ if upload.get("result") != "success":
299+ raise RuntimeError(upload.get("msg", "failed to upload Charon output"))
300+ content = f"[full output]({upload['url']})"
301+ return content if heading is None else f"{heading} {content}"
231302
232303 def handle_event(event):
233304 nonlocal handling_message
234305 if shutdown_requested:
235306 raise ShutdownRequested
236307 handling_message = True
237308 try:
238- message = event["message"]
239- if message["sender_id"] == bot_user_id:
240- return
241- if message["type"] == "stream" and "mentioned" not in event.get("flags", []):
309+ if event["type"] == "message":
310+ message = event["message"]
311+ if message["sender_id"] == bot_user_id:
312+ return
313+ if message["type"] == "stream" and "mentioned" not in event.get("flags", []):
314+ return
315+
316+ try:
317+ if not is_admin(message["sender_id"]):
318+ send_reply(
319+ message,
320+ "Only organization owners and administrators can run Charon.",
321+ )
322+ return
323+
324+ now = time.monotonic()
325+ retry_after = args.cooldown - (
326+ now - last_request_by_user.get(message["sender_id"], -math.inf)
327+ )
328+ if retry_after > 0:
329+ send_reply(
330+ message,
331+ f"Please wait {math.ceil(retry_after)} seconds before running Charon again.",
332+ )
333+ return
334+ last_request_by_user[message["sender_id"]] = now
335+
336+ content = spoiler(response_content(message["content"]))
337+ except Exception:
338+ logging.exception("failed to process Zulip message %s", message.get("id"))
339+ content = "The Charon bot encountered an internal error."
340+
341+ response_message_id = send_reply(message, content)
342+ if response_message_id is not None:
343+ with database:
344+ database.execute(
345+ """
346+ INSERT INTO responses (
347+ source_message_id, response_message_id, sender_id
348+ ) VALUES (?, ?, ?)
349+ ON CONFLICT(source_message_id) DO UPDATE SET
350+ response_message_id = excluded.response_message_id,
351+ sender_id = excluded.sender_id
352+ """,
353+ (message["id"], response_message_id, message["sender_id"]),
354+ )
242355 return
243356
244- try:
245- user_result = client.get_user_by_id(message["sender_id"])
246- if user_result.get("result") != "success":
247- raise RuntimeError(user_result.get("msg", "unknown Zulip API error"))
248- user = user_result["user"]
249- if not user.get("is_active") or user.get("role") not in ADMIN_ROLES:
250- send_reply(message, "Only organization owners and administrators can run Charon.")
357+ if event["type"] == "update_message":
358+ if event.get("rendering_only") or "content" not in event:
359+ return
360+ mapping = database.execute(
361+ """
362+ SELECT response_message_id, sender_id
363+ FROM responses
364+ WHERE source_message_id = ?
365+ """,
366+ (event["message_id"],),
367+ ).fetchone()
368+ if mapping is None:
251369 return
370+ response_message_id, sender_id = mapping
252371
253- now = time.monotonic()
254- retry_after = args.cooldown - (
255- now - last_request_by_user.get(message["sender_id"], -math.inf)
372+ try:
373+ if not is_admin(sender_id):
374+ content = "Only organization owners and administrators can run Charon."
375+ else:
376+ # Edits replace an existing request, so they deliberately
377+ # bypass the cooldown for new requests.
378+ content = spoiler(response_content(event["content"]))
379+ except Exception:
380+ logging.exception(
381+ "failed to process edited Zulip message %s", event["message_id"]
382+ )
383+ content = "The Charon bot encountered an internal error."
384+
385+ result = client.update_message(
386+ {"message_id": response_message_id, "content": content}
256387 )
257- if retry_after > 0:
258- send_reply(
259- message,
260- f"Please wait {math.ceil(retry_after)} seconds before running Charon again.",
388+ if result.get("result") != "success":
389+ logging.error(
390+ "failed to update Zulip response %s: %s",
391+ response_message_id,
392+ result.get("msg", result),
261393 )
262- return
263- last_request_by_user[message["sender_id"]] = now
264-
265- source = extract_source(message["content"])
266- returncode, output = run_charon(source, args.timeout)
267- heading = "Charon succeeded:" if returncode == 0 else f"Charon failed (exit status {returncode}):"
268- if len(output) <= MAX_INLINE_OUTPUT:
269- send_reply(message, f"{heading}\n\n{fenced(output)}")
270- else:
271- with tempfile.NamedTemporaryFile(
272- mode="w+", encoding="utf-8", suffix="-charon-output.txt"
273- ) as output_file:
274- output_file.write(output)
275- output_file.flush()
276- output_file.seek(0)
277- upload = client.upload_file(output_file)
278- if upload.get("result") != "success":
279- raise RuntimeError(upload.get("msg", "failed to upload Charon output"))
280- send_reply(message, f"{heading} [full output]({upload['url']})")
281- except ValueError as error:
282- send_reply(message, str(error))
283- except Exception:
284- logging.exception("failed to process Zulip message %s", message.get("id"))
285- send_reply(message, "The Charon bot encountered an internal error.")
286394 finally:
287395 handling_message = False
288396 if shutdown_requested:
293401 # stop processing any new ones and shut down.
294402 signal.signal(signal.SIGTERM, request_shutdown)
295403 logging.info("waiting for Zulip messages as %s", profile["full_name"])
296- client.call_on_each_event(handle_event, event_types=["message"])
404+ client.call_on_each_event(
405+ handle_event, event_types=["message", "update_message"]
406+ )
297407 except ShutdownRequested:
298408 logging.info("shutdown requested; all active work is complete")
409+ finally:
410+ database.close()
299411
300412
301413 if __name__ == "__main__":
0 commit comments