Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions include/commands.h
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
2 changes: 1 addition & 1 deletion include/common.h
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
1 change: 1 addition & 0 deletions include/i18n.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions include/json_text.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
27 changes: 27 additions & 0 deletions include/module_protocol.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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 */
13 changes: 13 additions & 0 deletions include/module_runtime.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packaging/arch/.SRCINFO
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packaging/arch/PKGBUILD
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Maintainer: M1ng <contact@m1ng.space>

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')
Expand Down
9 changes: 9 additions & 0 deletions packaging/debian/debian/changelog
Original file line number Diff line number Diff line change
@@ -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 <contact@m1ng.space> 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
Expand Down
2 changes: 1 addition & 1 deletion packaging/homebrew/tnt-chat.rb
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
49 changes: 48 additions & 1 deletion src/commands.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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];
Expand All @@ -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) {
Expand All @@ -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];
Expand Down Expand Up @@ -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++) {
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/i18n_text.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
53 changes: 53 additions & 0 deletions src/input.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 "@<prefix>" with "@<match> " (trailing
* space so the next word starts cleanly). */
Expand Down
Loading
Loading