Compare commits

...

10 commits

Author SHA1 Message Date
1284d5d052
Merge pull request #54 from m1ngsama/feat/module-foundation
Some checks failed
CI / PR gate (macos-latest) (push) Has been cancelled
CI / PR gate (ubuntu-24.04) (push) Has been cancelled
CI / Extended Linux runtime (push) Has been cancelled
CI / Portable build (alpine-musl) (push) Has been cancelled
CI / Portable build (debian-stable-glibc) (push) Has been cancelled
CI / Portable build (ubuntu-24.04-glibc) (push) Has been cancelled
CI / Package recipe gate (push) Has been cancelled
Add module foundation and optional runtime
2026-06-04 23:02:09 +08:00
2402c70d6f feat: add module foundation runtime
Add validated input buffering, shared JSON helpers, the tnt.module.v1 protocol helpers, and an opt-in external-process module runtime behind TNT_MODULE_PATHS.

Closes #52
2026-06-04 22:48:21 +08:00
2fcfcad613
Merge pull request #53 from m1ngsama/feat/private-message-flow
Improve private message inbox and reply flow
2026-06-04 22:47:20 +08:00
bacfe1ef4b Show empty inbox after clearing 2026-05-31 20:04:22 +08:00
7ff9474a5d Relax private inbox unread count test 2026-05-31 20:02:09 +08:00
d7531f9305 Show unread count in private inbox 2026-05-29 18:06:45 +08:00
845657e3c2 Allow clearing private message inbox 2026-05-29 18:04:34 +08:00
2fca031362 Mark unread private messages in inbox 2026-05-29 18:01:05 +08:00
1f8fb7acf4 Add private message reply command 2026-05-29 17:40:09 +08:00
5ae02054ee Improve terminal UX and private message flow 2026-05-29 17:05:22 +08:00
46 changed files with 3093 additions and 229 deletions

View file

@ -35,7 +35,7 @@ MANDIR ?= $(PREFIX)/share/man
SYSTEMD_UNIT_DIR ?= $(PREFIX)/lib/systemd/system
CI_TEST_PORT ?= $(if $(PORT),$(PORT),2222)
.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict package-publish-check debian-source-package asan valgrind check test test-advisory ci-test unit-test script-test integration-test anonymous-access-test connection-limit-test security-test stress-test soak-test slow-client-test user-lifecycle-test info
.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict package-publish-check debian-source-package asan valgrind check test test-advisory ci-test unit-test script-test integration-test module-runtime-test anonymous-access-test connection-limit-test security-test stress-test soak-test slow-client-test user-lifecycle-test info
all: $(TARGETS)
@ -123,6 +123,7 @@ test-advisory: all unit-test
@cd tests && PORT=$${PORT:-2222} ./test_basic.sh || echo "(basic integration tests are advisory)"
@cd tests && PORT=$$(($${PORT:-2222} + 1)) ./test_exec_mode.sh || echo "(exec mode tests are advisory)"
@cd tests && PORT=$$(($${PORT:-2222} + 2)) ./test_interactive_input.sh || echo "(interactive input tests are advisory)"
@cd tests && PORT=$$(($${PORT:-2222} + 6)) ./test_module_runtime.sh || echo "(module runtime tests are advisory)"
unit-test:
@echo "Running unit tests..."
@ -143,8 +144,15 @@ integration-test: all
@cd tests && PORT=$$(($${PORT:-2222} + 1)) ./test_exec_mode.sh
@cd tests && PORT=$$(($${PORT:-2222} + 2)) ./test_interactive_input.sh
@cd tests && PORT=$$(($${PORT:-2222} + 3)) ./test_user_lifecycle.sh
@cd tests && PORT=$$(($${PORT:-2222} + 4)) ./test_mute_joins_view.sh
@cd tests && PORT=$$(($${PORT:-2222} + 5)) ./test_empty_view.sh
@cd tests && PORT=$$(($${PORT:-2222} + 6)) ./test_module_runtime.sh
@cd tests && ./test_tntctl_cli.sh
module-runtime-test: all
@echo "Running module runtime tests..."
@cd tests && PORT=$${PORT:-2222} ./test_module_runtime.sh
anonymous-access-test: all
@echo "Running anonymous access tests..."
@cd tests && PORT=$${PORT:-2222} ./test_anonymous_access.sh

View file

