@sillybit/opencode-plugin-everynotify 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/LICENSE +21 -0
- package/README.md +147 -0
- package/dist/config.d.ts +29 -0
- package/dist/dispatcher.d.ts +28 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +397 -0
- package/dist/services/discord.d.ts +17 -0
- package/dist/services/pushover.d.ts +17 -0
- package/dist/services/slack.d.ts +17 -0
- package/dist/services/telegram.d.ts +17 -0
- package/dist/types.d.ts +78 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sillybit
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# EveryNotify — Multi-Service Notifications for opencode
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/opencode-plugin-everynotify)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
EveryNotify is a lightweight, zero-dependency notification plugin for [opencode](https://github.com/opencode-ai/plugin) designed to keep you informed about your AI-driven development sessions. It dispatches notifications to multiple services simultaneously, ensuring you stay updated on task progress, errors, and interaction requests, even when you're not actively monitoring your terminal.
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
In long-running development tasks or deep research sessions, it's common to switch focus while opencode works in the background. EveryNotify bridges the gap between your terminal and your preferred notification device, allowing you to react quickly when opencode requires input or has finished its work.
|
|
11
|
+
|
|
12
|
+
### Why EveryNotify?
|
|
13
|
+
|
|
14
|
+
- **Multitasking Efficiency**: Walk away from your desk while opencode processes complex requests.
|
|
15
|
+
- **Immediate Awareness**: Get notified the instant an error occurs or a permission is requested.
|
|
16
|
+
- **Centralized Logs**: Use Slack, Discord, or Telegram as a secondary history of your opencode sessions.
|
|
17
|
+
- **Universal Reach**: Works across desktop and mobile through native service apps.
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- ✅ **4 Notification Services**: Native support for Pushover, Telegram, Slack, and Discord.
|
|
22
|
+
- ✅ **Automatic Event Detection**: Notifies on session completion, idle states, errors, and questions.
|
|
23
|
+
- ✅ **Rich Session Meta**: Notifications include the project name, session ID, and total elapsed time.
|
|
24
|
+
- ✅ **Intelligent Debouncing**: Prevents notification storms by aggregating repeated events within a 1-second window.
|
|
25
|
+
- ✅ **Fault Tolerance**: Isolated service calls ensure that a failure in one provider doesn't block others.
|
|
26
|
+
- ✅ **Zero Runtime Dependencies**: Built entirely on standard Node.js APIs and native `fetch()`.
|
|
27
|
+
- ✅ **Privacy & Control**: Completely opt-in; no notifications are sent until you enable and configure a service.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
Install EveryNotify into your opencode environment using npm:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install opencode-plugin-everynotify
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
EveryNotify utilizes a simple JSON configuration file named `.everynotify.json`. The plugin aggregates configuration from two potential scopes:
|
|
40
|
+
|
|
41
|
+
1. **Global Configuration**: `~/.config/opencode/.everynotify.json`
|
|
42
|
+
_Use this for your default tokens and webhook URLs across all projects._
|
|
43
|
+
2. **Project Configuration**: `.opencode/.everynotify.json` (inside your project directory)
|
|
44
|
+
_Use this to override settings or redirect notifications for a specific repository._
|
|
45
|
+
|
|
46
|
+
### Example Configuration
|
|
47
|
+
|
|
48
|
+
Create your `.everynotify.json` with the tokens for the services you want to use. You can enable multiple services at once.
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"pushover": {
|
|
53
|
+
"enabled": true,
|
|
54
|
+
"token": "KzG789...your_app_token",
|
|
55
|
+
"userKey": "uQi678...your_user_key",
|
|
56
|
+
"priority": 0
|
|
57
|
+
},
|
|
58
|
+
"telegram": {
|
|
59
|
+
"enabled": true,
|
|
60
|
+
"botToken": "123456:ABC-DEF...your_bot_token",
|
|
61
|
+
"chatId": "987654321"
|
|
62
|
+
},
|
|
63
|
+
"slack": {
|
|
64
|
+
"enabled": false,
|
|
65
|
+
"webhookUrl": "https://hooks.slack.com/services/T000/B000/XXXX"
|
|
66
|
+
},
|
|
67
|
+
"discord": {
|
|
68
|
+
"enabled": false,
|
|
69
|
+
"webhookUrl": "https://discord.com/api/webhooks/0000/XXXX"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Usage
|
|
75
|
+
|
|
76
|
+
EveryNotify is a "fire-and-forget" plugin. Once installed and configured, it requires no manual intervention. opencode will automatically detect and load the plugin, which then runs silently in the background.
|
|
77
|
+
|
|
78
|
+
When an event is triggered, EveryNotify builds a descriptive message (e.g., `[complete] my-project (elapsed: 12m 30s)`) and dispatches it to all services marked as `"enabled": true`.
|
|
79
|
+
|
|
80
|
+
## Supported Services
|
|
81
|
+
|
|
82
|
+
| Service | Requirements | Recommended For | Setup Link |
|
|
83
|
+
| ------------ | ------------------- | ------------------------------- | ------------------------------------------------------------------------- |
|
|
84
|
+
| **Pushover** | App Token, User Key | High-priority mobile alerts | [Pushover API](https://pushover.net/api) |
|
|
85
|
+
| **Telegram** | Bot Token, Chat ID | Instant messaging & groups | [Telegram Bots](https://core.telegram.org/bots) |
|
|
86
|
+
| **Slack** | Webhook URL | Team collaboration & logging | [Slack Webhooks](https://api.slack.com/messaging/webhooks) |
|
|
87
|
+
| **Discord** | Webhook URL | Community & server-based alerts | [Discord Webhooks](https://discord.com/developers/docs/resources/webhook) |
|
|
88
|
+
|
|
89
|
+
## Event Types
|
|
90
|
+
|
|
91
|
+
The plugin monitors several key lifecycle hooks within opencode:
|
|
92
|
+
|
|
93
|
+
- **`complete`**: Dispatched when a task is finished and the session becomes idle. Also triggers when a subagent completes its assigned task.
|
|
94
|
+
- **`error`**: Dispatched if the session crashes or encounters a fatal execution error. Includes the error message in the notification.
|
|
95
|
+
- **`permission`**: Dispatched when opencode pauses to ask for tool execution permissions or file access.
|
|
96
|
+
- **`question`**: Dispatched when opencode uses the `question` tool to seek clarification from the user.
|
|
97
|
+
|
|
98
|
+
## Advanced Features
|
|
99
|
+
|
|
100
|
+
### Priority Management (Pushover)
|
|
101
|
+
|
|
102
|
+
For Pushover users, you can customize the `priority` level:
|
|
103
|
+
|
|
104
|
+
- `-2`: Lowest priority (no notification)
|
|
105
|
+
- `-1`: Quiet notification
|
|
106
|
+
- `0`: Normal priority (default)
|
|
107
|
+
- `1`: High priority (bypasses quiet hours)
|
|
108
|
+
- `2`: Emergency priority (requires acknowledgment)
|
|
109
|
+
|
|
110
|
+
### Session Enrichment
|
|
111
|
+
|
|
112
|
+
EveryNotify automatically calculates the time elapsed since the first user message in a session. This duration is included in the notification text to give you context on how long the task took to complete.
|
|
113
|
+
|
|
114
|
+
### Project Overrides
|
|
115
|
+
|
|
116
|
+
If you are working on a sensitive or client-specific project, you can place a `.everynotify.json` file in the project's `.opencode/` directory to send notifications to a specific Slack channel or Telegram group, bypassing your global configuration.
|
|
117
|
+
|
|
118
|
+
## Development
|
|
119
|
+
|
|
120
|
+
Developers looking to extend EveryNotify or add new service providers should consult [AGENTS.md](./AGENTS.md).
|
|
121
|
+
|
|
122
|
+
### Build & Test Workflow
|
|
123
|
+
|
|
124
|
+
The project uses [Bun](https://bun.sh) for lightning-fast development:
|
|
125
|
+
|
|
126
|
+
- **Install Dependencies**: `bun install`
|
|
127
|
+
- **Build Plugin**: `bun run build`
|
|
128
|
+
- **Run Tests**: `bun test`
|
|
129
|
+
- **Type Check**: `bun run typecheck`
|
|
130
|
+
|
|
131
|
+
## Contributing
|
|
132
|
+
|
|
133
|
+
We welcome contributions of all kinds!
|
|
134
|
+
|
|
135
|
+
1. **Bug Reports**: Open an issue describing the bug and your environment.
|
|
136
|
+
2. **Feature Requests**: Propose new services or event hooks via issues.
|
|
137
|
+
3. **Pull Requests**: Follow the existing code style (2-space indent, strict TypeScript) and ensure all tests pass.
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
Distributed under the **MIT License**. See `LICENSE` for more information.
|
|
142
|
+
|
|
143
|
+
## Credits
|
|
144
|
+
|
|
145
|
+
Created by **[Sillybit](https://sillybit.io)** — Pixel Perfect Innovation.
|
|
146
|
+
|
|
147
|
+
Built with ❤️ for the opencode community.
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EveryNotify Plugin — Configuration Loader
|
|
3
|
+
*
|
|
4
|
+
* Loads configuration from:
|
|
5
|
+
* 1. Global: ~/.config/opencode/.everynotify.json
|
|
6
|
+
* 2. Project: .opencode/.everynotify.json (in project directory)
|
|
7
|
+
*
|
|
8
|
+
* Merge order: defaults ← global ← project (project wins)
|
|
9
|
+
* All services disabled by default.
|
|
10
|
+
*/
|
|
11
|
+
import type { EverynotifyConfig } from "./types";
|
|
12
|
+
/**
|
|
13
|
+
* Default configuration — all services disabled, empty credentials
|
|
14
|
+
*/
|
|
15
|
+
export declare const DEFAULT_CONFIG: EverynotifyConfig;
|
|
16
|
+
/**
|
|
17
|
+
* Get config file path for a given scope
|
|
18
|
+
* @param scope "global" or "project"
|
|
19
|
+
* @param directory project directory (used for project scope)
|
|
20
|
+
* @returns full path to config file
|
|
21
|
+
*/
|
|
22
|
+
export declare function getConfigPath(scope: "global" | "project", directory: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Load configuration from global and project scopes
|
|
25
|
+
* Merge order: defaults ← global ← project (project wins)
|
|
26
|
+
* @param directory project directory
|
|
27
|
+
* @returns merged EverynotifyConfig
|
|
28
|
+
*/
|
|
29
|
+
export declare function loadConfig(directory: string): EverynotifyConfig;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EveryNotify Plugin — Dispatcher
|
|
3
|
+
*
|
|
4
|
+
* Dispatches notifications to all enabled services in parallel with:
|
|
5
|
+
* - Debouncing: 1000ms per event type (prevents duplicate notifications)
|
|
6
|
+
* - Timeout: 5s per service call (via AbortController)
|
|
7
|
+
* - Error isolation: One service failing doesn't block others (Promise.allSettled)
|
|
8
|
+
*/
|
|
9
|
+
import type { EverynotifyConfig, NotificationPayload } from "./types";
|
|
10
|
+
/**
|
|
11
|
+
* Truncate text to max length, appending "… [truncated]" if over limit
|
|
12
|
+
* Shared utility function used by all services
|
|
13
|
+
*/
|
|
14
|
+
export declare function truncate(text: string, maxLength: number): string;
|
|
15
|
+
/**
|
|
16
|
+
* Dispatcher interface returned by createDispatcher
|
|
17
|
+
*/
|
|
18
|
+
interface Dispatcher {
|
|
19
|
+
dispatch: (payload: NotificationPayload) => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Create a dispatcher that sends notifications to all enabled services
|
|
23
|
+
*
|
|
24
|
+
* @param config - EverynotifyConfig with enabled services
|
|
25
|
+
* @returns Dispatcher with dispatch() function
|
|
26
|
+
*/
|
|
27
|
+
export declare function createDispatcher(config: EverynotifyConfig): Dispatcher;
|
|
28
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EveryNotify Plugin — Entry Point
|
|
3
|
+
*
|
|
4
|
+
* Implements the opencode plugin interface with three hooks:
|
|
5
|
+
* - event: Handles session.idle, session.error, permission.updated events
|
|
6
|
+
* - permission.ask: Handles permission requests (debounce handles overlap with event hook)
|
|
7
|
+
* - tool.execute.before: Detects question tool usage
|
|
8
|
+
*
|
|
9
|
+
* Session enrichment:
|
|
10
|
+
* - Calls client.session.get() to check parentID (subagent detection)
|
|
11
|
+
* - Calls client.session.messages() to calculate elapsed time
|
|
12
|
+
* - All SDK calls wrapped in try/catch with fallback to null
|
|
13
|
+
*/
|
|
14
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
15
|
+
/**
|
|
16
|
+
* EveryNotify Plugin
|
|
17
|
+
*
|
|
18
|
+
* @param input - PluginInput from opencode SDK
|
|
19
|
+
* @returns Hooks object with event, permission.ask, and tool.execute.before hooks
|
|
20
|
+
*/
|
|
21
|
+
declare const EverynotifyPlugin: Plugin;
|
|
22
|
+
export default EverynotifyPlugin;
|
|
23
|
+
export { EverynotifyPlugin };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import * as path2 from "path";
|
|
3
|
+
|
|
4
|
+
// src/config.ts
|
|
5
|
+
import * as fs from "fs";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import * as os from "os";
|
|
8
|
+
var DEFAULT_CONFIG = {
|
|
9
|
+
pushover: {
|
|
10
|
+
enabled: false,
|
|
11
|
+
token: "",
|
|
12
|
+
userKey: "",
|
|
13
|
+
priority: 0
|
|
14
|
+
},
|
|
15
|
+
telegram: {
|
|
16
|
+
enabled: false,
|
|
17
|
+
botToken: "",
|
|
18
|
+
chatId: ""
|
|
19
|
+
},
|
|
20
|
+
slack: {
|
|
21
|
+
enabled: false,
|
|
22
|
+
webhookUrl: ""
|
|
23
|
+
},
|
|
24
|
+
discord: {
|
|
25
|
+
enabled: false,
|
|
26
|
+
webhookUrl: ""
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
function getConfigPath(scope, directory) {
|
|
30
|
+
if (scope === "global") {
|
|
31
|
+
return path.join(os.homedir(), ".config", "opencode", ".everynotify.json");
|
|
32
|
+
}
|
|
33
|
+
return path.join(directory, ".opencode", ".everynotify.json");
|
|
34
|
+
}
|
|
35
|
+
function deepMerge(target, source) {
|
|
36
|
+
const result = { ...target };
|
|
37
|
+
if (source.pushover) {
|
|
38
|
+
result.pushover = { ...result.pushover, ...source.pushover };
|
|
39
|
+
}
|
|
40
|
+
if (source.telegram) {
|
|
41
|
+
result.telegram = { ...result.telegram, ...source.telegram };
|
|
42
|
+
}
|
|
43
|
+
if (source.slack) {
|
|
44
|
+
result.slack = { ...result.slack, ...source.slack };
|
|
45
|
+
}
|
|
46
|
+
if (source.discord) {
|
|
47
|
+
result.discord = { ...result.discord, ...source.discord };
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
function loadConfigFile(filePath) {
|
|
52
|
+
try {
|
|
53
|
+
if (!fs.existsSync(filePath)) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
57
|
+
const parsed = JSON.parse(content);
|
|
58
|
+
return parsed;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
61
|
+
console.error(`[EveryNotify] Failed to load config from ${filePath}: ${errorMsg}`);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function loadConfig(directory) {
|
|
66
|
+
let config = { ...DEFAULT_CONFIG };
|
|
67
|
+
const globalPath = getConfigPath("global", directory);
|
|
68
|
+
const globalConfig = loadConfigFile(globalPath);
|
|
69
|
+
if (globalConfig) {
|
|
70
|
+
config = deepMerge(config, globalConfig);
|
|
71
|
+
}
|
|
72
|
+
const projectPath = getConfigPath("project", directory);
|
|
73
|
+
const projectConfig = loadConfigFile(projectPath);
|
|
74
|
+
if (projectConfig) {
|
|
75
|
+
config = deepMerge(config, projectConfig);
|
|
76
|
+
}
|
|
77
|
+
const allDisabled = !config.pushover.enabled && !config.telegram.enabled && !config.slack.enabled && !config.discord.enabled;
|
|
78
|
+
if (allDisabled) {
|
|
79
|
+
console.error("[EveryNotify] No services configured. Enable services in .everynotify.json");
|
|
80
|
+
}
|
|
81
|
+
return config;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/services/pushover.ts
|
|
85
|
+
function truncate(text, maxLength) {
|
|
86
|
+
if (text.length <= maxLength) {
|
|
87
|
+
return text;
|
|
88
|
+
}
|
|
89
|
+
const suffix = "… [truncated]";
|
|
90
|
+
return text.slice(0, maxLength - suffix.length) + suffix;
|
|
91
|
+
}
|
|
92
|
+
async function send(config, payload, signal) {
|
|
93
|
+
try {
|
|
94
|
+
const body = new URLSearchParams({
|
|
95
|
+
token: config.token,
|
|
96
|
+
user: config.userKey,
|
|
97
|
+
message: truncate(payload.message, 1024),
|
|
98
|
+
title: truncate(payload.title, 250),
|
|
99
|
+
priority: String(config.priority ?? 0)
|
|
100
|
+
});
|
|
101
|
+
const response = await fetch("https://api.pushover.net/1/messages.json", {
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers: {
|
|
104
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
105
|
+
},
|
|
106
|
+
body: body.toString(),
|
|
107
|
+
signal
|
|
108
|
+
});
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
const errorText = await response.text();
|
|
111
|
+
console.error(`[EveryNotify] Pushover error: ${response.status} ${errorText}`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
116
|
+
console.error(`[EveryNotify] Pushover failed: ${message}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// src/services/telegram.ts
|
|
121
|
+
function truncate2(text, maxLength) {
|
|
122
|
+
if (text.length <= maxLength) {
|
|
123
|
+
return text;
|
|
124
|
+
}
|
|
125
|
+
const suffix = "… [truncated]";
|
|
126
|
+
return text.slice(0, maxLength - suffix.length) + suffix;
|
|
127
|
+
}
|
|
128
|
+
function formatMessage(payload) {
|
|
129
|
+
const truncatedTitle = truncate2(payload.title, 250);
|
|
130
|
+
const truncatedMessage = truncate2(payload.message, 3840);
|
|
131
|
+
return `<b>${truncatedTitle}</b>
|
|
132
|
+
${truncatedMessage}`;
|
|
133
|
+
}
|
|
134
|
+
async function send2(config, payload, signal) {
|
|
135
|
+
try {
|
|
136
|
+
const text = formatMessage(payload);
|
|
137
|
+
const body = JSON.stringify({
|
|
138
|
+
chat_id: config.chatId,
|
|
139
|
+
text,
|
|
140
|
+
parse_mode: "HTML"
|
|
141
|
+
});
|
|
142
|
+
const response = await fetch(`https://api.telegram.org/bot${config.botToken}/sendMessage`, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: {
|
|
145
|
+
"Content-Type": "application/json"
|
|
146
|
+
},
|
|
147
|
+
body,
|
|
148
|
+
signal
|
|
149
|
+
});
|
|
150
|
+
if (!response.ok) {
|
|
151
|
+
const errorText = await response.text();
|
|
152
|
+
console.error(`[EveryNotify] Telegram error: ${response.status} ${errorText}`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
} catch (error) {
|
|
156
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
157
|
+
console.error(`[EveryNotify] Telegram failed: ${message}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// src/services/slack.ts
|
|
162
|
+
function truncate3(text, maxLength) {
|
|
163
|
+
if (text.length <= maxLength) {
|
|
164
|
+
return text;
|
|
165
|
+
}
|
|
166
|
+
const suffix = "… [truncated]";
|
|
167
|
+
return text.slice(0, maxLength - suffix.length) + suffix;
|
|
168
|
+
}
|
|
169
|
+
function formatSlackMessage(payload) {
|
|
170
|
+
return `*${payload.title}*
|
|
171
|
+
${payload.message}`;
|
|
172
|
+
}
|
|
173
|
+
async function send3(config, payload, signal) {
|
|
174
|
+
try {
|
|
175
|
+
const text = truncate3(formatSlackMessage(payload), 40000);
|
|
176
|
+
const response = await fetch(config.webhookUrl, {
|
|
177
|
+
method: "POST",
|
|
178
|
+
headers: {
|
|
179
|
+
"Content-Type": "application/json"
|
|
180
|
+
},
|
|
181
|
+
body: JSON.stringify({ text }),
|
|
182
|
+
signal
|
|
183
|
+
});
|
|
184
|
+
if (!response.ok) {
|
|
185
|
+
const errorText = await response.text();
|
|
186
|
+
console.error(`[EveryNotify] Slack error: ${response.status} ${errorText}`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
} catch (error) {
|
|
190
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
191
|
+
console.error(`[EveryNotify] Slack failed: ${message}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/services/discord.ts
|
|
196
|
+
function truncate4(text, maxLength) {
|
|
197
|
+
if (text.length <= maxLength) {
|
|
198
|
+
return text;
|
|
199
|
+
}
|
|
200
|
+
const suffix = "… [truncated]";
|
|
201
|
+
return text.slice(0, maxLength - suffix.length) + suffix;
|
|
202
|
+
}
|
|
203
|
+
function formatDiscordMessage(payload) {
|
|
204
|
+
return `**${payload.title}**
|
|
205
|
+
${payload.message}`;
|
|
206
|
+
}
|
|
207
|
+
async function send4(config, payload, signal) {
|
|
208
|
+
try {
|
|
209
|
+
const content = truncate4(formatDiscordMessage(payload), 2000);
|
|
210
|
+
const response = await fetch(config.webhookUrl, {
|
|
211
|
+
method: "POST",
|
|
212
|
+
headers: {
|
|
213
|
+
"Content-Type": "application/json"
|
|
214
|
+
},
|
|
215
|
+
body: JSON.stringify({ content }),
|
|
216
|
+
signal
|
|
217
|
+
});
|
|
218
|
+
if (response.status === 429) {
|
|
219
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
220
|
+
console.error(`[EveryNotify] Discord rate limited. Retry-After: ${retryAfter}s`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (!response.ok) {
|
|
224
|
+
const errorText = await response.text();
|
|
225
|
+
console.error(`[EveryNotify] Discord error: ${response.status} ${errorText}`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
} catch (error) {
|
|
229
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
230
|
+
console.error(`[EveryNotify] Discord failed: ${message}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// src/dispatcher.ts
|
|
235
|
+
function createDispatcher(config) {
|
|
236
|
+
const services = [];
|
|
237
|
+
if (config.pushover.enabled) {
|
|
238
|
+
services.push({
|
|
239
|
+
name: "Pushover",
|
|
240
|
+
send,
|
|
241
|
+
config: config.pushover
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
if (config.telegram.enabled) {
|
|
245
|
+
services.push({
|
|
246
|
+
name: "Telegram",
|
|
247
|
+
send: send2,
|
|
248
|
+
config: config.telegram
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
if (config.slack.enabled) {
|
|
252
|
+
services.push({
|
|
253
|
+
name: "Slack",
|
|
254
|
+
send: send3,
|
|
255
|
+
config: config.slack
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (config.discord.enabled) {
|
|
259
|
+
services.push({
|
|
260
|
+
name: "Discord",
|
|
261
|
+
send: send4,
|
|
262
|
+
config: config.discord
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
const lastDispatchTime = new Map;
|
|
266
|
+
async function dispatch(payload) {
|
|
267
|
+
const now = Date.now();
|
|
268
|
+
const lastTime = lastDispatchTime.get(payload.eventType) ?? 0;
|
|
269
|
+
if (now - lastTime < 1000) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
lastDispatchTime.set(payload.eventType, now);
|
|
273
|
+
if (services.length === 0) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const promises = services.map(async (service) => {
|
|
277
|
+
const controller = new AbortController;
|
|
278
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
279
|
+
try {
|
|
280
|
+
await service.send(service.config, payload, controller.signal);
|
|
281
|
+
} finally {
|
|
282
|
+
clearTimeout(timeoutId);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
const results = await Promise.allSettled(promises);
|
|
286
|
+
results.forEach((result, index) => {
|
|
287
|
+
if (result.status === "rejected") {
|
|
288
|
+
console.error(`[EveryNotify] ${services[index].name} failed:`, result.reason);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return { dispatch };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/index.ts
|
|
296
|
+
var EverynotifyPlugin = async (input) => {
|
|
297
|
+
const { client, directory } = input;
|
|
298
|
+
const config = loadConfig(directory);
|
|
299
|
+
const { dispatch } = createDispatcher(config);
|
|
300
|
+
async function buildPayload(eventType, sessionID, extraMessage) {
|
|
301
|
+
const projectName = directory ? path2.basename(directory) : null;
|
|
302
|
+
let elapsedSeconds = null;
|
|
303
|
+
let isSubagent = false;
|
|
304
|
+
try {
|
|
305
|
+
if (sessionID) {
|
|
306
|
+
const sessionResult = await client.session.get({
|
|
307
|
+
path: { id: sessionID }
|
|
308
|
+
});
|
|
309
|
+
if (sessionResult.data?.parentID) {
|
|
310
|
+
isSubagent = true;
|
|
311
|
+
}
|
|
312
|
+
const messagesResult = await client.session.messages({
|
|
313
|
+
path: { id: sessionID }
|
|
314
|
+
});
|
|
315
|
+
const messages = messagesResult.data;
|
|
316
|
+
if (messages && messages.length > 0) {
|
|
317
|
+
const firstUserMessage = messages.find((msg) => msg.info?.role === "user");
|
|
318
|
+
if (firstUserMessage?.info?.time?.created) {
|
|
319
|
+
const startTime = new Date(firstUserMessage.info.time.created).getTime();
|
|
320
|
+
const now = Date.now();
|
|
321
|
+
elapsedSeconds = Math.floor((now - startTime) / 1000);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
} catch (error) {}
|
|
326
|
+
let finalEventType = eventType;
|
|
327
|
+
if (eventType === "complete" && isSubagent) {
|
|
328
|
+
finalEventType = "subagent_complete";
|
|
329
|
+
}
|
|
330
|
+
const title = `[${finalEventType}] ${projectName || "opencode"}`;
|
|
331
|
+
let message = extraMessage || "Event occurred";
|
|
332
|
+
if (elapsedSeconds !== null) {
|
|
333
|
+
const minutes = Math.floor(elapsedSeconds / 60);
|
|
334
|
+
const seconds = elapsedSeconds % 60;
|
|
335
|
+
message += ` (elapsed: ${minutes}m ${seconds}s)`;
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
eventType: finalEventType,
|
|
339
|
+
title,
|
|
340
|
+
message,
|
|
341
|
+
projectName,
|
|
342
|
+
timestamp: Date.now(),
|
|
343
|
+
sessionID,
|
|
344
|
+
elapsedSeconds
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
async function eventHook({ event }) {
|
|
348
|
+
try {
|
|
349
|
+
const sessionID = event.properties?.sessionID || null;
|
|
350
|
+
if (event.type === "session.idle") {
|
|
351
|
+
const payload = await buildPayload("complete", sessionID);
|
|
352
|
+
await dispatch(payload);
|
|
353
|
+
} else if (event.type === "session.error") {
|
|
354
|
+
const rawError = event.properties?.error;
|
|
355
|
+
const errorMessage = rawError?.data?.message ?? rawError?.name ?? "Unknown error";
|
|
356
|
+
const payload = await buildPayload("error", sessionID, errorMessage);
|
|
357
|
+
await dispatch(payload);
|
|
358
|
+
} else if (event.type === "permission.updated") {
|
|
359
|
+
const payload = await buildPayload("permission", sessionID);
|
|
360
|
+
await dispatch(payload);
|
|
361
|
+
}
|
|
362
|
+
} catch (error) {
|
|
363
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
364
|
+
console.error(`[EveryNotify] Event hook error: ${errorMsg}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
async function permissionAskHook(_input, _output) {
|
|
368
|
+
try {
|
|
369
|
+
const payload = await buildPayload("permission", null);
|
|
370
|
+
await dispatch(payload);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
373
|
+
console.error(`[EveryNotify] Permission.ask hook error: ${errorMsg}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
async function toolExecuteBeforeHook(input2, _output) {
|
|
377
|
+
try {
|
|
378
|
+
if (input2.tool === "question") {
|
|
379
|
+
const payload = await buildPayload("question", null);
|
|
380
|
+
await dispatch(payload);
|
|
381
|
+
}
|
|
382
|
+
} catch (error) {
|
|
383
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
384
|
+
console.error(`[EveryNotify] Tool.execute.before hook error: ${errorMsg}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
event: eventHook,
|
|
389
|
+
"permission.ask": permissionAskHook,
|
|
390
|
+
"tool.execute.before": toolExecuteBeforeHook
|
|
391
|
+
};
|
|
392
|
+
};
|
|
393
|
+
var src_default = EverynotifyPlugin;
|
|
394
|
+
export {
|
|
395
|
+
src_default as default,
|
|
396
|
+
EverynotifyPlugin
|
|
397
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord notification service
|
|
3
|
+
* API: https://discord.com/developers/docs/resources/webhook#execute-webhook
|
|
4
|
+
*
|
|
5
|
+
* CRITICAL: Uses application/json
|
|
6
|
+
* Message limit: 2000 chars (Discord webhook content field limit)
|
|
7
|
+
* Rate limit: 10 requests per 10 seconds per webhook
|
|
8
|
+
*/
|
|
9
|
+
import type { DiscordConfig, NotificationPayload } from "../types";
|
|
10
|
+
/**
|
|
11
|
+
* Send notification via Discord webhook API
|
|
12
|
+
*
|
|
13
|
+
* @param config - Discord configuration (webhookUrl)
|
|
14
|
+
* @param payload - Notification payload (title, message, etc.)
|
|
15
|
+
* @param signal - AbortSignal for timeout control
|
|
16
|
+
*/
|
|
17
|
+
export declare function send(config: DiscordConfig, payload: NotificationPayload, signal: AbortSignal): Promise<void>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pushover notification service
|
|
3
|
+
* API: https://pushover.net/api
|
|
4
|
+
*
|
|
5
|
+
* CRITICAL: Uses application/x-www-form-urlencoded (NOT JSON)
|
|
6
|
+
* Message limit: 1024 chars
|
|
7
|
+
* Title limit: 250 chars
|
|
8
|
+
*/
|
|
9
|
+
import type { PushoverConfig, NotificationPayload } from "../types";
|
|
10
|
+
/**
|
|
11
|
+
* Send notification via Pushover API
|
|
12
|
+
*
|
|
13
|
+
* @param config - Pushover configuration (token, userKey, priority)
|
|
14
|
+
* @param payload - Notification payload (title, message, etc.)
|
|
15
|
+
* @param signal - AbortSignal for timeout control
|
|
16
|
+
*/
|
|
17
|
+
export declare function send(config: PushoverConfig, payload: NotificationPayload, signal: AbortSignal): Promise<void>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack notification service
|
|
3
|
+
* API: https://api.slack.com/messaging/webhooks
|
|
4
|
+
*
|
|
5
|
+
* Uses Incoming Webhooks with JSON POST
|
|
6
|
+
* Message limit: 40000 chars (Slack text field limit)
|
|
7
|
+
* Formatting: mrkdwn (Slack markdown)
|
|
8
|
+
*/
|
|
9
|
+
import type { SlackConfig, NotificationPayload } from "../types";
|
|
10
|
+
/**
|
|
11
|
+
* Send notification via Slack Incoming Webhook
|
|
12
|
+
*
|
|
13
|
+
* @param config - Slack configuration (webhookUrl)
|
|
14
|
+
* @param payload - Notification payload (title, message, etc.)
|
|
15
|
+
* @param signal - AbortSignal for timeout control
|
|
16
|
+
*/
|
|
17
|
+
export declare function send(config: SlackConfig, payload: NotificationPayload, signal: AbortSignal): Promise<void>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram notification service
|
|
3
|
+
* API: https://core.telegram.org/bots/api#sendmessage
|
|
4
|
+
*
|
|
5
|
+
* CRITICAL: Uses application/json (JSON body)
|
|
6
|
+
* Message limit: 4096 chars
|
|
7
|
+
* Supports HTML formatting via parse_mode: "HTML"
|
|
8
|
+
*/
|
|
9
|
+
import type { TelegramConfig, NotificationPayload } from "../types";
|
|
10
|
+
/**
|
|
11
|
+
* Send notification via Telegram Bot API
|
|
12
|
+
*
|
|
13
|
+
* @param config - Telegram configuration (botToken, chatId)
|
|
14
|
+
* @param payload - Notification payload (title, message, etc.)
|
|
15
|
+
* @param signal - AbortSignal for timeout control
|
|
16
|
+
*/
|
|
17
|
+
export declare function send(config: TelegramConfig, payload: NotificationPayload, signal: AbortSignal): Promise<void>;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EveryNotify Plugin — Shared Type Definitions
|
|
3
|
+
*
|
|
4
|
+
* All types for notification payloads, service configurations, and dispatch functions.
|
|
5
|
+
* No validation logic here — types only.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Event types that trigger notifications
|
|
9
|
+
*/
|
|
10
|
+
export type EventType = "complete" | "subagent_complete" | "error" | "permission" | "question";
|
|
11
|
+
/**
|
|
12
|
+
* Notification payload sent to all enabled services
|
|
13
|
+
*/
|
|
14
|
+
export interface NotificationPayload {
|
|
15
|
+
eventType: EventType;
|
|
16
|
+
title: string;
|
|
17
|
+
message: string;
|
|
18
|
+
projectName: string | null;
|
|
19
|
+
timestamp: number;
|
|
20
|
+
sessionID: string | null;
|
|
21
|
+
elapsedSeconds: number | null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Pushover service configuration
|
|
25
|
+
* API: https://pushover.net/api
|
|
26
|
+
* - token: 30-character app token
|
|
27
|
+
* - userKey: 30-character user key
|
|
28
|
+
* - priority: optional priority level (-2 to 2)
|
|
29
|
+
*/
|
|
30
|
+
export interface PushoverConfig {
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
token: string;
|
|
33
|
+
userKey: string;
|
|
34
|
+
priority?: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Telegram service configuration
|
|
38
|
+
* API: https://core.telegram.org/bots/api#sendmessage
|
|
39
|
+
* - botToken: bot token from BotFather
|
|
40
|
+
* - chatId: target chat ID
|
|
41
|
+
*/
|
|
42
|
+
export interface TelegramConfig {
|
|
43
|
+
enabled: boolean;
|
|
44
|
+
botToken: string;
|
|
45
|
+
chatId: string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Slack service configuration
|
|
49
|
+
* API: https://api.slack.com/messaging/webhooks
|
|
50
|
+
* - webhookUrl: incoming webhook URL
|
|
51
|
+
*/
|
|
52
|
+
export interface SlackConfig {
|
|
53
|
+
enabled: boolean;
|
|
54
|
+
webhookUrl: string;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Discord service configuration
|
|
58
|
+
* API: https://discord.com/developers/docs/resources/webhook#execute-webhook
|
|
59
|
+
* - webhookUrl: webhook URL
|
|
60
|
+
*/
|
|
61
|
+
export interface DiscordConfig {
|
|
62
|
+
enabled: boolean;
|
|
63
|
+
webhookUrl: string;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Top-level configuration object containing all service configs
|
|
67
|
+
*/
|
|
68
|
+
export interface EverynotifyConfig {
|
|
69
|
+
pushover: PushoverConfig;
|
|
70
|
+
telegram: TelegramConfig;
|
|
71
|
+
slack: SlackConfig;
|
|
72
|
+
discord: DiscordConfig;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Function signature for service send functions
|
|
76
|
+
* Each service implements this interface
|
|
77
|
+
*/
|
|
78
|
+
export type ServiceSendFunction = (config: any, payload: NotificationPayload, signal: AbortSignal) => Promise<void>;
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sillybit/opencode-plugin-everynotify",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Multi-service notification plugin for opencode — Pushover, Telegram, Slack, Discord",
|
|
5
|
+
"author": "Sillybit <https://sillybit.io>",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/sillybit-io/opencode-plugin-everynotify.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/sillybit-io/opencode-plugin-everynotify/issues"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/sillybit-io/opencode-plugin-everynotify#readme",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "dist/index.js",
|
|
16
|
+
"types": "dist/index.d.ts",
|
|
17
|
+
"files": [
|
|
18
|
+
"dist"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "bun build src/index.ts --outdir dist --target node && tsc --emitDeclarationOnly",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"test": "bun test",
|
|
24
|
+
"prepublishOnly": "bun run build"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@opencode-ai/plugin": "^1.1.53",
|
|
28
|
+
"@types/node": "^22.0.0",
|
|
29
|
+
"typescript": "^5.9.3"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@opencode-ai/plugin": ">=1.1.53"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"opencode",
|
|
36
|
+
"plugin",
|
|
37
|
+
"notification",
|
|
38
|
+
"pushover",
|
|
39
|
+
"telegram",
|
|
40
|
+
"slack",
|
|
41
|
+
"discord"
|
|
42
|
+
],
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public",
|
|
46
|
+
"provenance": true
|
|
47
|
+
}
|
|
48
|
+
}
|