urls: Add new endpoint to create scheduled messages.

This will help us remove scheduled message and reminder logic
from `/messages` code path.

Removes `deliver_at`/`defer_until` and `tz_guess` parameters. And
adds the `scheduled_delivery_timestamp` instead. Also updates the
scheduled message dicts to return `scheduled_delivery_timestamp`.

Also, revises some text in `/delete-scheduled-message` endpoint
and in the `ScheduledMessage` schema in the API documentation.
This commit is contained in:
Aman Agrawal 2023-04-20 02:26:41 +00:00 committed by Tim Abbott
parent 7739703111
commit d60d6e9115
6 changed files with 315 additions and 254 deletions

View File

@ -4288,7 +4288,7 @@ class StreamScheduledMessageAPI(TypedDict):
content: str content: str
rendered_content: str rendered_content: str
topic: str topic: str
deliver_at: int scheduled_delivery_timestamp: int
class DirectScheduledMessageAPI(TypedDict): class DirectScheduledMessageAPI(TypedDict):
@ -4297,7 +4297,7 @@ class DirectScheduledMessageAPI(TypedDict):
type: str type: str
content: str content: str
rendered_content: str rendered_content: str
deliver_at: int scheduled_delivery_timestamp: int
class ScheduledMessage(models.Model): class ScheduledMessage(models.Model):
@ -4348,7 +4348,7 @@ class ScheduledMessage(models.Model):
type=recipient_type_str, type=recipient_type_str,
content=self.content, content=self.content,
rendered_content=self.rendered_content, rendered_content=self.rendered_content,
deliver_at=int(self.scheduled_timestamp.timestamp() * 1000), scheduled_delivery_timestamp=datetime_to_timestamp(self.scheduled_timestamp),
) )
# The recipient for stream messages should always just be the unique stream ID. # The recipient for stream messages should always just be the unique stream ID.
@ -4361,7 +4361,7 @@ class ScheduledMessage(models.Model):
content=self.content, content=self.content,
rendered_content=self.rendered_content, rendered_content=self.rendered_content,
topic=self.topic_name(), topic=self.topic_name(),
deliver_at=int(self.scheduled_timestamp.timestamp() * 1000), scheduled_delivery_timestamp=datetime_to_timestamp(self.scheduled_timestamp),
) )

View File

@ -5021,8 +5021,9 @@ paths:
scheduled_messages: scheduled_messages:
type: array type: array
description: | description: |
Returns all of the current user's scheduled Returns all of the current user's undelivered scheduled
messages, ordered by scheduled timestamp (ascending). messages, ordered by `scheduled_delivery_timestamp`
(ascending).
items: items:
$ref: "#/components/schemas/ScheduledMessage" $ref: "#/components/schemas/ScheduledMessage"
example: example:
@ -5033,7 +5034,7 @@ paths:
[ [
{ {
"scheduled_message_id": 27, "scheduled_message_id": 27,
"to": [14], "to": 14,
"type": "stream", "type": "stream",
"content": "Hi", "content": "Hi",
"rendered_content": "<p>Hi</p>", "rendered_content": "<p>Hi</p>",
@ -5042,13 +5043,160 @@ paths:
}, },
], ],
} }
post:
operationId: create-or-update-scheduled-messages
tags: ["scheduled_messages"]
summary: Create or edit scheduled messages
description: |
Create a new scheduled message or edit an existing scheduled message.
The `scheduled_message_id` parameter determines whether a scheduled
message is created or updated. When it is omitted, a new scheduled
message is created. When it is specified, the existing scheduled
message with that unique ID is updated for the values passed to the
other endpoint parameters.
parameters:
- name: type
in: query
description: |
The type of scheduled message to be sent. `"direct"` for a direct
message and `"stream"` for a stream message.
In Zulip 7.0 (feature level 174), `"direct"` was added as the
preferred way to indicate the type of a direct message, deprecating
the original `"private"`. While `"private"` is supported for
scheduling direct messages, clients are encouraged to use to the
modern convention because support for `"private"` may eventually
be removed.
schema:
type: string
enum:
- direct
- stream
- private
example: direct
required: true
- name: to
in: query
description: |
The scheduled message's tentative target audience.
For stream messages, integer ID of the stream. For direct messages,
either a list containing integer user IDs or a list containing string
email addresses.
content:
application/json:
schema:
oneOf:
- type: integer
- type: array
items:
type: string
- type: array
items:
type: integer
minLength: 1
example: [9, 10]
required: true
- $ref: "#/components/parameters/RequiredContent"
- name: topic
in: query
description: |
The topic of the message. Only required for stream messages
(`type="stream"`).
Clients should use the `max_topic_length` returned by the
[`POST /register`](/api/register-queue) endpoint to determine
the maximum topic length.
schema:
type: string
example: Castle
allowEmptyValue: true
- name: scheduled_message_id
in: query
schema:
type: integer
description: |
The unique ID of the scheduled message to be updated.
If omitted, a new scheduled message will be created. Otherwise,
the existing scheduled message with this unique ID will be updated.
example: 1
- name: scheduled_delivery_timestamp
in: query
schema:
type: integer
description: |
The UNIX timestamp for when the message will be sent,
in UTC seconds.
required: true
example: 3165826990
responses:
"200":
description: Success.
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/JsonSuccessBase"
- $ref: "#/components/schemas/SuccessDescription"
- additionalProperties: false
properties:
result: {}
msg: {}
ignored_parameters_unsupported: {}
scheduled_message_id:
type: integer
description: |
The unique ID of the scheduled message.
This is different from the unique ID that the message will have
after it is sent.
example:
{
"msg": "",
"scheduled_message_id": 42,
"result": "success",
}
"400":
description: Bad request.
content:
application/json:
schema:
oneOf:
- allOf:
- $ref: "#/components/schemas/NonExistingStreamError"
- description: |
A typical failed JSON response for when a stream message is sent to a stream
that does not exist:
- allOf:
- $ref: "#/components/schemas/CodedError"
- example:
{
"code": "BAD_REQUEST",
"msg": "Invalid email 'eeshan@zulip.com'",
"result": "error",
}
description: |
A typical failed JSON response for when a private message is sent to a user
that does not exist:
- allOf:
- $ref: "#/components/schemas/JsonError"
- description: |
Example response for when no scheduled message exists with the provided ID.
example:
{
"code": "BAD_REQUEST",
"result": "error",
"msg": "Scheduled message does not exist",
}
/scheduled_messages/{scheduled_message_id}: /scheduled_messages/{scheduled_message_id}:
delete: delete:
operationId: delete-scheduled-message operationId: delete-scheduled-message
tags: ["scheduled_messages"] tags: ["scheduled_messages"]
summary: Delete a scheduled message. summary: Delete a scheduled message
description: | description: |
Delete/cancel a previously scheduled message. Delete, and therefore cancel sending, a previously scheduled message.
**Changes**: New in Zulip 7.0 (feature level 173). **Changes**: New in Zulip 7.0 (feature level 173).
parameters: parameters:
@ -5057,7 +5205,10 @@ paths:
schema: schema:
type: integer type: integer
description: | description: |
The ID of the scheduled message you want to delete. The ID of the scheduled message to delete.
This is different from the unique ID that a message would have
after it was sent.
required: True required: True
example: 1 example: 1
responses: responses:
@ -5074,6 +5225,7 @@ paths:
Example response for when no scheduled message exists with the provided ID. Example response for when no scheduled message exists with the provided ID.
example: example:
{ {
"code": "BAD_REQUEST",
"result": "error", "result": "error",
"msg": "Scheduled message does not exist", "msg": "Scheduled message does not exist",
} }
@ -17340,21 +17492,21 @@ components:
ScheduledMessage: ScheduledMessage:
type: object type: object
description: | description: |
A dictionary for representing a scheduled message. Object containing details of the scheduled message.
properties: properties:
scheduled_message_id: scheduled_message_id:
type: integer type: integer
description: | description: |
The unique ID of the scheduled message. It can be used to modify and The unique ID of the scheduled message, which can be used to
delete the scheduled message. This ID is different than the ID of the modify or delete the scheduled message.
message that will actually be sent.
This is different from the unique ID that the message will have
after it is sent.
type: type:
type: string type: string
description: | description: |
The type of the scheduled message. Either unaddressed (empty string), "stream", The type of the scheduled message. Either `"stream"` or `"private"`.
or "private" (for PMs and private group messages).
enum: enum:
- ""
- stream - stream
- private - private
to: to:
@ -17378,16 +17530,16 @@ components:
content: content:
type: string type: string
description: | description: |
The body of the scheduled message. Should not contain null bytes. The content/body of the scheduled message, in text/markdown format.
rendered_content: rendered_content:
type: string type: string
description: | description: |
The body of the scheduled message rendered in HTML. The content/body of the scheduled message rendered in HTML.
deliver_at: scheduled_delivery_timestamp:
type: number type: integer
description: | description: |
A Unix timestamp (seconds only) representing when the scheduled The UNIX timestamp for when the message will be sent
message will be sent by the server. by the server, in UTC seconds.
example: 1595479019 example: 1595479019
additionalProperties: false additionalProperties: false
required: required:
@ -17396,7 +17548,7 @@ components:
- to - to
- content - content
- rendered_content - rendered_content
- deliver_at - scheduled_delivery_timestamp
User: User:
allOf: allOf:
- $ref: "#/components/schemas/UserBase" - $ref: "#/components/schemas/UserBase"

