Line data Source code
1 : /* SPDX-License-Identifier: GPL-3.0-or-later */
2 : /* Copyright 2026 Peter Csaszar */
3 :
4 : /**
5 : * @file domain/read/dialogs.c
6 : * @brief messages.getDialogs parser (minimal v1).
7 : */
8 :
9 : #include "domain/read/dialogs.h"
10 :
11 : #include "tl_serial.h"
12 : #include "tl_registry.h"
13 : #include "tl_skip.h"
14 : #include "mtproto_rpc.h"
15 : #include "logger.h"
16 : #include "raii.h"
17 :
18 : #include <stddef.h>
19 : #include <time.h>
20 :
21 : #include <stdlib.h>
22 : #include <string.h>
23 :
24 : /* ---- In-memory TTL cache ---- */
25 :
26 : /** Default TTL for the dialogs cache (seconds). */
27 : #ifndef DIALOGS_CACHE_TTL_S
28 : #define DIALOGS_CACHE_TTL_S 60
29 : #endif
30 :
31 : /** Maximum cached dialogs per call site (archived=0 and archived=1). */
32 : #define DIALOGS_CACHE_SLOTS 2
33 : #define DIALOGS_CACHE_MAX 512
34 :
35 : typedef struct {
36 : int valid;
37 : int archived;
38 : time_t fetched_at;
39 : int count;
40 : int total_count;
41 : DialogEntry entries[DIALOGS_CACHE_MAX];
42 : } DialogsCache;
43 :
44 : static DialogsCache s_cache[DIALOGS_CACHE_SLOTS]; /* [0]=inbox [1]=archive */
45 :
46 : /** @brief Mockable clock — tests may replace this with a fake. */
47 : static time_t (*s_now_fn)(void) = NULL;
48 :
49 43 : static time_t dialogs_now(void) {
50 43 : if (s_now_fn) return s_now_fn();
51 38 : return time(NULL);
52 : }
53 :
54 : /**
55 : * @brief Override the clock used for TTL checks (test use only).
56 : * Pass NULL to restore the real clock.
57 : */
58 16 : void dialogs_cache_set_now_fn(time_t (*fn)(void)) {
59 16 : s_now_fn = fn;
60 16 : }
61 :
62 : /**
63 : * @brief Flush the in-memory dialogs cache (test use only).
64 : *
65 : * Call before each unit test that drives domain_get_dialogs directly so
66 : * that cached state from a previous test does not mask a fresh RPC.
67 : */
68 21 : void dialogs_cache_flush(void) {
69 21 : memset(s_cache, 0, sizeof(s_cache));
70 21 : }
71 :
72 :
73 : #define CRC_messages_getDialogs 0xa0f4cb4fu
74 : #define CRC_inputPeerEmpty 0x7f3b18eau
75 :
76 : /* ---- Request builder ---- */
77 :
78 : /* flags bit for the optional folder_id field in messages.getDialogs */
79 : #define GETDIALOGS_FLAG_FOLDER_ID (1u << 1)
80 :
81 23 : static int build_request(int limit, int archived,
82 : uint8_t *buf, size_t cap, size_t *out_len) {
83 : TlWriter w;
84 23 : tl_writer_init(&w);
85 23 : tl_write_uint32(&w, CRC_messages_getDialogs);
86 23 : uint32_t flags = archived ? GETDIALOGS_FLAG_FOLDER_ID : 0u;
87 23 : tl_write_uint32(&w, flags); /* flags */
88 23 : if (archived)
89 2 : tl_write_int32(&w, 1); /* folder_id = 1 (Archive) */
90 23 : tl_write_int32 (&w, 0); /* offset_date */
91 23 : tl_write_int32 (&w, 0); /* offset_id */
92 23 : tl_write_uint32(&w, CRC_inputPeerEmpty); /* offset_peer */
93 23 : tl_write_int32 (&w, limit); /* limit */
94 23 : tl_write_int64 (&w, 0); /* hash */
95 :
96 23 : int rc = -1;
97 23 : if (w.len <= cap) {
98 23 : memcpy(buf, w.data, w.len);
99 23 : *out_len = w.len;
100 23 : rc = 0;
101 : }
102 23 : tl_writer_free(&w);
103 23 : return rc;
104 : }
105 :
106 : /* ---- Dialog TL parsing (minimal, schema-tolerant) ----
107 : *
108 : * dialog#d58a08c6 flags:# pinned:flags.2?true unread_mark:flags.3?true
109 : * view_forum_as_messages:flags.6?true
110 : * peer:Peer
111 : * top_message:int
112 : * read_inbox_max_id:int
113 : * read_outbox_max_id:int
114 : * unread_count:int
115 : * unread_mentions_count:int
116 : * unread_reactions_count:int
117 : * notify_settings:PeerNotifySettings
118 : * pts:flags.0?int
119 : * draft:flags.1?DraftMessage
120 : * folder_id:flags.4?int
121 : * ttl_period:flags.5?int
122 : * = Dialog
123 : *
124 : * We only pull the first block (peer + unread_count + top_message_id).
125 : * Optional trailing fields are not read because callers don't need them;
126 : * the reader stops advancing after this function returns.
127 : */
128 : #define CRC_dialog 0xd58a08c6u
129 : #define CRC_dialogFolder 0x71bd134cu
130 :
131 : /* Skip a dialogFolder entry after its CRC has been consumed.
132 : * Layout: flags(uint32) peer:Peer top_message(int32) + 4×int32 counts.
133 : * pinned:flags.2?true is a flag bit, not a wire field. */
134 1 : static int skip_dialog_folder(TlReader *r) {
135 1 : tl_read_uint32(r); /* flags */
136 1 : if (tl_skip_peer(r) != 0) return -1;
137 0 : for (int k = 0; k < 5; k++) {
138 0 : if (r->len - r->pos < 4) return -1;
139 0 : tl_read_int32(r);
140 : }
141 0 : return 0;
142 : }
143 :
144 418 : static int parse_peer(TlReader *r, DialogEntry *out) {
145 418 : if (!tl_reader_ok(r)) return -1;
146 418 : uint32_t crc = tl_read_uint32(r);
147 418 : switch (crc) {
148 416 : case TL_peerUser: out->kind = DIALOG_PEER_USER; break;
149 1 : case TL_peerChat: out->kind = DIALOG_PEER_CHAT; break;
150 1 : case TL_peerChannel: out->kind = DIALOG_PEER_CHANNEL; break;
151 0 : default:
152 0 : logger_log(LOG_WARN, "dialogs: unknown Peer constructor 0x%08x", crc);
153 0 : out->kind = DIALOG_PEER_UNKNOWN;
154 0 : return -1;
155 : }
156 418 : out->peer_id = tl_read_int64(r);
157 418 : return 0;
158 : }
159 :
160 25 : int domain_get_dialogs(const ApiConfig *cfg,
161 : MtProtoSession *s, Transport *t,
162 : int max_entries, int archived,
163 : DialogEntry *out, int *out_count,
164 : int *total_count) {
165 25 : if (!cfg || !s || !t || !out || !out_count || max_entries <= 0) return -1;
166 25 : *out_count = 0;
167 25 : if (total_count) *total_count = 0;
168 :
169 : /* ---- TTL cache check ---- */
170 25 : int slot = archived ? 1 : 0;
171 25 : DialogsCache *cache = &s_cache[slot];
172 25 : time_t now = dialogs_now();
173 25 : if (cache->valid && (now - cache->fetched_at) < DIALOGS_CACHE_TTL_S) {
174 2 : int n = cache->count < max_entries ? cache->count : max_entries;
175 2 : memcpy(out, cache->entries, (size_t)n * sizeof(DialogEntry));
176 2 : *out_count = n;
177 2 : if (total_count) *total_count = cache->total_count;
178 2 : logger_log(LOG_DEBUG, "dialogs: served %d entries from cache (age=%lds)",
179 2 : n, (long)(now - cache->fetched_at));
180 2 : return 0;
181 : }
182 :
183 : uint8_t query[132];
184 23 : size_t qlen = 0;
185 23 : if (build_request(max_entries, archived, query, sizeof(query), &qlen) != 0) {
186 0 : logger_log(LOG_ERROR, "dialogs: build_request overflow");
187 0 : return -1;
188 : }
189 :
190 23 : RAII_STRING uint8_t *resp = (uint8_t *)malloc(262144);
191 23 : if (!resp) return -1;
192 23 : size_t resp_len = 0;
193 23 : if (api_call(cfg, s, t, query, qlen, resp, 262144, &resp_len) != 0) {
194 0 : logger_log(LOG_ERROR, "dialogs: api_call failed");
195 0 : return -1;
196 : }
197 23 : if (resp_len < 8) {
198 0 : logger_log(LOG_ERROR, "dialogs: response too short");
199 0 : return -1;
200 : }
201 :
202 : uint32_t top;
203 23 : memcpy(&top, resp, 4);
204 23 : if (top == TL_rpc_error) {
205 : RpcError err;
206 1 : rpc_parse_error(resp, resp_len, &err);
207 1 : logger_log(LOG_ERROR, "dialogs: RPC error %d: %s",
208 : err.error_code, err.error_msg);
209 1 : return -1;
210 : }
211 :
212 : /* messages.dialogsNotModified#f0e3e596 count:int
213 : * Returned by the server when the client's hash matches the cached list —
214 : * no entries follow. We surface count via total_count and return 0
215 : * dialogs so callers know the cache is valid. */
216 22 : if (top == TL_messages_dialogsNotModified) {
217 2 : TlReader r = tl_reader_init(resp, resp_len);
218 2 : tl_read_uint32(&r); /* skip constructor */
219 2 : int32_t srv_count = tl_read_int32(&r);
220 2 : if (total_count) *total_count = (int)srv_count;
221 2 : logger_log(LOG_DEBUG,
222 : "dialogs: not-modified, server count=%d", srv_count);
223 2 : return 0; /* *out_count remains 0; caller should use its cache */
224 : }
225 :
226 20 : if (top != TL_messages_dialogs && top != TL_messages_dialogsSlice) {
227 1 : logger_log(LOG_ERROR,
228 : "dialogs: unexpected top constructor 0x%08x", top);
229 1 : return -1;
230 : }
231 :
232 19 : TlReader r = tl_reader_init(resp, resp_len);
233 19 : tl_read_uint32(&r); /* top constructor */
234 :
235 : /* messages.dialogsSlice#71e094f3 count:int dialogs:Vector<Dialog>... */
236 19 : if (top == TL_messages_dialogsSlice) {
237 8 : int32_t slice_total = tl_read_int32(&r);
238 8 : if (total_count) *total_count = (int)slice_total;
239 : }
240 :
241 : /* dialogs vector */
242 19 : uint32_t vec = tl_read_uint32(&r);
243 19 : if (vec != TL_vector) {
244 1 : logger_log(LOG_ERROR, "dialogs: expected Vector<Dialog>, got 0x%08x", vec);
245 1 : return -1;
246 : }
247 18 : uint32_t count = tl_read_uint32(&r);
248 : /* For the complete-list variant, total == the vector length. */
249 18 : if (top == TL_messages_dialogs && total_count) *total_count = (int)count;
250 18 : int written = 0;
251 18 : int parsed = 0; /* entries fully consumed from the stream */
252 436 : for (uint32_t i = 0; i < count && written < max_entries; i++) {
253 422 : if (!tl_reader_ok(&r)) break;
254 420 : uint32_t dcrc = tl_read_uint32(&r);
255 420 : if (dcrc == CRC_dialogFolder) {
256 : /* Skip the archive-folder summary entry; it is not a real dialog.
257 : * Advance past it so the cursor stays aligned for the join. */
258 1 : if (skip_dialog_folder(&r) != 0) break;
259 0 : logger_log(LOG_DEBUG, "dialogs: skipped dialogFolder entry");
260 0 : parsed++;
261 0 : continue;
262 : }
263 419 : if (dcrc != CRC_dialog) {
264 1 : logger_log(LOG_WARN, "dialogs: unknown Dialog constructor 0x%08x",
265 : dcrc);
266 1 : break;
267 : }
268 :
269 418 : uint32_t flags = tl_read_uint32(&r);
270 418 : DialogEntry e = {0};
271 418 : if (parse_peer(&r, &e) != 0) break;
272 418 : e.top_message_id = tl_read_int32(&r);
273 418 : tl_read_int32(&r); /* read_inbox_max_id */
274 418 : tl_read_int32(&r); /* read_outbox_max_id */
275 418 : e.unread_count = tl_read_int32(&r);
276 418 : tl_read_int32(&r); /* unread_mentions_count */
277 418 : tl_read_int32(&r); /* unread_reactions_count */
278 :
279 418 : if (tl_skip_peer_notify_settings(&r) != 0) {
280 0 : logger_log(LOG_WARN,
281 : "dialogs: failed to skip PeerNotifySettings");
282 0 : out[written++] = e;
283 0 : parsed++;
284 0 : break;
285 : }
286 :
287 418 : if (flags & (1u << 0)) tl_read_int32(&r); /* pts */
288 418 : if (flags & (1u << 1)) {
289 0 : if (tl_skip_draft_message(&r) != 0) {
290 0 : logger_log(LOG_WARN,
291 : "dialogs: complex draft — stopping after entry %u",
292 : i);
293 0 : out[written++] = e;
294 0 : parsed++;
295 0 : break;
296 : }
297 : }
298 418 : if (flags & (1u << 4)) tl_read_int32(&r); /* folder_id */
299 418 : if (flags & (1u << 5)) tl_read_int32(&r); /* ttl_period */
300 :
301 418 : out[written++] = e;
302 418 : parsed++;
303 : }
304 :
305 18 : *out_count = written;
306 :
307 : /* ---- Populate cache after successful RPC (before title join) ----
308 : * The join adds titles in-place so we store after the join, but we
309 : * need to handle all exit paths. Prime the cache fields now so that
310 : * the goto-jump at join_done always sees a consistent state. */
311 : {
312 18 : int cached_n = written < DIALOGS_CACHE_MAX ? written : DIALOGS_CACHE_MAX;
313 18 : cache->valid = 1;
314 18 : cache->archived = archived;
315 18 : cache->fetched_at = dialogs_now();
316 18 : cache->count = cached_n;
317 18 : cache->total_count = total_count ? *total_count : written;
318 18 : memcpy(cache->entries, out, (size_t)cached_n * sizeof(DialogEntry));
319 : }
320 :
321 : /* ---- Title join ----
322 : *
323 : * messages.dialogs / dialogsSlice continues with:
324 : * messages:Vector<Message>
325 : * chats:Vector<Chat>
326 : * users:Vector<User>
327 : *
328 : * Proceed only when every Dialog entry was fully consumed from the stream
329 : * (parsed == count); otherwise the cursor is mis-positioned and reading
330 : * the messages/chats/users vectors would produce garbage. */
331 18 : if (parsed < (int)count) {
332 2 : logger_log(LOG_WARN, "dialogs: partial parse (%d/%u) — skipping join",
333 : parsed, count);
334 2 : return 0;
335 : }
336 16 : if (!tl_reader_ok(&r)) return 0;
337 :
338 16 : uint32_t mvec = tl_read_uint32(&r);
339 16 : if (mvec != TL_vector) {
340 0 : logger_log(LOG_WARN, "dialogs: expected messages Vector, got 0x%08x", mvec);
341 0 : return 0;
342 : }
343 16 : uint32_t mcount = tl_read_uint32(&r);
344 16 : for (uint32_t i = 0; i < mcount; i++) {
345 0 : if (tl_skip_message(&r) != 0) {
346 0 : logger_log(LOG_WARN, "dialogs: tl_skip_message failed at index %u "
347 : "(pos=%zu) — skipping join", i, r.pos);
348 0 : return 0;
349 : }
350 : }
351 :
352 16 : uint32_t cvec = tl_read_uint32(&r);
353 16 : if (cvec != TL_vector) {
354 0 : logger_log(LOG_WARN, "dialogs: expected chats Vector, got 0x%08x", cvec);
355 0 : return 0;
356 : }
357 16 : uint32_t ccount = tl_read_uint32(&r);
358 16 : ChatSummary *chats = (ccount > 0)
359 1 : ? (ChatSummary *)calloc(ccount, sizeof(ChatSummary))
360 16 : : NULL;
361 16 : uint32_t chats_written = 0;
362 17 : for (uint32_t i = 0; i < ccount; i++) {
363 1 : ChatSummary cs = {0};
364 1 : if (tl_extract_chat(&r, &cs) != 0) {
365 0 : logger_log(LOG_WARN, "dialogs: tl_extract_chat failed at index %u", i);
366 0 : free(chats); chats = NULL; goto join_done;
367 : }
368 1 : if (chats) chats[chats_written++] = cs;
369 : }
370 :
371 16 : uint32_t uvec = tl_read_uint32(&r);
372 16 : if (uvec != TL_vector) {
373 0 : logger_log(LOG_WARN, "dialogs: expected users Vector, got 0x%08x", uvec);
374 0 : free(chats); goto join_done;
375 : }
376 16 : uint32_t ucount = tl_read_uint32(&r);
377 16 : UserSummary *users = (ucount > 0)
378 4 : ? (UserSummary *)calloc(ucount, sizeof(UserSummary))
379 16 : : NULL;
380 16 : uint32_t users_written = 0;
381 20 : for (uint32_t i = 0; i < ucount; i++) {
382 4 : UserSummary us = {0};
383 4 : if (tl_extract_user(&r, &us) != 0) {
384 0 : logger_log(LOG_WARN, "dialogs: tl_extract_user failed at index %u", i);
385 0 : free(chats); free(users); goto join_done;
386 : }
387 4 : if (users) users[users_written++] = us;
388 : }
389 :
390 : /* Fill DialogEntry title/username + access_hash by looking up peer_id. */
391 432 : for (int i = 0; i < *out_count; i++) {
392 416 : DialogEntry *e = &out[i];
393 416 : if (e->kind == DIALOG_PEER_USER) {
394 414 : for (uint32_t j = 0; j < users_written; j++) {
395 4 : if (users[j].id == e->peer_id) {
396 4 : memcpy(e->title, users[j].name, sizeof(e->title));
397 4 : memcpy(e->username, users[j].username, sizeof(e->username));
398 4 : e->access_hash = users[j].access_hash;
399 4 : e->have_access_hash = users[j].have_access_hash;
400 4 : break;
401 : }
402 : }
403 : } else { /* CHAT / CHANNEL */
404 2 : for (uint32_t j = 0; j < chats_written; j++) {
405 1 : if (chats[j].id == e->peer_id) {
406 1 : memcpy(e->title, chats[j].title, sizeof(e->title));
407 : /* Legacy chat has no access_hash on the wire; leave
408 : * have_access_hash=0 so the caller knows. Channels do. */
409 1 : e->access_hash = chats[j].access_hash;
410 1 : e->have_access_hash = chats[j].have_access_hash;
411 1 : break;
412 : }
413 : }
414 : }
415 : }
416 16 : free(chats);
417 16 : free(users);
418 : /* Refresh cached entries to include joined titles / access_hashes. */
419 : {
420 16 : int cached_n = *out_count < DIALOGS_CACHE_MAX ? *out_count : DIALOGS_CACHE_MAX;
421 16 : memcpy(cache->entries, out, (size_t)cached_n * sizeof(DialogEntry));
422 : }
423 16 : join_done:
424 16 : return 0;
425 : }
426 :
427 0 : int domain_dialogs_find_by_id(int64_t peer_id, DialogEntry *out) {
428 0 : for (int slot = 0; slot < DIALOGS_CACHE_SLOTS; slot++) {
429 0 : const DialogsCache *c = &s_cache[slot];
430 0 : if (!c->valid) continue;
431 0 : for (int i = 0; i < c->count; i++) {
432 0 : if (c->entries[i].peer_id == peer_id) {
433 0 : if (out) *out = c->entries[i];
434 0 : return 0;
435 : }
436 : }
437 : }
438 0 : return -1;
439 : }
|