Compare commits

..

No commits in common. "eead27544c9ff35a5145ddff8012d04af7f35484" and "ff6df3ab1623028681c5dd98355e5598e31621cf" have entirely different histories.

11 changed files with 31 additions and 287 deletions

View file

@ -82,9 +82,6 @@ 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
@ -279,24 +276,9 @@ 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
@ -318,7 +300,7 @@ Delete `motd.txt` to disable the MOTD.
## Known Limitations ## Known Limitations
- Single chat room (no multi-room support yet) - Single chat room (no multi-room support yet)
- TUI displays at most 100 messages at once; use `:last N` or `:search` to access older history from disk - Keeps only last 100 messages in memory
- Ctrl+W only recognizes ASCII space as word boundary - Ctrl+W only recognizes ASCII space as word boundary
## Contributing ## Contributing

View file

@ -1,15 +1,5 @@
# 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,24 +89,6 @@ 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,37 +17,21 @@ 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, commands src/ssh_server.c SSH, threads
src/chat_room.c broadcast src/chat_room.c broadcast
src/message.c persistence, search src/message.c persistence
src/tui.c rendering, help src/tui.c rendering
src/utf8.c unicode src/utf8.c unicode
LIMITS LIMITS
64 clients max (configurable) 64 clients max
100 messages in RAM; unlimited on disk 100 messages in RAM
1024 bytes/message 1024 bytes/message
FILES FILES
messages.log chat log (RFC3339) HACKING dev guide
host_key SSH key (auto-generated) CHANGELOG.md changes
motd.txt message of the day (optional) messages.log chat log
CHANGELOG.md version history host_key SSH key

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` / `:name` — nickname change with broadcast - `/nick`
- `/me` — action messages - `/me`
- `:last N` — show last N messages from disk history - `/last N`
- `:search <keyword>` — case-insensitive full-text search - `/search`
- `:mute-joins` — per-client join/leave notification toggle - `/mute-joins`
- 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,8 +22,4 @@ 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,7 +30,6 @@ 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,78 +244,6 @@ 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,9 +1198,6 @@ 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"
@ -1292,63 +1289,6 @@ 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;
@ -1370,7 +1310,6 @@ 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...");
@ -1668,33 +1607,10 @@ 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,12 +5,6 @@
#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",
@ -228,17 +222,6 @@ 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);
@ -249,9 +232,8 @@ 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 | ? 帮助 ", " %s | 在线: %d | 模式: %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;
@ -433,16 +415,13 @@ 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"
" :last [N] - Show last N messages (max 50)\n" " :help - Show available commands\n"
" :search <keyword> - Search message history\n" " :clear - Clear command output\n"
" :mute-joins - Toggle join/leave notices\n" " :q, :quit, :exit - Disconnect\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"
@ -480,16 +459,13 @@ 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"
" :last [N] - 显示最后 N 条消息(最多50)\n" " :help - 显示可用命令\n"
" :search <关键词> - 搜索消息历史\n" " :clear - 清空命令输出\n"
" :mute-joins - 切换加入/离开提示\n" " :q, :quit, :exit - 断开连接\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,9 +107,6 @@ 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
@ -170,12 +167,6 @@ 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