zulipcli 0.6__tar.gz → 0.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {zulipcli-0.6 → zulipcli-0.7}/PKG-INFO +1 -1
- {zulipcli-0.6 → zulipcli-0.7}/pyproject.toml +1 -1
- {zulipcli-0.6 → zulipcli-0.7}/src/zulipcli/cli.nw +1042 -27
- {zulipcli-0.6 → zulipcli-0.7}/LICENSE +0 -0
- {zulipcli-0.6 → zulipcli-0.7}/README.md +0 -0
- {zulipcli-0.6 → zulipcli-0.7}/src/zulipcli/Makefile +0 -0
- {zulipcli-0.6 → zulipcli-0.7}/src/zulipcli/init.nw +0 -0
|
@@ -24,6 +24,7 @@ stay flat because each speaks to a different Zulip concept:
|
|
|
24
24
|
\item[streams] List available streams.
|
|
25
25
|
\item[topics] List the topics inside a stream.
|
|
26
26
|
\item[read] Read the history of a stream or one topic within it.
|
|
27
|
+
\item[bot] Run a long-lived worker whenever unread messages exist.
|
|
27
28
|
\end{description}
|
|
28
29
|
|
|
29
30
|
We keep the subcommand pattern familiar from Git and the
|
|
@@ -43,7 +44,9 @@ from datetime import datetime, timezone
|
|
|
43
44
|
from enum import Enum
|
|
44
45
|
from pathlib import Path
|
|
45
46
|
import re
|
|
47
|
+
import subprocess
|
|
46
48
|
import sys
|
|
49
|
+
import time
|
|
47
50
|
from types import SimpleNamespace
|
|
48
51
|
from typing import Annotated, Optional
|
|
49
52
|
|
|
@@ -406,6 +409,7 @@ def test_cli_shows_help_without_command():
|
|
|
406
409
|
result = runner.invoke(app, [])
|
|
407
410
|
assert result.exit_code == 0
|
|
408
411
|
assert "Commands" in result.output
|
|
412
|
+
assert "bot" in result.output
|
|
409
413
|
assert "read" in result.output
|
|
410
414
|
@
|
|
411
415
|
|
|
@@ -600,8 +604,8 @@ Grouping those verbs under a single [[users]] command family keeps
|
|
|
600
604
|
the top-level namespace tidy and makes the relationship obvious from
|
|
601
605
|
the help output.
|
|
602
606
|
The flat verbs that follow ([[send]], [[dm]], [[streams]], [[topics]],
|
|
603
|
-
[[read]]) each speak to a different Zulip concept and do not
|
|
604
|
-
anything from further nesting.
|
|
607
|
+
[[read]], [[bot]]) each speak to a different Zulip concept and do not
|
|
608
|
+
gain anything from further nesting.
|
|
605
609
|
|
|
606
610
|
We declare the group up-front so each per-verb handler below can
|
|
607
611
|
attach itself via [[@users_app.command(...)]] as it is introduced.
|
|
@@ -3192,6 +3196,920 @@ def _read_handler(
|
|
|
3192
3196
|
)
|
|
3193
3197
|
@
|
|
3194
3198
|
|
|
3199
|
+
\section{The \texttt{bot} subcommand}
|
|
3200
|
+
|
|
3201
|
+
The old cron-driven pattern reran a helper on a timer and let that
|
|
3202
|
+
helper ask Zulip whether anything unread had appeared.
|
|
3203
|
+
That is simple, but it wakes an expensive worker even when nothing has
|
|
3204
|
+
changed.
|
|
3205
|
+
The [[bot]] command keeps one process online instead: it maintains a
|
|
3206
|
+
local set of unread message IDs and only launches the worker when that
|
|
3207
|
+
set is non-empty.
|
|
3208
|
+
|
|
3209
|
+
The local unread set matters because a message event is only evidence
|
|
3210
|
+
that the message was new at one point.
|
|
3211
|
+
By the time the worker exits, that same message may already have been
|
|
3212
|
+
consumed by [[zulipcli read -U --mark-as-read]] or deleted.
|
|
3213
|
+
We therefore watch every event that changes unreadness for the current
|
|
3214
|
+
account, reconcile them into one set, and decide whether to rerun only
|
|
3215
|
+
after draining the queue.
|
|
3216
|
+
|
|
3217
|
+
The worker command belongs to some other program, not to [[zulipcli]].
|
|
3218
|
+
We therefore configure Click to stop parsing after [[bot]] and pass the
|
|
3219
|
+
remaining arguments through untouched.
|
|
3220
|
+
That keeps worker flags available without forcing this command to know
|
|
3221
|
+
anything about them.
|
|
3222
|
+
|
|
3223
|
+
<<constants>>=
|
|
3224
|
+
BOT_CONTEXT_SETTINGS = {
|
|
3225
|
+
"allow_extra_args": True,
|
|
3226
|
+
"ignore_unknown_options": True,
|
|
3227
|
+
}
|
|
3228
|
+
BOT_EVENT_TYPES = [
|
|
3229
|
+
"message",
|
|
3230
|
+
"update_message_flags",
|
|
3231
|
+
"delete_message",
|
|
3232
|
+
"restart",
|
|
3233
|
+
]
|
|
3234
|
+
BOT_INITIAL_RETRY_SECONDS = 60
|
|
3235
|
+
BOT_MAX_RETRY_SECONDS = 60 * 60
|
|
3236
|
+
@
|
|
3237
|
+
|
|
3238
|
+
<<functions>>=
|
|
3239
|
+
def _bot_handler(ctx: typer.Context):
|
|
3240
|
+
"""Run a worker whenever unread messages are pending."""
|
|
3241
|
+
command = list(ctx.args)
|
|
3242
|
+
if not command:
|
|
3243
|
+
_exit_with_error("bot requires a worker command")
|
|
3244
|
+
bot_command(
|
|
3245
|
+
_get_client_from_context(ctx),
|
|
3246
|
+
SimpleNamespace(command=command),
|
|
3247
|
+
)
|
|
3248
|
+
@
|
|
3249
|
+
|
|
3250
|
+
Let's verify that the CLI passes the worker command through untouched,
|
|
3251
|
+
including flags that belong to the worker rather than to [[zulipcli]].
|
|
3252
|
+
|
|
3253
|
+
<<test functions>>=
|
|
3254
|
+
def test_bot_cli_passes_worker_command(monkeypatch):
|
|
3255
|
+
seen = {}
|
|
3256
|
+
|
|
3257
|
+
def fake_bot_command(client, args):
|
|
3258
|
+
seen["client"] = client
|
|
3259
|
+
seen["args"] = args
|
|
3260
|
+
|
|
3261
|
+
monkeypatch.setattr(cli, "bot_command", fake_bot_command)
|
|
3262
|
+
monkeypatch.setattr(
|
|
3263
|
+
cli,
|
|
3264
|
+
"_get_client_from_context",
|
|
3265
|
+
lambda ctx: "client",
|
|
3266
|
+
)
|
|
3267
|
+
|
|
3268
|
+
result = runner.invoke(
|
|
3269
|
+
app,
|
|
3270
|
+
["bot", "--", "worker", "--flag", "value"],
|
|
3271
|
+
)
|
|
3272
|
+
|
|
3273
|
+
assert result.exit_code == 0
|
|
3274
|
+
assert seen["client"] == "client"
|
|
3275
|
+
assert seen["args"].command == ["worker", "--flag", "value"]
|
|
3276
|
+
@
|
|
3277
|
+
|
|
3278
|
+
Without a worker command, the bot has nothing useful to do.
|
|
3279
|
+
We should reject that case with the same user-facing error path as the
|
|
3280
|
+
other commands.
|
|
3281
|
+
|
|
3282
|
+
<<test functions>>=
|
|
3283
|
+
def test_bot_cli_requires_worker_command():
|
|
3284
|
+
result = runner.invoke(app, ["bot"])
|
|
3285
|
+
|
|
3286
|
+
assert result.exit_code == 1
|
|
3287
|
+
assert "Error: bot requires a worker command" in result.output
|
|
3288
|
+
@
|
|
3289
|
+
|
|
3290
|
+
Registering the queue gives us an initial snapshot of unread messages.
|
|
3291
|
+
When that snapshot is complete, we can seed the bot's unread set without
|
|
3292
|
+
any extra requests.
|
|
3293
|
+
|
|
3294
|
+
<<functions>>=
|
|
3295
|
+
def _unread_message_ids_from_snapshot(unread_msgs):
|
|
3296
|
+
"""Return unread message IDs from a register snapshot."""
|
|
3297
|
+
message_ids = set()
|
|
3298
|
+
for key in ("streams", "pms", "huddles"):
|
|
3299
|
+
for entry in unread_msgs.get(key, []):
|
|
3300
|
+
message_ids.update(entry.get("unread_message_ids", []))
|
|
3301
|
+
return message_ids
|
|
3302
|
+
@
|
|
3303
|
+
|
|
3304
|
+
The register snapshot is capped at Zulip's most recent unread messages.
|
|
3305
|
+
When the server tells us older unread messages are missing, a lower-bound
|
|
3306
|
+
snapshot is not good enough: the bot would make rerun decisions on stale
|
|
3307
|
+
or incomplete data.
|
|
3308
|
+
In that case we page through unread history ourselves.
|
|
3309
|
+
One query covers the account's personal history, while another covers
|
|
3310
|
+
shared public-channel history.
|
|
3311
|
+
Merging those results keeps the bot's fallback aligned with the live
|
|
3312
|
+
queue's [[all_public_streams]] scope.
|
|
3313
|
+
|
|
3314
|
+
<<functions>>=
|
|
3315
|
+
def _get_messages_for_narrow(client, narrow):
|
|
3316
|
+
"""Return all messages matching ``narrow`` from oldest to newest."""
|
|
3317
|
+
request = {
|
|
3318
|
+
"anchor": 0,
|
|
3319
|
+
"num_before": 0,
|
|
3320
|
+
"num_after": HISTORY_BATCH_SIZE,
|
|
3321
|
+
"narrow": list(narrow),
|
|
3322
|
+
"client_gravatar": False,
|
|
3323
|
+
"apply_markdown": False,
|
|
3324
|
+
}
|
|
3325
|
+
messages = []
|
|
3326
|
+
found_newest = False
|
|
3327
|
+
while not found_newest:
|
|
3328
|
+
result = _require_success(client.get_messages(request))
|
|
3329
|
+
found_newest = result["found_newest"]
|
|
3330
|
+
if result["messages"]:
|
|
3331
|
+
messages.extend(result["messages"])
|
|
3332
|
+
request["anchor"] = result["messages"][-1]["id"] + 1
|
|
3333
|
+
return messages
|
|
3334
|
+
@
|
|
3335
|
+
|
|
3336
|
+
Zulip's plain [[is:unread]] search only covers the account's personal
|
|
3337
|
+
history.
|
|
3338
|
+
Shared public-channel history needs an explicit [[channels public]]
|
|
3339
|
+
clause.
|
|
3340
|
+
We therefore merge both unread searches, deduplicate by message ID, and
|
|
3341
|
+
sort by ID so callers see one oldest-to-newest stream of visible unread
|
|
3342
|
+
messages.
|
|
3343
|
+
|
|
3344
|
+
<<functions>>=
|
|
3345
|
+
def _get_all_visible_unread_messages(client):
|
|
3346
|
+
"""Return all visible unread messages from oldest to newest."""
|
|
3347
|
+
messages_by_id = {}
|
|
3348
|
+
for narrow in (
|
|
3349
|
+
[{"operator": "is", "operand": "unread"}],
|
|
3350
|
+
[
|
|
3351
|
+
{"operator": "channels", "operand": "public"},
|
|
3352
|
+
{"operator": "is", "operand": "unread"},
|
|
3353
|
+
],
|
|
3354
|
+
):
|
|
3355
|
+
for message in _get_messages_for_narrow(client, narrow):
|
|
3356
|
+
messages_by_id[message["id"]] = message
|
|
3357
|
+
return [
|
|
3358
|
+
messages_by_id[message_id]
|
|
3359
|
+
for message_id in sorted(messages_by_id)
|
|
3360
|
+
]
|
|
3361
|
+
@
|
|
3362
|
+
|
|
3363
|
+
<<functions>>=
|
|
3364
|
+
def _get_all_unread_message_ids(client):
|
|
3365
|
+
"""Return the IDs of all visible unread messages."""
|
|
3366
|
+
return {
|
|
3367
|
+
message["id"]
|
|
3368
|
+
for message in _get_all_visible_unread_messages(client)
|
|
3369
|
+
}
|
|
3370
|
+
@
|
|
3371
|
+
|
|
3372
|
+
The queue registration helper combines those two pieces: use the cheap
|
|
3373
|
+
snapshot when it is complete, and fall back to the exact unread scan
|
|
3374
|
+
only when the server reports truncation.
|
|
3375
|
+
It also asks Zulip for message events from all public streams, because
|
|
3376
|
+
the bot's scope is every message visible to the account, not just
|
|
3377
|
+
streams the account is subscribed to.
|
|
3378
|
+
|
|
3379
|
+
<<functions>>=
|
|
3380
|
+
def _register_bot_queue(client):
|
|
3381
|
+
"""Register the bot queue and seed its unread state."""
|
|
3382
|
+
result = _require_success(client.register(
|
|
3383
|
+
event_types=BOT_EVENT_TYPES,
|
|
3384
|
+
all_public_streams=True,
|
|
3385
|
+
client_gravatar=False,
|
|
3386
|
+
))
|
|
3387
|
+
unread_msgs = result.get("unread_msgs", {})
|
|
3388
|
+
pending_unread_ids = _unread_message_ids_from_snapshot(unread_msgs)
|
|
3389
|
+
if unread_msgs.get("old_unreads_missing", False):
|
|
3390
|
+
pending_unread_ids = _get_all_unread_message_ids(client)
|
|
3391
|
+
return result["queue_id"], result["last_event_id"], pending_unread_ids
|
|
3392
|
+
@
|
|
3393
|
+
|
|
3394
|
+
Let's verify that truncated snapshots really do trigger the exact unread
|
|
3395
|
+
scan, rather than leaving the bot to reason from an incomplete set.
|
|
3396
|
+
|
|
3397
|
+
<<test functions>>=
|
|
3398
|
+
def test_register_bot_queue_rebuilds_truncated_unreads():
|
|
3399
|
+
client = MagicMock()
|
|
3400
|
+
client.register.return_value = _make_bot_register_result(
|
|
3401
|
+
unread_msgs={
|
|
3402
|
+
"old_unreads_missing": True,
|
|
3403
|
+
"streams": [
|
|
3404
|
+
{
|
|
3405
|
+
"stream_id": 17,
|
|
3406
|
+
"topic": "Announcements",
|
|
3407
|
+
"unread_message_ids": [1],
|
|
3408
|
+
},
|
|
3409
|
+
],
|
|
3410
|
+
"pms": [],
|
|
3411
|
+
"huddles": [],
|
|
3412
|
+
},
|
|
3413
|
+
)
|
|
3414
|
+
responses = [
|
|
3415
|
+
{
|
|
3416
|
+
"result": "success",
|
|
3417
|
+
"found_newest": True,
|
|
3418
|
+
"messages": [{"id": 1}, {"id": 2}],
|
|
3419
|
+
},
|
|
3420
|
+
{
|
|
3421
|
+
"result": "success",
|
|
3422
|
+
"found_newest": True,
|
|
3423
|
+
"messages": [{"id": 2}, {"id": 3}],
|
|
3424
|
+
},
|
|
3425
|
+
]
|
|
3426
|
+
requests = []
|
|
3427
|
+
|
|
3428
|
+
def get_messages(request):
|
|
3429
|
+
requests.append(copy.deepcopy(request))
|
|
3430
|
+
return responses.pop(0)
|
|
3431
|
+
|
|
3432
|
+
client.get_messages.side_effect = get_messages
|
|
3433
|
+
|
|
3434
|
+
queue_id, last_event_id, pending_unread_ids = cli._register_bot_queue(
|
|
3435
|
+
client
|
|
3436
|
+
)
|
|
3437
|
+
|
|
3438
|
+
assert queue_id == "queue-123"
|
|
3439
|
+
assert last_event_id == 0
|
|
3440
|
+
assert pending_unread_ids == {1, 2, 3}
|
|
3441
|
+
assert requests[0]["narrow"] == [
|
|
3442
|
+
{"operator": "is", "operand": "unread"},
|
|
3443
|
+
]
|
|
3444
|
+
assert requests[1]["narrow"] == [
|
|
3445
|
+
{"operator": "channels", "operand": "public"},
|
|
3446
|
+
{"operator": "is", "operand": "unread"},
|
|
3447
|
+
]
|
|
3448
|
+
@
|
|
3449
|
+
|
|
3450
|
+
The registration request itself should include Zulip's
|
|
3451
|
+
[[all_public_streams]] option so the long-lived queue keeps receiving
|
|
3452
|
+
message events from public streams that are visible through shared
|
|
3453
|
+
history.
|
|
3454
|
+
|
|
3455
|
+
<<test functions>>=
|
|
3456
|
+
def test_register_bot_queue_requests_all_public_streams():
|
|
3457
|
+
client = MagicMock()
|
|
3458
|
+
client.register.return_value = _make_bot_register_result()
|
|
3459
|
+
|
|
3460
|
+
cli._register_bot_queue(client)
|
|
3461
|
+
|
|
3462
|
+
client.register.assert_called_once_with(
|
|
3463
|
+
event_types=cli.BOT_EVENT_TYPES,
|
|
3464
|
+
all_public_streams=True,
|
|
3465
|
+
client_gravatar=False,
|
|
3466
|
+
)
|
|
3467
|
+
@
|
|
3468
|
+
|
|
3469
|
+
Once the bot is running, Zulip may tell us that a message vanished
|
|
3470
|
+
instead of merely changing flags.
|
|
3471
|
+
Removing those IDs from the pending set avoids a pointless rerun for a
|
|
3472
|
+
message that no longer exists or is no longer visible.
|
|
3473
|
+
|
|
3474
|
+
<<functions>>=
|
|
3475
|
+
def _deleted_message_ids(event):
|
|
3476
|
+
"""Return deleted message IDs from a delete-message event."""
|
|
3477
|
+
if "message_ids" in event:
|
|
3478
|
+
return event["message_ids"]
|
|
3479
|
+
if "message_id" in event:
|
|
3480
|
+
return [event["message_id"]]
|
|
3481
|
+
return []
|
|
3482
|
+
@
|
|
3483
|
+
|
|
3484
|
+
Unread tracking is just a small state machine over the queue events.
|
|
3485
|
+
Message events add unread IDs when Zulip says the message is still
|
|
3486
|
+
unread, flag updates add or remove IDs as the read state changes, and a
|
|
3487
|
+
restart event tells us to throw away local assumptions and resynchronize
|
|
3488
|
+
from a fresh register snapshot.
|
|
3489
|
+
|
|
3490
|
+
<<functions>>=
|
|
3491
|
+
def _apply_bot_event(pending_unread_ids, event):
|
|
3492
|
+
"""Apply one event to the bot's unread state.
|
|
3493
|
+
|
|
3494
|
+
Return [[True]] when the caller should resynchronize from register.
|
|
3495
|
+
"""
|
|
3496
|
+
event_type = event["type"]
|
|
3497
|
+
if event_type == "message":
|
|
3498
|
+
if "read" not in event.get("flags", []):
|
|
3499
|
+
pending_unread_ids.add(event["message"]["id"])
|
|
3500
|
+
return False
|
|
3501
|
+
if event_type == "update_message_flags" and event.get("flag") == "read":
|
|
3502
|
+
op = event.get("op", event.get("operation"))
|
|
3503
|
+
if op == "add":
|
|
3504
|
+
if event.get("all"):
|
|
3505
|
+
pending_unread_ids.clear()
|
|
3506
|
+
else:
|
|
3507
|
+
pending_unread_ids.difference_update(
|
|
3508
|
+
event.get("messages", [])
|
|
3509
|
+
)
|
|
3510
|
+
elif op == "remove":
|
|
3511
|
+
pending_unread_ids.update(event.get("messages", []))
|
|
3512
|
+
return False
|
|
3513
|
+
if event_type == "delete_message":
|
|
3514
|
+
pending_unread_ids.difference_update(_deleted_message_ids(event))
|
|
3515
|
+
return False
|
|
3516
|
+
return event_type == "restart"
|
|
3517
|
+
@
|
|
3518
|
+
|
|
3519
|
+
Batch application keeps the polling helpers simple: they can hand us a
|
|
3520
|
+
whole Zulip response and ask only whether that batch forced a full
|
|
3521
|
+
resynchronization.
|
|
3522
|
+
|
|
3523
|
+
<<functions>>=
|
|
3524
|
+
def _apply_bot_events(pending_unread_ids, events):
|
|
3525
|
+
"""Apply a batch of bot events.
|
|
3526
|
+
|
|
3527
|
+
Return [[True]] when the caller should resynchronize from register.
|
|
3528
|
+
"""
|
|
3529
|
+
for event in events:
|
|
3530
|
+
if _apply_bot_event(pending_unread_ids, event):
|
|
3531
|
+
return True
|
|
3532
|
+
return False
|
|
3533
|
+
@
|
|
3534
|
+
|
|
3535
|
+
Queue expiry is a normal condition for long-running workers.
|
|
3536
|
+
We therefore treat [[BAD_EVENT_QUEUE_ID]] as a resynchronization signal
|
|
3537
|
+
instead of as a fatal error, while still reporting every other Zulip API
|
|
3538
|
+
failure through the usual command-line path.
|
|
3539
|
+
|
|
3540
|
+
<<functions>>=
|
|
3541
|
+
def _get_bot_events(client, queue_id, last_event_id, *, dont_block=False):
|
|
3542
|
+
"""Return one batch of bot events.
|
|
3543
|
+
|
|
3544
|
+
The third return value reports whether the queue must be recreated.
|
|
3545
|
+
"""
|
|
3546
|
+
result = client.get_events(
|
|
3547
|
+
queue_id=queue_id,
|
|
3548
|
+
last_event_id=last_event_id,
|
|
3549
|
+
dont_block=dont_block,
|
|
3550
|
+
)
|
|
3551
|
+
if result.get("result") == "success":
|
|
3552
|
+
events = result.get("events", [])
|
|
3553
|
+
if events:
|
|
3554
|
+
last_event_id = events[-1]["id"]
|
|
3555
|
+
return events, last_event_id, False
|
|
3556
|
+
if result.get("code") == "BAD_EVENT_QUEUE_ID":
|
|
3557
|
+
return [], last_event_id, True
|
|
3558
|
+
_exit_with_error(result.get("msg", "unknown Zulip API error"))
|
|
3559
|
+
@
|
|
3560
|
+
|
|
3561
|
+
After a worker run we only care about the backlog already sitting in the
|
|
3562
|
+
queue.
|
|
3563
|
+
Non-blocking reads let us empty that backlog completely before deciding
|
|
3564
|
+
whether another worker run is actually necessary.
|
|
3565
|
+
|
|
3566
|
+
<<functions>>=
|
|
3567
|
+
def _drain_bot_events(client, queue_id, last_event_id, pending_unread_ids):
|
|
3568
|
+
"""Drain all currently queued bot events.
|
|
3569
|
+
|
|
3570
|
+
Return the new [[last_event_id]] and whether the queue must be
|
|
3571
|
+
recreated.
|
|
3572
|
+
"""
|
|
3573
|
+
while True:
|
|
3574
|
+
events, last_event_id, needs_resync = _get_bot_events(
|
|
3575
|
+
client,
|
|
3576
|
+
queue_id,
|
|
3577
|
+
last_event_id,
|
|
3578
|
+
dont_block=True,
|
|
3579
|
+
)
|
|
3580
|
+
if needs_resync or _apply_bot_events(pending_unread_ids, events):
|
|
3581
|
+
return last_event_id, True
|
|
3582
|
+
if not events:
|
|
3583
|
+
return last_event_id, False
|
|
3584
|
+
@
|
|
3585
|
+
|
|
3586
|
+
The main loop has two polling modes: block while idle, but drain without
|
|
3587
|
+
blocking after a worker run or a retry sleep.
|
|
3588
|
+
This helper hides that difference and recreates the queue whenever the
|
|
3589
|
+
existing one can no longer provide a trustworthy stream of events.
|
|
3590
|
+
|
|
3591
|
+
<<functions>>=
|
|
3592
|
+
def _refresh_bot_queue(
|
|
3593
|
+
client,
|
|
3594
|
+
queue_id,
|
|
3595
|
+
last_event_id,
|
|
3596
|
+
pending_unread_ids,
|
|
3597
|
+
*,
|
|
3598
|
+
dont_block,
|
|
3599
|
+
):
|
|
3600
|
+
"""Apply queued events, recreating the queue when needed."""
|
|
3601
|
+
if dont_block:
|
|
3602
|
+
last_event_id, needs_resync = _drain_bot_events(
|
|
3603
|
+
client,
|
|
3604
|
+
queue_id,
|
|
3605
|
+
last_event_id,
|
|
3606
|
+
pending_unread_ids,
|
|
3607
|
+
)
|
|
3608
|
+
else:
|
|
3609
|
+
events, last_event_id, needs_resync = _get_bot_events(
|
|
3610
|
+
client,
|
|
3611
|
+
queue_id,
|
|
3612
|
+
last_event_id,
|
|
3613
|
+
)
|
|
3614
|
+
if not needs_resync:
|
|
3615
|
+
needs_resync = _apply_bot_events(pending_unread_ids, events)
|
|
3616
|
+
if needs_resync:
|
|
3617
|
+
return _register_bot_queue(client)
|
|
3618
|
+
return queue_id, last_event_id, pending_unread_ids
|
|
3619
|
+
@
|
|
3620
|
+
|
|
3621
|
+
The retry schedule should grow quickly enough to avoid hammering a broken
|
|
3622
|
+
worker, but still stay bounded so the bot does not appear dead.
|
|
3623
|
+
We therefore double the delay from one minute upward and cap it at one
|
|
3624
|
+
hour.
|
|
3625
|
+
|
|
3626
|
+
<<functions>>=
|
|
3627
|
+
def _bot_retry_delay_seconds(failed_runs):
|
|
3628
|
+
"""Return the retry delay for consecutive failed worker runs."""
|
|
3629
|
+
return min(
|
|
3630
|
+
BOT_INITIAL_RETRY_SECONDS * 2 ** max(0, failed_runs - 1),
|
|
3631
|
+
BOT_MAX_RETRY_SECONDS,
|
|
3632
|
+
)
|
|
3633
|
+
@
|
|
3634
|
+
|
|
3635
|
+
Let's verify both the doubling pattern and the one-hour cap.
|
|
3636
|
+
|
|
3637
|
+
<<test functions>>=
|
|
3638
|
+
def test_bot_retry_delay_doubles_and_caps_at_one_hour():
|
|
3639
|
+
assert cli._bot_retry_delay_seconds(1) == 60
|
|
3640
|
+
assert cli._bot_retry_delay_seconds(2) == 120
|
|
3641
|
+
assert cli._bot_retry_delay_seconds(3) == 240
|
|
3642
|
+
assert cli._bot_retry_delay_seconds(4) == 480
|
|
3643
|
+
assert cli._bot_retry_delay_seconds(6) == 1920
|
|
3644
|
+
assert cli._bot_retry_delay_seconds(7) == 3600
|
|
3645
|
+
assert cli._bot_retry_delay_seconds(8) == 3600
|
|
3646
|
+
@
|
|
3647
|
+
|
|
3648
|
+
Spawning the worker is the only place where the bot touches the local
|
|
3649
|
+
process table.
|
|
3650
|
+
Treating startup failures as ordinary command-line errors gives the user
|
|
3651
|
+
an immediate, actionable message instead of leaving the bot in a useless
|
|
3652
|
+
retry loop.
|
|
3653
|
+
|
|
3654
|
+
<<functions>>=
|
|
3655
|
+
def _run_bot_worker(command):
|
|
3656
|
+
"""Run the configured worker command."""
|
|
3657
|
+
try:
|
|
3658
|
+
return subprocess.run(command)
|
|
3659
|
+
except OSError as error:
|
|
3660
|
+
_exit_with_error(
|
|
3661
|
+
f"could not start worker command {command[0]!r}: {error}"
|
|
3662
|
+
)
|
|
3663
|
+
@
|
|
3664
|
+
|
|
3665
|
+
Queue deletion is only best-effort.
|
|
3666
|
+
If cleanup itself fails, Zulip will still garbage-collect the queue after
|
|
3667
|
+
its idle timeout.
|
|
3668
|
+
|
|
3669
|
+
<<functions>>=
|
|
3670
|
+
def _discard_queue(client, queue_id):
|
|
3671
|
+
"""Best-effort deregistration of a bot queue."""
|
|
3672
|
+
if queue_id:
|
|
3673
|
+
try:
|
|
3674
|
+
client.deregister(queue_id)
|
|
3675
|
+
except Exception:
|
|
3676
|
+
pass
|
|
3677
|
+
@
|
|
3678
|
+
|
|
3679
|
+
The main loop is now straightforward to describe:
|
|
3680
|
+
wait until the unread set becomes non-empty, run the worker exactly once,
|
|
3681
|
+
drain the queue to reconcile everything that happened during that run,
|
|
3682
|
+
and only then decide whether another run is still justified.
|
|
3683
|
+
Non-zero worker exits keep the bot alive, but only after the requested
|
|
3684
|
+
backoff and only while unread work still remains.
|
|
3685
|
+
|
|
3686
|
+
<<functions>>=
|
|
3687
|
+
def bot_command(client, args):
|
|
3688
|
+
"""Run a worker whenever visible unread messages are pending."""
|
|
3689
|
+
queue_id = None
|
|
3690
|
+
last_event_id = None
|
|
3691
|
+
pending_unread_ids = set()
|
|
3692
|
+
failed_runs = 0
|
|
3693
|
+
try:
|
|
3694
|
+
<<register the initial bot queue>>
|
|
3695
|
+
while True:
|
|
3696
|
+
<<wait for unread work>>
|
|
3697
|
+
<<run the worker once>>
|
|
3698
|
+
<<reconcile unread state after the worker>>
|
|
3699
|
+
<<back off before retrying failed work>>
|
|
3700
|
+
finally:
|
|
3701
|
+
_discard_queue(client, queue_id)
|
|
3702
|
+
@
|
|
3703
|
+
|
|
3704
|
+
The very first register call seeds both the queue cursor and the unread
|
|
3705
|
+
set.
|
|
3706
|
+
That means the bot reacts immediately if unread messages already exist
|
|
3707
|
+
when it starts.
|
|
3708
|
+
|
|
3709
|
+
<<register the initial bot queue>>=
|
|
3710
|
+
queue_id, last_event_id, pending_unread_ids = _register_bot_queue(client)
|
|
3711
|
+
@
|
|
3712
|
+
|
|
3713
|
+
An idle bot blocks in [[get_events()]] until something interesting
|
|
3714
|
+
happens.
|
|
3715
|
+
That keeps the process quiet while still letting Zulip wake it as soon as
|
|
3716
|
+
new unread work appears.
|
|
3717
|
+
|
|
3718
|
+
<<wait for unread work>>=
|
|
3719
|
+
while not pending_unread_ids:
|
|
3720
|
+
queue_id, last_event_id, pending_unread_ids = _refresh_bot_queue(
|
|
3721
|
+
client,
|
|
3722
|
+
queue_id,
|
|
3723
|
+
last_event_id,
|
|
3724
|
+
pending_unread_ids,
|
|
3725
|
+
dont_block=False,
|
|
3726
|
+
)
|
|
3727
|
+
@
|
|
3728
|
+
|
|
3729
|
+
Worker execution itself is synchronous.
|
|
3730
|
+
That is deliberate: one active worker is enough, because the queue lets
|
|
3731
|
+
us merge every event that arrived while it was busy into one follow-up
|
|
3732
|
+
decision.
|
|
3733
|
+
|
|
3734
|
+
<<run the worker once>>=
|
|
3735
|
+
worker_result = _run_bot_worker(args.command)
|
|
3736
|
+
@
|
|
3737
|
+
|
|
3738
|
+
Draining the queue immediately after the worker run is what prevents a
|
|
3739
|
+
stale message event from forcing a second, empty worker invocation.
|
|
3740
|
+
If the worker succeeded, or if no unread work remains after the drain, we
|
|
3741
|
+
reset the failure counter and return to the top of the loop.
|
|
3742
|
+
|
|
3743
|
+
<<reconcile unread state after the worker>>=
|
|
3744
|
+
queue_id, last_event_id, pending_unread_ids = _refresh_bot_queue(
|
|
3745
|
+
client,
|
|
3746
|
+
queue_id,
|
|
3747
|
+
last_event_id,
|
|
3748
|
+
pending_unread_ids,
|
|
3749
|
+
dont_block=True,
|
|
3750
|
+
)
|
|
3751
|
+
if worker_result.returncode == 0 or not pending_unread_ids:
|
|
3752
|
+
failed_runs = 0
|
|
3753
|
+
continue
|
|
3754
|
+
@
|
|
3755
|
+
|
|
3756
|
+
Retry sleeps can easily outlast the current queue's idle lifetime.
|
|
3757
|
+
We therefore resynchronize once the backoff expires before trusting the
|
|
3758
|
+
old unread set enough to rerun the worker again.
|
|
3759
|
+
|
|
3760
|
+
<<back off before retrying failed work>>=
|
|
3761
|
+
failed_runs += 1
|
|
3762
|
+
delay_seconds = _bot_retry_delay_seconds(failed_runs)
|
|
3763
|
+
print(
|
|
3764
|
+
"Error: worker exited with status "
|
|
3765
|
+
f"{worker_result.returncode}; unread messages remain; "
|
|
3766
|
+
f"retrying in {delay_seconds // 60}m",
|
|
3767
|
+
file=sys.stderr,
|
|
3768
|
+
)
|
|
3769
|
+
time.sleep(delay_seconds)
|
|
3770
|
+
queue_id, last_event_id, pending_unread_ids = _refresh_bot_queue(
|
|
3771
|
+
client,
|
|
3772
|
+
queue_id,
|
|
3773
|
+
last_event_id,
|
|
3774
|
+
pending_unread_ids,
|
|
3775
|
+
dont_block=True,
|
|
3776
|
+
)
|
|
3777
|
+
if not pending_unread_ids:
|
|
3778
|
+
failed_runs = 0
|
|
3779
|
+
@
|
|
3780
|
+
|
|
3781
|
+
Let's build a few small queue fixtures for the bot tests.
|
|
3782
|
+
|
|
3783
|
+
<<test functions>>=
|
|
3784
|
+
def _make_bot_register_result(
|
|
3785
|
+
*,
|
|
3786
|
+
queue_id="queue-123",
|
|
3787
|
+
last_event_id=0,
|
|
3788
|
+
unread_msgs=None,
|
|
3789
|
+
):
|
|
3790
|
+
"""Create a sample register response for bot tests."""
|
|
3791
|
+
if unread_msgs is None:
|
|
3792
|
+
unread_msgs = {
|
|
3793
|
+
"old_unreads_missing": False,
|
|
3794
|
+
"streams": [],
|
|
3795
|
+
"pms": [],
|
|
3796
|
+
"huddles": [],
|
|
3797
|
+
}
|
|
3798
|
+
return {
|
|
3799
|
+
"result": "success",
|
|
3800
|
+
"queue_id": queue_id,
|
|
3801
|
+
"last_event_id": last_event_id,
|
|
3802
|
+
"unread_msgs": unread_msgs,
|
|
3803
|
+
}
|
|
3804
|
+
@
|
|
3805
|
+
|
|
3806
|
+
<<test functions>>=
|
|
3807
|
+
def _make_bot_message_event(event_id, message_id, *, flags=None):
|
|
3808
|
+
"""Create a sample message event for bot tests."""
|
|
3809
|
+
return {
|
|
3810
|
+
"id": event_id,
|
|
3811
|
+
"type": "message",
|
|
3812
|
+
"message": {"id": message_id},
|
|
3813
|
+
"flags": [] if flags is None else list(flags),
|
|
3814
|
+
}
|
|
3815
|
+
@
|
|
3816
|
+
|
|
3817
|
+
<<test functions>>=
|
|
3818
|
+
def _make_bot_flag_event(event_id, messages, *, op="add", all=False):
|
|
3819
|
+
"""Create a sample update-message-flags event for bot tests."""
|
|
3820
|
+
return {
|
|
3821
|
+
"id": event_id,
|
|
3822
|
+
"type": "update_message_flags",
|
|
3823
|
+
"flag": "read",
|
|
3824
|
+
"op": op,
|
|
3825
|
+
"messages": list(messages),
|
|
3826
|
+
"all": all,
|
|
3827
|
+
}
|
|
3828
|
+
@
|
|
3829
|
+
|
|
3830
|
+
<<test functions>>=
|
|
3831
|
+
def _make_bot_delete_event(event_id, *, message_id=None, message_ids=None):
|
|
3832
|
+
"""Create a sample delete-message event for bot tests."""
|
|
3833
|
+
event = {
|
|
3834
|
+
"id": event_id,
|
|
3835
|
+
"type": "delete_message",
|
|
3836
|
+
"message_type": "private",
|
|
3837
|
+
}
|
|
3838
|
+
if message_ids is not None:
|
|
3839
|
+
event["message_ids"] = list(message_ids)
|
|
3840
|
+
elif message_id is not None:
|
|
3841
|
+
event["message_id"] = message_id
|
|
3842
|
+
return event
|
|
3843
|
+
@
|
|
3844
|
+
|
|
3845
|
+
Starting the bot with unread work already pending should launch the
|
|
3846
|
+
worker immediately, but draining a subsequent read-flag event must clear
|
|
3847
|
+
that work instead of causing an empty second run.
|
|
3848
|
+
|
|
3849
|
+
<<test functions>>=
|
|
3850
|
+
def test_bot_does_not_rerun_when_read_flag_clears_pending_messages(
|
|
3851
|
+
monkeypatch,
|
|
3852
|
+
):
|
|
3853
|
+
client = MagicMock()
|
|
3854
|
+
client.register.return_value = _make_bot_register_result(
|
|
3855
|
+
unread_msgs={
|
|
3856
|
+
"old_unreads_missing": False,
|
|
3857
|
+
"streams": [
|
|
3858
|
+
{
|
|
3859
|
+
"stream_id": 17,
|
|
3860
|
+
"topic": "Announcements",
|
|
3861
|
+
"unread_message_ids": [1],
|
|
3862
|
+
},
|
|
3863
|
+
],
|
|
3864
|
+
"pms": [],
|
|
3865
|
+
"huddles": [],
|
|
3866
|
+
},
|
|
3867
|
+
)
|
|
3868
|
+
client.get_events.side_effect = [
|
|
3869
|
+
{
|
|
3870
|
+
"result": "success",
|
|
3871
|
+
"events": [_make_bot_flag_event(1, [1])],
|
|
3872
|
+
},
|
|
3873
|
+
KeyboardInterrupt,
|
|
3874
|
+
]
|
|
3875
|
+
runs = []
|
|
3876
|
+
|
|
3877
|
+
def run_worker(command):
|
|
3878
|
+
runs.append(list(command))
|
|
3879
|
+
return SimpleNamespace(returncode=0)
|
|
3880
|
+
|
|
3881
|
+
monkeypatch.setattr(cli.subprocess, "run", run_worker)
|
|
3882
|
+
|
|
3883
|
+
with pytest.raises(KeyboardInterrupt):
|
|
3884
|
+
cli.bot_command(client, SimpleNamespace(command=["worker"]))
|
|
3885
|
+
|
|
3886
|
+
assert runs == [["worker"]]
|
|
3887
|
+
client.deregister.assert_called_once_with("queue-123")
|
|
3888
|
+
@
|
|
3889
|
+
|
|
3890
|
+
If fresh unread work survives the post-run drain, the bot should launch
|
|
3891
|
+
exactly one follow-up run.
|
|
3892
|
+
That is the core coalescing behaviour we wanted from the start.
|
|
3893
|
+
|
|
3894
|
+
<<test functions>>=
|
|
3895
|
+
def test_bot_reruns_once_when_new_unread_remains_after_drain(monkeypatch):
|
|
3896
|
+
client = MagicMock()
|
|
3897
|
+
client.register.return_value = _make_bot_register_result(
|
|
3898
|
+
unread_msgs={
|
|
3899
|
+
"old_unreads_missing": False,
|
|
3900
|
+
"streams": [
|
|
3901
|
+
{
|
|
3902
|
+
"stream_id": 17,
|
|
3903
|
+
"topic": "Announcements",
|
|
3904
|
+
"unread_message_ids": [1],
|
|
3905
|
+
},
|
|
3906
|
+
],
|
|
3907
|
+
"pms": [],
|
|
3908
|
+
"huddles": [],
|
|
3909
|
+
},
|
|
3910
|
+
)
|
|
3911
|
+
client.get_events.side_effect = [
|
|
3912
|
+
{
|
|
3913
|
+
"result": "success",
|
|
3914
|
+
"events": [
|
|
3915
|
+
_make_bot_flag_event(1, [1]),
|
|
3916
|
+
_make_bot_message_event(2, 2),
|
|
3917
|
+
],
|
|
3918
|
+
},
|
|
3919
|
+
{
|
|
3920
|
+
"result": "success",
|
|
3921
|
+
"events": [],
|
|
3922
|
+
},
|
|
3923
|
+
{
|
|
3924
|
+
"result": "success",
|
|
3925
|
+
"events": [_make_bot_flag_event(3, [2])],
|
|
3926
|
+
},
|
|
3927
|
+
{
|
|
3928
|
+
"result": "success",
|
|
3929
|
+
"events": [],
|
|
3930
|
+
},
|
|
3931
|
+
KeyboardInterrupt,
|
|
3932
|
+
]
|
|
3933
|
+
runs = []
|
|
3934
|
+
|
|
3935
|
+
def run_worker(command):
|
|
3936
|
+
runs.append(list(command))
|
|
3937
|
+
return SimpleNamespace(returncode=0)
|
|
3938
|
+
|
|
3939
|
+
monkeypatch.setattr(cli.subprocess, "run", run_worker)
|
|
3940
|
+
|
|
3941
|
+
with pytest.raises(KeyboardInterrupt):
|
|
3942
|
+
cli.bot_command(client, SimpleNamespace(command=["worker"]))
|
|
3943
|
+
|
|
3944
|
+
assert runs == [["worker"], ["worker"]]
|
|
3945
|
+
@
|
|
3946
|
+
|
|
3947
|
+
Zulip can deliver a message event that is already marked read for this
|
|
3948
|
+
account.
|
|
3949
|
+
Those events must not wake the worker at all.
|
|
3950
|
+
|
|
3951
|
+
<<test functions>>=
|
|
3952
|
+
def test_bot_ignores_already_read_message_events(monkeypatch):
|
|
3953
|
+
client = MagicMock()
|
|
3954
|
+
client.register.return_value = _make_bot_register_result()
|
|
3955
|
+
client.get_events.side_effect = [
|
|
3956
|
+
{
|
|
3957
|
+
"result": "success",
|
|
3958
|
+
"events": [_make_bot_message_event(1, 10, flags=["read"])],
|
|
3959
|
+
},
|
|
3960
|
+
KeyboardInterrupt,
|
|
3961
|
+
]
|
|
3962
|
+
|
|
3963
|
+
def run_worker(command):
|
|
3964
|
+
raise AssertionError("worker should not run")
|
|
3965
|
+
|
|
3966
|
+
monkeypatch.setattr(cli.subprocess, "run", run_worker)
|
|
3967
|
+
|
|
3968
|
+
with pytest.raises(KeyboardInterrupt):
|
|
3969
|
+
cli.bot_command(client, SimpleNamespace(command=["worker"]))
|
|
3970
|
+
@
|
|
3971
|
+
|
|
3972
|
+
Worker failures should trigger the configured backoff only while unread
|
|
3973
|
+
work still remains.
|
|
3974
|
+
Once the worker eventually succeeds and clears that work, the bot should
|
|
3975
|
+
return to its idle state.
|
|
3976
|
+
|
|
3977
|
+
<<test functions>>=
|
|
3978
|
+
def test_bot_retries_failed_worker_with_backoff(monkeypatch):
|
|
3979
|
+
client = MagicMock()
|
|
3980
|
+
client.register.return_value = _make_bot_register_result(
|
|
3981
|
+
unread_msgs={
|
|
3982
|
+
"old_unreads_missing": False,
|
|
3983
|
+
"streams": [
|
|
3984
|
+
{
|
|
3985
|
+
"stream_id": 17,
|
|
3986
|
+
"topic": "Announcements",
|
|
3987
|
+
"unread_message_ids": [1],
|
|
3988
|
+
},
|
|
3989
|
+
],
|
|
3990
|
+
"pms": [],
|
|
3991
|
+
"huddles": [],
|
|
3992
|
+
},
|
|
3993
|
+
)
|
|
3994
|
+
client.get_events.side_effect = [
|
|
3995
|
+
{"result": "success", "events": []},
|
|
3996
|
+
{"result": "success", "events": []},
|
|
3997
|
+
{
|
|
3998
|
+
"result": "success",
|
|
3999
|
+
"events": [_make_bot_flag_event(1, [1])],
|
|
4000
|
+
},
|
|
4001
|
+
KeyboardInterrupt,
|
|
4002
|
+
]
|
|
4003
|
+
runs = []
|
|
4004
|
+
sleep_delays = []
|
|
4005
|
+
return_codes = [1, 0]
|
|
4006
|
+
|
|
4007
|
+
def run_worker(command):
|
|
4008
|
+
runs.append(list(command))
|
|
4009
|
+
return SimpleNamespace(returncode=return_codes.pop(0))
|
|
4010
|
+
|
|
4011
|
+
monkeypatch.setattr(cli.subprocess, "run", run_worker)
|
|
4012
|
+
monkeypatch.setattr(cli.time, "sleep", sleep_delays.append)
|
|
4013
|
+
|
|
4014
|
+
with pytest.raises(KeyboardInterrupt):
|
|
4015
|
+
cli.bot_command(client, SimpleNamespace(command=["worker"]))
|
|
4016
|
+
|
|
4017
|
+
assert runs == [["worker"], ["worker"]]
|
|
4018
|
+
assert sleep_delays == [60]
|
|
4019
|
+
@
|
|
4020
|
+
|
|
4021
|
+
Deleting a pending message should clear it from the unread set in the
|
|
4022
|
+
same way as marking it read.
|
|
4023
|
+
Otherwise the bot could keep retrying for work that no longer exists.
|
|
4024
|
+
|
|
4025
|
+
<<test functions>>=
|
|
4026
|
+
def test_bot_drops_deleted_messages_from_pending_set(monkeypatch):
|
|
4027
|
+
client = MagicMock()
|
|
4028
|
+
client.register.return_value = _make_bot_register_result(
|
|
4029
|
+
unread_msgs={
|
|
4030
|
+
"old_unreads_missing": False,
|
|
4031
|
+
"streams": [
|
|
4032
|
+
{
|
|
4033
|
+
"stream_id": 17,
|
|
4034
|
+
"topic": "Announcements",
|
|
4035
|
+
"unread_message_ids": [1],
|
|
4036
|
+
},
|
|
4037
|
+
],
|
|
4038
|
+
"pms": [],
|
|
4039
|
+
"huddles": [],
|
|
4040
|
+
},
|
|
4041
|
+
)
|
|
4042
|
+
client.get_events.side_effect = [
|
|
4043
|
+
{
|
|
4044
|
+
"result": "success",
|
|
4045
|
+
"events": [_make_bot_delete_event(1, message_id=1)],
|
|
4046
|
+
},
|
|
4047
|
+
KeyboardInterrupt,
|
|
4048
|
+
]
|
|
4049
|
+
runs = []
|
|
4050
|
+
|
|
4051
|
+
def run_worker(command):
|
|
4052
|
+
runs.append(list(command))
|
|
4053
|
+
return SimpleNamespace(returncode=0)
|
|
4054
|
+
|
|
4055
|
+
monkeypatch.setattr(cli.subprocess, "run", run_worker)
|
|
4056
|
+
|
|
4057
|
+
with pytest.raises(KeyboardInterrupt):
|
|
4058
|
+
cli.bot_command(client, SimpleNamespace(command=["worker"]))
|
|
4059
|
+
|
|
4060
|
+
assert runs == [["worker"]]
|
|
4061
|
+
@
|
|
4062
|
+
|
|
4063
|
+
If the queue expires while the worker is running or sleeping, the bot
|
|
4064
|
+
must not trust its old cursor.
|
|
4065
|
+
Re-registering and reseeding from current unread state lets it continue
|
|
4066
|
+
without an unnecessary extra worker run.
|
|
4067
|
+
|
|
4068
|
+
<<test functions>>=
|
|
4069
|
+
def test_bot_resynchronizes_after_bad_event_queue_id(monkeypatch):
|
|
4070
|
+
client = MagicMock()
|
|
4071
|
+
client.register.side_effect = [
|
|
4072
|
+
_make_bot_register_result(
|
|
4073
|
+
queue_id="queue-1",
|
|
4074
|
+
unread_msgs={
|
|
4075
|
+
"old_unreads_missing": False,
|
|
4076
|
+
"streams": [
|
|
4077
|
+
{
|
|
4078
|
+
"stream_id": 17,
|
|
4079
|
+
"topic": "Announcements",
|
|
4080
|
+
"unread_message_ids": [1],
|
|
4081
|
+
},
|
|
4082
|
+
],
|
|
4083
|
+
"pms": [],
|
|
4084
|
+
"huddles": [],
|
|
4085
|
+
},
|
|
4086
|
+
),
|
|
4087
|
+
_make_bot_register_result(queue_id="queue-2"),
|
|
4088
|
+
]
|
|
4089
|
+
client.get_events.side_effect = [
|
|
4090
|
+
{
|
|
4091
|
+
"result": "error",
|
|
4092
|
+
"code": "BAD_EVENT_QUEUE_ID",
|
|
4093
|
+
"msg": "Bad event queue ID",
|
|
4094
|
+
},
|
|
4095
|
+
KeyboardInterrupt,
|
|
4096
|
+
]
|
|
4097
|
+
runs = []
|
|
4098
|
+
|
|
4099
|
+
def run_worker(command):
|
|
4100
|
+
runs.append(list(command))
|
|
4101
|
+
return SimpleNamespace(returncode=0)
|
|
4102
|
+
|
|
4103
|
+
monkeypatch.setattr(cli.subprocess, "run", run_worker)
|
|
4104
|
+
|
|
4105
|
+
with pytest.raises(KeyboardInterrupt):
|
|
4106
|
+
cli.bot_command(client, SimpleNamespace(command=["worker"]))
|
|
4107
|
+
|
|
4108
|
+
assert runs == [["worker"]]
|
|
4109
|
+
assert client.register.call_count == 2
|
|
4110
|
+
client.deregister.assert_called_once_with("queue-2")
|
|
4111
|
+
@
|
|
4112
|
+
|
|
3195
4113
|
The standalone and embedded applications must expose the same command
|
|
3196
4114
|
surface.
|
|
3197
4115
|
We therefore register the handlers through one helper that binds the
|
|
@@ -3209,6 +4127,9 @@ def _register_commands(target: typer.Typer) -> None:
|
|
|
3209
4127
|
target.command("streams")(_streams_handler)
|
|
3210
4128
|
target.command("topics")(_topics_handler)
|
|
3211
4129
|
target.command("read")(_read_handler)
|
|
4130
|
+
target.command("bot", context_settings=BOT_CONTEXT_SETTINGS)(
|
|
4131
|
+
_bot_handler
|
|
4132
|
+
)
|
|
3212
4133
|
@
|
|
3213
4134
|
|
|
3214
4135
|
<<functions>>=
|
|
@@ -3287,6 +4208,13 @@ That loop does not care whether the narrow points at a stream or a DM
|
|
|
3287
4208
|
conversation, so [[read]] and [[dm]] share one helper that accepts the
|
|
3288
4209
|
already-constructed narrow.
|
|
3289
4210
|
|
|
4211
|
+
The bare [[read -U]] case needs one extra wrinkle.
|
|
4212
|
+
Without a stream filter, Zulip's plain [[is:unread]] narrow only covers
|
|
4213
|
+
the account's personal history, while the bot now also watches unread
|
|
4214
|
+
messages in shared public-channel history.
|
|
4215
|
+
To keep the worker-facing [[read -U]] command aligned with the bot's
|
|
4216
|
+
trigger condition, we read those two scopes separately and merge them.
|
|
4217
|
+
|
|
3290
4218
|
<<functions>>=
|
|
3291
4219
|
def _read_messages(client, narrow, *, mark_as_read):
|
|
3292
4220
|
"""Read all messages matching ``narrow`` from oldest to newest."""
|
|
@@ -3323,6 +4251,21 @@ def _read_messages(client, narrow, *, mark_as_read):
|
|
|
3323
4251
|
<<functions>>=
|
|
3324
4252
|
def read_command(client, args):
|
|
3325
4253
|
"""Read messages from a stream or topic."""
|
|
4254
|
+
if args.unread and not args.stream:
|
|
4255
|
+
messages = sorted(
|
|
4256
|
+
_get_all_visible_unread_messages(client),
|
|
4257
|
+
key=lambda message: message["id"],
|
|
4258
|
+
)
|
|
4259
|
+
for message in messages:
|
|
4260
|
+
_print_read_message(message)
|
|
4261
|
+
if args.mark_as_read and messages:
|
|
4262
|
+
_require_success(client.update_message_flags({
|
|
4263
|
+
"messages": [message["id"] for message in messages],
|
|
4264
|
+
"op": "add",
|
|
4265
|
+
"flag": "read",
|
|
4266
|
+
}))
|
|
4267
|
+
return
|
|
4268
|
+
|
|
3326
4269
|
narrow = []
|
|
3327
4270
|
if args.stream:
|
|
3328
4271
|
narrow.append(
|
|
@@ -3494,26 +4437,32 @@ def test_read_stream_uses_stream_name(capsys, monkeypatch):
|
|
|
3494
4437
|
client.update_message_flags.assert_not_called()
|
|
3495
4438
|
@
|
|
3496
4439
|
|
|
3497
|
-
The streamless form
|
|
3498
|
-
|
|
4440
|
+
The streamless unread form now merges unread messages from personal
|
|
4441
|
+
history and shared public-channel history so it matches the bot's notion
|
|
4442
|
+
of visible unread work.
|
|
3499
4443
|
|
|
3500
4444
|
<<test functions>>=
|
|
3501
|
-
def
|
|
4445
|
+
def test_read_without_stream_reads_all_visible_unread_messages(
|
|
4446
|
+
capsys,
|
|
3502
4447
|
monkeypatch,
|
|
3503
4448
|
):
|
|
3504
|
-
response = {
|
|
3505
|
-
"result": "success",
|
|
3506
|
-
"found_newest": True,
|
|
3507
|
-
"messages": [],
|
|
3508
|
-
}
|
|
3509
|
-
requests = []
|
|
3510
|
-
|
|
3511
|
-
def get_messages(request):
|
|
3512
|
-
requests.append(copy.deepcopy(request))
|
|
3513
|
-
return response
|
|
3514
|
-
|
|
3515
4449
|
client = MagicMock()
|
|
3516
|
-
|
|
4450
|
+
messages = [
|
|
4451
|
+
_make_stream_message(
|
|
4452
|
+
2,
|
|
4453
|
+
60,
|
|
4454
|
+
"Bob Builder",
|
|
4455
|
+
"Public topic",
|
|
4456
|
+
"From shared history",
|
|
4457
|
+
),
|
|
4458
|
+
_make_stream_message(
|
|
4459
|
+
1,
|
|
4460
|
+
0,
|
|
4461
|
+
"Alice Admin",
|
|
4462
|
+
"DM-ish topic",
|
|
4463
|
+
"From personal history",
|
|
4464
|
+
),
|
|
4465
|
+
]
|
|
3517
4466
|
args = SimpleNamespace(
|
|
3518
4467
|
stream=None,
|
|
3519
4468
|
topic=None,
|
|
@@ -3521,19 +4470,85 @@ def test_read_without_stream_uses_only_requested_unread_filter(
|
|
|
3521
4470
|
mark_as_read=False,
|
|
3522
4471
|
)
|
|
3523
4472
|
monkeypatch.setattr(cli, "_stdout_is_tty", lambda: False)
|
|
4473
|
+
monkeypatch.setattr(
|
|
4474
|
+
cli,
|
|
4475
|
+
"_get_all_visible_unread_messages",
|
|
4476
|
+
lambda client: messages,
|
|
4477
|
+
)
|
|
3524
4478
|
|
|
3525
4479
|
read_command(client, args)
|
|
3526
4480
|
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
"
|
|
3530
|
-
"
|
|
3531
|
-
"
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
4481
|
+
output = capsys.readouterr().out
|
|
4482
|
+
assert output == (
|
|
4483
|
+
"1970-01-01T00:00:00Z\tAlice Admin\tDM-ish topic\n"
|
|
4484
|
+
"From personal history\n\n"
|
|
4485
|
+
"1970-01-01T00:01:00Z\tBob Builder\tPublic topic\n"
|
|
4486
|
+
"From shared history\n\n"
|
|
4487
|
+
)
|
|
4488
|
+
client.get_messages.assert_not_called()
|
|
4489
|
+
client.update_message_flags.assert_not_called()
|
|
4490
|
+
@
|
|
4491
|
+
|
|
4492
|
+
When the worker wants [[read -U --mark-as-read]], the merged visible
|
|
4493
|
+
unread set should still be marked only after every message has been
|
|
4494
|
+
printed successfully.
|
|
4495
|
+
|
|
4496
|
+
<<test functions>>=
|
|
4497
|
+
def test_read_without_stream_marks_visible_unreads_after_printing(
|
|
4498
|
+
monkeypatch,
|
|
4499
|
+
):
|
|
4500
|
+
client = MagicMock()
|
|
4501
|
+
messages = [
|
|
4502
|
+
_make_stream_message(
|
|
4503
|
+
2,
|
|
4504
|
+
60,
|
|
4505
|
+
"Bob Builder",
|
|
4506
|
+
"Public topic",
|
|
4507
|
+
"From shared history",
|
|
4508
|
+
),
|
|
4509
|
+
_make_stream_message(
|
|
4510
|
+
1,
|
|
4511
|
+
0,
|
|
4512
|
+
"Alice Admin",
|
|
4513
|
+
"DM-ish topic",
|
|
4514
|
+
"From personal history",
|
|
4515
|
+
),
|
|
4516
|
+
]
|
|
4517
|
+
events = []
|
|
4518
|
+
|
|
4519
|
+
def print_message(message):
|
|
4520
|
+
events.append(("print", message["id"]))
|
|
4521
|
+
|
|
4522
|
+
def update_message_flags(request):
|
|
4523
|
+
events.append(("mark", copy.deepcopy(request)))
|
|
4524
|
+
return {"result": "success"}
|
|
4525
|
+
|
|
4526
|
+
client.update_message_flags.side_effect = update_message_flags
|
|
4527
|
+
args = SimpleNamespace(
|
|
4528
|
+
stream=None,
|
|
4529
|
+
topic=None,
|
|
4530
|
+
unread=True,
|
|
4531
|
+
mark_as_read=True,
|
|
4532
|
+
)
|
|
4533
|
+
monkeypatch.setattr(
|
|
4534
|
+
cli,
|
|
4535
|
+
"_get_all_visible_unread_messages",
|
|
4536
|
+
lambda client: messages,
|
|
4537
|
+
)
|
|
4538
|
+
monkeypatch.setattr(cli, "_print_read_message", print_message)
|
|
4539
|
+
|
|
4540
|
+
read_command(client, args)
|
|
4541
|
+
|
|
4542
|
+
assert events == [
|
|
4543
|
+
("print", 1),
|
|
4544
|
+
("print", 2),
|
|
4545
|
+
("mark", {
|
|
4546
|
+
"messages": [1, 2],
|
|
4547
|
+
"op": "add",
|
|
4548
|
+
"flag": "read",
|
|
4549
|
+
}),
|
|
4550
|
+
]
|
|
4551
|
+
client.get_messages.assert_not_called()
|
|
3537
4552
|
@
|
|
3538
4553
|
|
|
3539
4554
|
We should also verify that interactive terminals render Markdown through
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|