소스 검색

Add support for disappearing messages

Tulir Asokan 3 년 전
부모
커밋
18ea5af45e
12개의 변경된 파일375개의 추가작업 그리고 33개의 파일을 삭제
  1. 1 0
      CHANGELOG.md
  2. 2 0
      config/bridge.go
  3. 1 0
      config/upgrade.go
  4. 6 0
      database/database.go
  5. 140 0
      database/disappearingmessage.go
  6. 7 5
      database/portal.go
  7. 20 0
      database/upgrades/2022-01-07-disappearing-messages.go
  8. 1 1
      database/upgrades/upgrades.go
  9. 71 0
      disappear.go
  10. 4 0
      example-config.yaml
  11. 1 0
      main.go
  12. 121 27
      portal.go

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@
   very obscure extensions in their mime type database.
 * Added support for personal filtering spaces (started by [@HelderFSFerreira] and [@clmnin] in [#413]).
 * Added support for multi-contact messages.
+* Added support for disappearing messages.
 * Fixed avatar remove events from WhatsApp being ignored.
 * Fixed the bridge using the wrong Olm session if a client established a new
   one due to corruption.

+ 2 - 0
config/bridge.go

@@ -73,6 +73,8 @@ type BridgeConfig struct {
 	AllowUserInvite       bool   `yaml:"allow_user_invite"`
 	FederateRooms         bool   `yaml:"federate_rooms"`
 
+	DisappearingMessagesInGroups bool `yaml:"disappearing_messages_in_groups"`
+
 	CommandPrefix string `yaml:"command_prefix"`
 
 	ManagementRoomText struct {

+ 1 - 0
config/upgrade.go

@@ -102,6 +102,7 @@ func (helper *UpgradeHelper) doUpgrade() {
 	helper.Copy(Bool, "bridge", "allow_user_invite")
 	helper.Copy(Str, "bridge", "command_prefix")
 	helper.Copy(Bool, "bridge", "federate_rooms")
+	helper.Copy(Bool, "bridge", "disappearing_messages_in_groups")
 	helper.Copy(Str, "bridge", "management_room_text", "welcome")
 	helper.Copy(Str, "bridge", "management_room_text", "welcome_connected")
 	helper.Copy(Str, "bridge", "management_room_text", "welcome_unconnected")

+ 6 - 0
database/database.go

@@ -41,6 +41,8 @@ type Database struct {
 	Portal  *PortalQuery
 	Puppet  *PuppetQuery
 	Message *MessageQuery
+
+	DisappearingMessage *DisappearingMessageQuery
 }
 
 func New(dbType string, uri string, baseLog log.Logger) (*Database, error) {
@@ -70,6 +72,10 @@ func New(dbType string, uri string, baseLog log.Logger) (*Database, error) {
 		db:  db,
 		log: db.log.Sub("Message"),
 	}
+	db.DisappearingMessage = &DisappearingMessageQuery{
+		db:  db,
+		log: db.log.Sub("DisappearingMessage"),
+	}
 	return db, nil
 }
 

+ 140 - 0
database/disappearingmessage.go

@@ -0,0 +1,140 @@
+// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
+// Copyright (C) 2022 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+package database
+
+import (
+	"database/sql"
+	"errors"
+	"time"
+
+	log "maunium.net/go/maulogger/v2"
+
+	"maunium.net/go/mautrix/id"
+)
+
+type DisappearingMessageQuery struct {
+	db  *Database
+	log log.Logger
+}
+
+func (dmq *DisappearingMessageQuery) New() *DisappearingMessage {
+	return &DisappearingMessage{
+		db:  dmq.db,
+		log: dmq.log,
+	}
+}
+
+func (dmq *DisappearingMessageQuery) NewWithValues(roomID id.RoomID, eventID id.EventID, expireIn time.Duration, startNow bool) *DisappearingMessage {
+	dm := &DisappearingMessage{
+		db:       dmq.db,
+		log:      dmq.log,
+		RoomID:   roomID,
+		EventID:  eventID,
+		ExpireIn: expireIn,
+	}
+	if startNow {
+		dm.ExpireAt = time.Now().Add(dm.ExpireIn)
+	}
+	return dm
+}
+
+const (
+	getAllScheduledDisappearingMessagesQuery = `
+		SELECT room_id, event_id, expire_in, expire_at FROM disappearing_message WHERE expire_at IS NOT NULL
+	`
+	startUnscheduledDisappearingMessagesInRoomQuery = `
+		UPDATE disappearing_message SET expire_at=$1+expire_in WHERE room_id=$2 AND expire_at IS NULL
+		RETURNING room_id, event_id, expire_in, expire_at
+	`
+)
+
+func (dmq *DisappearingMessageQuery) GetAllScheduled() (messages []*DisappearingMessage) {
+	rows, err := dmq.db.Query(getAllScheduledDisappearingMessagesQuery)
+	if err != nil || rows == nil {
+		return nil
+	}
+	for rows.Next() {
+		messages = append(messages, dmq.New().Scan(rows))
+	}
+	return
+}
+
+func (dmq *DisappearingMessageQuery) StartAllUnscheduledInRoom(roomID id.RoomID) (messages []*DisappearingMessage) {
+	rows, err := dmq.db.Query(startUnscheduledDisappearingMessagesInRoomQuery, time.Now().UnixMilli(), roomID)
+	if err != nil || rows == nil {
+		return nil
+	}
+	for rows.Next() {
+		messages = append(messages, dmq.New().Scan(rows))
+	}
+	return
+}
+
+type DisappearingMessage struct {
+	db  *Database
+	log log.Logger
+
+	RoomID   id.RoomID
+	EventID  id.EventID
+	ExpireIn time.Duration
+	ExpireAt time.Time
+}
+
+func (msg *DisappearingMessage) Scan(row Scannable) *DisappearingMessage {
+	var expireIn int64
+	var expireAt sql.NullInt64
+	err := row.Scan(&msg.RoomID, &msg.EventID, &expireIn, &expireAt)
+	if err != nil {
+		if !errors.Is(err, sql.ErrNoRows) {
+			msg.log.Errorln("Database scan failed:", err)
+		}
+		return nil
+	}
+	msg.ExpireIn = time.Duration(expireIn) * time.Millisecond
+	if expireAt.Valid {
+		msg.ExpireAt = time.UnixMilli(expireAt.Int64)
+	}
+	return msg
+}
+
+func (msg *DisappearingMessage) Insert() {
+	var expireAt sql.NullInt64
+	if !msg.ExpireAt.IsZero() {
+		expireAt.Valid = true
+		expireAt.Int64 = msg.ExpireAt.UnixMilli()
+	}
+	_, err := msg.db.Exec(`INSERT INTO disappearing_message (room_id, event_id, expire_in, expire_at) VALUES ($1, $2, $3, $4)`,
+		msg.RoomID, msg.EventID, msg.ExpireIn.Milliseconds(), expireAt)
+	if err != nil {
+		msg.log.Warnfln("Failed to insert %s/%s: %v", msg.RoomID, msg.EventID, err)
+	}
+}
+
+func (msg *DisappearingMessage) StartTimer() {
+	msg.ExpireAt = time.Now().Add(msg.ExpireIn * time.Second)
+	_, err := msg.db.Exec("UPDATE disappearing_message SET expire_at=$1 WHERE room_id=$2 AND event_id=$3", msg.ExpireAt.Unix(), msg.RoomID, msg.EventID)
+	if err != nil {
+		msg.log.Warnfln("Failed to update %s/%s: %v", msg.RoomID, msg.EventID, err)
+	}
+}
+
+func (msg *DisappearingMessage) Delete() {
+	_, err := msg.db.Exec("DELETE FROM disappearing_message WHERE room_id=$1 AND event_id=$2", msg.RoomID, msg.EventID)
+	if err != nil {
+		msg.log.Warnfln("Failed to delete %s/%s: %v", msg.RoomID, msg.EventID, err)
+	}
+}

+ 7 - 5
database/portal.go

@@ -140,11 +140,13 @@ type Portal struct {
 	NextBatchID  id.BatchID
 
 	RelayUserID id.UserID
+
+	ExpirationTime uint32
 }
 
 func (portal *Portal) Scan(row Scannable) *Portal {
 	var mxid, avatarURL, firstEventID, nextBatchID, relayUserID sql.NullString
-	err := row.Scan(&portal.Key.JID, &portal.Key.Receiver, &mxid, &portal.Name, &portal.Topic, &portal.Avatar, &avatarURL, &portal.Encrypted, &firstEventID, &nextBatchID, &relayUserID)
+	err := row.Scan(&portal.Key.JID, &portal.Key.Receiver, &mxid, &portal.Name, &portal.Topic, &portal.Avatar, &avatarURL, &portal.Encrypted, &firstEventID, &nextBatchID, &relayUserID, &portal.ExpirationTime)
 	if err != nil {
 		if err != sql.ErrNoRows {
 			portal.log.Errorln("Database scan failed:", err)
@@ -174,16 +176,16 @@ func (portal *Portal) relayUserPtr() *id.UserID {
 }
 
 func (portal *Portal) Insert() {
-	_, err := portal.db.Exec("INSERT INTO portal (jid, receiver, mxid, name, topic, avatar, avatar_url, encrypted, first_event_id, next_batch_id, relay_user_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
-		portal.Key.JID, portal.Key.Receiver, portal.mxidPtr(), portal.Name, portal.Topic, portal.Avatar, portal.AvatarURL.String(), portal.Encrypted, portal.FirstEventID.String(), portal.NextBatchID.String(), portal.relayUserPtr())
+	_, err := portal.db.Exec("INSERT INTO portal (jid, receiver, mxid, name, topic, avatar, avatar_url, encrypted, first_event_id, next_batch_id, relay_user_id, expiration_time) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)",
+		portal.Key.JID, portal.Key.Receiver, portal.mxidPtr(), portal.Name, portal.Topic, portal.Avatar, portal.AvatarURL.String(), portal.Encrypted, portal.FirstEventID.String(), portal.NextBatchID.String(), portal.relayUserPtr(), portal.ExpirationTime)
 	if err != nil {
 		portal.log.Warnfln("Failed to insert %s: %v", portal.Key, err)
 	}
 }
 
 func (portal *Portal) Update() {
-	_, err := portal.db.Exec("UPDATE portal SET mxid=$1, name=$2, topic=$3, avatar=$4, avatar_url=$5, encrypted=$6, first_event_id=$7, next_batch_id=$8, relay_user_id=$9 WHERE jid=$10 AND receiver=$11",
-		portal.mxidPtr(), portal.Name, portal.Topic, portal.Avatar, portal.AvatarURL.String(), portal.Encrypted, portal.FirstEventID.String(), portal.NextBatchID.String(), portal.relayUserPtr(), portal.Key.JID, portal.Key.Receiver)
+	_, err := portal.db.Exec("UPDATE portal SET mxid=$1, name=$2, topic=$3, avatar=$4, avatar_url=$5, encrypted=$6, first_event_id=$7, next_batch_id=$8, relay_user_id=$9, expiration_time=$10 WHERE jid=$11 AND receiver=$12",
+		portal.mxidPtr(), portal.Name, portal.Topic, portal.Avatar, portal.AvatarURL.String(), portal.Encrypted, portal.FirstEventID.String(), portal.NextBatchID.String(), portal.relayUserPtr(), portal.ExpirationTime, portal.Key.JID, portal.Key.Receiver)
 	if err != nil {
 		portal.log.Warnfln("Failed to update %s: %v", portal.Key, err)
 	}

+ 20 - 0
database/upgrades/2022-01-07-disappearing-messages.go

@@ -0,0 +1,20 @@
+package upgrades
+
+import "database/sql"
+
+func init() {
+	upgrades[34] = upgrade{"Add support for disappearing messages", func(tx *sql.Tx, ctx context) error {
+		_, err := tx.Exec(`ALTER TABLE portal ADD COLUMN expiration_time BIGINT NOT NULL DEFAULT 0 CHECK (expiration_time >= 0 AND expiration_time < 4294967296)`)
+		if err != nil {
+			return err
+		}
+		_, err = tx.Exec(`CREATE TABLE disappearing_message (
+			room_id   TEXT,
+			event_id  TEXT,
+			expire_in BIGINT NOT NULL,
+			expire_at BIGINT,
+			PRIMARY KEY (room_id, event_id)
+		)`)
+		return err
+	}}
+}

+ 1 - 1
database/upgrades/upgrades.go

@@ -39,7 +39,7 @@ type upgrade struct {
 	fn      upgradeFunc
 }
 
-const NumberOfUpgrades = 34
+const NumberOfUpgrades = 35
 
 var upgrades [NumberOfUpgrades]upgrade
 

+ 71 - 0
disappear.go

@@ -0,0 +1,71 @@
+// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
+// Copyright (C) 2022 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+package main
+
+import (
+	"fmt"
+	"time"
+
+	"maunium.net/go/mautrix"
+	"maunium.net/go/mautrix/id"
+
+	"maunium.net/go/mautrix-whatsapp/database"
+)
+
+func (portal *Portal) MarkDisappearing(eventID id.EventID, expiresIn uint32, startNow bool) {
+	if expiresIn == 0 || (!portal.bridge.Config.Bridge.DisappearingMessagesInGroups && portal.IsGroupChat()) {
+		return
+	}
+
+	msg := portal.bridge.DB.DisappearingMessage.NewWithValues(portal.MXID, eventID, time.Duration(expiresIn)*time.Second, startNow)
+	msg.Insert()
+	if startNow {
+		go portal.sleepAndDelete(msg)
+	}
+}
+
+func (portal *Portal) ScheduleDisappearing() {
+	if !portal.bridge.Config.Bridge.DisappearingMessagesInGroups && portal.IsGroupChat() {
+		return
+	}
+	for _, msg := range portal.bridge.DB.DisappearingMessage.StartAllUnscheduledInRoom(portal.MXID) {
+		go portal.sleepAndDelete(msg)
+	}
+}
+
+func (bridge *Bridge) RestartAllDisappearing() {
+	for _, msg := range bridge.DB.DisappearingMessage.GetAllScheduled() {
+		portal := bridge.GetPortalByMXID(msg.RoomID)
+		go portal.sleepAndDelete(msg)
+	}
+}
+
+func (portal *Portal) sleepAndDelete(msg *database.DisappearingMessage) {
+	sleepTime := msg.ExpireAt.Sub(time.Now())
+	portal.log.Debugfln("Sleeping for %s to make %s disappear", sleepTime, msg.EventID)
+	time.Sleep(sleepTime)
+	_, err := portal.MainIntent().RedactEvent(msg.RoomID, msg.EventID, mautrix.ReqRedact{
+		Reason: "Message expired",
+		TxnID:  fmt.Sprintf("mxwa_disappear_%s", msg.EventID),
+	})
+	if err != nil {
+		portal.log.Warnfln("Failed to make %s disappear: %v", msg.EventID, err)
+	} else {
+		portal.log.Debugfln("Disappeared %s", msg.EventID)
+	}
+	msg.Delete()
+}

+ 4 - 0
example-config.yaml

@@ -187,6 +187,10 @@ bridge:
     # Whether or not created rooms should have federation enabled.
     # If false, created portal rooms will never be federated.
     federate_rooms: true
+    # Whether to enable disappearing messages in groups. If enabled, then the expiration time of
+    # the messages will be determined by the first user to read the message, rather than individually.
+    # If the bridge only has a single user, this can be turned on safely.
+    disappearing_messages_in_groups: false
 
     # The prefix for commands. Only required in non-management rooms.
     command_prefix: "!wa"

+ 1 - 0
main.go

@@ -333,6 +333,7 @@ func (bridge *Bridge) Start() {
 	if bridge.Config.Bridge.ResendBridgeInfo {
 		go bridge.ResendBridgeInfo()
 	}
+	go bridge.RestartAllDisappearing()
 	bridge.AS.Ready = true
 }
 

+ 121 - 27
portal.go

@@ -300,6 +300,8 @@ func getMessageType(waMsg *waProto.Message) string {
 		switch waMsg.GetProtocolMessage().GetType() {
 		case waProto.ProtocolMessage_REVOKE:
 			return "revoke"
+		case waProto.ProtocolMessage_EPHEMERAL_SETTING:
+			return "disappearing timer change"
 		case waProto.ProtocolMessage_APP_STATE_SYNC_KEY_SHARE, waProto.ProtocolMessage_HISTORY_SYNC_NOTIFICATION, waProto.ProtocolMessage_INITIAL_SECURITY_NOTIFICATION_SETTING_SYNC:
 			return "ignore"
 		default:
@@ -342,6 +344,52 @@ func getMessageType(waMsg *waProto.Message) string {
 	}
 }
 
+func pluralUnit(val int, name string) string {
+	if val == 1 {
+		return fmt.Sprintf("%d %s", val, name)
+	} else if val == 0 {
+		return ""
+	}
+	return fmt.Sprintf("%d %ss", val, name)
+}
+
+func naturalJoin(parts []string) string {
+	if len(parts) == 0 {
+		return ""
+	} else if len(parts) == 1 {
+		return parts[0]
+	} else if len(parts) == 2 {
+		return fmt.Sprintf("%s and %s", parts[0], parts[1])
+	} else {
+		return fmt.Sprintf("%s and %s", strings.Join(parts[:len(parts)-1], ", "), parts[len(parts)-1])
+	}
+}
+
+func formatDuration(d time.Duration) string {
+	const Day = time.Hour * 24
+
+	var days, hours, minutes, seconds int
+	days, d = int(d/Day), d%Day
+	hours, d = int(d/time.Hour), d%time.Hour
+	minutes, d = int(d/time.Minute), d%time.Minute
+	seconds = int(d / time.Second)
+
+	parts := make([]string, 0, 4)
+	if days > 0 {
+		parts = append(parts, pluralUnit(days, "day"))
+	}
+	if hours > 0 {
+		parts = append(parts, pluralUnit(hours, "hour"))
+	}
+	if minutes > 0 {
+		parts = append(parts, pluralUnit(seconds, "minute"))
+	}
+	if seconds > 0 {
+		parts = append(parts, pluralUnit(seconds, "second"))
+	}
+	return naturalJoin(parts)
+}
+
 func (portal *Portal) convertMessage(intent *appservice.IntentAPI, source *User, info *types.MessageInfo, waMsg *waProto.Message) *ConvertedMessage {
 	switch {
 	case waMsg.Conversation != nil || waMsg.ExtendedTextMessage != nil:
@@ -366,6 +414,23 @@ func (portal *Portal) convertMessage(intent *appservice.IntentAPI, source *User,
 		return portal.convertLiveLocationMessage(intent, waMsg.GetLiveLocationMessage())
 	case waMsg.GroupInviteMessage != nil:
 		return portal.convertGroupInviteMessage(intent, info, waMsg.GetGroupInviteMessage())
+	case waMsg.ProtocolMessage != nil && waMsg.ProtocolMessage.GetType() == waProto.ProtocolMessage_EPHEMERAL_SETTING:
+		portal.ExpirationTime = waMsg.ProtocolMessage.GetEphemeralExpiration()
+		portal.Update()
+		var msg string
+		if portal.ExpirationTime == 0 {
+			msg = "Turned off disappearing messages"
+		} else {
+			msg = fmt.Sprintf("Set the disappearing message timer to %s", formatDuration(time.Duration(portal.ExpirationTime)*time.Second))
+		}
+		return &ConvertedMessage{
+			Intent: intent,
+			Type:   event.EventMessage,
+			Content: &event.MessageEventContent{
+				Body:    msg,
+				MsgType: event.MsgNotice,
+			},
+		}
 	default:
 		return nil
 	}
@@ -477,6 +542,7 @@ func (portal *Portal) handleMessage(source *User, evt *events.Message) {
 		}
 		var eventID id.EventID
 		if existingMsg != nil {
+			portal.MarkDisappearing(existingMsg.MXID, converted.ExpiresIn, false)
 			converted.Content.SetEdit(existingMsg.MXID)
 		} else if len(converted.ReplyTo) > 0 {
 			portal.SetReply(converted.Content, converted.ReplyTo)
@@ -485,6 +551,7 @@ func (portal *Portal) handleMessage(source *User, evt *events.Message) {
 		if err != nil {
 			portal.log.Errorfln("Failed to send %s to Matrix: %v", msgID, err)
 		} else {
+			portal.MarkDisappearing(resp.EventID, converted.ExpiresIn, false)
 			eventID = resp.EventID
 		}
 		// TODO figure out how to handle captions with undecryptable messages turning decryptable
@@ -493,6 +560,7 @@ func (portal *Portal) handleMessage(source *User, evt *events.Message) {
 			if err != nil {
 				portal.log.Errorfln("Failed to send caption of %s to Matrix: %v", msgID, err)
 			} else {
+				portal.MarkDisappearing(resp.EventID, converted.ExpiresIn, false)
 				eventID = resp.EventID
 			}
 		}
@@ -501,11 +569,13 @@ func (portal *Portal) handleMessage(source *User, evt *events.Message) {
 				resp, err = portal.sendMessage(converted.Intent, converted.Type, subEvt, nil, evt.Info.Timestamp.UnixMilli())
 				if err != nil {
 					portal.log.Errorfln("Failed to send sub-event %d of %s to Matrix: %v", index+1, msgID, err)
+				} else {
+					portal.MarkDisappearing(resp.EventID, converted.ExpiresIn, false)
 				}
 			}
 		}
 		if len(eventID) != 0 {
-			portal.finishHandling(existingMsg, &evt.Info, resp.EventID, false)
+			portal.finishHandling(existingMsg, &evt.Info, eventID, false)
 		}
 	} else if msgType == "revoke" {
 		portal.HandleMessageRevoke(source, &evt.Info, evt.Message.GetProtocolMessage().GetKey())
@@ -1194,6 +1264,10 @@ func (portal *Portal) IsPrivateChat() bool {
 	return portal.Key.JID.Server == types.DefaultUserServer
 }
 
+func (portal *Portal) IsGroupChat() bool {
+	return portal.Key.JID.Server == types.GroupServer
+}
+
 func (portal *Portal) IsBroadcastList() bool {
 	return portal.Key.JID.Server == types.BroadcastServer
 }
@@ -1337,7 +1411,8 @@ type ConvertedMessage struct {
 
 	MultiEvent []*event.MessageEventContent
 
-	ReplyTo types.MessageID
+	ReplyTo   types.MessageID
+	ExpiresIn uint32
 }
 
 func (portal *Portal) convertTextMessage(intent *appservice.IntentAPI, msg *waProto.Message) *ConvertedMessage {
@@ -1346,6 +1421,7 @@ func (portal *Portal) convertTextMessage(intent *appservice.IntentAPI, msg *waPr
 		MsgType: event.MsgText,
 	}
 	var replyTo types.MessageID
+	var expiresIn uint32
 	if msg.GetExtendedTextMessage() != nil {
 		content.Body = msg.GetExtendedTextMessage().GetText()
 
@@ -1354,9 +1430,16 @@ func (portal *Portal) convertTextMessage(intent *appservice.IntentAPI, msg *waPr
 			portal.bridge.Formatter.ParseWhatsApp(content, contextInfo.GetMentionedJid())
 			replyTo = contextInfo.GetStanzaId()
 		}
+		expiresIn = contextInfo.GetExpiration()
 	}
 
-	return &ConvertedMessage{Intent: intent, Type: event.EventMessage, Content: content, ReplyTo: replyTo}
+	return &ConvertedMessage{
+		Intent:    intent,
+		Type:      event.EventMessage,
+		Content:   content,
+		ReplyTo:   replyTo,
+		ExpiresIn: expiresIn,
+	}
 }
 
 func (portal *Portal) convertLiveLocationMessage(intent *appservice.IntentAPI, msg *waProto.LiveLocationMessage) *ConvertedMessage {
@@ -1368,10 +1451,11 @@ func (portal *Portal) convertLiveLocationMessage(intent *appservice.IntentAPI, m
 		content.Body += ": " + msg.GetCaption()
 	}
 	return &ConvertedMessage{
-		Intent:  intent,
-		Type:    event.EventMessage,
-		Content: content,
-		ReplyTo: msg.GetContextInfo().GetStanzaId(),
+		Intent:    intent,
+		Type:      event.EventMessage,
+		Content:   content,
+		ReplyTo:   msg.GetContextInfo().GetStanzaId(),
+		ExpiresIn: msg.GetContextInfo().GetExpiration(),
 	}
 }
 
@@ -1419,10 +1503,11 @@ func (portal *Portal) convertLocationMessage(intent *appservice.IntentAPI, msg *
 	}
 
 	return &ConvertedMessage{
-		Intent:  intent,
-		Type:    event.EventMessage,
-		Content: content,
-		ReplyTo: msg.GetContextInfo().GetStanzaId(),
+		Intent:    intent,
+		Type:      event.EventMessage,
+		Content:   content,
+		ReplyTo:   msg.GetContextInfo().GetStanzaId(),
+		ExpiresIn: msg.GetContextInfo().GetExpiration(),
 	}
 }
 
@@ -1447,11 +1532,12 @@ func (portal *Portal) convertGroupInviteMessage(intent *appservice.IntentAPI, in
 		},
 	}
 	return &ConvertedMessage{
-		Intent:  intent,
-		Type:    event.EventMessage,
-		Content: content,
-		Extra:   extraAttrs,
-		ReplyTo: msg.GetContextInfo().GetStanzaId(),
+		Intent:    intent,
+		Type:      event.EventMessage,
+		Content:   content,
+		Extra:     extraAttrs,
+		ReplyTo:   msg.GetContextInfo().GetStanzaId(),
+		ExpiresIn: msg.GetContextInfo().GetExpiration(),
 	}
 }
 
@@ -1483,10 +1569,11 @@ func (portal *Portal) convertContactMessage(intent *appservice.IntentAPI, msg *w
 	}
 
 	return &ConvertedMessage{
-		Intent:  intent,
-		Type:    event.EventMessage,
-		Content: content,
-		ReplyTo: msg.GetContextInfo().GetStanzaId(),
+		Intent:    intent,
+		Type:      event.EventMessage,
+		Content:   content,
+		ReplyTo:   msg.GetContextInfo().GetStanzaId(),
+		ExpiresIn: msg.GetContextInfo().GetExpiration(),
 	}
 }
 
@@ -1510,6 +1597,7 @@ func (portal *Portal) convertContactsArrayMessage(intent *appservice.IntentAPI,
 			Body:    fmt.Sprintf("Sent %s", name),
 		},
 		ReplyTo:    msg.GetContextInfo().GetStanzaId(),
+		ExpiresIn:  msg.GetContextInfo().GetExpiration(),
 		MultiEvent: contacts,
 	}
 }
@@ -1815,12 +1903,13 @@ func (portal *Portal) convertMediaMessage(intent *appservice.IntentAPI, source *
 	}
 
 	return &ConvertedMessage{
-		Intent:  intent,
-		Type:    eventType,
-		Content: content,
-		Caption: captionContent,
-		ReplyTo: msg.GetContextInfo().GetStanzaId(),
-		Extra:   extraContent,
+		Intent:    intent,
+		Type:      eventType,
+		Content:   content,
+		Caption:   captionContent,
+		ReplyTo:   msg.GetContextInfo().GetStanzaId(),
+		ExpiresIn: msg.GetContextInfo().GetExpiration(),
+		Extra:     extraContent,
 	}
 }
 
@@ -2047,6 +2136,9 @@ func (portal *Portal) convertMatrixMessage(sender *User, evt *event.Event) (*waP
 			ctxInfo.QuotedMessage = &waProto.Message{Conversation: proto.String("")}
 		}
 	}
+	if portal.ExpirationTime != 0 {
+		ctxInfo.Expiration = proto.Uint32(portal.ExpirationTime)
+	}
 	relaybotFormatted := false
 	if !sender.IsLoggedIn() || (portal.IsPrivateChat() && sender.JID.User != portal.Key.Receiver.User) {
 		if !portal.HasRelaybot() {
@@ -2075,7 +2167,7 @@ func (portal *Portal) convertMatrixMessage(sender *User, evt *event.Event) (*waP
 		if content.MsgType == event.MsgEmote && !relaybotFormatted {
 			text = "/me " + text
 		}
-		if ctxInfo.StanzaId != nil || ctxInfo.MentionedJid != nil {
+		if ctxInfo.StanzaId != nil || ctxInfo.MentionedJid != nil || ctxInfo.Expiration != nil {
 			msg.ExtendedTextMessage = &waProto.ExtendedTextMessage{
 				Text:        &text,
 				ContextInfo: &ctxInfo,
@@ -2227,6 +2319,7 @@ func (portal *Portal) HandleMatrixMessage(sender *User, evt *event.Event) {
 	if msg == nil {
 		return
 	}
+	portal.MarkDisappearing(evt.ID, portal.ExpirationTime, true)
 	info := portal.generateMessageInfo(sender)
 	dbMsg := portal.markHandled(nil, info, evt.ID, false, true, false)
 	portal.log.Debugln("Sending event", evt.ID, "to WhatsApp", info.ID)
@@ -2333,6 +2426,7 @@ func (portal *Portal) HandleMatrixReadReceipt(sender *User, eventID id.EventID,
 			portal.log.Warnfln("Failed to mark %v as read by %s: %v", ids, sender.JID, err)
 		}
 	}
+	portal.ScheduleDisappearing()
 }
 
 func typingDiff(prev, new []id.UserID) (started, stopped []id.UserID) {