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 104 : static time_t dialogs_now(void) {
50 104 : if (s_now_fn) return s_now_fn();
51 94 : 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 32 : void dialogs_cache_set_now_fn(time_t (*fn)(void)) {
59 32 : s_now_fn = fn;
60 32 : }
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 52 : void dialogs_cache_flush(void) {
69 52 : memset(s_cache, 0, sizeof(s_cache));
70 52 : }
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 56 : static int build_request(int limit, int archived,
82 : uint8_t *buf, size_t cap, size_t *out_len) {
83 : TlWriter w;
84 56 : tl_writer_init(&w);
85 56 : tl_write_uint32(&w, CRC_messages_getDialogs);
86 56 : uint32_t flags = archived ? GETDIALOGS_FLAG_FOLDER_ID : 0u;
87 56 : tl_write_uint32(&w, flags); /* flags */
88 56 : if (archived)
89 5 : tl_write_int32(&w, 1); /* folder_id = 1 (Archive) */
90 56 : tl_write_int32 (&w, 0); /* offset_date */
91 56 : tl_write_int32 (&w, 0); /* offset_id */
92 56 : tl_write_uint32(&w, CRC_inputPeerEmpty); /* offset_peer */
93 56 : tl_write_int32 (&w, limit); /* limit */
94 56 : tl_write_int64 (&w, 0); /* hash */
95 :
96 56 : int rc = -1;
97 56 : if (w.len <= cap) {
98 56 : memcpy(buf, w.data, w.len);
99 56 : *out_len = w.len;
100 56 : rc = 0;
101 : }
102 56 : tl_writer_free(&w);
103 56 : 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 2 : static int skip_dialog_folder(TlReader *r) {
135 2 : tl_read_uint32(r); /* flags */
136 2 : 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 846 : static int parse_peer(TlReader *r, DialogEntry *out) {
145 846 : if (!tl_reader_ok(r)) return -1;
146 846 : uint32_t crc = tl_read_uint32(r);
147 846 : switch (crc) {
148 840 : case TL_peerUser: out->kind = DIALOG_PEER_USER; break;
149 2 : case TL_peerChat: out->kind = DIALOG_PEER_CHAT; break;
150 4 : 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 846 : out->peer_id = tl_read_int64(r);
157 846 : return 0;
158 : }
159 :
160 63 : 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 63 : if (!cfg || !s || !t || !out || !out_count || max_entries <= 0) return -1;
166 60 : *out_count = 0;
167 60 : if (total_count) *total_count = 0;
168 :
169 : /* ---- TTL cache check ---- */
170 60 : int slot = archived ? 1 : 0;
171 60 : DialogsCache *cache = &s_cache[slot];
172 60 : time_t now = dialogs_now();
173 60 : if (cache->valid && (now - cache->fetched_at) < DIALOGS_CACHE_TTL_S) {
174 4 : int n = cache->count < max_entries ? cache->count : max_entries;
175 4 : memcpy(out, cache->entries, (size_t)n * sizeof(DialogEntry));
176 4 : *out_count = n;
177 4 : if (total_count) *total_count = cache->total_count;
178 4 : logger_log(LOG_DEBUG, "dialogs: served %d entries from cache (age=%lds)",
179 4 : n, (long)(now - cache->fetched_at));
180 4 : return 0;
181 : }
182 :
183 : uint8_t query[132];
184 56 : size_t qlen = 0;
185 56 : 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 56 : RAII_STRING uint8_t *resp = (uint8_t *)malloc(262144);
191 56 : if (!resp) return -1;
192 56 : size_t resp_len = 0;
193 56 : 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 56 : 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 56 : memcpy(&top, resp, 4);
204 56 : if (top == TL_rpc_error) {
205 : RpcError err;
206 3 : rpc_parse_error(resp, resp_len, &err);
207 3 : logger_log(LOG_ERROR, "dialogs: RPC error %d: %s",
208 : err.error_code, err.error_msg);
209 3 : 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 53 : if (top == TL_messages_dialogsNotModified) {
217 4 : TlReader r = tl_reader_init(resp, resp_len);
218 4 : tl_read_uint32(&r); /* skip constructor */
219 4 : int32_t srv_count = tl_read_int32(&r);
220 4 : if (total_count) *total_count = (int)srv_count;
221 4 : logger_log(LOG_DEBUG,
222 : "dialogs: not-modified, server count=%d", srv_count);
223 4 : return 0; /* *out_count remains 0; caller should use its cache */
224 : }
225 :
226 49 : if (top != TL_messages_dialogs && top != TL_messages_dialogsSlice) {
227 3 : logger_log(LOG_ERROR,
228 : "dialogs: unexpected top constructor 0x%08x", top);
229 3 : return -1;
230 : }
231 :
232 46 : TlReader r = tl_reader_init(resp, resp_len);
233 46 : tl_read_uint32(&r); /* top constructor */
234 :
235 : /* messages.dialogsSlice#71e094f3 count:int dialogs:Vector<Dialog>... */
236 46 : if (top == TL_messages_dialogsSlice) {
237 22 : int32_t slice_total = tl_read_int32(&r);
238 22 : if (total_count) *total_count = (int)slice_total;
239 : }
240 :
241 : /* dialogs vector */
242 46 : uint32_t vec = tl_read_uint32(&r);
243 46 : if (vec != TL_vector) {
244 2 : logger_log(LOG_ERROR, "dialogs: expected Vector<Dialog>, got 0x%08x", vec);
245 2 : return -1;
246 : }
247 44 : uint32_t count = tl_read_uint32(&r);
248 : /* For the complete-list variant, total == the vector length. */
249 44 : if (top == TL_messages_dialogs && total_count) *total_count = (int)count;
250 44 : int written = 0;
251 44 : int parsed = 0; /* entries fully consumed from the stream */
252 890 : for (uint32_t i = 0; i < count && written < max_entries; i++) {
253 854 : if (!tl_reader_ok(&r)) break;
254 850 : uint32_t dcrc = tl_read_uint32(&r);
255 850 : 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 2 : 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 848 : if (dcrc != CRC_dialog) {
264 2 : logger_log(LOG_WARN, "dialogs: unknown Dialog constructor 0x%08x",
265 : dcrc);
266 2 : break;
267 : }
268 :
269 846 : uint32_t flags = tl_read_uint32(&r);
270 846 : DialogEntry e = {0};
271 846 : if (parse_peer(&r, &e) != 0) break;
272 846 : e.top_message_id = tl_read_int32(&r);
273 846 : tl_read_int32(&r); /* read_inbox_max_id */
274 846 : tl_read_int32(&r); /* read_outbox_max_id */
275 846 : e.unread_count = tl_read_int32(&r);
276 846 : tl_read_int32(&r); /* unread_mentions_count */
277 846 : tl_read_int32(&r); /* unread_reactions_count */
278 :
279 846 : 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 846 : if (flags & (1u << 0)) tl_read_int32(&r); /* pts */
288 846 : 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 846 : if (flags & (1u << 4)) tl_read_int32(&r); /* folder_id */
299 846 : if (flags & (1u << 5)) tl_read_int32(&r); /* ttl_period */
300 :
301 846 : out[written++] = e;
302 846 : parsed++;
303 : }
304 :
305 44 : *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 44 : int cached_n = written < DIALOGS_CACHE_MAX ? written : DIALOGS_CACHE_MAX;
313 44 : cache->valid = 1;
314 44 : cache->archived = archived;
315 44 : cache->fetched_at = dialogs_now();
316 44 : cache->count = cached_n;
317 44 : cache->total_count = total_count ? *total_count : written;
318 44 : 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 44 : if (parsed < (int)count) {
332 4 : logger_log(LOG_WARN, "dialogs: partial parse (%d/%u) — skipping join",
333 : parsed, count);
334 4 : return 0;
335 : }
336 40 : if (!tl_reader_ok(&r)) return 0;
337 :
338 37 : uint32_t mvec = tl_read_uint32(&r);
339 37 : if (mvec != TL_vector) {
340 0 : logger_log(LOG_WARN, "dialogs: expected messages Vector, got 0x%08x", mvec);
341 0 : return 0;
342 : }
343 37 : uint32_t mcount = tl_read_uint32(&r);
344 38 : for (uint32_t i = 0; i < mcount; i++) {
345 1 : 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 37 : uint32_t cvec = tl_read_uint32(&r);
353 37 : if (cvec != TL_vector) {
354 0 : logger_log(LOG_WARN, "dialogs: expected chats Vector, got 0x%08x", cvec);
355 0 : return 0;
356 : }
357 37 : uint32_t ccount = tl_read_uint32(&r);
358 37 : ChatSummary *chats = (ccount > 0)
359 3 : ? (ChatSummary *)calloc(ccount, sizeof(ChatSummary))
360 37 : : NULL;
361 37 : uint32_t chats_written = 0;
362 40 : for (uint32_t i = 0; i < ccount; i++) {
363 3 : ChatSummary cs = {0};
364 3 : 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 3 : if (chats) chats[chats_written++] = cs;
369 : }
370 :
371 37 : uint32_t uvec = tl_read_uint32(&r);
372 37 : 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 37 : uint32_t ucount = tl_read_uint32(&r);
377 37 : UserSummary *users = (ucount > 0)
378 10 : ? (UserSummary *)calloc(ucount, sizeof(UserSummary))
379 37 : : NULL;
380 37 : uint32_t users_written = 0;
381 47 : for (uint32_t i = 0; i < ucount; i++) {
382 10 : UserSummary us = {0};
383 10 : 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 10 : if (users) users[users_written++] = us;
388 : }
389 :
390 : /* Fill DialogEntry title/username + access_hash by looking up peer_id. */
391 872 : for (int i = 0; i < *out_count; i++) {
392 835 : DialogEntry *e = &out[i];
393 835 : if (e->kind == DIALOG_PEER_USER) {
394 830 : for (uint32_t j = 0; j < users_written; j++) {
395 10 : if (users[j].id == e->peer_id) {
396 10 : memcpy(e->title, users[j].name, sizeof(e->title));
397 10 : memcpy(e->username, users[j].username, sizeof(e->username));
398 10 : e->access_hash = users[j].access_hash;
399 10 : e->have_access_hash = users[j].have_access_hash;
400 10 : break;
401 : }
402 : }
403 : } else { /* CHAT / CHANNEL */
404 5 : for (uint32_t j = 0; j < chats_written; j++) {
405 3 : if (chats[j].id == e->peer_id) {
406 3 : 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 3 : e->access_hash = chats[j].access_hash;
410 3 : e->have_access_hash = chats[j].have_access_hash;
411 3 : break;
412 : }
413 : }
414 : }
415 : }
416 37 : free(chats);
417 37 : free(users);
418 : /* Refresh cached entries to include joined titles / access_hashes. */
419 : {
420 37 : int cached_n = *out_count < DIALOGS_CACHE_MAX ? *out_count : DIALOGS_CACHE_MAX;
421 37 : memcpy(cache->entries, out, (size_t)cached_n * sizeof(DialogEntry));
422 : }
423 37 : join_done:
424 37 : 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 : }
|