nonotify 0.1.4 → 0.1.6

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
@@ -14,7 +14,7 @@ After install, `nnt` is available globally.
14
14
 
15
15
  ## Config location
16
16
 
17
- - Default: `~/.nnt/config`
17
+ - Default: `~/.config/nnt/nnt.json`
18
18
  - Override: set `NNT_CONFIG_DIR`
19
19
 
20
20
  Example:
@@ -23,7 +23,7 @@ Example:
23
23
  export NNT_CONFIG_DIR="$HOME/.config/nnt"
24
24
  ```
25
25
 
26
- Config is stored as JSON in `<config-dir>/config`.
26
+ Config is stored as JSON in `<config-dir>/nnt.json`.
27
27
 
28
28
  ## Add profile
29
29
 
@@ -116,3 +116,41 @@ nnt send "some message for user" --profile=important-profile
116
116
  ```bash
117
117
  nnt "Task finished: migrations applied and tests passed"
118
118
  ```
119
+
120
+ ## Node.js API
121
+
122
+ `Notifier` loads config using `EnvConfigLoader` by default.
123
+
124
+ ```ts
125
+ import { Notifier } from "nonotify";
126
+
127
+ const notifier = new Notifier();
128
+
129
+ await notifier.send({
130
+ message: "Build finished successfully",
131
+ });
132
+ ```
133
+
134
+ Also you can pass profile data directly.
135
+
136
+ ```ts
137
+ import { Notifier } from "nonotify";
138
+
139
+ const notifier = new Notifier({
140
+ defaultProfile: "dev",
141
+ profiles: [
142
+ {
143
+ name: "dev",
144
+ botToken: process.env.TELEGRAM_BOT_TOKEN!,
145
+ chatId: process.env.TELEGRAM_CHAT_ID!,
146
+ },
147
+ ],
148
+ });
149
+
150
+ await notifier.send({
151
+ profile: "dev",
152
+ message: "Task completed",
153
+ });
154
+
155
+ console.log(notifier.profiles);
156
+ ```
package/dist/cli.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ import { Cli } from "incur";
3
+ declare const cli: Cli.Cli<{
4
+ send: {
5
+ args: {
6
+ message: string;
7
+ };
8
+ options: {
9
+ profile?: string | undefined;
10
+ };
11
+ };
12
+ } & {}, undefined, undefined>;
13
+ export default cli;
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@ import { askConfirm, askRequired, askRequiredWithInitial, askSelect, } from "./p
4
4
  import { getConfigPath, loadConfig, saveConfig } from "./config.js";
5
5
  import { printKeyValueTable, printProfilesTable } from "./display.js";
6
6
  import { getLatestUpdateOffset, sendTelegramMessage, waitForChatId, } from "./telegram.js";
7
+ import { Notifier } from "./notifier.js";
7
8
  const profileCli = Cli.create("profile", {
8
9
  description: "Manage notification profiles",
9
10
  });
@@ -335,21 +336,11 @@ const cli = Cli.create("nnt", {
335
336
  profile: "p",
336
337
  },
337
338
  async run(c) {
338
- const config = await loadConfig();
339
- const profileName = c.options.profile ?? config.defaultProfile;
340
- if (!profileName) {
341
- throw new Error("No default profile found. Run `nnt profile add` first.");
342
- }
343
- const profile = config.profiles[profileName];
344
- if (!profile) {
345
- throw new Error(`Profile "${profileName}" not found.`);
346
- }
347
- await sendTelegramMessage(profile.botToken, profile.chatId, c.args.message);
348
- return {
349
- sent: true,
350
- profile: profileName,
351
- provider: profile.type,
352
- };
339
+ const notifier = new Notifier();
340
+ return notifier.send({
341
+ message: c.args.message,
342
+ profile: c.options.profile,
343
+ });
353
344
  },
354
345
  })
355
346
  .command(profileCli);
@@ -0,0 +1,16 @@
1
+ export type TelegramProfile = {
2
+ type: "telegram";
3
+ name: string;
4
+ botToken: string;
5
+ chatId: string;
6
+ createdAt: string;
7
+ };
8
+ export type NntConfig = {
9
+ defaultProfile: string | null;
10
+ profiles: Record<string, TelegramProfile>;
11
+ };
12
+ export declare function getConfigDir(): string;
13
+ export declare function getConfigPath(): string;
14
+ export declare function getLegacyConfigPath(): string;
15
+ export declare function loadConfig(): Promise<NntConfig>;
16
+ export declare function saveConfig(config: NntConfig): Promise<void>;
package/dist/config.js CHANGED
@@ -5,31 +5,47 @@ const DEFAULT_CONFIG = {
5
5
  defaultProfile: null,
6
6
  profiles: {},
7
7
  };
8
+ const CONFIG_FILENAME = "nnt.json";
9
+ const DEFAULT_CONFIG_DIR = join(homedir(), ".config", "nnt");
10
+ const LEGACY_CONFIG_PATH = join(homedir(), ".nnt", "config");
8
11
  export function getConfigDir() {
9
12
  const dir = process.env.NNT_CONFIG_DIR;
10
13
  if (dir && dir.trim() !== "") {
11
14
  return resolve(dir);
12
15
  }
13
- return join(homedir(), ".nnt");
16
+ return DEFAULT_CONFIG_DIR;
14
17
  }
15
18
  export function getConfigPath() {
16
- return join(getConfigDir(), "config");
19
+ return join(getConfigDir(), CONFIG_FILENAME);
20
+ }
21
+ export function getLegacyConfigPath() {
22
+ return LEGACY_CONFIG_PATH;
17
23
  }
18
24
  export async function loadConfig() {
19
25
  const path = getConfigPath();
20
26
  try {
21
27
  const raw = await readFile(path, "utf8");
22
- const parsed = JSON.parse(raw);
23
- return {
24
- defaultProfile: typeof parsed.defaultProfile === "string"
25
- ? parsed.defaultProfile
26
- : null,
27
- profiles: parsed.profiles ?? {},
28
- };
28
+ return parseConfig(raw);
29
29
  }
30
30
  catch (error) {
31
31
  if (isNodeError(error) && error.code === "ENOENT") {
32
- return { ...DEFAULT_CONFIG };
32
+ try {
33
+ const legacyRaw = await readFile(getLegacyConfigPath(), "utf8");
34
+ const parsed = parseConfig(legacyRaw);
35
+ try {
36
+ await saveConfig(parsed);
37
+ }
38
+ catch {
39
+ // Best effort migration only.
40
+ }
41
+ return parsed;
42
+ }
43
+ catch (legacyError) {
44
+ if (isNodeError(legacyError) && legacyError.code === "ENOENT") {
45
+ return { ...DEFAULT_CONFIG };
46
+ }
47
+ throw legacyError;
48
+ }
33
49
  }
34
50
  throw error;
35
51
  }
@@ -51,3 +67,10 @@ export async function saveConfig(config) {
51
67
  function isNodeError(error) {
52
68
  return typeof error === "object" && error !== null && "code" in error;
53
69
  }
70
+ function parseConfig(raw) {
71
+ const parsed = JSON.parse(raw);
72
+ return {
73
+ defaultProfile: typeof parsed.defaultProfile === "string" ? parsed.defaultProfile : null,
74
+ profiles: parsed.profiles ?? {},
75
+ };
76
+ }
@@ -0,0 +1,11 @@
1
+ type ProfileRow = {
2
+ name: string;
3
+ provider: string;
4
+ isDefault: boolean;
5
+ };
6
+ export declare function printProfilesTable(rows: ProfileRow[]): void;
7
+ export declare function printKeyValueTable(title: string, rows: Array<{
8
+ key: string;
9
+ value: string;
10
+ }>): void;
11
+ export {};
@@ -0,0 +1,2 @@
1
+ export { EnvConfigLoader, NoProfilesConfiguredError, Notifier, NotifierError, ProfileNotFoundError, } from "./notifier.js";
2
+ export type { NotifierConfig, NotifierConfigLoader, NotifierProfile, NotifierProfileInput, SendInput, SendResult, } from "./notifier.js";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { EnvConfigLoader, NoProfilesConfiguredError, Notifier, NotifierError, ProfileNotFoundError, } from "./notifier.js";
@@ -0,0 +1,49 @@
1
+ export type NotifierProfile = {
2
+ type: "telegram";
3
+ name: string;
4
+ botToken: string;
5
+ chatId: string;
6
+ };
7
+ export type NotifierProfileInput = {
8
+ type?: "telegram";
9
+ name: string;
10
+ botToken: string;
11
+ chatId: string;
12
+ };
13
+ export type NotifierConfig = {
14
+ profiles: NotifierProfileInput[];
15
+ defaultProfile?: string | null;
16
+ };
17
+ export interface NotifierConfigLoader {
18
+ load(): NotifierConfig;
19
+ }
20
+ export type SendInput = {
21
+ message: string;
22
+ profile?: string;
23
+ };
24
+ export type SendResult = {
25
+ sent: true;
26
+ profile: string;
27
+ provider: "telegram";
28
+ };
29
+ export declare class NotifierError extends Error {
30
+ constructor(message: string);
31
+ }
32
+ export declare class ProfileNotFoundError extends NotifierError {
33
+ readonly profile: string;
34
+ constructor(profile: string);
35
+ }
36
+ export declare class NoProfilesConfiguredError extends NotifierError {
37
+ constructor();
38
+ }
39
+ export declare class EnvConfigLoader implements NotifierConfigLoader {
40
+ load(): NotifierConfig;
41
+ }
42
+ export declare class Notifier {
43
+ readonly profiles: readonly Readonly<NotifierProfile>[];
44
+ private readonly defaultProfile;
45
+ private readonly profilesByName;
46
+ constructor(source?: NotifierConfig | NotifierConfigLoader);
47
+ send(input: SendInput): Promise<SendResult>;
48
+ private resolveProfile;
49
+ }
@@ -0,0 +1,171 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { getConfigPath, getLegacyConfigPath } from "./config.js";
3
+ import { sendTelegramMessage } from "./telegram.js";
4
+ export class NotifierError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = "NotifierError";
8
+ }
9
+ }
10
+ export class ProfileNotFoundError extends NotifierError {
11
+ profile;
12
+ constructor(profile) {
13
+ super(`Profile "${profile}" not found.`);
14
+ this.name = "ProfileNotFoundError";
15
+ this.profile = profile;
16
+ }
17
+ }
18
+ export class NoProfilesConfiguredError extends NotifierError {
19
+ constructor() {
20
+ super("No profiles configured. Run `nnt profile add` first.");
21
+ this.name = "NoProfilesConfiguredError";
22
+ }
23
+ }
24
+ export class EnvConfigLoader {
25
+ load() {
26
+ const configPaths = Array.from(new Set([getConfigPath(), getLegacyConfigPath()]));
27
+ for (const configPath of configPaths) {
28
+ try {
29
+ const raw = readFileSync(configPath, "utf8");
30
+ const rawConfig = JSON.parse(raw);
31
+ return normalizeEnvConfig(rawConfig);
32
+ }
33
+ catch (error) {
34
+ if (isNodeError(error) && error.code === "ENOENT") {
35
+ continue;
36
+ }
37
+ if (error instanceof SyntaxError) {
38
+ throw new NotifierError(`Invalid JSON in config file at ${configPath}: ${error.message}`);
39
+ }
40
+ throw error;
41
+ }
42
+ }
43
+ return {
44
+ profiles: [],
45
+ defaultProfile: null,
46
+ };
47
+ }
48
+ }
49
+ export class Notifier {
50
+ profiles;
51
+ defaultProfile;
52
+ profilesByName;
53
+ constructor(source = new EnvConfigLoader()) {
54
+ const config = isConfigLoader(source) ? source.load() : source;
55
+ const normalized = normalizeNotifierConfig(config);
56
+ const frozenProfiles = normalized.profiles.map((profile) => Object.freeze({ ...profile }));
57
+ this.profiles = Object.freeze(frozenProfiles);
58
+ this.defaultProfile = normalized.defaultProfile;
59
+ this.profilesByName = new Map(frozenProfiles.map((profile) => [profile.name, profile]));
60
+ }
61
+ async send(input) {
62
+ const message = input.message.trim();
63
+ if (message === "") {
64
+ throw new NotifierError("Message cannot be empty.");
65
+ }
66
+ const selectedProfile = this.resolveProfile(input.profile);
67
+ await sendTelegramMessage(selectedProfile.botToken, selectedProfile.chatId, message);
68
+ return {
69
+ sent: true,
70
+ profile: selectedProfile.name,
71
+ provider: selectedProfile.type,
72
+ };
73
+ }
74
+ resolveProfile(profileName) {
75
+ if (profileName) {
76
+ const profile = this.profilesByName.get(profileName);
77
+ if (!profile) {
78
+ throw new ProfileNotFoundError(profileName);
79
+ }
80
+ return profile;
81
+ }
82
+ if (this.defaultProfile) {
83
+ const defaultProfile = this.profilesByName.get(this.defaultProfile);
84
+ if (defaultProfile) {
85
+ return defaultProfile;
86
+ }
87
+ }
88
+ const firstProfile = this.profiles[0];
89
+ if (!firstProfile) {
90
+ throw new NoProfilesConfiguredError();
91
+ }
92
+ return firstProfile;
93
+ }
94
+ }
95
+ function normalizeEnvConfig(raw) {
96
+ return {
97
+ defaultProfile: typeof raw.defaultProfile === "string" ? raw.defaultProfile : null,
98
+ profiles: normalizeProfilesFromUnknown(raw.profiles),
99
+ };
100
+ }
101
+ function normalizeProfilesFromUnknown(rawProfiles) {
102
+ if (!rawProfiles) {
103
+ return [];
104
+ }
105
+ if (Array.isArray(rawProfiles)) {
106
+ return rawProfiles.map((profile, index) => normalizeSingleProfile(profile, `profiles[${index}]`));
107
+ }
108
+ if (typeof rawProfiles === "object") {
109
+ const entries = Object.entries(rawProfiles);
110
+ return entries.map(([name, profile]) => normalizeRecordProfile(name, profile, `profiles.${name}`));
111
+ }
112
+ throw new NotifierError("Invalid config format: `profiles` must be an array or object.");
113
+ }
114
+ function normalizeNotifierConfig(input) {
115
+ if (!Array.isArray(input.profiles)) {
116
+ throw new NotifierError("Invalid Notifier config: `profiles` must be an array.");
117
+ }
118
+ const profiles = input.profiles.map((profile, index) => normalizeSingleProfile(profile, `profiles[${index}]`));
119
+ const names = new Set();
120
+ for (const profile of profiles) {
121
+ if (names.has(profile.name)) {
122
+ throw new NotifierError(`Invalid Notifier config: duplicate profile name "${profile.name}".`);
123
+ }
124
+ names.add(profile.name);
125
+ }
126
+ return {
127
+ profiles,
128
+ defaultProfile: typeof input.defaultProfile === "string" ? input.defaultProfile : null,
129
+ };
130
+ }
131
+ function normalizeRecordProfile(profileName, rawProfile, sourceLabel) {
132
+ if (!rawProfile || typeof rawProfile !== "object") {
133
+ throw new NotifierError(`Invalid profile at ${sourceLabel}: expected an object.`);
134
+ }
135
+ return {
136
+ type: "telegram",
137
+ name: profileName,
138
+ botToken: requireNonEmptyString(rawProfile.botToken, `${sourceLabel}.botToken`),
139
+ chatId: requireNonEmptyString(rawProfile.chatId, `${sourceLabel}.chatId`),
140
+ };
141
+ }
142
+ function normalizeSingleProfile(rawProfile, sourceLabel) {
143
+ if (!rawProfile || typeof rawProfile !== "object") {
144
+ throw new NotifierError(`Invalid profile at ${sourceLabel}: expected an object.`);
145
+ }
146
+ const profile = rawProfile;
147
+ if (profile.type !== undefined && profile.type !== "telegram") {
148
+ throw new NotifierError(`Invalid profile at ${sourceLabel}.type: only "telegram" is supported.`);
149
+ }
150
+ return {
151
+ type: "telegram",
152
+ name: requireNonEmptyString(profile.name, `${sourceLabel}.name`),
153
+ botToken: requireNonEmptyString(profile.botToken, `${sourceLabel}.botToken`),
154
+ chatId: requireNonEmptyString(profile.chatId, `${sourceLabel}.chatId`),
155
+ };
156
+ }
157
+ function requireNonEmptyString(value, label) {
158
+ if (typeof value !== "string" || value.trim() === "") {
159
+ throw new NotifierError(`Invalid value for ${label}: expected non-empty string.`);
160
+ }
161
+ return value.trim();
162
+ }
163
+ function isConfigLoader(value) {
164
+ return (typeof value === "object" &&
165
+ value !== null &&
166
+ "load" in value &&
167
+ typeof value.load === "function");
168
+ }
169
+ function isNodeError(error) {
170
+ return typeof error === "object" && error !== null && "code" in error;
171
+ }
@@ -0,0 +1,11 @@
1
+ export declare function askRequired(question: string): Promise<string>;
2
+ export declare function askRequiredWithInitial(question: string, initialValue?: string): Promise<string>;
3
+ export declare function askConfirm(question: string, initialValue?: boolean): Promise<boolean>;
4
+ type SelectOption = {
5
+ value: string;
6
+ label: string;
7
+ hint?: string;
8
+ disabled?: boolean;
9
+ };
10
+ export declare function askSelect(question: string, options: SelectOption[]): Promise<string>;
11
+ export {};
@@ -0,0 +1,7 @@
1
+ export type TelegramConnection = {
2
+ chatId: string;
3
+ username: string | null;
4
+ };
5
+ export declare function getLatestUpdateOffset(botToken: string): Promise<number>;
6
+ export declare function waitForChatId(botToken: string, offset: number, timeoutSeconds?: number): Promise<TelegramConnection>;
7
+ export declare function sendTelegramMessage(botToken: string, chatId: string, text: string): Promise<void>;
package/package.json CHANGED
@@ -1,8 +1,17 @@
1
1
  {
2
2
  "name": "nonotify",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "nnt CLI for Telegram notifications",
5
5
  "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
6
15
  "bin": {
7
16
  "nnt": "./dist/cli.js"
8
17
  },
package/src/cli.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  sendTelegramMessage,
15
15
  waitForChatId,
16
16
  } from "./telegram.js";
17
+ import { Notifier } from "./notifier.js";
17
18
 
18
19
  const profileCli = Cli.create("profile", {
19
20
  description: "Manage notification profiles",
@@ -442,32 +443,12 @@ const cli = Cli.create("nnt", {
442
443
  profile: "p",
443
444
  },
444
445
  async run(c) {
445
- const config = await loadConfig();
446
- const profileName = c.options.profile ?? config.defaultProfile;
446
+ const notifier = new Notifier();
447
447
 
448
- if (!profileName) {
449
- throw new Error(
450
- "No default profile found. Run `nnt profile add` first.",
451
- );
452
- }
453
-
454
- const profile = config.profiles[profileName];
455
-
456
- if (!profile) {
457
- throw new Error(`Profile "${profileName}" not found.`);
458
- }
459
-
460
- await sendTelegramMessage(
461
- profile.botToken,
462
- profile.chatId,
463
- c.args.message,
464
- );
465
-
466
- return {
467
- sent: true,
468
- profile: profileName,
469
- provider: profile.type,
470
- };
448
+ return notifier.send({
449
+ message: c.args.message,
450
+ profile: c.options.profile,
451
+ });
471
452
  },
472
453
  })
473
454
  .command(profileCli);
package/src/config.ts CHANGED
@@ -20,17 +20,25 @@ const DEFAULT_CONFIG: NntConfig = {
20
20
  profiles: {},
21
21
  };
22
22
 
23
+ const CONFIG_FILENAME = "nnt.json";
24
+ const DEFAULT_CONFIG_DIR = join(homedir(), ".config", "nnt");
25
+ const LEGACY_CONFIG_PATH = join(homedir(), ".nnt", "config");
26
+
23
27
  export function getConfigDir(): string {
24
28
  const dir = process.env.NNT_CONFIG_DIR;
25
29
  if (dir && dir.trim() !== "") {
26
30
  return resolve(dir);
27
31
  }
28
32
 
29
- return join(homedir(), ".nnt");
33
+ return DEFAULT_CONFIG_DIR;
30
34
  }
31
35
 
32
36
  export function getConfigPath(): string {
33
- return join(getConfigDir(), "config");
37
+ return join(getConfigDir(), CONFIG_FILENAME);
38
+ }
39
+
40
+ export function getLegacyConfigPath(): string {
41
+ return LEGACY_CONFIG_PATH;
34
42
  }
35
43
 
36
44
  export async function loadConfig(): Promise<NntConfig> {
@@ -38,18 +46,27 @@ export async function loadConfig(): Promise<NntConfig> {
38
46
 
39
47
  try {
40
48
  const raw = await readFile(path, "utf8");
41
- const parsed = JSON.parse(raw) as Partial<NntConfig>;
42
-
43
- return {
44
- defaultProfile:
45
- typeof parsed.defaultProfile === "string"
46
- ? parsed.defaultProfile
47
- : null,
48
- profiles: parsed.profiles ?? {},
49
- };
49
+ return parseConfig(raw);
50
50
  } catch (error) {
51
51
  if (isNodeError(error) && error.code === "ENOENT") {
52
- return { ...DEFAULT_CONFIG };
52
+ try {
53
+ const legacyRaw = await readFile(getLegacyConfigPath(), "utf8");
54
+ const parsed = parseConfig(legacyRaw);
55
+
56
+ try {
57
+ await saveConfig(parsed);
58
+ } catch {
59
+ // Best effort migration only.
60
+ }
61
+
62
+ return parsed;
63
+ } catch (legacyError) {
64
+ if (isNodeError(legacyError) && legacyError.code === "ENOENT") {
65
+ return { ...DEFAULT_CONFIG };
66
+ }
67
+
68
+ throw legacyError;
69
+ }
53
70
  }
54
71
 
55
72
  throw error;
@@ -75,3 +92,13 @@ export async function saveConfig(config: NntConfig): Promise<void> {
75
92
  function isNodeError(error: unknown): error is NodeJS.ErrnoException {
76
93
  return typeof error === "object" && error !== null && "code" in error;
77
94
  }
95
+
96
+ function parseConfig(raw: string): NntConfig {
97
+ const parsed = JSON.parse(raw) as Partial<NntConfig>;
98
+
99
+ return {
100
+ defaultProfile:
101
+ typeof parsed.defaultProfile === "string" ? parsed.defaultProfile : null,
102
+ profiles: parsed.profiles ?? {},
103
+ };
104
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ export {
2
+ EnvConfigLoader,
3
+ NoProfilesConfiguredError,
4
+ Notifier,
5
+ NotifierError,
6
+ ProfileNotFoundError,
7
+ } from "./notifier.js";
8
+ export type {
9
+ NotifierConfig,
10
+ NotifierConfigLoader,
11
+ NotifierProfile,
12
+ NotifierProfileInput,
13
+ SendInput,
14
+ SendResult,
15
+ } from "./notifier.js";
@@ -0,0 +1,326 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { getConfigPath, getLegacyConfigPath } from "./config.js";
3
+ import { sendTelegramMessage } from "./telegram.js";
4
+
5
+ export type NotifierProfile = {
6
+ type: "telegram";
7
+ name: string;
8
+ botToken: string;
9
+ chatId: string;
10
+ };
11
+
12
+ export type NotifierProfileInput = {
13
+ type?: "telegram";
14
+ name: string;
15
+ botToken: string;
16
+ chatId: string;
17
+ };
18
+
19
+ export type NotifierConfig = {
20
+ profiles: NotifierProfileInput[];
21
+ defaultProfile?: string | null;
22
+ };
23
+
24
+ export interface NotifierConfigLoader {
25
+ load(): NotifierConfig;
26
+ }
27
+
28
+ export type SendInput = {
29
+ message: string;
30
+ profile?: string;
31
+ };
32
+
33
+ export type SendResult = {
34
+ sent: true;
35
+ profile: string;
36
+ provider: "telegram";
37
+ };
38
+
39
+ export class NotifierError extends Error {
40
+ constructor(message: string) {
41
+ super(message);
42
+ this.name = "NotifierError";
43
+ }
44
+ }
45
+
46
+ export class ProfileNotFoundError extends NotifierError {
47
+ readonly profile: string;
48
+
49
+ constructor(profile: string) {
50
+ super(`Profile "${profile}" not found.`);
51
+ this.name = "ProfileNotFoundError";
52
+ this.profile = profile;
53
+ }
54
+ }
55
+
56
+ export class NoProfilesConfiguredError extends NotifierError {
57
+ constructor() {
58
+ super("No profiles configured. Run `nnt profile add` first.");
59
+ this.name = "NoProfilesConfiguredError";
60
+ }
61
+ }
62
+
63
+ type RawEnvConfig = {
64
+ defaultProfile?: unknown;
65
+ profiles?: unknown;
66
+ };
67
+
68
+ type RawRecordProfile = {
69
+ type?: unknown;
70
+ name?: unknown;
71
+ botToken?: unknown;
72
+ chatId?: unknown;
73
+ };
74
+
75
+ export class EnvConfigLoader implements NotifierConfigLoader {
76
+ load(): NotifierConfig {
77
+ const configPaths = Array.from(
78
+ new Set([getConfigPath(), getLegacyConfigPath()]),
79
+ );
80
+
81
+ for (const configPath of configPaths) {
82
+ try {
83
+ const raw = readFileSync(configPath, "utf8");
84
+ const rawConfig = JSON.parse(raw) as RawEnvConfig;
85
+ return normalizeEnvConfig(rawConfig);
86
+ } catch (error) {
87
+ if (isNodeError(error) && error.code === "ENOENT") {
88
+ continue;
89
+ }
90
+
91
+ if (error instanceof SyntaxError) {
92
+ throw new NotifierError(
93
+ `Invalid JSON in config file at ${configPath}: ${error.message}`,
94
+ );
95
+ }
96
+
97
+ throw error;
98
+ }
99
+ }
100
+
101
+ return {
102
+ profiles: [],
103
+ defaultProfile: null,
104
+ };
105
+ }
106
+ }
107
+
108
+ export class Notifier {
109
+ readonly profiles: readonly Readonly<NotifierProfile>[];
110
+ private readonly defaultProfile: string | null;
111
+ private readonly profilesByName: ReadonlyMap<
112
+ string,
113
+ Readonly<NotifierProfile>
114
+ >;
115
+
116
+ constructor(
117
+ source: NotifierConfig | NotifierConfigLoader = new EnvConfigLoader(),
118
+ ) {
119
+ const config = isConfigLoader(source) ? source.load() : source;
120
+ const normalized = normalizeNotifierConfig(config);
121
+ const frozenProfiles = normalized.profiles.map((profile) =>
122
+ Object.freeze({ ...profile }),
123
+ );
124
+
125
+ this.profiles = Object.freeze(frozenProfiles);
126
+ this.defaultProfile = normalized.defaultProfile;
127
+ this.profilesByName = new Map(
128
+ frozenProfiles.map((profile) => [profile.name, profile]),
129
+ );
130
+ }
131
+
132
+ async send(input: SendInput): Promise<SendResult> {
133
+ const message = input.message.trim();
134
+
135
+ if (message === "") {
136
+ throw new NotifierError("Message cannot be empty.");
137
+ }
138
+
139
+ const selectedProfile = this.resolveProfile(input.profile);
140
+
141
+ await sendTelegramMessage(
142
+ selectedProfile.botToken,
143
+ selectedProfile.chatId,
144
+ message,
145
+ );
146
+
147
+ return {
148
+ sent: true,
149
+ profile: selectedProfile.name,
150
+ provider: selectedProfile.type,
151
+ };
152
+ }
153
+
154
+ private resolveProfile(profileName?: string): Readonly<NotifierProfile> {
155
+ if (profileName) {
156
+ const profile = this.profilesByName.get(profileName);
157
+
158
+ if (!profile) {
159
+ throw new ProfileNotFoundError(profileName);
160
+ }
161
+
162
+ return profile;
163
+ }
164
+
165
+ if (this.defaultProfile) {
166
+ const defaultProfile = this.profilesByName.get(this.defaultProfile);
167
+ if (defaultProfile) {
168
+ return defaultProfile;
169
+ }
170
+ }
171
+
172
+ const firstProfile = this.profiles[0];
173
+
174
+ if (!firstProfile) {
175
+ throw new NoProfilesConfiguredError();
176
+ }
177
+
178
+ return firstProfile;
179
+ }
180
+ }
181
+
182
+ function normalizeEnvConfig(raw: RawEnvConfig): NotifierConfig {
183
+ return {
184
+ defaultProfile:
185
+ typeof raw.defaultProfile === "string" ? raw.defaultProfile : null,
186
+ profiles: normalizeProfilesFromUnknown(raw.profiles),
187
+ };
188
+ }
189
+
190
+ function normalizeProfilesFromUnknown(rawProfiles: unknown): NotifierProfile[] {
191
+ if (!rawProfiles) {
192
+ return [];
193
+ }
194
+
195
+ if (Array.isArray(rawProfiles)) {
196
+ return rawProfiles.map((profile, index) =>
197
+ normalizeSingleProfile(profile, `profiles[${index}]`),
198
+ );
199
+ }
200
+
201
+ if (typeof rawProfiles === "object") {
202
+ const entries = Object.entries(
203
+ rawProfiles as Record<string, RawRecordProfile>,
204
+ );
205
+
206
+ return entries.map(([name, profile]) =>
207
+ normalizeRecordProfile(name, profile, `profiles.${name}`),
208
+ );
209
+ }
210
+
211
+ throw new NotifierError(
212
+ "Invalid config format: `profiles` must be an array or object.",
213
+ );
214
+ }
215
+
216
+ function normalizeNotifierConfig(input: NotifierConfig): {
217
+ profiles: NotifierProfile[];
218
+ defaultProfile: string | null;
219
+ } {
220
+ if (!Array.isArray(input.profiles)) {
221
+ throw new NotifierError(
222
+ "Invalid Notifier config: `profiles` must be an array.",
223
+ );
224
+ }
225
+
226
+ const profiles = input.profiles.map((profile, index) =>
227
+ normalizeSingleProfile(profile, `profiles[${index}]`),
228
+ );
229
+
230
+ const names = new Set<string>();
231
+
232
+ for (const profile of profiles) {
233
+ if (names.has(profile.name)) {
234
+ throw new NotifierError(
235
+ `Invalid Notifier config: duplicate profile name "${profile.name}".`,
236
+ );
237
+ }
238
+
239
+ names.add(profile.name);
240
+ }
241
+
242
+ return {
243
+ profiles,
244
+ defaultProfile:
245
+ typeof input.defaultProfile === "string" ? input.defaultProfile : null,
246
+ };
247
+ }
248
+
249
+ function normalizeRecordProfile(
250
+ profileName: string,
251
+ rawProfile: RawRecordProfile,
252
+ sourceLabel: string,
253
+ ): NotifierProfile {
254
+ if (!rawProfile || typeof rawProfile !== "object") {
255
+ throw new NotifierError(
256
+ `Invalid profile at ${sourceLabel}: expected an object.`,
257
+ );
258
+ }
259
+
260
+ return {
261
+ type: "telegram",
262
+ name: profileName,
263
+ botToken: requireNonEmptyString(
264
+ rawProfile.botToken,
265
+ `${sourceLabel}.botToken`,
266
+ ),
267
+ chatId: requireNonEmptyString(rawProfile.chatId, `${sourceLabel}.chatId`),
268
+ };
269
+ }
270
+
271
+ function normalizeSingleProfile(
272
+ rawProfile: unknown,
273
+ sourceLabel: string,
274
+ ): NotifierProfile {
275
+ if (!rawProfile || typeof rawProfile !== "object") {
276
+ throw new NotifierError(
277
+ `Invalid profile at ${sourceLabel}: expected an object.`,
278
+ );
279
+ }
280
+
281
+ const profile = rawProfile as {
282
+ type?: unknown;
283
+ name?: unknown;
284
+ botToken?: unknown;
285
+ chatId?: unknown;
286
+ };
287
+
288
+ if (profile.type !== undefined && profile.type !== "telegram") {
289
+ throw new NotifierError(
290
+ `Invalid profile at ${sourceLabel}.type: only "telegram" is supported.`,
291
+ );
292
+ }
293
+
294
+ return {
295
+ type: "telegram",
296
+ name: requireNonEmptyString(profile.name, `${sourceLabel}.name`),
297
+ botToken: requireNonEmptyString(
298
+ profile.botToken,
299
+ `${sourceLabel}.botToken`,
300
+ ),
301
+ chatId: requireNonEmptyString(profile.chatId, `${sourceLabel}.chatId`),
302
+ };
303
+ }
304
+
305
+ function requireNonEmptyString(value: unknown, label: string): string {
306
+ if (typeof value !== "string" || value.trim() === "") {
307
+ throw new NotifierError(
308
+ `Invalid value for ${label}: expected non-empty string.`,
309
+ );
310
+ }
311
+
312
+ return value.trim();
313
+ }
314
+
315
+ function isConfigLoader(value: unknown): value is NotifierConfigLoader {
316
+ return (
317
+ typeof value === "object" &&
318
+ value !== null &&
319
+ "load" in value &&
320
+ typeof (value as { load: unknown }).load === "function"
321
+ );
322
+ }
323
+
324
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
325
+ return typeof error === "object" && error !== null && "code" in error;
326
+ }
package/tsconfig.json CHANGED
@@ -8,6 +8,7 @@
8
8
  "resolveJsonModule": true,
9
9
  "esModuleInterop": true,
10
10
  "forceConsistentCasingInFileNames": true,
11
+ "declaration": true,
11
12
  "outDir": "dist",
12
13
  "rootDir": "src"
13
14
  },