@ -78,7 +78,7 @@ past the limit is ignored with a terminal bell.
```
Opens at latest messages
Stays pinned to latest until you scroll up
i - Return to INSERT mode
i/a/o - Return to INSERT mode
: - Enter COMMAND mode
j/k - Scroll down/up one line
Ctrl+D/U - Scroll half page down/up
@ -96,7 +96,10 @@ Ctrl+C - Exit chat
:nick <name> - Change nickname
:msg <user> <message> - Send private message
:w <user> <text> - Short alias for :msg
:reply <text> - Reply to latest private message
:r <text> - Short alias for :reply
:inbox - Show private messages
:inbox clear - Clear private messages for this session
:last [N] - Show last N messages from history (max 50, default 10)
:search <keyword> - Search message history (shows last 15 matches)
:mute-joins - Toggle join/leave system notifications
@ -109,8 +112,14 @@ ESC - Return to NORMAL mode
```
Command output pages use `j/k`, `Ctrl+D/U`, and `g/G` for paging. `:inbox`
is live: press `r` to refresh it manually, and it refreshes when a new private
message arrives while the inbox is open.
shows incoming and sent private messages newest-first; press `r` to refresh it
manually, and it refreshes when a new private message arrives while the inbox
is open. `:reply text` and `:r text` send to the latest private-message peer.
Unread incoming private messages are marked with `*` until `:inbox` renders.
The inbox title shows a transient unread count when new private messages are
present.
`:inbox clear` removes private messages and the reply target for this session.
Private messages are per-session only and are not written to `messages.log`.
**Special messages (INSERT mode)**
```
@ -223,7 +232,8 @@ tntctl -l operator chat.example.com post "service notice"
### Log Maintenance
Persisted public history is stored as `messages.log` in the TNT state
directory. For manual maintenance, archive and compact it with:
directory. Private messages and local inbox state are intentionally excluded.
For manual maintenance, archive and compact it with:
```sh
scripts/logrotate.sh /var/lib/tnt/messages.log 100 10000
@ -329,6 +339,10 @@ TNT/
│ ├── bootstrap.c # SSH authentication and session bootstrap
│ ├── chat_room.c # chat room logic
│ ├── message.c # message persistence
│ ├── module_protocol.c # external module JSONL protocol helpers
│ ├── module_runtime.c # optional external module supervisor
│ ├── json_text.c # small JSON string helpers
│ ├── input_buffer.c # validated terminal input buffer helpers
│ ├── history_view.c # message viewport and scroll state
│ ├── help_text.c # full-screen key reference content
│ ├── manual.c # concise manual panel rendering
@ -418,7 +432,11 @@ tnt.service - systemd service unit
```
The persisted chat-history format is documented in
[docs/MESSAGE_LOG.md](docs/MESSAGE_LOG.md).
[docs/MESSAGE_LOG.md](docs/MESSAGE_LOG.md). Experimental community modules
should follow the external-process protocol in
[docs/MODULE_PROTOCOL.md](docs/MODULE_PROTOCOL.md). Module-generated content
must always include a plain-text fallback so TNT can keep working on basic
terminal clients and preserve the stable `messages.log` v1 history contract.
### MOTD (Message of the Day)
@ -440,6 +458,7 @@ Delete `motd.txt` to disable the MOTD.
- [Quick Setup](docs/EASY_SETUP.md) - 5-minute deployment guide
- [Roadmap](docs/ROADMAP.md) - Long-term Unix/GNU direction and next stages
- [Interface Contract](docs/INTERFACE.md) - Scriptable commands, exit statuses, and JSON fields
- [Module Protocol](docs/MODULE_PROTOCOL.md) - External-process module contract
- [Security Reference](docs/SECURITY_QUICKREF.md) - Security config quick reference
- [Contributing](docs/CONTRIBUTING.md) - How to contribute
- [Changelog](docs/CHANGELOG.md) - Version history

View file

@ -89,6 +89,37 @@ Recommended interpretation:
- `TNT_MAX_CONN_RATE_PER_IP`: new connection attempts allowed per IP per 60 seconds
- `TNT_RATE_LIMIT=0`: disables rate-based blocking and auth-failure IP blocking, but not the explicit capacity limits
## Edge Module Production Profile
Some deployments intentionally track the newest TNT builds and newest module
integrations to exercise the full product surface. Treat these as edge
production environments: user-facing, but optimized for fast integration and
fast rollback.
For that profile:
- Deploy TNT and modules as separate artifacts so a module can be disabled
without replacing the core server.
- Keep module permissions explicit and minimal. Do not grant private-message
access unless the module exists for that purpose.
- Keep a known-good TNT binary and module manifest set on disk for immediate
rollback.
- Log module startup failures, invalid JSONL, protocol errors, and timeouts
separately from chat history.
- Prefer plain-text fallbacks for every module-created message, even when the
module also targets richer terminal renderers.
- Before promoting a module, test its manifest and JSONL handshake against the
protocol in `docs/MODULE_PROTOCOL.md`.
Enable modules explicitly with `TNT_MODULE_PATHS`, using a colon-separated
list of module directories:
```bash
TNT_MODULE_PATHS=/opt/tnt-modules/echo-module:/opt/tnt-modules/other-module
```
Unset `TNT_MODULE_PATHS` and restart TNT to return to the plain core server.
## MOTD (Message of the Day)
Place a `motd.txt` file in the state directory. TNT displays it to each user on connect; they press any key to enter the chat.

View file

@ -80,6 +80,10 @@ src/
├── command_catalog.c - COMMAND-mode names, aliases, and help summaries
├── exec_catalog.c - SSH exec command matching and help metadata
├── exec.c - SSH exec command dispatch
├── json_text.c - JSON string escaping and top-level string extraction
├── input_buffer.c - Validated INSERT/COMMAND/paste buffer helpers
├── module_protocol.c - External module JSONL protocol helpers
├── module_runtime.c - Optional external module supervisor
├── tntctl.c - Local wrapper around the SSH exec interface
├── tntctl_text.c - tntctl local help and diagnostics
├── chat_room.c - Chat room state, message ring, and update sequence
@ -112,6 +116,10 @@ include/
├── message_log_tool.h - Offline log check/recover interface
├── command_catalog.h - COMMAND-mode command metadata interface
├── exec_catalog.h - SSH exec command metadata interface
├── json_text.h - JSON text helper interface
├── input_buffer.h - Terminal input buffer helper interface
├── module_protocol.h - External module protocol helper interface
├── module_runtime.h - External module supervisor interface
├── cli_text.h - Server CLI text interface
├── tntctl_text.h - tntctl text interface
├── history_view.h - Scroll-state helpers

View file

@ -80,7 +80,9 @@ Common commands:
:users online users
:nick <name> change nickname
:msg <user> <message> send private message
:reply <message> reply to latest private message
:inbox show private messages
:inbox clear clear private messages
:last [N] recent messages
:search <keyword> search message history
:lang en|zh switch UI language

View file

@ -157,6 +157,23 @@ posted
In anonymous-access mode, the SSH login name is not authenticated. Operators
should configure `TNT_ACCESS_TOKEN` before relying on exec-post identity.
## Interactive Private Messages
`:msg user message` and its `:w` alias deliver private messages only to online
interactive clients. `:reply message` and its `:r` alias send to the latest
private-message peer in the current session. Private messages are not
persisted to `messages.log` and are not included in exec `tail`, exec `dump`,
`:last`, or `:search`.
Each participant keeps a bounded in-memory `:inbox` for the current session.
Recipients see incoming private messages; senders see local sent-message
copies. Unread incoming messages are marked with `*` until `:inbox` renders.
`:inbox` displays newest messages first, shows a transient unread count, can
be refreshed with `r`, and refreshes automatically while open when a new
private message arrives.
`:inbox clear` removes the current session's private messages, unread count,
and reply target.
### `help`
Prints a localized human-readable command summary. It is intended for people,

View file

@ -37,7 +37,10 @@ existing append-only logs remain readable.
- `|`, `\n`, and `\r` in content become spaces.
- Timestamps are written in UTC.
Private messages are not written to `messages.log`.
Private messages are not written to `messages.log`. `:inbox` stores incoming
and sent private-message copies only in each participant's live session memory,
so inbox state is lost on disconnect and never appears in `tail`, `dump`,
`:last`, or `:search`.
## Replay And Search

143
docs/MODULE_PROTOCOL.md Normal file
View file

@ -0,0 +1,143 @@
# TNT Module Protocol
This document defines the compatibility contract for external TNT modules.
The first implementation target is external-process modules that exchange
JSON Lines with TNT over stdin/stdout. Keeping modules out of the server
address space makes the community extension surface easier to audit, restart,
rate-limit, and disable.
The protocol is intentionally separate from `messages.log` v1. TNT 1.x keeps
the persisted public history format stable. Module-generated content must
always provide a plain-text fallback that can be stored and rendered by older
or less capable clients.
TNT core should stay conservative: text-first, terminal-compatible, and easy
to deploy over plain SSH. Modules are the extension surface for personalized
workflow features, rich rendering, terminal-specific visuals, and other
experience experiments. Integrating a module with TNT must not make plain
terminal users lose the basic chat path.
## Compatibility
- Protocol version: `tnt.module.v1`
- Transport: UTF-8 JSON Lines
- Framing: one complete JSON object per line
- Direction: TNT sends events to module stdin; modules write responses to
stdout
- Error stream: modules should write diagnostics to stderr
- License: module protocol examples and official community modules should use
the same license as TNT unless a module states stricter terms
Modules are disabled unless `TNT_MODULE_PATHS` is set. The value is a
colon-separated list of module directories, each containing `tnt-module.json`
and the declared executable entrypoint.
TNT may add optional fields to existing messages. Modules must ignore unknown
fields. TNT must ignore unknown response fields unless the response type
explicitly requires them.
## Manifest
Each module directory should include `tnt-module.json`:
```json
{
"protocol": "tnt.module.v1",
"name": "echo",
"version": "0.1.0",
"description": "Echoes public messages for testing",
"entrypoint": "./echo-module.sh",
"permissions": ["message:read", "message:create"],
"events": ["message.created"]
}
```
Required fields:
- `protocol`: protocol compatibility string
- `name`: stable module id, lowercase ASCII, `a-z`, `0-9`, and `-`
- `version`: module version
- `entrypoint`: executable path relative to the manifest directory
- `permissions`: explicit capabilities requested by the module
- `events`: event names the module wants to receive
## Handshake
TNT starts a module process and writes a handshake event:
```json
{"type":"handshake","protocol":"tnt.module.v1","server":{"name":"tnt","version":"1.0.1"}}
```
The module should answer:
```json
{"type":"handshake.ok","protocol":"tnt.module.v1","module":{"name":"echo","version":"0.1.0"}}
```
If the module cannot run, it should answer:
```json
{"type":"error","code":"unsupported_protocol","message":"requires tnt.module.v2"}
```
## Events
Message-created event:
```json
{
"type": "message.created",
"message": {
"id": "local-00000001",
"timestamp": "2026-06-04T12:00:00Z",
"sender": "alice",
"kind": "text",
"plain_text": "hello",
"metadata": {}
}
}
```
The `plain_text` field is mandatory for every user-visible message. Future
rich content, images, and terminal-specific render hints must be represented
as optional metadata or attachment records with a plain-text fallback.
## Responses
Create a public message:
```json
{"type":"message.create","plain_text":"echo: hello"}
```
No-op acknowledgement:
```json
{"type":"event.ok"}
```
Module error:
```json
{"type":"error","code":"bad_request","message":"missing plain_text"}
```
## Security Rules
- Modules are untrusted external processes.
- TNT should enforce per-module permissions before delivering events or
accepting responses.
- TNT should cap stdout line length, startup time, event handling time, and
total queued output.
- TNT should disable a module after repeated invalid JSON, protocol errors, or
timeout failures.
- Modules must never receive private messages unless they request and are
granted an explicit private-message permission.
## Rendering Rules
Every module-created message must be renderable as plain text. Terminal image
protocols such as Kitty graphics or Sixel are optional renderer capabilities,
not message requirements. A module may provide attachment metadata later, but
TNT must be able to fall back to a link, filename, digest, or short label.

View file

@ -30,7 +30,10 @@ COMMANDS (COMMAND mode, prefix with :)
nick <name> change nickname
msg <user> <message> send private message
w <user> <text> alias for msg
inbox show private messages
reply <text> reply to latest private message
r <text> alias for reply
inbox show private messages, newest first
inbox clear clear private messages for this session
last [N] last N messages from log (default 10, max 50)
search <keyword> search full history (case-insensitive, 15 results)
mute-joins toggle join/leave notifications
@ -45,6 +48,7 @@ INSERT MODE
paste multi-line paste stays in the input buffer
limit 1023 bytes/message; over-limit input rings bell
normal opens/follows latest; k/PgUp older, j/PgDn newer
insert aliases i/a/o enter INSERT mode from NORMAL
EXEC COMMANDS
health print service health
@ -74,9 +78,13 @@ STRUCTURE
src/commands.c COMMAND-mode command dispatch
src/exec_catalog.c SSH exec command matching, usage, argument shape
src/exec.c SSH exec command dispatch
src/json_text.c JSON string escape/extract helpers
src/input_buffer.c validated INSERT/COMMAND/paste buffer helpers
src/message.c persistence, search
src/message_log.c messages.log v1 parsing and formatting
src/message_log_tool.c offline messages.log check/recover CLI
src/module_protocol.c external module JSONL protocol helpers
src/module_runtime.c optional external module supervisor
src/history_view.c message viewport / scroll state
src/help_text.c full-screen key reference text
src/manual.c concise manual panel rendering
@ -94,7 +102,7 @@ LIMITS
1024 bytes/message
FILES
messages.log chat log (RFC3339)
messages.log public chat log (RFC3339; excludes private messages)
host_key SSH key (auto-generated)
motd.txt message of the day (optional)
CHANGELOG.md version history

View file

@ -80,6 +80,26 @@ Goal: keep the interface efficient for terminal users without sacrificing simpli
- ✅ improve discoverability of NORMAL and COMMAND mode actions
- ✅ make status lines and help output concise enough for small terminals
## Stage 4.5: Module Foundation
Goal: let community features plug into TNT without coupling every user request
to the core server binary.
- keep TNT core basic and broadly compatible; route personalized workflows,
rich visuals, and terminal-specific experience upgrades through modules
- define the external-process module protocol before loading any third-party
code into production rooms
- keep module messages compatible with plain terminal clients by requiring
plain-text fallbacks for rich content and attachments
- treat terminal image protocols as optional renderer capabilities, not as the
core message format
- prefer JSON Lines over stdin/stdout for early modules so TNT can supervise,
restart, rate-limit, and disable modules independently
- keep module permissions explicit: message read/create, command registration,
private-message access, and future attachment access must be separate grants
- publish official examples in a companion community repository that tracks
TNT protocol versions and license terms
## Stage 5: Operations and Security
Goal: make public deployment manageable.

View file

@ -12,8 +12,8 @@ The product path should stay short:
5. User presses Esc to browse history with Vim-style movement.
6. User uses `:help` for the concise manual or `?` for the full key reference.
7. User searches from NORMAL with `/term`, or uses commands when needed:
`:users`, `:msg`, `:inbox`, `:last`, `:search`, `:nick`, `:mute-joins`,
and `:q`.
`:users`, `:msg`, `:reply`, `:inbox`, `:last`, `:search`, `:nick`,
`:mute-joins`, and `:q`.
8. Scripts and operators use `tntctl` or SSH exec commands for `health`,
`stats`, `users`, `tail`, `dump`, and `post`.
@ -32,11 +32,18 @@ The product path should stay short:
parallel support commands for the same task.
- Command syntax stays ASCII even in localized UI text. Translations explain;
they do not change the command language.
- Private messages are visible only in the recipient inbox and are not written
to `messages.log`.
- Private messages are visible in each participant's in-memory `:inbox`:
recipients see incoming messages, senders see local sent-message copies,
newest first. They are not written to `messages.log` and do not survive a
reconnect.
- `:inbox` is live enough for normal chat use: it can be refreshed with `r`
and refreshes automatically when a new private message arrives while the
inbox is open.
inbox is open. Incoming unread messages are marked with `*` and counted in
the inbox title until the inbox renders them. `:inbox clear` removes private
messages and the reply target for the current session.
- `:reply` / `:r` keeps the private-message path keyboard-short: it answers
the latest private-message peer in the current session without retyping a
username.
- Long command output uses a small pager so `:last` and `:search` are readable
on small terminals.
@ -47,10 +54,12 @@ The product path should stay short:
- second user joins and is visible through `users --json`
- first user opens `?`, checks `:users`, sends a public message, scrolls, uses
`:last` and `:search`
- first user toggles `:mute-joins`, sends `:msg`, changes nickname, sends
`/me`, and exits
- second user opens `:inbox` before the private message arrives and sees it
auto-refresh after delivery
- first user toggles `:mute-joins`, sends two `:msg` messages, receives a
`:reply`, confirms private-message copies in `:inbox`, clears the inbox,
changes nickname, sends `/me`, and exits
- second user opens `:inbox` before the private messages arrive, sees it
auto-refresh after delivery, newest first, and replies without retyping the
sender's username
- exec `tail` sees public messages
- `messages.log` contains public history and excludes private-message content

View file

@ -8,6 +8,7 @@ typedef enum {
TNT_COMMAND_HELP,
TNT_COMMAND_LANG,
TNT_COMMAND_MSG,
TNT_COMMAND_REPLY,
TNT_COMMAND_INBOX,
TNT_COMMAND_NICK,
TNT_COMMAND_LAST,

View file

@ -35,6 +35,8 @@ typedef enum {
I18N_TITLE_ONLINE_FORMAT,
I18N_TITLE_MUTED,
I18N_TITLE_HELP_HINT,
I18N_EMPTY_ROOM,
I18N_EMPTY_FILTERED,
I18N_IDLE_TIMEOUT_FORMAT,
I18N_SYSTEM_USERNAME,
I18N_SYSTEM_JOIN_FORMAT,
@ -43,14 +45,20 @@ typedef enum {
I18N_USERS_TITLE,
I18N_MSG_SENT_FORMAT,
I18N_MSG_USER_NOT_FOUND_FORMAT,
I18N_REPLY_NO_TARGET,
I18N_INBOX_TITLE,
I18N_INBOX_EMPTY,
I18N_INBOX_SENT_TO_FORMAT,
I18N_INBOX_CLEARED,
I18N_INBOX_UNREAD_FORMAT,
I18N_NICK_INVALID,
I18N_NICK_TAKEN_FORMAT,
I18N_NICK_UNCHANGED,
I18N_NICK_CHANGED_FORMAT,
I18N_LAST_HEADER_FORMAT,
I18N_LAST_EMPTY,
I18N_SEARCH_HEADER_FORMAT,
I18N_SEARCH_EMPTY,
I18N_MUTE_JOINS_FORMAT,
I18N_MUTE_JOINS_MUTED,
I18N_MUTE_JOINS_UNMUTED,

35
include/input_buffer.h Normal file
View file

@ -0,0 +1,35 @@
#ifndef INPUT_BUFFER_H
#define INPUT_BUFFER_H
#include "common.h"
typedef enum {
TNT_INPUT_APPEND_OK = 0,
TNT_INPUT_APPEND_IGNORED = 1 << 0,
TNT_INPUT_APPEND_OVERFLOW = 1 << 1,
TNT_INPUT_APPEND_INVALID_UTF8 = 1 << 2
} tnt_input_append_status_t;
typedef struct {
char bytes[4];
int len;
int expected_len;
} tnt_input_utf8_state_t;
void tnt_input_utf8_state_reset(tnt_input_utf8_state_t *state);
int tnt_input_append_ascii(char *input, size_t input_size, unsigned char b);
int tnt_input_append_utf8_sequence(char *input, size_t input_size,
const char *bytes, int len);
/* Append one byte from a terminal stream, validating UTF-8 across calls.
* In paste mode CR/LF/TAB are normalized to spaces so existing TNT 1.x
* single-line message semantics are preserved. */
int tnt_input_append_stream_byte(char *input, size_t input_size,
tnt_input_utf8_state_t *state,
unsigned char b, bool paste_mode);
/* Returns TNT_INPUT_APPEND_INVALID_UTF8 when the stream ended mid-codepoint. */
int tnt_input_utf8_state_finish(tnt_input_utf8_state_t *state);
#endif /* INPUT_BUFFER_H */

15
include/json_text.h Normal file
View file

@ -0,0 +1,15 @@
#ifndef JSON_TEXT_H
#define JSON_TEXT_H
#include "common.h"
void tnt_json_append_string(char *buffer, size_t buf_size, size_t *pos,
const char *text);
/* Extract a top-level JSON string field from a single JSON object.
* Returns false for malformed JSON, missing key, non-string value, or output
* overflow. Unknown nested objects and arrays are skipped. */
bool tnt_json_get_string_field(const char *json, const char *key,
char *out, size_t out_size);
#endif /* JSON_TEXT_H */

24
include/module_protocol.h Normal file
View file

@ -0,0 +1,24 @@
#ifndef MODULE_PROTOCOL_H
#define MODULE_PROTOCOL_H
#include "message.h"
#define TNT_MODULE_PROTOCOL_VERSION "tnt.module.v1"
#define TNT_MODULE_EVENT_MESSAGE_CREATED "message.created"
#define TNT_MODULE_RESPONSE_MESSAGE_CREATE "message.create"
typedef struct {
char plain_text[MAX_MESSAGE_LEN];
} tnt_module_message_create_t;
int tnt_module_append_handshake(char *buffer, size_t buf_size, size_t *pos,
const char *server_version);
int tnt_module_append_message_created(char *buffer, size_t buf_size,
size_t *pos, const char *message_id,
const message_t *msg);
bool tnt_module_parse_message_create(const char *line,
tnt_module_message_create_t *out);
#endif /* MODULE_PROTOCOL_H */

27
include/module_runtime.h Normal file
View file

@ -0,0 +1,27 @@
#ifndef MODULE_RUNTIME_H
#define MODULE_RUNTIME_H
#include "message.h"
#define TNT_MAX_MODULES 8
#define TNT_MODULE_QUEUE_LIMIT 128
typedef struct {
char name[64];
char entrypoint[PATH_MAX];
bool wants_message_created;
bool can_read_messages;
bool can_create_messages;
} tnt_module_manifest_t;
int tnt_module_manifest_load(const char *module_dir,
tnt_module_manifest_t *out);
int tnt_module_runtime_init(void);
void tnt_module_runtime_shutdown(void);
/* Queue a user/core-created public message for enabled modules. This is
* intentionally fire-and-forget so basic chat never depends on module health. */
void tnt_module_runtime_publish_message_created(const message_t *msg);
#endif /* MODULE_RUNTIME_H */

View file

@ -14,7 +14,10 @@
typedef struct {
time_t timestamp;
char from[MAX_USERNAME_LEN];
char to[MAX_USERNAME_LEN];
char content[MAX_MESSAGE_LEN];
bool outgoing;
bool unread;
} whisper_t;
typedef enum {
@ -59,10 +62,13 @@ typedef struct client {
_Atomic int pending_bells; /* Bell nudges for this client's loop */
_Atomic int unread_mentions; /* @-mentions received since last reset */
_Atomic int unread_whispers; /* whispers received since last :inbox view */
char last_whisper_peer[MAX_USERNAME_LEN]; /* Most recent private-message peer */
char *outbox; /* Bounded queued output for interactive writes */
size_t outbox_len;
size_t outbox_pos;
size_t outbox_capacity;
char *render_buffer; /* Reused main-screen render buffer */
size_t render_buffer_capacity;
/* Per-client whisper inbox. Protected separately from SSH channel I/O
* so slow writes do not block in-memory private-message delivery. */
whisper_t whisper_inbox[WHISPER_INBOX_SIZE];

View file

@ -24,6 +24,9 @@ void tui_render_motd(struct client *client);
/* Render the input line */
void tui_render_input(struct client *client, const char *input);
/* Render only the command input/status line */
void tui_render_command_input(struct client *client);
/* Clear the screen */
void tui_clear_screen(struct client *client);

View file

@ -237,6 +237,7 @@ void client_release(client_t *client) {
free(client->channel_cb);
}
free(client->outbox);
free(client->render_buffer);
pthread_mutex_destroy(&client->io_lock);
pthread_mutex_destroy(&client->whisper_lock);
pthread_mutex_destroy(&client->ref_lock);

View file

@ -36,13 +36,25 @@ static const command_catalog_entry_t entries[] = {
" w <user> <message>\n"),
2, false, true
},
{
{TNT_COMMAND_REPLY, "reply", {"reply", "r", NULL}},
I18N_STRING(":reply <message>, :r <message>",
":reply <message>, :r <message>"),
I18N_STRING("Reply to latest private message", "回复最近私信"),
I18N_STRING(":reply <message>", ":reply <message>"),
I18N_STRING("Usage: reply <message>\n"
" r <message>\n",
"用法: reply <message>\n"
" r <message>\n"),
2, false, true
},
{
{TNT_COMMAND_INBOX, "inbox", {"inbox", NULL}},
I18N_STRING(":inbox, :inbox clear", ":inbox, :inbox clear"),
I18N_STRING("Show or clear private messages", "查看或清空私信"),
I18N_STRING(":inbox", ":inbox"),
I18N_STRING("Show private messages", "查看私信"),
I18N_STRING(":inbox", ":inbox"),
I18N_STRING("Usage: inbox\n", "用法: inbox\n"),
2, true, false
I18N_STRING("Usage: inbox [clear]\n", "用法: inbox [clear]\n"),
2, false, false
},
{
{TNT_COMMAND_NICK, "nick", {"nick", "name", NULL}},
@ -227,6 +239,9 @@ bool command_catalog_args_valid(tnt_command_id_t id, const char *args) {
if (!entry) {
return false;
}
if (id == TNT_COMMAND_INBOX) {
return !args || args[0] == '\0' || strcmp(args, "clear") == 0;
}
if (entry->no_args) {
return !args || args[0] == '\0';
}

View file

@ -52,36 +52,151 @@ static void append_command_usage(char *output, size_t buf_size, size_t *pos,
command_catalog_append_usage(output, buf_size, pos, id, lang);
}
static bool message_visible_for_client(const client_t *client,
const message_t *msg) {
return !client || !client->mute_joins ||
!system_message_is_join_leave(msg);
}
static void client_append_whisper(client_t *owner, const char *from,
const char *to, const char *content,
bool outgoing, bool count_unread) {
if (!owner || !from || !to || !content) return;
pthread_mutex_lock(&owner->whisper_lock);
int slot;
if (owner->whisper_inbox_count < WHISPER_INBOX_SIZE) {
slot = owner->whisper_inbox_count++;
} else {
memmove(&owner->whisper_inbox[0],
&owner->whisper_inbox[1],
(WHISPER_INBOX_SIZE - 1) * sizeof(whisper_t));
slot = WHISPER_INBOX_SIZE - 1;
}
owner->whisper_inbox[slot].timestamp = time(NULL);
snprintf(owner->whisper_inbox[slot].from,
sizeof(owner->whisper_inbox[slot].from), "%s", from);
snprintf(owner->whisper_inbox[slot].to,
sizeof(owner->whisper_inbox[slot].to), "%s", to);
snprintf(owner->whisper_inbox[slot].content,
sizeof(owner->whisper_inbox[slot].content), "%s", content);
owner->whisper_inbox[slot].outgoing = outgoing;
owner->whisper_inbox[slot].unread = count_unread;
snprintf(owner->last_whisper_peer, sizeof(owner->last_whisper_peer), "%s",
outgoing ? to : from);
if (count_unread) {
owner->unread_whispers++;
}
pthread_mutex_unlock(&owner->whisper_lock);
}
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;
client_t *target = NULL;
pthread_rwlock_rdlock(&g_room->lock);
for (int i = 0; i < g_room->client_count; i++) {
if (strcmp(g_room->clients[i]->username, target_name) == 0) {
target = g_room->clients[i];
client_addref(target);
found = true;
break;
}
}
pthread_rwlock_unlock(&g_room->lock);
if (target) {
client_append_whisper(target, client->username, target_name,
content, false, true);
if (target != client) {
client_append_whisper(client, client->username, target_name,
content, true, false);
}
/* Audible nudge: the title bar whisper counter carries the
* persistent signal without cross-client SSH writes. */
client_queue_bell(target);
client_release(target);
}
if (found) {
buffer_appendf(output, buf_size, pos,
i18n_text(client->ui_lang, I18N_MSG_SENT_FORMAT),
target_name);
} else {
buffer_appendf(output, buf_size, pos,
i18n_text(client->ui_lang,
I18N_MSG_USER_NOT_FOUND_FORMAT),
target_name);
}
}
static void append_inbox_output(client_t *client, char *output,
size_t buf_size, size_t *pos) {
whisper_t snapshot[WHISPER_INBOX_SIZE];
int snap_count;
int unread_count;
pthread_mutex_lock(&client->whisper_lock);
snap_count = client->whisper_inbox_count;
unread_count = client->unread_whispers;
memcpy(snapshot, client->whisper_inbox,
snap_count * sizeof(whisper_t));
for (int i = 0; i < snap_count; i++) {
client->whisper_inbox[i].unread = false;
}
client->unread_whispers = 0;
pthread_mutex_unlock(&client->whisper_lock);
buffer_appendf(output, buf_size, pos,
"\033[1;36m%s\033[0m \033[2;37m· %d\033[0m\n",
"\033[1;36m%s\033[0m \033[2;37m· %d",
i18n_text(client->ui_lang, I18N_INBOX_TITLE),
snap_count);
if (unread_count > 0) {
buffer_appendf(output, buf_size, pos,
" · ");
buffer_appendf(output, buf_size, pos,
i18n_text(client->ui_lang,
I18N_INBOX_UNREAD_FORMAT),
unread_count);
}
buffer_appendf(output, buf_size, pos, "\033[0m\n");
if (snap_count == 0) {
buffer_appendf(output, buf_size, pos,
" \033[2;37m%s\033[0m\n",
i18n_text(client->ui_lang, I18N_INBOX_EMPTY));
}
for (int i = 0; i < snap_count; i++) {
for (int i = snap_count - 1; i >= 0; i--) {
char ts[20];
char peer[MAX_USERNAME_LEN + 16];
const char *marker = snapshot[i].unread ? "\033[1;35m*\033[0m" : " ";
struct tm tmi;
localtime_r(&snapshot[i].timestamp, &tmi);
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
buffer_appendf(output, buf_size, pos,
" \033[90m%s\033[0m \033[35m%s\033[0m: %s\n",
ts, snapshot[i].from, snapshot[i].content);
if (snapshot[i].outgoing) {
snprintf(peer, sizeof(peer),
i18n_text(client->ui_lang,
I18N_INBOX_SENT_TO_FORMAT),
snapshot[i].to);
} else {
snprintf(peer, sizeof(peer), "%s", snapshot[i].from);
}
buffer_appendf(output, buf_size, pos,
" %s \033[90m%s\033[0m \033[35m%s\033[0m: %s\n",
marker, ts, peer, snapshot[i].content);
}
}
static void clear_inbox(client_t *client) {
pthread_mutex_lock(&client->whisper_lock);
memset(client->whisper_inbox, 0, sizeof(client->whisper_inbox));
client->whisper_inbox_count = 0;
client->unread_whispers = 0;
client->last_whisper_peer[0] = '\0';
pthread_mutex_unlock(&client->whisper_lock);
}
bool commands_refresh_active_output(client_t *client) {
@ -237,65 +352,49 @@ void commands_dispatch(client_t *client) {
append_command_usage(output, sizeof(output), &pos,
TNT_COMMAND_MSG, client->ui_lang);
} else {
bool found = false;
client_t *target = NULL;
pthread_rwlock_rdlock(&g_room->lock);
for (int i = 0; i < g_room->client_count; i++) {
if (strcmp(g_room->clients[i]->username, target_name) == 0) {
target = g_room->clients[i];
client_addref(target);
found = true;
break;
send_private_message(client, target_name, rest, output,
sizeof(output), &pos);
}
}
pthread_rwlock_unlock(&g_room->lock);
if (target) {
/* Push into recipient's inbox. whisper_lock serialises so
* two senders to the same recipient don't tear the ring. */
pthread_mutex_lock(&target->whisper_lock);
int slot;
if (target->whisper_inbox_count < WHISPER_INBOX_SIZE) {
slot = target->whisper_inbox_count++;
} else if (command_id == TNT_COMMAND_REPLY) {
const char *message = arg;
char target_name[MAX_USERNAME_LEN] = {0};
while (*message == ' ') message++;
if (message[0] == '\0') {
append_command_usage(output, sizeof(output), &pos,
TNT_COMMAND_REPLY, client->ui_lang);
} else {
/* FIFO evict the oldest */
memmove(&target->whisper_inbox[0],
&target->whisper_inbox[1],
(WHISPER_INBOX_SIZE - 1) * sizeof(whisper_t));
slot = WHISPER_INBOX_SIZE - 1;
}
target->whisper_inbox[slot].timestamp = time(NULL);
snprintf(target->whisper_inbox[slot].from,
sizeof(target->whisper_inbox[slot].from),
"%s", client->username);
snprintf(target->whisper_inbox[slot].content,
sizeof(target->whisper_inbox[slot].content),
"%s", rest);
target->unread_whispers++;
pthread_mutex_unlock(&target->whisper_lock);
pthread_mutex_lock(&client->whisper_lock);
snprintf(target_name, sizeof(target_name), "%s",
client->last_whisper_peer);
pthread_mutex_unlock(&client->whisper_lock);
/* Audible nudge — the title bar ✉ counter (UX-11 style)
* carries the persistent signal. */
client_queue_bell(target);
client_release(target);
}
if (found) {
buffer_appendf(output, sizeof(output), &pos,
if (target_name[0] == '\0') {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang,
I18N_MSG_SENT_FORMAT),
target_name);
I18N_REPLY_NO_TARGET));
} else {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->ui_lang,
I18N_MSG_USER_NOT_FOUND_FORMAT),
target_name);
send_private_message(client, target_name, message, output,
sizeof(output), &pos);
}
}
} else if (command_id == TNT_COMMAND_INBOX) {
const char *inbox_arg = arg;
while (*inbox_arg == ' ') inbox_arg++;
if (strcmp(inbox_arg, "clear") == 0) {
clear_inbox(client);
output_kind = TNT_COMMAND_OUTPUT_INBOX;
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang,
I18N_INBOX_CLEARED));
buffer_appendf(output, sizeof(output), &pos, "\n");
append_inbox_output(client, output, sizeof(output), &pos);
} else {
output_kind = TNT_COMMAND_OUTPUT_INBOX;
append_inbox_output(client, output, sizeof(output), &pos);
}
} else if (command_id == TNT_COMMAND_NICK) {
const char *new_name = arg;
@ -374,17 +473,31 @@ void commands_dispatch(client_t *client) {
}
message_t *last_msgs = NULL;
int last_count = message_load(&last_msgs, n);
int load_count = message_load(&last_msgs,
client->mute_joins ? MAX_MESSAGES : n);
int visible_count = 0;
for (int i = 0; i < load_count; i++) {
if (message_visible_for_client(client, &last_msgs[i])) {
last_msgs[visible_count++] = last_msgs[i];
}
}
int start = visible_count > n ? visible_count - n : 0;
int last_count = visible_count - start;
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->ui_lang, I18N_LAST_HEADER_FORMAT),
last_count);
if (last_count == 0) {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang, I18N_LAST_EMPTY));
}
for (int i = 0; i < last_count; i++) {
message_t *msg = &last_msgs[start + i];
char ts[20];
struct tm tmi;
localtime_r(&last_msgs[i].timestamp, &tmi);
localtime_r(&msg->timestamp, &tmi);
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
buffer_appendf(output, sizeof(output), &pos,
"[%s] %s: %s\n", ts, last_msgs[i].username, last_msgs[i].content);
"[%s] %s: %s\n", ts, msg->username, msg->content);
}
free(last_msgs);
@ -396,23 +509,38 @@ void commands_dispatch(client_t *client) {
TNT_COMMAND_SEARCH, client->ui_lang);
} else {
message_t *found = NULL;
int found_count = message_search(query, &found, 15);
int search_limit = client->mute_joins ? MAX_MESSAGES : 15;
int found_count = message_search(query, &found, search_limit);
int visible_count = 0;
for (int i = 0; i < found_count; i++) {
if (message_visible_for_client(client, &found[i])) {
found[visible_count++] = found[i];
}
}
int start = visible_count > 15 ? visible_count - 15 : 0;
int display_count = visible_count - start;
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->ui_lang,
I18N_SEARCH_HEADER_FORMAT),
query, found_count);
for (int i = 0; i < found_count; i++) {
query, display_count);
if (display_count == 0) {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang,
I18N_SEARCH_EMPTY));
}
for (int i = 0; i < display_count; i++) {
message_t *msg = &found[start + i];
char ts[20];
struct tm tmi;
localtime_r(&found[i].timestamp, &tmi);
localtime_r(&msg->timestamp, &tmi);
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
buffer_appendf(output, sizeof(output), &pos,
"[%s] ", ts);
append_highlighted(output, sizeof(output), &pos,
found[i].username, query);
msg->username, query);
buffer_appendf(output, sizeof(output), &pos, ": ");
append_highlighted(output, sizeof(output), &pos,
found[i].content, query);
msg->content, query);
buffer_appendf(output, sizeof(output), &pos, "\n");
}
free(found);

View file

@ -5,7 +5,9 @@
#include "exec_catalog.h"
#include "i18n.h"
#include "input.h"
#include "json_text.h"
#include "message.h"
#include "module_runtime.h"
#include "ratelimit.h"
#include "utf8.h"
#include <ctype.h>
@ -56,48 +58,6 @@ static void trim_ascii_whitespace(char *text) {
}
}
static void json_append_string(char *buffer, size_t buf_size, size_t *pos,
const char *text) {
const unsigned char *p = (const unsigned char *)(text ? text : "");
buffer_append_bytes(buffer, buf_size, pos, "\"", 1);
while (*p && *pos < buf_size - 1) {
char escaped[7];
switch (*p) {
case '\\':
buffer_append_bytes(buffer, buf_size, pos, "\\\\", 2);
break;
case '"':
buffer_append_bytes(buffer, buf_size, pos, "\\\"", 2);
break;
case '\n':
buffer_append_bytes(buffer, buf_size, pos, "\\n", 2);
break;
case '\r':
buffer_append_bytes(buffer, buf_size, pos, "\\r", 2);
break;
case '\t':
buffer_append_bytes(buffer, buf_size, pos, "\\t", 2);
break;
default:
if (*p < 0x20) {
snprintf(escaped, sizeof(escaped), "\\u%04x", *p);
buffer_append_bytes(buffer, buf_size, pos,
escaped, strlen(escaped));
} else {
buffer_append_bytes(buffer, buf_size, pos,
(const char *)p, 1);
}
break;
}
p++;
}
buffer_append_bytes(buffer, buf_size, pos, "\"", 1);
}
static void resolve_exec_username(const client_t *client, char *buffer,
size_t buf_size) {
if (!buffer || buf_size == 0) {
@ -188,7 +148,7 @@ static int exec_command_users(client_t *client, bool json) {
if (i > 0) {
buffer_append_bytes(output, output_size, &pos, ",", 1);
}
json_append_string(output, output_size, &pos, usernames[i]);
tnt_json_append_string(output, output_size, &pos, usernames[i]);
}
buffer_append_bytes(output, output_size, &pos, "]\n", 2);
} else {
@ -467,6 +427,7 @@ static int exec_command_post(client_t *client, const char *args) {
room_broadcast(g_room, &msg);
notify_mentions(msg.content, client);
tnt_module_runtime_publish_message_created(&msg);
if (client_send(client, "posted\n", 7) != 0) {
return TNT_EXIT_ERROR;

View file

@ -26,7 +26,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
"NORMAL MODE KEYS:\n"
" Opens at latest messages\n"
" Follows latest until you scroll up\n"
" i - Return to INSERT mode\n"
" i/a/o - Return to INSERT mode\n"
" : - Enter COMMAND mode\n"
" / - Search message history\n"
" j/k - Scroll down/up one line\n"
@ -59,7 +59,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
"NORMAL 模式按键:\n"
" 默认停在最新消息\n"
" 未向上翻阅时自动跟随最新消息\n"
" i - 返回 INSERT 模式\n"
" i/a/o - 返回 INSERT 模式\n"
" : - 进入 COMMAND 模式\n"
" / - 搜索消息历史\n"
" j/k - 向下/上滚动一行\n"

View file

@ -81,6 +81,14 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = {
"? keys",
"? 按键"
),
[I18N_EMPTY_ROOM] = I18N_STRING(
"No messages yet",
"暂无消息"
),
[I18N_EMPTY_FILTERED] = I18N_STRING(
"No visible messages",
"暂无可见消息"
),
[I18N_IDLE_TIMEOUT_FORMAT] = I18N_STRING(
"\r\n\033[33mDisconnected: idle timeout (%d min)\033[0m\r\n",
"\r\n\033[33m已断开: 空闲超时 (%d 分钟)\033[0m\r\n"
@ -113,6 +121,10 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = {
"User '%s' not found\n",
"未找到用户 '%s'\n"
),
[I18N_REPLY_NO_TARGET] = I18N_STRING(
"No private message to reply to\n",
"没有可回复的私信\n"
),
[I18N_INBOX_TITLE] = I18N_STRING(
"Private messages",
"私信"
@ -121,6 +133,18 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = {
"(empty)",
"(空)"
),
[I18N_INBOX_SENT_TO_FORMAT] = I18N_STRING(
"you -> %s",
"你 -> %s"
),
[I18N_INBOX_CLEARED] = I18N_STRING(
"Private messages cleared\n",
"私信已清空\n"
),
[I18N_INBOX_UNREAD_FORMAT] = I18N_STRING(
"%d new",
"%d 新"
),
[I18N_NICK_INVALID] = I18N_STRING(
"Invalid username\n",
"用户名无效\n"
@ -141,10 +165,18 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = {
"--- Last %d message(s) ---\n",
"--- 最近 %d 条消息 ---\n"
),
[I18N_LAST_EMPTY] = I18N_STRING(
"No messages to show\n",
"没有可显示的消息\n"
),
[I18N_SEARCH_HEADER_FORMAT] = I18N_STRING(
"--- Search: \"%s\" (showing last %d match(es)) ---\n",
"--- 搜索: \"%s\" (显示最近 %d 条匹配) ---\n"
),
[I18N_SEARCH_EMPTY] = I18N_STRING(
"No matches\n",
"没有匹配结果\n"
),
[I18N_MUTE_JOINS_FORMAT] = I18N_STRING(
"Join/leave notifications: %s\n",
"加入/离开提示: %s\n"

View file

@ -7,7 +7,9 @@
#include "exec.h"
#include "history_view.h"
#include "i18n.h"
#include "input_buffer.h"
#include "message.h"
#include "module_runtime.h"
#include "ratelimit.h"
#include "system_message.h"
#include "tui.h"
@ -24,6 +26,8 @@
static int g_idle_timeout = TNT_DEFAULT_IDLE_TIMEOUT;
static ui_lang_t g_default_ui_lang = UI_LANG_EN;
#define MAIN_LOOP_POLL_TIMEOUT_MS 250
void input_init(void) {
g_idle_timeout = tnt_config_env_int(&TNT_CONFIG_IDLE_TIMEOUT);
g_default_ui_lang = i18n_default_ui_lang();
@ -194,38 +198,44 @@ static int read_channel_exact(client_t *client, char *buf, size_t len,
return (int)got;
}
static bool append_paste_byte(char *input, unsigned char b) {
if (b == '\r' || b == '\n' || b == '\t') {
b = ' ';
}
if (b < 32) {
return true;
static int normal_visible_message_count(const client_t *client) {
if (!client || !client->mute_joins) {
return room_get_message_count(g_room);
}
size_t cur = strlen(input);
if (cur < MAX_MESSAGE_LEN - 1) {
input[cur] = (char)b;
input[cur + 1] = '\0';
return true;
int count = 0;
pthread_rwlock_rdlock(&g_room->lock);
for (int i = 0; i < g_room->message_count; i++) {
if (!system_message_is_join_leave(&g_room->messages[i])) {
count++;
}
return false;
}
pthread_rwlock_unlock(&g_room->lock);
return count;
}
static void normal_scroll_to_latest(client_t *client) {
if (!client) return;
history_view_scroll_to_latest(&client->scroll_pos, &client->follow_tail,
room_get_message_count(g_room),
normal_visible_message_count(client),
history_view_height(client->height));
}
static void normal_scroll_by(client_t *client, int delta) {
if (!client) return;
history_view_scroll_by(&client->scroll_pos, &client->follow_tail,
room_get_message_count(g_room),
normal_visible_message_count(client),
history_view_height(client->height), delta);
}
static void normal_enter_insert(client_t *client) {
if (!client) return;
client->mode = MODE_INSERT;
client->follow_tail = true;
client->unread_mentions = 0;
tui_render_screen(client);
}
static void dismiss_command_output(client_t *client) {
bool was_motd;
@ -474,6 +484,8 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
* spaces so a multi-line paste stays a
* single message instead of N sends. */
bool overflow = false;
bool invalid_utf8 = false;
tnt_input_utf8_state_t paste_utf8 = {0};
while (1) {
char b;
int k = ssh_channel_read_timeout(
@ -494,21 +506,40 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
* but keep printable bytes that
* followed it. */
for (int i = 0; i < t; i++) {
if (!append_paste_byte(
input,
(unsigned char)tail[i])) {
int status =
tnt_input_append_stream_byte(
input, MAX_MESSAGE_LEN,
&paste_utf8,
(unsigned char)tail[i],
true);
if (status &
TNT_INPUT_APPEND_OVERFLOW) {
overflow = true;
}
if (status &
TNT_INPUT_APPEND_INVALID_UTF8) {
invalid_utf8 = true;
}
}
continue;
}
if (!append_paste_byte(input,
(unsigned char)b)) {
int status = tnt_input_append_stream_byte(
input, MAX_MESSAGE_LEN, &paste_utf8,
(unsigned char)b, true);
if (status & TNT_INPUT_APPEND_OVERFLOW) {
overflow = true;
}
if (status & TNT_INPUT_APPEND_INVALID_UTF8) {
invalid_utf8 = true;
}
}
if (tnt_input_utf8_state_finish(
&paste_utf8) &
TNT_INPUT_APPEND_INVALID_UTF8) {
invalid_utf8 = true;
}
tui_render_input(client, input);
if (overflow) {
if (overflow || invalid_utf8) {
client_send(client, "\a", 1);
}
}
@ -554,7 +585,11 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
}
room_broadcast(g_room, &msg);
notify_mentions(msg.content, client);
message_save(&msg);
if (message_save(&msg) == 0) {
tnt_module_runtime_publish_message_created(&msg);
} else {
fprintf(stderr, "interactive: failed to persist message\n");
}
input[0] = '\0';
}
tui_render_screen(client);
@ -629,22 +664,20 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
case MODE_NORMAL: {
int nm_msg_height = history_view_height(client->height);
if (key == 'i') {
client->mode = MODE_INSERT;
client->follow_tail = true;
client->unread_mentions = 0;
tui_render_screen(client);
if (key == 'i' || key == 'a' || key == 'A' ||
key == 'o' || key == 'O') {
normal_enter_insert(client);
return true;
} else if (key == ':') {
client->mode = MODE_COMMAND;
client->command_input[0] = '\0';
tui_render_screen(client);
tui_render_command_input(client);
return true;
} else if (key == '/') {
client->mode = MODE_COMMAND;
snprintf(client->command_input, sizeof(client->command_input),
"search ");
tui_render_screen(client);
tui_render_command_input(client);
return true;
} else if (key == 'j') {
normal_scroll_by(client, 1);
@ -744,7 +777,7 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
client->command_history[client->command_history_pos],
sizeof(client->command_input) - 1);
client->command_input[sizeof(client->command_input) - 1] = '\0';
tui_render_screen(client);
tui_render_command_input(client);
}
return true;
} else if (seq[1] == 'B') { /* Down arrow */
@ -758,7 +791,7 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
client->command_history_pos = client->command_history_count;
client->command_input[0] = '\0';
}
tui_render_screen(client);
tui_render_command_input(client);
return true;
}
}
@ -773,19 +806,19 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
} else if (key == 127 || key == 8) { /* Backspace */
if (client->command_input[0] != '\0') {
utf8_remove_last_char(client->command_input);
tui_render_screen(client);
tui_render_command_input(client);
}
return true; /* Key consumed */
} else if (key == 23) { /* Ctrl+W (Delete Word) */
if (client->command_input[0] != '\0') {
utf8_remove_last_word(client->command_input);
tui_render_screen(client);
tui_render_command_input(client);
}
return true;
} else if (key == 21) { /* Ctrl+U (Delete Line) */
if (client->command_input[0] != '\0') {
client->command_input[0] = '\0';
tui_render_screen(client);
tui_render_command_input(client);
}
return true;
}
@ -892,7 +925,8 @@ main_loop:
break;
}
int ready = ssh_channel_poll_timeout(client->channel, 1000, 0);
int ready = ssh_channel_poll_timeout(client->channel,
MAIN_LOOP_POLL_TIMEOUT_MS, 0);
if (ready == SSH_ERROR) {
break;
@ -986,10 +1020,9 @@ main_loop:
if (client->mode == MODE_INSERT && !client->show_help &&
client->command_output[0] == '\0') {
if (b >= 32 && b < 127) { /* ASCII printable */
int len = strlen(input);
if (len < MAX_MESSAGE_LEN - 1) {
input[len] = b;
input[len + 1] = '\0';
int status = tnt_input_append_ascii(input,
MAX_MESSAGE_LEN, b);
if (status == TNT_INPUT_APPEND_OK) {
tui_render_input(client, input);
} else {
client_send(client, "\a", 1);
@ -1013,10 +1046,9 @@ main_loop:
/* Invalid UTF-8 sequence */
continue;
}
int len = strlen(input);
if (len + char_len <= MAX_MESSAGE_LEN - 1) {
memcpy(input + len, buf, char_len);
input[len + char_len] = '\0';
int status = tnt_input_append_utf8_sequence(
input, MAX_MESSAGE_LEN, buf, char_len);
if (status == TNT_INPUT_APPEND_OK) {
tui_render_input(client, input);
} else {
client_send(client, "\a", 1);
@ -1025,11 +1057,11 @@ main_loop:
} else if (client->mode == MODE_COMMAND && !client->show_help &&
client->command_output[0] == '\0') {
if (b >= 32 && b < 127) { /* ASCII printable */
size_t len = strlen(client->command_input);
if (len < sizeof(client->command_input) - 1) {
client->command_input[len] = b;
client->command_input[len + 1] = '\0';
tui_render_screen(client);
int status = tnt_input_append_ascii(
client->command_input, sizeof(client->command_input),
b);
if (status == TNT_INPUT_APPEND_OK) {
tui_render_command_input(client);
} else {
client_send(client, "\a", 1);
}
@ -1043,11 +1075,11 @@ main_loop:
if (read_bytes != char_len - 1) continue;
}
if (!utf8_is_valid_sequence(buf, char_len)) continue;
size_t len = strlen(client->command_input);
if (len + (size_t)char_len <= sizeof(client->command_input) - 1) {
memcpy(client->command_input + len, buf, char_len);
client->command_input[len + char_len] = '\0';
tui_render_screen(client);
int status = tnt_input_append_utf8_sequence(
client->command_input, sizeof(client->command_input),
buf, char_len);
if (status == TNT_INPUT_APPEND_OK) {
tui_render_command_input(client);
} else {
client_send(client, "\a", 1);
}

147
src/input_buffer.c Normal file
View file

@ -0,0 +1,147 @@
#include "input_buffer.h"
#include "utf8.h"
void tnt_input_utf8_state_reset(tnt_input_utf8_state_t *state) {
if (!state) return;
state->len = 0;
state->expected_len = 0;
memset(state->bytes, 0, sizeof(state->bytes));
}
static int append_bytes(char *input, size_t input_size, const char *bytes,
size_t len) {
size_t cur;
if (!input || !bytes || input_size == 0 || len == 0) {
return TNT_INPUT_APPEND_IGNORED;
}
cur = strlen(input);
if (cur + len >= input_size) {
return TNT_INPUT_APPEND_OVERFLOW;
}
memcpy(input + cur, bytes, len);
input[cur + len] = '\0';
return TNT_INPUT_APPEND_OK;
}
int tnt_input_append_ascii(char *input, size_t input_size, unsigned char b) {
char c = (char)b;
if (b < 32 || b >= 127) {
return TNT_INPUT_APPEND_IGNORED;
}
return append_bytes(input, input_size, &c, 1);
}
int tnt_input_append_utf8_sequence(char *input, size_t input_size,
const char *bytes, int len) {
if (!bytes || len <= 0 || len > 4 ||
!utf8_is_valid_sequence(bytes, len)) {
return TNT_INPUT_APPEND_INVALID_UTF8;
}
return append_bytes(input, input_size, bytes, (size_t)len);
}
static int append_printable_byte(char *input, size_t input_size,
tnt_input_utf8_state_t *state,
unsigned char b, bool paste_mode);
static int start_utf8_sequence(char *input, size_t input_size,
tnt_input_utf8_state_t *state,
unsigned char b, bool paste_mode) {
int expected = utf8_byte_length(b);
if (expected <= 1 || expected > 4) {
tnt_input_utf8_state_reset(state);
return TNT_INPUT_APPEND_INVALID_UTF8;
}
state->bytes[0] = (char)b;
state->len = 1;
state->expected_len = expected;
if (expected == 1) {
int status = tnt_input_append_utf8_sequence(input, input_size,
state->bytes, 1);
tnt_input_utf8_state_reset(state);
return status;
}
(void)paste_mode;
return TNT_INPUT_APPEND_OK;
}
static int append_printable_byte(char *input, size_t input_size,
tnt_input_utf8_state_t *state,
unsigned char b, bool paste_mode) {
int status = TNT_INPUT_APPEND_OK;
if (b < 128) {
if (state && state->len > 0) {
tnt_input_utf8_state_reset(state);
status |= TNT_INPUT_APPEND_INVALID_UTF8;
}
status |= tnt_input_append_ascii(input, input_size, b);
return status;
}
if (!state) {
return TNT_INPUT_APPEND_INVALID_UTF8;
}
if (state->len == 0) {
return start_utf8_sequence(input, input_size, state, b, paste_mode);
}
if ((b & 0xC0) != 0x80) {
tnt_input_utf8_state_reset(state);
status |= TNT_INPUT_APPEND_INVALID_UTF8;
status |= append_printable_byte(input, input_size, state, b,
paste_mode);
return status;
}
state->bytes[state->len++] = (char)b;
if (state->len == state->expected_len) {
status |= tnt_input_append_utf8_sequence(input, input_size,
state->bytes, state->len);
tnt_input_utf8_state_reset(state);
}
return status;
}
int tnt_input_append_stream_byte(char *input, size_t input_size,
tnt_input_utf8_state_t *state,
unsigned char b, bool paste_mode) {
int status = TNT_INPUT_APPEND_OK;
if (paste_mode && (b == '\r' || b == '\n' || b == '\t')) {
b = ' ';
}
if (b < 32) {
if (state && state->len > 0) {
tnt_input_utf8_state_reset(state);
status |= TNT_INPUT_APPEND_INVALID_UTF8;
}
return status | TNT_INPUT_APPEND_IGNORED;
}
return status | append_printable_byte(input, input_size, state, b,
paste_mode);
}
int tnt_input_utf8_state_finish(tnt_input_utf8_state_t *state) {
if (!state || state->len == 0) {
return TNT_INPUT_APPEND_OK;
}
tnt_input_utf8_state_reset(state);
return TNT_INPUT_APPEND_INVALID_UTF8;
}

343
src/json_text.c Normal file
View file

@ -0,0 +1,343 @@
#include "json_text.h"
#include <ctype.h>
void tnt_json_append_string(char *buffer, size_t buf_size, size_t *pos,
const char *text) {
const unsigned char *p = (const unsigned char *)(text ? text : "");
buffer_append_bytes(buffer, buf_size, pos, "\"", 1);
while (*p && pos && *pos < buf_size - 1) {
char escaped[7];
switch (*p) {
case '\\':
buffer_append_bytes(buffer, buf_size, pos, "\\\\", 2);
break;
case '"':
buffer_append_bytes(buffer, buf_size, pos, "\\\"", 2);
break;
case '\b':
buffer_append_bytes(buffer, buf_size, pos, "\\b", 2);
break;
case '\f':
buffer_append_bytes(buffer, buf_size, pos, "\\f", 2);
break;
case '\n':
buffer_append_bytes(buffer, buf_size, pos, "\\n", 2);
break;
case '\r':
buffer_append_bytes(buffer, buf_size, pos, "\\r", 2);
break;
case '\t':
buffer_append_bytes(buffer, buf_size, pos, "\\t", 2);
break;
default:
if (*p < 0x20) {
snprintf(escaped, sizeof(escaped), "\\u%04x", *p);
buffer_append_bytes(buffer, buf_size, pos,
escaped, strlen(escaped));
} else {
buffer_append_bytes(buffer, buf_size, pos,
(const char *)p, 1);
}
break;
}
p++;
}
buffer_append_bytes(buffer, buf_size, pos, "\"", 1);
}
static const char *skip_ws(const char *p) {
while (p && isspace((unsigned char)*p)) {
p++;
}
return p;
}
static int hex_value(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
static bool parse_hex4(const char *p, uint32_t *out) {
uint32_t cp = 0;
if (!p || !out) return false;
for (int i = 0; i < 4; i++) {
int v = hex_value(p[i]);
if (v < 0) return false;
cp = (cp << 4) | (uint32_t)v;
}
*out = cp;
return true;
}
static bool append_decoded_codepoint(char *out, size_t out_size,
size_t *pos, uint32_t cp) {
char bytes[4];
size_t len;
if (!out || !pos || out_size == 0 || cp == 0 ||
cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) {
return false;
}
if (cp <= 0x7F) {
bytes[0] = (char)cp;
len = 1;
} else if (cp <= 0x7FF) {
bytes[0] = (char)(0xC0 | (cp >> 6));
bytes[1] = (char)(0x80 | (cp & 0x3F));
len = 2;
} else if (cp <= 0xFFFF) {
bytes[0] = (char)(0xE0 | (cp >> 12));
bytes[1] = (char)(0x80 | ((cp >> 6) & 0x3F));
bytes[2] = (char)(0x80 | (cp & 0x3F));
len = 3;
} else {
bytes[0] = (char)(0xF0 | (cp >> 18));
bytes[1] = (char)(0x80 | ((cp >> 12) & 0x3F));
bytes[2] = (char)(0x80 | ((cp >> 6) & 0x3F));
bytes[3] = (char)(0x80 | (cp & 0x3F));
len = 4;
}
if (*pos + len >= out_size) {
return false;
}
memcpy(out + *pos, bytes, len);
*pos += len;
out[*pos] = '\0';
return true;
}
static bool append_byte(char *out, size_t out_size, size_t *pos, char c) {
if (!out || !pos || out_size == 0 || *pos + 1 >= out_size) {
return false;
}
out[(*pos)++] = c;
out[*pos] = '\0';
return true;
}
static bool parse_json_string(const char **cursor, char *out,
size_t out_size) {
const char *p;
size_t pos = 0;
if (!cursor || !*cursor || **cursor != '"' || !out || out_size == 0) {
return false;
}
out[0] = '\0';
p = *cursor + 1;
while (*p) {
unsigned char c = (unsigned char)*p++;
if (c == '"') {
*cursor = p;
return true;
}
if (c < 0x20) {
return false;
}
if (c != '\\') {
if (!append_byte(out, out_size, &pos, (char)c)) {
return false;
}
continue;
}
char esc = *p++;
switch (esc) {
case '"':
case '\\':
case '/':
if (!append_byte(out, out_size, &pos, esc)) return false;
break;
case 'b':
if (!append_byte(out, out_size, &pos, '\b')) return false;
break;
case 'f':
if (!append_byte(out, out_size, &pos, '\f')) return false;
break;
case 'n':
if (!append_byte(out, out_size, &pos, '\n')) return false;
break;
case 'r':
if (!append_byte(out, out_size, &pos, '\r')) return false;
break;
case 't':
if (!append_byte(out, out_size, &pos, '\t')) return false;
break;
case 'u': {
uint32_t cp;
if (!parse_hex4(p, &cp)) return false;
p += 4;
if (cp >= 0xD800 && cp <= 0xDBFF) {
uint32_t low;
if (p[0] != '\\' || p[1] != 'u' ||
!parse_hex4(p + 2, &low) ||
low < 0xDC00 || low > 0xDFFF) {
return false;
}
p += 6;
cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00);
} else if (cp >= 0xDC00 && cp <= 0xDFFF) {
return false;
}
if (!append_decoded_codepoint(out, out_size, &pos, cp)) {
return false;
}
break;
}
default:
return false;
}
}
return false;
}
static bool skip_json_string(const char **cursor) {
const char *p;
if (!cursor || !*cursor || **cursor != '"') {
return false;
}
p = *cursor + 1;
while (*p) {
unsigned char c = (unsigned char)*p++;
if (c == '"') {
*cursor = p;
return true;
}
if (c < 0x20) return false;
if (c == '\\') {
if (*p == 'u') {
uint32_t ignored;
if (!parse_hex4(p + 1, &ignored)) return false;
p += 5;
} else if (*p) {
p++;
} else {
return false;
}
}
}
return false;
}
static bool skip_json_value(const char **cursor) {
const char *p;
if (!cursor || !*cursor) return false;
p = skip_ws(*cursor);
if (*p == '"') {
if (!skip_json_string(&p)) return false;
*cursor = p;
return true;
}
if (*p == '{' || *p == '[') {
int depth = 0;
do {
if (*p == '"' && !skip_json_string(&p)) {
return false;
}
if (*p == '{' || *p == '[') {
depth++;
p++;
} else if (*p == '}' || *p == ']') {
depth--;
p++;
} else if (*p) {
p++;
} else {
return false;
}
} while (depth > 0);
*cursor = p;
return true;
}
while (*p && *p != ',' && *p != '}' && *p != ']') {
p++;
}
*cursor = p;
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;
if (!json || !key || key[0] == '\0' || !out || out_size == 0) {
return false;
}
out[0] = '\0';
p = skip_ws(json);
if (*p != '{') {
return false;
}
p++;
while (1) {
char parsed_key[128];
p = skip_ws(p);
if (*p == '}') {
return false;
}
if (*p != '"' || !parse_json_string(&p, parsed_key,
sizeof(parsed_key))) {
return false;
}
p = skip_ws(p);
if (*p != ':') {
return false;
}
p = skip_ws(p + 1);
if (strcmp(parsed_key, key) == 0) {
if (*p != '"' || !parse_json_string(&p, out, out_size)) {
return false;
}
found = true;
} else if (!skip_json_value(&p)) {
return false;
}
p = skip_ws(p);
if (*p == ',') {
p++;
continue;
}
if (*p == '}') {
return found;
}
return false;
}
}

View file

@ -5,6 +5,7 @@
#include "i18n.h"
#include "message.h"
#include "message_log_tool.h"
#include "module_runtime.h"
#include "ssh_server.h"
#include <signal.h>
#include <unistd.h>
@ -238,17 +239,23 @@ int main(int argc, char **argv) {
}
message_init();
if (tnt_module_runtime_init() < 0) {
fprintf(stderr, "Failed to initialize module runtime\n");
return TNT_EXIT_ERROR;
}
/* Create chat room */
g_room = room_create();
if (!g_room) {
fprintf(stderr, "Failed to create chat room\n");
tnt_module_runtime_shutdown();
return TNT_EXIT_ERROR;
}
/* Initialize server */
if (ssh_server_init(port) < 0) {
fprintf(stderr, "Failed to initialize server\n");
tnt_module_runtime_shutdown();
room_destroy(g_room);
return TNT_EXIT_ERROR;
}
@ -256,6 +263,7 @@ int main(int argc, char **argv) {
/* Start server (blocking) */
int ret = ssh_server_start(0);
tnt_module_runtime_shutdown();
room_destroy(g_room);
return ret;
}

View file

@ -13,7 +13,7 @@ void manual_text_append_interactive(char *buffer, size_t buf_size,
"\n"
"\033[1;37mUse\033[0m\n"
" Type, Enter sends; Up/Down recalls; Tab completes @mentions\n"
" Esc browses; / searches; G latest; i types; : commands; ? keys\n"
" Esc browses; / searches; G latest; i/a/o types; : commands; ? keys\n"
"\n"
"\033[1;37mCommands\033[0m\n",
"\033[1;36mTNT(1) 帮助\033[0m\n"
@ -23,7 +23,7 @@ void manual_text_append_interactive(char *buffer, size_t buf_size,
"\n"
"\033[1;37m使用\033[0m\n"
" 输入并 Enter 发送Up/Down 调出消息Tab 补全 @mention\n"
" Esc 浏览;/ 搜索G 最新i 输入;: 命令;? 按键\n"
" Esc 浏览;/ 搜索G 最新i/a/o 输入;: 命令;? 按键\n"
"\n"
"\033[1;37m命令\033[0m\n"
);

112
src/module_protocol.c Normal file
View file

@ -0,0 +1,112 @@
#include "module_protocol.h"
#include "common.h"
#include "json_text.h"
#include "message_log.h"
#include "utf8.h"
static bool append_was_truncated(size_t pos, size_t buf_size) {
return buf_size == 0 || pos >= buf_size - 1;
}
static bool has_plain_text_controls(const char *text) {
const unsigned char *p = (const unsigned char *)text;
while (p && *p) {
if (*p < 32 || *p == 127) {
return true;
}
p++;
}
return false;
}
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;
size_t before;
if (!buffer || !pos || buf_size == 0) {
return -1;
}
before = *pos;
buffer_appendf(buffer, buf_size, pos,
"{\"type\":\"handshake\",\"protocol\":");
tnt_json_append_string(buffer, buf_size, pos, TNT_MODULE_PROTOCOL_VERSION);
buffer_appendf(buffer, buf_size, pos,
",\"server\":{\"name\":\"tnt\",\"version\":");
tnt_json_append_string(buffer, buf_size, pos, version);
buffer_appendf(buffer, buf_size, pos, "}}\n");
return append_was_truncated(*pos, buf_size) && *pos == buf_size - 1 &&
before != *pos
? -1
: 0;
}
int tnt_module_append_message_created(char *buffer, size_t buf_size,
size_t *pos, const char *message_id,
const message_t *msg) {
char timestamp[64];
size_t before;
if (!buffer || !pos || buf_size == 0 || !message_id || !msg ||
message_id[0] == '\0' || !utf8_is_valid_string(msg->username) ||
!utf8_is_valid_string(msg->content)) {
return -1;
}
before = *pos;
message_log_format_timestamp_utc(msg->timestamp, timestamp,
sizeof(timestamp));
buffer_appendf(buffer, buf_size, pos,
"{\"type\":\"%s\",\"message\":{\"id\":",
TNT_MODULE_EVENT_MESSAGE_CREATED);
tnt_json_append_string(buffer, buf_size, pos, message_id);
buffer_appendf(buffer, buf_size, pos, ",\"timestamp\":");
tnt_json_append_string(buffer, buf_size, pos, timestamp);
buffer_appendf(buffer, buf_size, pos, ",\"sender\":");
tnt_json_append_string(buffer, buf_size, pos, msg->username);
buffer_appendf(buffer, buf_size, pos, ",\"kind\":\"text\","
"\"plain_text\":");
tnt_json_append_string(buffer, buf_size, pos, msg->content);
buffer_appendf(buffer, buf_size, pos, ",\"metadata\":{}}}\n");
return append_was_truncated(*pos, buf_size) && *pos == buf_size - 1 &&
before != *pos
? -1
: 0;
}
bool tnt_module_parse_message_create(const char *line,
tnt_module_message_create_t *out) {
char type[64];
char plain_text[MAX_MESSAGE_LEN];
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_RESPONSE_MESSAGE_CREATE) != 0) {
return false;
}
if (!tnt_json_get_string_field(line, "plain_text", plain_text,
sizeof(plain_text))) {
return false;
}
if (plain_text[0] == '\0' ||
strlen(plain_text) >= sizeof(out->plain_text) ||
!utf8_is_valid_string(plain_text) ||
has_plain_text_controls(plain_text)) {
return false;
}
snprintf(out->plain_text, sizeof(out->plain_text), "%s", plain_text);
return true;
}

537
src/module_runtime.c Normal file
View file

@ -0,0 +1,537 @@
#include "module_runtime.h"
#include "chat_room.h"
#include "common.h"
#include "json_text.h"
#include "module_protocol.h"
#include "utf8.h"
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/select.h>
#include <sys/wait.h>
#include <unistd.h>
#define TNT_MODULE_LINE_MAX 4096
#define TNT_MODULE_HANDSHAKE_TIMEOUT_MS 2000
#define TNT_MODULE_RESPONSE_TIMEOUT_MS 100
struct client;
void notify_mentions(const char *content, const struct client *sender);
typedef struct module_process {
tnt_module_manifest_t manifest;
pid_t pid;
int stdin_fd;
int stdout_fd;
bool active;
} module_process_t;
typedef struct module_event_node {
message_t msg;
struct module_event_node *next;
} module_event_node_t;
static module_process_t g_modules[TNT_MAX_MODULES];
static int g_module_count = 0;
static pthread_t g_module_thread;
static bool g_thread_started = false;
static bool g_running = false;
static pthread_mutex_t g_queue_lock = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t g_queue_cond = PTHREAD_COND_INITIALIZER;
static module_event_node_t *g_queue_head = NULL;
static module_event_node_t *g_queue_tail = NULL;
static int g_queue_len = 0;
static bool is_safe_relative_entrypoint(const char *entrypoint) {
if (!entrypoint || entrypoint[0] == '\0' || entrypoint[0] == '/') {
return false;
}
if (strstr(entrypoint, "..") != NULL) {
return false;
}
for (const unsigned char *p = (const unsigned char *)entrypoint; *p; p++) {
if (*p <= 32 || *p == 127 || *p == '|' || *p == ';' ||
*p == '&' || *p == '`' || *p == '$' || *p == '<' ||
*p == '>' || *p == '\\') {
return false;
}
}
return true;
}
static bool json_array_contains_string(const char *json, const char *key,
const char *value) {
char needle[128];
const char *p;
if (!json || !key || !value ||
snprintf(needle, sizeof(needle), "\"%s\"", key) >=
(int)sizeof(needle)) {
return false;
}
p = strstr(json, needle);
if (!p) return false;
p = strchr(p + strlen(needle), ':');
if (!p) return false;
p++;
while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') p++;
if (*p != '[') return false;
p++;
while (*p) {
char item[128];
while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n' ||
*p == ',') {
p++;
}
if (*p == ']') return false;
if (*p != '"') return false;
const char *cursor = p;
size_t pos = 0;
item[0] = '\0';
cursor++;
while (*cursor && *cursor != '"') {
if (*cursor == '\\') {
cursor++;
if (!*cursor) return false;
}
if (pos + 1 >= sizeof(item)) return false;
item[pos++] = *cursor++;
}
if (*cursor != '"') return false;
item[pos] = '\0';
if (strcmp(item, value) == 0) return true;
p = cursor + 1;
}
return false;
}
int tnt_module_manifest_load(const char *module_dir,
tnt_module_manifest_t *out) {
char manifest_path[PATH_MAX];
char manifest[TNT_MODULE_LINE_MAX];
char protocol[64];
FILE *fp;
size_t n;
if (!module_dir || module_dir[0] == '\0' || !out) {
return -1;
}
memset(out, 0, sizeof(*out));
if (snprintf(manifest_path, sizeof(manifest_path), "%s/tnt-module.json",
module_dir) >= (int)sizeof(manifest_path)) {
return -1;
}
fp = fopen(manifest_path, "rb");
if (!fp) {
return -1;
}
n = fread(manifest, 1, sizeof(manifest) - 1, fp);
fclose(fp);
manifest[n] = '\0';
if (n == 0 || n >= sizeof(manifest) - 1 ||
!tnt_json_get_string_field(manifest, "protocol", protocol,
sizeof(protocol)) ||
strcmp(protocol, TNT_MODULE_PROTOCOL_VERSION) != 0 ||
!tnt_json_get_string_field(manifest, "name", out->name,
sizeof(out->name)) ||
!tnt_json_get_string_field(manifest, "entrypoint", out->entrypoint,
sizeof(out->entrypoint)) ||
!is_valid_username(out->name) ||
!is_safe_relative_entrypoint(out->entrypoint)) {
return -1;
}
out->wants_message_created = json_array_contains_string(
manifest, "events", TNT_MODULE_EVENT_MESSAGE_CREATED);
out->can_read_messages = json_array_contains_string(
manifest, "permissions", "message:read");
out->can_create_messages = json_array_contains_string(
manifest, "permissions", "message:create");
if (!out->wants_message_created || !out->can_read_messages ||
!out->can_create_messages) {
return -1;
}
return 0;
}
static int wait_fd_readable(int fd, int timeout_ms) {
fd_set readfds;
struct timeval tv;
FD_ZERO(&readfds);
FD_SET(fd, &readfds);
tv.tv_sec = timeout_ms / 1000;
tv.tv_usec = (timeout_ms % 1000) * 1000;
return select(fd + 1, &readfds, NULL, NULL, &tv);
}
static int read_line_timeout(int fd, char *line, size_t line_size,
int timeout_ms) {
size_t pos = 0;
if (!line || line_size == 0) return -1;
line[0] = '\0';
while (pos + 1 < line_size) {
char c;
int ready = wait_fd_readable(fd, timeout_ms);
if (ready <= 0) {
break;
}
ssize_t n = read(fd, &c, 1);
if (n <= 0) {
return -1;
}
if (c == '\n') {
line[pos] = '\0';
return (int)pos;
}
if ((unsigned char)c < 32 && c != '\t' && c != '\r') {
return -1;
}
line[pos++] = c;
}
line[pos] = '\0';
return pos > 0 ? (int)pos : 0;
}
static void close_module_process(module_process_t *module) {
if (!module || !module->active) return;
close(module->stdin_fd);
close(module->stdout_fd);
kill(module->pid, SIGTERM);
waitpid(module->pid, NULL, WNOHANG);
module->active = false;
}
static bool handshake_ok(const char *line) {
char type[64];
char protocol[64];
return tnt_json_get_string_field(line, "type", type, sizeof(type)) &&
strcmp(type, "handshake.ok") == 0 &&
tnt_json_get_string_field(line, "protocol", protocol,
sizeof(protocol)) &&
strcmp(protocol, TNT_MODULE_PROTOCOL_VERSION) == 0;
}
static int start_module_process(const char *module_dir,
module_process_t *module) {
int in_pipe[2] = {-1, -1};
int out_pipe[2] = {-1, -1};
char handshake[512] = "";
char line[TNT_MODULE_LINE_MAX];
size_t pos = 0;
if (!module) {
return -1;
}
if (pipe(in_pipe) < 0) {
return -1;
}
if (pipe(out_pipe) < 0) {
close(in_pipe[0]);
close(in_pipe[1]);
return -1;
}
pid_t pid = fork();
if (pid < 0) {
close(in_pipe[0]);
close(in_pipe[1]);
close(out_pipe[0]);
close(out_pipe[1]);
return -1;
}
if (pid == 0) {
close(in_pipe[1]);
close(out_pipe[0]);
if (dup2(in_pipe[0], STDIN_FILENO) < 0 ||
dup2(out_pipe[1], STDOUT_FILENO) < 0 ||
chdir(module_dir) < 0) {
_exit(127);
}
close(in_pipe[0]);
close(out_pipe[1]);
execl(module->manifest.entrypoint, module->manifest.entrypoint,
(char *)NULL);
_exit(127);
}
close(in_pipe[0]);
close(out_pipe[1]);
module->pid = pid;
module->stdin_fd = in_pipe[1];
module->stdout_fd = out_pipe[0];
module->active = true;
if (tnt_module_append_handshake(handshake, sizeof(handshake), &pos,
TNT_VERSION) < 0 ||
write(module->stdin_fd, handshake, strlen(handshake)) !=
(ssize_t)strlen(handshake) ||
read_line_timeout(module->stdout_fd, line, sizeof(line),
TNT_MODULE_HANDSHAKE_TIMEOUT_MS) <= 0 ||
!handshake_ok(line)) {
close_module_process(module);
return -1;
}
return 0;
}
static void enqueue_message(const message_t *msg) {
module_event_node_t *node;
if (!msg) return;
pthread_mutex_lock(&g_queue_lock);
if (!g_running || g_queue_len >= TNT_MODULE_QUEUE_LIMIT) {
pthread_mutex_unlock(&g_queue_lock);
if (g_queue_len >= TNT_MODULE_QUEUE_LIMIT) {
fprintf(stderr, "module runtime: event queue full, dropping\n");
}
return;
}
node = calloc(1, sizeof(*node));
if (!node) {
pthread_mutex_unlock(&g_queue_lock);
return;
}
node->msg = *msg;
if (g_queue_tail) {
g_queue_tail->next = node;
} else {
g_queue_head = node;
}
g_queue_tail = node;
g_queue_len++;
pthread_cond_signal(&g_queue_cond);
pthread_mutex_unlock(&g_queue_lock);
}
static module_event_node_t *dequeue_message(void) {
module_event_node_t *node;
pthread_mutex_lock(&g_queue_lock);
while (g_running && !g_queue_head) {
pthread_cond_wait(&g_queue_cond, &g_queue_lock);
}
node = g_queue_head;
if (node) {
g_queue_head = node->next;
if (!g_queue_head) g_queue_tail = NULL;
g_queue_len--;
}
pthread_mutex_unlock(&g_queue_lock);
return node;
}
static void publish_module_message(const module_process_t *module,
const char *plain_text) {
message_t msg = {
.timestamp = time(NULL),
};
if (!module || !plain_text || plain_text[0] == '\0') return;
snprintf(msg.username, sizeof(msg.username), "module:%s",
module->manifest.name);
snprintf(msg.content, sizeof(msg.content), "%s", plain_text);
if (message_save(&msg) < 0) {
fprintf(stderr, "module runtime: failed to persist module message\n");
return;
}
room_broadcast(g_room, &msg);
notify_mentions(msg.content, NULL);
}
static void handle_module_response(module_process_t *module, const char *line) {
tnt_module_message_create_t create;
char type[64];
if (!module || !line || line[0] == '\0') return;
if (tnt_module_parse_message_create(line, &create)) {
publish_module_message(module, create.plain_text);
return;
}
if (tnt_json_get_string_field(line, "type", type, sizeof(type)) &&
strcmp(type, "event.ok") == 0) {
return;
}
fprintf(stderr, "module runtime: ignored invalid response from %s\n",
module->manifest.name);
}
static void deliver_message_to_module(module_process_t *module,
const message_t *msg,
uint64_t event_id) {
char event[TNT_MODULE_LINE_MAX] = "";
char line[TNT_MODULE_LINE_MAX];
char message_id[64];
size_t pos = 0;
if (!module || !module->active || !msg) return;
snprintf(message_id, sizeof(message_id), "local-%llu",
(unsigned long long)event_id);
if (tnt_module_append_message_created(event, sizeof(event), &pos,
message_id, msg) < 0 ||
write(module->stdin_fd, event, strlen(event)) !=
(ssize_t)strlen(event)) {
fprintf(stderr, "module runtime: disabling %s after write failure\n",
module->manifest.name);
close_module_process(module);
return;
}
while (1) {
int n = read_line_timeout(module->stdout_fd, line, sizeof(line),
TNT_MODULE_RESPONSE_TIMEOUT_MS);
if (n == 0) {
return;
}
if (n < 0) {
fprintf(stderr, "module runtime: disabling %s after read failure\n",
module->manifest.name);
close_module_process(module);
return;
}
handle_module_response(module, line);
}
}
static void *module_worker_main(void *arg) {
uint64_t event_id = 0;
(void)arg;
while (g_running) {
module_event_node_t *node = dequeue_message();
if (!node) {
continue;
}
event_id++;
for (int i = 0; i < g_module_count; i++) {
deliver_message_to_module(&g_modules[i], &node->msg, event_id);
}
free(node);
}
return NULL;
}
static int load_modules_from_env(void) {
const char *paths = getenv("TNT_MODULE_PATHS");
char copy[4096];
char *saveptr = NULL;
char *token;
if (!paths || paths[0] == '\0') {
return 0;
}
if (strlen(paths) >= sizeof(copy)) {
fprintf(stderr, "module runtime: TNT_MODULE_PATHS too long\n");
return -1;
}
snprintf(copy, sizeof(copy), "%s", paths);
token = strtok_r(copy, ":", &saveptr);
while (token && g_module_count < TNT_MAX_MODULES) {
module_process_t *module = &g_modules[g_module_count];
memset(module, 0, sizeof(*module));
module->stdin_fd = -1;
module->stdout_fd = -1;
if (tnt_module_manifest_load(token, &module->manifest) == 0 &&
start_module_process(token, module) == 0) {
fprintf(stderr, "module runtime: enabled %s\n",
module->manifest.name);
g_module_count++;
} else {
fprintf(stderr, "module runtime: failed to enable module at %s\n",
token);
}
token = strtok_r(NULL, ":", &saveptr);
}
return 0;
}
int tnt_module_runtime_init(void) {
g_module_count = 0;
g_running = false;
if (load_modules_from_env() < 0) {
return -1;
}
if (g_module_count == 0) {
return 0;
}
g_running = true;
if (pthread_create(&g_module_thread, NULL, module_worker_main, NULL) != 0) {
g_running = false;
for (int i = 0; i < g_module_count; i++) {
close_module_process(&g_modules[i]);
}
g_module_count = 0;
return -1;
}
g_thread_started = true;
return 0;
}
void tnt_module_runtime_shutdown(void) {
module_event_node_t *node;
pthread_mutex_lock(&g_queue_lock);
g_running = false;
pthread_cond_broadcast(&g_queue_cond);
pthread_mutex_unlock(&g_queue_lock);
if (g_thread_started) {
pthread_join(g_module_thread, NULL);
g_thread_started = false;
}
while ((node = g_queue_head) != NULL) {
g_queue_head = node->next;
free(node);
}
g_queue_tail = NULL;
g_queue_len = 0;
for (int i = 0; i < g_module_count; i++) {
close_module_process(&g_modules[i]);
}
g_module_count = 0;
}
void tnt_module_runtime_publish_message_created(const message_t *msg) {
if (!msg || g_module_count == 0) {
return;
}
enqueue_message(msg);
}

