Skip to content

Commit be36b26

Browse files
author
Slyrian
committed
feat: add configurable truncation direction for notification messages
Messages exceeding service limits can now be truncated from the beginning or end, controlled via `truncateFrom` ("end" default, "start" keeps the conclusion). Configurable globally and per-service, with `...` indicator.
1 parent 1918126 commit be36b26

13 files changed

Lines changed: 144 additions & 62 deletions

File tree

README.md

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ In long-running development tasks or deep research sessions, it's common to swit
2727
-**Privacy & Control**: Completely opt-in; no notifications are sent until you enable and configure a service.
2828
-**Event Filtering**: Selectively enable or disable notifications for specific event types.
2929
-**Rich Content**: Notifications include the actual assistant response text for better context.
30+
-**Truncation Direction**: Choose whether to keep the beginning or end of long messages when they exceed service limits.
3031

3132
## Installation
3233

@@ -117,7 +118,8 @@ Create your `.everynotify.json` with the tokens for the services you want to use
117118
"error": true,
118119
"permission": false,
119120
"question": true
120-
}
121+
},
122+
"truncateFrom": "end"
121123
}
122124
```
123125

@@ -135,6 +137,41 @@ You can control which events trigger notifications by adding an `events` block t
135137
| `permission` | opencode is waiting for you to grant permission for a tool or file access. |
136138
| `question` | The `question` tool was used to ask you for clarification. |
137139

140+
### Message Truncation
141+
142+
Each notification service has a maximum message length (e.g., Pushover: 1024 chars, Discord: 2000 chars). When a message exceeds the limit, EveryNotify truncates it and adds `...` as an indicator.
143+
144+
The `truncateFrom` option controls which part of the message is kept:
145+
146+
| Value | Behavior | Best For |
147+
| --------- | ---------------------------------- | --------------------------------------------- |
148+
| `"end"` | Keep beginning, trim end (default) | When the start of the message has the context |
149+
| `"start"` | Keep end, trim beginning | When the conclusion/result at the end matters |
150+
151+
**Global setting** applies to all services:
152+
153+
```json
154+
{
155+
"truncateFrom": "start"
156+
}
157+
```
158+
159+
**Per-service override** — for example, keep message endings on Pushover but beginnings on Slack:
160+
161+
```json
162+
{
163+
"truncateFrom": "end",
164+
"pushover": {
165+
"enabled": true,
166+
"token": "...",
167+
"userKey": "...",
168+
"truncateFrom": "start"
169+
}
170+
}
171+
```
172+
173+
Service-level `truncateFrom` takes priority over the global setting.
174+
138175
### Rich Message Content
139176

140177
EveryNotify provides rich context in its notifications by extracting the assistant's last response. Instead of generic "Task completed" messages, you receive the actual summary or answer provided by opencode.
@@ -227,6 +264,10 @@ For Pushover users, you can customize the `priority` level:
227264
- `1`: High priority (bypasses quiet hours)
228265
- `2`: Emergency priority (requires acknowledgment)
229266

267+
### Message Truncation Direction
268+
269+
See [Message Truncation](#message-truncation) under Configuration Options for details on the `truncateFrom` setting. This is especially useful for services with tight message limits like Pushover (1024 chars), where the end of a message often contains the most important information (the result or conclusion).
270+
230271
### Session Enrichment
231272

232273
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.

src/__tests__/dispatcher.test.ts

Lines changed: 31 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ describe("dispatcher", () => {
7777
log: {
7878
enabled: false,
7979
},
80+
events: {
81+
complete: true,
82+
subagent_complete: true,
83+
error: true,
84+
permission: true,
85+
question: true,
86+
},
87+
truncateFrom: "end",
8088
});
8189

8290
const createTestPayload = (
@@ -108,24 +116,23 @@ describe("dispatcher", () => {
108116
expect(mockSlackSend).toHaveBeenCalledTimes(1);
109117
expect(mockDiscordSend).toHaveBeenCalledTimes(1);
110118

111-
// Verify each service received correct config and payload
112119
expect(mockPushoverSend).toHaveBeenCalledWith(
113-
config.pushover,
120+
{ ...config.pushover, truncateFrom: "end" },
114121
payload,
115-
expect.any(Object), // AbortSignal
122+
expect.any(Object),
116123
);
117124
expect(mockTelegramSend).toHaveBeenCalledWith(
118-
config.telegram,
125+
{ ...config.telegram, truncateFrom: "end" },
119126
payload,
120127
expect.any(Object),
121128
);
122129
expect(mockSlackSend).toHaveBeenCalledWith(
123-
config.slack,
130+
{ ...config.slack, truncateFrom: "end" },
124131
payload,
125132
expect.any(Object),
126133
);
127134
expect(mockDiscordSend).toHaveBeenCalledWith(
128-
config.discord,
135+
{ ...config.discord, truncateFrom: "end" },
129136
payload,
130137
expect.any(Object),
131138
);
@@ -291,39 +298,31 @@ describe("dispatcher", () => {
291298
expect(mockDiscordSend).toHaveBeenCalledTimes(0);
292299
});
293300

294-
test("truncate function works correctly", () => {
295-
// Text shorter than limit — no truncation
301+
test("truncate from end (default) keeps beginning of text", () => {
296302
expect(truncate("Hello", 10)).toBe("Hello");
297-
298-
// Text exactly at limit — no truncation
299303
expect(truncate("Hello", 5)).toBe("Hello");
300304

301-
// Text over limit — truncated with suffix
302-
const longText = "A".repeat(100);
303-
const truncated = truncate(longText, 50);
304-
expect(truncated.length).toBe(50);
305-
expect(truncated.endsWith("… [truncated]")).toBe(true);
305+
const longText = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
306+
const result = truncate(longText, 10);
307+
expect(result.length).toBe(10);
308+
expect(result).toBe("ABCDEFG...");
309+
});
310+
311+
test("truncate from start keeps end of text", () => {
312+
expect(truncate("Hello", 10, "start")).toBe("Hello");
313+
expect(truncate("Hello", 5, "start")).toBe("Hello");
306314

307-
// Verify truncation preserves correct prefix length
308-
const suffix = "… [truncated]";
309-
const expectedPrefix = "A".repeat(50 - suffix.length);
310-
expect(truncated).toBe(expectedPrefix + suffix);
315+
const longText = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
316+
const result = truncate(longText, 10, "start");
317+
expect(result.length).toBe(10);
318+
expect(result).toBe("...TUVWXYZ");
311319
});
312320

313321
test("truncate handles boundary cases", () => {
314-
// Empty string
315322
expect(truncate("", 10)).toBe("");
316-
317-
// maxLength = 0
318-
expect(truncate("Hello", 0)).toBe("… [truncated]");
319-
320-
// maxLength < suffix length
321-
expect(truncate("Hello", 5)).toBe("Hello");
322-
expect(truncate("Hello World", 5)).toBe("… [truncated]");
323-
324-
// maxLength = suffix length (text longer than maxLength)
325-
const suffix = "… [truncated]";
326-
const longText = "A".repeat(100);
327-
expect(truncate(longText, suffix.length)).toBe(suffix);
323+
expect(truncate("Hello World", 0)).toBe("...");
324+
expect(truncate("Hello World", 3)).toBe("...");
325+
expect(truncate("Hello World", 4)).toBe("H...");
326+
expect(truncate("Hello World", 4, "start")).toBe("...d");
328327
});
329328
});

src/__tests__/services/discord.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,6 @@ describe("Discord Service", () => {
103103
const [, options] = fetchSpy.mock.calls[0];
104104
const body = JSON.parse(options?.body as string);
105105
expect(body.content.length).toBeLessThanOrEqual(2000);
106-
expect(body.content).toContain("… [truncated]");
107106
});
108107

109108
it("should pass AbortSignal to fetch for timeout control", async () => {

src/__tests__/services/pushover.test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,6 @@ describe("Pushover Service", () => {
102102
encodedMessage.replace(/\+/g, " "),
103103
);
104104
expect(decodedMessage.length).toBeLessThanOrEqual(1024);
105-
expect(decodedMessage).toContain("… [truncated]");
106105
});
107106

108107
it("truncates title at 250 characters", async () => {
@@ -123,7 +122,6 @@ describe("Pushover Service", () => {
123122
const encodedTitle = titleMatch![1];
124123
const decodedTitle = decodeURIComponent(encodedTitle.replace(/\+/g, " "));
125124
expect(decodedTitle.length).toBeLessThanOrEqual(250);
126-
expect(decodedTitle).toContain("… [truncated]");
127125
});
128126

129127
it("passes AbortSignal to fetch for timeout control", async () => {

src/__tests__/services/slack.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,6 @@ describe("Slack Service", () => {
175175
const body = options?.body as string;
176176
const parsed = JSON.parse(body);
177177
expect(parsed.text.length).toBeLessThanOrEqual(40000);
178-
expect(parsed.text).toContain("… [truncated]");
179178
});
180179

181180
it("passes AbortSignal to fetch", async () => {

src/__tests__/services/telegram.test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,6 @@ describe("Telegram Service", () => {
102102
const body = JSON.parse(options.body);
103103

104104
expect(body.text.length).toBeLessThanOrEqual(4096);
105-
expect(body.text).toContain("… [truncated]");
106105
});
107106

108107
it("passes AbortSignal to fetch for timeout control", async () => {
@@ -172,7 +171,6 @@ describe("Telegram Service", () => {
172171
const body = JSON.parse(options.body);
173172

174173
expect(body.text.length).toBeLessThanOrEqual(4096);
175-
expect(body.text).toContain("… [truncated]");
176174
});
177175

178176
it("uses correct chat_id format for group chats", async () => {

src/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const DEFAULT_CONFIG: EverynotifyConfig = {
4848
permission: true,
4949
question: true,
5050
},
51+
truncateFrom: "end",
5152
};
5253

5354
/**
@@ -97,6 +98,9 @@ function deepMerge(
9798
if (source.events) {
9899
result.events = { ...result.events, ...source.events };
99100
}
101+
if (source.truncateFrom !== undefined) {
102+
result.truncateFrom = source.truncateFrom;
103+
}
100104

101105
return result;
102106
}

src/dispatcher.ts

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,27 +11,30 @@ import type {
1111
EverynotifyConfig,
1212
NotificationPayload,
1313
EventType,
14+
TruncationMode,
1415
} from "./types";
1516
import type { Logger } from "./logger";
1617
import { send as pushoverSend } from "./services/pushover";
1718
import { send as telegramSend } from "./services/telegram";
1819
import { send as slackSend } from "./services/slack";
1920
import { send as discordSend } from "./services/discord";
2021

21-
/**
22-
* Truncate text to max length, appending "… [truncated]" if over limit
23-
* Shared utility function used by all services
24-
*/
25-
export function truncate(text: string, maxLength: number): string {
22+
export function truncate(
23+
text: string,
24+
maxLength: number,
25+
from: TruncationMode = "end",
26+
): string {
2627
if (text.length <= maxLength) {
2728
return text;
2829
}
29-
const suffix = "… [truncated]";
30-
// If maxLength is too small for suffix, return just the suffix
31-
if (maxLength <= suffix.length) {
32-
return suffix;
30+
const indicator = "...";
31+
if (maxLength <= indicator.length) {
32+
return indicator;
3333
}
34-
return text.slice(0, maxLength - suffix.length) + suffix;
34+
if (from === "start") {
35+
return indicator + text.slice(text.length - (maxLength - indicator.length));
36+
}
37+
return text.slice(0, maxLength - indicator.length) + indicator;
3538
}
3639

3740
/**
@@ -68,35 +71,49 @@ export function createDispatcher(
6871
// Build array of enabled services
6972
const services: ServiceDescriptor[] = [];
7073

74+
const globalTruncateFrom = config.truncateFrom ?? "end";
75+
7176
if (config.pushover.enabled) {
7277
services.push({
7378
name: "Pushover",
7479
send: pushoverSend,
75-
config: config.pushover,
80+
config: {
81+
...config.pushover,
82+
truncateFrom: config.pushover.truncateFrom ?? globalTruncateFrom,
83+
},
7684
});
7785
}
7886

7987
if (config.telegram.enabled) {
8088
services.push({
8189
name: "Telegram",
8290
send: telegramSend,
83-
config: config.telegram,
91+
config: {
92+
...config.telegram,
93+
truncateFrom: config.telegram.truncateFrom ?? globalTruncateFrom,
94+
},
8495
});
8596
}
8697

8798
if (config.slack.enabled) {
8899
services.push({
89100
name: "Slack",
90101
send: slackSend,
91-
config: config.slack,
102+
config: {
103+
...config.slack,
104+
truncateFrom: config.slack.truncateFrom ?? globalTruncateFrom,
105+
},
92106
});
93107
}
94108

95109
if (config.discord.enabled) {
96110
services.push({
97111
name: "Discord",
98112
send: discordSend,
99-
config: config.discord,
113+
config: {
114+
...config.discord,
115+
truncateFrom: config.discord.truncateFrom ?? globalTruncateFrom,
116+
},
100117
});
101118
}
102119

src/services/discord.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ export async function send(
3030
payload: NotificationPayload,
3131
signal: AbortSignal,
3232
): Promise<void> {
33-
const content = truncate(formatDiscordMessage(payload), 2000);
33+
const content = truncate(
34+
formatDiscordMessage(payload),
35+
2000,
36+
config.truncateFrom,
37+
);
3438

3539
const response = await fetch(config.webhookUrl, {
3640
method: "POST",

src/services/pushover.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ export async function send(
2525
const body = new URLSearchParams({
2626
token: config.token,
2727
user: config.userKey,
28-
message: truncate(payload.message, 1024),
29-
title: truncate(payload.title, 250),
28+
message: truncate(payload.message, 1024, config.truncateFrom),
29+
title: truncate(payload.title, 250, config.truncateFrom),
3030
priority: String(config.priority ?? 0),
3131
});
3232

0 commit comments

Comments
 (0)