Skip to content

Commit dcf0fb9

Browse files
authored
Merge pull request #1334 from Nadrieril/improve-bot
Zulip bot: support message edits
2 parents a52daa4 + 645c5cf commit dcf0fb9

1 file changed

Lines changed: 159 additions & 47 deletions

File tree

nix/zulip_bot.nix

Lines changed: 159 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ in
7676
import os
7777
import re
7878
import signal
79+
import sqlite3
7980
import socket
8081
import subprocess
8182
import tempfile
@@ -169,6 +170,7 @@ in
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."
@@ -180,11 +182,17 @@ in
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."
@@ -199,16 +207,46 @@ in
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:
@@ -293,9 +401,13 @@ in
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

Comments
 (0)