113
src/tui.c
View file

@ -21,6 +21,25 @@ static const char *username_color(const char *name) {
return colors[h % 6];
}
static char *client_render_buffer(client_t *client, size_t min_size) {
if (!client || min_size == 0) {
return NULL;
}
if (client->render_buffer_capacity >= min_size) {
return client->render_buffer;
}
char *grown = realloc(client->render_buffer, min_size);
if (!grown) {
return NULL;
}
client->render_buffer = grown;
client->render_buffer_capacity = min_size;
return client->render_buffer;
}
static void format_message_colored(const message_t *msg, char *buffer,
size_t buf_size, int width,
const char *my_username) {
@ -245,7 +264,7 @@ void tui_render_screen(client_t *client) {
if (render_height < 4) render_height = 4;
const size_t buf_size = (size_t)(render_height + 10) * (MAX_MESSAGE_LEN + 64) + 2048;
char *buffer = malloc(buf_size);
char *buffer = client_render_buffer(client, buf_size);
if (!buffer) return;
size_t pos = 0;
buffer[0] = '\0';
@ -255,6 +274,7 @@ void tui_render_screen(client_t *client) {
int online = g_room->client_count;
int msg_count = g_room->message_count;
pthread_rwlock_unlock(&g_room->lock);
int raw_msg_count = msg_count;
/* Calculate which messages to show. The initial slice is capped by
* message count; the lock-held copy below tightens "latest" slices so
@ -280,10 +300,57 @@ void tui_render_screen(client_t *client) {
int end = start + msg_height;
if (end > msg_count) end = msg_count;
/* Allocate snapshot outside the lock to avoid blocking writers */
message_t *visible_messages = NULL;
message_t *msg_snapshot = NULL;
int snapshot_count = 0;
if (client->mute_joins && msg_count > 0) {
visible_messages = calloc(MAX_MESSAGES, sizeof(message_t));
if (visible_messages) {
int visible_count = 0;
pthread_rwlock_rdlock(&g_room->lock);
online = g_room->client_count;
raw_msg_count = g_room->message_count;
for (int i = 0; i < g_room->message_count; i++) {
if (!system_message_is_join_leave(&g_room->messages[i])) {
visible_messages[visible_count++] = g_room->messages[i];
}
}
pthread_rwlock_unlock(&g_room->lock);
msg_count = visible_count;
latest_scroll_start = history_view_max_scroll(msg_count, msg_height);
anchor_latest = client->mode != MODE_NORMAL ||
client->follow_tail ||
client->scroll_pos >= latest_scroll_start;
if (client->mode == MODE_NORMAL) {
start = client->scroll_pos;
if (start > latest_scroll_start) {
start = latest_scroll_start;
}
if (start < 0) start = 0;
} else {
start = latest_scroll_start;
}
end = start + msg_height;
if (end > msg_count) end = msg_count;
if (anchor_latest) {
start = history_view_latest_start_for_height(
visible_messages, msg_count, msg_height);
end = msg_count;
}
snapshot_count = end - start;
if (snapshot_count > 0) {
msg_snapshot = visible_messages + start;
}
}
}
if (!visible_messages) {
/* Allocate snapshot outside the lock to avoid blocking writers */
int snapshot_capacity = msg_height;
int snapshot_count = end - start;
snapshot_count = end - start;
if (snapshot_count > 0 && snapshot_capacity > 0) {
msg_snapshot = calloc(snapshot_capacity, sizeof(message_t));
@ -316,11 +383,12 @@ void tui_render_screen(client_t *client) {
}
pthread_rwlock_unlock(&g_room->lock);
}
}
/* Now render using snapshot (no lock held) */
/* If mute_joins is set, remove join/leave messages from snapshot in place */
if (client->mute_joins && msg_snapshot) {
if (client->mute_joins && msg_snapshot && !anchor_latest) {
int filtered = 0;
for (int i = 0; i < snapshot_count; i++) {
if (!system_message_is_join_leave(&msg_snapshot[i])) {
@ -513,9 +581,26 @@ void tui_render_screen(client_t *client) {
buffer_appendf(buffer, buf_size, &pos, "%s\033[K\r\n", msg_line);
rows_written++;
}
free(msg_snapshot);
}
if (rows_written == 0) {
const char *empty_text =
client->mute_joins && raw_msg_count > 0
? i18n_text(client->ui_lang, I18N_EMPTY_FILTERED)
: i18n_text(client->ui_lang, I18N_EMPTY_ROOM);
int empty_width = utf8_string_width(empty_text);
int empty_pad = (render_width - empty_width) / 2;
if (empty_pad < 0) empty_pad = 0;
for (int i = 0; i < empty_pad; i++) {
buffer_append_bytes(buffer, buf_size, &pos, " ", 1);
}
buffer_appendf(buffer, buf_size, &pos,
"\033[2;37m%s\033[0m\033[K\r\n", empty_text);
rows_written++;
}
free(visible_messages ? visible_messages : msg_snapshot);
/* Fill empty lines and clear them */
for (int i = rows_written; i < msg_height; i++) {
buffer_appendf(buffer, buf_size, &pos, "\033[K\r\n");
@ -531,7 +616,6 @@ void tui_render_screen(client_t *client) {
tui_status_append(buffer, buf_size, &pos, client, msg_count, start, end);
client_send(client, buffer, pos);
free(buffer);
}
/* Render the input line.
@ -608,6 +692,23 @@ void tui_render_input(client_t *client, const char *input) {
client_send(client, buffer, strlen(buffer));
}
void tui_render_command_input(client_t *client) {
if (!client || !client->connected) return;
int rh = client->height;
if (rh < 4) rh = 4;
char buffer[sizeof(client->command_input) + 64];
size_t pos = 0;
buffer[0] = '\0';
buffer_appendf(buffer, sizeof(buffer), &pos,
"\033[%d;1H" ANSI_CLEAR_LINE, rh);
tui_status_append(buffer, sizeof(buffer), &pos, client, 0, 0, 0);
client_send(client, buffer, pos);
}
/* Render the command output screen */
void tui_render_command_output(client_t *client) {
if (!client || !client->connected) return;

109
tests/test_empty_view.sh Executable file
View file

@ -0,0 +1,109 @@
#!/bin/sh
# Regression test for the empty/filtered-empty main view.
PORT=${PORT:-12350}
PASS=0
FAIL=0
BIN="../tnt"
SERVER_PID=""
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-empty-view-test.XXXXXX")
cleanup() {
if [ -n "$SERVER_PID" ]; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
rm -rf "$STATE_DIR"
}
trap cleanup EXIT
if ! command -v expect >/dev/null 2>&1; then
echo "expect not installed; skipping empty view test"
exit 0
fi
if [ ! -f "$BIN" ]; then
echo "Error: Binary $BIN not found. Run make first."
exit 1
fi
SSH_OPTS="-e none -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p $PORT"
echo "=== TNT Empty View Test ==="
TNT_LANG=en TNT_RATE_LIMIT=0 "$BIN" --bind 127.0.0.1 \
-p "$PORT" -d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 &
SERVER_PID=$!
SERVER_READY=0
for _ in 1 2 3 4 5; do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "x Server failed to start"
sed -n '1,120p' "$STATE_DIR/server.log"
exit 1
fi
if grep -q "TNT chat server listening" "$STATE_DIR/server.log"; then
SERVER_READY=1
break
fi
sleep 1
done
if [ "$SERVER_READY" -eq 1 ]; then
echo "✓ server started"
PASS=$((PASS + 1))
else
echo "x Server did not become ready"
sed -n '1,120p' "$STATE_DIR/server.log"
exit 1
fi
VIEW_SCRIPT="$STATE_DIR/empty-view.expect"
cat >"$VIEW_SCRIPT" <<EOF
set timeout 10
stty rows 10 columns 80
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "viewer\r"
expect "Esc NORMAL"
send -- "\033"
expect "NORMAL"
send -- ":"
expect ":"
send -- "mute-joins\r"
expect "Join/leave notifications"
expect "muted"
expect "q:close"
send -- "q"
expect "NORMAL"
expect "No visible messages"
send -- ":"
expect ":"
send -- "last 5\r"
expect "Last 0"
expect "No messages to show"
send -- "q"
expect "NORMAL"
sleep 0.2
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$VIEW_SCRIPT" >"$STATE_DIR/empty-view.log" 2>&1; then
echo "✓ filtered-empty main view shows a state hint"
PASS=$((PASS + 1))
else
echo "x filtered-empty main view did not show state hint"
sed -n '1,220p' "$STATE_DIR/empty-view.log"
sed -n '1,120p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
echo ""
echo "PASSED: $PASS"
echo "FAILED: $FAIL"
[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed"
exit "$FAIL"

View file

@ -591,6 +591,38 @@ else
FAIL=$((FAIL + 1))
fi
VIM_INSERT_ALIASES_SCRIPT="$STATE_DIR/vim-insert-aliases.expect"
cat >"$VIM_INSERT_ALIASES_SCRIPT" <<EOF
set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "vimalias\r"
expect "Esc NORMAL"
send -- "\033"
expect "NORMAL"
send -- "a"
expect "INSERT"
send -- "\033"
expect "NORMAL"
send -- "o"
expect "INSERT"
sleep 0.2
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$VIM_INSERT_ALIASES_SCRIPT" >"$STATE_DIR/vim-insert-aliases.log" 2>&1; then
echo "✓ Vim insert aliases enter INSERT mode"
PASS=$((PASS + 1))
else
echo "x Vim insert aliases failed"
sed -n '1,200p' "$STATE_DIR/vim-insert-aliases.log"
sed -n '1,120p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
echo ""
echo "PASSED: $PASS"
echo "FAILED: $FAIL"

130
tests/test_module_runtime.sh Executable file
View file

@ -0,0 +1,130 @@
#!/bin/sh
# Module runtime regression tests for TNT.
PORT=${PORT:-12352}
PASS=0
FAIL=0
BIN="../tnt"
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-module-test.XXXXXX")
MODULE_DIR="$STATE_DIR/echo-module"
SERVER_PID=""
cleanup() {
if [ -n "$SERVER_PID" ]; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
rm -rf "$STATE_DIR"
}
trap cleanup EXIT
if [ ! -f "$BIN" ]; then
echo "Error: Binary $BIN not found. Run make first."
exit 1
fi
SSH_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
mkdir -p "$MODULE_DIR"
cat >"$MODULE_DIR/tnt-module.json" <<'JSON'
{
"protocol": "tnt.module.v1",
"name": "echo-module",
"version": "0.1.0",
"entrypoint": "./echo-module.sh",
"permissions": ["message:read", "message:create"],
"events": ["message.created"]
}
JSON
cat >"$MODULE_DIR/echo-module.sh" <<'SH'
#!/bin/sh
json_escape() {
printf '%s' "$1" | awk '
BEGIN { ORS = "" }
{ gsub(/\\/,"\\\\"); gsub(/"/,"\\\""); print }
'
}
extract_string() {
key=$1
line=$2
printf '%s\n' "$line" | sed -n "s/.*\"$key\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p"
}
while IFS= read -r line; do
protocol=$(extract_string protocol "$line")
plain_text=$(extract_string plain_text "$line")
if printf '%s\n' "$line" | grep -q '"type"[[:space:]]*:[[:space:]]*"handshake"'; then
if [ "$protocol" = "tnt.module.v1" ]; then
printf '{"type":"handshake.ok","protocol":"tnt.module.v1","module":{"name":"echo-module","version":"0.1.0"}}\n'
else
printf '{"type":"error","code":"unsupported_protocol","message":"requires tnt.module.v1"}\n'
fi
elif printf '%s\n' "$line" | grep -q '"type"[[:space:]]*:[[:space:]]*"message.created"' && [ -n "$plain_text" ]; then
escaped=$(json_escape "echo: $plain_text")
printf '{"type":"message.create","plain_text":"%s"}\n' "$escaped"
else
printf '{"type":"event.ok"}\n'
fi
done
SH
chmod +x "$MODULE_DIR/echo-module.sh"
echo "=== TNT Module Runtime Tests ==="
TNT_LANG=en TNT_RATE_LIMIT=0 TNT_MODULE_PATHS="$MODULE_DIR" \
"$BIN" -p "$PORT" -d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 &
SERVER_PID=$!
HEALTH_OUTPUT=""
for _ in 1 2 3 4 5 6 7 8 9 10; do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "x server failed to start"
sed -n '1,160p' "$STATE_DIR/server.log"
exit 1
fi
HEALTH_OUTPUT=$(ssh $SSH_OPTS localhost health 2>/dev/null || true)
[ "$HEALTH_OUTPUT" = "ok" ] && break
sleep 1
done
if [ "$HEALTH_OUTPUT" = "ok" ]; then
echo "✓ server starts with module runtime"
PASS=$((PASS + 1))
else
echo "x health failed: $HEALTH_OUTPUT"
sed -n '1,160p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
POST_OUTPUT=$(ssh $SSH_OPTS alice@localhost post "hello module" 2>/dev/null || true)
if [ "$POST_OUTPUT" = "posted" ]; then
echo "✓ post succeeds with module runtime"
PASS=$((PASS + 1))
else
echo "x post failed: $POST_OUTPUT"
FAIL=$((FAIL + 1))
fi
FOUND=0
for _ in 1 2 3 4 5; do
TAIL_OUTPUT=$(ssh $SSH_OPTS localhost "tail -n 5" 2>/dev/null || true)
if printf '%s\n' "$TAIL_OUTPUT" | grep -q 'module:echo-module.*echo: hello module'; then
FOUND=1
break
fi
sleep 1
done
if [ "$FOUND" -eq 1 ]; then
echo "✓ module response is persisted and visible"
PASS=$((PASS + 1))
else
echo "x module response missing"
printf '%s\n' "$TAIL_OUTPUT"
sed -n '1,200p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
printf '\nPASSED: %d\nFAILED: %d\n' "$PASS" "$FAIL"
[ "$FAIL" -eq 0 ]

132
tests/test_mute_joins_view.sh Executable file
View file

@ -0,0 +1,132 @@
#!/bin/sh
# Regression test for :mute-joins filling the latest view with real messages.
PORT=${PORT:-12349}
PASS=0
FAIL=0
BIN="../tnt"
SERVER_PID=""
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-mute-joins-test.XXXXXX")
cleanup() {
if [ -n "$SERVER_PID" ]; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
rm -rf "$STATE_DIR"
}
trap cleanup EXIT
if ! command -v expect >/dev/null 2>&1; then
echo "expect not installed; skipping mute-joins view test"
exit 0
fi
if [ ! -f "$BIN" ]; then
echo "Error: Binary $BIN not found. Run make first."
exit 1
fi
SSH_OPTS="-e none -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p $PORT"
echo "=== TNT Mute Joins View Test ==="
seed_ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
i=1
while [ "$i" -le 18 ]; do
printf '%s|fixture|kept visible %02d\n' "$seed_ts" "$i" >>"$STATE_DIR/messages.log"
i=$((i + 1))
done
i=1
while [ "$i" -le 20 ]; do
printf '%s|system|noise%02d joined the room\n' "$seed_ts" "$i" >>"$STATE_DIR/messages.log"
i=$((i + 1))
done
TNT_LANG=en TNT_RATE_LIMIT=0 "$BIN" --bind 127.0.0.1 \
-p "$PORT" -d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 &
SERVER_PID=$!
SERVER_READY=0
for _ in 1 2 3 4 5; do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "x Server failed to start"
sed -n '1,120p' "$STATE_DIR/server.log"
exit 1
fi
if grep -q "TNT chat server listening" "$STATE_DIR/server.log"; then
SERVER_READY=1
break
fi
sleep 1
done
if [ "$SERVER_READY" -eq 1 ]; then
echo "✓ server started"
PASS=$((PASS + 1))
else
echo "x Server did not become ready"
sed -n '1,120p' "$STATE_DIR/server.log"
exit 1
fi
VIEW_SCRIPT="$STATE_DIR/mute-joins-view.expect"
cat >"$VIEW_SCRIPT" <<EOF
set timeout 10
stty rows 12 columns 100
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "viewer\r"
expect "Esc NORMAL"
send -- "\033"
expect "NORMAL"
send -- ":"
expect ":"
send -- "mute-joins\r"
expect "Join/leave notifications"
expect "muted"
expect "q:close"
send -- "q"
expect "NORMAL"
expect "kept visible 11"
expect "kept visible 18"
send -- "k"
expect "kept visible 09"
send -- ":"
expect ":"
send -- "last 10\r"
expect "Last"
expect "kept visible 18"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "search joined\r"
expect {showing last 0 match}
expect "No matches"
send -- "q"
expect "NORMAL"
sleep 0.2
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$VIEW_SCRIPT" >"$STATE_DIR/mute-joins-view.log" 2>&1; then
echo "✓ :mute-joins fills latest view with non-join messages"
PASS=$((PASS + 1))
else
echo "x :mute-joins latest view did not show older non-join messages"
sed -n '1,220p' "$STATE_DIR/mute-joins-view.log"
sed -n '1,120p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
echo ""
echo "PASSED: $PASS"
echo "FAILED: $FAIL"
[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed"
exit "$FAIL"

View file

@ -37,6 +37,7 @@ SSH_OPTS="-e none -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/nul
SSH_EXEC_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
BOB_READY="$STATE_DIR/bob.ready"
PRIVATE_SENT="$STATE_DIR/private.sent"
REPLY_SENT="$STATE_DIR/reply.sent"
wait_for_health() {
out=""
@ -92,7 +93,16 @@ exec touch "$BOB_READY"
exec sh -c "while \[ ! -f '$PRIVATE_SENT' \]; do sleep 1; done"
expect "私信"
expect "alice"
expect "private lifecycle ping"
expect "private lifecycle second"
expect "private lifecycle first"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "reply private lifecycle reply\r"
expect "私信已发送给 alice"
exec touch "$REPLY_SENT"
expect "q:关闭"
send -- "q"
expect "NORMAL"
@ -201,12 +211,46 @@ send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "msg bob private lifecycle ping\r"
send -- "msg bob private lifecycle first\r"
expect "私信已发送给 bob"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "msg bob private lifecycle second\r"
expect "私信已发送给 bob"
exec touch "$PRIVATE_SENT"
expect "q:关闭"
send -- "q"
expect "NORMAL"
exec sh -c "while \[ ! -f '$REPLY_SENT' \]; do sleep 1; done"
send -- ":"
expect ":"
send -- "inbox\r"
expect "bob"
expect "private lifecycle reply"
expect "你 -> bob"
expect "private lifecycle second"
expect "private lifecycle first"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "inbox clear\r"
expect "私信已清空"
expect "(空)"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "reply should not send after clear\r"
expect "没有可回复的私信"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "nick alice2\r"
@ -244,6 +288,20 @@ else
fi
BOB_PID=""
if grep -q '.*alice.*private lifecycle second' "$STATE_DIR/bob.log" &&
grep -Eq '私信.*[0-9]+ 新' "$STATE_DIR/bob.log" &&
grep -q '\*.*alice.*private lifecycle second' "$STATE_DIR/bob.log" &&
grep -Eq '私信.*[0-9]+ 新' "$STATE_DIR/alice.log" &&
grep -q '\*.*bob.*private lifecycle reply' "$STATE_DIR/alice.log"; then
echo "✓ unread private messages are visibly marked in inbox"
PASS=$((PASS + 1))
else
echo "✗ inbox unread marker missing"
sed -n '1,220p' "$STATE_DIR/bob.log"
sed -n '1,260p' "$STATE_DIR/alice.log"
FAIL=$((FAIL + 1))
fi
TAIL_OUTPUT=$(ssh $SSH_EXEC_OPTS localhost "tail -n 10" 2>/dev/null || true)
printf '%s\n' "$TAIL_OUTPUT" | grep -q 'hello lifecycle alpha' &&
printf '%s\n' "$TAIL_OUTPUT" | grep -q 'alice2 ships lifecycle'

View file

@ -11,6 +11,10 @@ endif
# Source files
UTF8_SRC = ../../src/utf8.c
INPUT_BUFFER_SRC = ../../src/input_buffer.c
JSON_TEXT_SRC = ../../src/json_text.c
MODULE_PROTOCOL_SRC = ../../src/module_protocol.c
MODULE_RUNTIME_SRC = ../../src/module_runtime.c
MESSAGE_SRC = ../../src/message.c
MESSAGE_LOG_SRC = ../../src/message_log.c
COMMON_SRC = ../../src/common.c
@ -28,7 +32,7 @@ HELP_TEXT_SRC = ../../src/help_text.c
MANUAL_TEXT_SRC = ../../src/manual_text.c
RATELIMIT_SRC = ../../src/ratelimit.c
TESTS = test_utf8 test_message test_chat_room test_history_view test_i18n test_system_message test_command_catalog test_exec_catalog test_help_text test_manual_text test_cli_text test_tntctl_text test_ratelimit test_config_defaults
TESTS = test_utf8 test_input_buffer test_json_text test_module_protocol test_module_runtime test_message test_chat_room test_history_view test_i18n test_system_message test_command_catalog test_exec_catalog test_help_text test_manual_text test_cli_text test_tntctl_text test_ratelimit test_config_defaults
.PHONY: all clean run
@ -37,6 +41,18 @@ all: $(TESTS)
test_utf8: test_utf8.c $(UTF8_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_input_buffer: test_input_buffer.c $(INPUT_BUFFER_SRC) $(UTF8_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_json_text: test_json_text.c $(JSON_TEXT_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_module_protocol: test_module_protocol.c $(MODULE_PROTOCOL_SRC) $(JSON_TEXT_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_module_runtime: test_module_runtime.c $(MODULE_RUNTIME_SRC) $(MODULE_PROTOCOL_SRC) $(JSON_TEXT_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_message: test_message.c $(MESSAGE_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
@ -80,6 +96,18 @@ run: all
@echo "=== Running UTF-8 Tests ==="
./test_utf8
@echo ""
@echo "=== Running Input Buffer Tests ==="
./test_input_buffer
@echo ""
@echo "=== Running JSON Text Tests ==="
./test_json_text
@echo ""
@echo "=== Running Module Protocol Tests ==="
./test_module_protocol
@echo ""
@echo "=== Running Module Runtime Tests ==="
./test_module_runtime
@echo ""
@echo "=== Running Message Tests ==="
./test_message
@echo ""

View file

@ -35,6 +35,18 @@ TEST(matches_canonical_names_and_aliases) {
assert(id == TNT_COMMAND_MSG);
assert(strcmp(args, "alice hello") == 0);
assert(command_catalog_match("reply hello back", &id, &args));
assert(id == TNT_COMMAND_REPLY);
assert(strcmp(args, "hello back") == 0);
assert(command_catalog_match("r hello back", &id, &args));
assert(id == TNT_COMMAND_REPLY);
assert(strcmp(args, "hello back") == 0);
assert(command_catalog_match("inbox clear", &id, &args));
assert(id == TNT_COMMAND_INBOX);
assert(strcmp(args, "clear") == 0);
assert(command_catalog_match("language zh", &id, &args));
assert(id == TNT_COMMAND_LANG);
assert(strcmp(args, "zh") == 0);
@ -65,6 +77,11 @@ TEST(validates_argument_shapes) {
assert(!command_catalog_args_valid(TNT_COMMAND_MSG, NULL));
assert(command_catalog_args_valid(TNT_COMMAND_MSG, "alice hello"));
assert(!command_catalog_args_valid(TNT_COMMAND_REPLY, ""));
assert(command_catalog_args_valid(TNT_COMMAND_REPLY, "hello back"));
assert(command_catalog_args_valid(TNT_COMMAND_INBOX, NULL));
assert(command_catalog_args_valid(TNT_COMMAND_INBOX, "clear"));
assert(!command_catalog_args_valid(TNT_COMMAND_INBOX, "clear now"));
assert(!command_catalog_args_valid(TNT_COMMAND_SEARCH, ""));
assert(command_catalog_args_valid(TNT_COMMAND_SEARCH, "needle"));
@ -92,13 +109,15 @@ TEST(generates_localized_help_sections) {
assert(strstr(en, ":users, :list, :who") != NULL);
assert(strstr(en, "Show online users") != NULL);
assert(strstr(en, ":msg <user> <message>") != NULL);
assert(strstr(en, "Show private messages") != NULL);
assert(strstr(en, ":reply <message>") != NULL);
assert(strstr(en, "Show or clear private messages") != NULL);
assert(strstr(en, ":support") == NULL);
assert(strstr(zh, ":users, :list, :who") != NULL);
assert(strstr(zh, "显示在线用户") != NULL);
assert(strstr(zh, "查看私信") != NULL);
assert(strstr(zh, "查看或清空私信") != NULL);
assert(strstr(zh, ":msg <user> <message>") != NULL);
assert(strstr(zh, ":reply <message>") != NULL);
assert(strstr(zh, "<用户>") == NULL);
assert(strstr(zh, "<消息>") == NULL);
assert(strstr(zh, ":support") == NULL);
@ -120,6 +139,19 @@ TEST(generates_localized_usage) {
assert(strcmp(zh, "用法: msg <user> <message>\n"
" w <user> <message>\n") == 0);
en[0] = '\0';
en_pos = 0;
command_catalog_append_usage(en, sizeof(en), &en_pos,
TNT_COMMAND_REPLY, UI_LANG_EN);
assert(strcmp(en, "Usage: reply <message>\n"
" r <message>\n") == 0);
zh[0] = '\0';
zh_pos = 0;
command_catalog_append_usage(zh, sizeof(zh), &zh_pos,
TNT_COMMAND_INBOX, UI_LANG_ZH);
assert(strcmp(zh, "用法: inbox [clear]\n") == 0);
en[0] = '\0';
en_pos = 0;
command_catalog_append_usage(en, sizeof(en), &en_pos,

View file

@ -136,6 +136,10 @@ TEST(text_lookup_matches_language) {
"online") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_TITLE_ONLINE_FORMAT),
"在线") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_EMPTY_ROOM),
"No messages") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EMPTY_FILTERED),
"可见") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_IDLE_TIMEOUT_FORMAT),
"idle timeout") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_IDLE_TIMEOUT_FORMAT),
@ -144,14 +148,34 @@ TEST(text_lookup_matches_language) {
"Private message sent") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_MSG_SENT_FORMAT),
"私信已发送") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_REPLY_NO_TARGET),
"No private message") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_REPLY_NO_TARGET),
"可回复") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_INBOX_TITLE),
"Private messages") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_INBOX_TITLE),
"私信") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_INBOX_SENT_TO_FORMAT),
"you ->") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_INBOX_SENT_TO_FORMAT),
"你 ->") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_INBOX_CLEARED),
"cleared") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_INBOX_CLEARED),
"清空") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_INBOX_UNREAD_FORMAT),
"new") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_INBOX_UNREAD_FORMAT),
"") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_SEARCH_HEADER_FORMAT),
"Search") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_SEARCH_HEADER_FORMAT),
"搜索") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_LAST_EMPTY),
"No messages") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_SEARCH_EMPTY),
"匹配") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_LANG_CURRENT_FORMAT),
"lang <en|zh>") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_LANG_CURRENT_FORMAT),

