thelounge-plugin-ntfy 1.1.0 → 1.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/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # The Lounge ntfy Plugin
2
2
 
3
+ ![NPM Version](https://img.shields.io/npm/v/thelounge-plugin-ntfy?style=for-the-badge)
4
+ ![NPM Downloads](https://img.shields.io/npm/dy/thelounge-plugin-ntfy?style=for-the-badge)
5
+
3
6
  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
7
 
5
8
  ## Installation
@@ -41,6 +44,14 @@ To start/stop sending push notifications in the desired network, enter:
41
44
  /ntfy start/stop
42
45
  ```
43
46
 
47
+ ## Private Messages
48
+
49
+ By default, you will only be notified when you are mentioned, **this includes messages sent privately to you**. If you want to be notified of all private messages, enter this command and start the notifier like usual:
50
+
51
+ ```
52
+ /ntfy config set config.notify_on_private_messages true
53
+ ```
54
+
44
55
  ## License
45
56
 
46
57
  This plugin is licensed under [MIT](https://opensource.org/license/mit)
@@ -8,3 +8,10 @@ services:
8
8
  restart: unless-stopped
9
9
  volumes:
10
10
  - $PWD/tl:/var/opt/thelounge
11
+ - $PWD/src:/var/opt/thelounge/packages/thelounge-plugin-ntfy/src
12
+ - $PWD/index.js:/var/opt/thelounge/packages/thelounge-plugin-ntfy/index.js
13
+ - $PWD/package.json:/var/opt/thelounge/packages/thelounge-plugin-ntfy/package.json
14
+ - $PWD/package-lock.json:/var/opt/thelounge/packages/thelounge-plugin-ntfy/package-lock.json
15
+
16
+ # Installation: su node -c "thelounge install file:/var/opt/thelounge/packages/thelounge-plugin-ntfy"
17
+ # Removal: su node -c "thelounge uninstall thelounge-plugin-ntfy"
package/index.js CHANGED
@@ -22,17 +22,17 @@ const ntfyCommand = {
22
22
  say(`/${command} start - Start the ntfy listener for this network`);
23
23
  say(`/${command} stop - Stop the ntfy listener for this network`);
24
24
  say(
25
- `/${command} status - Show the ntfy listener status for this network`
25
+ `/${command} status - Show the ntfy listener status for this network`,
26
26
  );
27
27
  say(`/${command} test - Send a test notification`);
28
28
  say(
29
- `/${command} config set <setting_key> <setting_value> - Set a configuration setting`
29
+ `/${command} config set <setting_key> <setting_value> - Set a configuration setting`,
30
30
  );
31
31
  say(
32
- `/${command} config remove <setting_key> - Set configuration setting to null`
32
+ `/${command} config remove <setting_key> - Set configuration setting to null`,
33
33
  );
34
34
  say(
35
- `/${command} config print - Print the current configuration with warnings if any`
35
+ `/${command} config print - Print the current configuration with warnings if any`,
36
36
  );
37
37
  };
38
38
 
@@ -197,7 +197,7 @@ const ntfyCommand = {
197
197
  const response = saveUserSetting(
198
198
  client.client.name,
199
199
  settingKey,
200
- settingValue
200
+ settingValue,
201
201
  );
202
202
 
203
203
  say(response);
@@ -217,7 +217,7 @@ const ntfyCommand = {
217
217
  const response = saveUserSetting(
218
218
  client.client.name,
219
219
  settingKey,
220
- null
220
+ null,
221
221
  );
222
222
 
223
223
  say(response);
@@ -228,6 +228,8 @@ const ntfyCommand = {
228
228
  case "print": {
229
229
  const [userConfig, errors] = loadUserConfig(client.client.name);
230
230
 
231
+ const sensitiveKeys = new Set(["ntfy.password", "ntfy.token"]);
232
+
231
233
  const printConfig = (obj, parentKey = "") => {
232
234
  for (const key in obj) {
233
235
  const value = obj[key];
@@ -235,6 +237,8 @@ const ntfyCommand = {
235
237
 
236
238
  if (typeof value === "object" && value !== null) {
237
239
  printConfig(value, fullKey);
240
+ } else if (sensitiveKeys.has(fullKey) && value) {
241
+ say(`${fullKey}=********`);
238
242
  } else {
239
243
  say(`${fullKey}=${value}`);
240
244
  }
@@ -257,7 +261,7 @@ const ntfyCommand = {
257
261
  userConfig.ntfy.password
258
262
  ) {
259
263
  say(
260
- "Warning: Both ntfy.token and ntfy.username/password are set, ntfy.token will be used for authentication"
264
+ "Warning: Both ntfy.token and ntfy.username/password are set, ntfy.token will be used for authentication",
261
265
  );
262
266
  }
263
267
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thelounge-plugin-ntfy",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "A plugin for The Lounge that sends push notifications via ntfy when highlighted",
5
5
  "keywords": [
6
6
  "thelounge",
@@ -29,7 +29,7 @@
29
29
  "ajv": "^8.17.1",
30
30
  "ajv-errors": "^3.0.0",
31
31
  "ajv-formats": "^3.0.1",
32
- "ntfy": "^1.11.8",
32
+ "ntfy": "~1.13.3",
33
33
  "thelounge": "^4.4.3"
34
34
  }
35
35
  }
package/src/config.js CHANGED
@@ -5,6 +5,12 @@ const path = require("path");
5
5
  const Ajv2019 = require("ajv/dist/2019").default;
6
6
  const addFormats = require("ajv-formats");
7
7
  const addErrors = require("ajv-errors");
8
+ const {
9
+ initEncryptionKey,
10
+ encrypt,
11
+ decrypt,
12
+ isEncrypted,
13
+ } = require("./crypto.js");
8
14
 
9
15
  const DEFAULT_CONFIG = {
10
16
  ntfy: {
@@ -14,6 +20,9 @@ const DEFAULT_CONFIG = {
14
20
  password: null,
15
21
  token: null,
16
22
  },
23
+ config: {
24
+ notify_on_private_messages: false,
25
+ },
17
26
  };
18
27
 
19
28
  const ALLOWED_KEYS = new Set([
@@ -22,12 +31,17 @@ const ALLOWED_KEYS = new Set([
22
31
  "ntfy.username",
23
32
  "ntfy.password",
24
33
  "ntfy.token",
34
+ "config.notify_on_private_messages",
25
35
  ]);
26
36
 
37
+ const BOOLEAN_KEYS = new Set(["config.notify_on_private_messages"]);
38
+
39
+ const SENSITIVE_KEYS = new Set(["ntfy.password", "ntfy.token"]);
40
+
27
41
  const userConfigSchema = {
28
42
  type: "object",
29
43
  additionalProperties: false,
30
- required: ["ntfy"],
44
+ required: ["ntfy", "config"],
31
45
  properties: {
32
46
  ntfy: {
33
47
  type: "object",
@@ -90,6 +104,17 @@ const userConfigSchema = {
90
104
  password: ["username"],
91
105
  },
92
106
  },
107
+ config: {
108
+ type: "object",
109
+ additionalProperties: false,
110
+ required: ["notify_on_private_messages"],
111
+ properties: {
112
+ notify_on_private_messages: {
113
+ type: "boolean",
114
+ default: false,
115
+ },
116
+ },
117
+ },
93
118
  },
94
119
  };
95
120
 
@@ -104,6 +129,38 @@ let rootDir = null;
104
129
 
105
130
  function setRootDir(dir) {
106
131
  rootDir = dir;
132
+ initEncryptionKey(dir);
133
+ }
134
+
135
+ function decryptSensitiveFields(config) {
136
+ const decrypted = JSON.parse(JSON.stringify(config)); // Deep clone
137
+
138
+ for (const key of SENSITIVE_KEYS) {
139
+ const keys = key.split(".");
140
+ let curr = decrypted;
141
+
142
+ for (let i = 0; i < keys.length - 1; i++) {
143
+ if (curr[keys[i]]) {
144
+ curr = curr[keys[i]];
145
+ } else {
146
+ curr = null;
147
+ break;
148
+ }
149
+ }
150
+
151
+ if (curr) {
152
+ const finalKey = keys[keys.length - 1];
153
+ if (curr[finalKey] && isEncrypted(curr[finalKey])) {
154
+ try {
155
+ curr[finalKey] = decrypt(curr[finalKey]);
156
+ } catch (e) {
157
+ // Leave as-is
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ return decrypted;
107
164
  }
108
165
 
109
166
  function loadUserConfig(username) {
@@ -125,10 +182,12 @@ function loadUserConfig(username) {
125
182
  userConfig = JSON.parse(fs.readFileSync(userConfigPath, "utf-8"));
126
183
  } catch (e) {
127
184
  throw new Error(
128
- `Invalid JSON in user config for ${username}: ${e.message}`
185
+ `Invalid JSON in user config for ${username}: ${e.message}`,
129
186
  );
130
187
  }
131
188
 
189
+ userConfig = decryptSensitiveFields(userConfig);
190
+
132
191
  const validate = ajv.compile(userConfigSchema);
133
192
  const valid = validate(userConfig);
134
193
 
@@ -136,6 +195,33 @@ function loadUserConfig(username) {
136
195
  }
137
196
  }
138
197
 
198
+ function encryptSensitiveFields(config) {
199
+ const encrypted = JSON.parse(JSON.stringify(config)); // Deep clone
200
+
201
+ for (const key of SENSITIVE_KEYS) {
202
+ const keys = key.split(".");
203
+ let curr = encrypted;
204
+
205
+ for (let i = 0; i < keys.length - 1; i++) {
206
+ if (curr[keys[i]]) {
207
+ curr = curr[keys[i]];
208
+ } else {
209
+ curr = null;
210
+ break;
211
+ }
212
+ }
213
+
214
+ if (curr) {
215
+ const finalKey = keys[keys.length - 1];
216
+ if (curr[finalKey] && !isEncrypted(curr[finalKey])) {
217
+ curr[finalKey] = encrypt(curr[finalKey]);
218
+ }
219
+ }
220
+ }
221
+
222
+ return encrypted;
223
+ }
224
+
139
225
  function saveUserSetting(username, settingKey, settingValue) {
140
226
  if (!rootDir) {
141
227
  throw new Error("Root directory is not set");
@@ -155,22 +241,42 @@ function saveUserSetting(username, settingKey, settingValue) {
155
241
  }
156
242
  }
157
243
 
244
+ if (settingValue && typeof settingValue !== "string") {
245
+ return `Error: expected value to be a string`;
246
+ }
247
+
248
+ if (BOOLEAN_KEYS.has(settingKey)) {
249
+ try {
250
+ settingValue = settingValue
251
+ ? JSON.parse(settingValue.toLowerCase())
252
+ : false;
253
+
254
+ if (typeof settingValue !== "boolean") {
255
+ return `Invalid value for ${settingKey}, expected a boolean`;
256
+ }
257
+ } catch {
258
+ return `Invalid value for ${settingKey}, expected a boolean`;
259
+ }
260
+ }
261
+
158
262
  curr[keys[keys.length - 1]] = settingValue;
159
263
 
264
+ const configToSave = encryptSensitiveFields(userConfig);
265
+
160
266
  const userConfigPath = path.join(rootDir, "config", `${username}.json`);
161
267
 
162
268
  fs.mkdirSync(path.dirname(userConfigPath), { recursive: true });
163
269
  fs.writeFileSync(
164
270
  userConfigPath,
165
- JSON.stringify(userConfig, null, 2),
166
- "utf-8"
271
+ JSON.stringify(configToSave, null, 2),
272
+ "utf-8",
167
273
  );
168
274
 
169
275
  return "Success";
170
276
  }
171
277
 
172
278
  return `Invalid setting ${settingKey}, allowed settings are: ${Array.from(
173
- ALLOWED_KEYS
279
+ ALLOWED_KEYS,
174
280
  ).join(", ")}`;
175
281
  }
176
282
 
package/src/crypto.js ADDED
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+
7
+ const ALGORITHM = "aes-256-gcm";
8
+ const KEY_LENGTH = 32; // 256 bits
9
+ const IV_LENGTH = 16; // 128 bits
10
+ const AUTH_TAG_LENGTH = 16; // 128 bits
11
+ const ENCRYPTED_PREFIX = "enc:";
12
+
13
+ let encryptionKey = null;
14
+
15
+ function initEncryptionKey(configDir) {
16
+ const keyPath = path.join(configDir, ".ntfy_key");
17
+
18
+ if (fs.existsSync(keyPath)) {
19
+ const keyData = fs.readFileSync(keyPath, "utf-8").trim();
20
+ encryptionKey = Buffer.from(keyData, "hex");
21
+
22
+ if (encryptionKey.length !== KEY_LENGTH) {
23
+ throw new Error("Invalid encryption key length");
24
+ }
25
+ } else {
26
+ encryptionKey = crypto.randomBytes(KEY_LENGTH);
27
+ fs.mkdirSync(configDir, { recursive: true });
28
+ fs.writeFileSync(keyPath, encryptionKey.toString("hex"), {
29
+ encoding: "utf-8",
30
+ mode: 0o600, // R/W for owner only
31
+ });
32
+ }
33
+ }
34
+
35
+ function isInitialized() {
36
+ return encryptionKey !== null;
37
+ }
38
+
39
+ function encrypt(plaintext) {
40
+ if (!encryptionKey) {
41
+ throw new Error("Encryption key not initialized");
42
+ }
43
+
44
+ if (!plaintext || typeof plaintext !== "string") {
45
+ return plaintext;
46
+ }
47
+
48
+ if (plaintext.startsWith(ENCRYPTED_PREFIX)) {
49
+ return plaintext;
50
+ }
51
+
52
+ const iv = crypto.randomBytes(IV_LENGTH);
53
+ const cipher = crypto.createCipheriv(ALGORITHM, encryptionKey, iv, {
54
+ authTagLength: AUTH_TAG_LENGTH,
55
+ });
56
+
57
+ let encrypted = cipher.update(plaintext, "utf8", "hex");
58
+ encrypted += cipher.final("hex");
59
+
60
+ const authTag = cipher.getAuthTag();
61
+
62
+ return `${ENCRYPTED_PREFIX}${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
63
+ }
64
+
65
+ function decrypt(encryptedText) {
66
+ if (!encryptionKey) {
67
+ throw new Error("Encryption key not initialized");
68
+ }
69
+
70
+ if (!encryptedText || typeof encryptedText !== "string") {
71
+ return encryptedText;
72
+ }
73
+
74
+ if (!encryptedText.startsWith(ENCRYPTED_PREFIX)) {
75
+ return encryptedText;
76
+ }
77
+
78
+ const data = encryptedText.slice(ENCRYPTED_PREFIX.length);
79
+ const parts = data.split(":");
80
+
81
+ if (parts.length !== 3) {
82
+ throw new Error("Invalid encrypted data format");
83
+ }
84
+
85
+ const [ivHex, authTagHex, encrypted] = parts;
86
+ const iv = Buffer.from(ivHex, "hex");
87
+ const authTag = Buffer.from(authTagHex, "hex");
88
+
89
+ const decipher = crypto.createDecipheriv(ALGORITHM, encryptionKey, iv, {
90
+ authTagLength: AUTH_TAG_LENGTH,
91
+ });
92
+ decipher.setAuthTag(authTag);
93
+
94
+ let decrypted = decipher.update(encrypted, "hex", "utf8");
95
+ decrypted += decipher.final("utf8");
96
+
97
+ return decrypted;
98
+ }
99
+
100
+ function isEncrypted(value) {
101
+ return typeof value === "string" && value.startsWith(ENCRYPTED_PREFIX);
102
+ }
103
+
104
+ module.exports = {
105
+ initEncryptionKey,
106
+ isInitialized,
107
+ encrypt,
108
+ decrypt,
109
+ isEncrypted,
110
+ ENCRYPTED_PREFIX,
111
+ };
package/src/handler.js CHANGED
@@ -1,45 +1,88 @@
1
1
  "use strict";
2
2
 
3
3
  const { loadUserConfig } = require("./config.js");
4
+ const { PluginLogger } = require("./logger.js");
4
5
 
5
6
  function createHandler(client, network) {
6
7
  return async (data) => {
8
+ // Ignore own messages
9
+ if (network.nick === data.nick) {
10
+ return;
11
+ }
12
+
7
13
  const highlightRegex = new RegExp(network.highlightRegex, "i");
8
14
  const message = data.message || "";
9
15
 
10
- if (highlightRegex.test(message)) {
11
- // Load config after each message to get latest settings
12
- const [userConfig, errors] = loadUserConfig(client.client.name);
16
+ const mentioned = highlightRegex.test(message);
17
+ const isPM = data.target === network.nick;
18
+
19
+ let notify = false;
20
+ let userConfig;
21
+
22
+ if (mentioned) {
23
+ // Mentions always notify
24
+ notify = true;
25
+ } else if (isPM) {
26
+ // PMs notify only if enabled in config
27
+ const [uc, errors] = loadUserConfig(client.client.name);
13
28
 
14
29
  if (errors.length > 0) {
15
30
  return;
16
31
  }
17
32
 
18
- let ntfyAuth;
33
+ userConfig = uc;
19
34
 
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
- };
35
+ if (userConfig.config.notify_on_private_messages) {
36
+ notify = true;
27
37
  }
38
+ }
39
+
40
+ if (notify) {
41
+ try {
42
+ // Avoid needlessly loading user config multiple times
43
+ if (!userConfig) {
44
+ const [uc, errors] = loadUserConfig(client.client.name);
28
45
 
29
- const { NtfyClient, MessagePriority } = await import("ntfy");
46
+ if (errors.length > 0) {
47
+ return;
48
+ }
30
49
 
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
- });
50
+ userConfig = uc;
51
+ }
38
52
 
39
- ntfyClient.publish({
40
- title: `${network.name} ${data.target}: ${data.nick}`,
41
- message: message,
42
- });
53
+ let ntfyAuth;
54
+
55
+ if (userConfig.ntfy.token) {
56
+ ntfyAuth = {
57
+ username: "",
58
+ password: userConfig.ntfy.token,
59
+ };
60
+ } else if (userConfig.ntfy.username && userConfig.ntfy.password) {
61
+ ntfyAuth = {
62
+ username: userConfig.ntfy.username,
63
+ password: userConfig.ntfy.password,
64
+ };
65
+ }
66
+
67
+ const { NtfyClient, MessagePriority } = await import("ntfy");
68
+
69
+ const ntfyClient = new NtfyClient({
70
+ server: userConfig.ntfy.server,
71
+ topic: userConfig.ntfy.topic,
72
+ priority: MessagePriority.HIGH,
73
+ tags: ["speech_balloon"],
74
+ authorization: ntfyAuth,
75
+ });
76
+
77
+ ntfyClient.publish({
78
+ title: isPM
79
+ ? `${network.name}: ${data.nick}`
80
+ : `${network.name} ${data.target}: ${data.nick}`,
81
+ message: message,
82
+ });
83
+ } catch (e) {
84
+ PluginLogger.error("Failed to send ntfy notification", e);
85
+ }
43
86
  }
44
87
  };
45
88
  }