mirror of
https://oauth2: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@github.com/m1ngsama/TNT.git
synced 2026-06-26 05:54:38 +08:00
Compare commits
10 commits
5ac760d196
...
1284d5d052
| Author | SHA1 | Date | |
|---|---|---|---|
| 1284d5d052 | |||
| 2402c70d6f | |||
| 2fcfcad613 | |||
| bacfe1ef4b | |||
| 7ff9474a5d | |||
| d7531f9305 | |||
| 845657e3c2 | |||
| 2fca031362 | |||
| 1f8fb7acf4 | |||
| 5ae02054ee |
46 changed files with 3093 additions and 229 deletions
10
Makefile
10
Makefile
|
|
@ -35,7 +35,7 @@ MANDIR ?= $(PREFIX)/share/man
|
||||||
SYSTEMD_UNIT_DIR ?= $(PREFIX)/lib/systemd/system
|
SYSTEMD_UNIT_DIR ?= $(PREFIX)/lib/systemd/system
|
||||||
CI_TEST_PORT ?= $(if $(PORT),$(PORT),2222)
|
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)
|
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} ./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} + 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} + 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:
|
unit-test:
|
||||||
@echo "Running unit tests..."
|
@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} + 1)) ./test_exec_mode.sh
|
||||||
@cd tests && PORT=$$(($${PORT:-2222} + 2)) ./test_interactive_input.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} + 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
|
@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
|
anonymous-access-test: all
|
||||||
@echo "Running anonymous access tests..."
|
@echo "Running anonymous access tests..."
|
||||||
@cd tests && PORT=$${PORT:-2222} ./test_anonymous_access.sh
|
@cd tests && PORT=$${PORT:-2222} ./test_anonymous_access.sh
|
||||||
|
|
|
||||||
29
README.md
29
README.md
|
|
@ -78,7 +78,7 @@ past the limit is ignored with a terminal bell.
|
||||||
```
|
```
|
||||||
Opens at latest messages
|
Opens at latest messages
|
||||||
Stays pinned to latest until you scroll up
|
Stays pinned to latest until you scroll up
|
||||||
i - Return to INSERT mode
|
i/a/o - Return to INSERT mode
|
||||||
: - Enter COMMAND mode
|
: - Enter COMMAND mode
|
||||||
j/k - Scroll down/up one line
|
j/k - Scroll down/up one line
|
||||||
Ctrl+D/U - Scroll half page down/up
|
Ctrl+D/U - Scroll half page down/up
|
||||||
|
|
@ -96,7 +96,10 @@ Ctrl+C - Exit chat
|
||||||
:nick <name> - Change nickname
|
:nick <name> - Change nickname
|
||||||
:msg <user> <message> - Send private message
|
:msg <user> <message> - Send private message
|
||||||
:w <user> <text> - Short alias for :msg
|
: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 - Show private messages
|
||||||
|
:inbox clear - Clear private messages for this session
|
||||||
:last [N] - Show last N messages from history (max 50, default 10)
|
:last [N] - Show last N messages from history (max 50, default 10)
|
||||||
:search <keyword> - Search message history (shows last 15 matches)
|
:search <keyword> - Search message history (shows last 15 matches)
|
||||||
:mute-joins - Toggle join/leave system notifications
|
: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`
|
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
|
shows incoming and sent private messages newest-first; press `r` to refresh it
|
||||||
message arrives while the inbox is open.
|
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)**
|
**Special messages (INSERT mode)**
|
||||||
```
|
```
|
||||||
|
|
@ -223,7 +232,8 @@ tntctl -l operator chat.example.com post "service notice"
|
||||||
### Log Maintenance
|
### Log Maintenance
|
||||||
|
|
||||||
Persisted public history is stored as `messages.log` in the TNT state
|
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
|
```sh
|
||||||
scripts/logrotate.sh /var/lib/tnt/messages.log 100 10000
|
scripts/logrotate.sh /var/lib/tnt/messages.log 100 10000
|
||||||
|
|
@ -329,6 +339,10 @@ TNT/
|
||||||
│ ├── bootstrap.c # SSH authentication and session bootstrap
|
│ ├── bootstrap.c # SSH authentication and session bootstrap
|
||||||
│ ├── chat_room.c # chat room logic
|
│ ├── chat_room.c # chat room logic
|
||||||
│ ├── message.c # message persistence
|
│ ├── 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
|
│ ├── history_view.c # message viewport and scroll state
|
||||||
│ ├── help_text.c # full-screen key reference content
|
│ ├── help_text.c # full-screen key reference content
|
||||||
│ ├── manual.c # concise manual panel rendering
|
│ ├── manual.c # concise manual panel rendering
|
||||||
|
|
@ -418,7 +432,11 @@ tnt.service - systemd service unit
|
||||||
```
|
```
|
||||||
|
|
||||||
The persisted chat-history format is documented in
|
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)
|
### 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
|
- [Quick Setup](docs/EASY_SETUP.md) - 5-minute deployment guide
|
||||||
- [Roadmap](docs/ROADMAP.md) - Long-term Unix/GNU direction and next stages
|
- [Roadmap](docs/ROADMAP.md) - Long-term Unix/GNU direction and next stages
|
||||||
- [Interface Contract](docs/INTERFACE.md) - Scriptable commands, exit statuses, and JSON fields
|
- [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
|
- [Security Reference](docs/SECURITY_QUICKREF.md) - Security config quick reference
|
||||||
- [Contributing](docs/CONTRIBUTING.md) - How to contribute
|
- [Contributing](docs/CONTRIBUTING.md) - How to contribute
|
||||||
- [Changelog](docs/CHANGELOG.md) - Version history
|
- [Changelog](docs/CHANGELOG.md) - Version history
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,37 @@ Recommended interpretation:
|
||||||
- `TNT_MAX_CONN_RATE_PER_IP`: new connection attempts allowed per IP per 60 seconds
|
- `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
|
- `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)
|
## 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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,10 @@ src/
|
||||||
├── command_catalog.c - COMMAND-mode names, aliases, and help summaries
|
├── command_catalog.c - COMMAND-mode names, aliases, and help summaries
|
||||||
├── exec_catalog.c - SSH exec command matching and help metadata
|
├── exec_catalog.c - SSH exec command matching and help metadata
|
||||||
├── exec.c - SSH exec command dispatch
|
├── 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.c - Local wrapper around the SSH exec interface
|
||||||
├── tntctl_text.c - tntctl local help and diagnostics
|
├── tntctl_text.c - tntctl local help and diagnostics
|
||||||
├── chat_room.c - Chat room state, message ring, and update sequence
|
├── 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
|
├── message_log_tool.h - Offline log check/recover interface
|
||||||
├── command_catalog.h - COMMAND-mode command metadata interface
|
├── command_catalog.h - COMMAND-mode command metadata interface
|
||||||
├── exec_catalog.h - SSH exec 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
|
├── cli_text.h - Server CLI text interface
|
||||||
├── tntctl_text.h - tntctl text interface
|
├── tntctl_text.h - tntctl text interface
|
||||||
├── history_view.h - Scroll-state helpers
|
├── history_view.h - Scroll-state helpers
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,9 @@ Common commands:
|
||||||
:users online users
|
:users online users
|
||||||
:nick <name> change nickname
|
:nick <name> change nickname
|
||||||
:msg <user> <message> send private message
|
:msg <user> <message> send private message
|
||||||
|
:reply <message> reply to latest private message
|
||||||
:inbox show private messages
|
:inbox show private messages
|
||||||
|
:inbox clear clear private messages
|
||||||
:last [N] recent messages
|
:last [N] recent messages
|
||||||
:search <keyword> search message history
|
:search <keyword> search message history
|
||||||
:lang en|zh switch UI language
|
:lang en|zh switch UI language
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,23 @@ posted
|
||||||
In anonymous-access mode, the SSH login name is not authenticated. Operators
|
In anonymous-access mode, the SSH login name is not authenticated. Operators
|
||||||
should configure `TNT_ACCESS_TOKEN` before relying on exec-post identity.
|
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`
|
### `help`
|
||||||
|
|
||||||
Prints a localized human-readable command summary. It is intended for people,
|
Prints a localized human-readable command summary. It is intended for people,
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,10 @@ existing append-only logs remain readable.
|
||||||
- `|`, `\n`, and `\r` in content become spaces.
|
- `|`, `\n`, and `\r` in content become spaces.
|
||||||
- Timestamps are written in UTC.
|
- 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
|
## Replay And Search
|
||||||
|
|
||||||
|
|
|
||||||
143
docs/MODULE_PROTOCOL.md
Normal file
143
docs/MODULE_PROTOCOL.md
Normal 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.
|
||||||
|
|
@ -30,7 +30,10 @@ COMMANDS (COMMAND mode, prefix with :)
|
||||||
nick <name> change nickname
|
nick <name> change nickname
|
||||||
msg <user> <message> send private message
|
msg <user> <message> send private message
|
||||||
w <user> <text> alias for msg
|
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)
|
last [N] last N messages from log (default 10, max 50)
|
||||||
search <keyword> search full history (case-insensitive, 15 results)
|
search <keyword> search full history (case-insensitive, 15 results)
|
||||||
mute-joins toggle join/leave notifications
|
mute-joins toggle join/leave notifications
|
||||||
|
|
@ -45,6 +48,7 @@ INSERT MODE
|
||||||
paste multi-line paste stays in the input buffer
|
paste multi-line paste stays in the input buffer
|
||||||
limit 1023 bytes/message; over-limit input rings bell
|
limit 1023 bytes/message; over-limit input rings bell
|
||||||
normal opens/follows latest; k/PgUp older, j/PgDn newer
|
normal opens/follows latest; k/PgUp older, j/PgDn newer
|
||||||
|
insert aliases i/a/o enter INSERT mode from NORMAL
|
||||||
|
|
||||||
EXEC COMMANDS
|
EXEC COMMANDS
|
||||||
health print service health
|
health print service health
|
||||||
|
|
@ -74,9 +78,13 @@ STRUCTURE
|
||||||
src/commands.c COMMAND-mode command dispatch
|
src/commands.c COMMAND-mode command dispatch
|
||||||
src/exec_catalog.c SSH exec command matching, usage, argument shape
|
src/exec_catalog.c SSH exec command matching, usage, argument shape
|
||||||
src/exec.c SSH exec command dispatch
|
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.c persistence, search
|
||||||
src/message_log.c messages.log v1 parsing and formatting
|
src/message_log.c messages.log v1 parsing and formatting
|
||||||
src/message_log_tool.c offline messages.log check/recover CLI
|
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/history_view.c message viewport / scroll state
|
||||||
src/help_text.c full-screen key reference text
|
src/help_text.c full-screen key reference text
|
||||||
src/manual.c concise manual panel rendering
|
src/manual.c concise manual panel rendering
|
||||||
|
|
@ -94,7 +102,7 @@ LIMITS
|
||||||
1024 bytes/message
|
1024 bytes/message
|
||||||
|
|
||||||
FILES
|
FILES
|
||||||
messages.log chat log (RFC3339)
|
messages.log public chat log (RFC3339; excludes private messages)
|
||||||
host_key SSH key (auto-generated)
|
host_key SSH key (auto-generated)
|
||||||
motd.txt message of the day (optional)
|
motd.txt message of the day (optional)
|
||||||
CHANGELOG.md version history
|
CHANGELOG.md version history
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,26 @@ Goal: keep the interface efficient for terminal users without sacrificing simpli
|
||||||
- ✅ improve discoverability of NORMAL and COMMAND mode actions
|
- ✅ improve discoverability of NORMAL and COMMAND mode actions
|
||||||
- ✅ make status lines and help output concise enough for small terminals
|
- ✅ 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
|
## Stage 5: Operations and Security
|
||||||
|
|
||||||
Goal: make public deployment manageable.
|
Goal: make public deployment manageable.
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ The product path should stay short:
|
||||||
5. User presses Esc to browse history with Vim-style movement.
|
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.
|
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:
|
7. User searches from NORMAL with `/term`, or uses commands when needed:
|
||||||
`:users`, `:msg`, `:inbox`, `:last`, `:search`, `:nick`, `:mute-joins`,
|
`:users`, `:msg`, `:reply`, `:inbox`, `:last`, `:search`, `:nick`,
|
||||||
and `:q`.
|
`:mute-joins`, and `:q`.
|
||||||
8. Scripts and operators use `tntctl` or SSH exec commands for `health`,
|
8. Scripts and operators use `tntctl` or SSH exec commands for `health`,
|
||||||
`stats`, `users`, `tail`, `dump`, and `post`.
|
`stats`, `users`, `tail`, `dump`, and `post`.
|
||||||
|
|
||||||
|
|
@ -32,11 +32,18 @@ The product path should stay short:
|
||||||
parallel support commands for the same task.
|
parallel support commands for the same task.
|
||||||
- Command syntax stays ASCII even in localized UI text. Translations explain;
|
- Command syntax stays ASCII even in localized UI text. Translations explain;
|
||||||
they do not change the command language.
|
they do not change the command language.
|
||||||
- Private messages are visible only in the recipient inbox and are not written
|
- Private messages are visible in each participant's in-memory `:inbox`:
|
||||||
to `messages.log`.
|
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`
|
- `: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
|
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
|
- Long command output uses a small pager so `:last` and `:search` are readable
|
||||||
on small terminals.
|
on small terminals.
|
||||||
|
|
||||||
|
|
@ -47,10 +54,12 @@ The product path should stay short:
|
||||||
- second user joins and is visible through `users --json`
|
- second user joins and is visible through `users --json`
|
||||||
- first user opens `?`, checks `:users`, sends a public message, scrolls, uses
|
- first user opens `?`, checks `:users`, sends a public message, scrolls, uses
|
||||||
`:last` and `:search`
|
`:last` and `:search`
|
||||||
- first user toggles `:mute-joins`, sends `:msg`, changes nickname, sends
|
- first user toggles `:mute-joins`, sends two `:msg` messages, receives a
|
||||||
`/me`, and exits
|
`:reply`, confirms private-message copies in `:inbox`, clears the inbox,
|
||||||
- second user opens `:inbox` before the private message arrives and sees it
|
changes nickname, sends `/me`, and exits
|
||||||
auto-refresh after delivery
|
- 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
|
- exec `tail` sees public messages
|
||||||
- `messages.log` contains public history and excludes private-message content
|
- `messages.log` contains public history and excludes private-message content
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ typedef enum {
|
||||||
TNT_COMMAND_HELP,
|
TNT_COMMAND_HELP,
|
||||||
TNT_COMMAND_LANG,
|
TNT_COMMAND_LANG,
|
||||||
TNT_COMMAND_MSG,
|
TNT_COMMAND_MSG,
|
||||||
|
TNT_COMMAND_REPLY,
|
||||||
TNT_COMMAND_INBOX,
|
TNT_COMMAND_INBOX,
|
||||||
TNT_COMMAND_NICK,
|
TNT_COMMAND_NICK,
|
||||||
TNT_COMMAND_LAST,
|
TNT_COMMAND_LAST,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ typedef enum {
|
||||||
I18N_TITLE_ONLINE_FORMAT,
|
I18N_TITLE_ONLINE_FORMAT,
|
||||||
I18N_TITLE_MUTED,
|
I18N_TITLE_MUTED,
|
||||||
I18N_TITLE_HELP_HINT,
|
I18N_TITLE_HELP_HINT,
|
||||||
|
I18N_EMPTY_ROOM,
|
||||||
|
I18N_EMPTY_FILTERED,
|
||||||
I18N_IDLE_TIMEOUT_FORMAT,
|
I18N_IDLE_TIMEOUT_FORMAT,
|
||||||
I18N_SYSTEM_USERNAME,
|
I18N_SYSTEM_USERNAME,
|
||||||
I18N_SYSTEM_JOIN_FORMAT,
|
I18N_SYSTEM_JOIN_FORMAT,
|
||||||
|
|
@ -43,14 +45,20 @@ typedef enum {
|
||||||
I18N_USERS_TITLE,
|
I18N_USERS_TITLE,
|
||||||
I18N_MSG_SENT_FORMAT,
|
I18N_MSG_SENT_FORMAT,
|
||||||
I18N_MSG_USER_NOT_FOUND_FORMAT,
|
I18N_MSG_USER_NOT_FOUND_FORMAT,
|
||||||
|
I18N_REPLY_NO_TARGET,
|
||||||
I18N_INBOX_TITLE,
|
I18N_INBOX_TITLE,
|
||||||
I18N_INBOX_EMPTY,
|
I18N_INBOX_EMPTY,
|
||||||
|
I18N_INBOX_SENT_TO_FORMAT,
|
||||||
|
I18N_INBOX_CLEARED,
|
||||||
|
I18N_INBOX_UNREAD_FORMAT,
|
||||||
I18N_NICK_INVALID,
|
I18N_NICK_INVALID,
|
||||||
I18N_NICK_TAKEN_FORMAT,
|
I18N_NICK_TAKEN_FORMAT,
|
||||||
I18N_NICK_UNCHANGED,
|
I18N_NICK_UNCHANGED,
|
||||||
I18N_NICK_CHANGED_FORMAT,
|
I18N_NICK_CHANGED_FORMAT,
|
||||||
I18N_LAST_HEADER_FORMAT,
|
I18N_LAST_HEADER_FORMAT,
|
||||||
|
I18N_LAST_EMPTY,
|
||||||
I18N_SEARCH_HEADER_FORMAT,
|
I18N_SEARCH_HEADER_FORMAT,
|
||||||
|
I18N_SEARCH_EMPTY,
|
||||||
I18N_MUTE_JOINS_FORMAT,
|
I18N_MUTE_JOINS_FORMAT,
|
||||||
I18N_MUTE_JOINS_MUTED,
|
I18N_MUTE_JOINS_MUTED,
|
||||||
I18N_MUTE_JOINS_UNMUTED,
|
I18N_MUTE_JOINS_UNMUTED,
|
||||||
|
|
|
||||||
35
include/input_buffer.h
Normal file
35
include/input_buffer.h
Normal 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
15
include/json_text.h
Normal 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
24
include/module_protocol.h
Normal 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
27
include/module_runtime.h
Normal 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 */
|
||||||
|
|
@ -14,7 +14,10 @@
|
||||||
typedef struct {
|
typedef struct {
|
||||||
time_t timestamp;
|
time_t timestamp;
|
||||||
char from[MAX_USERNAME_LEN];
|
char from[MAX_USERNAME_LEN];
|
||||||
|
char to[MAX_USERNAME_LEN];
|
||||||
char content[MAX_MESSAGE_LEN];
|
char content[MAX_MESSAGE_LEN];
|
||||||
|
bool outgoing;
|
||||||
|
bool unread;
|
||||||
} whisper_t;
|
} whisper_t;
|
||||||
|
|
||||||
typedef enum {
|
typedef enum {
|
||||||
|
|
@ -59,10 +62,13 @@ typedef struct client {
|
||||||
_Atomic int pending_bells; /* Bell nudges for this client's loop */
|
_Atomic int pending_bells; /* Bell nudges for this client's loop */
|
||||||
_Atomic int unread_mentions; /* @-mentions received since last reset */
|
_Atomic int unread_mentions; /* @-mentions received since last reset */
|
||||||
_Atomic int unread_whispers; /* whispers received since last :inbox view */
|
_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 */
|
char *outbox; /* Bounded queued output for interactive writes */
|
||||||
size_t outbox_len;
|
size_t outbox_len;
|
||||||
size_t outbox_pos;
|
size_t outbox_pos;
|
||||||
size_t outbox_capacity;
|
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
|
/* Per-client whisper inbox. Protected separately from SSH channel I/O
|
||||||
* so slow writes do not block in-memory private-message delivery. */
|
* so slow writes do not block in-memory private-message delivery. */
|
||||||
whisper_t whisper_inbox[WHISPER_INBOX_SIZE];
|
whisper_t whisper_inbox[WHISPER_INBOX_SIZE];
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ void tui_render_motd(struct client *client);
|
||||||
/* Render the input line */
|
/* Render the input line */
|
||||||
void tui_render_input(struct client *client, const char *input);
|
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 */
|
/* Clear the screen */
|
||||||
void tui_clear_screen(struct client *client);
|
void tui_clear_screen(struct client *client);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,7 @@ void client_release(client_t *client) {
|
||||||
free(client->channel_cb);
|
free(client->channel_cb);
|
||||||
}
|
}
|
||||||
free(client->outbox);
|
free(client->outbox);
|
||||||
|
free(client->render_buffer);
|
||||||
pthread_mutex_destroy(&client->io_lock);
|
pthread_mutex_destroy(&client->io_lock);
|
||||||
pthread_mutex_destroy(&client->whisper_lock);
|
pthread_mutex_destroy(&client->whisper_lock);
|
||||||
pthread_mutex_destroy(&client->ref_lock);
|
pthread_mutex_destroy(&client->ref_lock);
|
||||||
|
|
|
||||||
|
|
@ -36,13 +36,25 @@ static const command_catalog_entry_t entries[] = {
|
||||||
" w <user> <message>\n"),
|
" w <user> <message>\n"),
|
||||||
2, false, true
|
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}},
|
{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(":inbox", ":inbox"),
|
||||||
I18N_STRING("Show private messages", "查看私信"),
|
I18N_STRING("Usage: inbox [clear]\n", "用法: inbox [clear]\n"),
|
||||||
I18N_STRING(":inbox", ":inbox"),
|
2, false, false
|
||||||
I18N_STRING("Usage: inbox\n", "用法: inbox\n"),
|
|
||||||
2, true, false
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
{TNT_COMMAND_NICK, "nick", {"nick", "name", NULL}},
|
{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) {
|
if (!entry) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (id == TNT_COMMAND_INBOX) {
|
||||||
|
return !args || args[0] == '\0' || strcmp(args, "clear") == 0;
|
||||||
|
}
|
||||||
if (entry->no_args) {
|
if (entry->no_args) {
|
||||||
return !args || args[0] == '\0';
|
return !args || args[0] == '\0';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
254
src/commands.c
254
src/commands.c
|
|
@ -52,38 +52,153 @@ static void append_command_usage(char *output, size_t buf_size, size_t *pos,
|
||||||
command_catalog_append_usage(output, buf_size, pos, id, lang);
|
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,
|
static void append_inbox_output(client_t *client, char *output,
|
||||||
size_t buf_size, size_t *pos) {
|
size_t buf_size, size_t *pos) {
|
||||||
whisper_t snapshot[WHISPER_INBOX_SIZE];
|
whisper_t snapshot[WHISPER_INBOX_SIZE];
|
||||||
int snap_count;
|
int snap_count;
|
||||||
|
int unread_count;
|
||||||
|
|
||||||
pthread_mutex_lock(&client->whisper_lock);
|
pthread_mutex_lock(&client->whisper_lock);
|
||||||
snap_count = client->whisper_inbox_count;
|
snap_count = client->whisper_inbox_count;
|
||||||
|
unread_count = client->unread_whispers;
|
||||||
memcpy(snapshot, client->whisper_inbox,
|
memcpy(snapshot, client->whisper_inbox,
|
||||||
snap_count * sizeof(whisper_t));
|
snap_count * sizeof(whisper_t));
|
||||||
|
for (int i = 0; i < snap_count; i++) {
|
||||||
|
client->whisper_inbox[i].unread = false;
|
||||||
|
}
|
||||||
client->unread_whispers = 0;
|
client->unread_whispers = 0;
|
||||||
pthread_mutex_unlock(&client->whisper_lock);
|
pthread_mutex_unlock(&client->whisper_lock);
|
||||||
|
|
||||||
buffer_appendf(output, buf_size, pos,
|
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),
|
i18n_text(client->ui_lang, I18N_INBOX_TITLE),
|
||||||
snap_count);
|
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) {
|
if (snap_count == 0) {
|
||||||
buffer_appendf(output, buf_size, pos,
|
buffer_appendf(output, buf_size, pos,
|
||||||
" \033[2;37m%s\033[0m\n",
|
" \033[2;37m%s\033[0m\n",
|
||||||
i18n_text(client->ui_lang, I18N_INBOX_EMPTY));
|
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 ts[20];
|
||||||
|
char peer[MAX_USERNAME_LEN + 16];
|
||||||
|
const char *marker = snapshot[i].unread ? "\033[1;35m*\033[0m" : " ";
|
||||||
struct tm tmi;
|
struct tm tmi;
|
||||||
localtime_r(&snapshot[i].timestamp, &tmi);
|
localtime_r(&snapshot[i].timestamp, &tmi);
|
||||||
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
|
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
|
||||||
|
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,
|
buffer_appendf(output, buf_size, pos,
|
||||||
" \033[90m%s\033[0m \033[35m%s\033[0m: %s\n",
|
" %s \033[90m%s\033[0m \033[35m%s\033[0m: %s\n",
|
||||||
ts, snapshot[i].from, snapshot[i].content);
|
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) {
|
bool commands_refresh_active_output(client_t *client) {
|
||||||
char output[MAX_COMMAND_OUTPUT_LEN] = {0};
|
char output[MAX_COMMAND_OUTPUT_LEN] = {0};
|
||||||
size_t pos = 0;
|
size_t pos = 0;
|
||||||
|
|
@ -237,65 +352,49 @@ void commands_dispatch(client_t *client) {
|
||||||
append_command_usage(output, sizeof(output), &pos,
|
append_command_usage(output, sizeof(output), &pos,
|
||||||
TNT_COMMAND_MSG, client->ui_lang);
|
TNT_COMMAND_MSG, client->ui_lang);
|
||||||
} else {
|
} else {
|
||||||
bool found = false;
|
send_private_message(client, target_name, rest, output,
|
||||||
client_t *target = NULL;
|
sizeof(output), &pos);
|
||||||
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) {
|
} else if (command_id == TNT_COMMAND_REPLY) {
|
||||||
/* Push into recipient's inbox. whisper_lock serialises so
|
const char *message = arg;
|
||||||
* two senders to the same recipient don't tear the ring. */
|
char target_name[MAX_USERNAME_LEN] = {0};
|
||||||
pthread_mutex_lock(&target->whisper_lock);
|
|
||||||
int slot;
|
|
||||||
if (target->whisper_inbox_count < WHISPER_INBOX_SIZE) {
|
|
||||||
slot = target->whisper_inbox_count++;
|
|
||||||
} else {
|
|
||||||
/* FIFO evict the oldest */
|
|
||||||
memmove(&target->whisper_inbox[0],
|
|
||||||
&target->whisper_inbox[1],
|
|
||||||
(WHISPER_INBOX_SIZE - 1) * sizeof(whisper_t));
|
|
||||||
slot = WHISPER_INBOX_SIZE - 1;
|
|
||||||
}
|
|
||||||
target->whisper_inbox[slot].timestamp = time(NULL);
|
|
||||||
snprintf(target->whisper_inbox[slot].from,
|
|
||||||
sizeof(target->whisper_inbox[slot].from),
|
|
||||||
"%s", client->username);
|
|
||||||
snprintf(target->whisper_inbox[slot].content,
|
|
||||||
sizeof(target->whisper_inbox[slot].content),
|
|
||||||
"%s", rest);
|
|
||||||
target->unread_whispers++;
|
|
||||||
pthread_mutex_unlock(&target->whisper_lock);
|
|
||||||
|
|
||||||
/* Audible nudge — the title bar ✉ counter (UX-11 style)
|
while (*message == ' ') message++;
|
||||||
* carries the persistent signal. */
|
if (message[0] == '\0') {
|
||||||
client_queue_bell(target);
|
append_command_usage(output, sizeof(output), &pos,
|
||||||
client_release(target);
|
TNT_COMMAND_REPLY, client->ui_lang);
|
||||||
}
|
} else {
|
||||||
|
pthread_mutex_lock(&client->whisper_lock);
|
||||||
|
snprintf(target_name, sizeof(target_name), "%s",
|
||||||
|
client->last_whisper_peer);
|
||||||
|
pthread_mutex_unlock(&client->whisper_lock);
|
||||||
|
|
||||||
if (found) {
|
if (target_name[0] == '\0') {
|
||||||
buffer_appendf(output, sizeof(output), &pos,
|
buffer_appendf(output, sizeof(output), &pos, "%s",
|
||||||
i18n_text(client->ui_lang,
|
i18n_text(client->ui_lang,
|
||||||
I18N_MSG_SENT_FORMAT),
|
I18N_REPLY_NO_TARGET));
|
||||||
target_name);
|
|
||||||
} else {
|
} else {
|
||||||
buffer_appendf(output, sizeof(output), &pos,
|
send_private_message(client, target_name, message, output,
|
||||||
i18n_text(client->ui_lang,
|
sizeof(output), &pos);
|
||||||
I18N_MSG_USER_NOT_FOUND_FORMAT),
|
|
||||||
target_name);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (command_id == TNT_COMMAND_INBOX) {
|
} else if (command_id == TNT_COMMAND_INBOX) {
|
||||||
output_kind = TNT_COMMAND_OUTPUT_INBOX;
|
const char *inbox_arg = arg;
|
||||||
append_inbox_output(client, output, sizeof(output), &pos);
|
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) {
|
} else if (command_id == TNT_COMMAND_NICK) {
|
||||||
const char *new_name = arg;
|
const char *new_name = arg;
|
||||||
|
|
@ -374,17 +473,31 @@ void commands_dispatch(client_t *client) {
|
||||||
}
|
}
|
||||||
|
|
||||||
message_t *last_msgs = NULL;
|
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,
|
buffer_appendf(output, sizeof(output), &pos,
|
||||||
i18n_text(client->ui_lang, I18N_LAST_HEADER_FORMAT),
|
i18n_text(client->ui_lang, I18N_LAST_HEADER_FORMAT),
|
||||||
last_count);
|
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++) {
|
for (int i = 0; i < last_count; i++) {
|
||||||
|
message_t *msg = &last_msgs[start + i];
|
||||||
char ts[20];
|
char ts[20];
|
||||||
struct tm tmi;
|
struct tm tmi;
|
||||||
localtime_r(&last_msgs[i].timestamp, &tmi);
|
localtime_r(&msg->timestamp, &tmi);
|
||||||
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
|
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
|
||||||
buffer_appendf(output, sizeof(output), &pos,
|
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);
|
free(last_msgs);
|
||||||
|
|
||||||
|
|
@ -396,23 +509,38 @@ void commands_dispatch(client_t *client) {
|
||||||
TNT_COMMAND_SEARCH, client->ui_lang);
|
TNT_COMMAND_SEARCH, client->ui_lang);
|
||||||
} else {
|
} else {
|
||||||
message_t *found = NULL;
|
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,
|
buffer_appendf(output, sizeof(output), &pos,
|
||||||
i18n_text(client->ui_lang,
|
i18n_text(client->ui_lang,
|
||||||
I18N_SEARCH_HEADER_FORMAT),
|
I18N_SEARCH_HEADER_FORMAT),
|
||||||
query, found_count);
|
query, display_count);
|
||||||
for (int i = 0; i < found_count; i++) {
|
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];
|
char ts[20];
|
||||||
struct tm tmi;
|
struct tm tmi;
|
||||||
localtime_r(&found[i].timestamp, &tmi);
|
localtime_r(&msg->timestamp, &tmi);
|
||||||
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
|
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
|
||||||
buffer_appendf(output, sizeof(output), &pos,
|
buffer_appendf(output, sizeof(output), &pos,
|
||||||
"[%s] ", ts);
|
"[%s] ", ts);
|
||||||
append_highlighted(output, sizeof(output), &pos,
|
append_highlighted(output, sizeof(output), &pos,
|
||||||
found[i].username, query);
|
msg->username, query);
|
||||||
buffer_appendf(output, sizeof(output), &pos, ": ");
|
buffer_appendf(output, sizeof(output), &pos, ": ");
|
||||||
append_highlighted(output, sizeof(output), &pos,
|
append_highlighted(output, sizeof(output), &pos,
|
||||||
found[i].content, query);
|
msg->content, query);
|
||||||
buffer_appendf(output, sizeof(output), &pos, "\n");
|
buffer_appendf(output, sizeof(output), &pos, "\n");
|
||||||
}
|
}
|
||||||
free(found);
|
free(found);
|
||||||
|
|
|
||||||
47
src/exec.c
47
src/exec.c
|
|
@ -5,7 +5,9 @@
|
||||||
#include "exec_catalog.h"
|
#include "exec_catalog.h"
|
||||||
#include "i18n.h"
|
#include "i18n.h"
|
||||||
#include "input.h"
|
#include "input.h"
|
||||||
|
#include "json_text.h"
|
||||||
#include "message.h"
|
#include "message.h"
|
||||||
|
#include "module_runtime.h"
|
||||||
#include "ratelimit.h"
|
#include "ratelimit.h"
|
||||||
#include "utf8.h"
|
#include "utf8.h"
|
||||||
#include <ctype.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,
|
static void resolve_exec_username(const client_t *client, char *buffer,
|
||||||
size_t buf_size) {
|
size_t buf_size) {
|
||||||
if (!buffer || buf_size == 0) {
|
if (!buffer || buf_size == 0) {
|
||||||
|
|
@ -188,7 +148,7 @@ static int exec_command_users(client_t *client, bool json) {
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
buffer_append_bytes(output, output_size, &pos, ",", 1);
|
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);
|
buffer_append_bytes(output, output_size, &pos, "]\n", 2);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -467,6 +427,7 @@ static int exec_command_post(client_t *client, const char *args) {
|
||||||
|
|
||||||
room_broadcast(g_room, &msg);
|
room_broadcast(g_room, &msg);
|
||||||
notify_mentions(msg.content, client);
|
notify_mentions(msg.content, client);
|
||||||
|
tnt_module_runtime_publish_message_created(&msg);
|
||||||
|
|
||||||
if (client_send(client, "posted\n", 7) != 0) {
|
if (client_send(client, "posted\n", 7) != 0) {
|
||||||
return TNT_EXIT_ERROR;
|
return TNT_EXIT_ERROR;
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
|
||||||
"NORMAL MODE KEYS:\n"
|
"NORMAL MODE KEYS:\n"
|
||||||
" Opens at latest messages\n"
|
" Opens at latest messages\n"
|
||||||
" Follows latest until you scroll up\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"
|
" : - Enter COMMAND mode\n"
|
||||||
" / - Search message history\n"
|
" / - Search message history\n"
|
||||||
" j/k - Scroll down/up one line\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"
|
"NORMAL 模式按键:\n"
|
||||||
" 默认停在最新消息\n"
|
" 默认停在最新消息\n"
|
||||||
" 未向上翻阅时自动跟随最新消息\n"
|
" 未向上翻阅时自动跟随最新消息\n"
|
||||||
" i - 返回 INSERT 模式\n"
|
" i/a/o - 返回 INSERT 模式\n"
|
||||||
" : - 进入 COMMAND 模式\n"
|
" : - 进入 COMMAND 模式\n"
|
||||||
" / - 搜索消息历史\n"
|
" / - 搜索消息历史\n"
|
||||||
" j/k - 向下/上滚动一行\n"
|
" j/k - 向下/上滚动一行\n"
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,14 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = {
|
||||||
"? keys",
|
"? keys",
|
||||||
"? 按键"
|
"? 按键"
|
||||||
),
|
),
|
||||||
|
[I18N_EMPTY_ROOM] = I18N_STRING(
|
||||||
|
"No messages yet",
|
||||||
|
"暂无消息"
|
||||||
|
),
|
||||||
|
[I18N_EMPTY_FILTERED] = I18N_STRING(
|
||||||
|
"No visible messages",
|
||||||
|
"暂无可见消息"
|
||||||
|
),
|
||||||
[I18N_IDLE_TIMEOUT_FORMAT] = I18N_STRING(
|
[I18N_IDLE_TIMEOUT_FORMAT] = I18N_STRING(
|
||||||
"\r\n\033[33mDisconnected: idle timeout (%d min)\033[0m\r\n",
|
"\r\n\033[33mDisconnected: idle timeout (%d min)\033[0m\r\n",
|
||||||
"\r\n\033[33m已断开: 空闲超时 (%d 分钟)\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",
|
"User '%s' not found\n",
|
||||||
"未找到用户 '%s'\n"
|
"未找到用户 '%s'\n"
|
||||||
),
|
),
|
||||||
|
[I18N_REPLY_NO_TARGET] = I18N_STRING(
|
||||||
|
"No private message to reply to\n",
|
||||||
|
"没有可回复的私信\n"
|
||||||
|
),
|
||||||
[I18N_INBOX_TITLE] = I18N_STRING(
|
[I18N_INBOX_TITLE] = I18N_STRING(
|
||||||
"Private messages",
|
"Private messages",
|
||||||
"私信"
|
"私信"
|
||||||
|
|
@ -121,6 +133,18 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = {
|
||||||
"(empty)",
|
"(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(
|
[I18N_NICK_INVALID] = I18N_STRING(
|
||||||
"Invalid username\n",
|
"Invalid username\n",
|
||||||
"用户名无效\n"
|
"用户名无效\n"
|
||||||
|
|
@ -141,10 +165,18 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = {
|
||||||
"--- Last %d message(s) ---\n",
|
"--- Last %d message(s) ---\n",
|
||||||
"--- 最近 %d 条消息 ---\n"
|
"--- 最近 %d 条消息 ---\n"
|
||||||
),
|
),
|
||||||
|
[I18N_LAST_EMPTY] = I18N_STRING(
|
||||||
|
"No messages to show\n",
|
||||||
|
"没有可显示的消息\n"
|
||||||
|
),
|
||||||
[I18N_SEARCH_HEADER_FORMAT] = I18N_STRING(
|
[I18N_SEARCH_HEADER_FORMAT] = I18N_STRING(
|
||||||
"--- Search: \"%s\" (showing last %d match(es)) ---\n",
|
"--- Search: \"%s\" (showing last %d match(es)) ---\n",
|
||||||
"--- 搜索: \"%s\" (显示最近 %d 条匹配) ---\n"
|
"--- 搜索: \"%s\" (显示最近 %d 条匹配) ---\n"
|
||||||
),
|
),
|
||||||
|
[I18N_SEARCH_EMPTY] = I18N_STRING(
|
||||||
|
"No matches\n",
|
||||||
|
"没有匹配结果\n"
|
||||||
|
),
|
||||||
[I18N_MUTE_JOINS_FORMAT] = I18N_STRING(
|
[I18N_MUTE_JOINS_FORMAT] = I18N_STRING(
|
||||||
"Join/leave notifications: %s\n",
|
"Join/leave notifications: %s\n",
|
||||||
"加入/离开提示: %s\n"
|
"加入/离开提示: %s\n"
|
||||||
|
|
|
||||||
138
src/input.c
138
src/input.c
|
|
@ -7,7 +7,9 @@
|
||||||
#include "exec.h"
|
#include "exec.h"
|
||||||
#include "history_view.h"
|
#include "history_view.h"
|
||||||
#include "i18n.h"
|
#include "i18n.h"
|
||||||
|
#include "input_buffer.h"
|
||||||
#include "message.h"
|
#include "message.h"
|
||||||
|
#include "module_runtime.h"
|
||||||
#include "ratelimit.h"
|
#include "ratelimit.h"
|
||||||
#include "system_message.h"
|
#include "system_message.h"
|
||||||
#include "tui.h"
|
#include "tui.h"
|
||||||
|
|
@ -24,6 +26,8 @@
|
||||||
static int g_idle_timeout = TNT_DEFAULT_IDLE_TIMEOUT;
|
static int g_idle_timeout = TNT_DEFAULT_IDLE_TIMEOUT;
|
||||||
static ui_lang_t g_default_ui_lang = UI_LANG_EN;
|
static ui_lang_t g_default_ui_lang = UI_LANG_EN;
|
||||||
|
|
||||||
|
#define MAIN_LOOP_POLL_TIMEOUT_MS 250
|
||||||
|
|
||||||
void input_init(void) {
|
void input_init(void) {
|
||||||
g_idle_timeout = tnt_config_env_int(&TNT_CONFIG_IDLE_TIMEOUT);
|
g_idle_timeout = tnt_config_env_int(&TNT_CONFIG_IDLE_TIMEOUT);
|
||||||
g_default_ui_lang = i18n_default_ui_lang();
|
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;
|
return (int)got;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool append_paste_byte(char *input, unsigned char b) {
|
static int normal_visible_message_count(const client_t *client) {
|
||||||
if (b == '\r' || b == '\n' || b == '\t') {
|
if (!client || !client->mute_joins) {
|
||||||
b = ' ';
|
return room_get_message_count(g_room);
|
||||||
}
|
|
||||||
if (b < 32) {
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
size_t cur = strlen(input);
|
int count = 0;
|
||||||
if (cur < MAX_MESSAGE_LEN - 1) {
|
pthread_rwlock_rdlock(&g_room->lock);
|
||||||
input[cur] = (char)b;
|
for (int i = 0; i < g_room->message_count; i++) {
|
||||||
input[cur + 1] = '\0';
|
if (!system_message_is_join_leave(&g_room->messages[i])) {
|
||||||
return true;
|
count++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
pthread_rwlock_unlock(&g_room->lock);
|
||||||
return false;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void normal_scroll_to_latest(client_t *client) {
|
static void normal_scroll_to_latest(client_t *client) {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
history_view_scroll_to_latest(&client->scroll_pos, &client->follow_tail,
|
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));
|
history_view_height(client->height));
|
||||||
}
|
}
|
||||||
|
|
||||||
static void normal_scroll_by(client_t *client, int delta) {
|
static void normal_scroll_by(client_t *client, int delta) {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
history_view_scroll_by(&client->scroll_pos, &client->follow_tail,
|
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);
|
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) {
|
static void dismiss_command_output(client_t *client) {
|
||||||
bool was_motd;
|
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
|
* spaces so a multi-line paste stays a
|
||||||
* single message instead of N sends. */
|
* single message instead of N sends. */
|
||||||
bool overflow = false;
|
bool overflow = false;
|
||||||
|
bool invalid_utf8 = false;
|
||||||
|
tnt_input_utf8_state_t paste_utf8 = {0};
|
||||||
while (1) {
|
while (1) {
|
||||||
char b;
|
char b;
|
||||||
int k = ssh_channel_read_timeout(
|
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
|
* but keep printable bytes that
|
||||||
* followed it. */
|
* followed it. */
|
||||||
for (int i = 0; i < t; i++) {
|
for (int i = 0; i < t; i++) {
|
||||||
if (!append_paste_byte(
|
int status =
|
||||||
input,
|
tnt_input_append_stream_byte(
|
||||||
(unsigned char)tail[i])) {
|
input, MAX_MESSAGE_LEN,
|
||||||
|
&paste_utf8,
|
||||||
|
(unsigned char)tail[i],
|
||||||
|
true);
|
||||||
|
if (status &
|
||||||
|
TNT_INPUT_APPEND_OVERFLOW) {
|
||||||
overflow = true;
|
overflow = true;
|
||||||
}
|
}
|
||||||
|
if (status &
|
||||||
|
TNT_INPUT_APPEND_INVALID_UTF8) {
|
||||||
|
invalid_utf8 = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!append_paste_byte(input,
|
int status = tnt_input_append_stream_byte(
|
||||||
(unsigned char)b)) {
|
input, MAX_MESSAGE_LEN, &paste_utf8,
|
||||||
|
(unsigned char)b, true);
|
||||||
|
if (status & TNT_INPUT_APPEND_OVERFLOW) {
|
||||||
overflow = true;
|
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);
|
tui_render_input(client, input);
|
||||||
if (overflow) {
|
if (overflow || invalid_utf8) {
|
||||||
client_send(client, "\a", 1);
|
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);
|
room_broadcast(g_room, &msg);
|
||||||
notify_mentions(msg.content, client);
|
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';
|
input[0] = '\0';
|
||||||
}
|
}
|
||||||
tui_render_screen(client);
|
tui_render_screen(client);
|
||||||
|
|
@ -629,22 +664,20 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
|
||||||
case MODE_NORMAL: {
|
case MODE_NORMAL: {
|
||||||
int nm_msg_height = history_view_height(client->height);
|
int nm_msg_height = history_view_height(client->height);
|
||||||
|
|
||||||
if (key == 'i') {
|
if (key == 'i' || key == 'a' || key == 'A' ||
|
||||||
client->mode = MODE_INSERT;
|
key == 'o' || key == 'O') {
|
||||||
client->follow_tail = true;
|
normal_enter_insert(client);
|
||||||
client->unread_mentions = 0;
|
|
||||||
tui_render_screen(client);
|
|
||||||
return true;
|
return true;
|
||||||
} else if (key == ':') {
|
} else if (key == ':') {
|
||||||
client->mode = MODE_COMMAND;
|
client->mode = MODE_COMMAND;
|
||||||
client->command_input[0] = '\0';
|
client->command_input[0] = '\0';
|
||||||
tui_render_screen(client);
|
tui_render_command_input(client);
|
||||||
return true;
|
return true;
|
||||||
} else if (key == '/') {
|
} else if (key == '/') {
|
||||||
client->mode = MODE_COMMAND;
|
client->mode = MODE_COMMAND;
|
||||||
snprintf(client->command_input, sizeof(client->command_input),
|
snprintf(client->command_input, sizeof(client->command_input),
|
||||||
"search ");
|
"search ");
|
||||||
tui_render_screen(client);
|
tui_render_command_input(client);
|
||||||
return true;
|
return true;
|
||||||
} else if (key == 'j') {
|
} else if (key == 'j') {
|
||||||
normal_scroll_by(client, 1);
|
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],
|
client->command_history[client->command_history_pos],
|
||||||
sizeof(client->command_input) - 1);
|
sizeof(client->command_input) - 1);
|
||||||
client->command_input[sizeof(client->command_input) - 1] = '\0';
|
client->command_input[sizeof(client->command_input) - 1] = '\0';
|
||||||
tui_render_screen(client);
|
tui_render_command_input(client);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} else if (seq[1] == 'B') { /* Down arrow */
|
} 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_history_pos = client->command_history_count;
|
||||||
client->command_input[0] = '\0';
|
client->command_input[0] = '\0';
|
||||||
}
|
}
|
||||||
tui_render_screen(client);
|
tui_render_command_input(client);
|
||||||
return true;
|
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 */
|
} else if (key == 127 || key == 8) { /* Backspace */
|
||||||
if (client->command_input[0] != '\0') {
|
if (client->command_input[0] != '\0') {
|
||||||
utf8_remove_last_char(client->command_input);
|
utf8_remove_last_char(client->command_input);
|
||||||
tui_render_screen(client);
|
tui_render_command_input(client);
|
||||||
}
|
}
|
||||||
return true; /* Key consumed */
|
return true; /* Key consumed */
|
||||||
} else if (key == 23) { /* Ctrl+W (Delete Word) */
|
} else if (key == 23) { /* Ctrl+W (Delete Word) */
|
||||||
if (client->command_input[0] != '\0') {
|
if (client->command_input[0] != '\0') {
|
||||||
utf8_remove_last_word(client->command_input);
|
utf8_remove_last_word(client->command_input);
|
||||||
tui_render_screen(client);
|
tui_render_command_input(client);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} else if (key == 21) { /* Ctrl+U (Delete Line) */
|
} else if (key == 21) { /* Ctrl+U (Delete Line) */
|
||||||
if (client->command_input[0] != '\0') {
|
if (client->command_input[0] != '\0') {
|
||||||
client->command_input[0] = '\0';
|
client->command_input[0] = '\0';
|
||||||
tui_render_screen(client);
|
tui_render_command_input(client);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -892,7 +925,8 @@ main_loop:
|
||||||
break;
|
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) {
|
if (ready == SSH_ERROR) {
|
||||||
break;
|
break;
|
||||||
|
|
@ -986,10 +1020,9 @@ main_loop:
|
||||||
if (client->mode == MODE_INSERT && !client->show_help &&
|
if (client->mode == MODE_INSERT && !client->show_help &&
|
||||||
client->command_output[0] == '\0') {
|
client->command_output[0] == '\0') {
|
||||||
if (b >= 32 && b < 127) { /* ASCII printable */
|
if (b >= 32 && b < 127) { /* ASCII printable */
|
||||||
int len = strlen(input);
|
int status = tnt_input_append_ascii(input,
|
||||||
if (len < MAX_MESSAGE_LEN - 1) {
|
MAX_MESSAGE_LEN, b);
|
||||||
input[len] = b;
|
if (status == TNT_INPUT_APPEND_OK) {
|
||||||
input[len + 1] = '\0';
|
|
||||||
tui_render_input(client, input);
|
tui_render_input(client, input);
|
||||||
} else {
|
} else {
|
||||||
client_send(client, "\a", 1);
|
client_send(client, "\a", 1);
|
||||||
|
|
@ -1013,10 +1046,9 @@ main_loop:
|
||||||
/* Invalid UTF-8 sequence */
|
/* Invalid UTF-8 sequence */
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
int len = strlen(input);
|
int status = tnt_input_append_utf8_sequence(
|
||||||
if (len + char_len <= MAX_MESSAGE_LEN - 1) {
|
input, MAX_MESSAGE_LEN, buf, char_len);
|
||||||
memcpy(input + len, buf, char_len);
|
if (status == TNT_INPUT_APPEND_OK) {
|
||||||
input[len + char_len] = '\0';
|
|
||||||
tui_render_input(client, input);
|
tui_render_input(client, input);
|
||||||
} else {
|
} else {
|
||||||
client_send(client, "\a", 1);
|
client_send(client, "\a", 1);
|
||||||
|
|
@ -1025,11 +1057,11 @@ main_loop:
|
||||||
} else if (client->mode == MODE_COMMAND && !client->show_help &&
|
} else if (client->mode == MODE_COMMAND && !client->show_help &&
|
||||||
client->command_output[0] == '\0') {
|
client->command_output[0] == '\0') {
|
||||||
if (b >= 32 && b < 127) { /* ASCII printable */
|
if (b >= 32 && b < 127) { /* ASCII printable */
|
||||||
size_t len = strlen(client->command_input);
|
int status = tnt_input_append_ascii(
|
||||||
if (len < sizeof(client->command_input) - 1) {
|
client->command_input, sizeof(client->command_input),
|
||||||
client->command_input[len] = b;
|
b);
|
||||||
client->command_input[len + 1] = '\0';
|
if (status == TNT_INPUT_APPEND_OK) {
|
||||||
tui_render_screen(client);
|
tui_render_command_input(client);
|
||||||
} else {
|
} else {
|
||||||
client_send(client, "\a", 1);
|
client_send(client, "\a", 1);
|
||||||
}
|
}
|
||||||
|
|
@ -1043,11 +1075,11 @@ main_loop:
|
||||||
if (read_bytes != char_len - 1) continue;
|
if (read_bytes != char_len - 1) continue;
|
||||||
}
|
}
|
||||||
if (!utf8_is_valid_sequence(buf, char_len)) continue;
|
if (!utf8_is_valid_sequence(buf, char_len)) continue;
|
||||||
size_t len = strlen(client->command_input);
|
int status = tnt_input_append_utf8_sequence(
|
||||||
if (len + (size_t)char_len <= sizeof(client->command_input) - 1) {
|
client->command_input, sizeof(client->command_input),
|
||||||
memcpy(client->command_input + len, buf, char_len);
|
buf, char_len);
|
||||||
client->command_input[len + char_len] = '\0';
|
if (status == TNT_INPUT_APPEND_OK) {
|
||||||
tui_render_screen(client);
|
tui_render_command_input(client);
|
||||||
} else {
|
} else {
|
||||||
client_send(client, "\a", 1);
|
client_send(client, "\a", 1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
147
src/input_buffer.c
Normal file
147
src/input_buffer.c
Normal 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
343
src/json_text.c
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
#include "i18n.h"
|
#include "i18n.h"
|
||||||
#include "message.h"
|
#include "message.h"
|
||||||
#include "message_log_tool.h"
|
#include "message_log_tool.h"
|
||||||
|
#include "module_runtime.h"
|
||||||
#include "ssh_server.h"
|
#include "ssh_server.h"
|
||||||
#include <signal.h>
|
#include <signal.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
@ -238,17 +239,23 @@ int main(int argc, char **argv) {
|
||||||
}
|
}
|
||||||
|
|
||||||
message_init();
|
message_init();
|
||||||
|
if (tnt_module_runtime_init() < 0) {
|
||||||
|
fprintf(stderr, "Failed to initialize module runtime\n");
|
||||||
|
return TNT_EXIT_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
/* Create chat room */
|
/* Create chat room */
|
||||||
g_room = room_create();
|
g_room = room_create();
|
||||||
if (!g_room) {
|
if (!g_room) {
|
||||||
fprintf(stderr, "Failed to create chat room\n");
|
fprintf(stderr, "Failed to create chat room\n");
|
||||||
|
tnt_module_runtime_shutdown();
|
||||||
return TNT_EXIT_ERROR;
|
return TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Initialize server */
|
/* Initialize server */
|
||||||
if (ssh_server_init(port) < 0) {
|
if (ssh_server_init(port) < 0) {
|
||||||
fprintf(stderr, "Failed to initialize server\n");
|
fprintf(stderr, "Failed to initialize server\n");
|
||||||
|
tnt_module_runtime_shutdown();
|
||||||
room_destroy(g_room);
|
room_destroy(g_room);
|
||||||
return TNT_EXIT_ERROR;
|
return TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
|
|
@ -256,6 +263,7 @@ int main(int argc, char **argv) {
|
||||||
/* Start server (blocking) */
|
/* Start server (blocking) */
|
||||||
int ret = ssh_server_start(0);
|
int ret = ssh_server_start(0);
|
||||||
|
|
||||||
|
tnt_module_runtime_shutdown();
|
||||||
room_destroy(g_room);
|
room_destroy(g_room);
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ void manual_text_append_interactive(char *buffer, size_t buf_size,
|
||||||
"\n"
|
"\n"
|
||||||
"\033[1;37mUse\033[0m\n"
|
"\033[1;37mUse\033[0m\n"
|
||||||
" Type, Enter sends; Up/Down recalls; Tab completes @mentions\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"
|
"\n"
|
||||||
"\033[1;37mCommands\033[0m\n",
|
"\033[1;37mCommands\033[0m\n",
|
||||||
"\033[1;36mTNT(1) 帮助\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"
|
"\n"
|
||||||
"\033[1;37m使用\033[0m\n"
|
"\033[1;37m使用\033[0m\n"
|
||||||
" 输入并 Enter 发送;Up/Down 调出消息;Tab 补全 @mention\n"
|
" 输入并 Enter 发送;Up/Down 调出消息;Tab 补全 @mention\n"
|
||||||
" Esc 浏览;/ 搜索;G 最新;i 输入;: 命令;? 按键\n"
|
" Esc 浏览;/ 搜索;G 最新;i/a/o 输入;: 命令;? 按键\n"
|
||||||
"\n"
|
"\n"
|
||||||
"\033[1;37m命令\033[0m\n"
|
"\033[1;37m命令\033[0m\n"
|
||||||
);
|
);
|
||||||
|
|
|
||||||
112
src/module_protocol.c
Normal file
112
src/module_protocol.c
Normal 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
537
src/module_runtime.c
Normal 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);
|
||||||
|
}
|
||||||
167
src/tui.c
167
src/tui.c
|
|
@ -21,6 +21,25 @@ static const char *username_color(const char *name) {
|
||||||
return colors[h % 6];
|
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,
|
static void format_message_colored(const message_t *msg, char *buffer,
|
||||||
size_t buf_size, int width,
|
size_t buf_size, int width,
|
||||||
const char *my_username) {
|
const char *my_username) {
|
||||||
|
|
@ -245,7 +264,7 @@ void tui_render_screen(client_t *client) {
|
||||||
if (render_height < 4) render_height = 4;
|
if (render_height < 4) render_height = 4;
|
||||||
|
|
||||||
const size_t buf_size = (size_t)(render_height + 10) * (MAX_MESSAGE_LEN + 64) + 2048;
|
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;
|
if (!buffer) return;
|
||||||
size_t pos = 0;
|
size_t pos = 0;
|
||||||
buffer[0] = '\0';
|
buffer[0] = '\0';
|
||||||
|
|
@ -255,6 +274,7 @@ void tui_render_screen(client_t *client) {
|
||||||
int online = g_room->client_count;
|
int online = g_room->client_count;
|
||||||
int msg_count = g_room->message_count;
|
int msg_count = g_room->message_count;
|
||||||
pthread_rwlock_unlock(&g_room->lock);
|
pthread_rwlock_unlock(&g_room->lock);
|
||||||
|
int raw_msg_count = msg_count;
|
||||||
|
|
||||||
/* Calculate which messages to show. The initial slice is capped by
|
/* Calculate which messages to show. The initial slice is capped by
|
||||||
* message count; the lock-held copy below tightens "latest" slices so
|
* message count; the lock-held copy below tightens "latest" slices so
|
||||||
|
|
@ -280,47 +300,95 @@ void tui_render_screen(client_t *client) {
|
||||||
int end = start + msg_height;
|
int end = start + msg_height;
|
||||||
if (end > msg_count) end = msg_count;
|
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;
|
message_t *msg_snapshot = NULL;
|
||||||
int snapshot_capacity = msg_height;
|
int snapshot_count = 0;
|
||||||
int snapshot_count = end - start;
|
|
||||||
|
|
||||||
if (snapshot_count > 0 && snapshot_capacity > 0) {
|
if (client->mute_joins && msg_count > 0) {
|
||||||
msg_snapshot = calloc(snapshot_capacity, sizeof(message_t));
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Second pass under lock: copy messages */
|
if (!visible_messages) {
|
||||||
if (msg_snapshot) {
|
/* Allocate snapshot outside the lock to avoid blocking writers */
|
||||||
pthread_rwlock_rdlock(&g_room->lock);
|
int snapshot_capacity = msg_height;
|
||||||
/* Re-clamp in case msg_count changed */
|
snapshot_count = end - start;
|
||||||
int actual_count = g_room->message_count;
|
|
||||||
int actual_start = start;
|
if (snapshot_count > 0 && snapshot_capacity > 0) {
|
||||||
int actual_end = end;
|
msg_snapshot = calloc(snapshot_capacity, sizeof(message_t));
|
||||||
if (anchor_latest) {
|
|
||||||
actual_end = actual_count;
|
|
||||||
actual_start = history_view_latest_start_for_height(
|
|
||||||
g_room->messages, actual_count, msg_height);
|
|
||||||
} else {
|
|
||||||
actual_end = (actual_end <= actual_count) ? actual_end : actual_count;
|
|
||||||
actual_start = (actual_start < actual_end) ? actual_start : actual_end;
|
|
||||||
}
|
}
|
||||||
int actual_snapshot = actual_end - actual_start;
|
|
||||||
if (actual_snapshot > 0 && actual_snapshot <= snapshot_capacity) {
|
/* Second pass under lock: copy messages */
|
||||||
memcpy(msg_snapshot, &g_room->messages[actual_start],
|
if (msg_snapshot) {
|
||||||
actual_snapshot * sizeof(message_t));
|
pthread_rwlock_rdlock(&g_room->lock);
|
||||||
start = actual_start;
|
/* Re-clamp in case msg_count changed */
|
||||||
end = actual_end;
|
int actual_count = g_room->message_count;
|
||||||
snapshot_count = actual_snapshot;
|
int actual_start = start;
|
||||||
} else {
|
int actual_end = end;
|
||||||
snapshot_count = 0;
|
if (anchor_latest) {
|
||||||
|
actual_end = actual_count;
|
||||||
|
actual_start = history_view_latest_start_for_height(
|
||||||
|
g_room->messages, actual_count, msg_height);
|
||||||
|
} else {
|
||||||
|
actual_end = (actual_end <= actual_count) ? actual_end : actual_count;
|
||||||
|
actual_start = (actual_start < actual_end) ? actual_start : actual_end;
|
||||||
|
}
|
||||||
|
int actual_snapshot = actual_end - actual_start;
|
||||||
|
if (actual_snapshot > 0 && actual_snapshot <= snapshot_capacity) {
|
||||||
|
memcpy(msg_snapshot, &g_room->messages[actual_start],
|
||||||
|
actual_snapshot * sizeof(message_t));
|
||||||
|
start = actual_start;
|
||||||
|
end = actual_end;
|
||||||
|
snapshot_count = actual_snapshot;
|
||||||
|
} else {
|
||||||
|
snapshot_count = 0;
|
||||||
|
}
|
||||||
|
pthread_rwlock_unlock(&g_room->lock);
|
||||||
}
|
}
|
||||||
pthread_rwlock_unlock(&g_room->lock);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Now render using snapshot (no lock held) */
|
/* Now render using snapshot (no lock held) */
|
||||||
|
|
||||||
/* If mute_joins is set, remove join/leave messages from snapshot in place */
|
/* 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;
|
int filtered = 0;
|
||||||
for (int i = 0; i < snapshot_count; i++) {
|
for (int i = 0; i < snapshot_count; i++) {
|
||||||
if (!system_message_is_join_leave(&msg_snapshot[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);
|
buffer_appendf(buffer, buf_size, &pos, "%s\033[K\r\n", msg_line);
|
||||||
rows_written++;
|
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 */
|
/* Fill empty lines and clear them */
|
||||||
for (int i = rows_written; i < msg_height; i++) {
|
for (int i = rows_written; i < msg_height; i++) {
|
||||||
buffer_appendf(buffer, buf_size, &pos, "\033[K\r\n");
|
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);
|
tui_status_append(buffer, buf_size, &pos, client, msg_count, start, end);
|
||||||
|
|
||||||
client_send(client, buffer, pos);
|
client_send(client, buffer, pos);
|
||||||
free(buffer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Render the input line.
|
/* Render the input line.
|
||||||
|
|
@ -608,6 +692,23 @@ void tui_render_input(client_t *client, const char *input) {
|
||||||
client_send(client, buffer, strlen(buffer));
|
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 */
|
/* Render the command output screen */
|
||||||
void tui_render_command_output(client_t *client) {
|
void tui_render_command_output(client_t *client) {
|
||||||
if (!client || !client->connected) return;
|
if (!client || !client->connected) return;
|
||||||
|
|
|
||||||
109
tests/test_empty_view.sh
Executable file
109
tests/test_empty_view.sh
Executable 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"
|
||||||
|
|
@ -591,6 +591,38 @@ else
|
||||||
FAIL=$((FAIL + 1))
|
FAIL=$((FAIL + 1))
|
||||||
fi
|
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 ""
|
||||||
echo "PASSED: $PASS"
|
echo "PASSED: $PASS"
|
||||||
echo "FAILED: $FAIL"
|
echo "FAILED: $FAIL"
|
||||||
|
|
|
||||||
130
tests/test_module_runtime.sh
Executable file
130
tests/test_module_runtime.sh
Executable 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
132
tests/test_mute_joins_view.sh
Executable 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"
|
||||||
|
|
@ -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"
|
SSH_EXEC_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
|
||||||
BOB_READY="$STATE_DIR/bob.ready"
|
BOB_READY="$STATE_DIR/bob.ready"
|
||||||
PRIVATE_SENT="$STATE_DIR/private.sent"
|
PRIVATE_SENT="$STATE_DIR/private.sent"
|
||||||
|
REPLY_SENT="$STATE_DIR/reply.sent"
|
||||||
|
|
||||||
wait_for_health() {
|
wait_for_health() {
|
||||||
out=""
|
out=""
|
||||||
|
|
@ -92,7 +93,16 @@ exec touch "$BOB_READY"
|
||||||
exec sh -c "while \[ ! -f '$PRIVATE_SENT' \]; do sleep 1; done"
|
exec sh -c "while \[ ! -f '$PRIVATE_SENT' \]; do sleep 1; done"
|
||||||
expect "私信"
|
expect "私信"
|
||||||
expect "alice"
|
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:关闭"
|
expect "q:关闭"
|
||||||
send -- "q"
|
send -- "q"
|
||||||
expect "NORMAL"
|
expect "NORMAL"
|
||||||
|
|
@ -201,12 +211,46 @@ send -- "q"
|
||||||
expect "NORMAL"
|
expect "NORMAL"
|
||||||
send -- ":"
|
send -- ":"
|
||||||
expect ":"
|
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"
|
expect "私信已发送给 bob"
|
||||||
exec touch "$PRIVATE_SENT"
|
exec touch "$PRIVATE_SENT"
|
||||||
expect "q:关闭"
|
expect "q:关闭"
|
||||||
send -- "q"
|
send -- "q"
|
||||||
expect "NORMAL"
|
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 -- ":"
|
send -- ":"
|
||||||
expect ":"
|
expect ":"
|
||||||
send -- "nick alice2\r"
|
send -- "nick alice2\r"
|
||||||
|
|
@ -244,6 +288,20 @@ else
|
||||||
fi
|
fi
|
||||||
BOB_PID=""
|
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)
|
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 'hello lifecycle alpha' &&
|
||||||
printf '%s\n' "$TAIL_OUTPUT" | grep -q 'alice2 ships lifecycle'
|
printf '%s\n' "$TAIL_OUTPUT" | grep -q 'alice2 ships lifecycle'
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ endif
|
||||||
|
|
||||||
# Source files
|
# Source files
|
||||||
UTF8_SRC = ../../src/utf8.c
|
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_SRC = ../../src/message.c
|
||||||
MESSAGE_LOG_SRC = ../../src/message_log.c
|
MESSAGE_LOG_SRC = ../../src/message_log.c
|
||||||
COMMON_SRC = ../../src/common.c
|
COMMON_SRC = ../../src/common.c
|
||||||
|
|
@ -28,7 +32,7 @@ HELP_TEXT_SRC = ../../src/help_text.c
|
||||||
MANUAL_TEXT_SRC = ../../src/manual_text.c
|
MANUAL_TEXT_SRC = ../../src/manual_text.c
|
||||||
RATELIMIT_SRC = ../../src/ratelimit.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
|
.PHONY: all clean run
|
||||||
|
|
||||||
|
|
@ -37,6 +41,18 @@ all: $(TESTS)
|
||||||
test_utf8: test_utf8.c $(UTF8_SRC)
|
test_utf8: test_utf8.c $(UTF8_SRC)
|
||||||
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
$(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)
|
test_message: test_message.c $(MESSAGE_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC)
|
||||||
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
||||||
|
|
||||||
|
|
@ -80,6 +96,18 @@ run: all
|
||||||
@echo "=== Running UTF-8 Tests ==="
|
@echo "=== Running UTF-8 Tests ==="
|
||||||
./test_utf8
|
./test_utf8
|
||||||
@echo ""
|
@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 ==="
|
@echo "=== Running Message Tests ==="
|
||||||
./test_message
|
./test_message
|
||||||
@echo ""
|
@echo ""
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,18 @@ TEST(matches_canonical_names_and_aliases) {
|
||||||
assert(id == TNT_COMMAND_MSG);
|
assert(id == TNT_COMMAND_MSG);
|
||||||
assert(strcmp(args, "alice hello") == 0);
|
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(command_catalog_match("language zh", &id, &args));
|
||||||
assert(id == TNT_COMMAND_LANG);
|
assert(id == TNT_COMMAND_LANG);
|
||||||
assert(strcmp(args, "zh") == 0);
|
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, NULL));
|
||||||
assert(command_catalog_args_valid(TNT_COMMAND_MSG, "alice hello"));
|
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, ""));
|
||||||
assert(command_catalog_args_valid(TNT_COMMAND_SEARCH, "needle"));
|
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, ":users, :list, :who") != NULL);
|
||||||
assert(strstr(en, "Show online users") != NULL);
|
assert(strstr(en, "Show online users") != NULL);
|
||||||
assert(strstr(en, ":msg <user> <message>") != 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(en, ":support") == NULL);
|
||||||
|
|
||||||
assert(strstr(zh, ":users, :list, :who") != NULL);
|
assert(strstr(zh, ":users, :list, :who") != NULL);
|
||||||
assert(strstr(zh, "显示在线用户") != NULL);
|
assert(strstr(zh, "显示在线用户") != NULL);
|
||||||
assert(strstr(zh, "查看私信") != NULL);
|
assert(strstr(zh, "查看或清空私信") != NULL);
|
||||||
assert(strstr(zh, ":msg <user> <message>") != 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, "<消息>") == NULL);
|
assert(strstr(zh, "<消息>") == NULL);
|
||||||
assert(strstr(zh, ":support") == NULL);
|
assert(strstr(zh, ":support") == NULL);
|
||||||
|
|
@ -120,6 +139,19 @@ TEST(generates_localized_usage) {
|
||||||
assert(strcmp(zh, "用法: msg <user> <message>\n"
|
assert(strcmp(zh, "用法: msg <user> <message>\n"
|
||||||
" w <user> <message>\n") == 0);
|
" 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[0] = '\0';
|
||||||
en_pos = 0;
|
en_pos = 0;
|
||||||
command_catalog_append_usage(en, sizeof(en), &en_pos,
|
command_catalog_append_usage(en, sizeof(en), &en_pos,
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,10 @@ TEST(text_lookup_matches_language) {
|
||||||
"online") != NULL);
|
"online") != NULL);
|
||||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_TITLE_ONLINE_FORMAT),
|
assert(strstr(i18n_text(UI_LANG_ZH, I18N_TITLE_ONLINE_FORMAT),
|
||||||
"在线") != NULL);
|
"在线") != 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),
|
assert(strstr(i18n_text(UI_LANG_EN, I18N_IDLE_TIMEOUT_FORMAT),
|
||||||
"idle timeout") != NULL);
|
"idle timeout") != NULL);
|
||||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_IDLE_TIMEOUT_FORMAT),
|
assert(strstr(i18n_text(UI_LANG_ZH, I18N_IDLE_TIMEOUT_FORMAT),
|
||||||
|
|
@ -144,14 +148,34 @@ TEST(text_lookup_matches_language) {
|
||||||
"Private message sent") != NULL);
|
"Private message sent") != NULL);
|
||||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_MSG_SENT_FORMAT),
|
assert(strstr(i18n_text(UI_LANG_ZH, I18N_MSG_SENT_FORMAT),
|
||||||
"私信已发送") != NULL);
|
"私信已发送") != 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),
|
assert(strstr(i18n_text(UI_LANG_EN, I18N_INBOX_TITLE),
|
||||||
"Private messages") != NULL);
|
"Private messages") != NULL);
|
||||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_INBOX_TITLE),
|
assert(strstr(i18n_text(UI_LANG_ZH, I18N_INBOX_TITLE),
|
||||||
"私信") != NULL);
|
"私信") != 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),
|
assert(strstr(i18n_text(UI_LANG_EN, I18N_SEARCH_HEADER_FORMAT),
|
||||||
"Search") != NULL);
|
"Search") != NULL);
|
||||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_SEARCH_HEADER_FORMAT),
|
assert(strstr(i18n_text(UI_LANG_ZH, I18N_SEARCH_HEADER_FORMAT),
|
||||||
"搜索") != NULL);
|
"搜索") != 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),
|
assert(strstr(i18n_text(UI_LANG_EN, I18N_LANG_CURRENT_FORMAT),
|
||||||
"lang <en|zh>") != NULL);
|
"lang <en|zh>") != NULL);
|
||||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_LANG_CURRENT_FORMAT),
|
assert(strstr(i18n_text(UI_LANG_ZH, I18N_LANG_CURRENT_FORMAT),
|
||||||
|
|
|
||||||
130
tests/unit/test_input_buffer.c
Normal file
130
tests/unit/test_input_buffer.c
Normal 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;
|
||||||
|
}
|
||||||
95
tests/unit/test_json_text.c
Normal file
95
tests/unit/test_json_text.c
Normal 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;
|
||||||
|
}
|
||||||
120
tests/unit/test_module_protocol.c
Normal file
120
tests/unit/test_module_protocol.c
Normal 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;
|
||||||
|
}
|
||||||
155
tests/unit/test_module_runtime.c
Normal file
155
tests/unit/test_module_runtime.c
Normal 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
27
tnt.1
|
|
@ -203,7 +203,7 @@ PageDown/PageUp Scroll full page down/up
|
||||||
End/Home Jump to bottom/top
|
End/Home Jump to bottom/top
|
||||||
g/G Jump to top/bottom
|
g/G Jump to top/bottom
|
||||||
/ Search message history
|
/ Search message history
|
||||||
i Switch to INSERT
|
i/a/o Switch to INSERT
|
||||||
: Enter COMMAND mode
|
: Enter COMMAND mode
|
||||||
? Open full key reference
|
? Open full key reference
|
||||||
Ctrl+C Disconnect
|
Ctrl+C Disconnect
|
||||||
|
|
@ -220,7 +220,10 @@ l l.
|
||||||
:name \fIname\fR Alias for :nick
|
:name \fIname\fR Alias for :nick
|
||||||
:msg \fIuser message\fR Send private message
|
:msg \fIuser message\fR Send private message
|
||||||
:w \fIuser text\fR Short alias for :msg
|
: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)
|
:last [\fIN\fR] Show last N messages from history (1\-50, default 10)
|
||||||
:search \fIkeyword\fR Case\-insensitive search; shows the last 15 matches
|
:search \fIkeyword\fR Case\-insensitive search; shows the last 15 matches
|
||||||
:mute\-joins Toggle join/leave system notifications on/off
|
:mute\-joins Toggle join/leave system notifications on/off
|
||||||
|
|
@ -249,8 +252,19 @@ r Refresh live output (:inbox)
|
||||||
.PP
|
.PP
|
||||||
The
|
The
|
||||||
.B :inbox
|
.B :inbox
|
||||||
page refreshes automatically when a new private message arrives while it is
|
page shows incoming messages and local sent-message copies for the current
|
||||||
open.
|
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
|
.SH EXEC INTERFACE
|
||||||
Commands can be run non\-interactively for scripting:
|
Commands can be run non\-interactively for scripting:
|
||||||
.PP
|
.PP
|
||||||
|
|
@ -340,10 +354,11 @@ libssh log verbosity from 0 to 4 (default: 1).
|
||||||
.SH FILES
|
.SH FILES
|
||||||
.TP
|
.TP
|
||||||
.I messages.log
|
.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
|
RFC\ 3339 UTC pipe\-delimited records
|
||||||
.RI ( timestamp | username | content ).
|
.RI ( timestamp | username | content ).
|
||||||
Stored in the state directory.
|
Stored in the state directory. Private messages and in-memory inbox state are
|
||||||
|
excluded.
|
||||||
See
|
See
|
||||||
.I docs/MESSAGE_LOG.md
|
.I docs/MESSAGE_LOG.md
|
||||||
in the source distribution for parser and recovery rules.
|
in the source distribution for parser and recovery rules.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue