mirror of
https://oauth2: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@github.com/m1ngsama/TNT.git
synced 2026-05-10 19:00:57 +08:00
Compare commits
2 commits
ff6df3ab16
...
eead27544c
| Author | SHA1 | Date | |
|---|---|---|---|
| eead27544c | |||
| ed5fc43cbd |
11 changed files with 287 additions and 31 deletions
20
README.md
20
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (1–50, 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
28
src/tui.c
28
src/tui.c
|
|
@ -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;
|
||||
|
|
@ -419,6 +437,9 @@ const char* tui_get_help_text(help_lang_t lang) {
|
|||
" :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"
|
||||
|
|
@ -463,6 +484,9 @@ const char* tui_get_help_text(help_lang_t lang) {
|
|||
" :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"
|
||||
|
|
|
|||
9
tnt.1
9
tnt.1
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue