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
This commit is contained in:
m1ngsama 2026-04-23 12:03:27 +08:00
parent ff6df3ab16
commit ed5fc43cbd
5 changed files with 201 additions and 16 deletions

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;
@ -419,6 +437,9 @@ const char* tui_get_help_text(help_lang_t lang) {
" :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"
" :search <keyword> - Search message history\n"
" :mute-joins - Toggle join/leave notices\n"
" :help - Show available commands\n" " :help - Show available commands\n"
" :clear - Clear command output\n" " :clear - Clear command output\n"
" :q, :quit, :exit - Disconnect\n" " :q, :quit, :exit - Disconnect\n"
@ -463,6 +484,9 @@ const char* tui_get_help_text(help_lang_t lang) {
" :nick <名字> - 更改昵称\n" " :nick <名字> - 更改昵称\n"
" :msg <用户> <文本> - 私聊\n" " :msg <用户> <文本> - 私聊\n"
" :w <用户> <文本> - :msg 的简写\n" " :w <用户> <文本> - :msg 的简写\n"
" :last [N] - 显示最后 N 条消息(最多50)\n"
" :search <关键词> - 搜索消息历史\n"
" :mute-joins - 切换加入/离开提示\n"
" :help - 显示可用命令\n" " :help - 显示可用命令\n"
" :clear - 清空命令输出\n" " :clear - 清空命令输出\n"
" :q, :quit, :exit - 断开连接\n" " :q, :quit, :exit - 断开连接\n"