View File

@ -1,19 +1,12 @@
import datetime import time
import sys
from typing import TYPE_CHECKING, List, Union from typing import TYPE_CHECKING, List, Union
import orjson import orjson
from django.utils.timezone import now as timezone_now
from zerver.lib.test_classes import ZulipTestCase from zerver.lib.test_classes import ZulipTestCase
from zerver.lib.timestamp import convert_to_UTC from zerver.lib.timestamp import timestamp_to_datetime
from zerver.models import ScheduledMessage from zerver.models import ScheduledMessage
if sys.version_info < (3, 9): # nocoverage
from backports import zoneinfo
else: # nocoverage
import zoneinfo
if TYPE_CHECKING: if TYPE_CHECKING:
from django.test.client import _MonkeyPatchedWSGIResponse as TestHttpResponse from django.test.client import _MonkeyPatchedWSGIResponse as TestHttpResponse
@ -28,11 +21,9 @@ class ScheduledMessageTest(ZulipTestCase):
def do_schedule_message( def do_schedule_message(
self, self,
msg_type: str, msg_type: str,
to: Union[str, int, List[str], List[int]], to: Union[int, List[str], List[int]],
msg: str, msg: str,
defer_until: str = "", scheduled_delivery_timestamp: int,
tz_guess: str = "",
delivery_type: str = "send_later",
scheduled_message_id: str = "", scheduled_message_id: str = "",
) -> "TestHttpResponse": ) -> "TestHttpResponse":
self.login("hamlet") self.login("hamlet")
@ -46,158 +37,98 @@ class ScheduledMessageTest(ZulipTestCase):
"to": orjson.dumps(to).decode(), "to": orjson.dumps(to).decode(),
"content": msg, "content": msg,
"topic": topic_name, "topic": topic_name,
"delivery_type": delivery_type, "scheduled_delivery_timestamp": scheduled_delivery_timestamp,
"tz_guess": tz_guess,
} }
if defer_until:
payload["deliver_at"] = defer_until
if scheduled_message_id: if scheduled_message_id:
payload["scheduled_message_id"] = scheduled_message_id payload["scheduled_message_id"] = scheduled_message_id
# `Topic` cannot be empty according to OpenAPI specification. result = self.client_post("/json/scheduled_messages", payload)
intentionally_undocumented: bool = topic_name == ""
result = self.client_post(
"/json/messages", payload, intentionally_undocumented=intentionally_undocumented
)
return result return result
def test_schedule_message(self) -> None: def test_schedule_message(self) -> None:
content = "Test message" content = "Test message"
defer_until = timezone_now().replace(tzinfo=None) + datetime.timedelta(days=1) scheduled_delivery_timestamp = int(time.time() + 86400)
defer_until_str = str(defer_until) verona_stream_id = self.get_stream_id("Verona")
# Scheduling a message to a stream you are subscribed is successful. # Scheduling a message to a stream you are subscribed is successful.
result = self.do_schedule_message("stream", "Verona", content + " 1", defer_until_str)
message = self.last_scheduled_message()
self.assert_json_success(result)
self.assertEqual(message.content, "Test message 1")
self.assertEqual(message.rendered_content, "<p>Test message 1</p>")
self.assertEqual(message.topic_name(), "Test topic")
self.assertEqual(message.scheduled_timestamp, convert_to_UTC(defer_until))
self.assertEqual(message.delivery_type, ScheduledMessage.SEND_LATER)
# Scheduling a message for reminders.
result = self.do_schedule_message( result = self.do_schedule_message(
"stream", "Verona", content + " 2", defer_until_str, delivery_type="remind" "stream", verona_stream_id, content + " 1", scheduled_delivery_timestamp
) )
message = self.last_scheduled_message() scheduled_message = self.last_scheduled_message()
self.assert_json_success(result) self.assert_json_success(result)
self.assertEqual(message.delivery_type, ScheduledMessage.REMIND) self.assertEqual(scheduled_message.content, "Test message 1")
self.assertEqual(scheduled_message.rendered_content, "<p>Test message 1</p>")
self.assertEqual(scheduled_message.topic_name(), "Test topic")
self.assertEqual(
scheduled_message.scheduled_timestamp,
timestamp_to_datetime(scheduled_delivery_timestamp),
)
# Scheduling a private message is successful. # Scheduling a private message is successful.
othello = self.example_user("othello") othello = self.example_user("othello")
hamlet = self.example_user("hamlet")
result = self.do_schedule_message( result = self.do_schedule_message(
"private", [othello.email], content + " 3", defer_until_str "direct", [othello.email], content + " 3", scheduled_delivery_timestamp
) )
message = self.last_scheduled_message() scheduled_message = self.last_scheduled_message()
self.assert_json_success(result) self.assert_json_success(result)
self.assertEqual(message.content, "Test message 3") self.assertEqual(scheduled_message.content, "Test message 3")
self.assertEqual(message.rendered_content, "<p>Test message 3</p>") self.assertEqual(scheduled_message.rendered_content, "<p>Test message 3</p>")
self.assertEqual(message.scheduled_timestamp, convert_to_UTC(defer_until)) self.assertEqual(
self.assertEqual(message.delivery_type, ScheduledMessage.SEND_LATER) scheduled_message.scheduled_timestamp,
timestamp_to_datetime(scheduled_delivery_timestamp),
# Setting a reminder in PM's to other users causes a error.
result = self.do_schedule_message(
"private", [othello.email], content + " 4", defer_until_str, delivery_type="remind"
) )
self.assert_json_error(result, "Reminders can only be set for streams.")
# Setting a reminder in PM's to ourself is successful.
# Required by reminders from message actions popover caret feature.
result = self.do_schedule_message(
"private", [hamlet.email], content + " 5", defer_until_str, delivery_type="remind"
)
message = self.last_scheduled_message()
self.assert_json_success(result)
self.assertEqual(message.content, "Test message 5")
self.assertEqual(message.delivery_type, ScheduledMessage.REMIND)
# Scheduling a message while guessing time zone.
tz_guess = "Asia/Kolkata"
result = self.do_schedule_message(
"stream", "Verona", content + " 6", defer_until_str, tz_guess=tz_guess
)
message = self.last_scheduled_message()
self.assert_json_success(result)
self.assertEqual(message.content, "Test message 6")
local_tz = zoneinfo.ZoneInfo(tz_guess)
utz_defer_until = defer_until.replace(tzinfo=local_tz)
self.assertEqual(message.scheduled_timestamp, convert_to_UTC(utz_defer_until))
self.assertEqual(message.delivery_type, ScheduledMessage.SEND_LATER)
# Test with users time zone setting as set to some time zone rather than
# empty. This will help interpret timestamp in users local time zone.
user = self.example_user("hamlet")
user.timezone = "US/Pacific"
user.save(update_fields=["timezone"])
result = self.do_schedule_message("stream", "Verona", content + " 7", defer_until_str)
message = self.last_scheduled_message()
self.assert_json_success(result)
self.assertEqual(message.content, "Test message 7")
local_tz = zoneinfo.ZoneInfo(user.timezone)
utz_defer_until = defer_until.replace(tzinfo=local_tz)
self.assertEqual(message.scheduled_timestamp, convert_to_UTC(utz_defer_until))
self.assertEqual(message.delivery_type, ScheduledMessage.SEND_LATER)
def test_scheduling_in_past(self) -> None: def test_scheduling_in_past(self) -> None:
# Scheduling a message in past should fail. # Scheduling a message in past should fail.
content = "Test message" content = "Test message"
defer_until = timezone_now() verona_stream_id = self.get_stream_id("Verona")
defer_until_str = str(defer_until) scheduled_delivery_timestamp = int(time.time() - 86400)
result = self.do_schedule_message("stream", "Verona", content + " 1", defer_until_str) result = self.do_schedule_message(
self.assert_json_error(result, "Time must be in the future.") "stream", verona_stream_id, content + " 1", scheduled_delivery_timestamp
def test_invalid_timestamp(self) -> None:
# Scheduling a message from which timestamp couldn't be parsed
# successfully should fail.
content = "Test message"
defer_until = "Missed the timestamp"
result = self.do_schedule_message("stream", "Verona", content + " 1", defer_until)
self.assert_json_error(result, "Invalid time format")
def test_missing_deliver_at(self) -> None:
content = "Test message"
result = self.do_schedule_message("stream", "Verona", content + " 1")
self.assert_json_error(
result, "Missing deliver_at in a request for delayed message delivery"
) )
self.assert_json_error(result, "Scheduled delivery time must be in the future.")
def test_edit_schedule_message(self) -> None: def test_edit_schedule_message(self) -> None:
content = "Original test message" content = "Original test message"
defer_until = timezone_now().replace(tzinfo=None) + datetime.timedelta(days=1) scheduled_delivery_timestamp = int(time.time() + 86400)
defer_until_str = str(defer_until) verona_stream_id = self.get_stream_id("Verona")
# Scheduling a message to a stream you are subscribed is successful. # Scheduling a message to a stream you are subscribed is successful.
result = self.do_schedule_message("stream", "Verona", content, defer_until_str) result = self.do_schedule_message(
message = self.last_scheduled_message() "stream", verona_stream_id, content, scheduled_delivery_timestamp
)
scheduled_message = self.last_scheduled_message()
self.assert_json_success(result) self.assert_json_success(result)
self.assertEqual(message.content, "Original test message") self.assertEqual(scheduled_message.content, "Original test message")
self.assertEqual(message.topic_name(), "Test topic") self.assertEqual(scheduled_message.topic_name(), "Test topic")
self.assertEqual(message.scheduled_timestamp, convert_to_UTC(defer_until)) self.assertEqual(
self.assertEqual(message.delivery_type, ScheduledMessage.SEND_LATER) scheduled_message.scheduled_timestamp,
timestamp_to_datetime(scheduled_delivery_timestamp),
)
# Edit content and time of scheduled message. # Edit content and time of scheduled message.
edited_content = "Edited test message" edited_content = "Edited test message"
new_defer_until = defer_until + datetime.timedelta(days=3) new_scheduled_delivery_timestamp = scheduled_delivery_timestamp + int(
new_defer_until_str = str(new_defer_until) time.time() + (3 * 86400)
)
result = self.do_schedule_message( result = self.do_schedule_message(
"stream", "stream",
"Verona", verona_stream_id,
edited_content, edited_content,
new_defer_until_str, new_scheduled_delivery_timestamp,
scheduled_message_id=str(message.id), scheduled_message_id=str(scheduled_message.id),
) )
message = self.get_scheduled_message(str(message.id)) scheduled_message = self.get_scheduled_message(str(scheduled_message.id))
self.assert_json_success(result) self.assert_json_success(result)
self.assertEqual(message.content, edited_content) self.assertEqual(scheduled_message.content, edited_content)
self.assertEqual(message.topic_name(), "Test topic") self.assertEqual(scheduled_message.topic_name(), "Test topic")
self.assertEqual(message.scheduled_timestamp, convert_to_UTC(new_defer_until)) self.assertEqual(
self.assertEqual(message.delivery_type, ScheduledMessage.SEND_LATER) scheduled_message.scheduled_timestamp,
timestamp_to_datetime(new_scheduled_delivery_timestamp),
)
def test_fetch_scheduled_messages(self) -> None: def test_fetch_scheduled_messages(self) -> None:
self.login("hamlet") self.login("hamlet")
@ -206,10 +137,10 @@ class ScheduledMessageTest(ZulipTestCase):
self.assert_json_success(result) self.assert_json_success(result)
self.assert_length(orjson.loads(result.content)["scheduled_messages"], 0) self.assert_length(orjson.loads(result.content)["scheduled_messages"], 0)
verona_stream_id = self.get_stream_id("Verona")
content = "Test message" content = "Test message"
defer_until = timezone_now().replace(tzinfo=None) + datetime.timedelta(days=1) scheduled_delivery_timestamp = int(time.time() + 86400)
defer_until_str = str(defer_until) self.do_schedule_message("stream", verona_stream_id, content, scheduled_delivery_timestamp)
self.do_schedule_message("stream", "Verona", content, defer_until_str)
# Single scheduled message # Single scheduled message
result = self.client_get("/json/scheduled_messages") result = self.client_get("/json/scheduled_messages")
@ -221,16 +152,16 @@ class ScheduledMessageTest(ZulipTestCase):
scheduled_messages[0]["scheduled_message_id"], self.last_scheduled_message().id scheduled_messages[0]["scheduled_message_id"], self.last_scheduled_message().id
) )
self.assertEqual(scheduled_messages[0]["content"], content) self.assertEqual(scheduled_messages[0]["content"], content)
self.assertEqual(scheduled_messages[0]["to"], self.get_stream_id("Verona")) self.assertEqual(scheduled_messages[0]["to"], verona_stream_id)
self.assertEqual(scheduled_messages[0]["type"], "stream") self.assertEqual(scheduled_messages[0]["type"], "stream")
self.assertEqual(scheduled_messages[0]["topic"], "Test topic") self.assertEqual(scheduled_messages[0]["topic"], "Test topic")
self.assertEqual( self.assertEqual(
scheduled_messages[0]["deliver_at"], int(convert_to_UTC(defer_until).timestamp() * 1000) scheduled_messages[0]["scheduled_delivery_timestamp"], scheduled_delivery_timestamp
) )
othello = self.example_user("othello") othello = self.example_user("othello")
result = self.do_schedule_message( result = self.do_schedule_message(
"private", [othello.email], content + " 3", defer_until_str "direct", [othello.email], content + " 3", scheduled_delivery_timestamp
) )
# Multiple scheduled messages # Multiple scheduled messages
@ -249,21 +180,22 @@ class ScheduledMessageTest(ZulipTestCase):
self.login("hamlet") self.login("hamlet")
content = "Test message" content = "Test message"
defer_until = timezone_now().replace(tzinfo=None) + datetime.timedelta(days=1) verona_stream_id = self.get_stream_id("Verona")
defer_until_str = str(defer_until) scheduled_delivery_timestamp = int(time.time() + 86400)
self.do_schedule_message("stream", "Verona", content, defer_until_str)
message = self.last_scheduled_message() self.do_schedule_message("stream", verona_stream_id, content, scheduled_delivery_timestamp)
scheduled_message = self.last_scheduled_message()
self.logout() self.logout()
# Other user cannot delete it. # Other user cannot delete it.
othello = self.example_user("othello") othello = self.example_user("othello")
result = self.api_delete(othello, f"/api/v1/scheduled_messages/{message.id}") result = self.api_delete(othello, f"/api/v1/scheduled_messages/{scheduled_message.id}")
self.assert_json_error(result, "Scheduled message does not exist", 404) self.assert_json_error(result, "Scheduled message does not exist", 404)
self.login("hamlet") self.login("hamlet")
result = self.client_delete(f"/json/scheduled_messages/{message.id}") result = self.client_delete(f"/json/scheduled_messages/{scheduled_message.id}")
self.assert_json_success(result) self.assert_json_success(result)
# Already deleted. # Already deleted.
result = self.client_delete(f"/json/scheduled_messages/{message.id}") result = self.client_delete(f"/json/scheduled_messages/{scheduled_message.id}")
self.assert_json_error(result, "Scheduled message does not exist", 404) self.assert_json_error(result, "Scheduled message does not exist", 404)

