/* * chat_msg.c — сообщения: отправка, DB-операции для chat_sync * * Вынесено из chat_core.c для уменьшения размера модуля. */ #include "chat_core_priv.h" #include "chat_event.h" #include "chat_setting.h" #include "chat_whisper.h" #include "../utun_instance.h" #include "../transport_layer/secure_channel.h" #include "../media_delivery/media_index.h" #include "../media_delivery/media_download.h" #include "../video/video.h" #include "../../lib/mem.h" #include "../../lib/platform_compat.h" #include #include chat_whisper_trigger_fn g_chat_whisper_trigger = NULL; /* forward decl */ void chat_core_submit_media_message(struct chat_msg_submit* req); static void wh_transcribe_done_cb(void* arg, const char* ch_id, const char* text, int err, uint64_t reply_ts, uint64_t reply_node); /* ─── отправка сообщения (GUI → uasync) ─── */ static int msg_get_prev_chain_hash(const char* tbl, uint8_t out[32]) { char sql[128]; snprintf(sql, sizeof(sql), "SELECT chain_hash FROM \"%s\" ORDER BY timestamp DESC LIMIT 1", tbl); sqlite3_stmt* st = NULL; if (sqlite3_prepare_v2(g_cc.db, sql, -1, &st, NULL) != SQLITE_OK) { memset(out,0,32); return -1; } if (sqlite3_step(st) == SQLITE_ROW) { const void* blob = sqlite3_column_blob(st, 0); int blen = sqlite3_column_bytes(st, 0); if (blob && blen == 32) memcpy(out, blob, 32); else memset(out, 0, 32); } else { memset(out, 0, 32); } sqlite3_finalize(st); return 0; } void chat_core_submit_message(struct chat_msg_submit* req) { if (!g_cc.initialized) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: submit before init", CC_ID); return; } if (!req) return; DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: SUBMIT ch=%s ct=%s len=%u", CC_ID, req->channel_id, req->content_type, req->data_len); struct DB_SYNC_INSTANCE* si = si_find(req->channel_id); if (!si) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: no db_sync instance for ch=%s", CC_ID, req->channel_id); return; } char json[4096]; int off = snprintf(json, sizeof(json), "{\"n\":%llu,\"ch\":\"%s\",\"ct\":\"%s\"", (unsigned long long)g_cc.my_node_id, req->channel_id, req->content_type); if (req->reply_to_ts) { off += snprintf(json + off, sizeof(json) - off, ",\"rt\":%llu,\"rn\":%llu", (unsigned long long)req->reply_to_ts, (unsigned long long)req->reply_to_node_id); } off += snprintf(json + off, sizeof(json) - off, ",\"d\":\"%.*s\"}", (int)req->data_len, (const char*)req->data); uint64_t ts = db_sync_next_timestamp(si); uint8_t sig_msg[8192]; size_t soff = 0; memcpy(sig_msg + soff, &ts, 8); soff += 8; size_t jl = strlen(json); memcpy(sig_msg + soff, json, jl); soff += jl; uint8_t sig[64]; if (sc_ed25519_sign(g_cc.inst->my_ed25519_privkey, sig_msg, soff, sig) != SC_OK) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: Ed25519 sign failed", CC_ID); return; } int ret = db_sync_insert_signed(si, json, strlen(json), sig, 64, ts, NULL); if (ret != 0) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: db_sync_insert_signed failed ret=%d", CC_ID, ret); return; } } void chat_core_submit_trampoline(void* arg) { struct chat_msg_submit* req = (struct chat_msg_submit*)arg; if (req->media_src[0] != '\0') chat_core_submit_media_message(req); else chat_core_submit_message(req); u_free(req); } /* ─── Медиа-сообщения: async регистрация в media_files + отправка ─── */ struct media_submit_ctx { char channel_id[64]; char content_type[32]; char media_dest[1024]; uint8_t msg_data[4096]; uint32_t msg_data_len; uint64_t timestamp; int64_t duration_ms; /* видео: длительность после транскода */ int width; /* видео: размеры после транскода */ int height; }; static void on_media_registered(void* arg, int err, const struct media_index_result* result) { struct media_submit_ctx* mctx = (struct media_submit_ctx*)arg; if (err || !result) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: media registration failed ch=%s err=%d", CC_ID, mctx->channel_id, err); u_free(mctx); return; } struct DB_SYNC_INSTANCE* si = si_find(mctx->channel_id); if (!si) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: no db_sync instance for ch=%s", CC_ID, mctx->channel_id); u_free(mctx); return; } /* build full_data: |||||| */ size_t base_len = mctx->msg_data_len; int nb = result->num_blocks; size_t pairs_len = (size_t)nb * (32 + 1 + 128 + 1); /* id_hex, sig_hex, commas */ size_t extra = 128 + 32 + 64 + pairs_len + 256; size_t fdcap = base_len + extra; char* full_data = u_malloc(fdcap); if (!full_data) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: data alloc fail", CC_ID); u_free(mctx); return; } size_t off = 0; if (base_len > 0) { char b64[2048]; size_t b64len = b64_encode(mctx->msg_data, base_len, b64, sizeof(b64)); full_data[off++] = '*'; memcpy(full_data + off, b64, b64len); off += b64len; } off += snprintf(full_data + off, fdcap - off, "|%lld|%lld|%d|", (long long)result->file_size, (long long)result->block_size, nb); for (int i = 0; i < 16; i++) off += snprintf(full_data + off, fdcap - off, "%02x", result->media_id[i]); off += snprintf(full_data + off, fdcap - off, "|"); for (int i = 0; i < 32; i++) off += snprintf(full_data + off, fdcap - off, "%02x", result->content_hash[i]); off += snprintf(full_data + off, fdcap - off, "|"); for (int n = 0; n < nb; n++) { if (n > 0) full_data[off++] = ','; for (int i = 0; i < 16; i++) off += snprintf(full_data + off, fdcap - off, "%02x", result->block_ids[n * 16 + i]); full_data[off++] = ','; for (int i = 0; i < 64; i++) off += snprintf(full_data + off, fdcap - off, "%02x", result->block_sigs[n * 64 + i]); } /* для видео дописываем длительность/размеры в конец (хвост игнорируется старыми парсерами) */ if (mctx->content_type[0] && strcmp(mctx->content_type, "video/mp4") == 0 && mctx->duration_ms > 0) { off += snprintf(full_data + off, fdcap - off, "|dur=%lld|w=%d|h=%d", (long long)mctx->duration_ms, mctx->width, mctx->height); } size_t full_data_len = off; size_t json_cap = full_data_len + 256; char* json = u_malloc(json_cap); if (!json) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: json alloc fail", CC_ID); u_free(full_data); u_free(mctx); return; } snprintf(json, json_cap, "{\"n\":%llu,\"ch\":\"%s\",\"ct\":\"%s\",\"d\":\"%.*s\"}", (unsigned long long)g_cc.my_node_id, mctx->channel_id, mctx->content_type, (int)full_data_len, full_data); uint64_t ts = db_sync_next_timestamp(si); size_t sig_msg_len = 8 + strlen(json); uint8_t* sig_msg = u_malloc(sig_msg_len); if (!sig_msg) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: sig_msg alloc fail", CC_ID); u_free(json); u_free(full_data); u_free(mctx); return; } memcpy(sig_msg, &ts, 8); memcpy(sig_msg + 8, json, sig_msg_len - 8); uint8_t json_sig[64]; if (sc_ed25519_sign(g_cc.inst->my_ed25519_privkey, sig_msg, sig_msg_len, json_sig) != SC_OK) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: Ed25519 sign JSON failed", CC_ID); u_free(sig_msg); u_free(json); u_free(full_data); u_free(mctx); return; } int ret = db_sync_insert_signed(si, json, strlen(json), json_sig, 64, ts, NULL); if (ret != 0) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: media insert failed ret=%d", CC_ID, ret); } else { DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: media message inserted ch=%s ts=%llu", CC_ID, mctx->channel_id, (unsigned long long)ts); const char* base = strrchr(mctx->media_dest, '/'); const char* filename = base ? base + 1 : mctx->media_dest; char attrs[1100]; snprintf(attrs, sizeof(attrs), "{\"st\":\"fl\",\"fp\":\"%s\"}", filename); chat_core_update_local_attrs(mctx->channel_id, ts, json_sig, attrs); /* whisper транскрипция для своих голосовых сообщений */ if (mctx->content_type[0] && strncmp(mctx->content_type, "voice", 5) == 0 && g_cc.inst && g_cc.inst->media_async && g_chat_whisper_trigger) { g_chat_whisper_trigger(g_cc.inst->media_async, g_cc.inst->ua, mctx->media_dest, mctx->channel_id, ts, g_cc.my_node_id, wh_transcribe_done_cb, NULL); } } u_free(sig_msg); u_free(json); u_free(full_data); u_free(mctx); } static void on_video_prepared(void* arg, int err) { struct media_submit_ctx* mctx = (struct media_submit_ctx*)arg; if (err) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: video prepare failed ch=%s err=%d", CC_ID, mctx->channel_id, err); u_free(mctx); return; } /* после транскода знаем итоговую длительность и размеры — для тела сообщения */ struct video_info vi; if (video_get_info(mctx->media_dest, &vi) == 0 && vi.is_video) { mctx->duration_ms = vi.duration_ms; mctx->width = vi.width; mctx->height = vi.height; DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: video prepared %dx%d dur=%lldms", CC_ID, vi.width, vi.height, (long long)vi.duration_ms); } char media_base[1024]; { const char* last_slash = strrchr(g_cc.db_path, '/'); if (last_slash) snprintf(media_base, sizeof(media_base), "%.*s", (int)(last_slash - g_cc.db_path), g_cc.db_path); else snprintf(media_base, sizeof(media_base), "%s", g_cc.db_path); } media_index_register_async( g_cc.inst->media_async, g_cc.inst->ua, g_cc.db, g_cc.my_node_id, g_cc.inst->my_ed25519_privkey, mctx->channel_id, mctx->media_dest, mctx->media_dest, 0, media_base, on_media_registered, mctx); } void chat_core_submit_media_message(struct chat_msg_submit* req) { if (!g_cc.initialized) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: media submit before init", CC_ID); return; } if (!req || req->media_src[0] == '\0') return; DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: MEDIA SUBMIT ch=%s ct=%s src=%s dst=%s copy=%d video=%d", CC_ID, req->channel_id, req->content_type, req->media_src, req->media_dest, req->media_copy, req->media_video); if (!g_cc.inst->media_async) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: media_async not initialized", CC_ID); return; } /* build media_base from db_path: dirname(db_path) e.g. /path/to/data */ char media_base[1024]; { const char* last_slash = strrchr(g_cc.db_path, '/'); if (last_slash) snprintf(media_base, sizeof(media_base), "%.*s", (int)(last_slash - g_cc.db_path), g_cc.db_path); else snprintf(media_base, sizeof(media_base), "%s", g_cc.db_path); } /* copy fields for async callback (req is freed by trampoline immediately) */ struct media_submit_ctx* mctx = u_calloc(1, sizeof(*mctx)); if (!mctx) return; snprintf(mctx->channel_id, sizeof(mctx->channel_id), "%s", req->channel_id); snprintf(mctx->content_type, sizeof(mctx->content_type), "%s", req->content_type); snprintf(mctx->media_dest, sizeof(mctx->media_dest), "%s", req->media_dest); mctx->timestamp = req->timestamp; if (req->data && req->data_len > 0) { mctx->msg_data_len = req->data_len < sizeof(mctx->msg_data) ? req->data_len : sizeof(mctx->msg_data) - 1; memcpy(mctx->msg_data, req->data, mctx->msg_data_len); } if (req->media_video) { video_transcode_start(g_cc.inst->ua, req->media_src, req->media_dest, on_video_prepared, mctx); return; } media_index_register_async( g_cc.inst->media_async, g_cc.inst->ua, g_cc.db, g_cc.my_node_id, g_cc.inst->my_ed25519_privkey, req->channel_id, req->media_src, req->media_dest, req->media_copy ? 1 : 0, media_base, on_media_registered, mctx); } /* ─── Обновление local_attrs (для будущей приёмной стороны) ─── */ int chat_core_update_local_attrs(const char* ch_id, uint64_t ts, const uint8_t* author_sig, const char* local_attrs_json) { if (!g_cc.initialized || !ch_id || !author_sig || !local_attrs_json) return -1; char tbl[80]; msg_table_name(ch_id, tbl, sizeof(tbl)); char sql[256]; snprintf(sql, sizeof(sql), "UPDATE \"%s\" SET local_attrs=? WHERE timestamp=? AND author_signature=?", tbl); sqlite3_stmt* stmt = NULL; if (sqlite3_prepare_v2(g_cc.db, sql, -1, &stmt, NULL) != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: update local_attrs prep fail: %s", CC_ID, sqlite3_errmsg(g_cc.db)); return -1; } sqlite3_bind_text(stmt, 1, local_attrs_json, -1, SQLITE_STATIC); sqlite3_bind_int64(stmt, 2, (sqlite3_int64)ts); sqlite3_bind_blob(stmt, 3, author_sig, DB_SIG_SIZE, SQLITE_STATIC); int rc = sqlite3_step(stmt); sqlite3_finalize(stmt); if (rc != SQLITE_DONE) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: update local_attrs step fail rc=%d", CC_ID, rc); return -1; } DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: local_attrs updated ch=%s ts=%llu", CC_ID, ch_id, (unsigned long long)ts); return 0; } /* ─── Отметка голосового как проигранного ─── */ void chat_core_mark_voice_played(const char* ch_id, uint64_t ts, uint64_t author_node_id) { if (!g_cc.initialized || !ch_id || ts == 0) return; char tbl[80]; msg_table_name(ch_id, tbl, sizeof(tbl)); char sql[256]; snprintf(sql, sizeof(sql), "SELECT local_attrs, author_signature FROM \"%s\" WHERE timestamp=? AND node_id=? LIMIT 1", tbl); sqlite3_stmt* st = NULL; if (sqlite3_prepare_v2(g_cc.db, sql, -1, &st, NULL) != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: mark_voice_played prep fail: %s", CC_ID, sqlite3_errmsg(g_cc.db)); return; } sqlite3_bind_int64(st, 1, (sqlite3_int64)ts); sqlite3_bind_int64(st, 2, (sqlite3_int64)author_node_id); if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); return; } const char* la = (const char*)sqlite3_column_text(st, 0); const uint8_t* sig = sqlite3_column_blob(st, 1); int sig_len = sqlite3_column_bytes(st, 1); if (!sig || sig_len != DB_SIG_SIZE) { sqlite3_finalize(st); return; } uint8_t author_sig[DB_SIG_SIZE]; memcpy(author_sig, sig, DB_SIG_SIZE); if (la && strstr(la, "\"pl\":1")) { sqlite3_finalize(st); return; } /* уже помечено */ /* вставляем "pl":1 перед последней '}' */ char new_attrs[256]; if (la && la[0]) { size_t la_len = strlen(la); if (la_len > 0 && la[la_len - 1] == '}') { snprintf(new_attrs, sizeof(new_attrs), "%.*s,\"pl\":1}", (int)(la_len - 1), la); } else { snprintf(new_attrs, sizeof(new_attrs), "%s,\"pl\":1}", la); } } else { snprintf(new_attrs, sizeof(new_attrs), "{\"pl\":1}"); } sqlite3_finalize(st); chat_core_update_local_attrs(ch_id, ts, author_sig, new_attrs); DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: voice marked played ch=%s ts=%llu", CC_ID, ch_id, (unsigned long long)ts); } void chat_core_mark_voice_played_trampoline(void* arg) { struct voice_played_req* req = (struct voice_played_req*)arg; chat_core_mark_voice_played(req->channel_id, req->ts, req->author_node_id); u_free(req); } /* ─── DB-операции для chat_sync ─── */ uint32_t chat_core_count(const char* ch_id) { if (!g_cc.initialized) return 0; char tbl[80]; msg_table_name(ch_id, tbl, sizeof(tbl)); char sql[128]; snprintf(sql, sizeof(sql), "SELECT COUNT(*) FROM \"%s\"", tbl); sqlite3_stmt* stmt = NULL; if (sqlite3_prepare_v2(g_cc.db, sql, -1, &stmt, NULL) != SQLITE_OK) return 0; uint32_t cnt = 0; if (sqlite3_step(stmt) == SQLITE_ROW) cnt = (uint32_t)sqlite3_column_int64(stmt, 0); sqlite3_finalize(stmt); return cnt; } int chat_core_chain_hash_at(const char* ch_id, uint32_t pos, uint8_t* hash_out) { if (!g_cc.initialized || !hash_out) return -1; char tbl[80]; msg_table_name(ch_id, tbl, sizeof(tbl)); char sql[200]; snprintf(sql, sizeof(sql), "SELECT chain_hash FROM \"%s\" ORDER BY timestamp, node_id ASC LIMIT 1 OFFSET ?", tbl); sqlite3_stmt* stmt = NULL; if (sqlite3_prepare_v2(g_cc.db, sql, -1, &stmt, NULL) != SQLITE_OK) { memset(hash_out, 0, 32); return -1; } sqlite3_bind_int64(stmt, 1, (sqlite3_int64)pos); if (sqlite3_step(stmt) == SQLITE_ROW) { const void* blob = sqlite3_column_blob(stmt, 0); int len = sqlite3_column_bytes(stmt, 0); if (blob && len == 32) memcpy(hash_out, blob, 32); else memset(hash_out, 0, 32); } else { memset(hash_out, 0, 32); } sqlite3_finalize(stmt); return 0; } int chat_core_list_channels(uint8_t* buf, size_t buf_size, size_t* out_len) { if (!g_cc.initialized || !buf || !out_len) return -1; sqlite3_stmt* stmt = NULL; if (sqlite3_prepare_v2(g_cc.db, "SELECT channel_id FROM channels ORDER BY created_at ASC", -1, &stmt, NULL) != SQLITE_OK) return -1; uint8_t* out = buf; uint8_t* start = out; out += 2; uint16_t cnt = 0; while (sqlite3_step(stmt) == SQLITE_ROW) { const char* ch_id = (const char*)sqlite3_column_text(stmt, 0); int ch_len = sqlite3_column_bytes(stmt, 0); if (!ch_id || ch_len <= 0 || ch_len > 63) continue; size_t need = (size_t)(out - buf) + 1 + (size_t)ch_len; if (need > buf_size) break; *out++ = (uint8_t)ch_len; memcpy(out, ch_id, (size_t)ch_len); out += ch_len; cnt++; } sqlite3_finalize(stmt); memcpy(start, &cnt, 2); *out_len = (size_t)(out - buf); return 0; } int chat_core_list_peers(const char* ch_id, uint8_t* buf, size_t buf_size, size_t* out_len) { if (!g_cc.initialized || !buf || !out_len) return -1; char tbl[80]; peers_table_name(ch_id, tbl, sizeof(tbl)); char sql[128]; snprintf(sql, sizeof(sql), "SELECT node_id FROM \"%s\"", tbl); sqlite3_stmt* stmt = NULL; if (sqlite3_prepare_v2(g_cc.db, sql, -1, &stmt, NULL) != SQLITE_OK) { uint16_t z = 0; memcpy(buf, &z, 2); *out_len = 2; return -1; } uint16_t cnt = 0; uint8_t* out = buf + 2; while (sqlite3_step(stmt) == SQLITE_ROW) { if ((size_t)(out - buf) + 8 > buf_size) break; uint64_t nid = (uint64_t)sqlite3_column_int64(stmt, 0); memcpy(out, &nid, 8); out += 8; cnt++; } sqlite3_finalize(stmt); memcpy(buf, &cnt, 2); *out_len = (size_t)(out - buf); return 0; } int chat_core_load_nodeinfo(uint64_t node_id, uint8_t* buf, size_t buf_size, size_t* out_len) { if (!g_cc.initialized || !buf || !out_len) return -1; sqlite3_stmt* stmt = NULL; if (sqlite3_prepare_v2(g_cc.db, "SELECT x25519_pubkey, ed25519_pubkey FROM nodes WHERE node_id=?", -1, &stmt, NULL) != SQLITE_OK) return -1; sqlite3_bind_int64(stmt, 1, (sqlite3_int64)node_id); if (sqlite3_step(stmt) != SQLITE_ROW) { sqlite3_finalize(stmt); return -1; } const uint8_t* x25519 = (const uint8_t*)sqlite3_column_blob(stmt, 0); int x25519_len = sqlite3_column_bytes(stmt, 0); const uint8_t* ed25519 = (const uint8_t*)sqlite3_column_blob(stmt, 1); int ed25519_len = sqlite3_column_bytes(stmt, 1); uint8_t* out = buf; if (x25519 && x25519_len == 32) memcpy(out, x25519, 32); else memset(out, 0, 32); out += 32; if (ed25519 && ed25519_len == 32) memcpy(out, ed25519, 32); else memset(out, 0, 32); out += 32; sqlite3_finalize(stmt); if (sqlite3_prepare_v2(g_cc.db, "SELECT family, protocol, address, port, rtt FROM node_addresses WHERE node_id=?", -1, &stmt, NULL) != SQLITE_OK) { *out++ = 0; *out_len = (size_t)(out - buf); return 0; } sqlite3_bind_int64(stmt, 1, (sqlite3_int64)node_id); uint8_t* cnt_pos = out; *out++ = 0; uint8_t addr_cnt = 0; while (sqlite3_step(stmt) == SQLITE_ROW && addr_cnt < 255) { int family = sqlite3_column_int(stmt, 0); int proto = sqlite3_column_int(stmt, 1); const uint8_t* addr = (const uint8_t*)sqlite3_column_blob(stmt, 2); int addr_len = sqlite3_column_bytes(stmt, 2); uint16_t port = (uint16_t)sqlite3_column_int(stmt, 3); int16_t rtt = (int16_t)sqlite3_column_int(stmt, 4); if ((size_t)(out - buf) + 7 + (size_t)addr_len > buf_size) break; *out++ = (uint8_t)family; *out++ = (uint8_t)proto; *out++ = (uint8_t)addr_len; if (addr_len > 0) { memcpy(out, addr, (size_t)addr_len); out += addr_len; } memcpy(out, &port, 2); out += 2; memcpy(out, &rtt, 2); out += 2; addr_cnt++; } *cnt_pos = addr_cnt; sqlite3_finalize(stmt); *out_len = (size_t)(out - buf); return 0; } /* ─── download completion ─── */ struct md_done_ctx { char channel_id[64]; uint64_t ts; int64_t msg_id; uint8_t author_sig[64]; char dest_relpath[512]; char dest_abs_path[1024]; char content_type[32]; uint64_t author_node_id; }; static void md_download_progress_cb(void* arg, int blocks_done, int num_blocks) { struct md_done_ctx* ctx = (struct md_done_ctx*)arg; uint8_t evt[256]; uint8_t cl = (uint8_t)strlen(ctx->channel_id); evt[0] = cl; memcpy(evt + 1, ctx->channel_id, cl); memcpy(evt + 1 + cl, &ctx->msg_id, 8); uint32_t bd = (uint32_t)blocks_done, nb = (uint32_t)num_blocks; memcpy(evt + 1 + cl + 8, &bd, 4); memcpy(evt + 1 + cl + 12, &nb, 4); chat_event_post(CHAT_EVT_DOWNLOAD_PROGRESS, evt, 1 + cl + 16); } static void md_download_done_cb(void* arg, int err) { struct md_done_ctx* ctx = (struct md_done_ctx*)arg; if (!err) { char attrs[1024]; snprintf(attrs, sizeof(attrs), "{\"st\":\"fl\",\"fp\":\"%s\"}", ctx->dest_relpath); chat_core_update_local_attrs(ctx->channel_id, ctx->ts, ctx->author_sig, attrs); DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "%s: media downloaded ch=%s", CC_ID, ctx->channel_id); /* whisper транскрипция для голосовых сообщений */ if (ctx->content_type[0] && strncmp(ctx->content_type, "voice", 5) == 0 && g_cc.inst && g_cc.inst->media_async && g_chat_whisper_trigger) { g_chat_whisper_trigger(g_cc.inst->media_async, g_cc.inst->ua, ctx->dest_abs_path, ctx->channel_id, ctx->ts, ctx->author_node_id, wh_transcribe_done_cb, NULL); } } else { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "%s: media download failed ch=%s err=%d", CC_ID, ctx->channel_id, err); /* пометить сообщение ошибкой, чтобы UI показал «повторить» */ char attrs[64]; snprintf(attrs, sizeof(attrs), "{\"st\":\"er\"}"); chat_core_update_local_attrs(ctx->channel_id, ctx->ts, ctx->author_sig, attrs); } uint8_t evt[80]; uint8_t cl = (uint8_t)strlen(ctx->channel_id); evt[0] = cl; memcpy(evt + 1, ctx->channel_id, cl); memcpy(evt + 1 + cl, &ctx->msg_id, 8); chat_event_post(CHAT_EVT_ATTACHMENT_DOWNLOADED, evt, 1 + cl + 8); u_free(ctx); } static void wh_transcribe_done_cb(void* arg, const char* ch_id, const char* text, int err, uint64_t reply_ts, uint64_t reply_node) { (void)arg; if (err || !text || !text[0]) { DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "%s: transcription failed ch=%s err=%d", CC_ID, ch_id, err); return; } DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "%s: transcription result ch=%s: [%s]", CC_ID, ch_id, text); /* mark original voice message as played */ chat_core_mark_voice_played(ch_id, reply_ts, reply_node); struct chat_msg_submit* req = u_calloc(1, sizeof(*req)); if (!req) return; snprintf(req->channel_id, sizeof(req->channel_id), "%s", ch_id); snprintf(req->content_type, sizeof(req->content_type), "voice_transcription"); req->data = (uint8_t*)text; req->data_len = (uint32_t)strlen(text); req->reply_to_ts = reply_ts; req->reply_to_node_id = reply_node; chat_core_submit_message(req); u_free(req); } static void sanitize_filename(char* buf, size_t size) { char orig[256]; snprintf(orig, sizeof(orig), "%s", buf); size_t w = 0; for (size_t r = 0; buf[r]; r++) { char c = buf[r]; if ((unsigned char)c < 0x20) continue; if (c == '/' || c == '\\' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>' || c == '|') continue; if (w < size - 1) buf[w++] = c; } buf[w] = '\0'; if (w == 0) snprintf(buf, size, "file"); if (strcmp(orig, buf) != 0) DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "%s: filename sanitized [%s] -> [%s]", CC_ID, orig, buf); } /* Извлекает имя файла из тела "d"-поля медиасообщения: берёт сегмент до '|', декодирует base64url-префикс '*', если он есть, и санитизирует. */ static void extract_base_filename(const char* body, char* out, size_t out_sz) { size_t bfi = 0; while (body[bfi] && body[bfi] != '|' && bfi < out_sz - 1) out[bfi++] = body[bfi]; out[bfi] = '\0'; if (out[0] == '*' && bfi > 1) { uint8_t dec[256]; int dlen = (int)b64_decode(out + 1, bfi - 1, dec, sizeof(dec)); if (dlen > 0 && dlen < (int)out_sz) { memcpy(out, dec, (size_t)dlen); out[dlen] = '\0'; } } if (bfi == 0) snprintf(out, out_sz, "file"); sanitize_filename(out, out_sz); } static int md_start_download(struct UTUN_INSTANCE* inst, const char* data_str, size_t data_len, const char* ch_id, const char* base_filename, uint64_t ts, const uint8_t* author_sig, uint64_t author_node_id, int64_t msg_id, const char* content_type) { (void)data_len; const char* p = data_str; while (*p && *p != '|') p++; if (!*p) return -1; p++; long long fsize = strtoll(p, (char**)&p, 10); if (*p != '|') return -1; p++; long long bsize = strtoll(p, (char**)&p, 10); if (*p != '|') return -1; p++; int nb = (int)strtol(p, (char**)&p, 10); while (*p && *p != '|') p++; if (!*p) return -1; p++; if (nb <= 0 || nb > 100 || fsize <= 0) return -1; struct media_index_result result; memset(&result, 0, sizeof(result)); result.file_size = fsize; result.block_size = bsize; result.num_blocks = nb; for (int i = 0; i < 16; i++) { char b[3] = { p[i*2], p[i*2+1], 0 }; result.media_id[i] = (uint8_t)strtol(b, NULL, 16); } p += 32; if (*p != '|') return -1; p++; for (int i = 0; i < 32; i++) { char b[3] = { p[i*2], p[i*2+1], 0 }; result.content_hash[i] = (uint8_t)strtol(b, NULL, 16); } p += 64; if (*p != '|') return -1; p++; result.block_ids = u_malloc((size_t)nb * 16); result.block_sigs = u_malloc((size_t)nb * 64); if (!result.block_ids || !result.block_sigs) { media_index_result_free(&result); return -1; } for (int n = 0; n < nb; n++) { for (int i = 0; i < 16; i++) { char b[3] = { p[i*2], p[i*2+1], 0 }; result.block_ids[n * 16 + i] = (uint8_t)strtol(b, NULL, 16); } p += 32; if (*p != ',') { media_index_result_free(&result); return -1; } p++; for (int i = 0; i < 64; i++) { char b[3] = { p[i*2], p[i*2+1], 0 }; result.block_sigs[n * 64 + i] = (uint8_t)strtol(b, NULL, 16); } p += 128; if (n < nb - 1 && *p == ',') p++; } /* для голосовых имя генерируем из media_id: msg_data — это wf-строка, а не имя файла */ char final_name[256]; if (content_type && strcmp(content_type, "audio/opus") == 0) { char hex[33]; for (int i = 0; i < 16; i++) snprintf(hex + i * 2, 3, "%02x", result.media_id[i]); snprintf(final_name, sizeof(final_name), "voice_%s.opus", hex); } else { snprintf(final_name, sizeof(final_name), "%s", base_filename); } char dest[1024], media_base[512]; { const char* last_slash = strrchr(g_cc.db_path, '/'); if (last_slash) { snprintf(media_base, sizeof(media_base), "%.*s", (int)(last_slash - g_cc.db_path), g_cc.db_path); } else { snprintf(media_base, sizeof(media_base), "%s", g_cc.db_path); } } snprintf(dest, sizeof(dest), "%s/media/%s/%s", media_base, ch_id, final_name); struct md_done_ctx* ctx = u_calloc(1, sizeof(*ctx)); if (!ctx) { media_index_result_free(&result); return -1; } snprintf(ctx->channel_id, sizeof(ctx->channel_id), "%s", ch_id); ctx->ts = ts; ctx->msg_id = msg_id; snprintf(ctx->dest_relpath, sizeof(ctx->dest_relpath), "%s", final_name); snprintf(ctx->dest_abs_path, sizeof(ctx->dest_abs_path), "%s", dest); ctx->author_node_id = author_node_id; if (content_type) snprintf(ctx->content_type, sizeof(ctx->content_type), "%s", content_type); else ctx->content_type[0] = '\0'; if (author_sig) memcpy(ctx->author_sig, author_sig, 64); DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "%s: download start ch=%s ct=%s file=%s blocks=%d size=%lld dest=%s", CC_ID, ch_id, content_type ? content_type : "?", final_name, nb, (long long)fsize, dest); uint64_t gid = strtoull(ch_id, NULL, 10); media_download_start(inst, gid, &result, dest, media_base, author_node_id, md_download_done_cb, ctx, md_download_progress_cb, ctx); media_index_result_free(&result); return 0; } static void md_auto_download(struct UTUN_INSTANCE* inst, const char* data_str, size_t data_len, const char* ch_id, const char* base_filename, uint64_t ts, const uint8_t* author_sig, uint64_t author, int64_t msg_id, const char* content_type) { if (!chat_setting_get_int("storage_autoload", 1)) return; uint64_t max_size = g_cc.inst->config->global.chatserver_storage_unit_size; const char* p = data_str; while (*p && *p != '|') p++; if (!*p) return; p++; long long fsize = strtoll(p, (char**)&p, 10); if (max_size > 0 && fsize > (long long)max_size) { DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: media too large (%lld > %llu bytes), skip auto-download", CC_ID, fsize, (unsigned long long)max_size); return; } md_start_download(inst, data_str, data_len, ch_id, base_filename, ts, author_sig, author, msg_id, content_type); } /* ─── db_sync callback ─── */ void on_msg_inserted(struct DB_SYNC_INSTANCE* si, uint64_t record_ts, const char* data, size_t len, uint64_t author, void* arg) { (void)si; const char* ch_id = (const char*)arg; uint8_t evt[73]; uint8_t cl = (uint8_t)strlen(ch_id); evt[0] = cl; memcpy(evt + 1, ch_id, cl); memcpy(evt + 1 + cl, &author, 8); chat_event_post(CHAT_EVT_MSG_RECEIVED, evt, 1 + cl + 8); /* auto-download media if message contains media metadata */ if (data && len > 0 && author != g_cc.my_node_id) { /* data from db_sync may not be null-terminated; copy to local buffer */ char buf[4096]; size_t blen = len < sizeof(buf) - 1 ? len : sizeof(buf) - 1; memcpy(buf, data, blen); buf[blen] = '\0'; const char* ct = strstr(buf, "\"ct\":\""); const char* dpos = strstr(buf, "\"d\":\""); if (ct && dpos) { /* extract content_type value */ char content_type[32] = {0}; const char* ct_val = ct + 6; int cti = 0; while (ct_val[cti] && ct_val[cti] != '\"' && cti < (int)sizeof(content_type) - 1) content_type[cti++] = ct_val[cti]; content_type[cti] = '\0'; const char* d_start = dpos + 5; char body[4096]; size_t bi = 0; while (*d_start && *d_start != '\"' && bi < sizeof(body) - 1) body[bi++] = *d_start++; body[bi] = '\0'; char base_filename[256]; extract_base_filename(body, base_filename, sizeof(base_filename)); const uint8_t* sig_field = (const uint8_t*)strstr(buf, "\"sig\":\""); uint8_t author_sig[64] = {0}; if (sig_field) { /* read author_signature from DB — find by ts in on_msg_inserted we don't have sig so leave as zeros; use record_ts to locate the record later */ } /* We don't have author_sig in the callback; derive from body content. Instead, read it from the DB using the just-inserted record */ char tbl[80]; msg_table_name(ch_id, tbl, sizeof(tbl)); char sql[256]; snprintf(sql, sizeof(sql), "SELECT author_signature, id FROM \"%s\" WHERE timestamp=? AND node_id=? LIMIT 1", tbl); sqlite3_stmt* st = NULL; if (sqlite3_prepare_v2(g_cc.db, sql, -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_int64(st, 1, (sqlite3_int64)record_ts); sqlite3_bind_int64(st, 2, (sqlite3_int64)author); int64_t msg_id = 0; if (sqlite3_step(st) == SQLITE_ROW) { const void* sblob = sqlite3_column_blob(st, 0); int sblen = sqlite3_column_bytes(st, 0); if (sblob && sblen == 64) memcpy(author_sig, sblob, 64); msg_id = sqlite3_column_int64(st, 1); } sqlite3_finalize(st); if (msg_id > 0) md_auto_download(g_cc.inst, body, bi, ch_id, base_filename, record_ts, author_sig, author, msg_id, content_type); } } } sqlite3_stmt* st = NULL; sqlite3_prepare_v2(g_cc.db, "UPDATE channels SET last_msg_at = ? WHERE channel_id = ?", -1, &st, NULL); if (st) { sqlite3_bind_int64(st, 1, (sqlite3_int64)record_ts); sqlite3_bind_text(st, 2, ch_id, -1, SQLITE_STATIC); sqlite3_step(st); sqlite3_finalize(st); } } /* ─── manual attachment download (called from JNI bridge via uasync post) ─── */ void chat_core_attachment_download(const char* channel_id, int64_t msg_id) { if (!channel_id || msg_id <= 0) return; char tbl[80]; msg_table_name(channel_id, tbl, sizeof(tbl)); char sql[256]; snprintf(sql, sizeof(sql), "SELECT data, timestamp, author_signature, node_id FROM \"%s\" WHERE id=?", tbl); sqlite3_stmt* st = NULL; if (sqlite3_prepare_v2(g_cc.db, sql, -1, &st, NULL) != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "%s: download sql prep failed ch=%s", CC_ID, channel_id); return; } sqlite3_bind_int64(st, 1, (sqlite3_int64)msg_id); if (sqlite3_step(st) != SQLITE_ROW) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "%s: download msg not found ch=%s id=%lld", CC_ID, channel_id, (long long)msg_id); sqlite3_finalize(st); return; } const uint8_t* jdata = sqlite3_column_text(st, 0); int jlen = sqlite3_column_bytes(st, 0); int64_t db_ts = sqlite3_column_int64(st, 1); const uint8_t* sig_blob = (const uint8_t*)sqlite3_column_blob(st, 2); int sig_len = sqlite3_column_bytes(st, 2); int64_t author_node_id = sqlite3_column_int64(st, 3); if (!jdata || jlen <= 0 || !sig_blob || sig_len != 64) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "%s: download bad msg data ch=%s id=%lld", CC_ID, channel_id, (long long)msg_id); sqlite3_finalize(st); return; } const char* ds = strstr((const char*)jdata, "\"d\":\""); if (!ds) { sqlite3_finalize(st); return; } const char* d_start = ds + 5; char body[4096]; size_t bi = 0; while (*d_start && *d_start != '\"' && bi < sizeof(body) - 1) body[bi++] = *d_start++; body[bi] = '\0'; char content_type[32] = {0}; { const char* ct = strstr((const char*)jdata, "\"ct\":\""); if (ct) { const char* cv = ct + 6; int i = 0; while (cv[i] && cv[i] != '\"' && i < (int)sizeof(content_type) - 1) content_type[i++] = cv[i]; content_type[i] = '\0'; } } char base_filename[256]; extract_base_filename(body, base_filename, sizeof(base_filename)); uint8_t author_sig[64]; memcpy(author_sig, sig_blob, 64); sqlite3_finalize(st); DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "%s: manual download ch=%s id=%lld ct=%s file=%s", CC_ID, channel_id, (long long)msg_id, content_type, base_filename); md_start_download(g_cc.inst, body, bi, channel_id, base_filename, (uint64_t)db_ts, author_sig, (uint64_t)author_node_id, msg_id, content_type); } static void chat_core_attachment_download_trampoline_impl(void* arg) { struct attachment_dl_req* req = (struct attachment_dl_req*)arg; chat_core_attachment_download(req->channel_id, req->msg_id); u_free(req); } void chat_core_attachment_download_trampoline(void* arg) { chat_core_attachment_download_trampoline_impl(arg); }