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 112 : static time_t dialogs_now(void) {
50 112 : if (s_now_fn) return s_now_fn();
51 102 : 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 60 : static int build_request(int limit, int archived,
82 : uint8_t *buf, size_t cap, size_t *out_len) {
83 : TlWriter w;
84 60 : tl_writer_init(&w);
85 60 : tl_write_uint32(&w, CRC_messages_getDialogs);
86 60 : uint32_t flags = archived ? GETDIALOGS_FLAG_FOLDER_ID : 0u;
87 60 : tl_write_uint32(&w, flags); /* flags */
88 60 : if (archived)
89 5 : tl_write_int32(&w, 1); /* folder_id = 1 (Archive) */
90 60 : tl_write_int32 (&w, 0); /* offset_date */
91 60 : tl_write_int32 (&w, 0); /* offset_id */
92 60 : tl_write_uint32(&w, CRC_inputPeerEmpty); /* offset_peer */
93 60 : tl_write_int32 (&w, limit); /* limit */
94 60 : tl_write_int64 (&w, 0); /* hash */
95 :
96 60 : int rc = -1;
97 60 : if (w.len <= cap) {
98 60 : memcpy(buf, w.data, w.len);
99 60 : *out_len = w.len;
100 60 : rc = 0;
101 : }
102 60 : tl_writer_free(&w);
103 60 : 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 846 : static int parse_peer(TlReader *r, DialogEntry *out) {
132 846 : if (!tl_reader_ok(r)) return -1;
133 846 : uint32_t crc = tl_read_uint32(r);
134 846 : switch (crc) {
135 840 : case TL_peerUser: out->kind = DIALOG_PEER_USER; break;
136 2 : case TL_peerChat: out->kind = DIALOG_PEER_CHAT; break;
137 4 : case TL_peerChannel: out->kind = DIALOG_PEER_CHANNEL; break;
138 0 : default:
139 0 : logger_log(LOG_WARN, "dialogs: unknown Peer constructor 0x%08x", crc);
140 0 : out->kind = DIALOG_PEER_UNKNOWN;
141 0 : return -1;
142 : }
143 846 : out->peer_id = tl_read_int64(r);
144 846 : return 0;
145 : }
146 :
147 67 : int domain_get_dialogs(const ApiConfig *cfg,
148 : MtProtoSession *s, Transport *t,
149 : int max_entries, int archived,
150 : DialogEntry *out, int *out_count,
151 : int *total_count) {
152 67 : if (!cfg || !s || !t || !out || !out_count || max_entries <= 0) return -1;
153 64 : *out_count = 0;
154 64 : if (total_count) *total_count = 0;
155 :
156 : /* ---- TTL cache check ---- */
157 64 : int slot = archived ? 1 : 0;
158 64 : DialogsCache *cache = &s_cache[slot];
159 64 : time_t now = dialogs_now();
160 64 : if (cache->valid && (now - cache->fetched_at) < DIALOGS_CACHE_TTL_S) {
161 4 : int n = cache->count < max_entries ? cache->count : max_entries;
162 4 : memcpy(out, cache->entries, (size_t)n * sizeof(DialogEntry));
163 4 : *out_count = n;
164 4 : if (total_count) *total_count = cache->total_count;
165 4 : logger_log(LOG_DEBUG, "dialogs: served %d entries from cache (age=%lds)",
166 4 : n, (long)(now - cache->fetched_at));
167 4 : return 0;
168 : }
169 :
170 : uint8_t query[132];
171 60 : size_t qlen = 0;
172 60 : if (build_request(max_entries, archived, query, sizeof(query), &qlen) != 0) {
173 0 : logger_log(LOG_ERROR, "dialogs: build_request overflow");
174 0 : return -1;
175 : }
176 :
177 60 : RAII_STRING uint8_t *resp = (uint8_t *)malloc(262144);
178 60 : if (!resp) return -1;
179 60 : size_t resp_len = 0;
180 60 : if (api_call(cfg, s, t, query, qlen, resp, 262144, &resp_len) != 0) {
181 0 : logger_log(LOG_ERROR, "dialogs: api_call failed");
182 0 : return -1;
183 : }
184 60 : if (resp_len < 8) {
185 0 : logger_log(LOG_ERROR, "dialogs: response too short");
186 0 : return -1;
187 : }
188 :
189 : uint32_t top;
190 60 : memcpy(&top, resp, 4);
191 60 : if (top == TL_rpc_error) {
192 : RpcError err;
193 3 : rpc_parse_error(resp, resp_len, &err);
194 3 : logger_log(LOG_ERROR, "dialogs: RPC error %d: %s",
195 : err.error_code, err.error_msg);
196 3 : return -1;
197 : }
198 :
199 : /* messages.dialogsNotModified#f0e3e596 count:int
200 : * Returned by the server when the client's hash matches the cached list —
201 : * no entries follow. We surface count via total_count and return 0
202 : * dialogs so callers know the cache is valid. */
203 57 : if (top == TL_messages_dialogsNotModified) {
204 4 : TlReader r = tl_reader_init(resp, resp_len);
205 4 : tl_read_uint32(&r); /* skip constructor */
206 4 : int32_t srv_count = tl_read_int32(&r);
207 4 : if (total_count) *total_count = (int)srv_count;
208 4 : logger_log(LOG_DEBUG,
209 : "dialogs: not-modified, server count=%d", srv_count);
210 4 : return 0; /* *out_count remains 0; caller should use its cache */
211 : }
212 :
213 53 : if (top != TL_messages_dialogs && top != TL_messages_dialogsSlice) {
214 3 : logger_log(LOG_ERROR,
215 : "dialogs: unexpected top constructor 0x%08x", top);
216 3 : return -1;
217 : }
218 :
219 50 : TlReader r = tl_reader_init(resp, resp_len);
220 50 : tl_read_uint32(&r); /* top constructor */
221 :
222 : /* messages.dialogsSlice#71e094f3 count:int dialogs:Vector<Dialog>... */
223 50 : if (top == TL_messages_dialogsSlice) {
224 22 : int32_t slice_total = tl_read_int32(&r);
225 22 : if (total_count) *total_count = (int)slice_total;
226 : }
227 :
228 : /* dialogs vector */
229 50 : uint32_t vec = tl_read_uint32(&r);
230 50 : if (vec != TL_vector) {
231 2 : logger_log(LOG_ERROR, "dialogs: expected Vector<Dialog>, got 0x%08x", vec);
232 2 : return -1;
233 : }
234 48 : uint32_t count = tl_read_uint32(&r);
235 : /* For the complete-list variant, total == the vector length. */
236 48 : if (top == TL_messages_dialogs && total_count) *total_count = (int)count;
237 48 : int written = 0;
238 894 : for (uint32_t i = 0; i < count && written < max_entries; i++) {
239 854 : if (!tl_reader_ok(&r)) break;
240 850 : uint32_t dcrc = tl_read_uint32(&r);
241 850 : if (dcrc == CRC_dialogFolder) {
242 : /* dialogFolder has a separate layout — skipping support is a
243 : * follow-up. Stop iteration cleanly. */
244 2 : logger_log(LOG_DEBUG, "dialogs: folder entry — stopping parse");
245 2 : break;
246 : }
247 848 : if (dcrc != CRC_dialog) {
248 2 : logger_log(LOG_WARN, "dialogs: unknown Dialog constructor 0x%08x",
249 : dcrc);
250 2 : break;
251 : }
252 :
253 846 : uint32_t flags = tl_read_uint32(&r);
254 846 : DialogEntry e = {0};
255 846 : if (parse_peer(&r, &e) != 0) break;
256 846 : e.top_message_id = tl_read_int32(&r);
257 846 : tl_read_int32(&r); /* read_inbox_max_id */
258 846 : tl_read_int32(&r); /* read_outbox_max_id */
259 846 : e.unread_count = tl_read_int32(&r);
260 846 : tl_read_int32(&r); /* unread_mentions_count */
261 846 : tl_read_int32(&r); /* unread_reactions_count */
262 :
263 846 : if (tl_skip_peer_notify_settings(&r) != 0) {
264 0 : logger_log(LOG_WARN,
265 : "dialogs: failed to skip PeerNotifySettings");
266 0 : out[written++] = e;
267 0 : break;
268 : }
269 :
270 846 : if (flags & (1u << 0)) tl_read_int32(&r); /* pts */
271 846 : if (flags & (1u << 1)) {
272 0 : if (tl_skip_draft_message(&r) != 0) {
273 0 : logger_log(LOG_WARN,
274 : "dialogs: complex draft — stopping after entry %u",
275 : i);
276 0 : out[written++] = e;
277 0 : break;
278 : }
279 : }
280 846 : if (flags & (1u << 4)) tl_read_int32(&r); /* folder_id */
281 846 : if (flags & (1u << 5)) tl_read_int32(&r); /* ttl_period */
282 :
283 846 : out[written++] = e;
284 : }
285 :
286 48 : *out_count = written;
287 :
288 : /* ---- Populate cache after successful RPC (before title join) ----
289 : * The join adds titles in-place so we store after the join, but we
290 : * need to handle all exit paths. Prime the cache fields now so that
291 : * the goto-jump at join_done always sees a consistent state. */
292 : {
293 48 : int cached_n = written < DIALOGS_CACHE_MAX ? written : DIALOGS_CACHE_MAX;
294 48 : cache->valid = 1;
295 48 : cache->archived = archived;
296 48 : cache->fetched_at = dialogs_now();
297 48 : cache->count = cached_n;
298 48 : cache->total_count = total_count ? *total_count : written;
299 48 : memcpy(cache->entries, out, (size_t)cached_n * sizeof(DialogEntry));
300 : }
301 :
302 : /* ---- Title join ----
303 : *
304 : * messages.dialogs / dialogsSlice continues with:
305 : * messages:Vector<Message>
306 : * chats:Vector<Chat>
307 : * users:Vector<User>
308 : *
309 : * If we consumed every Dialog above, the cursor is positioned at the
310 : * start of the messages vector. Walk it using tl_skip_message, then
311 : * build id→title maps from the chats and users vectors and back-fill
312 : * titles on the returned DialogEntry rows.
313 : *
314 : * If ANY step fails (unsupported flag etc.) we stop gracefully and
315 : * leave whatever titles we collected so far — the caller already has
316 : * ids and counts, so the feature degrades instead of failing. */
317 48 : if (written < (int)count) return 0; /* partial parse — skip join */
318 44 : if (!tl_reader_ok(&r)) return 0;
319 :
320 41 : uint32_t mvec = tl_read_uint32(&r);
321 41 : if (mvec != TL_vector) return 0;
322 41 : uint32_t mcount = tl_read_uint32(&r);
323 42 : for (uint32_t i = 0; i < mcount; i++) {
324 1 : if (tl_skip_message(&r) != 0) return 0;
325 : }
326 :
327 41 : uint32_t cvec = tl_read_uint32(&r);
328 41 : if (cvec != TL_vector) return 0;
329 41 : uint32_t ccount = tl_read_uint32(&r);
330 41 : ChatSummary *chats = (ccount > 0)
331 3 : ? (ChatSummary *)calloc(ccount, sizeof(ChatSummary))
332 41 : : NULL;
333 41 : uint32_t chats_written = 0;
334 44 : for (uint32_t i = 0; i < ccount; i++) {
335 3 : ChatSummary cs = {0};
336 3 : if (tl_extract_chat(&r, &cs) != 0) { free(chats); chats = NULL; goto join_done; }
337 3 : if (chats) chats[chats_written++] = cs;
338 : }
339 :
340 41 : uint32_t uvec = tl_read_uint32(&r);
341 41 : if (uvec != TL_vector) { free(chats); goto join_done; }
342 41 : uint32_t ucount = tl_read_uint32(&r);
343 41 : UserSummary *users = (ucount > 0)
344 10 : ? (UserSummary *)calloc(ucount, sizeof(UserSummary))
345 41 : : NULL;
346 41 : uint32_t users_written = 0;
347 51 : for (uint32_t i = 0; i < ucount; i++) {
348 10 : UserSummary us = {0};
349 10 : if (tl_extract_user(&r, &us) != 0) { free(chats); free(users); goto join_done; }
350 10 : if (users) users[users_written++] = us;
351 : }
352 :
353 : /* Fill DialogEntry title/username + access_hash by looking up peer_id. */
354 876 : for (int i = 0; i < *out_count; i++) {
355 835 : DialogEntry *e = &out[i];
356 835 : if (e->kind == DIALOG_PEER_USER) {
357 830 : for (uint32_t j = 0; j < users_written; j++) {
358 10 : if (users[j].id == e->peer_id) {
359 10 : memcpy(e->title, users[j].name, sizeof(e->title));
360 10 : memcpy(e->username, users[j].username, sizeof(e->username));
361 10 : e->access_hash = users[j].access_hash;
362 10 : e->have_access_hash = users[j].have_access_hash;
363 10 : break;
364 : }
365 : }
366 : } else { /* CHAT / CHANNEL */
367 5 : for (uint32_t j = 0; j < chats_written; j++) {
368 3 : if (chats[j].id == e->peer_id) {
369 3 : memcpy(e->title, chats[j].title, sizeof(e->title));
370 : /* Legacy chat has no access_hash on the wire; leave
371 : * have_access_hash=0 so the caller knows. Channels do. */
372 3 : e->access_hash = chats[j].access_hash;
373 3 : e->have_access_hash = chats[j].have_access_hash;
374 3 : break;
375 : }
376 : }
377 : }
378 : }
379 41 : free(chats);
380 41 : free(users);
381 : /* Refresh cached entries to include joined titles / access_hashes. */
382 : {
383 41 : int cached_n = *out_count < DIALOGS_CACHE_MAX ? *out_count : DIALOGS_CACHE_MAX;
384 41 : memcpy(cache->entries, out, (size_t)cached_n * sizeof(DialogEntry));
385 : }
386 41 : join_done:
387 41 : return 0;
388 : }
|