Compare commits

...

2 commits

Author SHA1 Message Date
eead27544c docs: update all docs for :last, :search, :mute-joins and MOTD
Some checks failed
CI / build-and-test (macos-latest) (push) Has been cancelled
CI / build-and-test (ubuntu-latest) (push) Has been cancelled
Deploy / test (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
- README: add new commands to COMMAND mode table, MOTD section,
  update Known Limitations (100-msg limit now softened by :last/:search)
- tnt.1: add :last/:search/:mute-joins to man page command table,
  add motd.txt to FILES section
- CHANGELOG: add 2026-04-23 entry
- QUICKREF: rewrite command section, add new commands, add motd.txt to files
- ROADMAP: mark Stage 4 :last/:search/:mute-joins items as completed
- DEPLOYMENT: add MOTD setup section
2026-04-23 12:38:04 +08:00
ed5fc43cbd feat: add :last, :search, :mute-joins commands and MOTD support
- :last [N]: show last N messages from log (max 50, default 10)
- :search <keyword>: case-insensitive full-text search across log history
- :mute-joins: per-client toggle to silence join/leave system messages,
  indicated by [静音] in the title bar
- MOTD: display motd.txt from state directory on connect before entering chat
- Add message_search() to message.c/h for log file scanning
- Update :help and tui help screen (EN/ZH) with new commands
2026-04-23 12:03:27 +08:00
11 changed files with 287 additions and 31 deletions

View file

@ -82,6 +82,9 @@ Ctrl+C - Exit chat
:nick <name> - Change nickname :nick <name> - Change nickname
:msg <user> <text> - Whisper to user :msg <user> <text> - Whisper to user
:w <user> <text> - Short alias for :msg :w <user> <text> - Short alias for :msg
:last [N] - Show last N messages from history (max 50, default 10)
:search <keyword> - Search full message history (case-insensitive)
:mute-joins - Toggle join/leave system notifications
:help - Show available commands :help - Show available commands
:clear - Clear command output :clear - Clear command output
:q, :quit, :exit - Disconnect :q, :quit, :exit - Disconnect
@ -276,9 +279,24 @@ See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) for details.
``` ```
messages.log - Chat history (RFC3339 format) messages.log - Chat history (RFC3339 format)
host_key - SSH host key (auto-generated, 4096-bit RSA) host_key - SSH host key (auto-generated, 4096-bit RSA)
motd.txt - Message of the Day (optional, shown to users on connect)
tnt.service - systemd service unit tnt.service - systemd service unit
``` ```
### MOTD (Message of the Day)
Place a `motd.txt` file in the state directory to show a welcome message to every user on connect. Users see the MOTD before entering the chat and press any key to continue.
```sh
# Example (assuming default state dir)
cat > motd.txt <<'EOF'
Welcome to the chat server!
Be respectful. No spam.
EOF
```
Delete `motd.txt` to disable the MOTD.
## Documentation ## Documentation
- [Development Guide](https://github.com/m1ngsama/TNT/wiki/Development-Guide) - Complete development manual - [Development Guide](https://github.com/m1ngsama/TNT/wiki/Development-Guide) - Complete development manual
@ -300,7 +318,7 @@ tnt.service - systemd service unit
## Known Limitations ## Known Limitations
- Single chat room (no multi-room support yet) - Single chat room (no multi-room support yet)
- Keeps only last 100 messages in memory - TUI displays at most 100 messages at once; use `:last N` or `:search` to access older history from disk
- Ctrl+W only recognizes ASCII space as word boundary - Ctrl+W only recognizes ASCII space as word boundary
## Contributing ## Contributing

View file

@ -1,5 +1,15 @@
# Changelog # Changelog
## 2026-04-23 - Chat UX Commands and MOTD
### Added
- **`:last [N]`** — show last N messages retrieved directly from the log file (150, default 10), bypassing the 100-message in-memory ring buffer limit
- **`:search <keyword>`** — case-insensitive full-text search across the entire message history on disk; returns the most recent 15 matches
- **`:mute-joins`** — per-client toggle to silence join/leave system notifications; title bar shows `[静音]` when active
- **MOTD support** — place `motd.txt` in the state directory; users see it on connect and press any key to enter chat
- **`message_search()`** — new function in `message.c` / `message.h` for log file keyword search with rolling result collection
- Updated in-TUI help screens (English and Chinese) with new commands
## 2026-03-10 - SSH Runtime & Unix Interface Update ## 2026-03-10 - SSH Runtime & Unix Interface Update
### Fixed ### Fixed

View file

@ -89,6 +89,24 @@ Recommended interpretation:
- `TNT_MAX_CONN_RATE_PER_IP`: new connection attempts allowed per IP per 60 seconds - `TNT_MAX_CONN_RATE_PER_IP`: new connection attempts allowed per IP per 60 seconds
- `TNT_RATE_LIMIT=0`: disables rate-based blocking and auth-failure IP blocking, but not the explicit capacity limits - `TNT_RATE_LIMIT=0`: disables rate-based blocking and auth-failure IP blocking, but not the explicit capacity limits
## MOTD (Message of the Day)
Place a `motd.txt` file in the state directory. TNT displays it to each user on connect; they press any key to enter the chat.
```bash
# Systemd deployment (state dir is /var/lib/tnt)
sudo tee /var/lib/tnt/motd.txt <<'EOF'
Welcome! Be respectful. No spam.
Type :help for available commands.
EOF
sudo chown tnt:tnt /var/lib/tnt/motd.txt
# Remove to disable
sudo rm /var/lib/tnt/motd.txt
```
No restart required — TNT reads the file on each new connection.
## Firewall ## Firewall
```bash ```bash

View file

@ -17,21 +17,37 @@ DEBUG
valgrind --leak-check=full ./tnt valgrind --leak-check=full ./tnt
make check make check
COMMANDS (COMMAND mode, prefix with :)
list, users, who show online users
nick <name> change nickname
msg <user> <text> whisper to user
w <user> <text> alias for msg
last [N] last N messages from log (default 10, max 50)
search <keyword> search full history (case-insensitive, 15 results)
mute-joins toggle join/leave notifications
help show all commands
clear clear output
q / quit / exit disconnect
INSERT MODE
/me <action> action message
@username mention (bell + highlight)
STRUCTURE STRUCTURE
src/main.c entry, signals src/main.c entry, signals
src/ssh_server.c SSH, threads src/ssh_server.c SSH, threads, commands
src/chat_room.c broadcast src/chat_room.c broadcast
src/message.c persistence src/message.c persistence, search
src/tui.c rendering src/tui.c rendering, help
src/utf8.c unicode src/utf8.c unicode
LIMITS LIMITS
64 clients max 64 clients max (configurable)
100 messages in RAM 100 messages in RAM; unlimited on disk
1024 bytes/message 1024 bytes/message
FILES FILES
HACKING dev guide messages.log chat log (RFC3339)
CHANGELOG.md changes host_key SSH key (auto-generated)
messages.log chat log motd.txt message of the day (optional)
host_key SSH key CHANGELOG.md version history

View file

@ -59,11 +59,11 @@ Goal: keep the interface efficient for terminal users without sacrificing simpli
- keep the current modal editing model, but make its behavior precise and documented - keep the current modal editing model, but make its behavior precise and documented
- support resize, cursor movement, command history, and predictable paste behavior - support resize, cursor movement, command history, and predictable paste behavior
- add useful chat commands with clear semantics: - add useful chat commands with clear semantics:
- `/nick` - `:nick` / `:name` — nickname change with broadcast
- `/me` - `/me` — action messages
- `/last N` - `:last N` — show last N messages from disk history
- `/search` - `:search <keyword>` — case-insensitive full-text search
- `/mute-joins` - `:mute-joins` — per-client join/leave notification toggle
- improve discoverability of NORMAL and COMMAND mode actions - improve discoverability of NORMAL and COMMAND mode actions
- make status lines and help output concise enough for small terminals - make status lines and help output concise enough for small terminals

View file

@ -22,4 +22,8 @@ int message_save(const message_t *msg);
/* Format a message for display */ /* Format a message for display */
void message_format(const message_t *msg, char *buffer, size_t buf_size, int width); void message_format(const message_t *msg, char *buffer, size_t buf_size, int width);
/* Search log file for messages matching query (case-insensitive, username or content).
* Returns the last max_results matches in chronological order; caller must free *results. */
int message_search(const char *query, message_t **results, int max_results);
#endif /* MESSAGE_H */ #endif /* MESSAGE_H */

View file

@ -30,6 +30,7 @@ typedef struct client {
time_t connect_time; time_t connect_time;
time_t last_active; time_t last_active;
atomic_bool redraw_pending; atomic_bool redraw_pending;
bool mute_joins;
pthread_t thread; pthread_t thread;
atomic_bool connected; atomic_bool connected;
int ref_count; /* Reference count for safe cleanup */ int ref_count; /* Reference count for safe cleanup */

View file

@ -244,6 +244,78 @@ int message_save(const message_t *msg) {
return rc; return rc;
} }
/* Search log file for messages whose username or content contains query.
* Case-insensitive. Returns the last max_results matches (most recent); caller frees *results. */
int message_search(const char *query, message_t **results, int max_results) {
char log_path[PATH_MAX];
message_t *res = calloc(max_results, sizeof(message_t));
if (!res) return 0;
if (!query || query[0] == '\0' ||
tnt_state_path(log_path, sizeof(log_path), LOG_FILE) < 0) {
*results = res;
return 0;
}
pthread_mutex_lock(&g_message_file_lock);
FILE *fp = fopen(log_path, "r");
if (!fp) {
pthread_mutex_unlock(&g_message_file_lock);
*results = res;
return 0;
}
char line[2048];
int count = 0;
while (fgets(line, sizeof(line), fp)) {
size_t line_len = strlen(line);
if (line_len >= sizeof(line) - 1) {
int c;
while ((c = fgetc(fp)) != '\n' && c != EOF);
continue;
}
char line_copy[2048];
strncpy(line_copy, line, sizeof(line_copy) - 1);
line_copy[sizeof(line_copy) - 1] = '\0';
char *timestamp_str = strtok(line_copy, "|");
char *username = strtok(NULL, "|");
char *content = strtok(NULL, "\n");
if (!timestamp_str || !username || !content || username[0] == '\0') continue;
if (strlen(username) >= MAX_USERNAME_LEN || strlen(content) >= MAX_MESSAGE_LEN) continue;
if (!utf8_is_valid_string(username) || !utf8_is_valid_string(content)) continue;
if (strcasestr(username, query) == NULL && strcasestr(content, query) == NULL) continue;
time_t msg_time = parse_rfc3339_utc(timestamp_str);
if (msg_time == (time_t)-1) continue;
message_t m;
m.timestamp = msg_time;
strncpy(m.username, username, MAX_USERNAME_LEN - 1);
m.username[MAX_USERNAME_LEN - 1] = '\0';
strncpy(m.content, content, MAX_MESSAGE_LEN - 1);
m.content[MAX_MESSAGE_LEN - 1] = '\0';
if (count < max_results) {
res[count++] = m;
} else {
memmove(&res[0], &res[1], (max_results - 1) * sizeof(message_t));
res[max_results - 1] = m;
/* count stays at max_results */
}
}
fclose(fp);
pthread_mutex_unlock(&g_message_file_lock);
*results = res;
return (count < max_results) ? count : max_results;
}
/* Format a message for display */ /* Format a message for display */
void message_format(const message_t *msg, char *buffer, size_t buf_size, int width) { void message_format(const message_t *msg, char *buffer, size_t buf_size, int width) {
struct tm tm_info; struct tm tm_info;

View file

@ -1198,6 +1198,9 @@ static void execute_command(client_t *client) {
"list, users, who - Show online users\n" "list, users, who - Show online users\n"
"nick/name <name> - Change nickname\n" "nick/name <name> - Change nickname\n"
"msg/w <user> <text> - Whisper to user\n" "msg/w <user> <text> - Whisper to user\n"
"last [N] - Show last N messages\n"
"search <keyword> - Search message history\n"
"mute-joins - Toggle join/leave notices\n"
"help, commands - Show this help\n" "help, commands - Show this help\n"
"clear, cls - Clear command output\n" "clear, cls - Clear command output\n"
"q, quit, exit - Disconnect\n" "q, quit, exit - Disconnect\n"
@ -1289,6 +1292,63 @@ static void execute_command(client_t *client) {
"Nickname changed: %s -> %s\n", old_name, client->username); "Nickname changed: %s -> %s\n", old_name, client->username);
} }
} else if (strncmp(cmd, "last", 4) == 0 && (cmd[4] == ' ' || cmd[4] == '\0')) {
char *arg = cmd + 4;
while (*arg == ' ') arg++;
int n = 10;
if (*arg != '\0') {
char *endp;
long val = strtol(arg, &endp, 10);
if (*endp != '\0' || val < 1 || val > 50) {
buffer_appendf(output, sizeof(output), &pos,
"Usage: last [N] (N: 1-50, default 10)\n");
goto cmd_done;
}
n = (int)val;
}
message_t *last_msgs = NULL;
int last_count = message_load(&last_msgs, n);
buffer_appendf(output, sizeof(output), &pos,
"--- Last %d message(s) ---\n", last_count);
for (int i = 0; i < last_count; i++) {
char ts[20];
struct tm tmi;
localtime_r(&last_msgs[i].timestamp, &tmi);
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
buffer_appendf(output, sizeof(output), &pos,
"[%s] %s: %s\n", ts, last_msgs[i].username, last_msgs[i].content);
}
free(last_msgs);
} else if (strncmp(cmd, "search ", 7) == 0) {
char *query = cmd + 7;
while (*query == ' ') query++;
if (*query == '\0') {
buffer_appendf(output, sizeof(output), &pos,
"Usage: search <keyword>\n");
} else {
message_t *found = NULL;
int found_count = message_search(query, &found, 15);
buffer_appendf(output, sizeof(output), &pos,
"--- Search: \"%s\" (%d match(es)) ---\n", query, found_count);
for (int i = 0; i < found_count; i++) {
char ts[20];
struct tm tmi;
localtime_r(&found[i].timestamp, &tmi);
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
buffer_appendf(output, sizeof(output), &pos,
"[%s] %s: %s\n", ts, found[i].username, found[i].content);
}
free(found);
}
} else if (strcmp(cmd, "mute-joins") == 0 || strcmp(cmd, "mute") == 0) {
client->mute_joins = !client->mute_joins;
buffer_appendf(output, sizeof(output), &pos,
"Join/leave notifications: %s\n",
client->mute_joins ? "muted" : "unmuted");
} else if (strcmp(cmd, "q") == 0 || strcmp(cmd, "quit") == 0 || } else if (strcmp(cmd, "q") == 0 || strcmp(cmd, "quit") == 0 ||
strcmp(cmd, "exit") == 0) { strcmp(cmd, "exit") == 0) {
client->connected = false; client->connected = false;
@ -1310,6 +1370,7 @@ static void execute_command(client_t *client) {
"Type 'help' for available commands\n", cmd); "Type 'help' for available commands\n", cmd);
} }
cmd_done:
buffer_appendf(output, sizeof(output), &pos, buffer_appendf(output, sizeof(output), &pos,
"\nPress any key to continue..."); "\nPress any key to continue...");
@ -1607,10 +1668,33 @@ void* client_handle_session(void *arg) {
room_broadcast(g_room, &join_msg); room_broadcast(g_room, &join_msg);
message_save(&join_msg); message_save(&join_msg);
/* Show MOTD if motd.txt exists in state directory */
{
char motd_path[PATH_MAX];
if (tnt_state_path(motd_path, sizeof(motd_path), "motd.txt") == 0) {
FILE *motd_fp = fopen(motd_path, "r");
if (motd_fp) {
char motd_buf[sizeof(client->command_output) - 64];
size_t motd_len = fread(motd_buf, 1, sizeof(motd_buf) - 1, motd_fp);
fclose(motd_fp);
if (motd_len > 0) {
motd_buf[motd_len] = '\0';
snprintf(client->command_output, sizeof(client->command_output),
"=== 公告 / MOTD ===\n%s", motd_buf);
tui_render_command_output(client);
seen_update_seq = room_get_update_seq(g_room);
goto main_loop;
}
}
}
}
/* Render initial screen */ /* Render initial screen */
tui_render_screen(client); tui_render_screen(client);
seen_update_seq = room_get_update_seq(g_room); seen_update_seq = room_get_update_seq(g_room);
main_loop:
/* Main input loop */ /* Main input loop */
while (client->connected && ssh_channel_is_open(client->channel)) { while (client->connected && ssh_channel_is_open(client->channel)) {
int ready = ssh_channel_poll_timeout(client->channel, 1000, 0); int ready = ssh_channel_poll_timeout(client->channel, 1000, 0);

View file

@ -5,6 +5,12 @@
#include <stdarg.h> #include <stdarg.h>
#include <unistd.h> #include <unistd.h>
static bool is_join_leave_msg(const message_t *msg) {
if (strcmp(msg->username, "系统") != 0) return false;
return strstr(msg->content, "加入了聊天室") != NULL ||
strstr(msg->content, "离开了聊天室") != NULL;
}
static const char *username_color(const char *name) { static const char *username_color(const char *name) {
static const char *colors[] = { static const char *colors[] = {
"\033[31m", "\033[32m", "\033[33m", "\033[31m", "\033[32m", "\033[33m",
@ -222,6 +228,17 @@ void tui_render_screen(client_t *client) {
/* Now render using snapshot (no lock held) */ /* Now render using snapshot (no lock held) */
/* If mute_joins is set, remove join/leave messages from snapshot in place */
if (client->mute_joins && msg_snapshot) {
int filtered = 0;
for (int i = 0; i < snapshot_count; i++) {
if (!is_join_leave_msg(&msg_snapshot[i])) {
msg_snapshot[filtered++] = msg_snapshot[i];
}
}
snapshot_count = filtered;
}
/* Move to top (Home) - Do NOT clear screen to prevent flicker */ /* Move to top (Home) - Do NOT clear screen to prevent flicker */
buffer_appendf(buffer, buf_size, &pos, ANSI_HOME); buffer_appendf(buffer, buf_size, &pos, ANSI_HOME);
@ -232,8 +249,9 @@ void tui_render_screen(client_t *client) {
char title[256]; char title[256];
snprintf(title, sizeof(title), snprintf(title, sizeof(title),
" %s | 在线: %d | 模式: %s | ? 帮助 ", " %s | 在线: %d | 模式: %s%s | ? 帮助 ",
client->username, online, mode_str); client->username, online, mode_str,
client->mute_joins ? " [静音]" : "");
int title_width = utf8_string_width(title); int title_width = utf8_string_width(title);
int padding = render_width - title_width; int padding = render_width - title_width;
@ -415,13 +433,16 @@ const char* tui_get_help_text(help_lang_t lang) {
" Ctrl+C - Exit chat\n" " Ctrl+C - Exit chat\n"
"\n" "\n"
"AVAILABLE COMMANDS:\n" "AVAILABLE COMMANDS:\n"
" :list, :users - Show online users\n" " :list, :users - Show online users\n"
" :nick <name> - Change nickname\n" " :nick <name> - Change nickname\n"
" :msg <user> <text> - Whisper to user\n" " :msg <user> <text> - Whisper to user\n"
" :w <user> <text> - Short alias for :msg\n" " :w <user> <text> - Short alias for :msg\n"
" :help - Show available commands\n" " :last [N] - Show last N messages (max 50)\n"
" :clear - Clear command output\n" " :search <keyword> - Search message history\n"
" :q, :quit, :exit - Disconnect\n" " :mute-joins - Toggle join/leave notices\n"
" :help - Show available commands\n"
" :clear - Clear command output\n"
" :q, :quit, :exit - Disconnect\n"
"\n" "\n"
"SPECIAL MESSAGES:\n" "SPECIAL MESSAGES:\n"
" /me <action> - Send action (e.g. /me waves)\n" " /me <action> - Send action (e.g. /me waves)\n"
@ -459,13 +480,16 @@ const char* tui_get_help_text(help_lang_t lang) {
" Ctrl+C - 退出聊天\n" " Ctrl+C - 退出聊天\n"
"\n" "\n"
"可用命令:\n" "可用命令:\n"
" :list, :users - 显示在线用户\n" " :list, :users - 显示在线用户\n"
" :nick <名字> - 更改昵称\n" " :nick <名字> - 更改昵称\n"
" :msg <用户> <文本> - 私聊\n" " :msg <用户> <文本> - 私聊\n"
" :w <用户> <文本> - :msg 的简写\n" " :w <用户> <文本> - :msg 的简写\n"
" :help - 显示可用命令\n" " :last [N] - 显示最后 N 条消息(最多50)\n"
" :clear - 清空命令输出\n" " :search <关键词> - 搜索消息历史\n"
" :q, :quit, :exit - 断开连接\n" " :mute-joins - 切换加入/离开提示\n"
" :help - 显示可用命令\n"
" :clear - 清空命令输出\n"
" :q, :quit, :exit - 断开连接\n"
"\n" "\n"
"特殊消息:\n" "特殊消息:\n"
" /me <动作> - 发送动作 (如 /me 挥手)\n" " /me <动作> - 发送动作 (如 /me 挥手)\n"

9
tnt.1
View file

@ -107,6 +107,9 @@ l l.
:name \fIname\fR Alias for :nick :name \fIname\fR Alias for :nick
:msg \fIuser text\fR Send private whisper :msg \fIuser text\fR Send private whisper
:w \fIuser text\fR Short alias for :msg :w \fIuser text\fR Short alias for :msg
:last [\fIN\fR] Show last N messages from history (1\-50, default 10)
:search \fIkeyword\fR Case\-insensitive search across full message history
:mute\-joins Toggle join/leave system notifications on/off
:help Show available commands :help Show available commands
:clear Clear command output :clear Clear command output
:q, :quit, :exit Disconnect :q, :quit, :exit Disconnect
@ -167,6 +170,12 @@ Stored in the state directory.
.I host_key .I host_key
RSA 4096\-bit host key, auto\-generated on first run. RSA 4096\-bit host key, auto\-generated on first run.
Stored in the state directory with mode 0600. Stored in the state directory with mode 0600.
.TP
.I motd.txt
Optional Message of the Day.
When present in the state directory, its contents are shown to each user
immediately on connect before the chat screen appears.
Delete the file to disable the MOTD.
.SH SYSTEMD .SH SYSTEMD
A unit file A unit file
.I tnt.service .I tnt.service