diff --git a/src/chat/chat_event.h b/src/chat/chat_event.h index 7299ab3e..68927afe 100644 --- a/src/chat/chat_event.h +++ b/src/chat/chat_event.h @@ -75,6 +75,7 @@ extern "C" { #define CHAT_EVT_AVATAR_RESULT 51 /* request_id/group:8LE, failed:1; результат публикации ссылки профиля */ #define CHAT_EVT_GROUP_AVATAR_RESULT 52 /* request_id/group:8LE, failed:1; результат публикации аватарки группы */ #define CHAT_EVT_FEEDBACK_CHANGED 53 /* [channel_len:1][channel][message_id:32][kind:1] — состояние после commit */ +#define CHAT_EVT_FEEDBACK_RESULT 54 /* [request_id:8LE][success:1][JSON] — результат команды интерфейса */ typedef void (*chat_event_handler_fn)(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len); diff --git a/src/chat/chat_feedback.c b/src/chat/chat_feedback.c index 7c236fc3..f056fdb8 100644 --- a/src/chat/chat_feedback.c +++ b/src/chat/chat_feedback.c @@ -1,4 +1,5 @@ #include "chat_feedback.h" +#include "chat_poll.h" #include "merkle_sync.h" #include "member_sync.h" #include "chat_event.h" @@ -62,6 +63,7 @@ static int fb_encode(const struct chat_feedback_record* r, uint8_t* wire) { || (r->kind != CHAT_FEEDBACK_REACTION && r->kind != CHAT_FEEDBACK_ANSWER) || r->data_len > CHAT_FEEDBACK_DATA_MAX) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: invalid record fields"); return -1; } + if (chat_feedback_validate_data(r->kind, r->data, r->data_len) < 0) return -1; memcpy(wire, r->message_id, 32); fb_write64(wire + 32, r->node_id); wire[40] = r->kind; fb_write64(wire + 41, r->ver); wire[49] = r->data_len >> 8; wire[50] = r->data_len & 255; memcpy(wire + 51, r->data, r->data_len); memcpy(wire + 51 + r->data_len, r->signature, 64); @@ -225,6 +227,7 @@ static int fb_apply_row(struct UTUN_INSTANCE* inst, const char* channel, const s static void fb_committed(struct UTUN_INSTANCE* inst, const char* channel, const struct chat_feedback_record* r) { uint8_t event[54]; size_t len = strlen(channel); event[0] = len; memcpy(event + 1, channel, len); memcpy(event + 1 + len, r->message_id, 32); event[33 + len] = r->kind; + chat_feedback_check_message(inst, channel, r->message_id); chat_event_post(inst, CHAT_EVT_FEEDBACK_CHANGED, event, (int)len + 34); } diff --git a/src/chat/chat_poll.c b/src/chat/chat_poll.c new file mode 100644 index 00000000..0135028e --- /dev/null +++ b/src/chat/chat_poll.c @@ -0,0 +1,364 @@ +#include "chat_poll.h" +#include "chat_feedback.h" +#include "chat_core_priv.h" +#include "chat_event.h" +#include "../utun_instance.h" +#include "../transport_layer/secure_channel.h" +#include "../../lib/strbuf.h" +#include "../../lib/mem.h" +#include +#include +#include + +/* Канал — канонический положительный ID, подходящий для имени таблицы. */ +static int poll_channel(struct UTUN_INSTANCE* inst, const char* channel, uint64_t* id) { + if (!inst || !inst->topo_sqlite_db || !channel || !channel[0] || channel[0] == '0' || strlen(channel) > 19) goto invalid; + for (const char* p = channel; *p; p++) if (*p < '0' || *p > '9') goto invalid; + errno = 0; char* end; uint64_t n = strtoull(channel, &end, 10); + if (errno || *end || n > INT64_MAX) goto invalid; + if (id) *id = n; + return 0; +invalid: + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: invalid channel=%s", channel ? channel : "-"); return -1; +} + +static int poll_db_error(struct UTUN_INSTANCE* inst, const char* operation) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: %s: %s", operation, sqlite3_errmsg(inst->topo_sqlite_db)); return -1; +} + +/* Возвращает 1 для отсутствующего сообщения, 0 для найденного, -1 для ошибки. + * is_poll отдельно отличает обычное сообщение от опроса. БД содержит проверенную подпись. */ +static int poll_target(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message_id[32], + struct chat_poll* poll, int* is_poll) { + if (poll_channel(inst, channel, NULL) < 0 || !message_id) return -1; + *is_poll = 0; + if (!inst->chat_core) return 1; + char sql[512]; + snprintf(sql, sizeof(sql), "SELECT CASE WHEN json_valid(CAST(data AS TEXT)) THEN json_extract(CAST(data AS TEXT),'$.ct') END," + "CASE WHEN json_valid(CAST(data AS TEXT)) THEN json_extract(CAST(data AS TEXT),'$.d') END," + "CASE WHEN json_valid(CAST(data AS TEXT)) THEN json_extract(CAST(data AS TEXT),'$.ch') END" + " FROM msg_%s WHERE message_id=?", channel); + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) != SQLITE_OK) return poll_db_error(inst, "target query"); + sqlite3_bind_blob(st, 1, message_id, 32, SQLITE_STATIC); + int step = sqlite3_step(st), result = 0; + if (step == SQLITE_DONE) result = 1; + else if (step != SQLITE_ROW) result = poll_db_error(inst, "target read"); + else { + const char* type = (const char*)sqlite3_column_text(st, 0); + if (type && !strcmp(type, "poll")) { + const char* body = (const char*)sqlite3_column_text(st, 1); + const char* ch = (const char*)sqlite3_column_text(st, 2); + int len = sqlite3_column_bytes(st, 1); + if (!body || !ch || strcmp(ch, channel) || len < 4 || len > 4 + 4 * ((CHAT_POLL_WIRE_MAX + 2) / 3) + || memcmp(body, "b64:", 4)) result = -1; + else { + uint8_t wire[CHAT_POLL_WIRE_MAX]; char canonical[4 * ((CHAT_POLL_WIRE_MAX + 2) / 3)]; + size_t bytes = b64_decode(body + 4, len - 4, wire, sizeof(wire)); + size_t encoded = b64_encode(wire, bytes, canonical, sizeof(canonical)); + if (encoded != (size_t)len - 4 || memcmp(canonical, body + 4, encoded) + || chat_poll_decode(wire, bytes, poll) < 0) result = -1; + else *is_poll = 1; + } + if (result < 0) DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "poll: invalid signed poll ch=%s", channel); + } + } + sqlite3_finalize(st); return result; +} + +/* Семантика ответа не влияет на сохранение подписанных байтов и Merkle-корень. */ +static int poll_answer_allowed(const struct chat_poll* poll, const struct chat_poll_answer* answer) { + if (!answer->count && !answer->custom[0]) return 1; + if (answer->custom[0] && !poll->allow_custom_answer) return 0; + if (poll->mode == CHAT_POLL_SINGLE && answer->count + (answer->custom[0] != 0) != 1) return 0; + for (unsigned i = 0; i < answer->count; i++) if (answer->choices[i] >= poll->option_count) return 0; + return 1; +} + +int chat_poll_create(struct UTUN_INSTANCE* inst, const char* channel, const struct chat_poll* poll, uint8_t out_id[32]) { + if (poll_channel(inst, channel, NULL) < 0 || !out_id) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: invalid create arguments"); return -1; + } + uint8_t wire[CHAT_POLL_WIRE_MAX]; char body[4 + 4 * ((CHAT_POLL_WIRE_MAX + 2) / 3) + 1]; + int bytes = chat_poll_encode(poll, wire, sizeof(wire)); if (bytes < 0) return -1; + memcpy(body, "b64:", 4); size_t len = 4 + b64_encode(wire, bytes, body + 4, sizeof(body) - 5); body[len] = 0; + struct chat_msg_submit req = { .inst = inst, .data = (uint8_t*)body, .data_len = len }; + snprintf(req.channel_id, sizeof(req.channel_id), "%s", channel); snprintf(req.content_type, sizeof(req.content_type), "poll"); + if (chat_core_submit_message(inst, &req) < 0) return -1; + memcpy(out_id, req.message_id, 32); + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "poll: published ch=%s mode=%u options=%u custom=%u bytes=%d", + channel, poll->mode, poll->option_count, poll->allow_custom_answer, bytes); return 0; +} + +/* Формат текста JSON и вывод выбора используются одинаково для mine и списка ответов. */ +static int poll_json_answer(struct strbuf* out, const struct chat_poll_answer* answer) { + if (strbuf_addf(out, "{\"choices\":[") < 0) return -1; + for (unsigned i = 0; i < answer->count; i++) + if (strbuf_addf(out, "%s%u", i ? "," : "", answer->choices[i]) < 0) return -1; + if (strbuf_addf(out, "],\"custom\":") < 0 || strbuf_add_json_string(out, answer->custom) < 0) return -1; + return strbuf_addf(out, "}") < 0 ? -1 : 0; +} + +/* Результаты производны от последних допустимых состояний каждого автора. + * Чужие неверные ответы сохраняются для сходимости, но не участвуют в подсчёте. */ +int chat_feedback_snapshot(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message_id[32], char** json) { + if (json) *json = NULL; + if (!json || poll_channel(inst, channel, NULL) < 0 || !message_id) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: invalid snapshot arguments"); return -1; + } + struct chat_poll poll; int is_poll, target = poll_target(inst, channel, message_id, &poll, &is_poll); + if (target < 0) return -1; + unsigned counts[CHAT_REACTION_COUNT] = {0}, votes[CHAT_POLL_OPTIONS_MAX] = {0}, voters = 0, custom_count = 0; + uint8_t mine[CHAT_REACTION_COUNT] = {0}; uint64_t reaction_ver = 0, answer_ver = 0; + struct chat_poll_answer own_answer = {0}; + struct strbuf replies = strbuf_new(), out = strbuf_new(); + sqlite3_stmt* st = NULL; int result = -1, step; + if (strbuf_addf(&replies, "[") < 0) goto done; + char sql[256]; + snprintf(sql, sizeof(sql), "SELECT node_id,kind,ver,data FROM feedback_%s WHERE message_id=? ORDER BY node_id,kind", channel); + if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) != SQLITE_OK) { poll_db_error(inst, "snapshot query"); goto done; } + sqlite3_bind_blob(st, 1, message_id, 32, SQLITE_STATIC); + while ((step = sqlite3_step(st)) == SQLITE_ROW) { + uint64_t author = (uint64_t)sqlite3_column_int64(st, 0), ver = (uint64_t)sqlite3_column_int64(st, 2); + const uint8_t* data = sqlite3_column_blob(st, 3); size_t len = sqlite3_column_bytes(st, 3); + int kind = sqlite3_column_int(st, 1); + if (kind == CHAT_FEEDBACK_REACTION) { + uint8_t ids[CHAT_REACTION_SELECTED_MAX]; size_t count; + if (chat_reactions_decode(data, len, ids, &count) < 0) goto done; + if (author == inst->node_id) reaction_ver = ver; + if (!target) for (size_t i = 0; i < count; i++) { counts[ids[i]]++; if (author == inst->node_id) mine[ids[i]] = 1; } + } else if (kind == CHAT_FEEDBACK_ANSWER) { + struct chat_poll_answer answer; + if (chat_answer_decode(data, len, &answer) < 0) goto done; + if (author == inst->node_id) answer_ver = ver; + if (target || !is_poll || !poll_answer_allowed(&poll, &answer)) continue; + if (author == inst->node_id) own_answer = answer; + if (!answer.count && !answer.custom[0]) continue; + for (unsigned i = 0; i < answer.count; i++) votes[answer.choices[i]]++; + if (answer.custom[0]) custom_count++; + if (strbuf_addf(&replies, "%s{\"node_id\":\"0x%016llx\",\"ver\":%llu,\"answer\":", + voters ? "," : "", (unsigned long long)author, (unsigned long long)ver) < 0 + || poll_json_answer(&replies, &answer) < 0 || strbuf_addf(&replies, "}") < 0) goto done; + voters++; + } + } + if (step != SQLITE_DONE) { poll_db_error(inst, "snapshot read"); goto done; } + char hex[65]; for (unsigned i = 0; i < 32; i++) snprintf(hex + i * 2, 3, "%02x", message_id[i]); + if (strbuf_addf(&replies, "]") < 0 || strbuf_addf(&out, + "{\"ch\":\"%s\",\"message_id\":\"%s\",\"pending\":%s,\"reaction_ver\":%llu,\"reactions\":[", + channel, hex, target ? "true" : "false", (unsigned long long)reaction_ver) < 0) goto done; + unsigned shown = 0; + for (unsigned i = 0; i < CHAT_REACTION_COUNT; i++) if (counts[i]) { + if (strbuf_addf(&out, "%s{\"id\":%u,\"emoji\":", shown++ ? "," : "", i) < 0 + || strbuf_add_json_string(&out, chat_reaction_emoji(i)) < 0 + || strbuf_addf(&out, ",\"count\":%u,\"mine\":%s}", counts[i], mine[i] ? "true" : "false") < 0) goto done; + } + if (strbuf_addf(&out, "],\"poll\":") < 0) goto done; + if (is_poll) { + if (strbuf_addf(&out, "{\"mode\":\"%s\",\"allow_custom_answer\":%s,\"question\":", + poll.mode == CHAT_POLL_SINGLE ? "single" : "multiple", poll.allow_custom_answer ? "true" : "false") < 0 + || strbuf_add_json_string(&out, poll.question) < 0 || strbuf_addf(&out, ",\"options\":[") < 0) goto done; + for (unsigned i = 0; i < poll.option_count; i++) { + if (strbuf_addf(&out, "%s{\"id\":%u,\"text\":", i ? "," : "", i) < 0 + || strbuf_add_json_string(&out, poll.options[i]) < 0 + || strbuf_addf(&out, ",\"count\":%u,\"percent\":%.2f}", votes[i], voters ? 100.0 * votes[i] / voters : 0.0) < 0) goto done; + } + if (strbuf_addf(&out, "],\"voters\":%u,\"custom_count\":%u,\"ver\":%llu,\"mine\":", + voters, custom_count, (unsigned long long)answer_ver) < 0 + || poll_json_answer(&out, &own_answer) < 0 + || strbuf_addf(&out, ",\"answers\":%s}", strbuf_str(&replies)) < 0) goto done; + } else if (strbuf_addf(&out, "null") < 0) goto done; + if (strbuf_addf(&out, "}") < 0) goto done; + *json = strbuf_detach(&out); result = 0; +done: + if (st) sqlite3_finalize(st); + strbuf_free(&replies); strbuf_free(&out); + if (result < 0) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: snapshot failed ch=%s", channel); + return result; +} + +/* Сохраняем диагностику семантики при новом состоянии/получении исходного сообщения. */ +void chat_feedback_check_message(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message_id[32]) { + char* snapshot = NULL; + if (chat_feedback_snapshot(inst, channel, message_id, &snapshot) == 0) { + DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "poll: evaluated ch=%s state=%s", channel, snapshot); u_free(snapshot); + } +} + +/* Читает поле JSON с его типом. Отсутствующее поле имеет type=NULL. */ +static sqlite3_stmt* poll_field(struct UTUN_INSTANCE* inst, const char* json, const char* path) { + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT json_type(?1,?2),json_extract(?1,?2)", -1, &st, NULL) != SQLITE_OK) { + poll_db_error(inst, "command field"); return NULL; + } + sqlite3_bind_text(st, 1, json, -1, SQLITE_STATIC); sqlite3_bind_text(st, 2, path, -1, SQLITE_STATIC); + if (sqlite3_step(st) == SQLITE_ROW) return st; + sqlite3_finalize(st); poll_db_error(inst, "command field read"); return NULL; +} + +/* Требуем полную строку, не разрешая тихое обрезание и встроенный NUL. */ +static int poll_field_text(struct UTUN_INSTANCE* inst, const char* json, const char* path, char* out, size_t capacity, int optional) { + sqlite3_stmt* st = poll_field(inst, json, path); if (!st) return -1; + const char* type = (const char*)sqlite3_column_text(st, 0), *text = (const char*)sqlite3_column_text(st, 1); + size_t bytes = sqlite3_column_bytes(st, 1); int result = -1; + if (!type && optional) { out[0] = 0; result = 0; } + else if (type && !strcmp(type, "text") && text && bytes < capacity && strlen(text) == bytes) { + memcpy(out, text, bytes + 1); result = 0; + } + sqlite3_finalize(st); + if (result < 0) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: invalid command field=%s", path); + return result; +} + +/* Выбор пунктов/эмодзи: только массив целых номеров, без преобразования строк/дробей. */ +static int poll_field_ids(struct UTUN_INSTANCE* inst, const char* json, const char* path, uint8_t* ids, size_t capacity) { + sqlite3_stmt* field = poll_field(inst, json, path); if (!field) return -1; + const char* type = (const char*)sqlite3_column_text(field, 0); int absent = !type; + int array = type && !strcmp(type, "array"); sqlite3_finalize(field); + if (absent) return 0; + if (!array) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: expected array field=%s", path); return -1; } + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT type,value FROM json_each(?1,?2)", -1, &st, NULL) != SQLITE_OK) { + poll_db_error(inst, "command array"); return -1; + } + sqlite3_bind_text(st, 1, json, -1, SQLITE_STATIC); sqlite3_bind_text(st, 2, path, -1, SQLITE_STATIC); + int step, count = 0; + while ((step = sqlite3_step(st)) == SQLITE_ROW) { + const char* element = (const char*)sqlite3_column_text(st, 0); sqlite3_int64 n = sqlite3_column_int64(st, 1); + if (count == (int)capacity || !element || strcmp(element, "integer") || n < 0 || n > 255) { + count = -1; DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: invalid array element field=%s", path); break; + } + ids[count++] = (uint8_t)n; + } + sqlite3_finalize(st); + if (count >= 0 && step != SQLITE_DONE) { poll_db_error(inst, "command array read"); return -1; } + return count; +} + +static int poll_field_bool(struct UTUN_INSTANCE* inst, const char* json, const char* path, int default_value) { + sqlite3_stmt* st = poll_field(inst, json, path); if (!st) return -2; + const char* type = (const char*)sqlite3_column_text(st, 0); + int value = !type ? default_value : !strcmp(type, "true") ? 1 : !strcmp(type, "false") ? 0 : -2; + sqlite3_finalize(st); + if (value == -2) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: expected boolean field=%s", path); + return value; +} + +/* Ошибка доступна интерфейсу, детали оставляем в логе ядра. */ +static int poll_command_error(const char* reason, char** result) { + struct strbuf out = strbuf_new(); + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: command rejected reason=%s", reason); + if (strbuf_addf(&out, "{\"error\":") >= 0 && strbuf_add_json_string(&out, reason) == 0 && strbuf_addf(&out, "}") >= 0) + *result = strbuf_detach(&out); + strbuf_free(&out); return -1; +} + +/* Общий командный API исключает отдельные правила голосования в каждом интерфейсе. */ +int chat_feedback_command(struct UTUN_INSTANCE* inst, const char* command, char** result) { + if (result) *result = NULL; + if (!inst || !inst->topo_sqlite_db || !command || !result) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: invalid command arguments"); return -1; + } + sqlite3_stmt* valid = NULL; + if (strlen(command) > 16384 || sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT json_valid(?1)", -1, &valid, NULL) != SQLITE_OK) + return poll_command_error("Invalid command size", result); + sqlite3_bind_text(valid, 1, command, -1, SQLITE_STATIC); + int ok = sqlite3_step(valid) == SQLITE_ROW && sqlite3_column_int(valid, 0) == 1; sqlite3_finalize(valid); + if (!ok) return poll_command_error("Malformed JSON", result); + char cmd[32], channel[21], hex[65]; uint8_t message_id[32]; + if (poll_field_text(inst, command, "$.cmd", cmd, sizeof(cmd), 0) < 0) return poll_command_error("Missing command", result); + if (!strcmp(cmd, "reaction_catalog")) { + struct strbuf out = strbuf_new(); int failed = strbuf_addf(&out, "{\"catalog\":[") < 0; + for (unsigned i = 0; i < CHAT_REACTION_COUNT && !failed; i++) { + failed = strbuf_addf(&out, "%s{\"id\":%u,\"emoji\":", i ? "," : "", i) < 0 + || strbuf_add_json_string(&out, chat_reaction_emoji(i)) < 0 || strbuf_addf(&out, "}") < 0; + } + if (!failed && strbuf_addf(&out, "]}") >= 0) *result = strbuf_detach(&out); + strbuf_free(&out); return *result ? 0 : poll_command_error("Out of memory", result); + } + if (poll_field_text(inst, command, "$.ch", channel, sizeof(channel), 0) < 0 || poll_channel(inst, channel, NULL) < 0) + return poll_command_error("Invalid channel", result); + if (!strcmp(cmd, "poll_create")) { + struct chat_poll poll = {0}; char mode[16]; + if (poll_field_text(inst, command, "$.question", poll.question, sizeof(poll.question), 0) < 0 + || poll_field_text(inst, command, "$.mode", mode, sizeof(mode), 0) < 0) return poll_command_error("Invalid poll text or mode", result); + poll.mode = !strcmp(mode, "single") ? CHAT_POLL_SINGLE : !strcmp(mode, "multiple") ? CHAT_POLL_MULTIPLE : 0; + int custom = poll_field_bool(inst, command, "$.allow_custom_answer", 0); + if (custom < 0) return poll_command_error("Invalid custom answer option", result); + poll.allow_custom_answer = custom; + sqlite3_stmt* field = poll_field(inst, command, "$.options"); if (!field) return poll_command_error("Invalid options", result); + const char* type = (const char*)sqlite3_column_text(field, 0); ok = type && !strcmp(type, "array"); sqlite3_finalize(field); + if (!ok) return poll_command_error("Options must be an array", result); + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT type,value FROM json_each(?1,'$.options')", -1, &st, NULL) != SQLITE_OK) { + poll_db_error(inst, "options"); return poll_command_error("Cannot read options", result); + } + sqlite3_bind_text(st, 1, command, -1, SQLITE_STATIC); + int step; + while ((step = sqlite3_step(st)) == SQLITE_ROW) { + const char* value_type = (const char*)sqlite3_column_text(st, 0), *text = (const char*)sqlite3_column_text(st, 1); + int len = sqlite3_column_bytes(st, 1); + if (poll.option_count == CHAT_POLL_OPTIONS_MAX || !value_type || strcmp(value_type, "text") || !text + || len <= 0 || len > CHAT_POLL_OPTION_MAX || strlen(text) != (size_t)len) { ok = 0; break; } + memcpy(poll.options[poll.option_count++], text, len + 1); + } + sqlite3_finalize(st); + if (!ok || step != SQLITE_DONE || chat_poll_create(inst, channel, &poll, message_id) < 0) + return poll_command_error("Invalid poll or publication failed", result); + return chat_feedback_snapshot(inst, channel, message_id, result); + } + if (poll_field_text(inst, command, "$.message_id", hex, sizeof(hex), 0) < 0 || strlen(hex) != 64 + || sc_hex_to_binary(hex, message_id, 32) != 0) return poll_command_error("Invalid message ID", result); + if (!strcmp(cmd, "feedback")) return chat_feedback_snapshot(inst, channel, message_id, result); + struct chat_poll poll; int is_poll, target = poll_target(inst, channel, message_id, &poll, &is_poll); + if (target != 0) return poll_command_error("Message is not available", result); + uint8_t data[CHAT_FEEDBACK_DATA_MAX]; int bytes, kind; + if (!strcmp(cmd, "vote")) { + if (!is_poll) return poll_command_error("Message is not a poll", result); + struct chat_poll_answer answer = {0}; + int n = poll_field_ids(inst, command, "$.choices", answer.choices, sizeof(answer.choices)); + if (n < 0 || poll_field_text(inst, command, "$.custom", answer.custom, sizeof(answer.custom), 1) < 0) + return poll_command_error("Invalid answer", result); + answer.count = n; bytes = chat_answer_encode(&answer, data, sizeof(data)); kind = CHAT_FEEDBACK_ANSWER; + if (!poll_answer_allowed(&poll, &answer)) return poll_command_error("Answer violates poll rules", result); + } else if (!strcmp(cmd, "react")) { + uint8_t ids[CHAT_REACTION_SELECTED_MAX + 1]; size_t count = 0; + int clear = poll_field_bool(inst, command, "$.clear", 0); if (clear < 0) return poll_command_error("Invalid clear flag", result); + if (!clear) { + struct chat_feedback_record old; + int found = chat_feedback_get(inst, channel, message_id, inst->node_id, CHAT_FEEDBACK_REACTION, &old); + if (found < 0 || (found && chat_reactions_decode(old.data, old.data_len, ids, &count) < 0)) + return poll_command_error("Cannot read reactions", result); + sqlite3_stmt* st = poll_field(inst, command, "$.emoji"); if (!st) return poll_command_error("Invalid emoji", result); + const char* type = (const char*)sqlite3_column_text(st, 0); sqlite3_int64 emoji = sqlite3_column_int64(st, 1); + ok = type && !strcmp(type, "integer") && emoji >= 0 && emoji < CHAT_REACTION_COUNT; sqlite3_finalize(st); + if (!ok) return poll_command_error("Invalid emoji", result); + size_t at = 0; while (at < count && ids[at] != emoji) at++; + int on = poll_field_bool(inst, command, "$.on", at == count); + if (on < 0) return poll_command_error("Invalid reaction state", result); + if (on && at == count) { + if (count == CHAT_REACTION_SELECTED_MAX) return poll_command_error("Too many reactions", result); + ids[count++] = emoji; + } else if (!on && at < count) { memmove(ids + at, ids + at + 1, count - at - 1); count--; } + } + bytes = chat_reactions_encode(ids, count, data, sizeof(data)); kind = CHAT_FEEDBACK_REACTION; + } else return poll_command_error("Unknown interaction command", result); + if (bytes < 0 || chat_feedback_set(inst, channel, message_id, kind, data, bytes, NULL) < 0) + return poll_command_error("Cannot save state", result); + return chat_feedback_snapshot(inst, channel, message_id, result); +} + +/* Ответ UI асинхронен: успех/ошибка и исходный request_id всегда идут вместе. */ +void chat_feedback_command_trampoline(void* arg) { + struct chat_feedback_request* req = arg; + if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: missing command request"); return; } + char* json = NULL; int rc = chat_feedback_command(req->inst, (const char*)(req + 1), &json); + if (!json) json = u_strdup("{\"error\":\"Interaction failed\"}"); + size_t bytes = json ? strlen(json) : 0; uint8_t* event = u_malloc(bytes + 9); + if (event) { + memcpy(event, &req->request_id, 8); event[8] = rc == 0; + if (bytes) memcpy(event + 9, json, bytes); + chat_event_post(req->inst, CHAT_EVT_FEEDBACK_RESULT, event, bytes + 9); u_free(event); + } else DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: command result allocation failed"); + u_free(json); u_free(req); +} diff --git a/src/chat/chat_poll.h b/src/chat/chat_poll.h index fad014a8..15aab678 100644 --- a/src/chat/chat_poll.h +++ b/src/chat/chat_poll.h @@ -57,6 +57,8 @@ int chat_feedback_snapshot(struct UTUN_INSTANCE* inst, const char* channel, cons * vote: choices:[0..31], custom:string; пустые значения отменяют голос. * react: emoji:0..15, on:boolean необязателен (без него переключить), clear:true снимает все. */ int chat_feedback_command(struct UTUN_INSTANCE* inst, const char* command, char** result); +/* Проверка отложенных ответов и диагностика при получении сообщения/нового состояния. */ +void chat_feedback_check_message(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message_id[32]); /* Команда из UI: req и следующая за ней NUL-строка выделены одним блоком через u_malloc. * Результат возвращается событием CHAT_EVT_FEEDBACK_RESULT и включает request_id. */ struct chat_feedback_request { struct UTUN_INSTANCE* inst; uint64_t request_id; };