@ulthon/ul-opencode-event 0.1.0 → 0.1.1
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 +8 -0
- package/dist/config.d.ts +1 -1
- package/dist/config.js +151 -0
- package/dist/email.d.ts +1 -1
- package/dist/email.js +56 -0
- package/dist/handler.d.ts +1 -1
- package/dist/handler.js +78 -0
- package/dist/index.js +101 -10061
- package/dist/template.js +9 -0
- package/dist/types.js +5 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -142,6 +142,14 @@ You can configure multiple channels, and all enabled channels will receive notif
|
|
|
142
142
|
1. Check if `enabled: true` is set
|
|
143
143
|
2. Check if the event type is enabled in `events`
|
|
144
144
|
3. Check SMTP credentials are correct
|
|
145
|
+
4. Ensure plugin config file exists at `.opencode/ul-opencode-event.json`, `./ul-opencode-event.json`, or `~/.config/opencode/ul-opencode-event.json`
|
|
146
|
+
5. Enable debug logs with environment variable `UL_OPENCODE_EVENT_DEBUG=1`
|
|
147
|
+
|
|
148
|
+
### How to view logs
|
|
149
|
+
|
|
150
|
+
- Start OpenCode from terminal to view stdout/stderr logs
|
|
151
|
+
- Plugin logs use prefix `[ul-opencode-event]`
|
|
152
|
+
- You will see errors for JSON parse failure, invalid config shape, and event handling failures
|
|
145
153
|
|
|
146
154
|
### SMTP authentication failed
|
|
147
155
|
|
package/dist/config.d.ts
CHANGED
package/dist/config.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
const SUPPORTED_CHANNEL_TYPES = ['smtp'];
|
|
5
|
+
const LOG_PREFIX = '[ul-opencode-event]';
|
|
6
|
+
const DEBUG_ENABLED = process.env.UL_OPENCODE_EVENT_DEBUG === '1' || process.env.UL_OPENCODE_EVENT_DEBUG === 'true';
|
|
7
|
+
/**
|
|
8
|
+
* Default configuration returned when no config files exist
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_CONFIG = {
|
|
11
|
+
channels: [],
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Load and parse a JSON config file if it exists
|
|
15
|
+
*/
|
|
16
|
+
function loadConfigFile(filePath) {
|
|
17
|
+
try {
|
|
18
|
+
if (!fs.existsSync(filePath)) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
22
|
+
const config = JSON.parse(content);
|
|
23
|
+
if (!Array.isArray(config.channels)) {
|
|
24
|
+
console.error(`${LOG_PREFIX} Invalid config format in ${filePath}: "channels" must be an array`);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (DEBUG_ENABLED) {
|
|
28
|
+
console.info(`${LOG_PREFIX} Loaded config: ${filePath} (${config.channels.length} channels)`);
|
|
29
|
+
}
|
|
30
|
+
return config;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
console.error(`${LOG_PREFIX} Failed to parse config file: ${filePath}`, error);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Validate that all channel types are supported
|
|
39
|
+
* @throws Error if any channel has an unsupported type
|
|
40
|
+
*/
|
|
41
|
+
function validateChannelTypes(channels) {
|
|
42
|
+
for (const channel of channels) {
|
|
43
|
+
if (!SUPPORTED_CHANNEL_TYPES.includes(channel.type)) {
|
|
44
|
+
throw new Error(`Unsupported channel type: "${channel.type}". Supported types: ${SUPPORTED_CHANNEL_TYPES.join(', ')}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Deep merge channels by name
|
|
50
|
+
* - If project channel name matches global channel name, project overrides global
|
|
51
|
+
* - If no name match, project channels are appended to global channels
|
|
52
|
+
*/
|
|
53
|
+
function mergeChannels(globalChannels, projectChannels) {
|
|
54
|
+
if (!globalChannels?.length && !projectChannels?.length) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
if (!globalChannels?.length) {
|
|
58
|
+
return projectChannels ?? [];
|
|
59
|
+
}
|
|
60
|
+
if (!projectChannels?.length) {
|
|
61
|
+
return globalChannels;
|
|
62
|
+
}
|
|
63
|
+
const result = [...globalChannels];
|
|
64
|
+
for (const projectChannel of projectChannels) {
|
|
65
|
+
const existingIndex = result.findIndex((c) => c.name !== undefined && c.name === projectChannel.name);
|
|
66
|
+
if (existingIndex >= 0) {
|
|
67
|
+
// Override global channel with project channel (same name)
|
|
68
|
+
result[existingIndex] = projectChannel;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
// Append project channel (no name match)
|
|
72
|
+
result.push(projectChannel);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Filter channels to only include enabled ones
|
|
79
|
+
* A channel is enabled if enabled is true or undefined (defaults to true)
|
|
80
|
+
*/
|
|
81
|
+
function filterEnabledChannels(channels) {
|
|
82
|
+
return channels.filter((channel) => channel.enabled !== false);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Get global config file path: ~/.config/opencode/ul-opencode-event.json
|
|
86
|
+
*/
|
|
87
|
+
function getGlobalConfigPath() {
|
|
88
|
+
return path.join(os.homedir(), '.config', 'opencode', 'ul-opencode-event.json');
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Get project config file path
|
|
92
|
+
* Priority: .opencode/ul-opencode-event.json > ./ul-opencode-event.json
|
|
93
|
+
*/
|
|
94
|
+
function getProjectConfigPath() {
|
|
95
|
+
const opencodePath = path.join(process.cwd(), '.opencode', 'ul-opencode-event.json');
|
|
96
|
+
if (fs.existsSync(opencodePath)) {
|
|
97
|
+
return opencodePath;
|
|
98
|
+
}
|
|
99
|
+
const localPath = path.join(process.cwd(), 'ul-opencode-event.json');
|
|
100
|
+
if (fs.existsSync(localPath)) {
|
|
101
|
+
return localPath;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Load and merge notification configuration
|
|
107
|
+
*
|
|
108
|
+
* Config locations (in order of priority):
|
|
109
|
+
* 1. Project-level: .opencode/ul-opencode-event.json or ./ul-opencode-event.json
|
|
110
|
+
* 2. Global: ~/.config/opencode/ul-opencode-event.json
|
|
111
|
+
*
|
|
112
|
+
* Merge strategy:
|
|
113
|
+
* - Channels are merged by `name` field
|
|
114
|
+
* - If names match, project channel overrides global channel
|
|
115
|
+
* - If no name match, project channels are appended to global channels
|
|
116
|
+
*
|
|
117
|
+
* Filtering:
|
|
118
|
+
* - Only returns channels where enabled is true (or undefined, which defaults to true)
|
|
119
|
+
* - Channels with enabled: false are excluded
|
|
120
|
+
*
|
|
121
|
+
* Validation:
|
|
122
|
+
* - Channel type must be 'smtp' (only supported type in phase 1)
|
|
123
|
+
* - Throws clear error for unsupported channel types
|
|
124
|
+
*
|
|
125
|
+
* @returns Merged and filtered configuration, or default config if no files exist
|
|
126
|
+
* @throws Error if any channel has an unsupported type
|
|
127
|
+
*/
|
|
128
|
+
export function loadConfig() {
|
|
129
|
+
// Load global config
|
|
130
|
+
const globalConfigPath = getGlobalConfigPath();
|
|
131
|
+
const globalConfig = loadConfigFile(globalConfigPath);
|
|
132
|
+
// Load project config
|
|
133
|
+
const projectConfigPath = getProjectConfigPath();
|
|
134
|
+
const projectConfig = projectConfigPath ? loadConfigFile(projectConfigPath) : null;
|
|
135
|
+
// If no configs exist, return default
|
|
136
|
+
if (!globalConfig && !projectConfig) {
|
|
137
|
+
if (DEBUG_ENABLED) {
|
|
138
|
+
console.info(`${LOG_PREFIX} No config found. Checked: ${globalConfigPath}, ${path.join(process.cwd(), '.opencode', 'ul-opencode-event.json')}, ${path.join(process.cwd(), 'ul-opencode-event.json')}`);
|
|
139
|
+
}
|
|
140
|
+
return DEFAULT_CONFIG;
|
|
141
|
+
}
|
|
142
|
+
// Merge channels
|
|
143
|
+
const mergedChannels = mergeChannels(globalConfig?.channels, projectConfig?.channels);
|
|
144
|
+
// Validate channel types
|
|
145
|
+
validateChannelTypes(mergedChannels);
|
|
146
|
+
// Filter enabled channels
|
|
147
|
+
const enabledChannels = filterEnabledChannels(mergedChannels);
|
|
148
|
+
return {
|
|
149
|
+
channels: enabledChannels,
|
|
150
|
+
};
|
|
151
|
+
}
|
package/dist/email.d.ts
CHANGED
package/dist/email.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email sender module for SMTP-based notifications
|
|
3
|
+
*/
|
|
4
|
+
import * as nodemailer from 'nodemailer';
|
|
5
|
+
/**
|
|
6
|
+
* Creates an email sender for the given SMTP channel
|
|
7
|
+
* @param channel - The channel configuration (must be SMTP type)
|
|
8
|
+
* @returns EmailSender instance or null if channel is invalid/disabled
|
|
9
|
+
*/
|
|
10
|
+
export function createEmailSender(channel) {
|
|
11
|
+
// Type guard: return null for non-smtp channels
|
|
12
|
+
if (channel.type !== 'smtp') {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
// Return null for disabled channels
|
|
16
|
+
if (channel.enabled === false) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
// Return null if no recipients configured
|
|
20
|
+
if (!channel.recipients || channel.recipients.length === 0) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const config = channel.config;
|
|
24
|
+
const fromName = channel.name || 'Notification';
|
|
25
|
+
// Create nodemailer transport with SMTP config
|
|
26
|
+
const transport = nodemailer.createTransport({
|
|
27
|
+
host: config.host,
|
|
28
|
+
port: config.port,
|
|
29
|
+
secure: config.secure,
|
|
30
|
+
auth: {
|
|
31
|
+
user: config.auth.user,
|
|
32
|
+
pass: config.auth.pass,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
// Set security options
|
|
36
|
+
transport.set('disableFileAccess', true);
|
|
37
|
+
transport.set('disableUrlAccess', true);
|
|
38
|
+
return {
|
|
39
|
+
async send(subject, body) {
|
|
40
|
+
try {
|
|
41
|
+
await transport.sendMail({
|
|
42
|
+
from: `"${fromName}" <${config.auth.user}>`,
|
|
43
|
+
to: channel.recipients.join(', '),
|
|
44
|
+
subject,
|
|
45
|
+
html: body,
|
|
46
|
+
text: body,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
// Silent failure: log error but don't throw
|
|
51
|
+
const timestamp = new Date().toISOString();
|
|
52
|
+
console.error(`[${timestamp}] [${fromName}] Failed to send email:`, error);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
package/dist/handler.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Multi-channel event handler for notifications
|
|
3
3
|
*/
|
|
4
|
-
import type { NotificationConfig, EventPayload, EventType } from './types';
|
|
4
|
+
import type { NotificationConfig, EventPayload, EventType } from './types.js';
|
|
5
5
|
export interface EventHandler {
|
|
6
6
|
handle: (eventType: EventType, payload: EventPayload) => Promise<void>;
|
|
7
7
|
}
|
package/dist/handler.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { renderTemplate } from './template.js';
|
|
2
|
+
import { createEmailSender } from './email.js';
|
|
3
|
+
// Default templates when channel doesn't have custom templates
|
|
4
|
+
const defaultTemplates = {
|
|
5
|
+
created: {
|
|
6
|
+
subject: '[OpenCode] Task Started - {{projectName}}',
|
|
7
|
+
body: 'Task started at {{timestamp}}\nProject: {{projectName}}\nSession: {{sessionId}}',
|
|
8
|
+
},
|
|
9
|
+
idle: {
|
|
10
|
+
subject: '[OpenCode] Task Completed - {{projectName}}',
|
|
11
|
+
body: 'Task completed at {{timestamp}}\nDuration: {{duration}}\nMessage: {{message}}',
|
|
12
|
+
},
|
|
13
|
+
error: {
|
|
14
|
+
subject: '[OpenCode] Task Error - {{projectName}}',
|
|
15
|
+
body: 'Error at {{timestamp}}\nError: {{error}}',
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Creates an event handler that dispatches notifications to all enabled channels
|
|
20
|
+
*/
|
|
21
|
+
export function createEventHandler(config) {
|
|
22
|
+
return {
|
|
23
|
+
handle: async (eventType, payload) => {
|
|
24
|
+
// Process each channel
|
|
25
|
+
for (const channel of config.channels) {
|
|
26
|
+
// Skip disabled channels
|
|
27
|
+
if (channel.enabled === false) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
// Check if this event type is enabled for the channel
|
|
31
|
+
const eventEnabled = channel.events[eventType];
|
|
32
|
+
if (eventEnabled === false) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
// Get template (channel-specific or default)
|
|
36
|
+
const template = channel.templates?.[eventType] ?? defaultTemplates[eventType];
|
|
37
|
+
// Prepare template variables from payload
|
|
38
|
+
const variables = {
|
|
39
|
+
eventType: payload.eventType,
|
|
40
|
+
timestamp: payload.timestamp,
|
|
41
|
+
projectName: payload.projectName,
|
|
42
|
+
sessionId: payload.sessionId,
|
|
43
|
+
message: payload.message,
|
|
44
|
+
error: payload.error,
|
|
45
|
+
duration: payload.duration,
|
|
46
|
+
};
|
|
47
|
+
// Render templates
|
|
48
|
+
const subject = template.subject ? renderTemplate(template.subject, variables) : '';
|
|
49
|
+
const body = template.body ? renderTemplate(template.body, variables) : '';
|
|
50
|
+
// Dispatch based on channel type
|
|
51
|
+
await dispatchToChannel(channel, subject, body);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Dispatch notification to the appropriate channel sender
|
|
58
|
+
*/
|
|
59
|
+
async function dispatchToChannel(channel, subject, body) {
|
|
60
|
+
switch (channel.type) {
|
|
61
|
+
case 'smtp': {
|
|
62
|
+
const sender = createEmailSender(channel);
|
|
63
|
+
if (sender) {
|
|
64
|
+
await sender.send(subject, body);
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
case 'feishu':
|
|
69
|
+
case 'dingtalk':
|
|
70
|
+
// Skip unsupported channel types silently
|
|
71
|
+
break;
|
|
72
|
+
default: {
|
|
73
|
+
// Log warning for unknown channel types
|
|
74
|
+
const _exhaustiveCheck = channel.type;
|
|
75
|
+
console.warn(`[EventHandler] Unsupported channel type: ${_exhaustiveCheck}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|