View file

@ -0,0 +1,130 @@
#include "../../include/input_buffer.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
#define TEST(name) static void test_##name(void)
#define RUN_TEST(name) do { \
printf("Running %s... ", #name); \
test_##name(); \
printf("ok\n"); \
tests_passed++; \
} while (0)
static int tests_passed = 0;
TEST(appends_ascii_until_capacity) {
char input[6] = "";
assert(tnt_input_append_ascii(input, sizeof(input), 'h') ==
TNT_INPUT_APPEND_OK);
assert(tnt_input_append_ascii(input, sizeof(input), 'e') ==
TNT_INPUT_APPEND_OK);
assert(tnt_input_append_ascii(input, sizeof(input), 'l') ==
TNT_INPUT_APPEND_OK);
assert(tnt_input_append_ascii(input, sizeof(input), 'l') ==
TNT_INPUT_APPEND_OK);
assert(tnt_input_append_ascii(input, sizeof(input), 'o') ==
TNT_INPUT_APPEND_OK);
assert(strcmp(input, "hello") == 0);
assert(tnt_input_append_ascii(input, sizeof(input), '!') ==
TNT_INPUT_APPEND_OVERFLOW);
assert(strcmp(input, "hello") == 0);
}
TEST(rejects_ascii_control_bytes) {
char input[8] = "x";
assert(tnt_input_append_ascii(input, sizeof(input), '\n') ==
TNT_INPUT_APPEND_IGNORED);
assert(strcmp(input, "x") == 0);
}
TEST(appends_valid_utf8_sequence) {
char input[16] = "hi ";
assert(tnt_input_append_utf8_sequence(input, sizeof(input),
"\xE4\xB8\xAD", 3) ==
TNT_INPUT_APPEND_OK);
assert(strcmp(input, "hi \xE4\xB8\xAD") == 0);
}
TEST(rejects_invalid_utf8_sequence) {
char input[16] = "";
assert(tnt_input_append_utf8_sequence(input, sizeof(input),
"\xC3\x28", 2) ==
TNT_INPUT_APPEND_INVALID_UTF8);
assert(strcmp(input, "") == 0);
}
TEST(paste_stream_normalizes_newlines_and_tabs) {
char input[32] = "";
tnt_input_utf8_state_t state = {0};
assert(tnt_input_append_stream_byte(input, sizeof(input), &state,
'a', true) == TNT_INPUT_APPEND_OK);
assert(tnt_input_append_stream_byte(input, sizeof(input), &state,
'\n', true) == TNT_INPUT_APPEND_OK);
assert(tnt_input_append_stream_byte(input, sizeof(input), &state,
'\t', true) == TNT_INPUT_APPEND_OK);
assert(tnt_input_append_stream_byte(input, sizeof(input), &state,
'b', true) == TNT_INPUT_APPEND_OK);
assert(tnt_input_utf8_state_finish(&state) == TNT_INPUT_APPEND_OK);
assert(strcmp(input, "a b") == 0);
}
TEST(paste_stream_validates_multibyte_utf8) {
char input[32] = "";
tnt_input_utf8_state_t state = {0};
assert(tnt_input_append_stream_byte(input, sizeof(input), &state,
0xE4, true) == TNT_INPUT_APPEND_OK);
assert(tnt_input_append_stream_byte(input, sizeof(input), &state,
0xB8, true) == TNT_INPUT_APPEND_OK);
assert(tnt_input_append_stream_byte(input, sizeof(input), &state,
0xAD, true) == TNT_INPUT_APPEND_OK);
assert(tnt_input_utf8_state_finish(&state) == TNT_INPUT_APPEND_OK);
assert(strcmp(input, "\xE4\xB8\xAD") == 0);
}
TEST(paste_stream_rejects_partial_utf8_at_end) {
char input[32] = "";
tnt_input_utf8_state_t state = {0};
assert(tnt_input_append_stream_byte(input, sizeof(input), &state,
0xE4, true) == TNT_INPUT_APPEND_OK);
assert(tnt_input_utf8_state_finish(&state) ==
TNT_INPUT_APPEND_INVALID_UTF8);
assert(strcmp(input, "") == 0);
}
TEST(paste_stream_drops_invalid_utf8_and_keeps_following_text) {
char input[32] = "";
tnt_input_utf8_state_t state = {0};
int status;
assert(tnt_input_append_stream_byte(input, sizeof(input), &state,
0xE4, true) == TNT_INPUT_APPEND_OK);
status = tnt_input_append_stream_byte(input, sizeof(input), &state,
'x', true);
assert((status & TNT_INPUT_APPEND_INVALID_UTF8) != 0);
assert(strcmp(input, "x") == 0);
}
int main(void) {
printf("Running input buffer unit tests...\n\n");
RUN_TEST(appends_ascii_until_capacity);
RUN_TEST(rejects_ascii_control_bytes);
RUN_TEST(appends_valid_utf8_sequence);
RUN_TEST(rejects_invalid_utf8_sequence);
RUN_TEST(paste_stream_normalizes_newlines_and_tabs);
RUN_TEST(paste_stream_validates_multibyte_utf8);
RUN_TEST(paste_stream_rejects_partial_utf8_at_end);
RUN_TEST(paste_stream_drops_invalid_utf8_and_keeps_following_text);
printf("\nAll %d input buffer tests passed.\n", tests_passed);
return 0;
}

