Add private message reply command

This commit is contained in:
m1ngsama 2026-05-29 17:40:09 +08:00
parent 5ae02054ee
commit 1f8fb7acf4
15 changed files with 148 additions and 43 deletions

View file

@ -96,6 +96,8 @@ Ctrl+C - Exit chat
:nick <name> - Change nickname
:msg <user> <message> - Send private message
:w <user> <text> - Short alias for :msg
:reply <text> - Reply to latest private message
:r <text> - Short alias for :reply
:inbox - Show private messages
:last [N] - Show last N messages from history (max 50, default 10)
:search <keyword> - 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)**
```

View file

@ -80,6 +80,7 @@ Common commands:
:users online users
:nick <name> change nickname
:msg <user> <message> send private message
:reply <message> reply to latest private message
:inbox show private messages
:last [N] recent messages
:search <keyword> search message history

View file

@ -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

View file

@ -30,6 +30,8 @@ COMMANDS (COMMAND mode, prefix with :)
nick <name> change nickname
msg <user> <message> send private message
w <user> <text> alias for msg
reply <text> reply to latest private message
r <text> alias for reply
inbox show private messages, newest first
last [N] last N messages from log (default 10, max 50)
search <keyword> search full history (case-insensitive, 15 results)

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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;

View file

@ -36,6 +36,18 @@ static const command_catalog_entry_t entries[] = {
" w <user> <message>\n"),
2, false, true
},
{
{TNT_COMMAND_REPLY, "reply", {"reply", "r", NULL}},
I18N_STRING(":reply <message>, :r <message>",
":reply <message>, :r <message>"),
I18N_STRING("Reply to latest private message", "回复最近私信"),
I18N_STRING(":reply <message>", ":reply <message>"),
I18N_STRING("Usage: reply <message>\n"
" r <message>\n",
"用法: reply <message>\n"
" r <message>\n"),
2, false, true
},
{
{TNT_COMMAND_INBOX, "inbox", {"inbox", NULL}},
I18N_STRING(":inbox", ":inbox"),

View file

@ -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);
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);
send_private_message(client, target_name, rest, output,
sizeof(output), &pos);
}
/* Audible nudge — the title bar ✉ counter (UX-11 style)
* carries the persistent signal. */
client_queue_bell(target);
client_release(target);
}
} else if (command_id == TNT_COMMAND_REPLY) {
const char *message = arg;
char target_name[MAX_USERNAME_LEN] = {0};
if (found) {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->ui_lang,
I18N_MSG_SENT_FORMAT),
target_name);
while (*message == ' ') message++;
if (message[0] == '\0') {
append_command_usage(output, sizeof(output), &pos,
TNT_COMMAND_REPLY, client->ui_lang);
} else {
buffer_appendf(output, sizeof(output), &pos,
pthread_mutex_lock(&client->whisper_lock);
snprintf(target_name, sizeof(target_name), "%s",
client->last_whisper_peer);
pthread_mutex_unlock(&client->whisper_lock);
if (target_name[0] == '\0') {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang,
I18N_MSG_USER_NOT_FOUND_FORMAT),
target_name);
I18N_REPLY_NO_TARGET));
} else {
send_private_message(client, target_name, message, output,
sizeof(output), &pos);
}
}

View file

@ -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",
"私信"

View file

@ -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"

View file

@ -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 <user> <message>") != NULL);
assert(strstr(en, ":reply <message>") != 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 <user> <message>") != NULL);
assert(strstr(zh, ":reply <message>") != 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 <user> <message>\n"
" w <user> <message>\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 <message>\n"
" r <message>\n") == 0);
en[0] = '\0';
en_pos = 0;
command_catalog_append_usage(en, sizeof(en), &en_pos,

View file

@ -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),

8
tnt.1
View file

@ -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: