From 9f2bdccbc932b21b3899c60763dd5537fd922026 Mon Sep 17 00:00:00 2001 From: Lee Yunjin Date: Wed, 28 Oct 2026 22:29:33 +0900 Subject: [PATCH] feat(chat): add thread lookup command --- src/host/core/command_messages.c | 48 +++++++++ src/host/core/help_entries.c | 23 +++++ src/host/core/locales.c | 10 ++ src/host/output/moderation.c | 94 +++++++++++++++++ src/host/runtime/dispatch.c | 5 + tests/unit/test_ddial_hub.c | 169 +++++++++++++++++++++++++++++++ 6 files changed, 349 insertions(+) diff --git a/src/host/core/command_messages.c b/src/host/core/command_messages.c index 34731ca..1da0a69 100644 --- a/src/host/core/command_messages.c +++ b/src/host/core/command_messages.c @@ -1575,6 +1575,54 @@ static const session_command_message_t kSessionCommandMessages[] = { [SESSION_UI_LANGUAGE_PL] = "%s jest teraz zajęty; spróbuj za chwilę.", }, }, + { + "Usage: /thread ", + { + [SESSION_UI_LANGUAGE_KO] = "사용법: /thread <메시지ID|r#답장ID>", + [SESSION_UI_LANGUAGE_JP] = "使い方: /thread <メッセージID|r#返信ID>", + [SESSION_UI_LANGUAGE_ZH] = "用法:/thread <消息ID|r#回复ID>", + [SESSION_UI_LANGUAGE_RU] = "Использование: /thread ", + [SESSION_UI_LANGUAGE_DE] = "Verwendung: /thread ", + [SESSION_UI_LANGUAGE_FR] = "Utilisation : /thread ", + [SESSION_UI_LANGUAGE_PL] = "Użycie: /thread ", + }, + }, + { + "Reply r#%s was not found.", + { + [SESSION_UI_LANGUAGE_KO] = "답장 r#%s을(를) 찾을 수 없습니다.", + [SESSION_UI_LANGUAGE_JP] = "返信 r#%s が見つかりません。", + [SESSION_UI_LANGUAGE_ZH] = "未找到回复 r#%s。", + [SESSION_UI_LANGUAGE_RU] = "Ответ r#%s не найден.", + [SESSION_UI_LANGUAGE_DE] = "Antwort r#%s wurde nicht gefunden.", + [SESSION_UI_LANGUAGE_FR] = "Réponse r#%s introuvable.", + [SESSION_UI_LANGUAGE_PL] = "Nie znaleziono odpowiedzi r#%s.", + }, + }, + { + "Message #%s was not found.", + { + [SESSION_UI_LANGUAGE_KO] = "메시지 #%s을(를) 찾을 수 없습니다.", + [SESSION_UI_LANGUAGE_JP] = "メッセージ #%s が見つかりません。", + [SESSION_UI_LANGUAGE_ZH] = "未找到消息 #%s。", + [SESSION_UI_LANGUAGE_RU] = "Сообщение #%s не найдено.", + [SESSION_UI_LANGUAGE_DE] = "Nachricht #%s wurde nicht gefunden.", + [SESSION_UI_LANGUAGE_FR] = "Message #%s introuvable.", + [SESSION_UI_LANGUAGE_PL] = "Nie znaleziono wiadomości #%s.", + }, + }, + { + "Thread for #%s:", + { + [SESSION_UI_LANGUAGE_KO] = "메시지 #%s의 스레드:", + [SESSION_UI_LANGUAGE_JP] = "メッセージ #%s のスレッド:", + [SESSION_UI_LANGUAGE_ZH] = "消息 #%s 的线程:", + [SESSION_UI_LANGUAGE_RU] = "Тред сообщения #%s:", + [SESSION_UI_LANGUAGE_DE] = "Thread zu Nachricht #%s:", + [SESSION_UI_LANGUAGE_FR] = "Fil du message #%s :", + [SESSION_UI_LANGUAGE_PL] = "Wątek wiadomości #%s:", + }, + }, }; // Returns the message in the session's UI language, or the English text when diff --git a/src/host/core/help_entries.c b/src/host/core/help_entries.c index 7599c1f..a3c2560 100644 --- a/src/host/core/help_entries.c +++ b/src/host/core/help_entries.c @@ -615,6 +615,29 @@ static const session_help_entry_t kSessionHelpEssential[] = { "ответ > <текст>", }, }, + { + .kind = SESSION_HELP_ENTRY_COMMAND, + .label = "thread ", + .description = + { + "Show a message and its complete reply thread.", + "메시지와 전체 답장 스레드를 보여줍니다.", + "メッセージと返信スレッド全体を表示します。", + "显示消息及其完整回复线程。", + "Показать сообщение и всю цепочку ответов.", + "Eine Nachricht und ihren vollständigen Antwort-Thread anzeigen.", + "Afficher un message et tout son fil de réponses.", + "Pokaż wiadomość i cały wątek odpowiedzi.", + }, + .label_translations = + { + [SESSION_UI_LANGUAGE_KO] = "스레드 <메시지ID|r#답장ID>", + [SESSION_UI_LANGUAGE_JP] = "スレッド <メッセージID|r#返信ID>", + [SESSION_UI_LANGUAGE_ZH] = "线程 <消息ID|r#回复ID>", + [SESSION_UI_LANGUAGE_RU] = + "тред ", + }, + }, { .kind = SESSION_HELP_ENTRY_COMMAND, .label = "pm |", diff --git a/src/host/core/locales.c b/src/host/core/locales.c index 0011f23..47a6636 100644 --- a/src/host/core/locales.c +++ b/src/host/core/locales.c @@ -653,6 +653,16 @@ static const session_command_alias_t kSessionCommandAliases[] = { [SESSION_UI_LANGUAGE_RU] = "/ответ", }, }, + { + .canonical = "/thread", + .localized = + { + [SESSION_UI_LANGUAGE_KO] = "/스레드", + [SESSION_UI_LANGUAGE_JP] = "/スレッド", + [SESSION_UI_LANGUAGE_ZH] = "/线程", + [SESSION_UI_LANGUAGE_RU] = "/тред", + }, + }, { .canonical = "/nick", .localized = diff --git a/src/host/output/moderation.c b/src/host/output/moderation.c index 24e8a13..5d4e5c2 100644 --- a/src/host/output/moderation.c +++ b/src/host/output/moderation.c @@ -1333,3 +1333,97 @@ static void session_handle_chat_lookup(session_ctx_t *ctx, session_send_history_entry(ctx, &entry); session_send_reply_tree(ctx, entry.message_id, 0U, 1U); } + +static void session_handle_thread(session_ctx_t *ctx, const char *arguments) +{ + if (ctx == nullptr || ctx->owner == nullptr) { + return; + } + + static const char *kUsage = "Usage: /thread "; + char usage[SSH_CHATTER_MESSAGE_LIMIT]; + session_command_format_usage(ctx, "/thread", + session_command_localize(ctx, kUsage), usage, + sizeof(usage)); + + if (arguments == nullptr) { + session_send_system_line(ctx, usage); + return; + } + + char working[64]; + snprintf(working, sizeof(working), "%s", arguments); + trim_whitespace_inplace(working); + if (working[0] == '\0') { + session_send_system_line(ctx, usage); + return; + } + + char *saveptr = nullptr; + char *target = strtok_r(working, " \t", &saveptr); + if (target == nullptr || strtok_r(nullptr, " \t", &saveptr) != nullptr) { + session_send_system_line(ctx, usage); + return; + } + + bool targeting_reply = false; + if (*target == 'r' || *target == 'R') { + targeting_reply = true; + ++target; + } + if (*target == '#') { + ++target; + } + if (*target == '\0') { + session_send_system_line(ctx, usage); + return; + } + + uint64_t identifier = 0U; + if (!host_compact_id_decode(target, &identifier) || identifier == 0U) { + session_send_system_line(ctx, usage); + return; + } + + uint64_t message_id = identifier; + if (targeting_reply) { + chat_reply_entry_t reply = {0}; + if (!host_replies_find_entry_by_id(ctx->owner, identifier, &reply)) { + char message[SSH_CHATTER_MESSAGE_LIMIT]; + char label[32]; + if (!host_compact_id_encode(identifier, label, sizeof(label))) { + snprintf(label, sizeof(label), "%" PRIu64, identifier); + } + session_command_snprintf(ctx, message, sizeof(message), + "Reply r#%s was not found.", label); + session_send_system_line(ctx, message); + return; + } + message_id = reply.parent_message_id; + } + + chat_history_entry_t entry = {0}; + if (message_id == 0U || + !host_history_find_entry_by_id(ctx->owner, message_id, &entry)) { + char message[SSH_CHATTER_MESSAGE_LIMIT]; + char label[32]; + if (!host_compact_id_encode(message_id, label, sizeof(label))) { + snprintf(label, sizeof(label), "%" PRIu64, message_id); + } + session_command_snprintf(ctx, message, sizeof(message), + "Message #%s was not found.", label); + session_send_system_line(ctx, message); + return; + } + + char header[SSH_CHATTER_MESSAGE_LIMIT]; + char label[32]; + if (!host_compact_id_encode(message_id, label, sizeof(label))) { + snprintf(label, sizeof(label), "%" PRIu64, message_id); + } + session_command_snprintf(ctx, header, sizeof(header), "Thread for #%s:", + label); + session_send_system_line(ctx, header); + session_send_history_entry(ctx, &entry); + session_send_reply_tree(ctx, entry.message_id, 0U, 1U); +} diff --git a/src/host/runtime/dispatch.c b/src/host/runtime/dispatch.c index f385ba4..7cb058f 100644 --- a/src/host/runtime/dispatch.c +++ b/src/host/runtime/dispatch.c @@ -281,6 +281,11 @@ static void session_dispatch_command(session_ctx_t *ctx, const char *line) return; } + else if (session_parse_command_any(ctx, "/thread", effective_line, &args)) { + session_handle_thread(ctx, args); + return; + } + else if (session_parse_command_any(ctx, "/image", effective_line, &args)) { session_handle_image(ctx, args); return; diff --git a/tests/unit/test_ddial_hub.c b/tests/unit/test_ddial_hub.c index dce3435..3483672 100644 --- a/tests/unit/test_ddial_hub.c +++ b/tests/unit/test_ddial_hub.c @@ -278,6 +278,172 @@ TEST(rebuild_is_idempotent) CHECK_EQ(reply_count_for(parent.message_id), 1); } +static bool command_output(const char *command, char *output, size_t output_cap) +{ + if (command == NULL || output == NULL || output_cap == 0U) { + return false; + } + output[0] = '\0'; + + int pipefd[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, pipefd) != 0) { + return false; + } + + session_ctx_t ctx = {0}; + ctx.transport_kind = SESSION_TRANSPORT_TELNET; + ctx.telnet_fd = pipefd[1]; + ctx.owner = g_host; + ctx.terminal_width = 80; + ctx.terminal_height = 24; + session_dispatch_command(&ctx, command); + close(pipefd[1]); + + size_t used = 0U; + while (used + 1U < output_cap) { + ssize_t n = read(pipefd[0], output + used, output_cap - used - 1U); + if (n > 0) { + used += (size_t)n; + continue; + } + if (n < 0 && errno == EINTR) { + continue; + } + break; + } + close(pipefd[0]); + output[used] = '\0'; + return true; +} + +TEST(thread_command_shows_mixed_nested_tree_from_message_or_reply) +{ + chat_history_entry_t root = + commit_user("ThreadRoot", "thread root marker"); + chat_history_entry_t ddial = commit_system( + "71#8[T1:Dialer) ddial child marker^ >>RE:ThreadRoot: thread root " + "marker"); + uint64_t ddial_reply_id = 0U; + CHECK(host_ddial_reply_link_get(ddial.message_id, &ddial_reply_id, NULL, + 0U)); + + chat_reply_entry_t nested = {0}; + nested.parent_message_id = root.message_id; + nested.parent_reply_id = ddial_reply_id; + snprintf(nested.username, sizeof(nested.username), "%s", "NestedUser"); + snprintf(nested.message, sizeof(nested.message), "%s", + "nested child marker"); + chat_reply_entry_t nested_stored = {0}; + CHECK(host_replies_commit_entry(g_host, &nested, &nested_stored)); + + chat_reply_entry_t direct = {0}; + direct.parent_message_id = root.message_id; + snprintf(direct.username, sizeof(direct.username), "%s", "DirectUser"); + snprintf(direct.message, sizeof(direct.message), "%s", + "direct child marker"); + chat_reply_entry_t direct_stored = {0}; + CHECK(host_replies_commit_entry(g_host, &direct, &direct_stored)); + + char root_label[32]; + char ddial_label[32]; + char nested_label[32]; + char direct_label[32]; + CHECK(host_compact_id_encode(root.message_id, root_label, + sizeof(root_label))); + CHECK(host_compact_id_encode(ddial_reply_id, ddial_label, + sizeof(ddial_label))); + CHECK(host_compact_id_encode(nested_stored.reply_id, nested_label, + sizeof(nested_label))); + CHECK(host_compact_id_encode(direct_stored.reply_id, direct_label, + sizeof(direct_label))); + + char command[128]; + snprintf(command, sizeof(command), "/thread %s", root_label); + char output[32768]; + CHECK(command_output(command, output, sizeof(output))); + + char expected[256]; + snprintf(expected, sizeof(expected), "Thread for #%s:", root_label); + CHECK(strstr(output, expected) != NULL); + CHECK(strstr(output, "thread root marker") != NULL); + snprintf(expected, sizeof(expected), " (r#%s) ddial child marker", + ddial_label); + char *ddial_pos = strstr(output, expected); + CHECK(ddial_pos != NULL); + snprintf(expected, sizeof(expected), " (r#%s) nested child marker", + nested_label); + char *nested_pos = strstr(output, expected); + CHECK(nested_pos != NULL); + snprintf(expected, sizeof(expected), " (r#%s) direct child marker", + direct_label); + char *direct_pos = strstr(output, expected); + CHECK(direct_pos != NULL); + CHECK(ddial_pos < nested_pos && nested_pos < direct_pos); + + snprintf(command, sizeof(command), "/thread r#%s", nested_label); + CHECK(command_output(command, output, sizeof(output))); + snprintf(expected, sizeof(expected), "Thread for #%s:", root_label); + CHECK(strstr(output, expected) != NULL); + CHECK(strstr(output, "thread root marker") != NULL); + CHECK(strstr(output, "ddial child marker") != NULL); + CHECK(strstr(output, "nested child marker") != NULL); + CHECK(strstr(output, "direct child marker") != NULL); +} + +TEST(thread_command_accepts_ddial_root_id) +{ + chat_history_entry_t root = + commit_system("71#9[T1:DialRoot) ddial root marker"); + chat_history_entry_t reply = commit_system( + "71#8[T1:Dialer) ddial root reply marker^ >>RE:[9] DialRoot: ddial " + "root marker"); + uint64_t reply_id = 0U; + CHECK(host_ddial_reply_link_get(reply.message_id, &reply_id, NULL, 0U)); + + char root_label[32]; + char reply_label[32]; + CHECK(host_compact_id_encode(root.message_id, root_label, + sizeof(root_label))); + CHECK(host_compact_id_encode(reply_id, reply_label, sizeof(reply_label))); + char command[128]; + snprintf(command, sizeof(command), "/thread d%s", root_label); + char output[16384]; + CHECK(command_output(command, output, sizeof(output))); + CHECK(strstr(output, "ddial root marker") != NULL); + char expected[256]; + snprintf(expected, sizeof(expected), " (r#%s) ddial root reply marker", + reply_label); + CHECK(strstr(output, expected) != NULL); +} + +TEST(thread_command_rejects_bad_targets_without_mutation) +{ + size_t history_before = host_history_total(g_host); + ttak_mutex_lock(&g_host->lock); + size_t replies_before = g_host->reply_count; + ttak_mutex_unlock(&g_host->lock); + + const char *usage_commands[] = { + "/thread", "/thread nonsense", "/thread 1 extra", "/thread r#", + }; + char output[16384]; + for (size_t idx = 0U; + idx < sizeof(usage_commands) / sizeof(usage_commands[0]); ++idx) { + CHECK(command_output(usage_commands[idx], output, sizeof(output))); + CHECK(strstr(output, "Usage: /thread ") != NULL); + } + + CHECK(command_output("/thread 9999", output, sizeof(output))); + CHECK(strstr(output, "Message #9999 was not found.") != NULL); + CHECK(command_output("/thread r#9999", output, sizeof(output))); + CHECK(strstr(output, "Reply r#9999 was not found.") != NULL); + CHECK_EQ(host_history_total(g_host), history_before); + ttak_mutex_lock(&g_host->lock); + size_t replies_after = g_host->reply_count; + ttak_mutex_unlock(&g_host->lock); + CHECK_EQ(replies_after, replies_before); +} + TEST(full_reply_table_drops_the_oldest) { ttak_mutex_lock(&g_host->lock); @@ -698,6 +864,9 @@ int main(void) RUN_TEST(ddial_reply_to_chatter_message_and_chatter_reply); RUN_TEST(unmatched_ddial_reply_stays_a_plain_line); RUN_TEST(rebuild_is_idempotent); + RUN_TEST(thread_command_shows_mixed_nested_tree_from_message_or_reply); + RUN_TEST(thread_command_accepts_ddial_root_id); + RUN_TEST(thread_command_rejects_bad_targets_without_mutation); RUN_TEST(full_reply_table_drops_the_oldest); RUN_TEST(station_key_create_match_revoke); RUN_TEST(failed_logins_block_an_address);