View file

@ -0,0 +1,95 @@
#include "../../include/json_text.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
#define TEST(name) static void test_##name(void)
#define RUN_TEST(name) do { \
printf("Running %s... ", #name); \
test_##name(); \
printf("ok\n"); \
tests_passed++; \
} while (0)
static int tests_passed = 0;
TEST(append_string_escapes_json_controls) {
char out[128] = "";
size_t pos = 0;
tnt_json_append_string(out, sizeof(out), &pos, "a\"b\\c\n\t");
assert(strcmp(out, "\"a\\\"b\\\\c\\n\\t\"") == 0);
}
TEST(get_string_field_extracts_top_level_value) {
char value[64];
assert(tnt_json_get_string_field(
"{\"type\":\"message.create\",\"plain_text\":\"hello\"}",
"plain_text", value, sizeof(value)));
assert(strcmp(value, "hello") == 0);
}
TEST(get_string_field_skips_nested_values) {
char value[64];
assert(tnt_json_get_string_field(
"{\"nested\":{\"plain_text\":\"wrong\"},\"plain_text\":\"right\"}",
"plain_text", value, sizeof(value)));
assert(strcmp(value, "right") == 0);
}
TEST(get_string_field_decodes_escapes) {
char value[64];
assert(tnt_json_get_string_field(
"{\"plain_text\":\"line\\nquote:\\\" ok\"}",
"plain_text", value, sizeof(value)));
assert(strcmp(value, "line\nquote:\" ok") == 0);
}
TEST(get_string_field_decodes_unicode_escape) {
char value[64];
assert(tnt_json_get_string_field(
"{\"plain_text\":\"hello \\u4e2d\"}",
"plain_text", value, sizeof(value)));
assert(strcmp(value, "hello \xE4\xB8\xAD") == 0);
}
TEST(get_string_field_rejects_missing_and_malformed_values) {
char value[64];
assert(!tnt_json_get_string_field("{\"x\":\"y\"}", "plain_text",
value, sizeof(value)));
assert(!tnt_json_get_string_field("{\"plain_text\":123}", "plain_text",
value, sizeof(value)));
assert(!tnt_json_get_string_field("{\"plain_text\":\"unterminated}",
"plain_text", value, sizeof(value)));
assert(!tnt_json_get_string_field(
"{\"plain_text\":\"ok\",\"unterminated\":\"x}",
"plain_text", value, sizeof(value)));
}
TEST(get_string_field_rejects_output_overflow) {
char value[4];
assert(!tnt_json_get_string_field("{\"plain_text\":\"abcd\"}",
"plain_text", value, sizeof(value)));
}
int main(void) {
printf("Running JSON text unit tests...\n\n");
RUN_TEST(append_string_escapes_json_controls);
RUN_TEST(get_string_field_extracts_top_level_value);
RUN_TEST(get_string_field_skips_nested_values);
RUN_TEST(get_string_field_decodes_escapes);
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);
printf("\nAll %d JSON text tests passed.\n", tests_passed);
return 0;
}

