TNT/src/commands.c

483 lines
19 KiB
C

#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE /* for strcasestr() on glibc */
#endif
#if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE)
#define _DARWIN_C_SOURCE /* for strcasestr() on macOS */
#endif
#include "commands.h"
#include "chat_room.h"
#include "client.h"
#include "common.h"
#include "help_text.h"
#include "i18n.h"
#include "message.h"
#include "support.h"
#include "system_message.h"
#include "tui.h"
#include "utf8.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
/* Append `text` to the output buffer with every case-insensitive match of
* `needle` wrapped in a reverse-yellow ANSI chip. Preserves the original
* casing of the matched substring. needle == NULL or empty appends raw. */
static void append_highlighted(char *output, size_t buf_size, size_t *pos,
const char *text, const char *needle) {
if (!needle || !*needle) {
buffer_appendf(output, buf_size, pos, "%s", text);
return;
}
size_t nlen = strlen(needle);
const char *p = text;
while (*p) {
const char *hit = strcasestr(p, needle);
if (!hit) {
buffer_appendf(output, buf_size, pos, "%s", p);
return;
}
if (hit > p) {
buffer_append_bytes(output, buf_size, pos, p, (size_t)(hit - p));
}
buffer_append_bytes(output, buf_size, pos, "\033[7;33m", 7);
buffer_append_bytes(output, buf_size, pos, hit, nlen);
buffer_append_bytes(output, buf_size, pos, "\033[0m", 4);
p = hit + nlen;
}
}
static int min3(int a, int b, int c) {
int m = a < b ? a : b;
return m < c ? m : c;
}
static int command_edit_distance(const char *a, const char *b) {
size_t la = strlen(a);
size_t lb = strlen(b);
int prev[32];
int curr[32];
if (la >= 32 || lb >= 32) {
return 99;
}
for (size_t j = 0; j <= lb; j++) {
prev[j] = (int)j;
}
for (size_t i = 1; i <= la; i++) {
curr[0] = (int)i;
for (size_t j = 1; j <= lb; j++) {
int cost = a[i - 1] == b[j - 1] ? 0 : 1;
curr[j] = min3(prev[j] + 1, curr[j - 1] + 1,
prev[j - 1] + cost);
}
for (size_t j = 0; j <= lb; j++) {
prev[j] = curr[j];
}
}
return prev[lb];
}
static const char *suggest_command(const char *cmd) {
static const char *commands[] = {
"list", "users", "who", "nick", "name", "msg", "w", "inbox",
"last", "search", "mute-joins", "mute", "support", "guide",
"lang", "language", "help", "commands", "clear", "cls",
"q", "quit", "exit"
};
const char *best = NULL;
int best_distance = 99;
if (!cmd || !*cmd) {
return NULL;
}
for (size_t i = 0; i < sizeof(commands) / sizeof(commands[0]); i++) {
int distance = command_edit_distance(cmd, commands[i]);
if (distance < best_distance) {
best_distance = distance;
best = commands[i];
}
}
return best_distance <= 2 ? best : NULL;
}
void commands_dispatch(client_t *client) {
char cmd_buf[256];
strncpy(cmd_buf, client->command_input, sizeof(cmd_buf) - 1);
cmd_buf[sizeof(cmd_buf) - 1] = '\0';
char *cmd = cmd_buf;
char output[2048] = {0};
size_t pos = 0;
/* Trim whitespace */
while (*cmd == ' ') cmd++;
size_t cmd_len = strlen(cmd);
if (cmd_len > 0) {
char *end = cmd + cmd_len - 1;
while (end > cmd && *end == ' ') {
*end = '\0';
end--;
}
}
/* Save to command history */
if (cmd[0] != '\0') {
int max_hist = 16;
if (client->command_history_count >= max_hist) {
memmove(&client->command_history[0], &client->command_history[1],
(max_hist - 1) * sizeof(client->command_history[0]));
client->command_history_count = max_hist - 1;
}
snprintf(client->command_history[client->command_history_count],
sizeof(client->command_history[0]), "%s", cmd);
client->command_history_count++;
client->command_history_pos = client->command_history_count;
}
if (strcmp(cmd, "list") == 0 || strcmp(cmd, "users") == 0 ||
strcmp(cmd, "who") == 0) {
pthread_rwlock_rdlock(&g_room->lock);
int total = g_room->client_count;
buffer_appendf(output, sizeof(output), &pos,
"\033[1;36m%s\033[0m \033[2;37m· %d\033[0m\n",
i18n_text(client->help_lang, I18N_USERS_TITLE), total);
time_t now = time(NULL);
for (int i = 0; i < total; i++) {
bool is_self = (g_room->clients[i] == client);
int dur = (int)(now - g_room->clients[i]->connect_time);
char dur_str[32];
if (dur < 60) {
snprintf(dur_str, sizeof(dur_str), "%ds", dur);
} else if (dur < 3600) {
snprintf(dur_str, sizeof(dur_str), "%dm", dur / 60);
} else {
snprintf(dur_str, sizeof(dur_str), "%dh%dm",
dur / 3600, (dur % 3600) / 60);
}
/* 1-column gutter: ▎ for you, blank for others */
buffer_appendf(output, sizeof(output), &pos,
"%s \033[37m%s\033[0m \033[2;37m· %s\033[0m\n",
is_self ? "\033[36m▎\033[0m" : " ",
g_room->clients[i]->username, dur_str);
}
pthread_rwlock_unlock(&g_room->lock);
} else if (strcmp(cmd, "help") == 0 || strcmp(cmd, "commands") == 0) {
help_text_append_commands(output, sizeof(output), &pos,
client->help_lang);
} else if (strcmp(cmd, "support") == 0 || strcmp(cmd, "guide") == 0) {
support_append_interactive_panel(output, sizeof(output), &pos,
client->help_lang);
} else if (strcmp(cmd, "lang") == 0 || strcmp(cmd, "language") == 0 ||
strncmp(cmd, "lang ", 5) == 0 ||
strncmp(cmd, "language ", 9) == 0) {
char *arg = NULL;
help_lang_t next_lang;
if (strncmp(cmd, "lang ", 5) == 0) {
arg = cmd + 5;
} else if (strncmp(cmd, "language ", 9) == 0) {
arg = cmd + 9;
}
if (!arg || arg[0] == '\0') {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
I18N_LANG_CURRENT_FORMAT),
i18n_lang_code(client->help_lang));
} else if (i18n_try_parse_lang(arg, &next_lang)) {
client->help_lang = next_lang;
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
I18N_LANG_SET_FORMAT),
i18n_lang_code(client->help_lang));
} else {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
I18N_LANG_UNSUPPORTED_FORMAT),
arg);
}
} else if (strcmp(cmd, "msg") == 0 || strcmp(cmd, "w") == 0 ||
strncmp(cmd, "msg ", 4) == 0 || strncmp(cmd, "w ", 2) == 0) {
char *rest = (cmd[0] == 'w') ? cmd + 1 : cmd + 3;
while (*rest == ' ') rest++;
char target_name[MAX_USERNAME_LEN] = {0};
int ti = 0;
while (*rest && *rest != ' ' && ti < MAX_USERNAME_LEN - 1) {
target_name[ti++] = *rest++;
}
while (*rest == ' ') rest++;
if (target_name[0] == '\0' || rest[0] == '\0') {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->help_lang, I18N_MSG_USAGE));
} else {
bool found = false;
client_t *target = NULL;
pthread_rwlock_rdlock(&g_room->lock);
for (int i = 0; i < g_room->client_count; i++) {
if (strcmp(g_room->clients[i]->username, target_name) == 0) {
target = g_room->clients[i];
client_addref(target);
found = true;
break;
}
}
pthread_rwlock_unlock(&g_room->lock);
if (target) {
/* Push into recipient's inbox. io_lock serialises so two
* senders to the same recipient don't tear the ring. */
pthread_mutex_lock(&target->io_lock);
int slot;
if (target->whisper_inbox_count < WHISPER_INBOX_SIZE) {
slot = target->whisper_inbox_count++;
} else {
/* FIFO evict the oldest */
memmove(&target->whisper_inbox[0],
&target->whisper_inbox[1],
(WHISPER_INBOX_SIZE - 1) * sizeof(whisper_t));
slot = WHISPER_INBOX_SIZE - 1;
}
target->whisper_inbox[slot].timestamp = time(NULL);
snprintf(target->whisper_inbox[slot].from,
sizeof(target->whisper_inbox[slot].from),
"%s", client->username);
snprintf(target->whisper_inbox[slot].content,
sizeof(target->whisper_inbox[slot].content),
"%s", rest);
pthread_mutex_unlock(&target->io_lock);
target->unread_whispers++;
target->redraw_pending = true;
/* Audible nudge — the title bar ✉ counter (UX-11 style)
* carries the persistent signal. */
client_send(target, "\a", 1);
client_release(target);
}
if (found) {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
I18N_MSG_SENT_FORMAT),
target_name);
} else {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
I18N_MSG_USER_NOT_FOUND_FORMAT),
target_name);
}
}
} else if (strcmp(cmd, "inbox") == 0) {
/* Snapshot the inbox under io_lock so a concurrent sender doesn't
* tear what we're rendering. Counter reset happens after copy. */
whisper_t snapshot[WHISPER_INBOX_SIZE];
int snap_count;
pthread_mutex_lock(&client->io_lock);
snap_count = client->whisper_inbox_count;
memcpy(snapshot, client->whisper_inbox,
snap_count * sizeof(whisper_t));
pthread_mutex_unlock(&client->io_lock);
client->unread_whispers = 0;
buffer_appendf(output, sizeof(output), &pos,
"\033[1;36m%s\033[0m \033[2;37m· %d\033[0m\n",
i18n_text(client->help_lang, I18N_INBOX_TITLE),
snap_count);
if (snap_count == 0) {
buffer_appendf(output, sizeof(output), &pos,
" \033[2;37m%s\033[0m\n",
i18n_text(client->help_lang, I18N_INBOX_EMPTY));
}
for (int i = 0; i < snap_count; i++) {
char ts[20];
struct tm tmi;
localtime_r(&snapshot[i].timestamp, &tmi);
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
buffer_appendf(output, sizeof(output), &pos,
" \033[90m%s\033[0m \033[35m%s\033[0m: %s\n",
ts, snapshot[i].from, snapshot[i].content);
}
} else if (strcmp(cmd, "nick") == 0 || strcmp(cmd, "name") == 0 ||
strncmp(cmd, "nick ", 5) == 0 || strncmp(cmd, "name ", 5) == 0) {
char *new_name = cmd + 4;
while (*new_name == ' ') new_name++;
if (new_name[0] == '\0') {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->help_lang, I18N_NICK_USAGE));
} else if (!is_valid_username(new_name)) {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->help_lang, I18N_NICK_INVALID));
} else {
char validated_name[MAX_USERNAME_LEN];
snprintf(validated_name, sizeof(validated_name), "%s", new_name);
if (utf8_strlen(validated_name) > 20) {
utf8_truncate(validated_name, 20);
}
/* Reject collisions with active room members. Held under
* wrlock so the username swap below races neither read nor
* concurrent :nick from another client. */
char old_name[MAX_USERNAME_LEN];
bool taken = false;
pthread_rwlock_wrlock(&g_room->lock);
snprintf(old_name, sizeof(old_name), "%s", client->username);
if (strcmp(validated_name, old_name) != 0) {
for (int i = 0; i < g_room->client_count; i++) {
if (g_room->clients[i] == client) continue;
if (strcmp(g_room->clients[i]->username,
validated_name) == 0) {
taken = true;
break;
}
}
}
if (!taken) {
snprintf(client->username, MAX_USERNAME_LEN, "%s", validated_name);
}
pthread_rwlock_unlock(&g_room->lock);
if (taken) {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
I18N_NICK_TAKEN_FORMAT),
validated_name);
} else if (strcmp(validated_name, old_name) == 0) {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->help_lang,
I18N_NICK_UNCHANGED));
} else {
message_t nick_msg;
system_message_make_nick(&nick_msg, old_name,
client->username, client->help_lang);
room_broadcast(g_room, &nick_msg);
message_save(&nick_msg);
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
I18N_NICK_CHANGED_FORMAT),
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, "%s",
i18n_text(client->help_lang, I18N_LAST_USAGE));
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,
i18n_text(client->help_lang, I18N_LAST_HEADER_FORMAT),
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 (strcmp(cmd, "search") == 0 || strncmp(cmd, "search ", 7) == 0) {
char *query = cmd + 6;
while (*query == ' ') query++;
if (*query == '\0') {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->help_lang, I18N_SEARCH_USAGE));
} else {
message_t *found = NULL;
int found_count = message_search(query, &found, 15);
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
I18N_SEARCH_HEADER_FORMAT),
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] ", ts);
append_highlighted(output, sizeof(output), &pos,
found[i].username, query);
buffer_appendf(output, sizeof(output), &pos, ": ");
append_highlighted(output, sizeof(output), &pos,
found[i].content, query);
buffer_appendf(output, sizeof(output), &pos, "\n");
}
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,
i18n_text(client->help_lang, I18N_MUTE_JOINS_FORMAT),
i18n_text(client->help_lang,
client->mute_joins ?
I18N_MUTE_JOINS_MUTED :
I18N_MUTE_JOINS_UNMUTED));
} else if (strcmp(cmd, "q") == 0 || strcmp(cmd, "quit") == 0 ||
strcmp(cmd, "exit") == 0) {
client->connected = false;
return;
} else if (strcmp(cmd, "clear") == 0 || strcmp(cmd, "cls") == 0) {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->help_lang, I18N_CLEAR_DONE));
} else if (cmd[0] == '\0') {
/* Empty command */
client->mode = MODE_NORMAL;
client->command_input[0] = '\0';
tui_render_screen(client);
return;
} else {
const char *suggestion = suggest_command(cmd);
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
I18N_UNKNOWN_COMMAND_FORMAT),
cmd);
if (suggestion) {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
I18N_DID_YOU_MEAN_FORMAT),
suggestion);
}
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->help_lang, I18N_UNKNOWN_GUIDANCE));
}
cmd_done:
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->help_lang, I18N_CONTINUE_PROMPT));
snprintf(client->command_output, sizeof(client->command_output), "%s", output);
client->command_input[0] = '\0';
tui_render_command_output(client);
}