stdout-chat 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +20 -0
- package/bin/stdout-chat.js +32 -3
- package/lib/api.js +14 -0
- package/lib/config.js +6 -3
- package/lib/notify.js +0 -0
- package/lib/render.js +16 -0
- package/lib/session.js +80 -5
- package/package.json +2 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `stdout-chat` (the CLI). Dates are release dates.
|
|
4
|
+
|
|
5
|
+
## [0.3.0] — 2026-09-20
|
|
6
|
+
|
|
7
|
+
### What's New
|
|
8
|
+
|
|
9
|
+
- `/dm <nick|sid>` at the prompt invites someone from #void to a private chat. Pass a nick, or the id of one of their lines (the dim column on the left) to target that author. The invite lives 10 minutes; accepting happens in the app, and the private chat itself opens on your phone — the CLI prints the server's answer (`invite sent · nova has 10 min · you'll get a push when they accept`) or its refusal verbatim (`not_found`, `busy`, `rate_limited` …).
|
|
10
|
+
|
|
11
|
+
### Technical
|
|
12
|
+
|
|
13
|
+
- `lib/api.js`: `dm({ nick | sid }, key)` → `POST /void/dm`, JSON body `{sid}` or `{nick}`, `Accept: application/json`, returns `{ id, to, expires_at }`. Errors go through the existing `ApiError` mapping (server `message` verbatim, `retry_after` honoured).
|
|
14
|
+
- `lib/session.js`: `cmdDm` — no key → the usual hint, no argument → `usage: /dm <nick|sid>`, argument equal to a `sid` seen this session → `{sid}`, otherwise `{nick}`; the success line is built from the returned `to`.
|
|
15
|
+
- Tests: 76 → 82 (`/dm` sid/nick routing, usage, no-key, verbatim errors; `api.dm` request shape).
|
|
16
|
+
|
|
17
|
+
## [0.2.0] — 2026-09-20
|
|
18
|
+
|
|
19
|
+
### What's New
|
|
20
|
+
|
|
21
|
+
- Desktop notifications on macOS and Linux: a banner when someone replies to your line or writes `@you` while the client is running (at the prompt or in `--tail`). Live lines only; history and your own lines never notify.
|
|
22
|
+
- `/notify` shows the level; `/notify mentions|all|off` sets it and saves it next to the key.
|
|
23
|
+
- `--no-notify` (and `--notify`) plus `STDOUT_CHAT_NO_NOTIFY=1` to silence a single run. Flag beats env beats config.
|
|
24
|
+
|
|
25
|
+
### Technical
|
|
26
|
+
|
|
27
|
+
- New `lib/notify.js`: `createNotifier()` wraps `osascript -e 'display notification …'` (darwin) / `notify-send -a stdout-chat` (linux); Windows and other platforms are a no-op. AppleScript escaping (`\` then `"`), body cut at 120 code points, control characters stripped, 3 s exec timeout, one banner per 2 s (drops, no queue). The first exec error disables banners for the session and prints one dim `· notifications unavailable here`.
|
|
28
|
+
- `lib/render.js`: `isAddressedTo(msg, myNick)` → `'reply' | 'mention' | null` (word-bounded, case-insensitive, `a@nick` does not count).
|
|
29
|
+
- `lib/session.js`: banner hook fires only on live `msg` events that were actually printed; `verifyKey({ quiet })` for `--tail`, which now checks a stored key silently to learn the nick.
|
|
30
|
+
- `lib/config.js`: `notify` persisted in `config.json`; `/key` preserves it. Files stay `0600`.
|
|
31
|
+
- Tests: 55 → 76 (`test/notify.test.js` new; `isAddressedTo`, session, config and smoke cases added).
|
|
32
|
+
|
|
33
|
+
## [0.1.0] — 2026-09-20
|
|
34
|
+
|
|
35
|
+
- First release: read, tail and post to `#void` from the terminal · zero dependencies · Node ≥ 18.
|
package/README.md
CHANGED
|
@@ -47,7 +47,9 @@ The key is checked against the server, then saved to `~/.config/stdout-chat/conf
|
|
|
47
47
|
| `/r <id> text` | reply to a line — ids are the dim column on the left |
|
|
48
48
|
| `/top` | this week's top authors |
|
|
49
49
|
| `/who` | how many are in the room |
|
|
50
|
+
| `/dm <nick|sid>` | invite them to a private chat — a line's id targets its author; the chat itself opens on your phone |
|
|
50
51
|
| `/key sc_…` | save a key · `/key` shows who you are · `/key off` forgets it |
|
|
52
|
+
| `/notify` | desktop banners: `/notify` shows the level · `/notify mentions` (default) · `all` · `off` |
|
|
51
53
|
| `/clear` | clear the screen |
|
|
52
54
|
| `/help` | list this |
|
|
53
55
|
| `/quit` | leave (Ctrl-C and Ctrl-D too) |
|
|
@@ -62,6 +64,7 @@ Server errors are printed as the server phrases them (`slow down · retry in 2s`
|
|
|
62
64
|
-n <count> lines of history (default 30, max 100)
|
|
63
65
|
--api <url> API base (default https://api.stdout.chat; env STDOUT_CHAT_API)
|
|
64
66
|
--no-color plain output (NO_COLOR is honoured too)
|
|
67
|
+
--no-notify no desktop banners this run (env STDOUT_CHAT_NO_NOTIFY=1 works too)
|
|
65
68
|
--help, --version
|
|
66
69
|
```
|
|
67
70
|
|
|
@@ -95,6 +98,23 @@ void() { # void · void -f · void -r a1b4 text · void some words
|
|
|
95
98
|
- `GET /void` for history, `GET /void/stream` (Server-Sent Events) for the live feed, `POST /void` to speak, `GET /void/me` to check a key. All JSON.
|
|
96
99
|
- Reconnects with backoff (1 → 30 s) and `Last-Event-ID`, so nothing is missed across the server's 15-minute stream rotation.
|
|
97
100
|
- Plain scrolling output with a `readline` prompt: no alternate screen, no curses — works in tmux splits and over ssh.
|
|
101
|
+
- `POST /void/dm` sends a private-chat invite (`/dm`). The invite lives 10 minutes; when they accept you get a push and the private chat opens in the app on your phone — the terminal only sends the invite and prints what the server says.
|
|
102
|
+
|
|
103
|
+
## Notifications
|
|
104
|
+
|
|
105
|
+
The terminal is usually behind the editor. When someone replies to your line or writes `@you` while the client is running, a desktop banner shows up:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
#void
|
|
109
|
+
mox → you
|
|
110
|
+
ceiling fans, obviously
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
- **When:** live lines only — never the history printed at start, never your own lines. Works at the `>` prompt and in `--tail`; `--read` and piped output never notify.
|
|
114
|
+
- **Levels:** `mentions` (default: replies to you and `@nick`), `all` (every line by someone else), `off`. Set with `/notify mentions|all|off` at the prompt; it is saved to `config.json`. In `--tail` the key on file is checked quietly so `mentions` knows your nick; without a key only `all` can fire.
|
|
115
|
+
- **Off for one run:** `--no-notify`, or `STDOUT_CHAT_NO_NOTIFY=1`. Flag beats env beats config.
|
|
116
|
+
- **How:** macOS via `osascript` (`display notification`), Linux via `notify-send` (needs `libnotify`), no sound. Windows: nothing yet. If the local command fails once, banners switch off for the session and one dim `· notifications unavailable here` is printed. At most one banner per 2 seconds — a burst is dropped, not queued.
|
|
117
|
+
- **Privacy:** nothing leaves the machine. The banner is a line the room already sent you, handed to a local binary; no extra requests, no background process.
|
|
98
118
|
|
|
99
119
|
## Privacy
|
|
100
120
|
|
package/bin/stdout-chat.js
CHANGED
|
@@ -6,7 +6,8 @@ import { createApi, DEFAULT_API, errorMessage } from '../lib/api.js';
|
|
|
6
6
|
import { loadConfig } from '../lib/config.js';
|
|
7
7
|
import { createUI } from '../lib/ui.js';
|
|
8
8
|
import { Session } from '../lib/session.js';
|
|
9
|
-
import { renderError } from '../lib/render.js';
|
|
9
|
+
import { renderError, renderInfo } from '../lib/render.js';
|
|
10
|
+
import { createNotifier } from '../lib/notify.js';
|
|
10
11
|
|
|
11
12
|
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
12
13
|
|
|
@@ -19,20 +20,24 @@ usage: npx stdout-chat [options]
|
|
|
19
20
|
-n <count> how many lines to load (default 30, max 100)
|
|
20
21
|
--api <url> API base (default ${DEFAULT_API}; env STDOUT_CHAT_API)
|
|
21
22
|
--no-color plain output (NO_COLOR is honoured too)
|
|
23
|
+
--no-notify no desktop banners (env STDOUT_CHAT_NO_NOTIFY=1 works too)
|
|
22
24
|
-h, --help this text
|
|
23
25
|
-v, --version print the version
|
|
24
26
|
|
|
25
27
|
at the prompt:
|
|
26
|
-
/help /r <id> text /top /who /key sc_… /key off /clear /quit
|
|
28
|
+
/help /r <id> text /dm <nick|sid> /top /who /key sc_… /key off /notify /clear /quit
|
|
27
29
|
anything else is posted to #void
|
|
28
30
|
|
|
31
|
+
desktop banners (macOS / Linux) when someone replies to you or writes @you —
|
|
32
|
+
only while the prompt or --tail is running · /notify mentions|all|off
|
|
33
|
+
|
|
29
34
|
key file: ~/.config/stdout-chat/config.json (get a key in the app: /key)
|
|
30
35
|
no telemetry · https://stdout.chat/void`;
|
|
31
36
|
|
|
32
37
|
class UsageError extends Error {}
|
|
33
38
|
|
|
34
39
|
function parseArgs(argv) {
|
|
35
|
-
const o = { read: false, tail: false, api: null, color: true, help: false, version: false, n: 30 };
|
|
40
|
+
const o = { read: false, tail: false, api: null, color: true, notify: null, help: false, version: false, n: 30 };
|
|
36
41
|
for (let i = 0; i < argv.length; i++) {
|
|
37
42
|
const a = argv[i];
|
|
38
43
|
const eq = a.startsWith('--') ? a.indexOf('=') : -1;
|
|
@@ -45,6 +50,8 @@ function parseArgs(argv) {
|
|
|
45
50
|
case '--api': o.api = next(); if (!o.api) throw new UsageError('--api needs a url'); break;
|
|
46
51
|
case '--no-color': o.color = false; break;
|
|
47
52
|
case '--color': o.color = true; break;
|
|
53
|
+
case '--no-notify': o.notify = false; break;
|
|
54
|
+
case '--notify': o.notify = true; break;
|
|
48
55
|
case '-h': case '--help': o.help = true; break;
|
|
49
56
|
case '-v': case '--version': o.version = true; break;
|
|
50
57
|
case '-n': case '--lines': {
|
|
@@ -68,6 +75,15 @@ function wantColor(opts) {
|
|
|
68
75
|
return Boolean(process.stdout.isTTY);
|
|
69
76
|
}
|
|
70
77
|
|
|
78
|
+
// flag > env > config > default. Returns the level ('mentions' | 'all' | 'off').
|
|
79
|
+
function wantNotify(opts, cfg) {
|
|
80
|
+
if (opts.notify === false) return 'off';
|
|
81
|
+
if (opts.notify === true) return cfg.notify && cfg.notify !== 'off' ? cfg.notify : 'mentions';
|
|
82
|
+
const env = process.env.STDOUT_CHAT_NO_NOTIFY;
|
|
83
|
+
if (env != null && env !== '' && env !== '0') return 'off';
|
|
84
|
+
return cfg.notify || 'mentions';
|
|
85
|
+
}
|
|
86
|
+
|
|
71
87
|
async function main() {
|
|
72
88
|
let opts;
|
|
73
89
|
try {
|
|
@@ -103,8 +119,18 @@ async function main() {
|
|
|
103
119
|
persistApi: api.base !== DEFAULT_API ? api.base : null, // keep a non-default api next to the key
|
|
104
120
|
n: opts.n,
|
|
105
121
|
onQuit: () => shutdown(0),
|
|
122
|
+
notifyLevel: wantNotify(opts, cfg),
|
|
106
123
|
});
|
|
107
124
|
session.onClear = () => ui.clear();
|
|
125
|
+
// Banners only where a human is watching a live feed: never in --read, never
|
|
126
|
+
// when stdout is a pipe (follow mode). The level gates the hook at runtime, so
|
|
127
|
+
// `/notify all` after `--no-notify` works without a restart; nothing spawns
|
|
128
|
+
// until a line actually qualifies.
|
|
129
|
+
if (mode === 'interactive' || mode === 'tail') {
|
|
130
|
+
session.notify = createNotifier({
|
|
131
|
+
onUnavailable: () => ui.print(renderInfo('· notifications unavailable here', { color })),
|
|
132
|
+
}).notify;
|
|
133
|
+
}
|
|
108
134
|
|
|
109
135
|
function shutdown(code = 0) {
|
|
110
136
|
if (closing) return;
|
|
@@ -130,6 +156,9 @@ async function main() {
|
|
|
130
156
|
process.on('SIGHUP', () => shutdown(0));
|
|
131
157
|
if (mode !== 'interactive') process.on('SIGINT', () => shutdown(0));
|
|
132
158
|
|
|
159
|
+
if (mode === 'tail' && session.key && session.notify) {
|
|
160
|
+
await session.verifyKey({ quiet: true }); // only to learn the nick for reply / @mention matching
|
|
161
|
+
}
|
|
133
162
|
if (mode !== 'tail') {
|
|
134
163
|
await session.verifyKey();
|
|
135
164
|
try {
|
package/lib/api.js
CHANGED
|
@@ -90,6 +90,20 @@ export function createApi({ base = DEFAULT_API, version = '0.0.0', fetchImpl = n
|
|
|
90
90
|
});
|
|
91
91
|
},
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* POST /void/dm {sid} | {nick} → 201 { id, to, expires_at }. Same shape as
|
|
95
|
+
* post(): JSON in, JSON out (Accept: application/json); the session builds
|
|
96
|
+
* the human line from `to`. Errors carry the server's message verbatim.
|
|
97
|
+
*/
|
|
98
|
+
dm({ nick, sid } = {}, key) {
|
|
99
|
+
const body = sid != null && sid !== '' ? { sid: String(sid) } : { nick: String(nick == null ? '' : nick) };
|
|
100
|
+
return json(`${root}/void/dm`, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: headers({ 'Content-Type': 'application/json' }, key),
|
|
103
|
+
body: JSON.stringify(body),
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
|
|
93
107
|
/** GET /void/stream → Response (200, body is a ReadableStream). Throws ApiError on non-2xx. */
|
|
94
108
|
async openStream({ lastEventId = null, signal = null } = {}) {
|
|
95
109
|
const extra = { 'Cache-Control': 'no-cache' };
|
package/lib/config.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// Key storage: ~/.config/stdout-chat/config.json { "key": "sc_…", "api": "https://…" }
|
|
1
|
+
// Key storage: ~/.config/stdout-chat/config.json { "key": "sc_…", "api": "https://…", "notify": "mentions" }
|
|
2
2
|
// plus a plain-text twin ~/.config/stdout-chat/key so shell one-liners can
|
|
3
3
|
// `$(cat ~/.config/stdout-chat/key)`. Directory 0700, files 0600.
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
+
import { NOTIFY_LEVELS } from './notify.js';
|
|
7
8
|
|
|
8
9
|
export function configDir() {
|
|
9
10
|
if (process.env.STDOUT_CHAT_CONFIG_DIR) return process.env.STDOUT_CHAT_CONFIG_DIR;
|
|
@@ -27,6 +28,7 @@ export function loadConfig() {
|
|
|
27
28
|
const out = {};
|
|
28
29
|
if (typeof parsed.key === 'string' && parsed.key) out.key = parsed.key;
|
|
29
30
|
if (typeof parsed.api === 'string' && parsed.api) out.api = parsed.api;
|
|
31
|
+
if (NOTIFY_LEVELS.includes(parsed.notify)) out.notify = parsed.notify;
|
|
30
32
|
return out;
|
|
31
33
|
} catch {
|
|
32
34
|
return {};
|
|
@@ -44,14 +46,15 @@ function rmQuiet(file) {
|
|
|
44
46
|
try { fs.unlinkSync(file); } catch { /* absent */ }
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
/** Writes config.json (+ plain `key` file). `api`
|
|
48
|
-
export function saveConfig({ key, api } = {}) {
|
|
49
|
+
/** Writes config.json (+ plain `key` file). `api` / `notify` are omitted when falsy or unknown. */
|
|
50
|
+
export function saveConfig({ key, api, notify } = {}) {
|
|
49
51
|
const dir = configDir();
|
|
50
52
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
51
53
|
try { fs.chmodSync(dir, 0o700); } catch { /* win32 */ }
|
|
52
54
|
const data = {};
|
|
53
55
|
if (key) data.key = key;
|
|
54
56
|
if (api) data.api = api;
|
|
57
|
+
if (NOTIFY_LEVELS.includes(notify)) data.notify = notify;
|
|
55
58
|
writePrivate(configPath(), `${JSON.stringify(data, null, 2)}\n`);
|
|
56
59
|
if (key) writePrivate(keyPath(), `${key}\n`);
|
|
57
60
|
else rmQuiet(keyPath());
|
package/lib/notify.js
ADDED
|
Binary file
|
package/lib/render.js
CHANGED
|
@@ -161,6 +161,22 @@ export function displayNick(msg, collisions) {
|
|
|
161
161
|
return nick;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Is this line aimed at me? A reply to my nick → 'reply'; `@nick` in the text
|
|
166
|
+
* (word-bounded, case-insensitive, `a@nick` does not count) → 'mention'; else null.
|
|
167
|
+
*/
|
|
168
|
+
export function isAddressedTo(msg, myNick) {
|
|
169
|
+
if (!msg || typeof msg !== 'object' || myNick == null || myNick === '') return null;
|
|
170
|
+
const me = String(myNick);
|
|
171
|
+
const reply = msg.reply && typeof msg.reply === 'object' ? msg.reply : null;
|
|
172
|
+
if (reply && reply.username != null && String(reply.username).toLowerCase() === me.toLowerCase()) return 'reply';
|
|
173
|
+
const text = msg.text == null ? '' : String(msg.text);
|
|
174
|
+
if (!text) return null;
|
|
175
|
+
const escaped = me.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
176
|
+
const re = new RegExp(`(^|[^\\w@])@${escaped}(?![\\w])`, 'i');
|
|
177
|
+
return re.test(text) ? 'mention' : null;
|
|
178
|
+
}
|
|
179
|
+
|
|
164
180
|
/** `[["❤️",2],["😂",1]]` or `{"❤️":2}` → `❤️ 2 😂 1`. Unknown shapes → ''. */
|
|
165
181
|
export function formatReactions(reactions) {
|
|
166
182
|
if (!reactions) return '';
|
package/lib/session.js
CHANGED
|
@@ -4,9 +4,10 @@ import { errorMessage } from './api.js';
|
|
|
4
4
|
import { readEvents } from './sse.js';
|
|
5
5
|
import {
|
|
6
6
|
renderLine, renderReactions, renderHide, renderTopic, renderHeader, renderTop,
|
|
7
|
-
renderError, renderInfo, MEDALS,
|
|
7
|
+
renderError, renderInfo, isAddressedTo, MEDALS,
|
|
8
8
|
} from './render.js';
|
|
9
9
|
import * as defaultConfig from './config.js';
|
|
10
|
+
import { NOTIFY_LEVELS } from './notify.js';
|
|
10
11
|
|
|
11
12
|
export const HINT_NO_KEY = 'type /key sc_… to post · get it in the app: /key';
|
|
12
13
|
export const HELP_TEXT = [
|
|
@@ -14,8 +15,10 @@ export const HELP_TEXT = [
|
|
|
14
15
|
'/r <id> text reply to a line (ids are the dim column on the left)',
|
|
15
16
|
'/top this week\'s top authors',
|
|
16
17
|
'/who how many are in the room',
|
|
18
|
+
'/dm <nick|sid> invite them to a private chat (accept happens in the app)',
|
|
17
19
|
'/key sc_… save your key (get it in the app: /key)',
|
|
18
20
|
'/key off forget the key on this machine (revoke it in the app)',
|
|
21
|
+
'/notify desktop banners: mentions (default) · all · off',
|
|
19
22
|
'/clear clear the screen',
|
|
20
23
|
'/quit leave (Ctrl-C / Ctrl-D work too)',
|
|
21
24
|
'anything else is posted to #void · @nick mentions pass through as typed',
|
|
@@ -56,6 +59,8 @@ export class Session {
|
|
|
56
59
|
onQuit = () => {},
|
|
57
60
|
onKeyChange = () => {},
|
|
58
61
|
timers = { setTimeout, clearTimeout },
|
|
62
|
+
notify = null,
|
|
63
|
+
notifyLevel = 'mentions',
|
|
59
64
|
}) {
|
|
60
65
|
this.api = api;
|
|
61
66
|
this.print = print;
|
|
@@ -68,6 +73,10 @@ export class Session {
|
|
|
68
73
|
this.onQuit = onQuit;
|
|
69
74
|
this.onKeyChange = onKeyChange;
|
|
70
75
|
this.timers = timers;
|
|
76
|
+
// Desktop banner hook: (title, subtitle, body) → void. Null in read/follow
|
|
77
|
+
// modes and when the user opted out, so the session never spawns anything.
|
|
78
|
+
this.notify = typeof notify === 'function' ? notify : null;
|
|
79
|
+
this.notifyLevel = NOTIFY_LEVELS.includes(notifyLevel) ? notifyLevel : 'mentions';
|
|
71
80
|
|
|
72
81
|
this.me = null;
|
|
73
82
|
this.topic = null;
|
|
@@ -94,13 +103,17 @@ export class Session {
|
|
|
94
103
|
|
|
95
104
|
// ── startup ─────────────────────────────────────────────────────────────
|
|
96
105
|
|
|
97
|
-
/**
|
|
98
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Verifies the stored key via /void/me. Never throws; leaves the key in place on failure.
|
|
108
|
+
* `quiet` swallows the warnings — --tail only needs the nick for mention matching.
|
|
109
|
+
*/
|
|
110
|
+
async verifyKey({ quiet = false } = {}) {
|
|
99
111
|
if (!this.key) return null;
|
|
100
112
|
try {
|
|
101
113
|
this.me = await this.api.getMe(this.key);
|
|
102
114
|
return this.me;
|
|
103
115
|
} catch (err) {
|
|
116
|
+
if (quiet) return null;
|
|
104
117
|
if (err && err.status === 401) this.error(`${errorMessage(err)} · /key sc_… to replace it`);
|
|
105
118
|
else this.info(`· couldn't verify key: ${errorMessage(err)}`);
|
|
106
119
|
return null;
|
|
@@ -178,7 +191,7 @@ export class Session {
|
|
|
178
191
|
case 'msg':
|
|
179
192
|
case 'message': {
|
|
180
193
|
if (data.id == null && ev.id) data.id = ev.id;
|
|
181
|
-
this.printMsg(data);
|
|
194
|
+
if (this.printMsg(data)) this.maybeNotify(data); // live lines only: history goes through loadHistory
|
|
182
195
|
break;
|
|
183
196
|
}
|
|
184
197
|
case 'react': {
|
|
@@ -210,6 +223,18 @@ export class Session {
|
|
|
210
223
|
return undefined;
|
|
211
224
|
}
|
|
212
225
|
|
|
226
|
+
/** Desktop banner for a freshly printed live line, per `notifyLevel`. Own lines never notify. */
|
|
227
|
+
maybeNotify(msg) {
|
|
228
|
+
if (!this.notify || this.notifyLevel === 'off') return;
|
|
229
|
+
const myNick = this.me ? this.me.username : null;
|
|
230
|
+
const nick = msg.username == null || msg.username === '' ? 'anon' : String(msg.username);
|
|
231
|
+
if (myNick != null && msg.username === myNick) return;
|
|
232
|
+
const how = isAddressedTo(msg, myNick);
|
|
233
|
+
if (!how && this.notifyLevel !== 'all') return;
|
|
234
|
+
const subtitle = how === 'reply' ? `${nick} → you` : how === 'mention' ? `${nick} @you` : nick;
|
|
235
|
+
this.notify('#void', subtitle, msg.text == null ? '' : String(msg.text));
|
|
236
|
+
}
|
|
237
|
+
|
|
213
238
|
// ── stream ──────────────────────────────────────────────────────────────
|
|
214
239
|
|
|
215
240
|
/** Runs until `signal` aborts. Backoff 1,2,4…30 s; `bye` → immediate quiet reconnect. */
|
|
@@ -285,9 +310,15 @@ export class Session {
|
|
|
285
310
|
case '/who':
|
|
286
311
|
await this.cmdWho();
|
|
287
312
|
return;
|
|
313
|
+
case '/dm':
|
|
314
|
+
await this.cmdDm(rest);
|
|
315
|
+
return;
|
|
288
316
|
case '/key':
|
|
289
317
|
await this.cmdKey(rest);
|
|
290
318
|
return;
|
|
319
|
+
case '/notify':
|
|
320
|
+
this.cmdNotify(rest);
|
|
321
|
+
return;
|
|
291
322
|
case '/r': case '/reply': {
|
|
292
323
|
const m = rest.match(/^(\S+)\s+([\s\S]+)$/);
|
|
293
324
|
if (!m) { this.info('usage: /r <id> text'); return; }
|
|
@@ -325,6 +356,28 @@ export class Session {
|
|
|
325
356
|
this.info(this.count === 1 ? '1 in room' : `${this.count} in room`);
|
|
326
357
|
}
|
|
327
358
|
|
|
359
|
+
/** `/dm <nick|sid>`: a sid seen this session targets that line's author, anything else is a nick. */
|
|
360
|
+
async cmdDm(arg) {
|
|
361
|
+
if (!this.key) { this.info(HINT_NO_KEY); return; }
|
|
362
|
+
if (!arg) { this.info('usage: /dm <nick|sid>'); return; }
|
|
363
|
+
const target = this.isKnownSid(arg) ? { sid: arg } : { nick: arg };
|
|
364
|
+
let res;
|
|
365
|
+
try {
|
|
366
|
+
res = await this.api.dm(target, this.key);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
this.error(errorMessage(err));
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const to = res && res.to != null && res.to !== '' ? String(res.to) : arg;
|
|
372
|
+
this.info(`invite sent · ${to} has 10 min · you'll get a push when they accept`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** True when `s` is the short id (the dim left column) of a line seen this session. */
|
|
376
|
+
isKnownSid(s) {
|
|
377
|
+
for (const m of this.lines.values()) if (m && m.sid != null && String(m.sid) === s) return true;
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
|
|
328
381
|
async cmdKey(arg) {
|
|
329
382
|
if (!arg) {
|
|
330
383
|
if (this.me) this.info(greeting(this.me));
|
|
@@ -354,11 +407,33 @@ export class Session {
|
|
|
354
407
|
this.key = arg;
|
|
355
408
|
this.me = me;
|
|
356
409
|
try {
|
|
357
|
-
this.config.saveConfig({ key: arg, api: this.persistApi || undefined });
|
|
410
|
+
this.config.saveConfig({ key: arg, api: this.persistApi || undefined, notify: this.notifyLevel });
|
|
358
411
|
} catch (err) {
|
|
359
412
|
this.error(`saved in memory only · ${errorMessage(err)}`);
|
|
360
413
|
}
|
|
361
414
|
this.onKeyChange(arg);
|
|
362
415
|
this.info(greeting(me) || 'key saved');
|
|
363
416
|
}
|
|
417
|
+
|
|
418
|
+
cmdNotify(arg) {
|
|
419
|
+
if (!arg) {
|
|
420
|
+
this.info(`notifications: ${this.notifyLevel}`);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const level = arg.toLowerCase();
|
|
424
|
+
if (!NOTIFY_LEVELS.includes(level)) {
|
|
425
|
+
this.info('usage: /notify mentions · /notify all · /notify off');
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
this.notifyLevel = level;
|
|
429
|
+
try {
|
|
430
|
+
// The key stays as it is; only the level changes. No key → no file is written
|
|
431
|
+
// for the key twin, but the level still persists in config.json.
|
|
432
|
+
this.config.saveConfig({ key: this.key || undefined, api: this.persistApi || undefined, notify: level });
|
|
433
|
+
} catch (err) {
|
|
434
|
+
this.error(`set for this session only · ${errorMessage(err)}`);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
this.info(`notifications: ${level}`);
|
|
438
|
+
}
|
|
364
439
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stdout-chat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "#void from your terminal — read, tail and post to stdout.chat's public room. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"bin",
|
|
11
11
|
"lib",
|
|
12
12
|
"README.md",
|
|
13
|
+
"CHANGELOG.md",
|
|
13
14
|
"LICENSE"
|
|
14
15
|
],
|
|
15
16
|
"engines": {
|