From f0520f4c428bd0f2d6ca3a2130a089b1616a2d21 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 14 Oct 2026 19:58:07 +0800 Subject: [PATCH 1/2] feat(module): let a message:private module carry private messages for app users A module granted message:private reports the users online through it with presence.remote. SSH users see them in :users, marked app, in the status bar's online count, and in nickname completion. :msg, :w and :reply to a remote name reach the module as private.created, and private.post delivers a remote user's private message to SSH sessions like one from another SSH user. At most one module holds the permission, and its remote users are cleared when it stops or is disabled. --- include/commands.h | 5 + include/i18n.h | 1 + include/json_text.h | 8 ++ include/module_protocol.h | 27 ++++ include/module_runtime.h | 13 ++ src/commands.c | 49 ++++++- src/i18n_text.c | 4 + src/input.c | 53 +++++++ src/json_text.c | 102 +++++++++++++- src/module_protocol.c | 104 +++++++++++++- src/module_runtime.c | 145 +++++++++++++++++-- src/tui.c | 7 +- tests/run_suites.sh | 3 +- tests/test_identity.sh | 2 +- tests/test_module_private.sh | 226 ++++++++++++++++++++++++++++++ tests/test_module_privileged.sh | 2 +- tests/unit/test_json_text.c | 41 ++++++ tests/unit/test_module_protocol.c | 112 +++++++++++++++ tests/unit/test_module_runtime.c | 11 +- tnt-chat.7 | 14 +- tnt-module-protocol.7 | 150 ++++++++++++++++++-- tnt.8 | 3 +- 22 files changed, 1042 insertions(+), 40 deletions(-) create mode 100755 tests/test_module_private.sh 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/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/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..c999666 100644 --- a/tnt-chat.7 +++ b/tnt-chat.7 @@ -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-module-protocol.7 b/tnt-module-protocol.7 index 2800d71..6aba86e 100644 --- a/tnt-module-protocol.7 +++ b/tnt-module-protocol.7 @@ -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..2077991 100644 --- a/tnt.8 +++ b/tnt.8 @@ -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 From 8c7e964a537c036a1c4b9f815b2061ba2b99d863 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 14 Oct 2026 19:58:07 +0800 Subject: [PATCH 2/2] chore(release): prepare v1.6.0 --- include/common.h | 2 +- packaging/arch/.SRCINFO | 4 ++-- packaging/arch/PKGBUILD | 2 +- packaging/debian/debian/changelog | 9 +++++++++ packaging/homebrew/tnt-chat.rb | 2 +- tnt-chat.7 | 2 +- tnt-exec.7 | 2 +- tnt-message-log.5 | 2 +- tnt-module-protocol.7 | 2 +- tnt.8 | 2 +- tntctl.1 | 2 +- 11 files changed, 20 insertions(+), 11 deletions(-) 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/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/tnt-chat.7 b/tnt-chat.7 index c999666..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 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 6aba86e..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 diff --git a/tnt.8 b/tnt.8 index 2077991..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 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