opencode-plugin-apprise 1.0.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/LICENSE +21 -0
- package/README.md +186 -0
- package/dist/config.d.ts +10 -0
- package/dist/dedup.d.ts +6 -0
- package/dist/formatter.d.ts +8 -0
- package/dist/hooks/background.d.ts +4 -0
- package/dist/hooks/idle.d.ts +4 -0
- package/dist/hooks/permission.d.ts +10 -0
- package/dist/hooks/question.d.ts +8 -0
- package/dist/hooks/shared.d.ts +4 -0
- package/dist/index.d.ts +3 -0
- package/dist/notifier.d.ts +8 -0
- package/dist/opencode-plugin-apprise.js +421 -0
- package/dist/types.d.ts +25 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 opencode-plugin-apprise contributors
|
|
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,186 @@
|
|
|
1
|
+
# opencode-plugin-apprise
|
|
2
|
+
|
|
3
|
+
OpenCode plugin for multi-service notifications via Apprise.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Multi-service support for 128+ notification services via Apprise.
|
|
8
|
+
- Automatic notifications when sessions go idle.
|
|
9
|
+
- Delayed notifications for Question tool prompts (30-second grace period).
|
|
10
|
+
- Alerts when sessions transition to idle after activity.
|
|
11
|
+
- Notifications for permission requests with dual-mechanism reliability.
|
|
12
|
+
|
|
13
|
+
## Prerequisites
|
|
14
|
+
|
|
15
|
+
- OpenCode
|
|
16
|
+
- Python 3.x
|
|
17
|
+
- Apprise (`pip install apprise`)
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
1. Install the plugin by adding it to your `opencode.json` plugin array:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
"plugins": ["opencode-plugin-apprise"]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or use the CLI:
|
|
28
|
+
`opencode plugins add opencode-plugin-apprise`
|
|
29
|
+
|
|
30
|
+
2. Configure Apprise with your notification URLs in a default Apprise config file such as `~/.apprise`, `~/.apprise.yml`, or `~/.config/apprise/apprise.yml`.
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
|
|
34
|
+
```yaml
|
|
35
|
+
# ~/.config/apprise/apprise.yml
|
|
36
|
+
urls:
|
|
37
|
+
- slack://TokenA/TokenB/TokenC
|
|
38
|
+
- discord://webhook_id/webhook_token
|
|
39
|
+
- tgram://bottoken/ChatID
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
3. Restart OpenCode — the plugin will automatically detect Apprise and use your configured services.
|
|
43
|
+
|
|
44
|
+
## Configuration
|
|
45
|
+
|
|
46
|
+
The plugin relies on Apprise's default configuration file behavior.
|
|
47
|
+
|
|
48
|
+
### Apprise Config File Locations
|
|
49
|
+
|
|
50
|
+
Apprise automatically looks for config files in these locations (in order):
|
|
51
|
+
|
|
52
|
+
- `~/.apprise`
|
|
53
|
+
- `~/.apprise.yml`
|
|
54
|
+
- `~/.config/apprise/apprise.yml`
|
|
55
|
+
|
|
56
|
+
For complete configuration options, see: https://github.com/caronc/apprise#configuration-file
|
|
57
|
+
|
|
58
|
+
### Environment Variables
|
|
59
|
+
|
|
60
|
+
| Variable | Required | Description |
|
|
61
|
+
|----------|----------|-------------|
|
|
62
|
+
| `OPENCODE_NOTIFY_TAG` | No | Apprise tag for filtering which configured services receive notifications. When set, only services matching this tag in your Apprise config will be notified. |
|
|
63
|
+
|
|
64
|
+
### Behavior Defaults
|
|
65
|
+
|
|
66
|
+
| Setting | Value |
|
|
67
|
+
|---------|:------|
|
|
68
|
+
| Maximum message length | 1,500 characters |
|
|
69
|
+
| Deduplication TTL | 5 minutes (max 100 entries) |
|
|
70
|
+
| Question notification delay | 30 seconds |
|
|
71
|
+
| Apprise CLI timeout | 30 seconds |
|
|
72
|
+
|
|
73
|
+
## Notification Triggers
|
|
74
|
+
|
|
75
|
+
### Idle
|
|
76
|
+
|
|
77
|
+
Fires immediately when OpenCode emits a `session.idle` event. Includes the last user request, agent response, and todo status.
|
|
78
|
+
|
|
79
|
+
**Severity**: info
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
📢 OpenCode Attention Required
|
|
83
|
+
📝 Request: Build a REST API
|
|
84
|
+
🤖 Response: I've created the Express server...
|
|
85
|
+
📋 Todo: ✅ 3 done | ▶️ 1 in_progress | ⚪ 2 pending
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Question
|
|
89
|
+
|
|
90
|
+
Fires 30 seconds after the Question tool is invoked. If the user answers within 30 seconds, the notification is cancelled. This prevents spam for quick interactions.
|
|
91
|
+
|
|
92
|
+
**Severity**: warning
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
❓ OpenCode Question
|
|
96
|
+
❓ Question: Deploy to production?
|
|
97
|
+
Options:
|
|
98
|
+
1. yes
|
|
99
|
+
2. no
|
|
100
|
+
3. cancel
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Background
|
|
104
|
+
|
|
105
|
+
Fires when a session's status transitions to `idle` after being active. This indicates the agent has finished working and the session is waiting.
|
|
106
|
+
|
|
107
|
+
**Severity**: success
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
✅ Background Task Complete
|
|
111
|
+
Task: Session ses_abc123
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Permission
|
|
115
|
+
|
|
116
|
+
Fires when a tool requires explicit user permission. Uses two mechanisms for reliability: the primary `permission.ask` hook and a fallback `permission.updated` event listener. Permissions are deduplicated by ID to prevent double notifications.
|
|
117
|
+
|
|
118
|
+
**Severity**: warning
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
🔐 OpenCode Permission Required
|
|
122
|
+
🔧 Tool: bash
|
|
123
|
+
⚡ Action: execute rm -rf node_modules
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Supported Services
|
|
127
|
+
|
|
128
|
+
Apprise supports many services. Use these URL formats:
|
|
129
|
+
|
|
130
|
+
- **Slack**: `slack://TokenA/TokenB/TokenC`
|
|
131
|
+
- **Discord**: `discord://webhook_id/webhook_token`
|
|
132
|
+
- **Telegram**: `tgram://bottoken/ChatID`
|
|
133
|
+
- **Email**: `mailto://user:pass@gmail.com`
|
|
134
|
+
|
|
135
|
+
For a complete list, see: https://github.com/caronc/apprise#supported-notifications
|
|
136
|
+
|
|
137
|
+
## How It Works
|
|
138
|
+
|
|
139
|
+
### Message Truncation
|
|
140
|
+
|
|
141
|
+
Messages exceeding 1,500 characters are truncated. For messages with more than 10 lines, the first 5 and last 5 lines are preserved with a `...(truncated)` marker. Otherwise, a simple character truncation is applied.
|
|
142
|
+
|
|
143
|
+
### Deduplication
|
|
144
|
+
|
|
145
|
+
Identical notifications are suppressed for 5 minutes. Duplicates are identified by a hash of the notification type, title, user request, and question text. The cache holds a maximum of 100 entries with LRU eviction.
|
|
146
|
+
|
|
147
|
+
### Notification Severity Mapping
|
|
148
|
+
|
|
149
|
+
| Event | Apprise Type |
|
|
150
|
+
|-------|:-------------|
|
|
151
|
+
| Idle | info |
|
|
152
|
+
| Question | warning |
|
|
153
|
+
| Background | success |
|
|
154
|
+
| Permission | warning |
|
|
155
|
+
|
|
156
|
+
## Troubleshooting
|
|
157
|
+
|
|
158
|
+
- **apprise CLI not found**: Run `pip install apprise` to install the required dependency.
|
|
159
|
+
- **No notifications received**: Check your Apprise config file (`~/.apprise`, `~/.apprise.yml`, or `~/.config/apprise/apprise.yml`) and test with `apprise -t test -b test`.
|
|
160
|
+
- **Notifications not reaching a specific service**: Set `OPENCODE_NOTIFY_TAG` to match the tag assigned to that service in your Apprise config.
|
|
161
|
+
- **Too many notifications**: Deduplication suppresses identical notifications for 5 minutes.
|
|
162
|
+
- **Notifications cut off**: Messages are truncated at 1,500 characters.
|
|
163
|
+
- **Apprise command hangs**: The CLI timeout is 30 seconds. If Apprise doesn't respond in time, the notification fails silently.
|
|
164
|
+
|
|
165
|
+
## Contributing
|
|
166
|
+
|
|
167
|
+
Contributions are welcome! Please:
|
|
168
|
+
|
|
169
|
+
1. Fork the repository
|
|
170
|
+
2. Create a feature branch (`git checkout -b feat/my-feature`)
|
|
171
|
+
3. Write tests first (TDD)
|
|
172
|
+
4. Ensure all tests pass (`bun test`)
|
|
173
|
+
5. Submit a pull request
|
|
174
|
+
|
|
175
|
+
**Development setup:**
|
|
176
|
+
```bash
|
|
177
|
+
git clone https://github.com/or1is1/opencode-plugin-apprise.git
|
|
178
|
+
cd opencode-plugin-apprise
|
|
179
|
+
bun install
|
|
180
|
+
pip install apprise
|
|
181
|
+
bun test
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
MIT
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PluginConfig } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Load minimal plugin configuration.
|
|
4
|
+
* All behavior handled by Apprise defaults — no environment variables.
|
|
5
|
+
*/
|
|
6
|
+
export declare function loadConfig(): PluginConfig;
|
|
7
|
+
/**
|
|
8
|
+
* Validate plugin configuration (no-op — no required fields).
|
|
9
|
+
*/
|
|
10
|
+
export declare function validateConfig(_config: PluginConfig): void;
|
package/dist/dedup.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FormattedNotification, NotificationPayload } from "./types.js";
|
|
2
|
+
export declare const DEFAULT_TRUNCATE_LENGTH = 1500;
|
|
3
|
+
export declare function truncateText(text: string, maxLength: number): string;
|
|
4
|
+
export declare function formatTodoStatus(todos: Array<{
|
|
5
|
+
status: string;
|
|
6
|
+
content: string;
|
|
7
|
+
}>): string;
|
|
8
|
+
export declare function formatNotification(payload: NotificationPayload, truncateLength?: number): FormattedNotification;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Hooks, PluginInput } from "@opencode-ai/plugin";
|
|
2
|
+
import type { DedupChecker } from "../dedup.js";
|
|
3
|
+
import type { PluginConfig } from "../types.js";
|
|
4
|
+
export declare function createIdleHook(ctx: PluginInput, config: PluginConfig, dedup: DedupChecker): NonNullable<Hooks["event"]>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Hooks } from "@opencode-ai/plugin";
|
|
2
|
+
import type { DedupChecker } from "../dedup.js";
|
|
3
|
+
import type { PluginConfig } from "../types.js";
|
|
4
|
+
export interface PermissionHooks {
|
|
5
|
+
/** Primary: permission.ask hook */
|
|
6
|
+
permissionAsk: NonNullable<Hooks["permission.ask"]>;
|
|
7
|
+
/** Fallback: event hook watching permission.updated */
|
|
8
|
+
eventFallback: NonNullable<Hooks["event"]>;
|
|
9
|
+
}
|
|
10
|
+
export declare function createPermissionHooks(config: PluginConfig, dedup: DedupChecker): PermissionHooks;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Hooks } from "@opencode-ai/plugin";
|
|
2
|
+
import type { DedupChecker } from "../dedup.js";
|
|
3
|
+
import type { PluginConfig } from "../types.js";
|
|
4
|
+
export interface QuestionHooks {
|
|
5
|
+
before: NonNullable<Hooks["tool.execute.before"]>;
|
|
6
|
+
after: NonNullable<Hooks["tool.execute.after"]>;
|
|
7
|
+
}
|
|
8
|
+
export declare function createQuestionHooks(config: PluginConfig, dedup: DedupChecker, delayMs?: number): QuestionHooks;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { DedupChecker } from "../dedup.js";
|
|
2
|
+
import type { HookEventType, NotificationContext, NotificationPayload, PluginConfig } from "../types.js";
|
|
3
|
+
export declare function createPayload(type: HookEventType, title: string, context?: Partial<NotificationContext>): NotificationPayload;
|
|
4
|
+
export declare function sendHookNotification(hookName: string, config: PluginConfig, dedup: DedupChecker, payload: NotificationPayload): Promise<void>;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FormattedNotification, PluginConfig } from "./types.js";
|
|
2
|
+
export interface NotifierResult {
|
|
3
|
+
success: boolean;
|
|
4
|
+
exitCode: number;
|
|
5
|
+
stderr: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function checkAppriseInstalled(): Promise<boolean>;
|
|
8
|
+
export declare function sendNotification(config: PluginConfig, notification: FormattedNotification): Promise<NotifierResult>;
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
function loadConfig() {
|
|
3
|
+
return {
|
|
4
|
+
tag: process.env.OPENCODE_NOTIFY_TAG
|
|
5
|
+
};
|
|
6
|
+
}
|
|
7
|
+
function validateConfig(_config) {}
|
|
8
|
+
|
|
9
|
+
// src/dedup.ts
|
|
10
|
+
function createDedupChecker() {
|
|
11
|
+
const TTL_MS = 5 * 60 * 1000;
|
|
12
|
+
const MAX_SIZE = 100;
|
|
13
|
+
const seen = new Map;
|
|
14
|
+
function hashPayload(payload) {
|
|
15
|
+
const key = `${payload.type}:${payload.title}:${payload.context.userRequest ?? ""}:${payload.context.question ?? ""}`;
|
|
16
|
+
let hash = 5381;
|
|
17
|
+
for (let i = 0;i < key.length; i++) {
|
|
18
|
+
hash = (hash << 5) + hash ^ key.charCodeAt(i);
|
|
19
|
+
hash = hash >>> 0;
|
|
20
|
+
}
|
|
21
|
+
return hash.toString(16);
|
|
22
|
+
}
|
|
23
|
+
function evictExpired() {
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
for (const [hash, ts] of seen) {
|
|
26
|
+
if (now - ts > TTL_MS)
|
|
27
|
+
seen.delete(hash);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function evictOldestIfFull() {
|
|
31
|
+
if (seen.size >= MAX_SIZE) {
|
|
32
|
+
const firstKey = seen.keys().next().value;
|
|
33
|
+
if (firstKey !== undefined)
|
|
34
|
+
seen.delete(firstKey);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
isDuplicate(payload) {
|
|
39
|
+
evictExpired();
|
|
40
|
+
const hash = hashPayload(payload);
|
|
41
|
+
if (seen.has(hash))
|
|
42
|
+
return true;
|
|
43
|
+
evictOldestIfFull();
|
|
44
|
+
seen.set(hash, Date.now());
|
|
45
|
+
return false;
|
|
46
|
+
},
|
|
47
|
+
clear() {
|
|
48
|
+
seen.clear();
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/formatter.ts
|
|
54
|
+
var TYPE_MAP = {
|
|
55
|
+
idle: "info",
|
|
56
|
+
question: "warning",
|
|
57
|
+
background: "success",
|
|
58
|
+
permission: "warning"
|
|
59
|
+
};
|
|
60
|
+
var DEFAULT_TRUNCATE_LENGTH = 1500;
|
|
61
|
+
var TRUNCATE_LINE_THRESHOLD = 10;
|
|
62
|
+
var TRUNCATE_HEAD_LINES = 5;
|
|
63
|
+
var TRUNCATE_TAIL_LINES = 5;
|
|
64
|
+
var TRUNCATE_MARKER = `
|
|
65
|
+
...(truncated)`;
|
|
66
|
+
function truncateText(text, maxLength) {
|
|
67
|
+
if (text.length <= maxLength)
|
|
68
|
+
return text;
|
|
69
|
+
const lines = text.split(`
|
|
70
|
+
`);
|
|
71
|
+
if (lines.length <= TRUNCATE_LINE_THRESHOLD) {
|
|
72
|
+
const keepLength = maxLength - TRUNCATE_MARKER.length;
|
|
73
|
+
return text.slice(0, keepLength) + TRUNCATE_MARKER;
|
|
74
|
+
}
|
|
75
|
+
const head = lines.slice(0, TRUNCATE_HEAD_LINES).join(`
|
|
76
|
+
`);
|
|
77
|
+
const tail = lines.slice(-TRUNCATE_TAIL_LINES).join(`
|
|
78
|
+
`);
|
|
79
|
+
const result = head + TRUNCATE_MARKER + `
|
|
80
|
+
` + tail;
|
|
81
|
+
if (result.length > maxLength) {
|
|
82
|
+
return text.slice(0, maxLength - TRUNCATE_MARKER.length) + TRUNCATE_MARKER;
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
function formatTodoStatus(todos) {
|
|
87
|
+
const done = todos.filter((todo) => todo.status === "completed").length;
|
|
88
|
+
const inProgress = todos.filter((todo) => todo.status === "in_progress").length;
|
|
89
|
+
const pending = todos.filter((todo) => todo.status === "pending").length;
|
|
90
|
+
const parts = [];
|
|
91
|
+
if (done > 0)
|
|
92
|
+
parts.push(`✅ ${done} done`);
|
|
93
|
+
if (inProgress > 0)
|
|
94
|
+
parts.push(`▶️ ${inProgress} in_progress`);
|
|
95
|
+
if (pending > 0)
|
|
96
|
+
parts.push(`⚪ ${pending} pending`);
|
|
97
|
+
return parts.length > 0 ? parts.join(" | ") : "No todos";
|
|
98
|
+
}
|
|
99
|
+
function formatNotification(payload, truncateLength = DEFAULT_TRUNCATE_LENGTH) {
|
|
100
|
+
const { type, title, context } = payload;
|
|
101
|
+
const notificationType = TYPE_MAP[type] ?? "info";
|
|
102
|
+
let body;
|
|
103
|
+
switch (type) {
|
|
104
|
+
case "idle": {
|
|
105
|
+
const parts = [];
|
|
106
|
+
if (context.userRequest)
|
|
107
|
+
parts.push(`\uD83D\uDCDD Request: ${context.userRequest}`);
|
|
108
|
+
if (context.agentResponse)
|
|
109
|
+
parts.push(`\uD83E\uDD16 Response: ${context.agentResponse}`);
|
|
110
|
+
if (context.todoStatus)
|
|
111
|
+
parts.push(`\uD83D\uDCCB Todo: ${context.todoStatus}`);
|
|
112
|
+
body = parts.join(`
|
|
113
|
+
|
|
114
|
+
`);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case "question": {
|
|
118
|
+
const parts = [];
|
|
119
|
+
if (context.userRequest)
|
|
120
|
+
parts.push(`\uD83D\uDCDD Request: ${context.userRequest}`);
|
|
121
|
+
if (context.question)
|
|
122
|
+
parts.push(`❓ Question: ${context.question}`);
|
|
123
|
+
if (context.options && context.options.length > 0) {
|
|
124
|
+
parts.push(`Options:
|
|
125
|
+
${context.options.map((option, index) => ` ${index + 1}. ${option}`).join(`
|
|
126
|
+
`)}`);
|
|
127
|
+
}
|
|
128
|
+
body = parts.join(`
|
|
129
|
+
|
|
130
|
+
`);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
case "background": {
|
|
134
|
+
const parts = [];
|
|
135
|
+
if (context.taskName)
|
|
136
|
+
parts.push(`Task: ${context.taskName}`);
|
|
137
|
+
if (context.agentResponse)
|
|
138
|
+
parts.push(`Result: ${context.agentResponse}`);
|
|
139
|
+
body = parts.join(`
|
|
140
|
+
|
|
141
|
+
`);
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
case "permission": {
|
|
145
|
+
const parts = [];
|
|
146
|
+
if (context.toolName)
|
|
147
|
+
parts.push(`\uD83D\uDD27 Tool: ${context.toolName}`);
|
|
148
|
+
if (context.action)
|
|
149
|
+
parts.push(`⚡ Action: ${context.action}`);
|
|
150
|
+
body = parts.join(`
|
|
151
|
+
|
|
152
|
+
`);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
default:
|
|
156
|
+
body = "";
|
|
157
|
+
}
|
|
158
|
+
body = truncateText(body, truncateLength);
|
|
159
|
+
return { title, body, notificationType };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// src/notifier.ts
|
|
163
|
+
var APPRISE_TIMEOUT_MS = 30000;
|
|
164
|
+
function getErrorMessage(error) {
|
|
165
|
+
if (error instanceof Error) {
|
|
166
|
+
return error.message;
|
|
167
|
+
}
|
|
168
|
+
return String(error);
|
|
169
|
+
}
|
|
170
|
+
async function checkAppriseInstalled() {
|
|
171
|
+
try {
|
|
172
|
+
const proc = Bun.spawn(["apprise", "--version"], {
|
|
173
|
+
timeout: APPRISE_TIMEOUT_MS,
|
|
174
|
+
stderr: "pipe"
|
|
175
|
+
});
|
|
176
|
+
const exitCode = await proc.exited;
|
|
177
|
+
return exitCode === 0;
|
|
178
|
+
} catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async function sendNotification(config, notification) {
|
|
183
|
+
const args = [
|
|
184
|
+
"apprise",
|
|
185
|
+
"-t",
|
|
186
|
+
notification.title,
|
|
187
|
+
"-b",
|
|
188
|
+
notification.body,
|
|
189
|
+
"--notification-type",
|
|
190
|
+
notification.notificationType
|
|
191
|
+
];
|
|
192
|
+
if (config.tag) {
|
|
193
|
+
args.push("--tag", config.tag);
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const proc = Bun.spawn(args, {
|
|
197
|
+
timeout: APPRISE_TIMEOUT_MS,
|
|
198
|
+
stderr: "pipe"
|
|
199
|
+
});
|
|
200
|
+
const exitCode = await proc.exited;
|
|
201
|
+
const stderr = await new Response(proc.stderr).text();
|
|
202
|
+
return {
|
|
203
|
+
success: exitCode === 0,
|
|
204
|
+
exitCode,
|
|
205
|
+
stderr
|
|
206
|
+
};
|
|
207
|
+
} catch (error) {
|
|
208
|
+
return {
|
|
209
|
+
success: false,
|
|
210
|
+
exitCode: -1,
|
|
211
|
+
stderr: getErrorMessage(error)
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// src/hooks/shared.ts
|
|
217
|
+
var EMPTY_CONTEXT = {
|
|
218
|
+
userRequest: undefined,
|
|
219
|
+
agentResponse: undefined,
|
|
220
|
+
question: undefined,
|
|
221
|
+
options: undefined,
|
|
222
|
+
todoStatus: undefined,
|
|
223
|
+
taskName: undefined,
|
|
224
|
+
toolName: undefined,
|
|
225
|
+
action: undefined
|
|
226
|
+
};
|
|
227
|
+
function createPayload(type, title, context = {}) {
|
|
228
|
+
return {
|
|
229
|
+
type,
|
|
230
|
+
title,
|
|
231
|
+
context: { ...EMPTY_CONTEXT, ...context }
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
async function sendHookNotification(hookName, config, dedup, payload) {
|
|
235
|
+
if (dedup.isDuplicate(payload))
|
|
236
|
+
return;
|
|
237
|
+
try {
|
|
238
|
+
const formatted = formatNotification(payload, DEFAULT_TRUNCATE_LENGTH);
|
|
239
|
+
await sendNotification(config, formatted);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
console.warn(`[opencode-plugin-apprise] ${hookName} hook error:`, err);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// src/hooks/background.ts
|
|
246
|
+
function createBackgroundHook(config, dedup) {
|
|
247
|
+
return async ({ event }) => {
|
|
248
|
+
if (event.type !== "session.status")
|
|
249
|
+
return;
|
|
250
|
+
const props = event.properties;
|
|
251
|
+
if (props.status.type !== "idle")
|
|
252
|
+
return;
|
|
253
|
+
const payload = createPayload("background", "✅ Background Task Complete", {
|
|
254
|
+
taskName: `Session ${props.sessionID}`
|
|
255
|
+
});
|
|
256
|
+
await sendHookNotification("background", config, dedup, payload);
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/hooks/idle.ts
|
|
261
|
+
function extractText(message) {
|
|
262
|
+
if (!message || typeof message !== "object") {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const parts = Array.isArray(message) ? message.map((p) => typeof p === "string" ? p : p.text || "") : [];
|
|
266
|
+
return parts.join(`
|
|
267
|
+
`).trim() || undefined;
|
|
268
|
+
}
|
|
269
|
+
function createIdleHook(ctx, config, dedup) {
|
|
270
|
+
return async ({ event }) => {
|
|
271
|
+
if (event.type !== "session.idle")
|
|
272
|
+
return;
|
|
273
|
+
const props = event.properties;
|
|
274
|
+
if (!props.sessionID)
|
|
275
|
+
return;
|
|
276
|
+
let userRequest = undefined;
|
|
277
|
+
let agentResponse = undefined;
|
|
278
|
+
let todoStatus = undefined;
|
|
279
|
+
try {
|
|
280
|
+
const messagesResponse = await ctx.client.session.messages({
|
|
281
|
+
path: { id: props.sessionID }
|
|
282
|
+
});
|
|
283
|
+
const messages = messagesResponse.data ?? [];
|
|
284
|
+
for (let i = messages.length - 1;i >= 0; i--) {
|
|
285
|
+
const msg = messages[i];
|
|
286
|
+
if (msg?.role === "user") {
|
|
287
|
+
userRequest = extractText(msg.content);
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (userRequest) {
|
|
292
|
+
for (let i = messages.length - 1;i >= 0; i--) {
|
|
293
|
+
const msg = messages[i];
|
|
294
|
+
if (msg?.role === "assistant") {
|
|
295
|
+
agentResponse = extractText(msg.content);
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
const todosResponse = await ctx.client.session.todo({
|
|
302
|
+
path: { id: props.sessionID }
|
|
303
|
+
});
|
|
304
|
+
if (todosResponse.data) {
|
|
305
|
+
todoStatus = formatTodoStatus(todosResponse.data);
|
|
306
|
+
}
|
|
307
|
+
} catch {}
|
|
308
|
+
} catch (err) {
|
|
309
|
+
console.warn("[opencode-plugin-apprise] failed to fetch session data:", err);
|
|
310
|
+
}
|
|
311
|
+
const payload = createPayload("idle", "\uD83D\uDCE2 OpenCode Attention Required", {
|
|
312
|
+
userRequest,
|
|
313
|
+
agentResponse,
|
|
314
|
+
todoStatus
|
|
315
|
+
});
|
|
316
|
+
await sendHookNotification("idle", config, dedup, payload);
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// src/hooks/permission.ts
|
|
321
|
+
function createPermissionHooks(config, dedup) {
|
|
322
|
+
const notifiedPermissions = new Set;
|
|
323
|
+
async function notifyPermission(permission) {
|
|
324
|
+
const permId = permission.id ?? "unknown";
|
|
325
|
+
if (notifiedPermissions.has(permId))
|
|
326
|
+
return;
|
|
327
|
+
notifiedPermissions.add(permId);
|
|
328
|
+
const toolName = permission.toolName ?? "Unknown Tool";
|
|
329
|
+
const action = permission.action ?? "Unknown Action";
|
|
330
|
+
const payload = createPayload("permission", "\uD83D\uDD10 OpenCode Permission Required", {
|
|
331
|
+
toolName,
|
|
332
|
+
action
|
|
333
|
+
});
|
|
334
|
+
await sendHookNotification("permission", config, dedup, payload);
|
|
335
|
+
}
|
|
336
|
+
const permissionAsk = async (input, _output) => {
|
|
337
|
+
await notifyPermission(input);
|
|
338
|
+
};
|
|
339
|
+
const eventFallback = async ({ event }) => {
|
|
340
|
+
if (event.type !== "permission.updated")
|
|
341
|
+
return;
|
|
342
|
+
const permission = event.properties;
|
|
343
|
+
await notifyPermission(permission);
|
|
344
|
+
};
|
|
345
|
+
return { permissionAsk, eventFallback };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/hooks/question.ts
|
|
349
|
+
function createQuestionHooks(config, dedup, delayMs = 30000) {
|
|
350
|
+
const timers = new Map;
|
|
351
|
+
const before = async ({ tool, callID }, input) => {
|
|
352
|
+
if (tool.toLowerCase() !== "question")
|
|
353
|
+
return;
|
|
354
|
+
const args = input?.args;
|
|
355
|
+
const question = typeof args?.question === "string" ? args.question : undefined;
|
|
356
|
+
const options = Array.isArray(args?.options) ? args.options.filter((option) => typeof option === "string") : undefined;
|
|
357
|
+
const timer = setTimeout(async () => {
|
|
358
|
+
if (!question)
|
|
359
|
+
return;
|
|
360
|
+
const payload = createPayload("question", "❓ OpenCode Question", {
|
|
361
|
+
question,
|
|
362
|
+
options,
|
|
363
|
+
toolName: "Question"
|
|
364
|
+
});
|
|
365
|
+
await sendHookNotification("question", config, dedup, payload);
|
|
366
|
+
}, delayMs);
|
|
367
|
+
timers.set(callID, timer);
|
|
368
|
+
};
|
|
369
|
+
const after = async ({
|
|
370
|
+
tool,
|
|
371
|
+
callID
|
|
372
|
+
}) => {
|
|
373
|
+
if (tool.toLowerCase() !== "question")
|
|
374
|
+
return;
|
|
375
|
+
const timer = timers.get(callID);
|
|
376
|
+
if (timer) {
|
|
377
|
+
clearTimeout(timer);
|
|
378
|
+
timers.delete(callID);
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
return { before, after };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// src/index.ts
|
|
385
|
+
var plugin = async (input) => {
|
|
386
|
+
let config;
|
|
387
|
+
try {
|
|
388
|
+
config = loadConfig();
|
|
389
|
+
validateConfig(config);
|
|
390
|
+
} catch (err) {
|
|
391
|
+
console.warn("[opencode-plugin-apprise] Configuration error:", err instanceof Error ? err.message : err);
|
|
392
|
+
console.warn("[opencode-plugin-apprise] Plugin disabled due to configuration error.");
|
|
393
|
+
return {};
|
|
394
|
+
}
|
|
395
|
+
const appriseInstalled = await checkAppriseInstalled();
|
|
396
|
+
if (!appriseInstalled) {
|
|
397
|
+
console.warn("[opencode-plugin-apprise] apprise CLI not found. Install with: pip install apprise");
|
|
398
|
+
console.warn("[opencode-plugin-apprise] Plugin disabled.");
|
|
399
|
+
return {};
|
|
400
|
+
}
|
|
401
|
+
const dedup = createDedupChecker();
|
|
402
|
+
const idleHook = createIdleHook(input, config, dedup);
|
|
403
|
+
const questionHooks = createQuestionHooks(config, dedup);
|
|
404
|
+
const backgroundHook = createBackgroundHook(config, dedup);
|
|
405
|
+
const permissionHooks = createPermissionHooks(config, dedup);
|
|
406
|
+
const combinedEventHook = async ({ event }) => {
|
|
407
|
+
await idleHook({ event });
|
|
408
|
+
await backgroundHook({ event });
|
|
409
|
+
await permissionHooks.eventFallback({ event });
|
|
410
|
+
};
|
|
411
|
+
return {
|
|
412
|
+
event: combinedEventHook,
|
|
413
|
+
"tool.execute.before": questionHooks.before,
|
|
414
|
+
"tool.execute.after": questionHooks.after,
|
|
415
|
+
"permission.ask": permissionHooks.permissionAsk
|
|
416
|
+
};
|
|
417
|
+
};
|
|
418
|
+
var src_default = plugin;
|
|
419
|
+
export {
|
|
420
|
+
src_default as default
|
|
421
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface PluginConfig {
|
|
2
|
+
tag?: string;
|
|
3
|
+
}
|
|
4
|
+
export type HookEventType = "idle" | "question" | "background" | "permission";
|
|
5
|
+
export type AppriseNotificationType = "info" | "warning" | "success" | "failure";
|
|
6
|
+
export interface NotificationContext {
|
|
7
|
+
userRequest: string | undefined;
|
|
8
|
+
agentResponse: string | undefined;
|
|
9
|
+
question: string | undefined;
|
|
10
|
+
options: string[] | undefined;
|
|
11
|
+
todoStatus: string | undefined;
|
|
12
|
+
taskName: string | undefined;
|
|
13
|
+
toolName: string | undefined;
|
|
14
|
+
action: string | undefined;
|
|
15
|
+
}
|
|
16
|
+
export interface NotificationPayload {
|
|
17
|
+
type: HookEventType;
|
|
18
|
+
title: string;
|
|
19
|
+
context: NotificationContext;
|
|
20
|
+
}
|
|
21
|
+
export interface FormattedNotification {
|
|
22
|
+
title: string;
|
|
23
|
+
body: string;
|
|
24
|
+
notificationType: AppriseNotificationType;
|
|
25
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-plugin-apprise",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "OpenCode plugin that sends rich notifications via Apprise CLI when the agent needs your attention",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/opencode-plugin-apprise.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/opencode-plugin-apprise.js",
|
|
12
|
+
"default": "./dist/opencode-plugin-apprise.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "bun build src/index.ts --outfile dist/opencode-plugin-apprise.js --target node --format esm && tsc --emitDeclarationOnly",
|
|
18
|
+
"test": "bun test",
|
|
19
|
+
"prepublishOnly": "bun run build"
|
|
20
|
+
},
|
|
21
|
+
"keywords": ["opencode", "plugin", "apprise", "notification", "slack", "discord", "telegram", "attention"],
|
|
22
|
+
"author": "",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/or1is1/opencode-plugin-apprise.git"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/or1is1/opencode-plugin-apprise#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/or1is1/opencode-plugin-apprise/issues"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@opencode-ai/plugin": ">=1.0.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@opencode-ai/plugin": "^1.2.15",
|
|
40
|
+
"@types/bun": "latest",
|
|
41
|
+
"typescript": "^5.9.3"
|
|
42
|
+
}
|
|
43
|
+
}
|