diff --git a/include/commands.h b/include/commands.h index 2851644..f2b4026 100644 --- a/include/commands.h +++ b/include/commands.h @@ -28,6 +28,11 @@ int commands_rename(client_t *client, const char *new_name, bool verified); /* Rename to "anonymous", or "anonymousN" when that is taken. */ void commands_become_anonymous(client_t *client); +/* Deliver a private message from a remote user to every session named `to`, + * matched without regard to ASCII letter case. */ +void commands_deliver_private(const char *from, const char *to, + const char *plain_text); + /* Rebuild the currently visible command output when it is backed by live * client state, such as :inbox. Returns true if output changed and the caller * should render it again. */ diff --git a/include/common.h b/include/common.h index b51f0ec..7f331b5 100644 --- a/include/common.h +++ b/include/common.h @@ -14,7 +14,7 @@ #include "config_defaults.h" /* Project Metadata */ -#define TNT_VERSION "1.5.0" +#define TNT_VERSION "1.6.0" /* Public process/exec exit statuses. TNT follows the common sysexits(3) * convention for usage errors while keeping runtime failures portable. */ diff --git a/include/i18n.h b/include/i18n.h index 2ccb1c9..1ccafe5 100644 --- a/include/i18n.h +++ b/include/i18n.h @@ -48,6 +48,7 @@ typedef enum { I18N_SYSTEM_LEAVE_FORMAT, I18N_SYSTEM_NICK_FORMAT, I18N_USERS_TITLE, + I18N_USERS_APP, I18N_MSG_SENT_FORMAT, I18N_MSG_USER_NOT_FOUND_FORMAT, I18N_REPLY_NO_TARGET, diff --git a/include/json_text.h b/include/json_text.h index f3697e4..527148b 100644 --- a/include/json_text.h +++ b/include/json_text.h @@ -12,4 +12,12 @@ void tnt_json_append_string(char *buffer, size_t buf_size, size_t *pos, bool tnt_json_get_string_field(const char *json, const char *key, char *out, size_t out_size); +/* Extract a top-level JSON array of strings into `max_items` slots of + * `item_size` bytes each and store the element count in `count`. Returns + * false for malformed JSON, a missing key, a non-string element, an element + * overflow, or more than `max_items` elements. */ +bool tnt_json_get_string_array_field(const char *json, const char *key, + char *items, size_t item_size, + size_t max_items, size_t *count); + #endif /* JSON_TEXT_H */ diff --git a/include/module_protocol.h b/include/module_protocol.h index a5e9442..0aaa6c8 100644 --- a/include/module_protocol.h +++ b/include/module_protocol.h @@ -13,6 +13,10 @@ #define TNT_MODULE_EVENT_PRESENCE_SNAPSHOT "presence.snapshot" #define TNT_MODULE_EVENT_IDENTITY_REQUESTED "identity.requested" #define TNT_MODULE_RECORD_IDENTITY_PREFIX "identity." +#define TNT_MODULE_RECORD_PRESENCE_REMOTE "presence.remote" +#define TNT_MODULE_RECORD_PRIVATE_POST "private.post" +#define TNT_MODULE_EVENT_PRIVATE_CREATED "private.created" +#define TNT_MODULE_REMOTE_USERS_MAX 256 #define TNT_MODULE_SESSION_ID_SIZE 32 typedef struct { @@ -37,6 +41,17 @@ typedef struct { char plain_text[MAX_MESSAGE_LEN]; } tnt_module_identity_record_t; +typedef struct { + char nicknames[TNT_MODULE_REMOTE_USERS_MAX][MAX_USERNAME_LEN]; + size_t count; +} tnt_module_presence_remote_t; + +typedef struct { + char from[MAX_USERNAME_LEN]; + char to[MAX_USERNAME_LEN]; + char plain_text[MAX_MESSAGE_LEN]; +} tnt_module_private_post_t; + int tnt_module_append_handshake(char *buffer, size_t buf_size, size_t *pos, const char *server_version); @@ -57,6 +72,11 @@ int tnt_module_append_identity_requested(char *buffer, size_t buf_size, size_t *pos, const char *session, const char *nickname); +int tnt_module_append_private_created(char *buffer, size_t buf_size, + size_t *pos, const char *from, + const char *to, const char *plain_text, + time_t timestamp, bool verified); + bool tnt_module_parse_message_create(const char *line, tnt_module_message_create_t *out); @@ -66,4 +86,11 @@ bool tnt_module_parse_message_post(const char *line, bool tnt_module_parse_identity_record(const char *line, tnt_module_identity_record_t *out); +/* Names repeated without regard to ASCII letter case are kept once. */ +bool tnt_module_parse_presence_remote(const char *line, + tnt_module_presence_remote_t *out); + +bool tnt_module_parse_private_post(const char *line, + tnt_module_private_post_t *out); + #endif /* MODULE_PROTOCOL_H */ diff --git a/include/module_runtime.h b/include/module_runtime.h index 0ee62ec..40bacf2 100644 --- a/include/module_runtime.h +++ b/include/module_runtime.h @@ -18,6 +18,7 @@ typedef struct { bool can_post_messages; bool can_read_presence; bool can_write_identity; + bool can_message_private; } tnt_module_manifest_t; int tnt_module_manifest_load(const char *module_dir, @@ -40,6 +41,18 @@ void tnt_module_runtime_publish_presence(const char *nickname, bool joined); bool tnt_module_runtime_request_identity(const char *session, const char *nickname); +/* Copy up to `capacity` names of the users the message:private module reports + * as online elsewhere. Returns the number copied. */ +int tnt_module_runtime_copy_remote_users(char (*out)[MAX_USERNAME_LEN], + int capacity); +int tnt_module_runtime_remote_user_count(void); + +/* Hand a private message for a remote user to the message:private module. + * `to` matches remote names without regard to ASCII letter case. Returns + * false when no remote user has that name or the module cannot take it. */ +bool tnt_module_runtime_send_private(const char *from, const char *to, + const char *plain_text, bool verified); + #ifdef TNT_TESTING /* Pipe-level test seams for deterministic backpressure/deadline coverage. */ int tnt_module_runtime_test_write_fd(int fd, const char *data, size_t len, diff --git a/packaging/arch/.SRCINFO b/packaging/arch/.SRCINFO index b255c14..f4c6ea4 100644 --- a/packaging/arch/.SRCINFO +++ b/packaging/arch/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = tnt-chat pkgdesc = SSH-native terminal chat server with a Vim-style interface - pkgver = 1.5.0 + pkgver = 1.6.0 pkgrel = 1 url = https://github.com/m1ngsama/TNT arch = x86_64 @@ -10,7 +10,7 @@ pkgbase = tnt-chat makedepends = make depends = libssh depends = openssh - source = tnt-chat-v1.5.0-source.tar.gz::https://github.com/m1ngsama/TNT/releases/download/v1.5.0/tnt-chat-v1.5.0-source.tar.gz + source = tnt-chat-v1.6.0-source.tar.gz::https://github.com/m1ngsama/TNT/releases/download/v1.6.0/tnt-chat-v1.6.0-source.tar.gz source = tnt-chat.sysusers sha256sums = SKIP sha256sums = 8a1f7dfbdc9f1305c4ed50d80e89f91333ffdf937890c497f93e41abaf76e3ed diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 9107d0d..89f63bc 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: M1ng pkgname=tnt-chat -pkgver=1.5.0 +pkgver=1.6.0 pkgrel=1 pkgdesc='SSH-native terminal chat server with a Vim-style interface' arch=('x86_64' 'aarch64') diff --git a/packaging/debian/debian/changelog b/packaging/debian/debian/changelog index e4b5a19..3af5880 100644 --- a/packaging/debian/debian/changelog +++ b/packaging/debian/debian/changelog @@ -1,3 +1,12 @@ +tnt-chat (1.6.0-1) unstable; urgency=medium + + * Add the message:private module permission. Users the module reports + with presence.remote appear in :users and nickname completion, and + private messages reach them through private.created and come back + through private.post. + + -- M1ng Wed, 14 Oct 2026 00:00:00 +0800 + tnt-chat (1.5.0-1) unstable; urgency=medium * Let members of m1ngsama ID sign in with :login and :logout, reserve diff --git a/packaging/homebrew/tnt-chat.rb b/packaging/homebrew/tnt-chat.rb index d7ff877..756df16 100644 --- a/packaging/homebrew/tnt-chat.rb +++ b/packaging/homebrew/tnt-chat.rb @@ -1,7 +1,7 @@ class TntChat < Formula desc "SSH-native terminal chat server with a Vim-style interface" homepage "https://github.com/m1ngsama/TNT" - url "https://github.com/m1ngsama/TNT/releases/download/v1.5.0/tnt-chat-v1.5.0-source.tar.gz" + url "https://github.com/m1ngsama/TNT/releases/download/v1.6.0/tnt-chat-v1.6.0-source.tar.gz" sha256 "REPLACE_WITH_RELEASE_TARBALL_SHA256" license "MIT" diff --git a/src/commands.c b/src/commands.c index 68d3693..9bb7849 100644 --- a/src/commands.c +++ b/src/commands.c @@ -14,6 +14,7 @@ #include "keymap.h" #include "manual.h" #include "message.h" +#include "module_protocol.h" #include "module_runtime.h" #include "richtext.h" #include "system_message.h" @@ -127,9 +128,11 @@ static void send_private_message(client_t *client, const char *target_name, const char *content, char *output, size_t buf_size, size_t *pos) { bool found = false; + bool verified; client_t *target = NULL; pthread_rwlock_rdlock(&g_room->lock); + verified = client->verified; for (int i = 0; i < g_room->client_count; i++) { if (strcasecmp(g_room->clients[i]->username, target_name) == 0) { target = g_room->clients[i]; @@ -152,6 +155,11 @@ static void send_private_message(client_t *client, const char *target_name, * persistent signal without cross-client SSH writes. */ client_queue_bell(target); client_release(target); + } else if (tnt_module_runtime_send_private(client->username, target_name, + content, verified)) { + client_append_whisper(client, client->username, target_name, content, + true, false); + found = true; } if (found) { @@ -166,6 +174,31 @@ static void send_private_message(client_t *client, const char *target_name, } } +void commands_deliver_private(const char *from, const char *to, + const char *plain_text) { + client_t **targets = NULL; + int target_count = 0; + + pthread_rwlock_rdlock(&g_room->lock); + if (g_room->client_count > 0) { + targets = calloc((size_t)g_room->client_count, sizeof(*targets)); + } + for (int i = 0; targets && i < g_room->client_count; i++) { + if (strcasecmp(g_room->clients[i]->username, to) == 0) { + client_addref(g_room->clients[i]); + targets[target_count++] = g_room->clients[i]; + } + } + pthread_rwlock_unlock(&g_room->lock); + + for (int i = 0; i < target_count; i++) { + client_append_whisper(targets[i], from, to, plain_text, false, true); + client_queue_bell(targets[i]); + client_release(targets[i]); + } + free(targets); +} + static void append_inbox_output(client_t *client, char *output, size_t buf_size, size_t *pos) { whisper_t snapshot[WHISPER_INBOX_SIZE]; @@ -374,12 +407,19 @@ void commands_dispatch(client_t *client) { char self_gutter[32]; snprintf(self_gutter, sizeof(self_gutter), "%s▎\033[0m", theme->accent); + char (*remote)[MAX_USERNAME_LEN] = + calloc(TNT_MODULE_REMOTE_USERS_MAX, sizeof(*remote)); + int remote_count = remote ? tnt_module_runtime_copy_remote_users( + remote, TNT_MODULE_REMOTE_USERS_MAX) + : 0; + pthread_rwlock_rdlock(&g_room->lock); int total = g_room->client_count; buffer_appendf(output, sizeof(output), &pos, "%s%s\033[0m \033[2;37m· %d\033[0m\n", theme->accent_bold, - i18n_text(client->ui_lang, I18N_USERS_TITLE), total); + i18n_text(client->ui_lang, I18N_USERS_TITLE), + total + remote_count); time_t now = time(NULL); for (int i = 0; i < total; i++) { @@ -401,6 +441,13 @@ void commands_dispatch(client_t *client) { g_room->clients[i]->username, dur_str); } pthread_rwlock_unlock(&g_room->lock); + for (int i = 0; i < remote_count; i++) { + buffer_appendf(output, sizeof(output), &pos, + " \033[37m%s\033[0m \033[2;37m· %s\033[0m\n", + remote[i], + i18n_text(client->ui_lang, I18N_USERS_APP)); + } + free(remote); } else if (command_id == TNT_COMMAND_HELP) { manual_append_interactive_panel(output, sizeof(output), &pos, diff --git a/src/i18n_text.c b/src/i18n_text.c index 844b4ca..987d6e2 100644 --- a/src/i18n_text.c +++ b/src/i18n_text.c @@ -133,6 +133,10 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "Online users", "在线用户" ), + [I18N_USERS_APP] = I18N_STRING( + "app", + "应用" + ), [I18N_MSG_SENT_FORMAT] = I18N_STRING( "Private message sent to %s\n", "私信已发送给 %s\n" diff --git a/src/input.c b/src/input.c index 3983ae1..eea2448 100644 --- a/src/input.c +++ b/src/input.c @@ -18,6 +18,7 @@ #include "input_buffer.h" #include "message.h" #include "message_log.h" +#include "module_protocol.h" #include "module_runtime.h" #include "ratelimit.h" #include "system_message.h" @@ -638,6 +639,40 @@ static size_t prefix_filter(const char **cands, size_t ncand, return count; } +static char (*copy_remote_users(int *count))[MAX_USERNAME_LEN] { + char (*remote)[MAX_USERNAME_LEN] = + calloc(TNT_MODULE_REMOTE_USERS_MAX, sizeof(*remote)); + + *count = remote ? tnt_module_runtime_copy_remote_users( + remote, TNT_MODULE_REMOTE_USERS_MAX) + : 0; + return remote; +} + +/* Remote users follow the SSH sessions, without a name an SSH session + * already offers. */ +static size_t append_remote_candidates(const char **cands, + char (*namebufs)[MAX_USERNAME_LEN], + size_t ncand, size_t max) { + int count; + char (*remote)[MAX_USERNAME_LEN] = copy_remote_users(&count); + + for (int i = 0; i < count && ncand < max; i++) { + bool listed = false; + + for (size_t j = 0; j < ncand && !listed; j++) { + listed = strcasecmp(cands[j], remote[i]) == 0; + } + if (!listed) { + snprintf(namebufs[ncand], MAX_USERNAME_LEN, "%s", remote[i]); + cands[ncand] = namebufs[ncand]; + ncand++; + } + } + free(remote); + return ncand; +} + /* Tab completion for COMMAND mode. Completes the command name when no * argument has been started, otherwise completes the first argument for the * handful of commands with a closed/known candidate set (theme, lang, and @@ -709,6 +744,7 @@ static void command_tab_complete(client_t *client) { ncand++; } pthread_rwlock_unlock(&g_room->lock); + ncand = append_remote_candidates(cands, namebufs, ncand, 64); } else { return; } @@ -1240,6 +1276,23 @@ static bool handle_key(client_t *client, unsigned char key, editor_t *ed, } } pthread_rwlock_unlock(&g_room->lock); + if (match[0] == '\0') { + int count; + char (*remote)[MAX_USERNAME_LEN] = + copy_remote_users(&count); + + for (int i = 0; i < count; i++) { + if (plen == 0 + ? strcmp(remote[i], client->username) != 0 + : strncasecmp(remote[i], prefix, + plen) == 0) { + snprintf(match, sizeof(match), "%s", + remote[i]); + break; + } + } + free(remote); + } if (match[0] != '\0') { /* Replace "@" with "@ " (trailing * space so the next word starts cleanly). */ diff --git a/src/json_text.c b/src/json_text.c index cd6bdf0..1ecbbef 100644 --- a/src/json_text.c +++ b/src/json_text.c @@ -287,16 +287,66 @@ static bool skip_json_value(const char **cursor) { return true; } -bool tnt_json_get_string_field(const char *json, const char *key, - char *out, size_t out_size) { - const char *p; - bool found = false; +typedef bool (*json_value_reader_fn)(const char **cursor, void *ctx); - if (!json || !key || key[0] == '\0' || !out || out_size == 0) { +typedef struct { + char *out; + size_t size; +} json_string_target_t; + +typedef struct { + char *items; + size_t item_size; + size_t max_items; + size_t count; +} json_string_array_target_t; + +static bool read_string_value(const char **cursor, void *ctx) { + json_string_target_t *target = ctx; + + return **cursor == '"' && + parse_json_string(cursor, target->out, target->size); +} + +static bool read_string_array_value(const char **cursor, void *ctx) { + json_string_array_target_t *target = ctx; + const char *p = *cursor; + + target->count = 0; + if (*p != '[') { return false; } + p = skip_ws(p + 1); + if (*p == ']') { + *cursor = p + 1; + return true; + } + for (;;) { + if (target->count >= target->max_items || *p != '"' || + !parse_json_string(&p, + target->items + + target->count * target->item_size, + target->item_size)) { + return false; + } + target->count++; + p = skip_ws(p); + if (*p == ']') { + *cursor = p + 1; + return true; + } + if (*p != ',') { + return false; + } + p = skip_ws(p + 1); + } +} + +static bool read_top_level_field(const char *json, const char *key, + json_value_reader_fn read, void *ctx) { + const char *p; + bool found = false; - out[0] = '\0'; p = skip_ws(json); if (*p != '{') { return false; @@ -322,7 +372,7 @@ bool tnt_json_get_string_field(const char *json, const char *key, p = skip_ws(p + 1); if (strcmp(parsed_key, key) == 0) { - if (*p != '"' || !parse_json_string(&p, out, out_size)) { + if (!read(&p, ctx)) { return false; } found = true; @@ -341,3 +391,41 @@ bool tnt_json_get_string_field(const char *json, const char *key, return false; } } + +bool tnt_json_get_string_field(const char *json, const char *key, + char *out, size_t out_size) { + json_string_target_t target = { + .out = out, + .size = out_size, + }; + + if (!json || !key || key[0] == '\0' || !out || out_size == 0) { + return false; + } + + out[0] = '\0'; + return read_top_level_field(json, key, read_string_value, &target); +} + +bool tnt_json_get_string_array_field(const char *json, const char *key, + char *items, size_t item_size, + size_t max_items, size_t *count) { + json_string_array_target_t target = { + .items = items, + .item_size = item_size, + .max_items = max_items, + }; + + if (count) { + *count = 0; + } + if (!json || !key || key[0] == '\0' || !items || item_size == 0 || + !count) { + return false; + } + if (!read_top_level_field(json, key, read_string_array_value, &target)) { + return false; + } + *count = target.count; + return true; +} diff --git a/src/module_protocol.c b/src/module_protocol.c index 3e96602..7748ac4 100644 --- a/src/module_protocol.c +++ b/src/module_protocol.c @@ -5,6 +5,8 @@ #include "message_log.h" #include "utf8.h" +#include + #define TNT_MODULE_SENDER_MAX_CHARS 20 static int append_result(size_t before, size_t pos, size_t buf_size) { @@ -17,6 +19,12 @@ static bool is_valid_plain_text(const char *plain_text) { !message_log_content_has_forbidden_control(plain_text); } +static bool is_valid_nickname(const char *nickname) { + return utf8_is_valid_string(nickname) && is_valid_username(nickname) && + strchr(nickname, ':') == NULL && + utf8_strlen(nickname) <= TNT_MODULE_SENDER_MAX_CHARS; +} + int tnt_module_append_handshake(char *buffer, size_t buf_size, size_t *pos, const char *server_version) { const char *version = server_version ? server_version : TNT_VERSION; @@ -139,6 +147,36 @@ int tnt_module_append_identity_requested(char *buffer, size_t buf_size, return append_result(before, *pos, buf_size); } +int tnt_module_append_private_created(char *buffer, size_t buf_size, + size_t *pos, const char *from, + const char *to, const char *plain_text, + time_t timestamp, bool verified) { + char formatted[64]; + size_t before; + + if (!buffer || !pos || buf_size == 0 || !from || !to || !plain_text || + !utf8_is_valid_string(from) || !utf8_is_valid_string(to) || + !utf8_is_valid_string(plain_text)) { + return -1; + } + + before = *pos; + message_log_format_timestamp_utc(timestamp, formatted, sizeof(formatted)); + buffer_appendf(buffer, buf_size, pos, "{\"type\":\"%s\",\"from\":", + TNT_MODULE_EVENT_PRIVATE_CREATED); + tnt_json_append_string(buffer, buf_size, pos, from); + buffer_appendf(buffer, buf_size, pos, ",\"to\":"); + tnt_json_append_string(buffer, buf_size, pos, to); + buffer_appendf(buffer, buf_size, pos, ",\"plain_text\":"); + tnt_json_append_string(buffer, buf_size, pos, plain_text); + buffer_appendf(buffer, buf_size, pos, ",\"timestamp\":"); + tnt_json_append_string(buffer, buf_size, pos, formatted); + buffer_appendf(buffer, buf_size, pos, ",\"verified\":%s}\n", + verified ? "true" : "false"); + + return append_result(before, *pos, buf_size); +} + bool tnt_module_parse_message_create(const char *line, tnt_module_message_create_t *out) { char type[64]; @@ -177,10 +215,7 @@ bool tnt_module_parse_message_post(const char *line, strcmp(type, TNT_MODULE_RECORD_MESSAGE_POST) == 0 && tnt_json_get_string_field(line, "sender", out->sender, sizeof(out->sender)) && - utf8_is_valid_string(out->sender) && - is_valid_username(out->sender) && - strchr(out->sender, ':') == NULL && - utf8_strlen(out->sender) <= TNT_MODULE_SENDER_MAX_CHARS && + is_valid_nickname(out->sender) && tnt_json_get_string_field(line, "plain_text", out->plain_text, sizeof(out->plain_text)) && is_valid_plain_text(out->plain_text); @@ -223,3 +258,64 @@ bool tnt_module_parse_identity_record(const char *line, sizeof(out->handle)) && identity_handle_valid(out->handle); } + +bool tnt_module_parse_presence_remote(const char *line, + tnt_module_presence_remote_t *out) { + char type[64]; + size_t count; + + if (!line || !out) { + return false; + } + + memset(out, 0, sizeof(*out)); + if (!tnt_json_get_string_field(line, "type", type, sizeof(type)) || + strcmp(type, TNT_MODULE_RECORD_PRESENCE_REMOTE) != 0 || + !tnt_json_get_string_array_field(line, "nicknames", + &out->nicknames[0][0], + sizeof(out->nicknames[0]), + TNT_MODULE_REMOTE_USERS_MAX, + &count)) { + memset(out, 0, sizeof(*out)); + return false; + } + + for (size_t i = 0; i < count; i++) { + bool repeated = false; + + if (!is_valid_nickname(out->nicknames[i])) { + memset(out, 0, sizeof(*out)); + return false; + } + for (size_t j = 0; j < out->count && !repeated; j++) { + repeated = strcasecmp(out->nicknames[j], out->nicknames[i]) == 0; + } + if (!repeated) { + memmove(out->nicknames[out->count], out->nicknames[i], + sizeof(out->nicknames[0])); + out->count++; + } + } + return true; +} + +bool tnt_module_parse_private_post(const char *line, + tnt_module_private_post_t *out) { + char type[64]; + + if (!line || !out) { + return false; + } + + memset(out, 0, sizeof(*out)); + return tnt_json_get_string_field(line, "type", type, sizeof(type)) && + strcmp(type, TNT_MODULE_RECORD_PRIVATE_POST) == 0 && + tnt_json_get_string_field(line, "from", out->from, + sizeof(out->from)) && + is_valid_nickname(out->from) && + tnt_json_get_string_field(line, "to", out->to, sizeof(out->to)) && + is_valid_nickname(out->to) && + tnt_json_get_string_field(line, "plain_text", out->plain_text, + sizeof(out->plain_text)) && + is_valid_plain_text(out->plain_text); +} diff --git a/src/module_runtime.c b/src/module_runtime.c index 99879d1..e39a9ba 100644 --- a/src/module_runtime.c +++ b/src/module_runtime.c @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -36,20 +37,25 @@ struct client; void notify_mentions(const char *content, const struct client *sender); bool input_identity_notice(const char *session, const char *plain_text); bool input_identity_verified(const char *session, const char *handle); +void commands_deliver_private(const char *from, const char *to, + const char *plain_text); typedef enum module_event_kind { MODULE_EVENT_MESSAGE_CREATED, MODULE_EVENT_PRESENCE_JOINED, MODULE_EVENT_PRESENCE_LEFT, MODULE_EVENT_PRESENCE_SNAPSHOT, - MODULE_EVENT_IDENTITY_REQUESTED + MODULE_EVENT_IDENTITY_REQUESTED, + MODULE_EVENT_PRIVATE_CREATED } module_event_kind_t; /* An identity request carries the nickname in msg.username and the session - * id in msg.content. */ + * id in msg.content. A private message carries its sender in msg.username + * and its recipient in target. */ typedef struct module_event { module_event_kind_t kind; message_t msg; + char target[MAX_USERNAME_LEN]; uint64_t event_id; bool verified; } module_event_t; @@ -121,6 +127,10 @@ static atomic_bool g_stop_requested = ATOMIC_VAR_INIT(true); static pthread_mutex_t g_dispatch_lock = PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t g_module_publish_lock = PTHREAD_MUTEX_INITIALIZER; static module_process_t *g_identity_module = NULL; +static module_process_t *g_private_module = NULL; +static pthread_mutex_t g_remote_lock = PTHREAD_MUTEX_INITIALIZER; +static char g_remote[TNT_MODULE_REMOTE_USERS_MAX][MAX_USERNAME_LEN]; +static int g_remote_count = 0; static char g_online[TNT_MAX_CONFIGURED_CLIENTS][MAX_USERNAME_LEN]; static int g_online_count = 0; @@ -271,6 +281,8 @@ int tnt_module_manifest_load(const char *module_dir, manifest, "permissions", "presence:read"); out->can_write_identity = json_array_contains_string( manifest, "permissions", "identity:write"); + out->can_message_private = json_array_contains_string( + manifest, "permissions", "message:private"); if (!out->wants_message_created || !out->can_read_messages || !out->can_create_messages) { @@ -616,6 +628,11 @@ static void close_module_process(module_process_t *module) { return; } + if (module->manifest.can_message_private) { + pthread_mutex_lock(&g_remote_lock); + g_remote_count = 0; + pthread_mutex_unlock(&g_remote_lock); + } if (module->stdin_fd >= 0) { close(module->stdin_fd); module->stdin_fd = -1; @@ -801,8 +818,9 @@ static module_queue_push_result_t module_queue_push(module_process_t *module, active = atomic_load_explicit(&module->active, memory_order_acquire); if (!module->queue_running || queue_full || !active) { if (module->queue_running && queue_full && active && - event->kind != MODULE_EVENT_MESSAGE_CREATED && - event->kind != MODULE_EVENT_IDENTITY_REQUESTED) { + (event->kind == MODULE_EVENT_PRESENCE_JOINED || + event->kind == MODULE_EVENT_PRESENCE_LEFT || + event->kind == MODULE_EVENT_PRESENCE_SNAPSHOT)) { module->dropped_presence++; } else if (module->queue_running && queue_full && active && !module->saturation_reported) { @@ -1065,6 +1083,14 @@ static void apply_identity_record(const tnt_module_identity_record_t *record) { } } +static void apply_remote_users(const tnt_module_presence_remote_t *remote) { + pthread_mutex_lock(&g_remote_lock); + memcpy(g_remote, remote->nicknames, + remote->count * sizeof(remote->nicknames[0])); + g_remote_count = (int)remote->count; + pthread_mutex_unlock(&g_remote_lock); +} + /* Records a granted module may write at any time, outside the response * window of an event. */ static bool is_unsolicited_record(const module_process_t *module, @@ -1078,13 +1104,18 @@ static bool is_unsolicited_record(const module_process_t *module, strcmp(type, TNT_MODULE_RECORD_MESSAGE_POST) == 0) || (module->manifest.can_write_identity && strncmp(type, TNT_MODULE_RECORD_IDENTITY_PREFIX, - strlen(TNT_MODULE_RECORD_IDENTITY_PREFIX)) == 0); + strlen(TNT_MODULE_RECORD_IDENTITY_PREFIX)) == 0) || + (module->manifest.can_message_private && + (strcmp(type, TNT_MODULE_RECORD_PRESENCE_REMOTE) == 0 || + strcmp(type, TNT_MODULE_RECORD_PRIVATE_POST) == 0)); } static module_response_action_t handle_module_record(module_process_t *module, const char *line) { tnt_module_message_post_t post; tnt_module_identity_record_t identity; + tnt_module_presence_remote_t remote; + tnt_module_private_post_t private_post; if (module->manifest.can_post_messages && tnt_module_parse_message_post(line, &post) && @@ -1094,6 +1125,15 @@ static module_response_action_t handle_module_record(module_process_t *module, } else if (module->manifest.can_write_identity && tnt_module_parse_identity_record(line, &identity)) { apply_identity_record(&identity); + } else if (module->manifest.can_message_private && + tnt_module_parse_presence_remote(line, &remote)) { + apply_remote_users(&remote); + } else if (module->manifest.can_message_private && + tnt_module_parse_private_post(line, &private_post) && + (module->manifest.can_write_identity || + !identity_is_reserved(private_post.from))) { + commands_deliver_private(private_post.from, private_post.to, + private_post.plain_text); } else { fprintf(stderr, "module runtime: ignored invalid response from %s\n", module->manifest.name); @@ -1246,6 +1286,20 @@ static void deliver_identity_request_to_module(module_process_t *module, (void)write_module_record(module, record); } +static void deliver_private_to_module(module_process_t *module, + const module_event_t *event) { + char record[TNT_MODULE_LINE_MAX] = ""; + size_t pos = 0; + + if (tnt_module_append_private_created( + record, sizeof(record), &pos, event->msg.username, event->target, + event->msg.content, event->msg.timestamp, event->verified) < 0) { + disable_module(module, "event encoding failure"); + return; + } + (void)write_module_record(module, record); +} + static void deliver_snapshot_to_module(module_process_t *module) { char (*online)[MAX_USERNAME_LEN] = malloc(sizeof(g_online)); int count; @@ -1302,6 +1356,9 @@ static void deliver_event_to_module(module_process_t *module, case MODULE_EVENT_IDENTITY_REQUESTED: deliver_identity_request_to_module(module, event); break; + case MODULE_EVENT_PRIVATE_CREATED: + deliver_private_to_module(module, event); + break; case MODULE_EVENT_PRESENCE_SNAPSHOT: deliver_snapshot_to_module(module); break; @@ -1396,11 +1453,13 @@ static bool module_permissions_allowed(module_process_t *module) { module->privileged = manifest->can_post_messages || manifest->can_read_presence || - manifest->can_write_identity; + manifest->can_write_identity || + manifest->can_message_private; if (module->privileged && !module_is_granted(manifest->name)) { fprintf(stderr, - "module runtime: %s requests message:post, presence:read or " - "identity:write but is not listed in TNT_MODULE_GRANTS\n", + "module runtime: %s requests message:post, presence:read, " + "identity:write or message:private but is not listed in " + "TNT_MODULE_GRANTS\n", manifest->name); return false; } @@ -1411,6 +1470,13 @@ static bool module_permissions_allowed(module_process_t *module) { manifest->name, g_identity_module->manifest.name); return false; } + if (manifest->can_message_private && g_private_module) { + fprintf(stderr, + "module runtime: %s requests message:private, which %s " + "already holds\n", + manifest->name, g_private_module->manifest.name); + return false; + } return true; } @@ -1447,6 +1513,9 @@ static int load_modules_from_env(void) { if (module->manifest.can_write_identity) { g_identity_module = module; } + if (module->manifest.can_message_private) { + g_private_module = module; + } g_module_count++; } else { fprintf(stderr, "module runtime: failed to enable module at %s\n", @@ -1572,6 +1641,7 @@ int tnt_module_runtime_init(void) { pthread_mutex_lock(&g_dispatch_lock); g_module_count = 0; g_identity_module = NULL; + g_private_module = NULL; pthread_mutex_unlock(&g_dispatch_lock); pthread_mutex_unlock(&g_lifecycle_lock); return -1; @@ -1593,6 +1663,7 @@ void tnt_module_runtime_shutdown(void) { pthread_mutex_lock(&g_dispatch_lock); g_module_count = 0; g_identity_module = NULL; + g_private_module = NULL; g_next_event_id = 0; pthread_mutex_unlock(&g_dispatch_lock); pthread_mutex_unlock(&g_lifecycle_lock); @@ -1647,6 +1718,64 @@ bool tnt_module_runtime_request_identity(const char *session, return pushed == MODULE_QUEUE_PUSHED; } +int tnt_module_runtime_copy_remote_users(char (*out)[MAX_USERNAME_LEN], + int capacity) { + int count; + + if (!out || capacity <= 0) { + return 0; + } + pthread_mutex_lock(&g_remote_lock); + count = g_remote_count < capacity ? g_remote_count : capacity; + memcpy(out, g_remote, (size_t)count * sizeof(g_remote[0])); + pthread_mutex_unlock(&g_remote_lock); + return count; +} + +int tnt_module_runtime_remote_user_count(void) { + int count; + + pthread_mutex_lock(&g_remote_lock); + count = g_remote_count; + pthread_mutex_unlock(&g_remote_lock); + return count; +} + +bool tnt_module_runtime_send_private(const char *from, const char *to, + const char *plain_text, bool verified) { + module_event_t event = { + .kind = MODULE_EVENT_PRIVATE_CREATED, + .verified = verified, + }; + module_queue_push_result_t pushed = MODULE_QUEUE_DROPPED; + + if (!from || !to || !plain_text) { + return false; + } + pthread_mutex_lock(&g_remote_lock); + for (int i = 0; i < g_remote_count; i++) { + if (strcasecmp(g_remote[i], to) == 0) { + memcpy(event.target, g_remote[i], sizeof(event.target)); + break; + } + } + pthread_mutex_unlock(&g_remote_lock); + if (event.target[0] == '\0') { + return false; + } + event.msg.timestamp = time(NULL); + snprintf(event.msg.username, sizeof(event.msg.username), "%s", from); + snprintf(event.msg.content, sizeof(event.msg.content), "%s", plain_text); + + pthread_mutex_lock(&g_dispatch_lock); + if (g_accepting && g_private_module && + !atomic_load_explicit(&g_stop_requested, memory_order_acquire)) { + pushed = module_queue_push(g_private_module, &event); + } + pthread_mutex_unlock(&g_dispatch_lock); + return pushed == MODULE_QUEUE_PUSHED; +} + #ifdef TNT_TESTING /* Deterministic pipe-level coverage for the timeout and shutdown-cancellation * paths. Production callers only reach this writer through module_process. */ diff --git a/src/tui.c b/src/tui.c index 8963ed2..5d16f8f 100644 --- a/src/tui.c +++ b/src/tui.c @@ -9,6 +9,7 @@ #include "editor.h" #include "i18n.h" #include "message_log.h" +#include "module_runtime.h" #include "system_message.h" #include "theme.h" #include "tui_status.h" @@ -281,7 +282,8 @@ void tui_render_screen(client_t *client) { buffer[0] = '\0'; /* First pass: compute indices and counts. */ - int online = room_get_client_count(g_room); + int online = room_get_client_count(g_room) + + tnt_module_runtime_remote_user_count(); int msg_count = room_get_message_count(g_room); int raw_msg_count = msg_count; @@ -320,7 +322,8 @@ void tui_render_screen(client_t *client) { raw_msg_count = room_copy_messages( g_room, 0, visible_messages, MAX_MESSAGES); - online = room_get_client_count(g_room); + online = room_get_client_count(g_room) + + tnt_module_runtime_remote_user_count(); for (int i = 0; i < raw_msg_count; i++) { if (!system_message_is_join_leave(&visible_messages[i])) { visible_messages[visible_count++] = visible_messages[i]; diff --git a/tests/run_suites.sh b/tests/run_suites.sh index e192724..bb5e148 100755 --- a/tests/run_suites.sh +++ b/tests/run_suites.sh @@ -22,7 +22,8 @@ cursor_editing 10 vim_keymap 12 wide_text_view 9 module_privileged 16 -identity 17" +identity 17 +module_private 18" WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-suites.XXXXXX") trap 'rm -rf "$WORK_DIR"' EXIT diff --git a/tests/test_identity.sh b/tests/test_identity.sh index 867405f..1631de7 100755 --- a/tests/test_identity.sh +++ b/tests/test_identity.sh @@ -137,7 +137,7 @@ TNT_MODULE_PATHS="$MODULE_DIR" export TNT_MODULE_PATHS tnt_start_server "$PORT" "$STATE_DIR" -if grep -q "ident requests message:post, presence:read or identity:write" \ +if grep -q "ident requests message:post, presence:read, identity:write or message:private" \ "$STATE_DIR/server.log"; then pass "an ungranted identity:write module is refused" else diff --git a/tests/test_module_private.sh b/tests/test_module_private.sh new file mode 100755 index 0000000..17f8d9c --- /dev/null +++ b/tests/test_module_private.sh @@ -0,0 +1,226 @@ +#!/bin/sh +# message:private: remote users in :users and completion, private messages +# between SSH sessions and a module, and clearing remote users when the +# module is disabled. + +. ./lib.sh + +PORT=${PORT:-12370} +trap tnt_cleanup EXIT + +tnt_skip_without_expect "private module test" +tnt_require_binary +tnt_state_dir module-private-test + +echo "=== TNT Private Message Module Tests ===" + +RELAY_DIR="$STATE_DIR/relay" +SECOND_DIR="$STATE_DIR/relay2" +LOG="$STATE_DIR/messages.log" +TNT_RELAY_EVENTS="$STATE_DIR/relay.events" +TNT_RELAY_CTL="$STATE_DIR/relay.ctl" +export TNT_RELAY_EVENTS TNT_RELAY_CTL + +mkdir -p "$RELAY_DIR" "$SECOND_DIR" +cat >"$RELAY_DIR/tnt-module.json" <<'JSON' +{ + "protocol": "tnt.module.v1", + "name": "relay", + "entrypoint": "./relay.sh", + "permissions": ["message:read", "message:create", "message:private"], + "events": ["message.created"] +} +JSON +# Every line appended to the control file becomes a module record. +cat >"$RELAY_DIR/relay.sh" <<'SH' +#!/bin/sh +while IFS= read -r line; do + printf '%s\n' "$line" >>"$TNT_RELAY_EVENTS" + case "$line" in + *'"type":"handshake"'*) + printf '{"type":"handshake.ok","protocol":"tnt.module.v1"}\n' + tail -n +1 -f "$TNT_RELAY_CTL" & ;; + *'"type":"message.created"'*) + printf '{"type":"event.ok"}\n' ;; + esac +done +SH +chmod +x "$RELAY_DIR/relay.sh" +sed 's/"name": "relay"/"name": "relay2"/' "$RELAY_DIR/tnt-module.json" \ + >"$SECOND_DIR/tnt-module.json" +cp "$RELAY_DIR/relay.sh" "$SECOND_DIR/relay.sh" + +ctl() { printf '%s\n' "$1" >>"$TNT_RELAY_CTL"; } +invalid_count() { grep -c "ignored invalid response from relay" "$STATE_DIR/server.log"; } + +ctl '{"type":"presence.remote","nicknames":["webby","Zed"]}' +printf 'member\n' >"$STATE_DIR/identities" + +TNT_MODULE_PATHS="$RELAY_DIR:$SECOND_DIR" +TNT_MODULE_GRANTS="relay:relay2" +export TNT_MODULE_PATHS TNT_MODULE_GRANTS +tnt_start_server "$PORT" "$STATE_DIR" + +if grep -q "relay2 requests message:private, which relay already holds" \ + "$STATE_DIR/server.log" && + grep -q "enabled relay$" "$STATE_DIR/server.log" && + ! grep -q "enabled relay2" "$STATE_DIR/server.log"; then + pass "only the first message:private module is enabled" +else + fail "a second message:private module was enabled" + sed -n '1,40p' "$STATE_DIR/server.log" +fi + +cat >"$STATE_DIR/carol.expect" <>"$TNT_RELAY_CTL"} +want "ping from app" +send -- "q" +want "NORMAL" +send -- ":" +send -- "reply pong\r" +want "Private message sent to webby" +send -- "q" +want "NORMAL" +send -- ":" +send -- "msg nobody hi\r" +want "User 'nobody' not found" +EOF +if expect -f "$STATE_DIR/carol.expect" >"$STATE_DIR/carol.session.log" 2>&1; then + pass "remote users are listed, completed, and reachable with :msg and :reply" +else + fail "remote user session" + tnt_dump_session carol +fi + +if tnt_wait_for_fixed "$TNT_RELAY_EVENTS" \ + '{"type":"private.created","from":"carol","to":"Zed","plain_text":"hello app","timestamp":"' && + grep -F '"plain_text":"hello app"' "$TNT_RELAY_EVENTS" | + grep -Eq '"timestamp":"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z","verified":false}$'; then + pass ":msg to a remote user sends private.created" +else + fail "private.created for :msg is missing or malformed" + cat "$TNT_RELAY_EVENTS" +fi + +if tnt_wait_for_fixed "$TNT_RELAY_EVENTS" \ + '{"type":"private.created","from":"carol","to":"webby","plain_text":"pong"'; then + pass ":reply to a private.post sender goes back through private.created" +else + fail "the :reply did not reach the module" + cat "$TNT_RELAY_EVENTS" +fi + +if ! grep -qE "hello app|ping from app|pong" "$LOG" 2>/dev/null && + ! grep -q '"type":"message.created".*\(hello app\|ping from app\|pong\)' \ + "$TNT_RELAY_EVENTS"; then + pass "private messages are neither logged nor published" +else + fail "a private message leaked" + cat "$LOG" "$TNT_RELAY_EVENTS" +fi + +BEFORE=$(invalid_count) +ctl '{"type":"private.post","from":"webby","to":"nobody","plain_text":"lost"}' +ctl '{"type":"presence.remote","nicknames":["webby","bad|name"]}' +ctl '{"type":"private.post","from":"system","to":"carol","plain_text":"forged"}' +ctl '{"type":"presence.remote","nicknames":["webby","Zed"]}' +ctl '{"type":"private.post","from":"member","to":"carol","plain_text":"forged"}' +ctl '{"type":"presence.remote","nicknames":["webby","Zed","yuki"]}' +if tnt_wait_for_count "$STATE_DIR/server.log" \ + "ignored invalid response from relay" $((BEFORE + 3)) && + [ "$(invalid_count)" -eq $((BEFORE + 3)) ] && + ! grep -q "disabling relay" "$STATE_DIR/server.log"; then + pass "invalid records and reserved senders are protocol failures; an unknown target is ignored" +else + fail "unexpected invalid-record handling" + sed -n '1,80p' "$STATE_DIR/server.log" +fi + +if tnt_expect_session "dave" ' +send -- "/users\r" +expect { + "yuki" {} + timeout { exit 1 } +} +'; then + pass "a later presence.remote replaces the remote set" +else + fail "the remote set was not replaced" + tnt_dump_session dave +fi + +ctl 'not json' +ctl 'not json' +ctl 'not json' +if tnt_wait_for "$STATE_DIR/server.log" "disabling relay after invalid responses"; then + pass "the module is disabled after three invalid records" +else + fail "the module was not disabled" + sed -n '1,80p' "$STATE_DIR/server.log" +fi + +if tnt_expect_session "erin" ' +send -- "/users\r" +expect { + "Online users" {} + timeout { exit 1 } +} +expect { + -timeout 1 + "· app" { exit 1 } + timeout {} +} +send -- "q" +expect -exact $TNT_INPUT_PROMPT +send -- "/msg webby hi\r" +expect { + "User '"'"'webby'"'"' not found" {} + timeout { exit 1 } +} +'; then + pass "a disabled module's remote users disappear" +else + fail "remote users outlived the module" + tnt_dump_session erin +fi + +tnt_check_server_alive "server survived the private module session" +tnt_summary diff --git a/tests/test_module_privileged.sh b/tests/test_module_privileged.sh index 0bd9d09..104a01f 100755 --- a/tests/test_module_privileged.sh +++ b/tests/test_module_privileged.sh @@ -76,7 +76,7 @@ TNT_MODULE_PATHS="$POSTER_DIR:$ECHO_DIR" export TNT_MODULE_PATHS tnt_start_server "$PORT" "$STATE_DIR" -if grep -q "poster requests message:post, presence:read or identity:write" "$STATE_DIR/server.log" && +if grep -q "poster requests message:post, presence:read, identity:write or message:private" "$STATE_DIR/server.log" && ! grep -q "enabled poster" "$STATE_DIR/server.log"; then pass "an ungranted module requesting message:post is refused" else diff --git a/tests/unit/test_json_text.c b/tests/unit/test_json_text.c index 6e2f49e..d2f18ba 100644 --- a/tests/unit/test_json_text.c +++ b/tests/unit/test_json_text.c @@ -79,6 +79,45 @@ TEST(get_string_field_rejects_output_overflow) { "plain_text", value, sizeof(value))); } +TEST(get_string_array_field_extracts_strings) { + char items[3][8]; + size_t count = 99; + + assert(tnt_json_get_string_array_field( + "{\"type\":\"x\",\"names\": [ \"a\" , \"b\\u00e9\" ]}", "names", + &items[0][0], sizeof(items[0]), 3, &count)); + assert(count == 2); + assert(strcmp(items[0], "a") == 0); + assert(strcmp(items[1], "b\xc3\xa9") == 0); + + assert(tnt_json_get_string_array_field("{\"names\":[]}", "names", + &items[0][0], sizeof(items[0]), 3, + &count)); + assert(count == 0); +} + +TEST(get_string_array_field_rejects_bad_arrays) { + static const char *const lines[] = { + "{\"names\":\"a\"}", + "{\"names\":[1]}", + "{\"names\":[\"a\",]}", + "{\"names\":[\"a\" \"b\"]}", + "{\"names\":[\"a\"", + "{\"names\":[\"a\",\"b\",\"c\",\"d\"]}", + "{\"names\":[\"abcdefgh\"]}", + "{\"other\":[\"a\"]}", + }; + char items[3][8]; + size_t count; + + for (size_t i = 0; i < sizeof(lines) / sizeof(lines[0]); i++) { + assert(!tnt_json_get_string_array_field(lines[i], "names", + &items[0][0], + sizeof(items[0]), 3, &count)); + assert(count == 0); + } +} + int main(void) { printf("Running JSON text unit tests...\n\n"); @@ -89,6 +128,8 @@ int main(void) { RUN_TEST(get_string_field_decodes_unicode_escape); RUN_TEST(get_string_field_rejects_missing_and_malformed_values); RUN_TEST(get_string_field_rejects_output_overflow); + RUN_TEST(get_string_array_field_extracts_strings); + RUN_TEST(get_string_array_field_rejects_bad_arrays); printf("\nAll %d JSON text tests passed.\n", tests_passed); return 0; diff --git a/tests/unit/test_module_protocol.c b/tests/unit/test_module_protocol.c index 323a23a..7eec655 100644 --- a/tests/unit/test_module_protocol.c +++ b/tests/unit/test_module_protocol.c @@ -272,6 +272,113 @@ TEST(presence_snapshot_reports_overflow) { names, 2) < 0); } +TEST(appends_private_created_jsonl) { + char out[512] = ""; + size_t pos = 0; + + assert(tnt_module_append_private_created(out, sizeof(out), &pos, "bob", + "alice", "hi \"there\"\nok", 0, + true) == 0); + assert(strcmp(out, + "{\"type\":\"private.created\",\"from\":\"bob\"," + "\"to\":\"alice\",\"plain_text\":\"hi \\\"there\\\"\\nok\"," + "\"timestamp\":\"1970-01-01T00:00:00Z\"," + "\"verified\":true}\n") == 0); + + out[0] = '\0'; + pos = 0; + assert(tnt_module_append_private_created(out, sizeof(out), &pos, "bob", + "alice", "x", 0, false) == 0); + assert(strstr(out, ",\"verified\":false}\n") != NULL); + + pos = 0; + assert(tnt_module_append_private_created(out, 16, &pos, "bob", "alice", + "x", 0, false) < 0); +} + +TEST(parse_presence_remote_accepts_names) { + static tnt_module_presence_remote_t remote; + + assert(tnt_module_parse_presence_remote( + "{\"type\":\"presence.remote\",\"nicknames\":[\"alice\",\"Bob\"," + "\"bob\",\"\u5c0f\u660e\"]}", &remote)); + assert(remote.count == 3); + assert(strcmp(remote.nicknames[0], "alice") == 0); + assert(strcmp(remote.nicknames[1], "Bob") == 0); + assert(strcmp(remote.nicknames[2], "\xe5\xb0\x8f\xe6\x98\x8e") == 0); + + assert(tnt_module_parse_presence_remote( + "{\"type\":\"presence.remote\",\"nicknames\":[]}", &remote)); + assert(remote.count == 0); +} + +TEST(parse_presence_remote_takes_256_names) { + static tnt_module_presence_remote_t remote; + char line[4096]; + size_t pos = 0; + + buffer_appendf(line, sizeof(line), &pos, + "{\"type\":\"presence.remote\",\"nicknames\":["); + for (int i = 0; i < TNT_MODULE_REMOTE_USERS_MAX; i++) { + buffer_appendf(line, sizeof(line), &pos, "%s\"%x\"", i ? "," : "", i); + } + buffer_appendf(line, sizeof(line), &pos, "]}"); + assert(tnt_module_parse_presence_remote(line, &remote)); + assert(remote.count == TNT_MODULE_REMOTE_USERS_MAX); + + pos -= 2; + buffer_appendf(line, sizeof(line), &pos, ",\"zz\"]}"); + assert(!tnt_module_parse_presence_remote(line, &remote)); + assert(remote.count == 0); +} + +TEST(parse_presence_remote_rejects_invalid) { + static const char *const lines[] = { + "{\"type\":\"presence.remote\"}", + "{\"type\":\"presence.remote\",\"nicknames\":\"alice\"}", + "{\"type\":\"presence.remote\",\"nicknames\":[\"alice\",1]}", + "{\"type\":\"presence.remote\",\"nicknames\":[\"\"]}", + "{\"type\":\"presence.remote\",\"nicknames\":[\"system\"]}", + "{\"type\":\"presence.remote\",\"nicknames\":[\"a:b\"]}", + "{\"type\":\"presence.remote\",\"nicknames\":[\"a|b\"]}", + "{\"type\":\"presence.remote\"," + "\"nicknames\":[\"abcdefghijklmnopqrstu\"]}", + "{\"type\":\"presence.snapshot\",\"nicknames\":[\"alice\"]}", + }; + static tnt_module_presence_remote_t remote; + + for (size_t i = 0; i < sizeof(lines) / sizeof(lines[0]); i++) { + assert(!tnt_module_parse_presence_remote(lines[i], &remote)); + } +} + +TEST(parse_private_post) { + tnt_module_private_post_t post; + + assert(tnt_module_parse_private_post( + "{\"type\":\"private.post\",\"from\":\"alice\",\"to\":\"Bob\"," + "\"plain_text\":\"hi\\nthere\"}", &post)); + assert(strcmp(post.from, "alice") == 0); + assert(strcmp(post.to, "Bob") == 0); + assert(strcmp(post.plain_text, "hi\nthere") == 0); + + assert(!tnt_module_parse_private_post( + "{\"type\":\"private.post\",\"from\":\"system\",\"to\":\"bob\"," + "\"plain_text\":\"hi\"}", &post)); + assert(!tnt_module_parse_private_post( + "{\"type\":\"private.post\",\"from\":\"alice\"," + "\"plain_text\":\"hi\"}", &post)); + assert(!tnt_module_parse_private_post( + "{\"type\":\"private.post\",\"from\":\"alice\",\"to\":\"bob\"," + "\"plain_text\":\"\"}", &post)); + assert(!tnt_module_parse_private_post( + "{\"type\":\"private.post\",\"from\":\"alice\",\"to\":\"bob\"," + "\"plain_text\":\"\\u001b[2J\"}", &post)); + assert(!tnt_module_parse_private_post( + "{\"type\":\"message.post\",\"from\":\"alice\",\"to\":\"bob\"," + "\"plain_text\":\"hi\"}", &post)); +} + int main(void) { printf("Running module protocol unit tests...\n\n"); @@ -294,6 +401,11 @@ int main(void) { RUN_TEST(appends_presence_jsonl); RUN_TEST(appends_presence_snapshot_jsonl); RUN_TEST(presence_snapshot_reports_overflow); + RUN_TEST(appends_private_created_jsonl); + RUN_TEST(parse_presence_remote_accepts_names); + RUN_TEST(parse_presence_remote_takes_256_names); + RUN_TEST(parse_presence_remote_rejects_invalid); + RUN_TEST(parse_private_post); printf("\nAll %d module protocol tests passed.\n", tests_passed); return 0; diff --git a/tests/unit/test_module_runtime.c b/tests/unit/test_module_runtime.c index ecbaf80..b2bfe51 100644 --- a/tests/unit/test_module_runtime.c +++ b/tests/unit/test_module_runtime.c @@ -59,6 +59,13 @@ bool input_identity_verified(const char *session, const char *handle) { return false; } +void commands_deliver_private(const char *from, const char *to, + const char *plain_text) { + (void)from; + (void)to; + (void)plain_text; +} + static void cleanup_module_dir(void) { if (module_dir[0] == '\0') return; @@ -202,11 +209,12 @@ TEST(loads_granted_permissions) { "{\"protocol\":\"tnt.module.v1\",\"name\":\"tnt-gateway\"," "\"entrypoint\":\"./tnt-gateway\"," "\"permissions\":[\"message:read\",\"message:create\"," - "\"message:post\",\"presence:read\"]," + "\"message:post\",\"presence:read\",\"message:private\"]," "\"events\":[\"message.created\"]}"); assert(tnt_module_manifest_load(module_dir, &manifest) == 0); assert(manifest.can_post_messages); assert(manifest.can_read_presence); + assert(manifest.can_message_private); write_manifest( "{\"protocol\":\"tnt.module.v1\",\"name\":\"echo\"," @@ -216,6 +224,7 @@ TEST(loads_granted_permissions) { assert(tnt_module_manifest_load(module_dir, &manifest) == 0); assert(!manifest.can_post_messages); assert(!manifest.can_read_presence); + assert(!manifest.can_message_private); cleanup_module_dir(); } diff --git a/tnt-chat.7 b/tnt-chat.7 index 7d33af0..98de2d8 100644 --- a/tnt-chat.7 +++ b/tnt-chat.7 @@ -1,4 +1,4 @@ -.TH TNT-CHAT 7 "2026-10-14" "TNT 1.5.0" +.TH TNT-CHAT 7 "2026-10-14" "TNT 1.6.0" .SH NAME tnt-chat \- use the TNT interactive terminal interface .SH DESCRIPTION @@ -213,7 +213,7 @@ Delete the input line. Move through this session's last 16 sent messages. .TP .B Tab -Complete an online nickname in the trailing +Complete an online nickname, including an app user's, in the trailing .B @ token. .TP @@ -300,7 +300,9 @@ For .BR theme , and .BR lang , -it also completes the first argument. +it also completes the first argument; for +.B msg +that includes app users. .SH MESSAGES A public message must be valid UTF-8 and may contain at most 1023 bytes as stored, where a line break costs two. @@ -364,12 +366,18 @@ The following aliases have identical behavior. .TP .B :users, :list, :who List active nicknames and connection durations. +Users connected through an app follow, marked +.BR app . +The online count in the status bar includes them. .TP .BI ":msg " "user message" .TQ .BI ":w " "user message" -Send a private message to an active user. +Send a private message to an active user, including one connected through +an app. Both the sender and recipient receive a session-local inbox copy. +When an SSH user and an app user share a name, the SSH user gets the +message. .TP .BI ":reply " message .TQ diff --git a/tnt-exec.7 b/tnt-exec.7 index 6d7fc75..e8f788a 100644 --- a/tnt-exec.7 +++ b/tnt-exec.7 @@ -1,4 +1,4 @@ -.TH TNT-EXEC 7 "2026-10-14" "TNT 1.5.0" +.TH TNT-EXEC 7 "2026-10-14" "TNT 1.6.0" .SH NAME tnt-exec \- TNT SSH exec command and output protocol .SH DESCRIPTION diff --git a/tnt-message-log.5 b/tnt-message-log.5 index d555a2e..0c5b780 100644 --- a/tnt-message-log.5 +++ b/tnt-message-log.5 @@ -1,4 +1,4 @@ -.TH TNT-MESSAGE-LOG 5 "2026-10-14" "TNT 1.5.0" +.TH TNT-MESSAGE-LOG 5 "2026-10-14" "TNT 1.6.0" .SH NAME tnt-message-log \- TNT public message log format .SH DESCRIPTION diff --git a/tnt-module-protocol.7 b/tnt-module-protocol.7 index 2800d71..af3561c 100644 --- a/tnt-module-protocol.7 +++ b/tnt-module-protocol.7 @@ -1,4 +1,4 @@ -.TH TNT-MODULE-PROTOCOL 7 "2026-10-14" "TNT 1.5.0" +.TH TNT-MODULE-PROTOCOL 7 "2026-10-14" "TNT 1.6.0" .SH NAME tnt-module-protocol \- implement an external TNT message module .SH DESCRIPTION @@ -10,7 +10,8 @@ The supported protocol identifier is Modules receive selected public message events and may create public plain-text messages. Modules the operator grants may also post as a chat user, observe -presence, and sign sessions in. +presence, sign sessions in, and carry private messages to and from users +connected elsewhere. The core chat path continues when an individual module is invalid, slow, stopped, or saturated. .SH ACTIVATION @@ -30,16 +31,19 @@ No command-line arguments are supplied. A module whose manifest requests .BR message:post , .BR presence:read , +.BR identity:write , or -.B identity:write +.B message:private is enabled only when its name appears in .BR TNT_MODULE_GRANTS , a colon-separated list of module names. Otherwise TNT reports the refusal on standard error and skips the module. At most one enabled module holds -.BR identity:write ; -TNT skips every later module that requests it and says so on standard -error. +.BR identity:write , +and at most one holds +.BR message:private ; +TNT skips every later module that requests either one and says so on +standard error. .SH MANIFEST The manifest is a JSON object of at most 4094 bytes. The following fields are required: @@ -71,8 +75,9 @@ and It may also contain .BR message:post , .BR presence:read , +.BR identity:write , and -.BR identity:write ; +.BR message:private ; see .BR "GRANTED PERMISSIONS" . .TP @@ -221,7 +226,15 @@ messages, and messages another module posts with .B message.post generate events. Private messages, room lifecycle notices, and module-created messages -produce no events. +produce no +.B message.created +events. +A private message to a remote user reaches the module holding +.B message:private +as a +.B private.created +record; see +.BR message:private . Module-created messages are not fed back into the module set, and a posted message is never sent back to the module that posted it. .SH RESPONSES @@ -343,8 +356,12 @@ for the new one: Presence records are notifications and must not be answered. They open no response window. For a module holding any granted permission, any record other than a valid -.B message.post -or identity record that arrives while no +.BR message.post , +identity, +.BR presence.remote , +or +.B private.post +record that arrives while no .B message.created event is waiting is a protocol failure. Around startup a joined record can name a user the snapshot already listed, @@ -432,6 +449,110 @@ without binding a key, for a member who signed in elsewhere. .P An identity record with a missing or invalid member, or an invalid handle, is a protocol failure like any other invalid response. +.SS message:private +The module holding +.B message:private +reports the users who are online through it, such as people using an app, +and carries private messages between them and SSH users. +TNT calls them remote users. +.P +The module may write these records at any time, as with +.BR message.post : +.TP +.B presence.remote +.EX +{"type":"presence.remote","nicknames":["alice","bob"]} +.EE +.IP +.B nicknames +is the complete set of remote users and replaces the previous one; an +empty array clears it. +Each name must be a valid interactive nickname, as for the +.B message.post +sender. +A record lists at most 256 names and must still fit the 4094-byte record +limit. +A name repeated without regard to ASCII letter case is kept once. +.IP +SSH users see remote users after the SSH sessions in +.BR :users , +marked +.BR app , +and in the online count of the status bar, which refreshes with the next +screen update. +Remote users complete like online users after +.B :msg +and +.BR @ . +TNT forgets every remote user when the module stops or is disabled. +.TP +.B private.post +.EX +{"type":"private.post","from":"alice","to":"bob",\c +"plain_text":"hi"} +.EE +.IP +Deliver a private message from +.B from +to every interactive session named +.BR to , +matched without regard to ASCII letter case. +Each session receives it as it would a private message from an SSH user: +an unread inbox entry, a bell, and +.B from +as its +.B :reply +target. +.B from +and +.B to +must be valid interactive nicknames, and +.B plain_text +follows the +.B message.create +rules. +Unless the module also holds +.BR identity:write , +.B from +must not be a reserved handle. +TNT ignores a +.B to +that no session uses. +.P +When an SSH user sends a private message with +.BR :msg , +.BR :w , +or +.B :reply +to a name that no SSH session uses but a remote user does, TNT sends the +module one notification: +.P +.EX +{"type":"private.created","from":"carol","to":"alice",\c +"plain_text":"hi","timestamp":"2026-06-04T12:00:00Z","verified":false} +.EE +.P +.B to +is the remote user's name as the module reported it. +.B verified +is +.B true +when the sender is a signed-in session. +Like presence records, this notification opens no response window and must +not be answered. +When an SSH session and a remote user share a name, the SSH session gets +the message. +When the module cannot take the notification, for example because its +queue is full, the sender is told that the user was not found. +.P +An invalid +.B presence.remote +or +.B private.post +record is a protocol failure. +Private messages are never logged and never produce +.B message.created +events. .SH FAILURE HANDLING A handshake failure skips the affected module. After activation, a write failure, write timeout, read failure, response @@ -479,6 +600,11 @@ Maximum nicknames in one .B presence.snapshot record. .TP +.B 256 nicknames +Maximum nicknames in one +.B presence.remote +record. +.TP .B 64 files Open file limit for a module process. A granted module may open 4096. @@ -512,7 +638,11 @@ except a reserved handle. A module holding .B identity:write can post under, and sign any session in as, any handle. -Grant either only to a module you trust as much as the server. +A module holding +.B message:private +reads every private message SSH users send to remote users and can send +private messages under any nickname that is not a reserved handle. +Grant any of them only to a module you trust as much as the server. .P The child inherits most of the server environment. TNT removes diff --git a/tnt.8 b/tnt.8 index fc4364f..636db51 100644 --- a/tnt.8 +++ b/tnt.8 @@ -1,4 +1,4 @@ -.TH TNT 8 "2026-10-14" "TNT 1.5.0" +.TH TNT 8 "2026-10-14" "TNT 1.6.0" .SH NAME tnt \- run the TNT SSH chat server .SH SYNOPSIS @@ -274,8 +274,9 @@ See Colon-separated module names allowed to use the .BR message:post , .BR presence:read , +.BR identity:write , and -.B identity:write +.B message:private permissions. A granted module can post under any valid nickname. See diff --git a/tntctl.1 b/tntctl.1 index 72e0d81..7eae595 100644 --- a/tntctl.1 +++ b/tntctl.1 @@ -1,4 +1,4 @@ -.TH TNTCTL 1 "2026-10-14" "TNT 1.5.0" +.TH TNTCTL 1 "2026-10-14" "TNT 1.6.0" .SH NAME tntctl \- run TNT exec commands through SSH .SH SYNOPSIS