From 1f8fb7acf4a6b7b68ee6b2091112bebe2efaa13d Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Fri, 29 May 2026 17:40:09 +0800 Subject: [PATCH] Add private message reply command --- README.md | 6 +- docs/EASY_SETUP.md | 1 + docs/INTERFACE.md | 6 +- docs/QUICKREF.md | 2 + docs/USER_LIFECYCLE.md | 17 ++++-- include/command_catalog.h | 1 + include/i18n.h | 1 + include/ssh_server.h | 1 + src/command_catalog.c | 12 ++++ src/commands.c | 97 +++++++++++++++++++++---------- src/i18n_text.c | 4 ++ tests/test_user_lifecycle.sh | 12 ++++ tests/unit/test_command_catalog.c | 19 ++++++ tests/unit/test_i18n.c | 4 ++ tnt.1 | 8 ++- 15 files changed, 148 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 8137019..c140efa 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,8 @@ Ctrl+C - Exit chat :nick - Change nickname :msg - Send private message :w - Short alias for :msg +:reply - Reply to latest private message +:r - Short alias for :reply :inbox - Show private messages :last [N] - Show last N messages from history (max 50, default 10) :search - Search message history (shows last 15 matches) @@ -111,8 +113,8 @@ ESC - Return to NORMAL mode Command output pages use `j/k`, `Ctrl+D/U`, and `g/G` for paging. `:inbox` shows incoming and sent private messages newest-first; press `r` to refresh it manually, and it refreshes when a new private message arrives while the inbox -is open. Private messages are per-session only and are not written to -`messages.log`. +is open. `:reply text` and `:r text` send to the latest private-message peer. +Private messages are per-session only and are not written to `messages.log`. **Special messages (INSERT mode)** ``` diff --git a/docs/EASY_SETUP.md b/docs/EASY_SETUP.md index 9af0b91..12a5482 100644 --- a/docs/EASY_SETUP.md +++ b/docs/EASY_SETUP.md @@ -80,6 +80,7 @@ Common commands: :users online users :nick change nickname :msg send private message +:reply reply to latest private message :inbox show private messages :last [N] recent messages :search search message history diff --git a/docs/INTERFACE.md b/docs/INTERFACE.md index dd0acc9..3381322 100644 --- a/docs/INTERFACE.md +++ b/docs/INTERFACE.md @@ -160,8 +160,10 @@ should configure `TNT_ACCESS_TOKEN` before relying on exec-post identity. ## Interactive Private Messages `:msg user message` and its `:w` alias deliver private messages only to online -interactive clients. They are not persisted to `messages.log` and are not -included in exec `tail`, exec `dump`, `:last`, or `:search`. +interactive clients. `:reply message` and its `:r` alias send to the latest +private-message peer in the current session. Private messages are not +persisted to `messages.log` and are not included in exec `tail`, exec `dump`, +`:last`, or `:search`. Each participant keeps a bounded in-memory `:inbox` for the current session. Recipients see incoming private messages; senders see local sent-message diff --git a/docs/QUICKREF.md b/docs/QUICKREF.md index 9901bfc..a84b017 100644 --- a/docs/QUICKREF.md +++ b/docs/QUICKREF.md @@ -30,6 +30,8 @@ COMMANDS (COMMAND mode, prefix with :) nick change nickname msg send private message w alias for msg + reply reply to latest private message + r alias for reply inbox show private messages, newest first last [N] last N messages from log (default 10, max 50) search search full history (case-insensitive, 15 results) diff --git a/docs/USER_LIFECYCLE.md b/docs/USER_LIFECYCLE.md index 5881337..03f6a64 100644 --- a/docs/USER_LIFECYCLE.md +++ b/docs/USER_LIFECYCLE.md @@ -12,8 +12,8 @@ The product path should stay short: 5. User presses Esc to browse history with Vim-style movement. 6. User uses `:help` for the concise manual or `?` for the full key reference. 7. User searches from NORMAL with `/term`, or uses commands when needed: - `:users`, `:msg`, `:inbox`, `:last`, `:search`, `:nick`, `:mute-joins`, - and `:q`. + `:users`, `:msg`, `:reply`, `:inbox`, `:last`, `:search`, `:nick`, + `:mute-joins`, and `:q`. 8. Scripts and operators use `tntctl` or SSH exec commands for `health`, `stats`, `users`, `tail`, `dump`, and `post`. @@ -39,6 +39,9 @@ The product path should stay short: - `:inbox` is live enough for normal chat use: it can be refreshed with `r` and refreshes automatically when a new private message arrives while the inbox is open. +- `:reply` / `:r` keeps the private-message path keyboard-short: it answers + the latest private-message peer in the current session without retyping a + username. - Long command output uses a small pager so `:last` and `:search` are readable on small terminals. @@ -49,10 +52,12 @@ The product path should stay short: - second user joins and is visible through `users --json` - first user opens `?`, checks `:users`, sends a public message, scrolls, uses `:last` and `:search` -- first user toggles `:mute-joins`, sends two `:msg` messages, confirms sent - copies in `:inbox`, changes nickname, sends `/me`, and exits -- second user opens `:inbox` before the private messages arrive and sees it - auto-refresh after delivery, newest first +- first user toggles `:mute-joins`, sends two `:msg` messages, receives a + `:reply`, confirms private-message copies in `:inbox`, changes nickname, + sends `/me`, and exits +- second user opens `:inbox` before the private messages arrive, sees it + auto-refresh after delivery, newest first, and replies without retyping the + sender's username - exec `tail` sees public messages - `messages.log` contains public history and excludes private-message content diff --git a/include/command_catalog.h b/include/command_catalog.h index c285927..09dcc06 100644 --- a/include/command_catalog.h +++ b/include/command_catalog.h @@ -8,6 +8,7 @@ typedef enum { TNT_COMMAND_HELP, TNT_COMMAND_LANG, TNT_COMMAND_MSG, + TNT_COMMAND_REPLY, TNT_COMMAND_INBOX, TNT_COMMAND_NICK, TNT_COMMAND_LAST, diff --git a/include/i18n.h b/include/i18n.h index e24593e..7fc2953 100644 --- a/include/i18n.h +++ b/include/i18n.h @@ -45,6 +45,7 @@ typedef enum { I18N_USERS_TITLE, I18N_MSG_SENT_FORMAT, I18N_MSG_USER_NOT_FOUND_FORMAT, + I18N_REPLY_NO_TARGET, I18N_INBOX_TITLE, I18N_INBOX_EMPTY, I18N_INBOX_SENT_TO_FORMAT, diff --git a/include/ssh_server.h b/include/ssh_server.h index cbc14a5..5cbd34e 100644 --- a/include/ssh_server.h +++ b/include/ssh_server.h @@ -61,6 +61,7 @@ typedef struct client { _Atomic int pending_bells; /* Bell nudges for this client's loop */ _Atomic int unread_mentions; /* @-mentions received since last reset */ _Atomic int unread_whispers; /* whispers received since last :inbox view */ + char last_whisper_peer[MAX_USERNAME_LEN]; /* Most recent private-message peer */ char *outbox; /* Bounded queued output for interactive writes */ size_t outbox_len; size_t outbox_pos; diff --git a/src/command_catalog.c b/src/command_catalog.c index f04c375..3817327 100644 --- a/src/command_catalog.c +++ b/src/command_catalog.c @@ -36,6 +36,18 @@ static const command_catalog_entry_t entries[] = { " w \n"), 2, false, true }, + { + {TNT_COMMAND_REPLY, "reply", {"reply", "r", NULL}}, + I18N_STRING(":reply , :r ", + ":reply , :r "), + I18N_STRING("Reply to latest private message", "回复最近私信"), + I18N_STRING(":reply ", ":reply "), + I18N_STRING("Usage: reply \n" + " r \n", + "用法: reply \n" + " r \n"), + 2, false, true + }, { {TNT_COMMAND_INBOX, "inbox", {"inbox", NULL}}, I18N_STRING(":inbox", ":inbox"), diff --git a/src/commands.c b/src/commands.c index 051fde8..84029e5 100644 --- a/src/commands.c +++ b/src/commands.c @@ -82,12 +82,57 @@ static void client_append_whisper(client_t *owner, const char *from, snprintf(owner->whisper_inbox[slot].content, sizeof(owner->whisper_inbox[slot].content), "%s", content); owner->whisper_inbox[slot].outgoing = outgoing; + snprintf(owner->last_whisper_peer, sizeof(owner->last_whisper_peer), "%s", + outgoing ? to : from); if (count_unread) { owner->unread_whispers++; } pthread_mutex_unlock(&owner->whisper_lock); } +static void send_private_message(client_t *client, const char *target_name, + const char *content, char *output, + size_t buf_size, size_t *pos) { + bool found = false; + client_t *target = NULL; + + pthread_rwlock_rdlock(&g_room->lock); + for (int i = 0; i < g_room->client_count; i++) { + if (strcmp(g_room->clients[i]->username, target_name) == 0) { + target = g_room->clients[i]; + client_addref(target); + found = true; + break; + } + } + pthread_rwlock_unlock(&g_room->lock); + + if (target) { + client_append_whisper(target, client->username, target_name, + content, false, true); + if (target != client) { + client_append_whisper(client, client->username, target_name, + content, true, false); + } + + /* Audible nudge: the title bar whisper counter carries the + * persistent signal without cross-client SSH writes. */ + client_queue_bell(target); + client_release(target); + } + + if (found) { + buffer_appendf(output, buf_size, pos, + i18n_text(client->ui_lang, I18N_MSG_SENT_FORMAT), + target_name); + } else { + buffer_appendf(output, buf_size, pos, + i18n_text(client->ui_lang, + I18N_MSG_USER_NOT_FOUND_FORMAT), + target_name); + } +} + static void append_inbox_output(client_t *client, char *output, size_t buf_size, size_t *pos) { whisper_t snapshot[WHISPER_INBOX_SIZE]; @@ -282,43 +327,31 @@ void commands_dispatch(client_t *client) { append_command_usage(output, sizeof(output), &pos, TNT_COMMAND_MSG, client->ui_lang); } else { - bool found = false; - client_t *target = NULL; - pthread_rwlock_rdlock(&g_room->lock); - for (int i = 0; i < g_room->client_count; i++) { - if (strcmp(g_room->clients[i]->username, target_name) == 0) { - target = g_room->clients[i]; - client_addref(target); - found = true; - break; - } - } - pthread_rwlock_unlock(&g_room->lock); + send_private_message(client, target_name, rest, output, + sizeof(output), &pos); + } - if (target) { - client_append_whisper(target, client->username, target_name, - rest, false, true); - if (target != client) { - client_append_whisper(client, client->username, - target_name, rest, true, false); - } + } else if (command_id == TNT_COMMAND_REPLY) { + const char *message = arg; + char target_name[MAX_USERNAME_LEN] = {0}; - /* Audible nudge — the title bar ✉ counter (UX-11 style) - * carries the persistent signal. */ - client_queue_bell(target); - client_release(target); - } + while (*message == ' ') message++; + if (message[0] == '\0') { + append_command_usage(output, sizeof(output), &pos, + TNT_COMMAND_REPLY, client->ui_lang); + } else { + pthread_mutex_lock(&client->whisper_lock); + snprintf(target_name, sizeof(target_name), "%s", + client->last_whisper_peer); + pthread_mutex_unlock(&client->whisper_lock); - if (found) { - buffer_appendf(output, sizeof(output), &pos, + if (target_name[0] == '\0') { + buffer_appendf(output, sizeof(output), &pos, "%s", i18n_text(client->ui_lang, - I18N_MSG_SENT_FORMAT), - target_name); + I18N_REPLY_NO_TARGET)); } else { - buffer_appendf(output, sizeof(output), &pos, - i18n_text(client->ui_lang, - I18N_MSG_USER_NOT_FOUND_FORMAT), - target_name); + send_private_message(client, target_name, message, output, + sizeof(output), &pos); } } diff --git a/src/i18n_text.c b/src/i18n_text.c index e53a662..6ed9cd8 100644 --- a/src/i18n_text.c +++ b/src/i18n_text.c @@ -121,6 +121,10 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "User '%s' not found\n", "未找到用户 '%s'\n" ), + [I18N_REPLY_NO_TARGET] = I18N_STRING( + "No private message to reply to\n", + "没有可回复的私信\n" + ), [I18N_INBOX_TITLE] = I18N_STRING( "Private messages", "私信" diff --git a/tests/test_user_lifecycle.sh b/tests/test_user_lifecycle.sh index 056ee08..f4f4d0a 100755 --- a/tests/test_user_lifecycle.sh +++ b/tests/test_user_lifecycle.sh @@ -37,6 +37,7 @@ SSH_OPTS="-e none -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/nul SSH_EXEC_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT" BOB_READY="$STATE_DIR/bob.ready" PRIVATE_SENT="$STATE_DIR/private.sent" +REPLY_SENT="$STATE_DIR/reply.sent" wait_for_health() { out="" @@ -97,6 +98,14 @@ expect "private lifecycle first" expect "q:关闭" send -- "q" expect "NORMAL" +send -- ":" +expect ":" +send -- "reply private lifecycle reply\r" +expect "私信已发送给 alice" +exec touch "$REPLY_SENT" +expect "q:关闭" +send -- "q" +expect "NORMAL" sleep 0.2 send -- "\003" sleep 0.2 @@ -215,9 +224,12 @@ exec touch "$PRIVATE_SENT" expect "q:关闭" send -- "q" expect "NORMAL" +exec sh -c "while \[ ! -f '$REPLY_SENT' \]; do sleep 1; done" send -- ":" expect ":" send -- "inbox\r" +expect "bob" +expect "private lifecycle reply" expect "你 -> bob" expect "private lifecycle second" expect "private lifecycle first" diff --git a/tests/unit/test_command_catalog.c b/tests/unit/test_command_catalog.c index 3dd67b8..d607105 100644 --- a/tests/unit/test_command_catalog.c +++ b/tests/unit/test_command_catalog.c @@ -35,6 +35,14 @@ TEST(matches_canonical_names_and_aliases) { assert(id == TNT_COMMAND_MSG); assert(strcmp(args, "alice hello") == 0); + assert(command_catalog_match("reply hello back", &id, &args)); + assert(id == TNT_COMMAND_REPLY); + assert(strcmp(args, "hello back") == 0); + + assert(command_catalog_match("r hello back", &id, &args)); + assert(id == TNT_COMMAND_REPLY); + assert(strcmp(args, "hello back") == 0); + assert(command_catalog_match("language zh", &id, &args)); assert(id == TNT_COMMAND_LANG); assert(strcmp(args, "zh") == 0); @@ -65,6 +73,8 @@ TEST(validates_argument_shapes) { assert(!command_catalog_args_valid(TNT_COMMAND_MSG, NULL)); assert(command_catalog_args_valid(TNT_COMMAND_MSG, "alice hello")); + assert(!command_catalog_args_valid(TNT_COMMAND_REPLY, "")); + assert(command_catalog_args_valid(TNT_COMMAND_REPLY, "hello back")); assert(!command_catalog_args_valid(TNT_COMMAND_SEARCH, "")); assert(command_catalog_args_valid(TNT_COMMAND_SEARCH, "needle")); @@ -92,6 +102,7 @@ TEST(generates_localized_help_sections) { assert(strstr(en, ":users, :list, :who") != NULL); assert(strstr(en, "Show online users") != NULL); assert(strstr(en, ":msg ") != NULL); + assert(strstr(en, ":reply ") != NULL); assert(strstr(en, "Show private messages") != NULL); assert(strstr(en, ":support") == NULL); @@ -99,6 +110,7 @@ TEST(generates_localized_help_sections) { assert(strstr(zh, "显示在线用户") != NULL); assert(strstr(zh, "查看私信") != NULL); assert(strstr(zh, ":msg ") != NULL); + assert(strstr(zh, ":reply ") != NULL); assert(strstr(zh, "<用户>") == NULL); assert(strstr(zh, "<消息>") == NULL); assert(strstr(zh, ":support") == NULL); @@ -120,6 +132,13 @@ TEST(generates_localized_usage) { assert(strcmp(zh, "用法: msg \n" " w \n") == 0); + en[0] = '\0'; + en_pos = 0; + command_catalog_append_usage(en, sizeof(en), &en_pos, + TNT_COMMAND_REPLY, UI_LANG_EN); + assert(strcmp(en, "Usage: reply \n" + " r \n") == 0); + en[0] = '\0'; en_pos = 0; command_catalog_append_usage(en, sizeof(en), &en_pos, diff --git a/tests/unit/test_i18n.c b/tests/unit/test_i18n.c index b08056a..06b4c2f 100644 --- a/tests/unit/test_i18n.c +++ b/tests/unit/test_i18n.c @@ -148,6 +148,10 @@ TEST(text_lookup_matches_language) { "Private message sent") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_MSG_SENT_FORMAT), "私信已发送") != NULL); + assert(strstr(i18n_text(UI_LANG_EN, I18N_REPLY_NO_TARGET), + "No private message") != NULL); + assert(strstr(i18n_text(UI_LANG_ZH, I18N_REPLY_NO_TARGET), + "可回复") != NULL); assert(strstr(i18n_text(UI_LANG_EN, I18N_INBOX_TITLE), "Private messages") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_INBOX_TITLE), diff --git a/tnt.1 b/tnt.1 index f671ca3..3f3ad1f 100644 --- a/tnt.1 +++ b/tnt.1 @@ -220,6 +220,8 @@ l l. :name \fIname\fR Alias for :nick :msg \fIuser message\fR Send private message :w \fIuser text\fR Short alias for :msg +:reply \fItext\fR Reply to latest private message +:r \fItext\fR Short alias for :reply :inbox Show private messages, newest first :last [\fIN\fR] Show last N messages from history (1\-50, default 10) :search \fIkeyword\fR Case\-insensitive search; shows the last 15 matches @@ -251,7 +253,11 @@ The .B :inbox page shows incoming messages and local sent-message copies for the current session. It refreshes automatically when a new private message arrives while -it is open. Private messages are not written to +it is open. Use +.B :reply +or +.B :r +to answer the latest private-message peer. Private messages are not written to .IR messages.log . .SH EXEC INTERFACE Commands can be run non\-interactively for scripting: