mirror of
https://github.com/zulip/zulip.git
synced 2025-10-23 16:14:02 +00:00
reminders: Add API endpoint to schedule reminders.
This commit is contained in:
@@ -20,6 +20,11 @@ format used by the Zulip server that they are interacting with.
|
||||
|
||||
## Changes in Zulip 11.0
|
||||
|
||||
**Feature level 381**
|
||||
|
||||
* [`POST /reminders`](/api/create-message-reminder): Added a new endpoint to
|
||||
schedule personal reminder for a message.
|
||||
|
||||
**Feature level 380**
|
||||
|
||||
* [`POST /register`](/api/register-queue), [`GET
|
||||
|
@@ -26,6 +26,10 @@
|
||||
* [Edit a scheduled message](/api/update-scheduled-message)
|
||||
* [Delete a scheduled message](/api/delete-scheduled-message)
|
||||
|
||||
#### Message reminders
|
||||
|
||||
* [Create a message reminder](/api/create-message-reminder)
|
||||
|
||||
#### Drafts
|
||||
|
||||
* [Get drafts](/api/get-drafts)
|
||||
|
@@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
|
||||
# new level means in api_docs/changelog.md, as well as "**Changes**"
|
||||
# entries in the endpoint's documentation in `zulip.yaml`.
|
||||
|
||||
API_FEATURE_LEVEL = 380
|
||||
API_FEATURE_LEVEL = 381
|
||||
|
||||
# Bump the minor PROVISION_VERSION to indicate that folks should provision
|
||||
# only when going from an old version of the code to a newer version. Bump
|
||||
|
@@ -290,6 +290,7 @@ export function quote_message(opts: {
|
||||
// ```quote
|
||||
// message content
|
||||
// ```
|
||||
// Keep syntax in sync with zerver/lib/reminders.py
|
||||
let content = $t(
|
||||
{defaultMessage: "{username} [said]({link_to_message}):"},
|
||||
{
|
||||
|
39
zerver/actions/reminders.py
Normal file
39
zerver/actions/reminders.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import datetime
|
||||
|
||||
from zerver.actions.message_send import check_message
|
||||
from zerver.actions.scheduled_messages import do_schedule_messages
|
||||
from zerver.lib.addressee import Addressee
|
||||
from zerver.lib.message import access_message
|
||||
from zerver.lib.reminders import get_reminder_formatted_content
|
||||
from zerver.models import Client, ScheduledMessage, UserProfile
|
||||
|
||||
|
||||
def schedule_reminder_for_message(
|
||||
current_user: UserProfile,
|
||||
client: Client,
|
||||
message_id: int,
|
||||
deliver_at: datetime.datetime,
|
||||
) -> int:
|
||||
message = access_message(current_user, message_id, is_modifying_message=False)
|
||||
# Even though reminder will be sent from NOTIFICATION_BOT, we still
|
||||
# set current_user as the sender here to help us make the permission checks easier.
|
||||
addressee = Addressee.for_user_profile(current_user)
|
||||
# This can raise an exception in the unlikely event that the current user cannot DM themself.
|
||||
send_request = check_message(
|
||||
current_user,
|
||||
client,
|
||||
addressee,
|
||||
get_reminder_formatted_content(message, current_user),
|
||||
current_user.realm,
|
||||
forwarder_user_profile=current_user,
|
||||
)
|
||||
send_request.deliver_at = deliver_at
|
||||
send_request.reminder_target_message_id = message_id
|
||||
|
||||
return do_schedule_messages(
|
||||
[send_request],
|
||||
current_user,
|
||||
read_by_sender=False,
|
||||
skip_events=True,
|
||||
delivery_type=ScheduledMessage.REMIND,
|
||||
)[0]
|
@@ -23,8 +23,9 @@ from zerver.lib.exceptions import (
|
||||
UserDeactivatedError,
|
||||
)
|
||||
from zerver.lib.markdown import render_message_markdown
|
||||
from zerver.lib.message import SendMessageRequest, truncate_topic
|
||||
from zerver.lib.message import SendMessageRequest, access_message, truncate_topic
|
||||
from zerver.lib.recipient_parsing import extract_direct_message_recipient_ids, extract_stream_id
|
||||
from zerver.lib.reminders import get_reminder_formatted_content
|
||||
from zerver.lib.scheduled_messages import access_scheduled_message
|
||||
from zerver.lib.string_validation import check_stream_topic
|
||||
from zerver.models import Client, Realm, ScheduledMessage, Subscription, UserProfile
|
||||
@@ -68,7 +69,11 @@ def check_schedule_message(
|
||||
)
|
||||
|
||||
return do_schedule_messages(
|
||||
[send_request], sender, read_by_sender=read_by_sender, skip_events=skip_events
|
||||
[send_request],
|
||||
sender,
|
||||
read_by_sender=read_by_sender,
|
||||
skip_events=skip_events,
|
||||
delivery_type=ScheduledMessage.SEND_LATER,
|
||||
)[0]
|
||||
|
||||
|
||||
@@ -78,6 +83,7 @@ def do_schedule_messages(
|
||||
*,
|
||||
read_by_sender: bool = False,
|
||||
skip_events: bool = False,
|
||||
delivery_type: int,
|
||||
) -> list[int]:
|
||||
scheduled_messages: list[tuple[ScheduledMessage, SendMessageRequest]] = []
|
||||
|
||||
@@ -98,7 +104,10 @@ def do_schedule_messages(
|
||||
assert send_request.deliver_at is not None
|
||||
scheduled_message.scheduled_timestamp = send_request.deliver_at
|
||||
scheduled_message.read_by_sender = read_by_sender
|
||||
scheduled_message.delivery_type = ScheduledMessage.SEND_LATER
|
||||
scheduled_message.delivery_type = delivery_type
|
||||
|
||||
if delivery_type == ScheduledMessage.REMIND:
|
||||
scheduled_message.reminder_target_message_id = send_request.reminder_target_message_id
|
||||
|
||||
scheduled_messages.append((scheduled_message, send_request))
|
||||
|
||||
@@ -114,6 +123,7 @@ def do_schedule_messages(
|
||||
scheduled_message.save(update_fields=["has_attachment"])
|
||||
|
||||
if not skip_events:
|
||||
assert delivery_type == ScheduledMessage.SEND_LATER
|
||||
event = {
|
||||
"type": "scheduled_messages",
|
||||
"op": "add",
|
||||
@@ -275,12 +285,35 @@ def delete_scheduled_message(user_profile: UserProfile, scheduled_message_id: in
|
||||
notify_remove_scheduled_message(user_profile, scheduled_message_id)
|
||||
|
||||
|
||||
def send_reminder(scheduled_message: ScheduledMessage) -> None:
|
||||
message_id = scheduled_message.reminder_target_message_id
|
||||
assert message_id is not None
|
||||
current_user = scheduled_message.sender
|
||||
try:
|
||||
message = access_message(current_user, message_id, is_modifying_message=False)
|
||||
content = get_reminder_formatted_content(message, current_user)
|
||||
except JsonableError:
|
||||
# If we no longer have access to the message, we send the reminder with the
|
||||
# last known message position and content.
|
||||
content = scheduled_message.content
|
||||
# Reminder messages are always sent from the notification bot.
|
||||
message_id = internal_send_private_message(
|
||||
get_system_bot(settings.NOTIFICATION_BOT, scheduled_message.realm.id),
|
||||
current_user,
|
||||
content,
|
||||
)
|
||||
scheduled_message.delivered_message_id = message_id
|
||||
scheduled_message.delivered = True
|
||||
scheduled_message.save(update_fields=["delivered", "delivered_message_id"])
|
||||
|
||||
|
||||
def send_scheduled_message(scheduled_message: ScheduledMessage) -> None:
|
||||
assert not scheduled_message.delivered
|
||||
assert not scheduled_message.failed
|
||||
|
||||
# It's currently not possible to use the reminder feature.
|
||||
assert scheduled_message.delivery_type == ScheduledMessage.SEND_LATER
|
||||
if scheduled_message.delivery_type == ScheduledMessage.REMIND:
|
||||
send_reminder(scheduled_message)
|
||||
return
|
||||
|
||||
# Repeat the checks from validate_account_and_subdomain, in case
|
||||
# the state changed since the message as scheduled.
|
||||
@@ -418,6 +451,8 @@ def try_deliver_one_scheduled_message() -> bool:
|
||||
|
||||
if (
|
||||
not was_delivered
|
||||
# Reminders have their own notification system.
|
||||
and scheduled_message.delivery_type != ScheduledMessage.REMIND
|
||||
# Do not send notification if either the realm or
|
||||
# the sending user account has been deactivated.
|
||||
and not isinstance(e, RealmDeactivatedError)
|
||||
|
@@ -147,6 +147,20 @@ CODE_VALIDATORS: dict[str | None, Callable[[list[str]], None]] = {
|
||||
}
|
||||
|
||||
|
||||
# This function is similar to one used in fenced_code.ts
|
||||
def get_unused_fence(content: str) -> str:
|
||||
# Define the regular expression pattern to match ``` fences
|
||||
fence_length_re = re.compile(r"^ {0,3}(`{3,})", re.MULTILINE)
|
||||
|
||||
# Initialize the length variable to 3, corresponding to default fence length
|
||||
length = 3
|
||||
matches = fence_length_re.findall(content)
|
||||
for match in matches:
|
||||
length = max(length, len(match) + 1)
|
||||
|
||||
return "`" * length
|
||||
|
||||
|
||||
class FencedCodeExtension(Extension):
|
||||
def __init__(self, config: Mapping[str, Any] = {}) -> None:
|
||||
self.config = {
|
||||
|
@@ -181,6 +181,7 @@ class SendMessageRequest:
|
||||
disable_external_notifications: bool = False
|
||||
automatic_new_visibility_policy: int | None = None
|
||||
recipients_for_user_creation_events: dict[UserProfile, set[int]] | None = None
|
||||
reminder_target_message_id: int | None = None
|
||||
|
||||
|
||||
# We won't try to fetch more unread message IDs from the database than
|
||||
|
51
zerver/lib/reminders.py
Normal file
51
zerver/lib/reminders.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from zerver.lib.markdown.fenced_code import get_unused_fence
|
||||
from zerver.lib.mention import silent_mention_syntax_for_user
|
||||
from zerver.lib.message import truncate_content
|
||||
from zerver.lib.message_cache import MessageDict
|
||||
from zerver.lib.url_encoding import near_message_url, topic_narrow_url
|
||||
from zerver.models import Message, Stream, UserProfile
|
||||
|
||||
|
||||
def get_reminder_formatted_content(message: Message, current_user: UserProfile) -> str:
|
||||
if message.is_stream_message():
|
||||
# We don't need to check access here since we already have the message
|
||||
# whose access has already been checked by the caller.
|
||||
stream = Stream.objects.get(
|
||||
id=message.recipient.type_id,
|
||||
realm=current_user.realm,
|
||||
)
|
||||
narrow_link = topic_narrow_url(
|
||||
realm=current_user.realm,
|
||||
stream=stream,
|
||||
topic_name=message.topic_name(),
|
||||
)
|
||||
content = _(
|
||||
"You requested a reminder for the following message sent to [{stream_name} > {topic_name}]({narrow_link})."
|
||||
).format(
|
||||
stream_name=stream.name,
|
||||
topic_name=message.topic_name(),
|
||||
narrow_link=narrow_link,
|
||||
)
|
||||
else:
|
||||
content = _("You requested a reminder for the following direct message.")
|
||||
|
||||
# Format the message content as a quote.
|
||||
content += "\n\n"
|
||||
content += _("{user_silent_mention} [said]({conversation_url}):").format(
|
||||
user_silent_mention=silent_mention_syntax_for_user(message.sender),
|
||||
conversation_url=near_message_url(current_user.realm, MessageDict.wide_dict(message)),
|
||||
)
|
||||
content += "\n"
|
||||
fence = get_unused_fence(content)
|
||||
quoted_message = "{fence}quote\n{msg_content}\n{fence}"
|
||||
content += quoted_message
|
||||
length_without_message_content = len(content.format(fence=fence, msg_content=""))
|
||||
max_length = settings.MAX_MESSAGE_LENGTH - length_without_message_content
|
||||
msg_content = truncate_content(message.content, max_length, "\n[message truncated]")
|
||||
return content.format(
|
||||
fence=fence,
|
||||
msg_content=msg_content,
|
||||
)
|
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1.8 on 2025-04-28 08:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("zerver", "0698_scheduledmessage_request_timestamp"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="scheduledmessage",
|
||||
name="reminder_target_message_id",
|
||||
field=models.IntegerField(null=True),
|
||||
),
|
||||
]
|
@@ -153,6 +153,8 @@ class ScheduledMessage(models.Model):
|
||||
delivered_message = models.ForeignKey(Message, null=True, on_delete=CASCADE)
|
||||
has_attachment = models.BooleanField(default=False, db_index=True)
|
||||
request_timestamp = models.DateTimeField(default=timezone_now)
|
||||
# Only used for REMIND delivery_type messages.
|
||||
reminder_target_message_id = models.IntegerField(null=True)
|
||||
|
||||
# Metadata for messages that failed to send when their scheduled
|
||||
# moment arrived.
|
||||
|
@@ -6560,6 +6560,51 @@ paths:
|
||||
"result": "error",
|
||||
"msg": "Saved snippet does not exist.",
|
||||
}
|
||||
/reminders:
|
||||
post:
|
||||
operationId: create-message-reminder
|
||||
tags: ["reminders"]
|
||||
summary: Create a message reminder
|
||||
description: |
|
||||
Schedule a reminder to be sent to the current user at the specified time. The reminder will link the relevant message.
|
||||
|
||||
**Changes**: New in Zulip 11.0 (feature level ZF-f8d751).
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message_id:
|
||||
description: |
|
||||
The ID of the previously sent message to reference in the reminder message.
|
||||
type: integer
|
||||
example: 1
|
||||
scheduled_delivery_timestamp:
|
||||
type: integer
|
||||
description: |
|
||||
The UNIX timestamp for when the reminder will be sent,
|
||||
in UTC seconds.
|
||||
example: 5681662420
|
||||
responses:
|
||||
"200":
|
||||
description: Success.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/JsonSuccessBase"
|
||||
- additionalProperties: false
|
||||
properties:
|
||||
result: {}
|
||||
msg: {}
|
||||
ignored_parameters_unsupported: {}
|
||||
reminder_id:
|
||||
type: integer
|
||||
description: |
|
||||
Unique ID of the scheduled message reminder.
|
||||
example: {"msg": "", "reminder_id": 42, "result": "success"}
|
||||
/scheduled_messages:
|
||||
get:
|
||||
operationId: get-scheduled-messages
|
||||
|
@@ -912,6 +912,7 @@ class OpenAPIAttributesTest(ZulipTestCase):
|
||||
"scheduled_messages",
|
||||
"mobile",
|
||||
"invites",
|
||||
"reminders",
|
||||
]
|
||||
paths = OpenAPISpec(OPENAPI_SPEC_PATH).openapi()["paths"]
|
||||
for path, path_item in paths.items():
|
||||
|
289
zerver/tests/test_reminders.py
Normal file
289
zerver/tests/test_reminders.py
Normal file
@@ -0,0 +1,289 @@
|
||||
import datetime
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import time_machine
|
||||
|
||||
from zerver.actions.scheduled_messages import try_deliver_one_scheduled_message
|
||||
from zerver.lib.test_classes import ZulipTestCase
|
||||
from zerver.lib.timestamp import timestamp_to_datetime
|
||||
from zerver.models import Message, ScheduledMessage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.test.client import _MonkeyPatchedWSGIResponse as TestHttpResponse
|
||||
|
||||
|
||||
class RemindersTest(ZulipTestCase):
|
||||
def do_schedule_reminder(
|
||||
self,
|
||||
message_id: int,
|
||||
scheduled_delivery_timestamp: int,
|
||||
) -> "TestHttpResponse":
|
||||
self.login("hamlet")
|
||||
|
||||
payload = {
|
||||
"message_id": message_id,
|
||||
"scheduled_delivery_timestamp": scheduled_delivery_timestamp,
|
||||
}
|
||||
|
||||
result = self.client_post("/json/reminders", payload)
|
||||
return result
|
||||
|
||||
def create_reminder(self, content: str, message_type: str = "direct") -> ScheduledMessage:
|
||||
if message_type == "stream":
|
||||
message_id = self.send_channel_message_for_hamlet(content)
|
||||
else:
|
||||
message_id = self.send_dm_from_hamlet_to_othello(content)
|
||||
|
||||
scheduled_delivery_timestamp = int(time.time() + 86400)
|
||||
result = self.do_schedule_reminder(message_id, scheduled_delivery_timestamp)
|
||||
self.assert_json_success(result)
|
||||
return self.last_scheduled_reminder()
|
||||
|
||||
def last_scheduled_reminder(self) -> ScheduledMessage:
|
||||
return ScheduledMessage.objects.filter(delivery_type=ScheduledMessage.REMIND).order_by(
|
||||
"-id"
|
||||
)[0]
|
||||
|
||||
def send_channel_message_for_hamlet(self, content: str) -> int:
|
||||
return self.send_stream_message(self.example_user("hamlet"), "Verona", content)
|
||||
|
||||
def send_dm_from_hamlet_to_othello(self, content: str) -> int:
|
||||
return self.send_personal_message(
|
||||
self.example_user("hamlet"), self.example_user("othello"), content
|
||||
)
|
||||
|
||||
def get_dm_reminder_content(self, msg_content: str, msg_id: int) -> str:
|
||||
return (
|
||||
"You requested a reminder for the following direct message.\n\n"
|
||||
f"@_**King Hamlet|10** [said](http://zulip.testserver/#narrow/dm/10,12-pm/near/{msg_id}):\n```quote\n{msg_content}\n```"
|
||||
)
|
||||
|
||||
def get_channel_message_reminder_content(self, msg_content: str, msg_id: int) -> str:
|
||||
return (
|
||||
"You requested a reminder for the following message sent to [Verona > test](http://zulip.testserver/#narrow/channel/3-Verona/topic/test).\n\n"
|
||||
f"@_**King Hamlet|10** [said](http://zulip.testserver/#narrow/channel/3-Verona/topic/test/near/{msg_id}):\n```quote\n{msg_content}\n```"
|
||||
)
|
||||
|
||||
def test_schedule_reminder(self) -> None:
|
||||
self.login("hamlet")
|
||||
content = "Test message"
|
||||
scheduled_delivery_timestamp = int(time.time() + 86400)
|
||||
|
||||
# Scheduling a reminder to a channel you are subscribed is successful.
|
||||
message_id = self.send_channel_message_for_hamlet(content)
|
||||
result = self.do_schedule_reminder(message_id, scheduled_delivery_timestamp)
|
||||
self.assert_json_success(result)
|
||||
scheduled_message = self.last_scheduled_reminder()
|
||||
self.assertEqual(
|
||||
scheduled_message.content,
|
||||
self.get_channel_message_reminder_content(content, message_id),
|
||||
)
|
||||
# Recipient and sender are the same for reminders.
|
||||
self.assertEqual(scheduled_message.recipient.type_id, self.example_user("hamlet").id)
|
||||
self.assertEqual(scheduled_message.sender, self.example_user("hamlet"))
|
||||
self.assertEqual(
|
||||
scheduled_message.scheduled_timestamp,
|
||||
timestamp_to_datetime(scheduled_delivery_timestamp),
|
||||
)
|
||||
self.assertEqual(
|
||||
scheduled_message.reminder_target_message_id,
|
||||
message_id,
|
||||
)
|
||||
|
||||
# Scheduling a direct message with user IDs is successful.
|
||||
self.example_user("othello")
|
||||
message_id = self.send_dm_from_hamlet_to_othello(content)
|
||||
result = self.do_schedule_reminder(message_id, scheduled_delivery_timestamp)
|
||||
self.assert_json_success(result)
|
||||
scheduled_message = self.last_scheduled_reminder()
|
||||
self.assertEqual(
|
||||
scheduled_message.content, self.get_dm_reminder_content(content, message_id)
|
||||
)
|
||||
self.assertEqual(scheduled_message.recipient.type_id, self.example_user("hamlet").id)
|
||||
self.assertEqual(scheduled_message.sender, self.example_user("hamlet"))
|
||||
self.assertEqual(
|
||||
scheduled_message.scheduled_timestamp,
|
||||
timestamp_to_datetime(scheduled_delivery_timestamp),
|
||||
)
|
||||
self.assertEqual(
|
||||
scheduled_message.reminder_target_message_id,
|
||||
message_id,
|
||||
)
|
||||
|
||||
def test_schedule_reminder_with_bad_timestamp(self) -> None:
|
||||
self.login("hamlet")
|
||||
content = "Test message"
|
||||
scheduled_delivery_timestamp = int(time.time() - 86400)
|
||||
|
||||
message_id = self.send_channel_message_for_hamlet(content)
|
||||
result = self.do_schedule_reminder(message_id, scheduled_delivery_timestamp)
|
||||
self.assert_json_error(result, "Scheduled delivery time must be in the future.")
|
||||
|
||||
def test_schedule_reminder_with_bad_message_id(self) -> None:
|
||||
self.login("hamlet")
|
||||
scheduled_delivery_timestamp = int(time.time() + 86400)
|
||||
|
||||
result = self.do_schedule_reminder(123456789, scheduled_delivery_timestamp)
|
||||
self.assert_json_error(result, "Invalid message(s)")
|
||||
|
||||
def test_successful_deliver_direct_message_reminder(self) -> None:
|
||||
# No scheduled message
|
||||
result = try_deliver_one_scheduled_message()
|
||||
self.assertFalse(result)
|
||||
|
||||
content = "Test content"
|
||||
reminder = self.create_reminder(content)
|
||||
|
||||
# mock current time to be greater than the scheduled time, so that the `scheduled_message` can be sent.
|
||||
more_than_scheduled_delivery_datetime = reminder.scheduled_timestamp + datetime.timedelta(
|
||||
minutes=1
|
||||
)
|
||||
|
||||
with (
|
||||
time_machine.travel(more_than_scheduled_delivery_datetime, tick=False),
|
||||
self.assertLogs(level="INFO") as logs,
|
||||
):
|
||||
result = try_deliver_one_scheduled_message()
|
||||
self.assertTrue(result)
|
||||
reminder.refresh_from_db()
|
||||
self.assertEqual(
|
||||
logs.output,
|
||||
[
|
||||
f"INFO:root:Sending scheduled message {reminder.id} with date {reminder.scheduled_timestamp} (sender: {reminder.sender_id})"
|
||||
],
|
||||
)
|
||||
self.assertEqual(reminder.delivered, True)
|
||||
self.assertEqual(reminder.failed, False)
|
||||
assert isinstance(reminder.delivered_message_id, int)
|
||||
delivered_message = Message.objects.get(id=reminder.delivered_message_id)
|
||||
assert isinstance(reminder.reminder_target_message_id, int)
|
||||
self.assertEqual(
|
||||
delivered_message.content,
|
||||
self.get_dm_reminder_content(content, reminder.reminder_target_message_id),
|
||||
)
|
||||
self.assertEqual(delivered_message.date_sent, more_than_scheduled_delivery_datetime)
|
||||
|
||||
def test_successful_deliver_channel_message_reminder(self) -> None:
|
||||
# No scheduled message
|
||||
result = try_deliver_one_scheduled_message()
|
||||
self.assertFalse(result)
|
||||
|
||||
content = "Test content"
|
||||
reminder = self.create_reminder(content, "stream")
|
||||
|
||||
# mock current time to be greater than the scheduled time, so that the `scheduled_message` can be sent.
|
||||
more_than_scheduled_delivery_datetime = reminder.scheduled_timestamp + datetime.timedelta(
|
||||
minutes=1
|
||||
)
|
||||
|
||||
with (
|
||||
time_machine.travel(more_than_scheduled_delivery_datetime, tick=False),
|
||||
self.assertLogs(level="INFO") as logs,
|
||||
):
|
||||
result = try_deliver_one_scheduled_message()
|
||||
self.assertTrue(result)
|
||||
reminder.refresh_from_db()
|
||||
self.assertEqual(
|
||||
logs.output,
|
||||
[
|
||||
f"INFO:root:Sending scheduled message {reminder.id} with date {reminder.scheduled_timestamp} (sender: {reminder.sender_id})"
|
||||
],
|
||||
)
|
||||
self.assertEqual(reminder.delivered, True)
|
||||
self.assertEqual(reminder.failed, False)
|
||||
assert isinstance(reminder.delivered_message_id, int)
|
||||
delivered_message = Message.objects.get(id=reminder.delivered_message_id)
|
||||
assert isinstance(reminder.reminder_target_message_id, int)
|
||||
self.assertEqual(
|
||||
delivered_message.content,
|
||||
self.get_channel_message_reminder_content(
|
||||
content, reminder.reminder_target_message_id
|
||||
),
|
||||
)
|
||||
self.assertEqual(delivered_message.date_sent, more_than_scheduled_delivery_datetime)
|
||||
|
||||
def test_send_reminder_at_max_content_limit(self) -> None:
|
||||
# No scheduled message
|
||||
result = try_deliver_one_scheduled_message()
|
||||
self.assertFalse(result)
|
||||
|
||||
content = "x" * 10000
|
||||
reminder = self.create_reminder(content)
|
||||
|
||||
# mock current time to be greater than the scheduled time, so that the `scheduled_message` can be sent.
|
||||
more_than_scheduled_delivery_datetime = reminder.scheduled_timestamp + datetime.timedelta(
|
||||
minutes=1
|
||||
)
|
||||
|
||||
with (
|
||||
time_machine.travel(more_than_scheduled_delivery_datetime, tick=False),
|
||||
self.assertLogs(level="INFO") as logs,
|
||||
):
|
||||
result = try_deliver_one_scheduled_message()
|
||||
self.assertTrue(result)
|
||||
reminder.refresh_from_db()
|
||||
self.assertEqual(
|
||||
logs.output,
|
||||
[
|
||||
f"INFO:root:Sending scheduled message {reminder.id} with date {reminder.scheduled_timestamp} (sender: {reminder.sender_id})"
|
||||
],
|
||||
)
|
||||
self.assertEqual(reminder.delivered, True)
|
||||
self.assertEqual(reminder.failed, False)
|
||||
assert isinstance(reminder.delivered_message_id, int)
|
||||
delivered_message = Message.objects.get(id=reminder.delivered_message_id)
|
||||
# The reminder message is truncated to 10,000 characters if it exceeds the limit.
|
||||
assert isinstance(reminder.reminder_target_message_id, int)
|
||||
length_of_reminder_content_wrapper = len(
|
||||
self.get_dm_reminder_content(
|
||||
"\n[message truncated]", reminder.reminder_target_message_id
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
delivered_message.content,
|
||||
self.get_dm_reminder_content(
|
||||
content[:-length_of_reminder_content_wrapper] + "\n[message truncated]",
|
||||
reminder.reminder_target_message_id,
|
||||
),
|
||||
)
|
||||
self.assertEqual(delivered_message.date_sent, more_than_scheduled_delivery_datetime)
|
||||
|
||||
def test_scheduled_reminder_with_inaccessible_message(self) -> None:
|
||||
# No scheduled message
|
||||
result = try_deliver_one_scheduled_message()
|
||||
self.assertFalse(result)
|
||||
|
||||
content = "Test content"
|
||||
reminder = self.create_reminder(content)
|
||||
|
||||
# Delete the message to make it inaccessible.
|
||||
assert isinstance(reminder.reminder_target_message_id, int)
|
||||
Message.objects.filter(id=reminder.reminder_target_message_id).delete()
|
||||
|
||||
# mock current time to be greater than the scheduled time, so that the `scheduled_message` can be sent.
|
||||
more_than_scheduled_delivery_datetime = reminder.scheduled_timestamp + datetime.timedelta(
|
||||
minutes=1
|
||||
)
|
||||
with (
|
||||
time_machine.travel(more_than_scheduled_delivery_datetime, tick=False),
|
||||
self.assertLogs(level="INFO") as logs,
|
||||
):
|
||||
result = try_deliver_one_scheduled_message()
|
||||
self.assertTrue(result)
|
||||
reminder.refresh_from_db()
|
||||
self.assertEqual(
|
||||
logs.output,
|
||||
[
|
||||
f"INFO:root:Sending scheduled message {reminder.id} with date {reminder.scheduled_timestamp} (sender: {reminder.sender_id})"
|
||||
],
|
||||
)
|
||||
self.assertEqual(reminder.delivered, True)
|
||||
self.assertEqual(reminder.failed, False)
|
||||
assert isinstance(reminder.delivered_message_id, int)
|
||||
delivered_message = Message.objects.get(id=reminder.delivered_message_id)
|
||||
self.assertEqual(
|
||||
delivered_message.content,
|
||||
self.get_dm_reminder_content(content, reminder.reminder_target_message_id),
|
||||
)
|
||||
self.assertEqual(delivered_message.date_sent, more_than_scheduled_delivery_datetime)
|
@@ -3,6 +3,7 @@ import time
|
||||
from datetime import timedelta
|
||||
from io import StringIO
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest import mock
|
||||
|
||||
import orjson
|
||||
import time_machine
|
||||
@@ -330,7 +331,7 @@ class ScheduledMessageTest(ZulipTestCase):
|
||||
self.assertEqual(message_after_deactivation.content, message_before_deactivation.content)
|
||||
self.assertNotIn(expected_failure_message, message_after_deactivation.content)
|
||||
|
||||
def test_delivery_type_reminder_failed_to_deliver_scheduled_message_unknown_exception(
|
||||
def test_failed_to_deliver_scheduled_message_unknown_exception(
|
||||
self,
|
||||
) -> None:
|
||||
self.create_scheduled_message()
|
||||
@@ -339,11 +340,14 @@ class ScheduledMessageTest(ZulipTestCase):
|
||||
more_than_scheduled_delivery_datetime = scheduled_message.scheduled_timestamp + timedelta(
|
||||
minutes=1
|
||||
)
|
||||
|
||||
with time_machine.travel(more_than_scheduled_delivery_datetime, tick=False):
|
||||
with (
|
||||
mock.patch(
|
||||
"zerver.actions.scheduled_messages.send_scheduled_message",
|
||||
side_effect=Exception(),
|
||||
),
|
||||
time_machine.travel(more_than_scheduled_delivery_datetime, tick=False),
|
||||
):
|
||||
scheduled_message = self.last_scheduled_message()
|
||||
scheduled_message.delivery_type = ScheduledMessage.REMIND
|
||||
scheduled_message.save()
|
||||
with self.assertLogs(level="INFO") as logs:
|
||||
result = try_deliver_one_scheduled_message()
|
||||
self.assertTrue(result)
|
||||
|
35
zerver/views/reminders.py
Normal file
35
zerver/views/reminders.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.timezone import now as timezone_now
|
||||
from pydantic import Json
|
||||
|
||||
from zerver.actions.reminders import schedule_reminder_for_message
|
||||
from zerver.lib.exceptions import DeliveryTimeNotInFutureError
|
||||
from zerver.lib.request import RequestNotes
|
||||
from zerver.lib.response import json_success
|
||||
from zerver.lib.timestamp import timestamp_to_datetime
|
||||
from zerver.lib.typed_endpoint import typed_endpoint
|
||||
from zerver.models import UserProfile
|
||||
|
||||
|
||||
@typed_endpoint
|
||||
def create_reminders_message_backend(
|
||||
request: HttpRequest,
|
||||
user_profile: UserProfile,
|
||||
*,
|
||||
message_id: Json[int],
|
||||
scheduled_delivery_timestamp: Json[int],
|
||||
) -> HttpResponse:
|
||||
deliver_at = timestamp_to_datetime(scheduled_delivery_timestamp)
|
||||
if deliver_at <= timezone_now():
|
||||
raise DeliveryTimeNotInFutureError
|
||||
|
||||
client = RequestNotes.get_notes(request).client
|
||||
assert client is not None
|
||||
|
||||
reminder_id = schedule_reminder_for_message(
|
||||
user_profile,
|
||||
client,
|
||||
message_id,
|
||||
deliver_at,
|
||||
)
|
||||
return json_success(request, data={"reminder_id": reminder_id})
|
@@ -150,6 +150,7 @@ from zerver.views.registration import (
|
||||
realm_register,
|
||||
signup_send_confirm,
|
||||
)
|
||||
from zerver.views.reminders import create_reminders_message_backend
|
||||
from zerver.views.report import report_csp_violations
|
||||
from zerver.views.saved_snippets import (
|
||||
create_saved_snippet,
|
||||
@@ -353,7 +354,7 @@ v1_api_and_json_patterns = [
|
||||
DELETE=delete_saved_snippet,
|
||||
PATCH=edit_saved_snippet,
|
||||
),
|
||||
# New scheduled messages are created via send_message_backend.
|
||||
rest_path("reminders", POST=create_reminders_message_backend),
|
||||
rest_path(
|
||||
"scheduled_messages", GET=fetch_scheduled_messages, POST=create_scheduled_message_backend
|
||||
),
|
||||
|
Reference in New Issue
Block a user