history.go (6943B)
1 // history.go — room history pagination: forward incremental sync and DM 2 // backward backfill. 3 package main 4 5 import ( 6 "bytes" 7 "context" 8 "encoding/json" 9 "fmt" 10 "log/slog" 11 12 "maunium.net/go/mautrix" 13 "maunium.net/go/mautrix/event" 14 "maunium.net/go/mautrix/id" 15 ) 16 17 // paginateRoom fetches room history and writes new events to a per-run 18 // age-encrypted JSONL file in S3. For DMs on first run the full history is 19 // fetched backwards from the current position before normal forward pagination. 20 // 21 // syncNextBatch is the nextBatch token from the current sync response. On first 22 // run it is saved as the forward cursor so the next run only sees events that 23 // arrive after this sync — avoiding re-fetching events already in the sync. 24 func paginateRoom( 25 ctx context.Context, 26 client *mautrix.Client, 27 roomID id.RoomID, 28 prefix string, 29 isDM bool, 30 prevBatch string, 31 syncNextBatch string, 32 sessions megolmSessions, 33 ) error { 34 safeKey := roomSafeKey(roomID) 35 cursorKey := prefix + "/history-cursor/" + safeKey + ".json" 36 dmHistoryDoneKey := prefix + "/history-cursor/" + safeKey + ".dm-done" 37 38 var cursor struct { 39 Token string `json:"token"` 40 } 41 if err := s3GetJSON(ctx, cursorKey, &cursor); err != nil { 42 return err 43 } 44 45 // fetchDMHistory does a full backward walk from fromToken and stores results. 46 // Used on first run for DMs and as catch-up when a room was initially missed. 47 fetchDMHistory := func(fromToken string) { 48 slog.Info("DM: fetching full history", "room_id", roomID, "from_token", fromToken) 49 var messages []historyEvent 50 token := fromToken 51 for { 52 resp, err := client.Messages(ctx, roomID, token, "", mautrix.DirectionBackward, nil, 100) 53 if err != nil { 54 slog.Warn("room_messages error (backward)", "room_id", roomID, "token", token, "error", err) 55 break 56 } 57 slog.Info("DM history page", "room_id", roomID, "chunk_size", len(resp.Chunk), "end", resp.End) 58 if len(resp.Chunk) == 0 { 59 break 60 } 61 for _, ev := range resp.Chunk { 62 wasEncrypted := ev.Type == event.EventEncrypted 63 ev = tryDecryptEvent(ev, sessions) 64 messages = append(messages, eventToRecord(ev, wasEncrypted)) 65 processEvent(ctx, client, ev, roomID, prefix, true) 66 } 67 if resp.End == "" || resp.End == token { 68 break 69 } 70 token = resp.End 71 } 72 if len(messages) > 0 { 73 // Reverse so events are stored oldest-first. 74 for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { 75 messages[i], messages[j] = messages[j], messages[i] 76 } 77 histKey := prefix + "/history/" + safeKey + "/" + runStr + "-backfill.jsonl" 78 var buf bytes.Buffer 79 for _, m := range messages { 80 line, err := json.Marshal(m) 81 if err != nil { 82 slog.Warn("Failed to marshal event, skipping", "event_id", m.EventID, "error", err) 83 continue 84 } 85 buf.Write(line) 86 buf.WriteByte('\n') 87 } 88 if err := s3PutAge(ctx, histKey, buf.Bytes()); err != nil { 89 slog.Warn("Failed to upload DM history", "room_id", roomID, "error", err) 90 } else { 91 slog.Info("DM history stored", "room_id", roomID, "events", len(messages)) 92 if err := s3Put(ctx, dmHistoryDoneKey, []byte("done"), "text/plain"); err != nil { 93 slog.Warn("Failed to write dm-done marker", "room_id", roomID, "error", err) 94 } 95 } 96 } else { 97 // Nothing to backfill — mark done so we don't retry every run. 98 slog.Info("DM history: no messages found, marking done", "room_id", roomID) 99 if err := s3Put(ctx, dmHistoryDoneKey, []byte("done"), "text/plain"); err != nil { 100 slog.Warn("Failed to write dm-done marker", "room_id", roomID, "error", err) 101 } 102 } 103 } 104 105 if cursor.Token == "" { 106 // First time we've seen this room. For DMs, walk all the way back through 107 // history so we have a complete record from day one. 108 if isDM && prevBatch != "" { 109 fetchDMHistory(prevBatch) 110 } 111 // Save the sync's nextBatch as the forward cursor so the next run only 112 // picks up genuinely new events. 113 if syncNextBatch == "" { 114 syncNextBatch = prevBatch 115 } 116 if syncNextBatch != "" { 117 cursorJSON, err := json.Marshal(map[string]string{"token": syncNextBatch}) 118 if err == nil { 119 if err := s3Put(ctx, cursorKey, cursorJSON, "application/json"); err != nil { 120 slog.Warn("Failed to save cursor (first run)", "room_id", roomID, "error", err) 121 } 122 } 123 } 124 return nil 125 } 126 127 // Incremental run: if this is a DM but we never completed a backward history 128 // fetch (e.g. room was missed because it wasn't in m.direct on first run), 129 // do it now using the saved cursor as the backward starting point. 130 // Note: prevBatch may be empty for rooms with no new events in a full_state 131 // sync, so we use cursor.Token directly rather than gating on prevBatch. 132 if isDM { 133 dmDoneData, _ := s3Get(ctx, dmHistoryDoneKey) 134 if dmDoneData == nil { 135 slog.Info("DM catch-up: no history marker found, backfilling", "room_id", roomID) 136 fetchDMHistory(cursor.Token) 137 } 138 } 139 140 // Incremental forward pagination from the saved cursor. 141 var messages []historyEvent 142 nextToken := cursor.Token 143 lastGoodToken := cursor.Token 144 histKey := prefix + "/history/" + safeKey + "/" + runStr + ".jsonl" 145 146 const maxPages = 100 147 pages := 0 148 for range maxPages { 149 pages++ 150 resp, err := client.Messages(ctx, roomID, nextToken, "", mautrix.DirectionForward, nil, 100) 151 if err != nil { 152 slog.Warn("room_messages error (forward)", "room_id", roomID, "error", err) 153 break 154 } 155 if len(resp.Chunk) == 0 { 156 break 157 } 158 for _, ev := range resp.Chunk { 159 wasEncrypted := ev.Type == event.EventEncrypted 160 ev = tryDecryptEvent(ev, sessions) 161 messages = append(messages, eventToRecord(ev, wasEncrypted)) 162 processEvent(ctx, client, ev, roomID, prefix, isDM) 163 } 164 if resp.End == "" || resp.End == nextToken { 165 lastGoodToken = nextToken 166 nextToken = "" 167 break 168 } 169 lastGoodToken = resp.End 170 nextToken = resp.End 171 } 172 if pages == maxPages && nextToken != "" { 173 slog.Warn("Pagination cap reached — room has more events; next run will continue", 174 "room_id", roomID, "pages", maxPages) 175 lastGoodToken = nextToken 176 } 177 178 if len(messages) > 0 { 179 var buf bytes.Buffer 180 for _, m := range messages { 181 line, err := json.Marshal(m) 182 if err != nil { 183 slog.Warn("Failed to marshal event, skipping", "event_id", m.EventID, "error", err) 184 continue 185 } 186 buf.Write(line) 187 buf.WriteByte('\n') 188 } 189 if err := s3PutAge(ctx, histKey, buf.Bytes()); err != nil { 190 slog.Warn("Failed to upload history", "room_id", roomID, "error", err) 191 } 192 slog.Info("History updated", "room_id", roomID, "new_events", len(messages)) 193 } 194 195 // Advance the cursor so the next run never re-processes stored events. 196 if lastGoodToken != cursor.Token { 197 cursorJSON, err := json.Marshal(map[string]string{"token": lastGoodToken}) 198 if err != nil { 199 return fmt.Errorf("marshal cursor: %w", err) 200 } 201 if err := s3Put(ctx, cursorKey, cursorJSON, "application/json"); err != nil { 202 slog.Warn("Failed to advance cursor", "room_id", roomID, "error", err) 203 } 204 } 205 return nil 206 }