Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/host/core/command_messages.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 <message-id|r#reply-id>",
{
[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 <id сообщения|r#id ответа>",
[SESSION_UI_LANGUAGE_DE] = "Verwendung: /thread <Nachrichten-ID|r#Antwort-ID>",
[SESSION_UI_LANGUAGE_FR] = "Utilisation : /thread <id-message|r#id-réponse>",
[SESSION_UI_LANGUAGE_PL] = "Użycie: /thread <id-wiadomości|r#id-odpowiedzi>",
},
},
{
"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
Expand Down
23 changes: 23 additions & 0 deletions src/host/core/help_entries.c
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,29 @@ static const session_help_entry_t kSessionHelpEssential[] = {
"ответ <id сообщения|r<id ответа>> <текст>",
},
},
{
.kind = SESSION_HELP_ENTRY_COMMAND,
.label = "thread <message-id|r#reply-id>",
.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] =
"тред <id сообщения|r#id ответа>",
},
},
{
.kind = SESSION_HELP_ENTRY_COMMAND,
.label = "pm <username>|<message>",
Expand Down
10 changes: 10 additions & 0 deletions src/host/core/locales.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
94 changes: 94 additions & 0 deletions src/host/output/moderation.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 <message-id|r#reply-id>";
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);
}
5 changes: 5 additions & 0 deletions src/host/runtime/dispatch.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
169 changes: 169 additions & 0 deletions tests/unit/test_ddial_hub.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 <message-id|r#reply-id>") != 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);
Expand Down Expand Up @@ -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);
Expand Down
Loading