Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,61 @@ Run your own script when something happens. Use `{event}`, `{message}`, `{sessio
}
```

### External notification channels

Send notifications to external services such as Gotify or Telegram. Channels are defined in the `externalChannels` array. By default the array is empty and no external notifications are sent.

The notification content (title and message) mirrors what is shown in desktop notifications, including the session name and interruption reason.

#### Gotify

```json
{
"externalChannels": [
{
"type": "gotify",
"url": "https://gotify.example.com",
"token": "your-app-token",
"priority": 5
}
]
}
```

- `url` - Base URL of your Gotify server
- `token` - Application token created in the Gotify UI
- `priority` - Message priority (optional, default `5`)

#### Telegram

```json
{
"externalChannels": [
{
"type": "telegram",
"token": "123456:ABC-yourBotToken",
"chatId": "your-chat-id"
}
]
}
```

- `token` - Bot API token from [@BotFather](https://t.me/BotFather)
- `chatId` - Chat, group, or channel ID to send messages to (find it with [@userinfobot](https://t.me/userinfobot))

You can combine multiple channels of the same or different types:

```json
{
"externalChannels": [
{ "type": "gotify", "url": "https://gotify.example.com", "token": "tok1" },
{ "type": "telegram", "token": "123:abc", "chatId": "-100123456" }
]
}
```

Each channel is sent to in parallel. A failure in one channel is logged to stderr and does not affect the others.

## macOS: Pick your notification style

**osascript** (default): Reliable but shows Script Editor icon
Expand Down Expand Up @@ -376,6 +431,8 @@ tmux source-file ~/.tmux.conf

When `suppressWhenFocused` is `true` (the default), notifications and sounds are skipped if the terminal running OpenCode is the active/focused window. The idea is simple: if you're already looking at it, you don't need an alert.

This suppression also applies to external channels (Gotify, Telegram). If you want external channels to always fire even when the terminal is focused, set `suppressWhenFocused` to `false`:

To disable this and always get notified:

```json
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 24 additions & 1 deletion src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,13 +185,36 @@ describe("Config", () => {
})

test("loadConfig defaults new high-frequency events to sound only", async () => {
const { loadConfig, isEventSoundEnabled, isEventNotificationEnabled } = await import("./config")
const { loadConfig, isEventSoundEnabled, isEventNotificationEnabled, isEventExternalNotificationEnabled } = await import("./config")
const config = loadConfig()

expect(isEventSoundEnabled(config, "session_started")).toBe(true)
expect(isEventNotificationEnabled(config, "session_started")).toBe(false)
expect(isEventExternalNotificationEnabled(config, "session_started")).toBe(false)
expect(isEventSoundEnabled(config, "user_message")).toBe(true)
expect(isEventNotificationEnabled(config, "user_message")).toBe(false)
expect(isEventExternalNotificationEnabled(config, "user_message")).toBe(false)
expect(isEventExternalNotificationEnabled(config, "client_connected")).toBe(false)
expect(isEventExternalNotificationEnabled(config, "user_cancelled")).toBe(false)
expect(isEventExternalNotificationEnabled(config, "subagent_complete")).toBe(false)
})

test("loadConfig defaults actionable events to externalNotification enabled", async () => {
const { loadConfig, isEventExternalNotificationEnabled } = await import("./config")
const config = loadConfig()

expect(isEventExternalNotificationEnabled(config, "permission")).toBe(true)
expect(isEventExternalNotificationEnabled(config, "complete")).toBe(true)
expect(isEventExternalNotificationEnabled(config, "error")).toBe(true)
expect(isEventExternalNotificationEnabled(config, "question")).toBe(true)
expect(isEventExternalNotificationEnabled(config, "interrupted")).toBe(true)
expect(isEventExternalNotificationEnabled(config, "plan_exit")).toBe(true)
})

test("loadConfig defaults high-frequency events to sound only (client_connected)", async () => {
const { loadConfig, isEventSoundEnabled, isEventNotificationEnabled } = await import("./config")
const config = loadConfig()

expect(isEventSoundEnabled(config, "client_connected")).toBe(true)
expect(isEventNotificationEnabled(config, "client_connected")).toBe(false)
})
Expand Down
72 changes: 61 additions & 11 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { join, dirname } from "path"
import { homedir } from "os"
import { fileURLToPath } from "url"
import isWsl from "is-wsl"
import type { ExternalChannelConfig } from "./external-notify"

export type EventType =
| "permission"
Expand All @@ -22,6 +23,8 @@ export interface EventConfig {
notification: boolean
command: boolean
bell: boolean
/** Send to external channels (Gotify, Telegram, etc.). Default: true for most events. */
externalNotification: boolean
}

export interface CommandConfig {
Expand Down Expand Up @@ -58,6 +61,8 @@ export interface NotifierConfig {
linux: LinuxConfig
minDuration: number
command: CommandConfig
/** External notification channels (Gotify, Telegram, etc.). Default: [] */
externalChannels: ExternalChannelConfig[]
events: {
permission: EventConfig
complete: EventConfig
Expand Down Expand Up @@ -117,6 +122,7 @@ const DEFAULT_EVENT_CONFIG: EventConfig = {
notification: true,
command: true,
bell: false,
externalNotification: true,
}

const DEFAULT_CONFIG: NotifierConfig = {
Expand All @@ -140,18 +146,19 @@ const DEFAULT_CONFIG: NotifierConfig = {
path: "",
minDuration: 0,
},
externalChannels: [],
events: {
permission: { ...DEFAULT_EVENT_CONFIG },
complete: { ...DEFAULT_EVENT_CONFIG },
subagent_complete: { ...DEFAULT_EVENT_CONFIG, sound: false, notification: false },
subagent_complete: { ...DEFAULT_EVENT_CONFIG, sound: false, notification: false, externalNotification: false },
error: { ...DEFAULT_EVENT_CONFIG },
question: { ...DEFAULT_EVENT_CONFIG },
interrupted: { ...DEFAULT_EVENT_CONFIG },
user_cancelled: { ...DEFAULT_EVENT_CONFIG, sound: false, notification: false },
user_cancelled: { ...DEFAULT_EVENT_CONFIG, sound: false, notification: false, externalNotification: false },
plan_exit: { ...DEFAULT_EVENT_CONFIG },
session_started: { ...DEFAULT_EVENT_CONFIG, notification: false },
user_message: { ...DEFAULT_EVENT_CONFIG, notification: false },
client_connected: { ...DEFAULT_EVENT_CONFIG, notification: false },
session_started: { ...DEFAULT_EVENT_CONFIG, notification: false, externalNotification: false },
user_message: { ...DEFAULT_EVENT_CONFIG, notification: false, externalNotification: false },
client_connected: { ...DEFAULT_EVENT_CONFIG, notification: false, externalNotification: false },
},
messages: {
permission: "Session needs permission: {sessionTitle}",
Expand Down Expand Up @@ -207,7 +214,7 @@ export function getStatePath(): string {
}

function parseEventConfig(
userEvent: boolean | { sound?: boolean; notification?: boolean; command?: boolean; bell?: boolean } | undefined,
userEvent: boolean | { sound?: boolean; notification?: boolean; command?: boolean; bell?: boolean; externalNotification?: boolean } | undefined,
defaultConfig: EventConfig
): EventConfig {
if (userEvent === undefined) {
Expand All @@ -220,6 +227,7 @@ function parseEventConfig(
notification: userEvent,
command: userEvent,
bell: defaultConfig.bell,
externalNotification: userEvent,
}
}

Expand All @@ -228,6 +236,7 @@ function parseEventConfig(
notification: userEvent.notification ?? defaultConfig.notification,
command: userEvent.command ?? defaultConfig.command,
bell: userEvent.bell ?? defaultConfig.bell,
externalNotification: userEvent.externalNotification ?? defaultConfig.externalNotification,
}
}

Expand All @@ -247,6 +256,41 @@ function parseVolume(value: unknown, defaultVolume: number): number {
return value
}

function parseExternalChannels(raw: unknown): ExternalChannelConfig[] {
if (!Array.isArray(raw)) {
return []
}

const result: ExternalChannelConfig[] = []
for (const item of raw) {
if (item === null || typeof item !== "object") {
continue
}
const ch = item as Record<string, unknown>

if (ch.type === "gotify") {
if (typeof ch.url !== "string" || ch.url.length === 0) continue
if (typeof ch.token !== "string" || ch.token.length === 0) continue
result.push({
type: "gotify",
url: ch.url,
token: ch.token,
priority: typeof ch.priority === "number" ? ch.priority : undefined,
})
} else if (ch.type === "telegram") {
if (typeof ch.token !== "string" || ch.token.length === 0) continue
if (typeof ch.chatId !== "string" && typeof ch.chatId !== "number") continue
result.push({
type: "telegram",
token: ch.token,
chatId: ch.chatId as string | number,
})
}
}

return result
}

export function loadConfig(): NotifierConfig {
const configPath = getConfigPath()

Expand All @@ -267,6 +311,7 @@ export function loadConfig(): NotifierConfig {
notification: globalNotification,
command: true,
bell: globalBell,
externalNotification: true,
}

const userCommand = userConfig.command ?? {}
Expand Down Expand Up @@ -314,18 +359,19 @@ export function loadConfig(): NotifierConfig {
args: commandArgs,
minDuration: commandMinDuration,
},
externalChannels: parseExternalChannels(userConfig.externalChannels),
events: {
permission: parseEventConfig(userConfig.events?.permission ?? userConfig.permission, defaultWithGlobal),
complete: parseEventConfig(userConfig.events?.complete ?? userConfig.complete, defaultWithGlobal),
subagent_complete: parseEventConfig(userConfig.events?.subagent_complete ?? userConfig.subagent_complete, { sound: false, notification: false, command: true, bell: false }),
subagent_complete: parseEventConfig(userConfig.events?.subagent_complete ?? userConfig.subagent_complete, { sound: false, notification: false, command: true, bell: false, externalNotification: false }),
error: parseEventConfig(userConfig.events?.error ?? userConfig.error, defaultWithGlobal),
question: parseEventConfig(userConfig.events?.question ?? userConfig.question, defaultWithGlobal),
interrupted: parseEventConfig(userConfig.events?.interrupted ?? userConfig.interrupted, defaultWithGlobal),
user_cancelled: parseEventConfig(userConfig.events?.user_cancelled ?? userConfig.user_cancelled, { sound: false, notification: false, command: true, bell: false }),
user_cancelled: parseEventConfig(userConfig.events?.user_cancelled ?? userConfig.user_cancelled, { sound: false, notification: false, command: true, bell: false, externalNotification: false }),
plan_exit: parseEventConfig(userConfig.events?.plan_exit ?? userConfig.plan_exit, defaultWithGlobal),
session_started: parseEventConfig(userConfig.events?.session_started ?? userConfig.session_started, { ...defaultWithGlobal, notification: false }),
user_message: parseEventConfig(userConfig.events?.user_message ?? userConfig.user_message, { ...defaultWithGlobal, notification: false }),
client_connected: parseEventConfig(userConfig.events?.client_connected ?? userConfig.client_connected, { ...defaultWithGlobal, notification: false }),
session_started: parseEventConfig(userConfig.events?.session_started ?? userConfig.session_started, { ...defaultWithGlobal, notification: false, externalNotification: false }),
user_message: parseEventConfig(userConfig.events?.user_message ?? userConfig.user_message, { ...defaultWithGlobal, notification: false, externalNotification: false }),
client_connected: parseEventConfig(userConfig.events?.client_connected ?? userConfig.client_connected, { ...defaultWithGlobal, notification: false, externalNotification: false }),
},
messages: {
permission: userConfig.messages?.permission ?? DEFAULT_CONFIG.messages.permission,
Expand Down Expand Up @@ -391,6 +437,10 @@ export function isEventBellEnabled(config: NotifierConfig, event: EventType): bo
return config.events[event].bell
}

export function isEventExternalNotificationEnabled(config: NotifierConfig, event: EventType): boolean {
return config.events[event].externalNotification
}

export function getMessage(config: NotifierConfig, event: EventType): string {
return config.messages[event]
}
Expand Down
Loading