opencode-pilot 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.
- package/.devcontainer/devcontainer.json +16 -0
- package/.github/workflows/ci.yml +67 -0
- package/.releaserc.cjs +28 -0
- package/AGENTS.md +71 -0
- package/CONTRIBUTING.md +102 -0
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/bin/opencode-pilot +809 -0
- package/dist/opencode-ntfy.tar.gz +0 -0
- package/examples/config.yaml +73 -0
- package/examples/templates/default.md +7 -0
- package/examples/templates/devcontainer.md +7 -0
- package/examples/templates/review-feedback.md +7 -0
- package/examples/templates/review.md +15 -0
- package/install.sh +246 -0
- package/package.json +40 -0
- package/plugin/config.js +76 -0
- package/plugin/index.js +260 -0
- package/plugin/logger.js +125 -0
- package/plugin/notifier.js +110 -0
- package/service/actions.js +334 -0
- package/service/io.opencode.ntfy.plist +29 -0
- package/service/logger.js +82 -0
- package/service/poll-service.js +246 -0
- package/service/poller.js +339 -0
- package/service/readiness.js +234 -0
- package/service/repo-config.js +222 -0
- package/service/server.js +1523 -0
- package/service/utils.js +21 -0
- package/test/run_tests.bash +34 -0
- package/test/test_actions.bash +263 -0
- package/test/test_cli.bash +161 -0
- package/test/test_config.bash +438 -0
- package/test/test_helper.bash +140 -0
- package/test/test_logger.bash +401 -0
- package/test/test_notifier.bash +310 -0
- package/test/test_plist.bash +125 -0
- package/test/test_plugin.bash +952 -0
- package/test/test_poll_service.bash +179 -0
- package/test/test_poller.bash +120 -0
- package/test/test_readiness.bash +313 -0
- package/test/test_repo_config.bash +406 -0
- package/test/test_service.bash +1342 -0
- package/test/unit/actions.test.js +235 -0
- package/test/unit/config.test.js +86 -0
- package/test/unit/paths.test.js +77 -0
- package/test/unit/poll-service.test.js +142 -0
- package/test/unit/poller.test.js +347 -0
- package/test/unit/repo-config.test.js +441 -0
- package/test/unit/utils.test.js +53 -0
package/plugin/index.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// opencode-pilot - ntfy notification plugin for OpenCode
|
|
2
|
+
//
|
|
3
|
+
// This plugin sends notifications via ntfy.sh when:
|
|
4
|
+
// - Session goes idle after delay
|
|
5
|
+
// - Errors or retries occur
|
|
6
|
+
//
|
|
7
|
+
// Configuration via ~/.config/opencode-pilot/config.yaml
|
|
8
|
+
// See README.md for full configuration options.
|
|
9
|
+
|
|
10
|
+
import { basename } from 'path'
|
|
11
|
+
import { sendNotification } from './notifier.js'
|
|
12
|
+
import { loadConfig } from './config.js'
|
|
13
|
+
import { initLogger, debug } from './logger.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse directory path to extract repo name (and branch if in devcontainer clone)
|
|
17
|
+
*
|
|
18
|
+
* Devcontainer clone paths follow the pattern:
|
|
19
|
+
* /path/.cache/devcontainer-clones/{repo}/{branch}
|
|
20
|
+
*
|
|
21
|
+
* For these paths, returns "{repo}/{branch}" to show both in notifications.
|
|
22
|
+
* For regular paths, returns just the basename.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} directory - Directory path from OpenCode
|
|
25
|
+
* @returns {string} Repo name (with branch suffix for devcontainer clones)
|
|
26
|
+
*/
|
|
27
|
+
function parseRepoInfo(directory) {
|
|
28
|
+
if (!directory) {
|
|
29
|
+
return 'unknown'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Check for devcontainer-clones path pattern
|
|
33
|
+
const devcontainerMatch = directory.match(/devcontainer-clones\/([^/]+)\/([^/]+)$/)
|
|
34
|
+
if (devcontainerMatch) {
|
|
35
|
+
const [, repo, branch] = devcontainerMatch
|
|
36
|
+
return `${repo}/${branch}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Fall back to basename for regular directories
|
|
40
|
+
return basename(directory) || 'unknown'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Load configuration from config file and environment
|
|
44
|
+
const config = loadConfig()
|
|
45
|
+
|
|
46
|
+
// Initialize debug logger (writes to file when enabled, no-op when disabled)
|
|
47
|
+
initLogger({ debug: config.debug, debugPath: config.debugPath })
|
|
48
|
+
|
|
49
|
+
const Notify = async ({ client, directory }) => {
|
|
50
|
+
if (!config.topic) {
|
|
51
|
+
debug('Plugin disabled: no topic configured')
|
|
52
|
+
return {}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Use directory from OpenCode (the actual repo), not process.cwd() (may be temp devcontainer dir)
|
|
56
|
+
// For devcontainer clones, show both repo and branch name
|
|
57
|
+
const repoName = parseRepoInfo(directory)
|
|
58
|
+
|
|
59
|
+
debug(`Plugin initialized: topic=${config.topic}, repo=${repoName}`)
|
|
60
|
+
|
|
61
|
+
// Per-conversation state tracking (Issue #34)
|
|
62
|
+
// Each conversation has its own idle timer, cancel state, etc.
|
|
63
|
+
// Key: conversationId (OpenCode session ID)
|
|
64
|
+
const conversations = new Map()
|
|
65
|
+
|
|
66
|
+
// Session ownership verification (Issue #50)
|
|
67
|
+
// Prevents duplicate notifications when multiple OpenCode instances are running.
|
|
68
|
+
// Events may be broadcast to all plugin instances, so we verify each session
|
|
69
|
+
// belongs to our OpenCode instance before processing.
|
|
70
|
+
const verifiedSessions = new Set() // Sessions confirmed to be ours
|
|
71
|
+
const rejectedSessions = new Set() // Sessions confirmed to be foreign
|
|
72
|
+
|
|
73
|
+
// Global state (shared across all conversations in this plugin instance)
|
|
74
|
+
let retryCount = 0
|
|
75
|
+
let lastErrorTime = 0
|
|
76
|
+
|
|
77
|
+
// Helper to get or create conversation state (Issue #34)
|
|
78
|
+
const getConversation = (id) => {
|
|
79
|
+
if (!id) return null
|
|
80
|
+
if (!conversations.has(id)) {
|
|
81
|
+
conversations.set(id, { idleTimer: null, wasCanceled: false })
|
|
82
|
+
}
|
|
83
|
+
return conversations.get(id)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Verify session ownership by checking if it exists in our OpenCode instance (Issue #50)
|
|
87
|
+
// Returns true if session belongs to us, false if it's foreign
|
|
88
|
+
const verifySessionOwnership = async (sessionId) => {
|
|
89
|
+
if (!sessionId) return false
|
|
90
|
+
|
|
91
|
+
// Already verified as ours
|
|
92
|
+
if (verifiedSessions.has(sessionId)) return true
|
|
93
|
+
|
|
94
|
+
// Already rejected as foreign
|
|
95
|
+
if (rejectedSessions.has(sessionId)) return false
|
|
96
|
+
|
|
97
|
+
// Query our OpenCode instance to check if session exists
|
|
98
|
+
try {
|
|
99
|
+
const result = await client.session.get({ id: sessionId })
|
|
100
|
+
if (result.data) {
|
|
101
|
+
verifiedSessions.add(sessionId)
|
|
102
|
+
return true
|
|
103
|
+
}
|
|
104
|
+
// Session doesn't exist in our instance - it's foreign
|
|
105
|
+
rejectedSessions.add(sessionId)
|
|
106
|
+
return false
|
|
107
|
+
} catch {
|
|
108
|
+
// Error (likely 404) means session doesn't exist in our instance
|
|
109
|
+
rejectedSessions.add(sessionId)
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
event: async ({ event }) => {
|
|
116
|
+
debug(`Event: ${event.type}`)
|
|
117
|
+
|
|
118
|
+
// Handle session status events (idle, busy, retry notifications)
|
|
119
|
+
if (event.type === 'session.status') {
|
|
120
|
+
const status = event.properties?.status?.type
|
|
121
|
+
// Extract conversation ID from event for per-conversation tracking (Issue #34)
|
|
122
|
+
// OpenCode uses sessionID (capital ID)
|
|
123
|
+
const conversationId = event.properties?.sessionID || event.properties?.info?.id
|
|
124
|
+
debug(`Event: session.status, status=${status}, sessionId=${conversationId || 'unknown'}`)
|
|
125
|
+
|
|
126
|
+
// Verify session belongs to our OpenCode instance (Issue #50)
|
|
127
|
+
// Skip events for sessions from other OpenCode instances to prevent duplicates
|
|
128
|
+
const isOurs = await verifySessionOwnership(conversationId)
|
|
129
|
+
if (!isOurs) return
|
|
130
|
+
|
|
131
|
+
const conv = getConversation(conversationId)
|
|
132
|
+
|
|
133
|
+
// Skip if no conversation ID or already canceled
|
|
134
|
+
if (!conv) return
|
|
135
|
+
if (conv.wasCanceled && status !== 'canceled') return
|
|
136
|
+
|
|
137
|
+
// Handle canceled status - suppress all future notifications for this conversation
|
|
138
|
+
if (status === 'canceled') {
|
|
139
|
+
conv.wasCanceled = true
|
|
140
|
+
if (conv.idleTimer) {
|
|
141
|
+
clearTimeout(conv.idleTimer)
|
|
142
|
+
conv.idleTimer = null
|
|
143
|
+
}
|
|
144
|
+
// Clean up conversation state
|
|
145
|
+
conversations.delete(conversationId)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Handle retry status
|
|
150
|
+
if (status === 'retry') {
|
|
151
|
+
retryCount++
|
|
152
|
+
|
|
153
|
+
// Check if we should notify based on config
|
|
154
|
+
const shouldNotifyFirst = config.retryNotifyFirst && retryCount === 1
|
|
155
|
+
const shouldNotifyAfterN = config.retryNotifyAfter > 0 && retryCount === config.retryNotifyAfter
|
|
156
|
+
|
|
157
|
+
if (shouldNotifyFirst || shouldNotifyAfterN) {
|
|
158
|
+
await sendNotification({
|
|
159
|
+
server: config.server,
|
|
160
|
+
topic: config.topic,
|
|
161
|
+
title: `Retry (${repoName})`,
|
|
162
|
+
message: `Retry attempt #${retryCount}`,
|
|
163
|
+
priority: 4,
|
|
164
|
+
tags: ['repeat'],
|
|
165
|
+
authToken: config.authToken,
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Reset retry counter on any non-retry status
|
|
172
|
+
if (retryCount > 0) {
|
|
173
|
+
retryCount = 0
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Handle idle status - set timer for THIS conversation
|
|
177
|
+
if (status === 'idle' && !conv.idleTimer) {
|
|
178
|
+
debug(`Idle timer starting: ${config.idleDelayMs}ms for session ${conversationId}`)
|
|
179
|
+
// Capture conversation ID in closure so notification goes to correct conversation
|
|
180
|
+
const capturedSessionId = conversationId
|
|
181
|
+
|
|
182
|
+
conv.idleTimer = setTimeout(async () => {
|
|
183
|
+
// Clear timer reference immediately to prevent race conditions
|
|
184
|
+
const currentConv = conversations.get(capturedSessionId)
|
|
185
|
+
if (currentConv) {
|
|
186
|
+
currentConv.idleTimer = null
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Don't send notification if conversation was canceled
|
|
190
|
+
if (currentConv?.wasCanceled) {
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await sendNotification({
|
|
195
|
+
server: config.server,
|
|
196
|
+
topic: config.topic,
|
|
197
|
+
title: `Idle (${repoName})`,
|
|
198
|
+
message: 'Session waiting for input',
|
|
199
|
+
authToken: config.authToken,
|
|
200
|
+
})
|
|
201
|
+
}, config.idleDelayMs)
|
|
202
|
+
} else if (status === 'busy' && conv.idleTimer) {
|
|
203
|
+
debug(`Idle timer cancelled: session ${conversationId} now busy`)
|
|
204
|
+
clearTimeout(conv.idleTimer)
|
|
205
|
+
conv.idleTimer = null
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Handle session.error events (debounced error notifications)
|
|
210
|
+
if (event.type === 'session.error') {
|
|
211
|
+
if (!config.errorNotify) {
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const now = Date.now()
|
|
216
|
+
const timeSinceLastError = now - lastErrorTime
|
|
217
|
+
|
|
218
|
+
if (lastErrorTime === 0 || timeSinceLastError >= config.errorDebounceMs) {
|
|
219
|
+
lastErrorTime = now
|
|
220
|
+
|
|
221
|
+
// Extract error message with fallback chain
|
|
222
|
+
const errorMessage =
|
|
223
|
+
event.properties?.error?.message ||
|
|
224
|
+
event.properties?.message ||
|
|
225
|
+
event.properties?.error?.code ||
|
|
226
|
+
event.properties?.error?.type ||
|
|
227
|
+
'Unknown error'
|
|
228
|
+
|
|
229
|
+
await sendNotification({
|
|
230
|
+
server: config.server,
|
|
231
|
+
topic: config.topic,
|
|
232
|
+
title: `Error (${repoName})`,
|
|
233
|
+
message: errorMessage,
|
|
234
|
+
priority: 5,
|
|
235
|
+
tags: ['warning'],
|
|
236
|
+
authToken: config.authToken,
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
// Cleanup on shutdown
|
|
243
|
+
shutdown: async () => {
|
|
244
|
+
// Clear all conversation idle timers
|
|
245
|
+
for (const [, conv] of conversations) {
|
|
246
|
+
if (conv.idleTimer) {
|
|
247
|
+
clearTimeout(conv.idleTimer)
|
|
248
|
+
conv.idleTimer = null
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
conversations.clear()
|
|
252
|
+
|
|
253
|
+
// Clear session ownership caches (Issue #50)
|
|
254
|
+
verifiedSessions.clear()
|
|
255
|
+
rejectedSessions.clear()
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export default Notify
|
package/plugin/logger.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Debug logging module for opencode-pilot plugin
|
|
2
|
+
// Writes to ~/.config/opencode-pilot/debug.log when enabled via NTFY_DEBUG=true or config.debug
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// import { initLogger, debug } from './logger.js'
|
|
6
|
+
// initLogger({ debug: true, debugPath: '/custom/path.log' })
|
|
7
|
+
// debug('Event received', { type: 'session.status', status: 'idle' })
|
|
8
|
+
|
|
9
|
+
import { appendFileSync, existsSync, mkdirSync, statSync, unlinkSync } from 'fs'
|
|
10
|
+
import { join, dirname } from 'path'
|
|
11
|
+
import { homedir } from 'os'
|
|
12
|
+
|
|
13
|
+
// Maximum log file size before rotation (1MB)
|
|
14
|
+
export const MAX_LOG_SIZE = 1024 * 1024
|
|
15
|
+
|
|
16
|
+
// Default log path
|
|
17
|
+
const DEFAULT_LOG_PATH = join(homedir(), '.config', 'opencode-pilot', 'debug.log')
|
|
18
|
+
|
|
19
|
+
// Module state
|
|
20
|
+
let enabled = false
|
|
21
|
+
let logPath = DEFAULT_LOG_PATH
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Initialize the logger with configuration
|
|
25
|
+
* @param {Object} options
|
|
26
|
+
* @param {boolean} [options.debug] - Enable debug logging
|
|
27
|
+
* @param {string} [options.debugPath] - Custom log file path
|
|
28
|
+
*/
|
|
29
|
+
export function initLogger(options = {}) {
|
|
30
|
+
// Check environment variables first, then options
|
|
31
|
+
const envDebug = process.env.NTFY_DEBUG
|
|
32
|
+
const envDebugPath = process.env.NTFY_DEBUG_PATH
|
|
33
|
+
|
|
34
|
+
// Enable if NTFY_DEBUG is set to any truthy value (not 'false' or '0')
|
|
35
|
+
if (envDebug !== undefined && envDebug !== '' && envDebug !== 'false' && envDebug !== '0') {
|
|
36
|
+
enabled = true
|
|
37
|
+
} else if (options.debug !== undefined) {
|
|
38
|
+
enabled = Boolean(options.debug)
|
|
39
|
+
} else {
|
|
40
|
+
enabled = false
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Set log path (env var takes precedence)
|
|
44
|
+
if (envDebugPath) {
|
|
45
|
+
logPath = envDebugPath
|
|
46
|
+
} else if (options.debugPath) {
|
|
47
|
+
logPath = options.debugPath
|
|
48
|
+
} else {
|
|
49
|
+
logPath = DEFAULT_LOG_PATH
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Create directory if it doesn't exist
|
|
53
|
+
if (enabled) {
|
|
54
|
+
try {
|
|
55
|
+
const dir = dirname(logPath)
|
|
56
|
+
if (!existsSync(dir)) {
|
|
57
|
+
mkdirSync(dir, { recursive: true })
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// Silently ignore directory creation errors
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Write a debug log entry
|
|
67
|
+
* @param {string} message - Log message
|
|
68
|
+
* @param {Object} [data] - Optional data to include
|
|
69
|
+
*/
|
|
70
|
+
export function debug(message, data) {
|
|
71
|
+
if (!enabled) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
// Check file size and rotate if needed
|
|
77
|
+
rotateIfNeeded()
|
|
78
|
+
|
|
79
|
+
// Format log entry with ISO 8601 timestamp
|
|
80
|
+
const timestamp = new Date().toISOString()
|
|
81
|
+
let entry = `[${timestamp}] ${message}`
|
|
82
|
+
|
|
83
|
+
// Append data if provided
|
|
84
|
+
if (data !== undefined) {
|
|
85
|
+
if (typeof data === 'object') {
|
|
86
|
+
entry += ' ' + JSON.stringify(data)
|
|
87
|
+
} else {
|
|
88
|
+
entry += ' ' + String(data)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
entry += '\n'
|
|
93
|
+
|
|
94
|
+
// Ensure directory exists
|
|
95
|
+
const dir = dirname(logPath)
|
|
96
|
+
if (!existsSync(dir)) {
|
|
97
|
+
mkdirSync(dir, { recursive: true })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Append to log file
|
|
101
|
+
appendFileSync(logPath, entry)
|
|
102
|
+
} catch {
|
|
103
|
+
// Silently ignore write errors to avoid affecting the plugin
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Rotate log file if it exceeds MAX_LOG_SIZE
|
|
109
|
+
*/
|
|
110
|
+
function rotateIfNeeded() {
|
|
111
|
+
try {
|
|
112
|
+
if (!existsSync(logPath)) {
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const stats = statSync(logPath)
|
|
117
|
+
if (stats.size > MAX_LOG_SIZE) {
|
|
118
|
+
// Simple rotation: just truncate the file
|
|
119
|
+
// For more sophisticated rotation, could rename to .old first
|
|
120
|
+
unlinkSync(logPath)
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// Silently ignore rotation errors
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// ntfy HTTP client for sending notifications
|
|
2
|
+
|
|
3
|
+
import { debug } from './logger.js'
|
|
4
|
+
|
|
5
|
+
// Deduplication cache: track recently sent notifications to prevent duplicates
|
|
6
|
+
// Key: hash of notification content, Value: timestamp
|
|
7
|
+
const recentNotifications = new Map()
|
|
8
|
+
const DEDUPE_WINDOW_MS = 5000 // 5 seconds
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate a simple hash for deduplication
|
|
12
|
+
* @param {string} str - String to hash
|
|
13
|
+
* @returns {string} Simple hash
|
|
14
|
+
*/
|
|
15
|
+
function simpleHash(str) {
|
|
16
|
+
let hash = 0
|
|
17
|
+
for (let i = 0; i < str.length; i++) {
|
|
18
|
+
const char = str.charCodeAt(i)
|
|
19
|
+
hash = ((hash << 5) - hash) + char
|
|
20
|
+
hash = hash & hash // Convert to 32bit integer
|
|
21
|
+
}
|
|
22
|
+
return hash.toString(36)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check if notification was recently sent (for deduplication)
|
|
27
|
+
* @param {string} key - Deduplication key
|
|
28
|
+
* @returns {boolean} True if duplicate
|
|
29
|
+
*/
|
|
30
|
+
function isDuplicate(key) {
|
|
31
|
+
const now = Date.now()
|
|
32
|
+
|
|
33
|
+
// Clean up old entries
|
|
34
|
+
for (const [k, timestamp] of recentNotifications) {
|
|
35
|
+
if (now - timestamp > DEDUPE_WINDOW_MS) {
|
|
36
|
+
recentNotifications.delete(k)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (recentNotifications.has(key)) {
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
recentNotifications.set(key, now)
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build headers for ntfy requests
|
|
50
|
+
* @param {string} [authToken] - Optional ntfy access token for Bearer auth
|
|
51
|
+
* @returns {Object} Headers object
|
|
52
|
+
*/
|
|
53
|
+
function buildHeaders(authToken) {
|
|
54
|
+
const headers = {
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (authToken) {
|
|
59
|
+
headers['Authorization'] = `Bearer ${authToken}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return headers
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Send a basic notification to ntfy
|
|
67
|
+
* @param {Object} options
|
|
68
|
+
* @param {string} options.server - ntfy server URL
|
|
69
|
+
* @param {string} options.topic - ntfy topic name
|
|
70
|
+
* @param {string} options.title - Notification title
|
|
71
|
+
* @param {string} options.message - Notification message
|
|
72
|
+
* @param {number} [options.priority] - Priority (1-5, default 3)
|
|
73
|
+
* @param {string[]} [options.tags] - Emoji tags
|
|
74
|
+
* @param {string} [options.authToken] - Optional ntfy access token for protected topics
|
|
75
|
+
*/
|
|
76
|
+
export async function sendNotification({ server, topic, title, message, priority, tags, authToken }) {
|
|
77
|
+
// Deduplicate: skip if same notification sent recently
|
|
78
|
+
const dedupeKey = simpleHash(`${topic}:${title}:${message}`)
|
|
79
|
+
if (isDuplicate(dedupeKey)) {
|
|
80
|
+
debug(`Notification skipped (duplicate): ${title}`)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const body = {
|
|
85
|
+
topic,
|
|
86
|
+
title,
|
|
87
|
+
message,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Add optional fields only if provided
|
|
91
|
+
if (priority !== undefined) {
|
|
92
|
+
body.priority = priority
|
|
93
|
+
}
|
|
94
|
+
if (tags && tags.length > 0) {
|
|
95
|
+
body.tags = tags
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
debug(`Notification sending: ${title}`)
|
|
100
|
+
const response = await fetch(server, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: buildHeaders(authToken),
|
|
103
|
+
body: JSON.stringify(body),
|
|
104
|
+
})
|
|
105
|
+
debug(`Notification sent: ${title} (status=${response.status})`)
|
|
106
|
+
} catch (error) {
|
|
107
|
+
debug(`Notification failed: ${title} (error=${error.message})`)
|
|
108
|
+
// Silently ignore - errors here shouldn't affect the user
|
|
109
|
+
}
|
|
110
|
+
}
|