nonotify 0.1.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.
@@ -0,0 +1,57 @@
1
+ async function telegramRequest(botToken, method, payload) {
2
+ const response = await fetch(`https://api.telegram.org/bot${botToken}/${method}`, {
3
+ method: 'POST',
4
+ headers: {
5
+ 'content-type': 'application/json',
6
+ },
7
+ body: JSON.stringify(payload),
8
+ });
9
+ if (!response.ok) {
10
+ throw new Error(`Telegram API HTTP ${response.status}`);
11
+ }
12
+ const json = await response.json();
13
+ if (!json.ok) {
14
+ throw new Error(json.description);
15
+ }
16
+ return json.result;
17
+ }
18
+ export async function getLatestUpdateOffset(botToken) {
19
+ const updates = await telegramRequest(botToken, 'getUpdates', {
20
+ timeout: 0,
21
+ allowed_updates: ['message'],
22
+ });
23
+ if (updates.length === 0) {
24
+ return 0;
25
+ }
26
+ const maxUpdateId = updates.reduce((acc, item) => Math.max(acc, item.update_id), 0);
27
+ return maxUpdateId + 1;
28
+ }
29
+ export async function waitForChatId(botToken, offset, timeoutSeconds = 120) {
30
+ const startedAt = Date.now();
31
+ let currentOffset = offset;
32
+ while ((Date.now() - startedAt) / 1000 < timeoutSeconds) {
33
+ const remainingSeconds = timeoutSeconds - Math.floor((Date.now() - startedAt) / 1000);
34
+ const pollTimeout = Math.max(1, Math.min(25, remainingSeconds));
35
+ const updates = await telegramRequest(botToken, 'getUpdates', {
36
+ offset: currentOffset,
37
+ timeout: pollTimeout,
38
+ allowed_updates: ['message'],
39
+ });
40
+ for (const update of updates) {
41
+ currentOffset = Math.max(currentOffset, update.update_id + 1);
42
+ if (update.message?.chat?.id !== undefined) {
43
+ return {
44
+ chatId: String(update.message.chat.id),
45
+ username: update.message.from?.username ?? update.message.chat.username ?? null,
46
+ };
47
+ }
48
+ }
49
+ }
50
+ throw new Error('Timed out waiting for Telegram message. Send a message to your bot and try again.');
51
+ }
52
+ export async function sendTelegramMessage(botToken, chatId, text) {
53
+ await telegramRequest(botToken, 'sendMessage', {
54
+ chat_id: chatId,
55
+ text,
56
+ });
57
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "nonotify",
3
+ "version": "0.1.0",
4
+ "description": "nnt CLI for Telegram notifications",
5
+ "type": "module",
6
+ "bin": {
7
+ "nnt": "dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc -p tsconfig.json",
11
+ "dev": "tsx src/cli.ts",
12
+ "start": "node dist/cli.js"
13
+ },
14
+ "keywords": [
15
+ "cli",
16
+ "telegram",
17
+ "notifications",
18
+ "incur"
19
+ ],
20
+ "license": "MIT",
21
+ "dependencies": {
22
+ "@clack/prompts": "^1.0.1",
23
+ "cli-table3": "^0.6.5",
24
+ "incur": "^0.1.8"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^24.6.2",
28
+ "tsx": "^4.20.6",
29
+ "typescript": "^5.9.3"
30
+ }
31
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,520 @@
1
+ #!/usr/bin/env node
2
+ import { Cli, z } from 'incur'
3
+ import { askConfirm, askRequired, askRequiredWithInitial, askSelect } from './prompt.js'
4
+ import { getConfigPath, loadConfig, saveConfig } from './config.js'
5
+ import { printKeyValueTable, printProfilesTable } from './display.js'
6
+ import { getLatestUpdateOffset, sendTelegramMessage, waitForChatId } from './telegram.js'
7
+
8
+ const profileCli = Cli.create('profile', {
9
+ description: 'Manage notification profiles',
10
+ })
11
+
12
+ profileCli.command('add', {
13
+ description: 'Add a notification profile',
14
+ outputPolicy: 'agent-only',
15
+ args: z.object({
16
+ provider: z.enum(['telegram']).default('telegram').describe('Profile provider'),
17
+ }),
18
+ async run(c) {
19
+ if (c.args.provider !== 'telegram') {
20
+ throw new Error(`Unsupported provider: ${c.args.provider}`)
21
+ }
22
+
23
+ const config = await loadConfig()
24
+ const profileName = await askRequired('Profile name: ')
25
+
26
+ if (config.profiles[profileName]) {
27
+ throw new Error(`Profile "${profileName}" already exists.`)
28
+ }
29
+
30
+ const botToken = await askRequired('Telegram bot token: ')
31
+
32
+ if (shouldRenderPretty(c.agent)) {
33
+ process.stdout.write('\nSend any message to your bot in Telegram.\n')
34
+ process.stdout.write('Waiting for message to detect chat_id (up to 120s)...\n')
35
+ }
36
+
37
+ const offset = await getLatestUpdateOffset(botToken)
38
+ const connection = await waitForChatId(botToken, offset, 120)
39
+ const chatId = connection.chatId
40
+
41
+ if (shouldRenderPretty(c.agent)) {
42
+ if (connection.username) {
43
+ process.stdout.write(`Connected Telegram username: @${connection.username}\n`)
44
+ }
45
+ else {
46
+ process.stdout.write('Connected Telegram user has no username set.\n')
47
+ }
48
+ }
49
+
50
+ config.profiles[profileName] = {
51
+ type: 'telegram',
52
+ name: profileName,
53
+ botToken,
54
+ chatId,
55
+ createdAt: new Date().toISOString(),
56
+ }
57
+
58
+ if (!config.defaultProfile) {
59
+ config.defaultProfile = profileName
60
+ }
61
+
62
+ await saveConfig(config)
63
+
64
+ let confirmationSent = false
65
+ let confirmationWarning: string | null = null
66
+
67
+ try {
68
+ await sendTelegramMessage(
69
+ botToken,
70
+ chatId,
71
+ `nnt: profile "${profileName}" connected successfully. You can now send notifications from CLI.`,
72
+ )
73
+ confirmationSent = true
74
+ }
75
+ catch (error) {
76
+ confirmationWarning = error instanceof Error ? error.message : 'Unknown error while sending confirmation'
77
+ }
78
+
79
+ if (shouldRenderPretty(c.agent)) {
80
+ printKeyValueTable('Profile added', [
81
+ { key: 'profile', value: profileName },
82
+ { key: 'provider', value: 'telegram' },
83
+ { key: 'chat_id', value: chatId },
84
+ { key: 'username', value: connection.username ? `@${connection.username}` : '(none)' },
85
+ { key: 'default', value: config.defaultProfile ?? '(none)' },
86
+ ])
87
+ }
88
+
89
+ return {
90
+ added: true,
91
+ profile: profileName,
92
+ provider: 'telegram',
93
+ chatId,
94
+ username: connection.username,
95
+ defaultProfile: config.defaultProfile,
96
+ configPath: getConfigPath(),
97
+ confirmationSent,
98
+ confirmationWarning,
99
+ }
100
+ },
101
+ })
102
+
103
+ profileCli.command('list', {
104
+ description: 'List configured profiles',
105
+ outputPolicy: 'agent-only',
106
+ async run(c) {
107
+ const config = await loadConfig()
108
+ const names = Object.keys(config.profiles).sort((a, b) => a.localeCompare(b))
109
+ const profiles = names.map(name => ({
110
+ name,
111
+ provider: config.profiles[name].type,
112
+ isDefault: name === config.defaultProfile,
113
+ }))
114
+
115
+ if (shouldRenderPretty(c.agent)) {
116
+ printProfilesTable(profiles)
117
+ }
118
+
119
+ return {
120
+ defaultProfile: config.defaultProfile,
121
+ totalProfiles: names.length,
122
+ profiles,
123
+ }
124
+ },
125
+ })
126
+
127
+ profileCli.command('default', {
128
+ description: 'Get or set default profile',
129
+ outputPolicy: 'agent-only',
130
+ args: z.object({
131
+ profile: z.string().optional().describe('Profile name to set as default'),
132
+ }),
133
+ async run(c) {
134
+ const config = await loadConfig()
135
+
136
+ if (!c.args.profile) {
137
+ if (shouldRenderPretty(c.agent)) {
138
+ printKeyValueTable('Default profile', [
139
+ { key: 'default', value: config.defaultProfile ?? '(not set)' },
140
+ ])
141
+ }
142
+
143
+ return {
144
+ defaultProfile: config.defaultProfile,
145
+ }
146
+ }
147
+
148
+ if (!config.profiles[c.args.profile]) {
149
+ throw new Error(`Profile "${c.args.profile}" not found.`)
150
+ }
151
+
152
+ config.defaultProfile = c.args.profile
153
+ await saveConfig(config)
154
+
155
+ if (shouldRenderPretty(c.agent)) {
156
+ printKeyValueTable('Default profile updated', [
157
+ { key: 'default', value: config.defaultProfile ?? '(not set)' },
158
+ ])
159
+ }
160
+
161
+ return {
162
+ updated: true,
163
+ defaultProfile: config.defaultProfile,
164
+ }
165
+ },
166
+ })
167
+
168
+ profileCli.command('delete', {
169
+ description: 'Delete a profile',
170
+ outputPolicy: 'agent-only',
171
+ args: z.object({
172
+ profile: z.string().describe('Profile name to delete'),
173
+ }),
174
+ async run(c) {
175
+ const config = await loadConfig()
176
+ const targetName = c.args.profile
177
+ const profile = config.profiles[targetName]
178
+
179
+ if (!profile) {
180
+ throw new Error(`Profile "${targetName}" not found.`)
181
+ }
182
+
183
+ delete config.profiles[targetName]
184
+
185
+ if (config.defaultProfile === targetName) {
186
+ const remaining = Object.keys(config.profiles).sort((a, b) => a.localeCompare(b))
187
+ config.defaultProfile = remaining[0] ?? null
188
+ }
189
+
190
+ await saveConfig(config)
191
+
192
+ if (shouldRenderPretty(c.agent)) {
193
+ printKeyValueTable('Profile deleted', [
194
+ { key: 'profile', value: targetName },
195
+ { key: 'provider', value: profile.type },
196
+ { key: 'default', value: config.defaultProfile ?? '(not set)' },
197
+ ])
198
+ }
199
+
200
+ return {
201
+ deleted: true,
202
+ profile: targetName,
203
+ provider: profile.type,
204
+ defaultProfile: config.defaultProfile,
205
+ }
206
+ },
207
+ })
208
+
209
+ profileCli.command('edit', {
210
+ description: 'Edit profile data',
211
+ outputPolicy: 'agent-only',
212
+ args: z.object({
213
+ profile: z.string().optional().describe('Existing profile name'),
214
+ }),
215
+ options: z.object({
216
+ newName: z.string().optional().describe('Rename profile to a new name'),
217
+ botToken: z.string().optional().describe('Replace Telegram bot token'),
218
+ chatId: z.string().optional().describe('Replace Telegram chat id'),
219
+ reconnect: z.boolean().optional().describe('Re-detect chat id from next Telegram message'),
220
+ }),
221
+ alias: {
222
+ newName: 'n',
223
+ botToken: 't',
224
+ chatId: 'c',
225
+ reconnect: 'r',
226
+ },
227
+ async run(c) {
228
+ const config = await loadConfig()
229
+ const profileNames = Object.keys(config.profiles).sort((a, b) => a.localeCompare(b))
230
+
231
+ if (profileNames.length === 0) {
232
+ throw new Error('No profiles found. Run `nnt profile add` first.')
233
+ }
234
+
235
+ const hasDirectEditOptions = Boolean(c.options.newName || c.options.botToken || c.options.chatId || c.options.reconnect)
236
+
237
+ const sourceName = await resolveProfileForEdit(c.args.profile, profileNames, hasDirectEditOptions)
238
+ const sourceProfile = config.profiles[sourceName]
239
+
240
+ if (!sourceProfile) {
241
+ throw new Error(`Profile "${sourceName}" not found.`)
242
+ }
243
+
244
+ const targetName = c.options.newName ?? sourceName
245
+
246
+ if (targetName !== sourceName && config.profiles[targetName]) {
247
+ throw new Error(`Profile "${targetName}" already exists.`)
248
+ }
249
+
250
+ const botToken = c.options.botToken ?? sourceProfile.botToken
251
+ let nextName = targetName
252
+ let nextBotToken = botToken
253
+ let chatId = c.options.chatId ?? sourceProfile.chatId
254
+ let connectedUsername: string | null = null
255
+
256
+ if (!hasDirectEditOptions && canPromptInteractively()) {
257
+ nextName = await askRequiredWithInitial('Profile name', sourceName)
258
+
259
+ if (nextName !== sourceName && config.profiles[nextName]) {
260
+ throw new Error(`Profile "${nextName}" already exists.`)
261
+ }
262
+
263
+ nextBotToken = await askRequiredWithInitial('Telegram bot token', sourceProfile.botToken)
264
+
265
+ const shouldReconnect = await askConfirm('Reconnect and detect chat_id from a new message?', false)
266
+
267
+ if (shouldReconnect) {
268
+ if (shouldRenderPretty(c.agent)) {
269
+ process.stdout.write('\nSend any message to your bot in Telegram.\n')
270
+ process.stdout.write('Waiting for message to detect chat_id (up to 120s)...\n')
271
+ }
272
+
273
+ const offset = await getLatestUpdateOffset(nextBotToken)
274
+ const connection = await waitForChatId(nextBotToken, offset, 120)
275
+ chatId = connection.chatId
276
+ connectedUsername = connection.username
277
+
278
+ if (shouldRenderPretty(c.agent)) {
279
+ if (connection.username) {
280
+ process.stdout.write(`Connected Telegram username: @${connection.username}\n`)
281
+ }
282
+ else {
283
+ process.stdout.write('Connected Telegram user has no username set.\n')
284
+ }
285
+ }
286
+ }
287
+ else {
288
+ chatId = await askRequiredWithInitial('Telegram chat_id', sourceProfile.chatId)
289
+ }
290
+ }
291
+
292
+ if (c.options.reconnect) {
293
+ if (shouldRenderPretty(c.agent)) {
294
+ process.stdout.write('\nSend any message to your bot in Telegram.\n')
295
+ process.stdout.write('Waiting for message to detect chat_id (up to 120s)...\n')
296
+ }
297
+
298
+ const offset = await getLatestUpdateOffset(nextBotToken)
299
+ const connection = await waitForChatId(nextBotToken, offset, 120)
300
+ chatId = connection.chatId
301
+ connectedUsername = connection.username
302
+
303
+ if (shouldRenderPretty(c.agent)) {
304
+ if (connection.username) {
305
+ process.stdout.write(`Connected Telegram username: @${connection.username}\n`)
306
+ }
307
+ else {
308
+ process.stdout.write('Connected Telegram user has no username set.\n')
309
+ }
310
+ }
311
+ }
312
+
313
+ const updatedProfile = {
314
+ ...sourceProfile,
315
+ name: nextName,
316
+ botToken: nextBotToken,
317
+ chatId,
318
+ }
319
+
320
+ if (nextName !== sourceName) {
321
+ delete config.profiles[sourceName]
322
+ }
323
+
324
+ config.profiles[nextName] = updatedProfile
325
+
326
+ if (config.defaultProfile === sourceName) {
327
+ config.defaultProfile = nextName
328
+ }
329
+
330
+ const hasChanges =
331
+ nextName !== sourceName
332
+ || nextBotToken !== sourceProfile.botToken
333
+ || chatId !== sourceProfile.chatId
334
+
335
+ if (!hasChanges) {
336
+ return {
337
+ updated: false,
338
+ profile: sourceName,
339
+ provider: sourceProfile.type,
340
+ defaultProfile: config.defaultProfile,
341
+ connectedUsername,
342
+ }
343
+ }
344
+
345
+ await saveConfig(config)
346
+
347
+ if (shouldRenderPretty(c.agent)) {
348
+ printKeyValueTable('Profile updated', [
349
+ { key: 'profile', value: nextName },
350
+ { key: 'provider', value: updatedProfile.type },
351
+ { key: 'chat_id', value: chatId },
352
+ { key: 'username', value: connectedUsername ? `@${connectedUsername}` : '(unchanged/none)' },
353
+ { key: 'default', value: config.defaultProfile ?? '(not set)' },
354
+ ])
355
+ }
356
+
357
+ return {
358
+ updated: true,
359
+ previousProfile: sourceName,
360
+ profile: nextName,
361
+ provider: updatedProfile.type,
362
+ defaultProfile: config.defaultProfile,
363
+ connectedUsername,
364
+ }
365
+ },
366
+ })
367
+
368
+ const cli = Cli.create('nnt', {
369
+ description: 'Send Telegram notifications from terminal and agents',
370
+ })
371
+ .command('send', {
372
+ description: 'Send a message via a saved profile',
373
+ args: z.object({
374
+ message: z.string().describe('Message text to send'),
375
+ }),
376
+ options: z.object({
377
+ profile: z.string().optional().describe('Profile name from config'),
378
+ }),
379
+ alias: {
380
+ profile: 'p',
381
+ },
382
+ async run(c) {
383
+ const config = await loadConfig()
384
+ const profileName = c.options.profile ?? config.defaultProfile
385
+
386
+ if (!profileName) {
387
+ throw new Error('No default profile found. Run `nnt profile add` first.')
388
+ }
389
+
390
+ const profile = config.profiles[profileName]
391
+
392
+ if (!profile) {
393
+ throw new Error(`Profile "${profileName}" not found.`)
394
+ }
395
+
396
+ await sendTelegramMessage(profile.botToken, profile.chatId, c.args.message)
397
+
398
+ return {
399
+ sent: true,
400
+ profile: profileName,
401
+ provider: profile.type,
402
+ }
403
+ },
404
+ })
405
+ .command(profileCli)
406
+
407
+ function routeDefaultCommand(argv: string[]): string[] {
408
+ if (argv.length === 0) {
409
+ return argv
410
+ }
411
+
412
+ const topLevelCommands = new Set(['profile', 'send', 'skills', 'mcp'])
413
+ const bareGlobalFlags = new Set(['--help', '-h', '--version', '--llms', '--mcp', '--json', '--verbose'])
414
+
415
+ let index = 0
416
+ while (index < argv.length) {
417
+ const token = argv[index]
418
+
419
+ if (token === '--format') {
420
+ index += 2
421
+ continue
422
+ }
423
+
424
+ if (bareGlobalFlags.has(token)) {
425
+ index += 1
426
+ continue
427
+ }
428
+
429
+ break
430
+ }
431
+
432
+ if (index >= argv.length) {
433
+ return argv
434
+ }
435
+
436
+ if (topLevelCommands.has(argv[index])) {
437
+ return argv
438
+ }
439
+
440
+ return [...argv.slice(0, index), 'send', ...argv.slice(index)]
441
+ }
442
+
443
+ function normalizeFormatFlag(argv: string[]): string[] {
444
+ const normalized: string[] = []
445
+
446
+ for (const token of argv) {
447
+ if (token.startsWith('--format=')) {
448
+ normalized.push('--format', token.slice('--format='.length))
449
+ continue
450
+ }
451
+
452
+ normalized.push(token)
453
+ }
454
+
455
+ return normalized
456
+ }
457
+
458
+ function isStrictOutputRequested(argv: string[]): boolean {
459
+ for (const token of argv) {
460
+ if (token === '--json' || token === '--verbose' || token === '--format') {
461
+ return true
462
+ }
463
+ }
464
+
465
+ return false
466
+ }
467
+
468
+ function isAgentEnvironment(): boolean {
469
+ return [
470
+ process.env.OPENCODE,
471
+ process.env.CLAUDECODE,
472
+ process.env.CURSOR_AGENT,
473
+ process.env.AIDER_SESSION,
474
+ process.env.NNT_AGENT_MODE,
475
+ ].some(Boolean)
476
+ }
477
+
478
+ function withAgentDefaultFormat(argv: string[], strictOutputRequested: boolean): string[] {
479
+ if (strictOutputRequested || !isAgentEnvironment()) {
480
+ return argv
481
+ }
482
+
483
+ return ['--format', 'toon', ...argv]
484
+ }
485
+
486
+ function canPromptInteractively(): boolean {
487
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY)
488
+ }
489
+
490
+ async function resolveProfileForEdit(
491
+ profileFromArgs: string | undefined,
492
+ profileNames: string[],
493
+ hasDirectEditOptions: boolean,
494
+ ): Promise<string> {
495
+ if (profileFromArgs) {
496
+ return profileFromArgs
497
+ }
498
+
499
+ if (hasDirectEditOptions || !canPromptInteractively()) {
500
+ throw new Error('Profile name is required in non-interactive mode.')
501
+ }
502
+
503
+ return askSelect('Select profile to edit', profileNames.map(name => ({
504
+ value: name,
505
+ label: name,
506
+ })))
507
+ }
508
+
509
+ const normalizedArgv = normalizeFormatFlag(process.argv.slice(2))
510
+ const strictOutputRequested = isStrictOutputRequested(normalizedArgv)
511
+ const argvWithAgentDefaults = withAgentDefaultFormat(normalizedArgv, strictOutputRequested)
512
+
513
+ function shouldRenderPretty(agent: boolean): boolean {
514
+ return !agent && !strictOutputRequested && !isAgentEnvironment()
515
+ }
516
+
517
+ const routedArgv = routeDefaultCommand(argvWithAgentDefaults)
518
+ await cli.serve(routedArgv)
519
+
520
+ export default cli
package/src/config.ts ADDED
@@ -0,0 +1,74 @@
1
+ import { homedir } from 'node:os'
2
+ import { join, resolve } from 'node:path'
3
+ import { mkdir, readFile, writeFile, chmod } from 'node:fs/promises'
4
+
5
+ export type TelegramProfile = {
6
+ type: 'telegram'
7
+ name: string
8
+ botToken: string
9
+ chatId: string
10
+ createdAt: string
11
+ }
12
+
13
+ export type NntConfig = {
14
+ defaultProfile: string | null
15
+ profiles: Record<string, TelegramProfile>
16
+ }
17
+
18
+ const DEFAULT_CONFIG: NntConfig = {
19
+ defaultProfile: null,
20
+ profiles: {},
21
+ }
22
+
23
+ export function getConfigDir(): string {
24
+ const dir = process.env.NNT_CONFIG_DIR
25
+ if (dir && dir.trim() !== '') {
26
+ return resolve(dir)
27
+ }
28
+
29
+ return join(homedir(), '.nnt')
30
+ }
31
+
32
+ export function getConfigPath(): string {
33
+ return join(getConfigDir(), 'config')
34
+ }
35
+
36
+ export async function loadConfig(): Promise<NntConfig> {
37
+ const path = getConfigPath()
38
+
39
+ try {
40
+ const raw = await readFile(path, 'utf8')
41
+ const parsed = JSON.parse(raw) as Partial<NntConfig>
42
+
43
+ return {
44
+ defaultProfile: typeof parsed.defaultProfile === 'string' ? parsed.defaultProfile : null,
45
+ profiles: parsed.profiles ?? {},
46
+ }
47
+ }
48
+ catch (error) {
49
+ if (isNodeError(error) && error.code === 'ENOENT') {
50
+ return { ...DEFAULT_CONFIG }
51
+ }
52
+
53
+ throw error
54
+ }
55
+ }
56
+
57
+ export async function saveConfig(config: NntConfig): Promise<void> {
58
+ const dir = getConfigDir()
59
+ const path = getConfigPath()
60
+
61
+ await mkdir(dir, { recursive: true })
62
+ await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
63
+
64
+ try {
65
+ await chmod(path, 0o600)
66
+ }
67
+ catch {
68
+ // Best effort only.
69
+ }
70
+ }
71
+
72
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
73
+ return typeof error === 'object' && error !== null && 'code' in error
74
+ }