View file

@ -0,0 +1,120 @@
#include "../../include/module_protocol.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
#define TEST(name) static void test_##name(void)
#define RUN_TEST(name) do { \
printf("Running %s... ", #name); \
test_##name(); \
printf("ok\n"); \
tests_passed++; \
} while (0)
static int tests_passed = 0;
TEST(appends_handshake_jsonl) {
char out[256] = "";
size_t pos = 0;
assert(tnt_module_append_handshake(out, sizeof(out), &pos, "9.9.9") == 0);
assert(strcmp(out,
"{\"type\":\"handshake\",\"protocol\":\"tnt.module.v1\","
"\"server\":{\"name\":\"tnt\",\"version\":\"9.9.9\"}}\n") ==
0);
}
TEST(appends_message_created_jsonl_with_escaping) {
char out[512] = "";
size_t pos = 0;
message_t msg = {
.timestamp = 0,
};
snprintf(msg.username, sizeof(msg.username), "%s", "alice");
snprintf(msg.content, sizeof(msg.content), "%s", "hello \"core\"");
assert(tnt_module_append_message_created(out, sizeof(out), &pos,
"local-1", &msg) == 0);
assert(strstr(out, "\"type\":\"message.created\"") != NULL);
assert(strstr(out, "\"id\":\"local-1\"") != NULL);
assert(strstr(out, "\"timestamp\":\"1970-01-01T00:00:00Z\"") != NULL);
assert(strstr(out, "\"sender\":\"alice\"") != NULL);
assert(strstr(out, "\"kind\":\"text\"") != NULL);
assert(strstr(out, "\"plain_text\":\"hello \\\"core\\\"\"") != NULL);
assert(out[strlen(out) - 1] == '\n');
}
TEST(parse_message_create_accepts_valid_plain_text) {
tnt_module_message_create_t response;
assert(tnt_module_parse_message_create(
"{\"type\":\"message.create\",\"plain_text\":\"echo: hello\"}",
&response));
assert(strcmp(response.plain_text, "echo: hello") == 0);
}
TEST(parse_message_create_accepts_utf8_plain_text) {
tnt_module_message_create_t response;
assert(tnt_module_parse_message_create(
"{\"type\":\"message.create\",\"plain_text\":\"echo: \\u4e2d\"}",
&response));
assert(strcmp(response.plain_text, "echo: \xE4\xB8\xAD") == 0);
}
TEST(parse_message_create_rejects_wrong_type) {
tnt_module_message_create_t response;
assert(!tnt_module_parse_message_create(
"{\"type\":\"event.ok\",\"plain_text\":\"hello\"}", &response));
}
TEST(parse_message_create_rejects_empty_or_control_text) {
tnt_module_message_create_t response;
assert(!tnt_module_parse_message_create(
"{\"type\":\"message.create\",\"plain_text\":\"\"}", &response));
assert(!tnt_module_parse_message_create(
"{\"type\":\"message.create\",\"plain_text\":\"line\\nnext\"}",
&response));
}
TEST(parse_message_create_rejects_invalid_utf8_text) {
tnt_module_message_create_t response;
char line[] = "{\"type\":\"message.create\",\"plain_text\":\"bad \xC3\x28\"}";
assert(!tnt_module_parse_message_create(line, &response));
}
TEST(parse_message_create_rejects_overlong_text) {
char line[MAX_MESSAGE_LEN + 128];
tnt_module_message_create_t response;
size_t pos = 0;
buffer_appendf(line, sizeof(line), &pos,
"{\"type\":\"message.create\",\"plain_text\":\"");
for (int i = 0; i < MAX_MESSAGE_LEN; i++) {
buffer_append_bytes(line, sizeof(line), &pos, "a", 1);
}
buffer_appendf(line, sizeof(line), &pos, "\"}");
assert(!tnt_module_parse_message_create(line, &response));
}
int main(void) {
printf("Running module protocol unit tests...\n\n");
RUN_TEST(appends_handshake_jsonl);
RUN_TEST(appends_message_created_jsonl_with_escaping);
RUN_TEST(parse_message_create_accepts_valid_plain_text);
RUN_TEST(parse_message_create_accepts_utf8_plain_text);
RUN_TEST(parse_message_create_rejects_wrong_type);
RUN_TEST(parse_message_create_rejects_empty_or_control_text);
RUN_TEST(parse_message_create_rejects_invalid_utf8_text);
RUN_TEST(parse_message_create_rejects_overlong_text);
printf("\nAll %d module protocol tests passed.\n", tests_passed);
return 0;
}

