thelounge-plugin-ntfy 1.0.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/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # The Lounge ntfy Plugin
2
+
3
+ A plugin for [The Lounge](https://thelounge.chat/) that sends a message to an [ntfy](https://ntfy.sh/) server whenever you are mentioned in a chat.
4
+
5
+ ## Installation
6
+
7
+ Via the `thelounge` command line:
8
+
9
+ ```bash
10
+ thelounge install thelounge-plugin-ntfy
11
+ ```
12
+
13
+ Restart The Lounge after installation
14
+
15
+ ## Usage
16
+
17
+ This plugin introduces the `/ntfy` command, subcommands are:
18
+
19
+ - `/ntfy start`: Start the ntfy listener for the network
20
+ - `/ntfy stop`: Stop the ntfy listener for the network
21
+ - `/ntfy config`: Config commands
22
+ - `/ntfy config set <setting_key> <setting_value>`: Set a configuration setting
23
+ - `/ntfy config remove <setting_key>`: Set a configuration setting to null
24
+ - `/ntfy config print`: Print the current configuration with warnings if any
25
+
26
+ ## Setup
27
+
28
+ This plugin will **not** work out of the box, by default the plugin sends notifications to the official `ntfy.sh` server but no topic is set. To set a topic, enter this command:
29
+
30
+ ```
31
+ /ntfy config set ntfy.topic <topic>
32
+ ```
33
+
34
+ You may also set your account credentials or token as well if needed, see the config print command for all the possible settings.
35
+
36
+ To start/stop sending push notifications in the desired network, enter:
37
+
38
+ ```
39
+ /ntfy start/stop
40
+ ```
41
+
42
+ ## License
43
+
44
+ This plugin is licensed under [MIT](https://opensource.org/license/mit)
@@ -0,0 +1,10 @@
1
+ services:
2
+ thelounge:
3
+ image: ghcr.io/thelounge/thelounge:latest
4
+ container_name: thelounge
5
+ network_mode: bridge
6
+ ports:
7
+ - 9000:9000
8
+ restart: unless-stopped
9
+ volumes:
10
+ - $PWD/tl:/var/opt/thelounge
package/index.js ADDED
@@ -0,0 +1,230 @@
1
+ "use strict";
2
+
3
+ const { PluginLogger } = require("./src/logger.js");
4
+ const { createHandler } = require("./src/handler.js");
5
+ const {
6
+ setRootDir,
7
+ loadUserConfig,
8
+ saveUserSetting,
9
+ } = require("./src/config.js");
10
+
11
+ // user -> Map<network.uuid -> handler and client>
12
+ const globalActiveListeners = new Map();
13
+
14
+ const ntfyCommand = {
15
+ input: (client, target, command, args) => {
16
+ const say = (message) => {
17
+ client.sendMessage(message, target.chan);
18
+ };
19
+
20
+ const helpMessage = () => {
21
+ say(`${command} command help:`);
22
+ say(`/${command} start - Start the ntfy listener for this network`);
23
+ say(`/${command} stop - Stop the ntfy listener for this network`);
24
+ say(
25
+ `/${command} config set <setting_key> <setting_value> - Set a configuration setting`
26
+ );
27
+ say(
28
+ `/${command} config remove <setting_key> - Set configuration setting to null`
29
+ );
30
+ say(
31
+ `/${command} config print - Print the current configuration with warnings if any`
32
+ );
33
+ };
34
+
35
+ const subcommand = args;
36
+ const network = target.network;
37
+
38
+ if (subcommand.length === 0) {
39
+ helpMessage();
40
+ return;
41
+ }
42
+
43
+ if (typeof subcommand[0] !== "string") {
44
+ helpMessage();
45
+ return;
46
+ }
47
+
48
+ switch (subcommand[0].toLowerCase()) {
49
+ case "start": {
50
+ const [_, errors] = loadUserConfig(client.client.name);
51
+
52
+ if (errors.length > 0) {
53
+ say("Cannot start ntfy listener due to invalid configuration:");
54
+ for (const error of errors) {
55
+ say(`- ${error.instancePath} ${error.message}`);
56
+ }
57
+ return;
58
+ }
59
+
60
+ const userListeners = globalActiveListeners.get(client.client.name);
61
+
62
+ if (
63
+ userListeners &&
64
+ typeof userListeners.has === "function" &&
65
+ userListeners.has(network.uuid)
66
+ ) {
67
+ say("ntfy listener is already running for this network");
68
+ return;
69
+ }
70
+
71
+ const handler = createHandler(client, network);
72
+ network.irc.on("privmsg", handler);
73
+
74
+ if (!userListeners) {
75
+ const map = new Map();
76
+ map.set(network.uuid, { handler: handler, client: client });
77
+ globalActiveListeners.set(client.client.name, map);
78
+ } else {
79
+ userListeners.set(network.uuid, { handler: handler, client: client });
80
+ }
81
+
82
+ say("ntfy listener started for this network");
83
+
84
+ break;
85
+ }
86
+
87
+ case "stop": {
88
+ const userListeners = globalActiveListeners.get(client.client.name);
89
+
90
+ if (
91
+ !userListeners ||
92
+ typeof userListeners.has !== "function" ||
93
+ !userListeners.has(network.uuid)
94
+ ) {
95
+ say("ntfy listener is not running for this network");
96
+ return;
97
+ }
98
+
99
+ const { handler } = userListeners.get(network.uuid);
100
+ network.irc.removeListener("privmsg", handler);
101
+ userListeners.delete(network.uuid);
102
+
103
+ say("ntfy listener stopped for this network");
104
+
105
+ break;
106
+ }
107
+
108
+ case "config": {
109
+ const subsubcommand = subcommand.slice(1);
110
+
111
+ if (subsubcommand.length === 0) {
112
+ helpMessage();
113
+ return;
114
+ }
115
+
116
+ if (typeof subsubcommand[0] !== "string") {
117
+ helpMessage();
118
+ return;
119
+ }
120
+
121
+ switch (subsubcommand[0].toLowerCase()) {
122
+ case "set": {
123
+ const subsubargs = subsubcommand.slice(1);
124
+
125
+ if (subsubargs.length < 2) {
126
+ helpMessage();
127
+ return;
128
+ }
129
+
130
+ const settingKey = subsubargs[0];
131
+ const settingValue = subsubargs.slice(1).join(" ");
132
+ const response = saveUserSetting(
133
+ client.client.name,
134
+ settingKey,
135
+ settingValue
136
+ );
137
+
138
+ say(response);
139
+
140
+ break;
141
+ }
142
+
143
+ case "remove": {
144
+ const subsubargs = subsubcommand.slice(1);
145
+
146
+ if (subsubargs.length < 1) {
147
+ helpMessage();
148
+ return;
149
+ }
150
+
151
+ const settingKey = subsubargs[0];
152
+ const response = saveUserSetting(
153
+ client.client.name,
154
+ settingKey,
155
+ null
156
+ );
157
+
158
+ say(response);
159
+
160
+ break;
161
+ }
162
+
163
+ case "print": {
164
+ const [userConfig, errors] = loadUserConfig(client.client.name);
165
+
166
+ const printConfig = (obj, parentKey = "") => {
167
+ for (const key in obj) {
168
+ const value = obj[key];
169
+ const fullKey = parentKey ? `${parentKey}.${key}` : key;
170
+
171
+ if (typeof value === "object" && value !== null) {
172
+ printConfig(value, fullKey);
173
+ } else {
174
+ say(`${fullKey}=${value}`);
175
+ }
176
+ }
177
+ };
178
+
179
+ printConfig(userConfig);
180
+
181
+ if (errors.length > 0) {
182
+ say("");
183
+
184
+ for (const error of errors) {
185
+ say(`Warning: ${error.instancePath} ${error.message}`);
186
+ }
187
+ }
188
+
189
+ if (
190
+ userConfig.ntfy.token &&
191
+ userConfig.ntfy.username &&
192
+ userConfig.ntfy.password
193
+ ) {
194
+ say(
195
+ "Warning: Both ntfy.token and ntfy.username/password are set, ntfy.token will be used for authentication"
196
+ );
197
+ }
198
+
199
+ break;
200
+ }
201
+
202
+ default: {
203
+ helpMessage();
204
+ break;
205
+ }
206
+ }
207
+
208
+ break;
209
+ }
210
+
211
+ default: {
212
+ helpMessage();
213
+ break;
214
+ }
215
+ }
216
+ },
217
+ allowDisconnected: true,
218
+ };
219
+
220
+ module.exports = {
221
+ onServerStart(tl) {
222
+ PluginLogger.init(tl.Logger);
223
+
224
+ const configDir = tl.Config.getPersistentStorageDir();
225
+ setRootDir(configDir);
226
+ PluginLogger.info(`[ntfy] Using config directory: ${configDir}`);
227
+
228
+ tl.Commands.add("ntfy", ntfyCommand);
229
+ },
230
+ };
package/misc/api.txt ADDED
@@ -0,0 +1,53 @@
1
+ https://thelounge.chat/docs/api
2
+
3
+ api
4
+ Commands
5
+ add(commandText: string, command: Command)
6
+ Config
7
+ getConfig()
8
+ getPersistentStorageDir()
9
+ Logger
10
+ error(...args)
11
+ warn(...args)
12
+ info(...args)
13
+ debug(...args)
14
+ Stylesheets
15
+ addFile(filename: String)
16
+
17
+ command
18
+ input(client: Client, target: Target, command: String, args: String[])
19
+ allowDisconnected: bool
20
+
21
+ target
22
+ network: String
23
+ chan: String
24
+
25
+ client
26
+ runAsUser(command: String, targetId: String)
27
+ createChannel(attributes: Attributes)
28
+ sendToBrowser(event: String, data: Object)
29
+ getChannel(channelId: Number)
30
+ sendMessage(text: String, chan: Chan)
31
+
32
+ attributes
33
+ id: Number
34
+ messages: Msg[]
35
+ name: string
36
+ key: String
37
+ topic: String
38
+ type: Chan.Type
39
+ state: Chan.State
40
+ firstUnread: Number
41
+ unread: Number
42
+ highlight: Number
43
+ users: String -> User Map
44
+
45
+ Chan.Type
46
+ CHANNEL
47
+ LOBBY
48
+ QUERY
49
+ SPECIAL
50
+
51
+ Chan.State
52
+ PARTED
53
+ JOINED
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "thelounge-plugin-ntfy",
3
+ "version": "1.0.0",
4
+ "description": "A plugin for The Lounge that sends push notifications via ntfy when highlighted",
5
+ "keywords": [
6
+ "thelounge",
7
+ "thelounge-plugin",
8
+ "ntfy",
9
+ "irc"
10
+ ],
11
+ "thelounge": {
12
+ "name": "ntfy",
13
+ "type": "plugin",
14
+ "supports": ">=4.0.0"
15
+ },
16
+ "homepage": "https://github.com/cy1der/thelounge-plugin-ntfy#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/cy1der/thelounge-plugin-ntfy/issues"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/cy1der/thelounge-plugin-ntfy.git"
23
+ },
24
+ "license": "MIT",
25
+ "author": "cy1der",
26
+ "type": "commonjs",
27
+ "main": "index.js",
28
+ "dependencies": {
29
+ "ajv": "^8.17.1",
30
+ "ajv-errors": "^3.0.0",
31
+ "ajv-formats": "^3.0.1",
32
+ "ntfy": "^1.11.8",
33
+ "thelounge": "^4.4.3"
34
+ }
35
+ }
package/src/config.js ADDED
@@ -0,0 +1,181 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const Ajv2019 = require("ajv/dist/2019").default;
6
+ const addFormats = require("ajv-formats");
7
+ const addErrors = require("ajv-errors");
8
+
9
+ const DEFAULT_CONFIG = {
10
+ ntfy: {
11
+ server: "https://ntfy.sh",
12
+ topic: null, // Intentionally left null to force user configuration
13
+ username: null,
14
+ password: null,
15
+ token: null,
16
+ },
17
+ };
18
+
19
+ const ALLOWED_KEYS = new Set([
20
+ "ntfy.server",
21
+ "ntfy.topic",
22
+ "ntfy.username",
23
+ "ntfy.password",
24
+ "ntfy.token",
25
+ ]);
26
+
27
+ const userConfigSchema = {
28
+ type: "object",
29
+ additionalProperties: false,
30
+ required: ["ntfy"],
31
+ properties: {
32
+ ntfy: {
33
+ type: "object",
34
+ additionalProperties: false,
35
+ required: ["server", "topic"],
36
+ properties: {
37
+ server: {
38
+ type: "string",
39
+ format: "uri",
40
+ errorMessage: "Invalid server URL",
41
+ },
42
+ topic: {
43
+ type: "string",
44
+ minLength: 1,
45
+ errorMessage: "Topic cannot be empty",
46
+ },
47
+ username: { type: "string", nullable: true },
48
+ password: { type: "string", nullable: true },
49
+ token: {
50
+ type: "string",
51
+ format: "ntfy-token",
52
+ nullable: true,
53
+ errorMessage: "Invalid ntfy token, must start with 'tk_'",
54
+ },
55
+ },
56
+ allOf: [
57
+ {
58
+ if: {
59
+ properties: { username: { type: "string" } },
60
+ required: ["username"],
61
+ },
62
+ then: {
63
+ properties: { password: { type: "string" } },
64
+ required: ["password"],
65
+ errorMessage: {
66
+ properties: {
67
+ password: "Password is required when username is set",
68
+ },
69
+ },
70
+ },
71
+ },
72
+ {
73
+ if: {
74
+ properties: { password: { type: "string" } },
75
+ required: ["password"],
76
+ },
77
+ then: {
78
+ properties: { username: { type: "string" } },
79
+ required: ["username"],
80
+ errorMessage: {
81
+ properties: {
82
+ username: "Username is required when password is set",
83
+ },
84
+ },
85
+ },
86
+ },
87
+ ],
88
+ dependentRequired: {
89
+ username: ["password"],
90
+ password: ["username"],
91
+ },
92
+ },
93
+ },
94
+ };
95
+
96
+ const ajv = new Ajv2019({ allErrors: true });
97
+ addFormats(ajv);
98
+ addErrors(ajv);
99
+ ajv.addFormat("ntfy-token", {
100
+ validate: (data) => typeof data === "string" && data.startsWith("tk_"),
101
+ });
102
+
103
+ let rootDir = null;
104
+
105
+ function setRootDir(dir) {
106
+ rootDir = dir;
107
+ }
108
+
109
+ function loadUserConfig(username) {
110
+ if (!rootDir) {
111
+ throw new Error("Root directory is not set");
112
+ }
113
+
114
+ const userConfigPath = path.join(rootDir, "config", `${username}.json`);
115
+
116
+ if (!fs.existsSync(userConfigPath)) {
117
+ const validate = ajv.compile(userConfigSchema);
118
+ const valid = validate(DEFAULT_CONFIG);
119
+
120
+ return [DEFAULT_CONFIG, valid ? [] : validate.errors];
121
+ } else {
122
+ let userConfig;
123
+
124
+ try {
125
+ userConfig = JSON.parse(fs.readFileSync(userConfigPath, "utf-8"));
126
+ } catch (e) {
127
+ throw new Error(
128
+ `Invalid JSON in user config for ${username}: ${e.message}`
129
+ );
130
+ }
131
+
132
+ const validate = ajv.compile(userConfigSchema);
133
+ const valid = validate(userConfig);
134
+
135
+ return [userConfig, valid ? [] : validate.errors];
136
+ }
137
+ }
138
+
139
+ function saveUserSetting(username, settingKey, settingValue) {
140
+ if (!rootDir) {
141
+ throw new Error("Root directory is not set");
142
+ }
143
+
144
+ if (ALLOWED_KEYS.has(settingKey)) {
145
+ let userConfig = loadUserConfig(username)[0];
146
+
147
+ const keys = settingKey.split(".");
148
+
149
+ let curr = userConfig;
150
+
151
+ for (let i = 0; i < keys.length - 1; i++) {
152
+ const key = keys[i];
153
+ if (key in curr) {
154
+ curr = curr[key];
155
+ }
156
+ }
157
+
158
+ curr[keys[keys.length - 1]] = settingValue;
159
+
160
+ const userConfigPath = path.join(rootDir, "config", `${username}.json`);
161
+
162
+ fs.mkdirSync(path.dirname(userConfigPath), { recursive: true });
163
+ fs.writeFileSync(
164
+ userConfigPath,
165
+ JSON.stringify(userConfig, null, 2),
166
+ "utf-8"
167
+ );
168
+
169
+ return "Success";
170
+ }
171
+
172
+ return `Invalid setting ${settingKey}, allowed settings are: ${Array.from(
173
+ ALLOWED_KEYS
174
+ ).join(", ")}`;
175
+ }
176
+
177
+ module.exports = {
178
+ setRootDir,
179
+ loadUserConfig,
180
+ saveUserSetting,
181
+ };
package/src/handler.js ADDED
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+
3
+ const { loadUserConfig } = require("./config.js");
4
+
5
+ function createHandler(client, network) {
6
+ return async (data) => {
7
+ const highlightRegex = new RegExp(network.highlightRegex, "i");
8
+ const message = data.message || "";
9
+
10
+ if (highlightRegex.test(message)) {
11
+ // Load config after each message to get latest settings
12
+ const [userConfig, errors] = loadUserConfig(client.client.name);
13
+
14
+ if (errors.length > 0) {
15
+ return;
16
+ }
17
+
18
+ let ntfyAuth;
19
+
20
+ if (userConfig.ntfy.token) {
21
+ ntfyAuth = userConfig.ntfy.token;
22
+ } else if (userConfig.ntfy.username && userConfig.ntfy.password) {
23
+ ntfyAuth = {
24
+ username: userConfig.ntfy.username,
25
+ password: userConfig.ntfy.password,
26
+ };
27
+ }
28
+
29
+ const { NtfyClient, MessagePriority } = await import("ntfy");
30
+
31
+ const ntfyClient = new NtfyClient({
32
+ server: userConfig.ntfy.server,
33
+ topic: userConfig.ntfy.topic,
34
+ priority: MessagePriority.HIGH,
35
+ tags: ["speech_balloon"],
36
+ authorization: ntfyAuth,
37
+ });
38
+
39
+ ntfyClient.publish({
40
+ title: `${network.name} ${data.target}: ${data.nick}`,
41
+ message: message,
42
+ });
43
+ }
44
+ };
45
+ }
46
+
47
+ module.exports = {
48
+ createHandler,
49
+ };
package/src/logger.js ADDED
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+
3
+ let Logger;
4
+
5
+ // Inspired by https://github.com/juanjocerero/thelounge-plugin-am/blob/main/src/logger.js
6
+ const PluginLogger = {
7
+ init: (logger) => {
8
+ Logger = logger;
9
+ },
10
+ error: (...args) => {
11
+ if (Logger) Logger.error(...args);
12
+ },
13
+ warn: (...args) => {
14
+ if (Logger) Logger.warn(...args);
15
+ },
16
+ info: (...args) => {
17
+ if (Logger) Logger.info(...args);
18
+ },
19
+ debug: (...args) => {
20
+ if (Logger) Logger.debug(...args);
21
+ },
22
+ };
23
+
24
+ module.exports = {
25
+ PluginLogger,
26
+ };