View File

@ -1,12 +1,9 @@
import sys
from email.headerregistry import Address from email.headerregistry import Address
from typing import Iterable, Optional, Sequence, Union, cast from typing import Iterable, Optional, Sequence, Union, cast
from dateutil.parser import parse as dateparser
from django.core import validators from django.core import validators
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.utils.timezone import now as timezone_now
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from zerver.actions.message_send import ( from zerver.actions.message_send import (
@ -17,30 +14,22 @@ from zerver.actions.message_send import (
extract_private_recipients, extract_private_recipients,
extract_stream_indicator, extract_stream_indicator,
) )
from zerver.actions.scheduled_messages import check_schedule_message
from zerver.lib.exceptions import JsonableError from zerver.lib.exceptions import JsonableError
from zerver.lib.message import render_markdown from zerver.lib.message import render_markdown
from zerver.lib.request import REQ, RequestNotes, has_request_variables from zerver.lib.request import REQ, RequestNotes, has_request_variables
from zerver.lib.response import json_success from zerver.lib.response import json_success
from zerver.lib.timestamp import convert_to_UTC
from zerver.lib.topic import REQ_topic from zerver.lib.topic import REQ_topic
from zerver.lib.validator import check_int, check_string_in, to_float from zerver.lib.validator import check_string_in, to_float
from zerver.lib.zcommand import process_zcommands from zerver.lib.zcommand import process_zcommands
from zerver.lib.zephyr import compute_mit_user_fullname from zerver.lib.zephyr import compute_mit_user_fullname
from zerver.models import ( from zerver.models import (
Client, Client,
Message, Message,
Realm,
RealmDomain, RealmDomain,
UserProfile, UserProfile,
get_user_including_cross_realm, get_user_including_cross_realm,
) )
if sys.version_info < (3, 9): # nocoverage
from backports import zoneinfo
else: # nocoverage
import zoneinfo
class InvalidMirrorInputError(Exception): class InvalidMirrorInputError(Exception):
pass pass
@ -139,56 +128,6 @@ def same_realm_jabber_user(user_profile: UserProfile, email: str) -> bool:
return RealmDomain.objects.filter(realm=user_profile.realm, domain=domain).exists() return RealmDomain.objects.filter(realm=user_profile.realm, domain=domain).exists()
def handle_deferred_message(
sender: UserProfile,
client: Client,
recipient_type_name: str,
message_to: Union[Sequence[str], Sequence[int]],
topic_name: Optional[str],
message_content: str,
scheduled_message_id: Optional[int],
delivery_type: str,
defer_until: str,
tz_guess: Optional[str],
forwarder_user_profile: UserProfile,
realm: Optional[Realm],
) -> str:
deliver_at = None
local_tz = "UTC"
if tz_guess:
local_tz = tz_guess
elif sender.timezone:
local_tz = sender.timezone
try:
deliver_at = dateparser(defer_until)
except ValueError:
raise JsonableError(_("Invalid time format"))
deliver_at_usertz = deliver_at
if deliver_at_usertz.tzinfo is None:
user_tz = zoneinfo.ZoneInfo(local_tz)
deliver_at_usertz = deliver_at.replace(tzinfo=user_tz)
deliver_at = convert_to_UTC(deliver_at_usertz)
if deliver_at <= timezone_now():
raise JsonableError(_("Time must be in the future."))
check_schedule_message(
sender,
client,
recipient_type_name,
message_to,
topic_name,
message_content,
scheduled_message_id,
delivery_type,
deliver_at,
realm=realm,
forwarder_user_profile=forwarder_user_profile,
)
return str(deliver_at_usertz)
@has_request_variables @has_request_variables
def send_message_backend( def send_message_backend(
request: HttpRequest, request: HttpRequest,
@ -202,12 +141,6 @@ def send_message_backend(
widget_content: Optional[str] = REQ(default=None, documentation_pending=True), widget_content: Optional[str] = REQ(default=None, documentation_pending=True),
local_id: Optional[str] = REQ(default=None), local_id: Optional[str] = REQ(default=None),
queue_id: Optional[str] = REQ(default=None), queue_id: Optional[str] = REQ(default=None),
scheduled_message_id: Optional[int] = REQ(
default=None, json_validator=check_int, documentation_pending=True
),
delivery_type: str = REQ("delivery_type", default="send_now", documentation_pending=True),
defer_until: Optional[str] = REQ("deliver_at", default=None, documentation_pending=True),
tz_guess: Optional[str] = REQ("tz_guess", default=None, documentation_pending=True),
time: Optional[float] = REQ(default=None, converter=to_float, documentation_pending=True), time: Optional[float] = REQ(default=None, converter=to_float, documentation_pending=True),
) -> HttpResponse: ) -> HttpResponse:
recipient_type_name = req_type recipient_type_name = req_type
@ -294,26 +227,6 @@ def send_message_backend(
raise JsonableError(_("Invalid mirrored message")) raise JsonableError(_("Invalid mirrored message"))
sender = user_profile sender = user_profile
if (delivery_type == "send_later" or delivery_type == "remind") and defer_until is None:
raise JsonableError(_("Missing deliver_at in a request for delayed message delivery"))
if (delivery_type == "send_later" or delivery_type == "remind") and defer_until is not None:
deliver_at = handle_deferred_message(
sender,
client,
recipient_type_name,
message_to,
topic_name,
message_content,
scheduled_message_id,
delivery_type,
defer_until,
tz_guess,
forwarder_user_profile=user_profile,
realm=realm,
)
return json_success(request, data={"deliver_at": deliver_at})
ret = check_send_message( ret = check_send_message(
sender, sender,
client, client,

View File

@ -1,10 +1,22 @@
from django.http import HttpRequest, HttpResponse from typing import Optional, Sequence, Union
from zerver.actions.scheduled_messages import delete_scheduled_message from django.http import HttpRequest, HttpResponse
from zerver.lib.request import has_request_variables from django.utils.timezone import now as timezone_now
from django.utils.translation import gettext as _
from zerver.actions.message_send import extract_private_recipients
from zerver.actions.scheduled_messages import (
check_schedule_message,
delete_scheduled_message,
)
from zerver.lib.exceptions import JsonableError
from zerver.lib.request import REQ, RequestNotes, has_request_variables
from zerver.lib.response import json_success from zerver.lib.response import json_success
from zerver.lib.scheduled_messages import get_undelivered_scheduled_messages from zerver.lib.scheduled_messages import get_undelivered_scheduled_messages
from zerver.models import UserProfile from zerver.lib.timestamp import timestamp_to_datetime
from zerver.lib.topic import REQ_topic
from zerver.lib.validator import check_int, check_string_in
from zerver.models import Message, UserProfile
@has_request_variables @has_request_variables
@ -20,3 +32,51 @@ def delete_scheduled_messages(
) -> HttpResponse: ) -> HttpResponse:
delete_scheduled_message(user_profile, scheduled_message_id) delete_scheduled_message(user_profile, scheduled_message_id)
return json_success(request) return json_success(request)
@has_request_variables
def scheduled_messages_backend(
request: HttpRequest,
user_profile: UserProfile,
req_type: str = REQ("type", str_validator=check_string_in(Message.API_RECIPIENT_TYPES)),
req_to: str = REQ("to"),
topic_name: Optional[str] = REQ_topic(),
message_content: str = REQ("content"),
scheduled_message_id: Optional[int] = REQ(default=None, json_validator=check_int),
scheduled_delivery_timestamp: int = REQ(json_validator=check_int),
) -> HttpResponse:
recipient_type_name = req_type
if recipient_type_name == "direct":
# For now, use "private" from Message.API_RECIPIENT_TYPES.
# TODO: Use "direct" here, as well as in events and
# scheduled message objects/dicts.
recipient_type_name = "private"
deliver_at = timestamp_to_datetime(scheduled_delivery_timestamp)
if deliver_at <= timezone_now():
raise JsonableError(_("Scheduled delivery time must be in the future."))
sender = user_profile
client = RequestNotes.get_notes(request).client
assert client is not None
if recipient_type_name == "stream":
# req_to is ID of the recipient stream.
message_to: Union[Sequence[str], Sequence[int]] = [int(req_to)]
else:
message_to = extract_private_recipients(req_to)
scheduled_message_id = check_schedule_message(
sender,
client,
recipient_type_name,
message_to,
topic_name,
message_content,
scheduled_message_id,
"send_later",
deliver_at,
realm=user_profile.realm,
forwarder_user_profile=user_profile,
)
return json_success(request, data={"scheduled_message_id": scheduled_message_id})

View File

@ -136,7 +136,11 @@ from zerver.views.report import (
report_send_times, report_send_times,
report_unnarrow_times, report_unnarrow_times,
) )
from zerver.views.scheduled_messages import delete_scheduled_messages, fetch_scheduled_messages from zerver.views.scheduled_messages import (
delete_scheduled_messages,
fetch_scheduled_messages,
scheduled_messages_backend,
)
from zerver.views.sentry import sentry_tunnel from zerver.views.sentry import sentry_tunnel
from zerver.views.storage import get_storage, remove_storage, update_storage from zerver.views.storage import get_storage, remove_storage, update_storage
from zerver.views.streams import ( from zerver.views.streams import (
@ -323,7 +327,7 @@ v1_api_and_json_patterns = [
rest_path("drafts", GET=fetch_drafts, POST=create_drafts), rest_path("drafts", GET=fetch_drafts, POST=create_drafts),
rest_path("drafts/<int:draft_id>", PATCH=edit_draft, DELETE=delete_draft), rest_path("drafts/<int:draft_id>", PATCH=edit_draft, DELETE=delete_draft),
# New scheduled messages are created via send_message_backend. # New scheduled messages are created via send_message_backend.
rest_path("scheduled_messages", GET=fetch_scheduled_messages), rest_path("scheduled_messages", GET=fetch_scheduled_messages, POST=scheduled_messages_backend),
rest_path("scheduled_messages/<int:scheduled_message_id>", DELETE=delete_scheduled_messages), rest_path("scheduled_messages/<int:scheduled_message_id>", DELETE=delete_scheduled_messages),
# messages -> zerver.views.message* # messages -> zerver.views.message*
# GET returns messages, possibly filtered, POST sends a message # GET returns messages, possibly filtered, POST sends a message