View file

@ -0,0 +1,155 @@
#include "../../include/module_runtime.h"
#include "../../include/chat_room.h"
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
#define TEST(name) static void test_##name(void)
#define RUN_TEST(name) do { \
printf("Running %s... ", #name); \
test_##name(); \
printf("ok\n"); \
tests_passed++; \
} while (0)
static int tests_passed = 0;
static char module_dir[PATH_MAX];
chat_room_t *g_room = NULL;
void room_broadcast(chat_room_t *room, const message_t *msg) {
(void)room;
(void)msg;
}
int message_save(const message_t *msg) {
(void)msg;
return 0;
}
void notify_mentions(const char *content, const void *sender) {
(void)content;
(void)sender;
}
static void cleanup_module_dir(void) {
if (module_dir[0] == '\0') return;
char path[PATH_MAX];
snprintf(path, sizeof(path), "%s/tnt-module.json", module_dir);
unlink(path);
rmdir(module_dir);
module_dir[0] = '\0';
}
static void setup_module_dir(void) {
const char *tmp = getenv("TMPDIR");
cleanup_module_dir();
if (!tmp || tmp[0] == '\0') tmp = "/tmp";
snprintf(module_dir, sizeof(module_dir), "%s/tnt-module-test.XXXXXX", tmp);
assert(mkdtemp(module_dir) != NULL);
}
static void write_manifest(const char *body) {
char path[PATH_MAX];
FILE *fp;
snprintf(path, sizeof(path), "%s/tnt-module.json", module_dir);
fp = fopen(path, "wb");
assert(fp != NULL);
fputs(body, fp);
fclose(fp);
}
TEST(loads_valid_manifest) {
tnt_module_manifest_t manifest;
setup_module_dir();
write_manifest(
"{"
"\"protocol\":\"tnt.module.v1\","
"\"name\":\"echo-module\","
"\"version\":\"0.1.0\","
"\"entrypoint\":\"./echo-module.sh\","
"\"permissions\":[\"message:read\",\"message:create\"],"
"\"events\":[\"message.created\"]"
"}");
assert(tnt_module_manifest_load(module_dir, &manifest) == 0);
assert(strcmp(manifest.name, "echo-module") == 0);
assert(strcmp(manifest.entrypoint, "./echo-module.sh") == 0);
assert(manifest.wants_message_created);
assert(manifest.can_read_messages);
assert(manifest.can_create_messages);
cleanup_module_dir();
}
TEST(rejects_wrong_protocol) {
tnt_module_manifest_t manifest;
setup_module_dir();
write_manifest(
"{\"protocol\":\"tnt.module.v2\",\"name\":\"echo\","
"\"entrypoint\":\"./echo.sh\","
"\"permissions\":[\"message:read\",\"message:create\"],"
"\"events\":[\"message.created\"]}");
assert(tnt_module_manifest_load(module_dir, &manifest) < 0);
cleanup_module_dir();
}
TEST(rejects_missing_permissions_or_events) {
tnt_module_manifest_t manifest;
setup_module_dir();
write_manifest(
"{\"protocol\":\"tnt.module.v1\",\"name\":\"echo\","
"\"entrypoint\":\"./echo.sh\","
"\"permissions\":[\"message:read\"],"
"\"events\":[\"message.created\"]}");
assert(tnt_module_manifest_load(module_dir, &manifest) < 0);
write_manifest(
"{\"protocol\":\"tnt.module.v1\",\"name\":\"echo\","
"\"entrypoint\":\"./echo.sh\","
"\"permissions\":[\"message:read\",\"message:create\"],"
"\"events\":[]}");
assert(tnt_module_manifest_load(module_dir, &manifest) < 0);
cleanup_module_dir();
}
TEST(rejects_unsafe_entrypoint) {
tnt_module_manifest_t manifest;
setup_module_dir();
write_manifest(
"{\"protocol\":\"tnt.module.v1\",\"name\":\"echo\","
"\"entrypoint\":\"../echo.sh\","
"\"permissions\":[\"message:read\",\"message:create\"],"
"\"events\":[\"message.created\"]}");
assert(tnt_module_manifest_load(module_dir, &manifest) < 0);
write_manifest(
"{\"protocol\":\"tnt.module.v1\",\"name\":\"echo\","
"\"entrypoint\":\"/tmp/echo.sh\","
"\"permissions\":[\"message:read\",\"message:create\"],"
"\"events\":[\"message.created\"]}");
assert(tnt_module_manifest_load(module_dir, &manifest) < 0);
cleanup_module_dir();
}
int main(void) {
printf("Running module runtime unit tests...\n\n");
RUN_TEST(loads_valid_manifest);
RUN_TEST(rejects_wrong_protocol);
RUN_TEST(rejects_missing_permissions_or_events);
RUN_TEST(rejects_unsafe_entrypoint);
printf("\nAll %d module runtime tests passed.\n", tests_passed);
return 0;
}

