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
:msg <user> <text> - Whisper to user
: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
:clear - Clear command output
:q, :quit, :exit - Disconnect
@ -276,9 +279,24 @@ See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) for details.
```
messages.log - Chat history (RFC3339 format)
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
```
### 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
- [Development Guide](https://github.com/m1ngsama/TNT/wiki/Development-Guide) - Complete development manual
@ -300,7 +318,7 @@ tnt.service - systemd service unit
## Known Limitations
- 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
## Contributing

View file

@ -1,5 +1,15 @@
# 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
### 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_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
```bash

View file

@ -17,21 +17,37 @@ DEBUG
valgrind --leak-check=full ./tnt
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
src/main.c entry, signals
src/ssh_server.c SSH, threads
src/ssh_server.c SSH, threads, commands
src/chat_room.c broadcast
src/message.c persistence
src/tui.c rendering
src/message.c persistence, search
src/tui.c rendering, help
src/utf8.c unicode
LIMITS
64 clients max
100 messages in RAM
64 clients max (configurable)
100 messages in RAM; unlimited on disk
1024 bytes/message
FILES
HACKING dev guide
CHANGELOG.md changes
messages.log chat log
host_key SSH key
messages.log chat log (RFC3339)
host_key SSH key (auto-generated)
motd.txt message of the day (optional)
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
- support resize, cursor movement, command history, and predictable paste behavior
- add useful chat commands with clear semantics:
- `/nick`
- `/me`
- `/last N`
- `/search`
- `/mute-joins`
- `:nick` / `:name` — nickname change with broadcast
- `/me` — action messages
- `:last N` — show last N messages from disk history
- `:search <keyword>` — case-insensitive full-text search
- `:mute-joins` — per-client join/leave notification toggle
- improve discoverability of NORMAL and COMMAND mode actions
- 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 */
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 */

View file

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

View file

@ -244,6 +244,78 @@ int message_save(const message_t *msg) {
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 */
void message_format(const message_t *msg, char *buffer, size_t buf_size, int width) {
struct tm tm_info;

View file

@ -1198,6 +1198,9 @@ static void execute_command(client_t *client) {
"list, users, who - Show online users\n"
"nick/name <name> - Change nickname\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"
"clear, cls - Clear command output\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);
}
} 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 ||
strcmp(cmd, "exit") == 0) {
client->connected = false;
@ -1310,6 +1370,7 @@ static void execute_command(client_t *client) {
"Type 'help' for available commands\n", cmd);
}
cmd_done:
buffer_appendf(output, sizeof(output), &pos,
"\nPress any key to continue...");
@ -1607,10 +1668,33 @@ void* client_handle_session(void *arg) {
room_broadcast(g_room, &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 */
tui_render_screen(client);
seen_update_seq = room_get_update_seq(g_room);
main_loop:
/* Main input loop */
while (client->connected && ssh_channel_is_open(client->channel)) {
int ready = ssh_channel_poll_timeout(client->channel, 1000, 0);

View file

@ -5,6 +5,12 @@
#include <stdarg.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 *colors[] = {
"\033[31m", "\033[32m", "\033[33m",
@ -222,6 +228,17 @@ void tui_render_screen(client_t *client) {
/* 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 */
buffer_appendf(buffer, buf_size, &pos, ANSI_HOME);
@ -232,8 +249,9 @@ void tui_render_screen(client_t *client) {
char title[256];
snprintf(title, sizeof(title),
" %s | 在线: %d | 模式: %s | ? 帮助 ",
client->username, online, mode_str);
" %s | 在线: %d | 模式: %s%s | ? 帮助 ",
client->username, online, mode_str,
client->mute_joins ? " [静音]" : "");
int title_width = utf8_string_width(title);
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"
"\n"
"AVAILABLE COMMANDS:\n"
" :list, :users - Show online users\n"
" :nick <name> - Change nickname\n"
" :msg <user> <text> - Whisper to user\n"
" :w <user> <text> - Short alias for :msg\n"
" :help - Show available commands\n"
" :clear - Clear command output\n"
" :q, :quit, :exit - Disconnect\n"
" :list, :users - Show online users\n"
" :nick <name> - Change nickname\n"
" :msg <user> <text> - Whisper to user\n"
" :w <user> <text> - Short alias for :msg\n"
" :last [N] - Show last N messages (max 50)\n"
" :search <keyword> - Search message history\n"
" :mute-joins - Toggle join/leave notices\n"
" :help - Show available commands\n"
" :clear - Clear command output\n"
" :q, :quit, :exit - Disconnect\n"
"\n"
"SPECIAL MESSAGES:\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"
"\n"
"可用命令:\n"
" :list, :users - 显示在线用户\n"
" :nick <名字> - 更改昵称\n"
" :msg <用户> <文本> - 私聊\n"
" :w <用户> <文本> - :msg 的简写\n"
" :help - 显示可用命令\n"
" :clear - 清空命令输出\n"
" :q, :quit, :exit - 断开连接\n"
" :list, :users - 显示在线用户\n"
" :nick <名字> - 更改昵称\n"
" :msg <用户> <文本> - 私聊\n"
" :w <用户> <文本> - :msg 的简写\n"
" :last [N] - 显示最后 N 条消息(最多50)\n"
" :search <关键词> - 搜索消息历史\n"
" :mute-joins - 切换加入/离开提示\n"
" :help - 显示可用命令\n"
" :clear - 清空命令输出\n"
" :q, :quit, :exit - 断开连接\n"
"\n"
"特殊消息:\n"
" /me <动作> - 发送动作 (如 /me 挥手)\n"

9
tnt.1
View file

@ -107,6 +107,9 @@ l l.
:name \fIname\fR Alias for :nick
:msg \fIuser text\fR Send private whisper
: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
:clear Clear command output
:q, :quit, :exit Disconnect
@ -167,6 +170,12 @@ Stored in the state directory.
.I host_key
RSA 4096\-bit host key, auto\-generated on first run.
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
A unit file
.I tnt.service