Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.

Commit 54dd5dc

Browse files
authored
Add ephemeral messages support (MSC2228) (#6409)
Implement part [MSC2228](matrix-org/matrix-spec-proposals#2228). The parts that differ are: * the feature is hidden behind a configuration flag (`enable_ephemeral_messages`) * self-destruction doesn't happen for state events * only implement support for the `m.self_destruct_after` field (not the `m.self_destruct` one) * doesn't send synthetic redactions to clients because for this specific case we consider the clients to be able to destroy an event themselves, instead we just censor it (by pruning its JSON) in the database
1 parent 620f98b commit 54dd5dc

File tree

8 files changed

+379
-7
lines changed

8 files changed

+379
-7
lines changed

changelog.d/6409.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add ephemeral messages support by partially implementing [MSC2228](https://github.com/matrix-org/matrix-doc/pull/2228).

synapse/api/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,7 @@ class EventContentFields(object):
147147

148148
# Labels for the event, cf https://github.com/matrix-org/matrix-doc/pull/2326
149149
LABELS = "org.matrix.labels"
150+
151+
# Timestamp to delete the event after
152+
# cf https://github.com/matrix-org/matrix-doc/pull/2228
153+
SELF_DESTRUCT_AFTER = "org.matrix.self_destruct_after"

synapse/config/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,8 @@ class LimitRemoteRoomsConfig(object):
490490
"cleanup_extremities_with_dummy_events", True
491491
)
492492

493+
self.enable_ephemeral_messages = config.get("enable_ephemeral_messages", False)
494+
493495
def has_tls_listener(self) -> bool:
494496
return any(l["tls"] for l in self.listeners)
495497

synapse/handlers/federation.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ def __init__(self, hs):
121121
self.pusher_pool = hs.get_pusherpool()
122122
self.spam_checker = hs.get_spam_checker()
123123
self.event_creation_handler = hs.get_event_creation_handler()
124+
self._message_handler = hs.get_message_handler()
124125
self._server_notices_mxid = hs.config.server_notices_mxid
125126
self.config = hs.config
126127
self.http_client = hs.get_simple_http_client()
@@ -141,6 +142,8 @@ def __init__(self, hs):
141142

142143
self.third_party_event_rules = hs.get_third_party_event_rules()
143144

145+
self._ephemeral_messages_enabled = hs.config.enable_ephemeral_messages
146+
144147
@defer.inlineCallbacks
145148
def on_receive_pdu(self, origin, pdu, sent_to_us_directly=False):
146149
""" Process a PDU received via a federation /send/ transaction, or
@@ -2715,6 +2718,11 @@ def persist_events_and_notify(self, event_and_contexts, backfilled=False):
27152718
event_and_contexts, backfilled=backfilled
27162719
)
27172720

2721+
if self._ephemeral_messages_enabled:
2722+
for (event, context) in event_and_contexts:
2723+
# If there's an expiry timestamp on the event, schedule its expiry.
2724+
self._message_handler.maybe_schedule_expiry(event)
2725+
27182726
if not backfilled: # Never notify for backfilled events
27192727
for event, _ in event_and_contexts:
27202728
yield self._notify_persisted_event(event, max_stream_id)

synapse/handlers/message.py

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,24 @@
1515
# See the License for the specific language governing permissions and
1616
# limitations under the License.
1717
import logging
18+
from typing import Optional
1819

1920
from six import iteritems, itervalues, string_types
2021

2122
from canonicaljson import encode_canonical_json, json
2223

2324
from twisted.internet import defer
2425
from twisted.internet.defer import succeed
26+
from twisted.internet.interfaces import IDelayedCall
2527

2628
from synapse import event_auth
27-
from synapse.api.constants import EventTypes, Membership, RelationTypes, UserTypes
29+
from synapse.api.constants import (
30+
EventContentFields,
31+
EventTypes,
32+
Membership,
33+
RelationTypes,
34+
UserTypes,
35+
)
2836
from synapse.api.errors import (
2937
AuthError,
3038
Codes,
@@ -62,6 +70,17 @@ def __init__(self, hs):
6270
self.storage = hs.get_storage()
6371
self.state_store = self.storage.state
6472
self._event_serializer = hs.get_event_client_serializer()
73+
self._ephemeral_events_enabled = hs.config.enable_ephemeral_messages
74+
self._is_worker_app = bool(hs.config.worker_app)
75+
76+
# The scheduled call to self._expire_event. None if no call is currently
77+
# scheduled.
78+
self._scheduled_expiry = None # type: Optional[IDelayedCall]
79+
80+
if not hs.config.worker_app:
81+
run_as_background_process(
82+
"_schedule_next_expiry", self._schedule_next_expiry
83+
)
6584

6685
@defer.inlineCallbacks
6786
def get_room_data(
@@ -225,6 +244,100 @@ def get_joined_members(self, requester, room_id):
225244
for user_id, profile in iteritems(users_with_profile)
226245
}
227246

247+
def maybe_schedule_expiry(self, event):
248+
"""Schedule the expiry of an event if there's not already one scheduled,
249+
or if the one running is for an event that will expire after the provided
250+
timestamp.
251+
252+
This function needs to invalidate the event cache, which is only possible on
253+
the master process, and therefore needs to be run on there.
254+
255+
Args:
256+
event (EventBase): The event to schedule the expiry of.
257+
"""
258+
assert not self._is_worker_app
259+
260+
expiry_ts = event.content.get(EventContentFields.SELF_DESTRUCT_AFTER)
261+
if not isinstance(expiry_ts, int) or event.is_state():
262+
return
263+
264+
# _schedule_expiry_for_event won't actually schedule anything if there's already
265+
# a task scheduled for a timestamp that's sooner than the provided one.
266+
self._schedule_expiry_for_event(event.event_id, expiry_ts)
267+
268+
@defer.inlineCallbacks
269+
def _schedule_next_expiry(self):
270+
"""Retrieve the ID and the expiry timestamp of the next event to be expired,
271+
and schedule an expiry task for it.
272+
273+
If there's no event left to expire, set _expiry_scheduled to None so that a
274+
future call to save_expiry_ts can schedule a new expiry task.
275+
"""
276+
# Try to get the expiry timestamp of the next event to expire.
277+
res = yield self.store.get_next_event_to_expire()
278+
if res:
279+
event_id, expiry_ts = res
280+
self._schedule_expiry_for_event(event_id, expiry_ts)
281+
282+
def _schedule_expiry_for_event(self, event_id, expiry_ts):
283+
"""Schedule an expiry task for the provided event if there's not already one
284+
scheduled at a timestamp that's sooner than the provided one.
285+
286+
Args:
287+
event_id (str): The ID of the event to expire.
288+
expiry_ts (int): The timestamp at which to expire the event.
289+
"""
290+
if self._scheduled_expiry:
291+
# If the provided timestamp refers to a time before the scheduled time of the
292+
# next expiry task, cancel that task and reschedule it for this timestamp.
293+
next_scheduled_expiry_ts = self._scheduled_expiry.getTime() * 1000
294+
if expiry_ts < next_scheduled_expiry_ts:
295+
self._scheduled_expiry.cancel()
296+
else:
297+
return
298+
299+
# Figure out how many seconds we need to wait before expiring the event.
300+
now_ms = self.clock.time_msec()
301+
delay = (expiry_ts - now_ms) / 1000
302+
303+
# callLater doesn't support negative delays, so trim the delay to 0 if we're
304+
# in that case.
305+
if delay < 0:
306+
delay = 0
307+
308+
logger.info("Scheduling expiry for event %s in %.3fs", event_id, delay)
309+
310+
self._scheduled_expiry = self.clock.call_later(
311+
delay,
312+
run_as_background_process,
313+
"_expire_event",
314+
self._expire_event,
315+
event_id,
316+
)
317+
318+
@defer.inlineCallbacks
319+
def _expire_event(self, event_id):
320+
"""Retrieve and expire an event that needs to be expired from the database.
321+
322+
If the event doesn't exist in the database, log it and delete the expiry date
323+
from the database (so that we don't try to expire it again).
324+
"""
325+
assert self._ephemeral_events_enabled
326+
327+
self._scheduled_expiry = None
328+
329+
logger.info("Expiring event %s", event_id)
330+
331+
try:
332+
# Expire the event if we know about it. This function also deletes the expiry
333+
# date from the database in the same database transaction.
334+
yield self.store.expire_event(event_id)
335+
except Exception as e:
336+
logger.error("Could not expire event %s: %r", event_id, e)
337+
338+
# Schedule the expiry of the next event to expire.
339+
yield self._schedule_next_expiry()
340+
228341

229342
# The duration (in ms) after which rooms should be removed
230343
# `_rooms_to_exclude_from_dummy_event_insertion` (with the effect that we will try
@@ -295,6 +408,10 @@ def __init__(self, hs):
295408
5 * 60 * 1000,
296409
)
297410

411+
self._message_handler = hs.get_message_handler()
412+
413+
self._ephemeral_events_enabled = hs.config.enable_ephemeral_messages
414+
298415
@defer.inlineCallbacks
299416
def create_event(
300417
self,
@@ -877,6 +994,10 @@ def is_inviter_member_event(e):
877994
event, context=context
878995
)
879996

997+
if self._ephemeral_events_enabled:
998+
# If there's an expiry timestamp on the event, schedule its expiry.
999+
self._message_handler.maybe_schedule_expiry(event)
1000+
8801001
yield self.pusher_pool.on_new_notifications(event_stream_id, max_stream_id)
8811002

8821003
def _notify():

synapse/storage/data_stores/main/events.py

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ def _censor_redactions():
130130
if self.hs.config.redaction_retention_period is not None:
131131
hs.get_clock().looping_call(_censor_redactions, 5 * 60 * 1000)
132132

133+
self._ephemeral_messages_enabled = hs.config.enable_ephemeral_messages
134+
133135
@defer.inlineCallbacks
134136
def _read_forward_extremities(self):
135137
def fetch(txn):
@@ -940,6 +942,12 @@ def _update_metadata_tables_txn(
940942
txn, event.event_id, labels, event.room_id, event.depth
941943
)
942944

945+
if self._ephemeral_messages_enabled:
946+
# If there's an expiry timestamp on the event, store it.
947+
expiry_ts = event.content.get(EventContentFields.SELF_DESTRUCT_AFTER)
948+
if isinstance(expiry_ts, int) and not event.is_state():
949+
self._insert_event_expiry_txn(txn, event.event_id, expiry_ts)
950+
943951
# Insert into the room_memberships table.
944952
self._store_room_members_txn(
945953
txn,
@@ -1101,12 +1109,7 @@ def _censor_redactions(self):
11011109
def _update_censor_txn(txn):
11021110
for redaction_id, event_id, pruned_json in updates:
11031111
if pruned_json:
1104-
self._simple_update_one_txn(
1105-
txn,
1106-
table="event_json",
1107-
keyvalues={"event_id": event_id},
1108-
updatevalues={"json": pruned_json},
1109-
)
1112+
self._censor_event_txn(txn, event_id, pruned_json)
11101113

11111114
self._simple_update_one_txn(
11121115
txn,
@@ -1117,6 +1120,22 @@ def _update_censor_txn(txn):
11171120

11181121
yield self.runInteraction("_update_censor_txn", _update_censor_txn)
11191122

1123+
def _censor_event_txn(self, txn, event_id, pruned_json):
1124+
"""Censor an event by replacing its JSON in the event_json table with the
1125+
provided pruned JSON.
1126+
1127+
Args:
1128+
txn (LoggingTransaction): The database transaction.
1129+
event_id (str): The ID of the event to censor.
1130+
pruned_json (str): The pruned JSON
1131+
"""
1132+
self._simple_update_one_txn(
1133+
txn,
1134+
table="event_json",
1135+
keyvalues={"event_id": event_id},
1136+
updatevalues={"json": pruned_json},
1137+
)
1138+
11201139
@defer.inlineCallbacks
11211140
def count_daily_messages(self):
11221141
"""
@@ -1957,6 +1976,101 @@ def insert_labels_for_event_txn(
19571976
],
19581977
)
19591978

1979+
def _insert_event_expiry_txn(self, txn, event_id, expiry_ts):
1980+
"""Save the expiry timestamp associated with a given event ID.
1981+
1982+
Args:
1983+
txn (LoggingTransaction): The database transaction to use.
1984+
event_id (str): The event ID the expiry timestamp is associated with.
1985+
expiry_ts (int): The timestamp at which to expire (delete) the event.
1986+
"""
1987+
return self._simple_insert_txn(
1988+
txn=txn,
1989+
table="event_expiry",
1990+
values={"event_id": event_id, "expiry_ts": expiry_ts},
1991+
)
1992+
1993+
@defer.inlineCallbacks
1994+
def expire_event(self, event_id):
1995+
"""Retrieve and expire an event that has expired, and delete its associated
1996+
expiry timestamp. If the event can't be retrieved, delete its associated
1997+
timestamp so we don't try to expire it again in the future.
1998+
1999+
Args:
2000+
event_id (str): The ID of the event to delete.
2001+
"""
2002+
# Try to retrieve the event's content from the database or the event cache.
2003+
event = yield self.get_event(event_id)
2004+
2005+
def delete_expired_event_txn(txn):
2006+
# Delete the expiry timestamp associated with this event from the database.
2007+
self._delete_event_expiry_txn(txn, event_id)
2008+
2009+
if not event:
2010+
# If we can't find the event, log a warning and delete the expiry date
2011+
# from the database so that we don't try to expire it again in the
2012+
# future.
2013+
logger.warning(
2014+
"Can't expire event %s because we don't have it.", event_id
2015+
)
2016+
return
2017+
2018+
# Prune the event's dict then convert it to JSON.
2019+
pruned_json = encode_json(prune_event_dict(event.get_dict()))
2020+
2021+
# Update the event_json table to replace the event's JSON with the pruned
2022+
# JSON.
2023+
self._censor_event_txn(txn, event.event_id, pruned_json)
2024+
2025+
# We need to invalidate the event cache entry for this event because we
2026+
# changed its content in the database. We can't call
2027+
# self._invalidate_cache_and_stream because self.get_event_cache isn't of the
2028+
# right type.
2029+
txn.call_after(self._get_event_cache.invalidate, (event.event_id,))
2030+
# Send that invalidation to replication so that other workers also invalidate
2031+
# the event cache.
2032+
self._send_invalidation_to_replication(
2033+
txn, "_get_event_cache", (event.event_id,)
2034+
)
2035+
2036+
yield self.runInteraction("delete_expired_event", delete_expired_event_txn)
2037+
2038+
def _delete_event_expiry_txn(self, txn, event_id):
2039+
"""Delete the expiry timestamp associated with an event ID without deleting the
2040+
actual event.
2041+
2042+
Args:
2043+
txn (LoggingTransaction): The transaction to use to perform the deletion.
2044+
event_id (str): The event ID to delete the associated expiry timestamp of.
2045+
"""
2046+
return self._simple_delete_txn(
2047+
txn=txn, table="event_expiry", keyvalues={"event_id": event_id}
2048+
)
2049+
2050+
def get_next_event_to_expire(self):
2051+
"""Retrieve the entry with the lowest expiry timestamp in the event_expiry
2052+
table, or None if there's no more event to expire.
2053+
2054+
Returns: Deferred[Optional[Tuple[str, int]]]
2055+
A tuple containing the event ID as its first element and an expiry timestamp
2056+
as its second one, if there's at least one row in the event_expiry table.
2057+
None otherwise.
2058+
"""
2059+
2060+
def get_next_event_to_expire_txn(txn):
2061+
txn.execute(
2062+
"""
2063+
SELECT event_id, expiry_ts FROM event_expiry
2064+
ORDER BY expiry_ts ASC LIMIT 1
2065+
"""
2066+
)
2067+
2068+
return txn.fetchone()
2069+
2070+
return self.runInteraction(
2071+
desc="get_next_event_to_expire", func=get_next_event_to_expire_txn
2072+
)
2073+
19602074

19612075
AllNewEventsResult = namedtuple(
19622076
"AllNewEventsResult",
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/* Copyright 2019 The Matrix.org Foundation C.I.C.
2+
*
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
CREATE TABLE IF NOT EXISTS event_expiry (
17+
event_id TEXT PRIMARY KEY,
18+
expiry_ts BIGINT NOT NULL
19+
);
20+
21+
CREATE INDEX event_expiry_expiry_ts_idx ON event_expiry(expiry_ts);

0 commit comments

Comments
 (0)