27
tnt.1
View file

@ -203,7 +203,7 @@ PageDown/PageUp Scroll full page down/up
End/Home Jump to bottom/top
g/G Jump to top/bottom
/ Search message history
i Switch to INSERT
i/a/o Switch to INSERT
: Enter COMMAND mode
? Open full key reference
Ctrl+C Disconnect
@ -220,7 +220,10 @@ l l.
:name \fIname\fR Alias for :nick
:msg \fIuser message\fR Send private message
:w \fIuser text\fR Short alias for :msg
:inbox Show private messages
:reply \fItext\fR Reply to latest private message
:r \fItext\fR Short alias for :reply
:inbox Show private messages, newest first
:inbox clear Clear private messages for this session
:last [\fIN\fR] Show last N messages from history (1\-50, default 10)
:search \fIkeyword\fR Case\-insensitive search; shows the last 15 matches
:mute\-joins Toggle join/leave system notifications on/off
@ -249,8 +252,19 @@ r Refresh live output (:inbox)
.PP
The
.B :inbox
page refreshes automatically when a new private message arrives while it is
open.
page shows incoming messages and local sent-message copies for the current
session. It refreshes automatically when a new private message arrives while
it is open. Incoming unread messages are marked with
.B *
and counted in the inbox title until the inbox renders them. Use
.B :reply
or
.B :r
to answer the latest private-message peer.
.B :inbox clear
removes private messages and the reply target for this session. Private
messages are not written to
.IR messages.log .
.SH EXEC INTERFACE
Commands can be run non\-interactively for scripting:
.PP
@ -340,10 +354,11 @@ libssh log verbosity from 0 to 4 (default: 1).
.SH FILES
.TP
.I messages.log
Chat history in the TNT message log v1 format:
Public chat history in the TNT message log v1 format:
RFC\ 3339 UTC pipe\-delimited records
.RI ( timestamp | username | content ).
Stored in the state directory.
Stored in the state directory. Private messages and in-memory inbox state are
excluded.
See
.I docs/MESSAGE_LOG.md
in the source distribution for parser and recovery rules.