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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zulipcli
3
- Version: 0.6
3
+ Version: 0.7
4
4
  Summary: Command-line interface for Zulip
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "zulipcli"
3
- version = "0.6"
3
+ version = "0.7"
4
4
  description = "Command-line interface for Zulip"
5
5
  authors = [
6
6
  {name = "Daniel Bosk", email = "daniel@bosk.se"}
@@ -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 gain
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 should generate no stream narrow term at all; unread
3498
- filtering remains just another narrow clause layered on top.
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 test_read_without_stream_uses_only_requested_unread_filter(
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
- client.get_messages.side_effect = get_messages
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
- assert requests == [{
3528
- "anchor": 0,
3529
- "num_before": 0,
3530
- "num_after": HISTORY_BATCH_SIZE,
3531
- "narrow": [
3532
- {"operator": "is", "operand": "unread"},
3533
- ],
3534
- "client_gravatar": False,
3535
- "apply_markdown": False,
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