paperclip-plugin-telegram 0.2.0 → 0.2.2

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.
Files changed (62) hide show
  1. package/README.md +68 -4
  2. package/dist/acp-bridge.d.ts +34 -0
  3. package/dist/acp-bridge.js +805 -0
  4. package/dist/acp-bridge.js.map +1 -0
  5. package/dist/adapter.d.ts +35 -0
  6. package/dist/adapter.js +75 -0
  7. package/dist/adapter.js.map +1 -0
  8. package/dist/command-registry.d.ts +3 -0
  9. package/dist/command-registry.js +273 -0
  10. package/dist/command-registry.js.map +1 -0
  11. package/dist/commands.d.ts +10 -0
  12. package/dist/commands.js +213 -0
  13. package/dist/commands.js.map +1 -0
  14. package/dist/constants.d.ts +44 -0
  15. package/dist/constants.js +48 -0
  16. package/dist/constants.js.map +1 -0
  17. package/dist/escalation.d.ts +41 -0
  18. package/dist/escalation.js +254 -0
  19. package/dist/escalation.js.map +1 -0
  20. package/dist/formatters.d.ts +13 -0
  21. package/dist/formatters.js +130 -0
  22. package/dist/formatters.js.map +1 -0
  23. package/dist/index.js +4 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/manifest.d.ts +3 -0
  26. package/dist/manifest.js +230 -0
  27. package/dist/manifest.js.map +1 -0
  28. package/dist/media-pipeline.d.ts +46 -0
  29. package/dist/media-pipeline.js +161 -0
  30. package/dist/media-pipeline.js.map +1 -0
  31. package/dist/telegram-api.d.ts +28 -0
  32. package/dist/telegram-api.js +147 -0
  33. package/dist/telegram-api.js.map +1 -0
  34. package/dist/watch-registry.d.ts +9 -0
  35. package/dist/watch-registry.js +272 -0
  36. package/dist/watch-registry.js.map +1 -0
  37. package/dist/worker.d.ts +1 -0
  38. package/dist/worker.js +548 -0
  39. package/dist/worker.js.map +1 -0
  40. package/package.json +7 -3
  41. package/src/acp-bridge.ts +0 -1273
  42. package/src/adapter.ts +0 -129
  43. package/src/command-registry.ts +0 -482
  44. package/src/commands.ts +0 -346
  45. package/src/constants.ts +0 -51
  46. package/src/escalation.ts +0 -421
  47. package/src/formatters.ts +0 -148
  48. package/src/manifest.ts +0 -246
  49. package/src/media-pipeline.ts +0 -234
  50. package/src/telegram-api.ts +0 -202
  51. package/src/watch-registry.ts +0 -369
  52. package/src/worker.ts +0 -783
  53. package/tests/acp-bridge.test.ts +0 -314
  54. package/tests/command-registry.test.ts +0 -283
  55. package/tests/commands.test.ts +0 -213
  56. package/tests/escalation.test.ts +0 -550
  57. package/tests/formatters.test.ts +0 -185
  58. package/tests/media-pipeline.test.ts +0 -324
  59. package/tests/telegram-api.test.ts +0 -108
  60. package/tests/watch-registry.test.ts +0 -404
  61. package/tsconfig.json +0 -16
  62. /package/{src/index.ts → dist/index.d.ts} +0 -0
@@ -1,185 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import {
3
- formatIssueCreated,
4
- formatIssueDone,
5
- formatApprovalCreated,
6
- formatAgentError,
7
- formatAgentRunStarted,
8
- formatAgentRunFinished,
9
- } from "../src/formatters.js";
10
- import type { PluginEvent } from "@paperclipai/plugin-sdk";
11
-
12
- function mockEvent(overrides: Record<string, unknown> = {}): PluginEvent {
13
- return {
14
- eventType: "issue.created",
15
- entityId: "iss-123",
16
- entityType: "issue",
17
- companyId: "co-1",
18
- occurredAt: new Date().toISOString(),
19
- payload: { identifier: "PROJ-42", title: "Test issue", ...overrides },
20
- } as PluginEvent;
21
- }
22
-
23
- describe("formatIssueCreated", () => {
24
- it("includes identifier and title", () => {
25
- const msg = formatIssueCreated(mockEvent());
26
- expect(msg.text).toContain("PROJ\\-42");
27
- expect(msg.text).toContain("Test issue");
28
- });
29
-
30
- it("falls back to entityId when no identifier", () => {
31
- const msg = formatIssueCreated(mockEvent({ identifier: undefined }));
32
- expect(msg.text).toContain("iss\\-123");
33
- });
34
-
35
- it("uses MarkdownV2 parse mode", () => {
36
- const msg = formatIssueCreated(mockEvent());
37
- expect(msg.options.parseMode).toBe("MarkdownV2");
38
- });
39
-
40
- it("includes metadata fields when available", () => {
41
- const msg = formatIssueCreated(mockEvent({
42
- status: "open",
43
- priority: "high",
44
- assigneeName: "Alice",
45
- projectName: "Backend",
46
- }));
47
- expect(msg.text).toContain("open");
48
- expect(msg.text).toContain("high");
49
- expect(msg.text).toContain("Alice");
50
- expect(msg.text).toContain("Backend");
51
- });
52
-
53
- it("includes description snippet", () => {
54
- const msg = formatIssueCreated(mockEvent({ description: "A long description about this issue" }));
55
- expect(msg.text).toContain("A long description");
56
- });
57
-
58
- it("truncates long descriptions at word boundary", () => {
59
- const words = Array(50).fill("word").join(" ");
60
- const msg = formatIssueCreated(mockEvent({ description: words }));
61
- expect(msg.text).toContain("\\.\\.\\.");
62
- expect(msg.text.length).toBeLessThan(words.length * 2);
63
- });
64
-
65
- it("omits metadata line when no metadata", () => {
66
- const msg = formatIssueCreated(mockEvent({
67
- status: undefined,
68
- priority: undefined,
69
- assigneeName: undefined,
70
- projectName: undefined,
71
- }));
72
- expect(msg.text).not.toContain("\\|");
73
- });
74
- });
75
-
76
- describe("formatIssueDone", () => {
77
- it("includes identifier and done text", () => {
78
- const msg = formatIssueDone(mockEvent());
79
- expect(msg.text).toContain("PROJ\\-42");
80
- expect(msg.text).toContain("done");
81
- });
82
-
83
- it("falls back to entityId", () => {
84
- const msg = formatIssueDone(mockEvent({ identifier: undefined }));
85
- expect(msg.text).toContain("iss\\-123");
86
- });
87
- });
88
-
89
- describe("formatApprovalCreated", () => {
90
- it("includes approve and reject buttons", () => {
91
- const msg = formatApprovalCreated(mockEvent({
92
- type: "deploy",
93
- approvalId: "apr-1",
94
- title: "Deploy to prod",
95
- }));
96
- expect(msg.options.inlineKeyboard).toBeDefined();
97
- const buttons = msg.options.inlineKeyboard![0];
98
- expect(buttons.length).toBe(2);
99
- expect(buttons[0].text).toBe("Approve");
100
- expect(buttons[0].callback_data).toBe("approve_apr-1");
101
- expect(buttons[1].text).toBe("Reject");
102
- expect(buttons[1].callback_data).toBe("reject_apr-1");
103
- });
104
-
105
- it("falls back to entityId for approvalId", () => {
106
- const msg = formatApprovalCreated(mockEvent({ approvalId: undefined }));
107
- const buttons = msg.options.inlineKeyboard![0];
108
- expect(buttons[0].callback_data).toBe("approve_iss-123");
109
- });
110
-
111
- it("includes agent name when provided", () => {
112
- const msg = formatApprovalCreated(mockEvent({
113
- agentName: "Builder",
114
- type: "deploy",
115
- }));
116
- expect(msg.text).toContain("Builder");
117
- });
118
-
119
- it("includes linked issues", () => {
120
- const msg = formatApprovalCreated(mockEvent({
121
- linkedIssues: [
122
- { identifier: "ISS-1", title: "First", status: "open" },
123
- { identifier: "ISS-2", title: "Second", status: "done" },
124
- ],
125
- }));
126
- expect(msg.text).toContain("ISS\\-1");
127
- expect(msg.text).toContain("ISS\\-2");
128
- expect(msg.text).toContain("Linked Issues");
129
- });
130
-
131
- it("truncates description at word boundary", () => {
132
- const longDesc = Array(80).fill("word").join(" ");
133
- const msg = formatApprovalCreated(mockEvent({ description: longDesc }));
134
- expect(msg.text).toContain("\\.\\.\\.");
135
- });
136
- });
137
-
138
- describe("formatAgentError", () => {
139
- it("includes agent name and error", () => {
140
- const msg = formatAgentError(mockEvent({
141
- agentName: "Builder",
142
- error: "Connection refused",
143
- }));
144
- expect(msg.text).toContain("Builder");
145
- expect(msg.text).toContain("Connection refused");
146
- });
147
-
148
- it("truncates long error messages", () => {
149
- const longError = "x".repeat(600);
150
- const msg = formatAgentError(mockEvent({ error: longError }));
151
- expect(msg.text).toContain("\\.\\.\\.");
152
- expect(msg.text).not.toContain("x".repeat(501));
153
- });
154
-
155
- it("falls back to entityId for agent name", () => {
156
- const msg = formatAgentError(mockEvent({ agentName: undefined, name: undefined }));
157
- expect(msg.text).toContain("iss\\-123");
158
- });
159
- });
160
-
161
- describe("formatAgentRunStarted", () => {
162
- it("includes agent name", () => {
163
- const msg = formatAgentRunStarted(mockEvent({ agentName: "Deployer" }));
164
- expect(msg.text).toContain("Deployer");
165
- expect(msg.text).toContain("started");
166
- });
167
-
168
- it("disables notification", () => {
169
- const msg = formatAgentRunStarted(mockEvent());
170
- expect(msg.options.disableNotification).toBe(true);
171
- });
172
- });
173
-
174
- describe("formatAgentRunFinished", () => {
175
- it("includes agent name and completion text", () => {
176
- const msg = formatAgentRunFinished(mockEvent({ agentName: "Deployer" }));
177
- expect(msg.text).toContain("Deployer");
178
- expect(msg.text).toContain("completed");
179
- });
180
-
181
- it("disables notification", () => {
182
- const msg = formatAgentRunFinished(mockEvent());
183
- expect(msg.options.disableNotification).toBe(true);
184
- });
185
- });
@@ -1,324 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
- import { handleMediaMessage } from "../src/media-pipeline.js";
3
- import type { PluginContext } from "@paperclipai/plugin-sdk";
4
-
5
- let sentMessages: Array<{ chatId: string; text: string; options?: Record<string, unknown> }> = [];
6
- let stateStore: Record<string, unknown> = {};
7
- let emittedEvents: Array<{ event: string; companyId: string; payload: unknown }> = [];
8
-
9
- vi.mock("../src/telegram-api.js", async () => {
10
- const actual = await vi.importActual("../src/telegram-api.js") as Record<string, unknown>;
11
- return {
12
- ...actual,
13
- sendMessage: vi.fn(async (_ctx: unknown, _token: string, chatId: string, text: string, options?: Record<string, unknown>) => {
14
- sentMessages.push({ chatId, text, options });
15
- return 1;
16
- }),
17
- sendChatAction: vi.fn(),
18
- };
19
- });
20
-
21
- vi.mock("../src/acp-bridge.js", async () => {
22
- return {
23
- getSessions: vi.fn(async (_ctx: unknown, _chatId: string, _threadId: number) => {
24
- const key = `sessions_${_chatId}_${_threadId}`;
25
- return (stateStore[key] as unknown[]) ?? [];
26
- }),
27
- };
28
- });
29
-
30
- function mockCtx(): PluginContext {
31
- return {
32
- http: {
33
- fetch: vi.fn().mockResolvedValue({
34
- json: () => Promise.resolve({
35
- ok: true,
36
- result: { file_path: "voice/file_0.oga" },
37
- }),
38
- blob: () => Promise.resolve(new Blob(["audio"])),
39
- }),
40
- },
41
- metrics: { write: vi.fn() },
42
- state: {
43
- get: vi.fn(async (key: { stateKey: string }) => stateStore[key.stateKey] ?? null),
44
- set: vi.fn(async (key: { stateKey: string }, value: unknown) => {
45
- stateStore[key.stateKey] = value;
46
- }),
47
- },
48
- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
49
- events: {
50
- emit: vi.fn((event: string, companyId: string, payload: unknown) => {
51
- emittedEvents.push({ event, companyId, payload });
52
- }),
53
- },
54
- agents: {
55
- invoke: vi.fn().mockResolvedValue({ runId: "run-1" }),
56
- sessions: {
57
- sendMessage: vi.fn(),
58
- },
59
- },
60
- secrets: {
61
- resolve: vi.fn().mockResolvedValue("sk-test-key"),
62
- },
63
- } as unknown as PluginContext;
64
- }
65
-
66
- const defaultConfig = {
67
- briefAgentId: "brief-agent",
68
- briefAgentChatIds: ["intake-chat"],
69
- transcriptionApiKeyRef: "openai-key",
70
- };
71
-
72
- beforeEach(() => {
73
- sentMessages = [];
74
- stateStore = {};
75
- emittedEvents = [];
76
- });
77
-
78
- describe("Intake detection", () => {
79
- it("processes media in intake channel", async () => {
80
- const ctx = mockCtx();
81
- const result = await handleMediaMessage(ctx, "token", {
82
- message_id: 1,
83
- chat: { id: Number("intake-chat") || 123 },
84
- voice: { file_id: "voice-1", duration: 5 },
85
- from: { id: 1, username: "user1" },
86
- }, {
87
- ...defaultConfig,
88
- briefAgentChatIds: ["123"],
89
- }, "company-1");
90
-
91
- // Chat id "123" is in briefAgentChatIds -> intake channel
92
- expect(result).toBe(true);
93
- });
94
-
95
- it("skips media not in intake channel and not in agent thread", async () => {
96
- const ctx = mockCtx();
97
- const result = await handleMediaMessage(ctx, "token", {
98
- message_id: 1,
99
- chat: { id: 999 },
100
- voice: { file_id: "voice-1", duration: 5 },
101
- }, defaultConfig, "company-1");
102
-
103
- expect(result).toBe(false);
104
- });
105
-
106
- it("processes media in agent thread with active session", async () => {
107
- stateStore["sessions_456_42"] = [{
108
- sessionId: "s1",
109
- agentId: "a1",
110
- agentName: "builder",
111
- agentDisplayName: "Builder",
112
- transport: "acp",
113
- spawnedAt: "2026-01-01T00:00:00Z",
114
- status: "active",
115
- lastActivityAt: "2026-01-01T00:00:00Z",
116
- }];
117
-
118
- const ctx = mockCtx();
119
- const result = await handleMediaMessage(ctx, "token", {
120
- message_id: 1,
121
- chat: { id: 456 },
122
- message_thread_id: 42,
123
- document: { file_id: "doc-1", file_name: "report.pdf", mime_type: "application/pdf" },
124
- }, { ...defaultConfig, briefAgentChatIds: [] }, "company-1");
125
-
126
- expect(result).toBe(true);
127
- });
128
-
129
- it("skips media in thread without active session", async () => {
130
- stateStore["sessions_456_42"] = [{
131
- sessionId: "s1",
132
- agentId: "a1",
133
- agentName: "builder",
134
- agentDisplayName: "Builder",
135
- transport: "acp",
136
- spawnedAt: "2026-01-01T00:00:00Z",
137
- status: "closed",
138
- lastActivityAt: "2026-01-01T00:00:00Z",
139
- }];
140
-
141
- const ctx = mockCtx();
142
- const result = await handleMediaMessage(ctx, "token", {
143
- message_id: 1,
144
- chat: { id: 456 },
145
- message_thread_id: 42,
146
- voice: { file_id: "voice-1", duration: 5 },
147
- }, { ...defaultConfig, briefAgentChatIds: [] }, "company-1");
148
-
149
- expect(result).toBe(false);
150
- });
151
- });
152
-
153
- describe("Audio type detection", () => {
154
- it("detects voice messages as audio", async () => {
155
- const ctx = mockCtx();
156
- // Mock the Whisper API response
157
- (ctx.http.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => {
158
- if (String(url).includes("openai.com")) {
159
- return { json: () => Promise.resolve({ text: "Transcribed voice" }) };
160
- }
161
- if (String(url).includes("getFile")) {
162
- return { json: () => Promise.resolve({ ok: true, result: { file_path: "voice/file.oga" } }) };
163
- }
164
- return { blob: () => Promise.resolve(new Blob(["data"])) };
165
- });
166
-
167
- await handleMediaMessage(ctx, "token", {
168
- message_id: 1,
169
- chat: { id: 123 },
170
- voice: { file_id: "voice-1", duration: 5, mime_type: "audio/ogg" },
171
- from: { id: 1, username: "user1" },
172
- }, { ...defaultConfig, briefAgentChatIds: ["123"] }, "company-1");
173
-
174
- // Should show transcription preview
175
- expect(sentMessages.some(m => m.text.includes("Transcription"))).toBe(true);
176
- });
177
-
178
- it("detects audio messages as audio", async () => {
179
- const ctx = mockCtx();
180
- (ctx.http.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => {
181
- if (String(url).includes("openai.com")) {
182
- return { json: () => Promise.resolve({ text: "Transcribed audio" }) };
183
- }
184
- if (String(url).includes("getFile")) {
185
- return { json: () => Promise.resolve({ ok: true, result: { file_path: "audio/file.mp3" } }) };
186
- }
187
- return { blob: () => Promise.resolve(new Blob(["data"])) };
188
- });
189
-
190
- await handleMediaMessage(ctx, "token", {
191
- message_id: 1,
192
- chat: { id: 123 },
193
- audio: { file_id: "audio-1", duration: 120, title: "Song", mime_type: "audio/mpeg" },
194
- from: { id: 1, username: "user1" },
195
- }, { ...defaultConfig, briefAgentChatIds: ["123"] }, "company-1");
196
-
197
- expect(sentMessages.some(m => m.text.includes("Transcription"))).toBe(true);
198
- });
199
-
200
- it("detects video_note as audio (transcribable)", async () => {
201
- const ctx = mockCtx();
202
- (ctx.http.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => {
203
- if (String(url).includes("openai.com")) {
204
- return { json: () => Promise.resolve({ text: "Video note text" }) };
205
- }
206
- if (String(url).includes("getFile")) {
207
- return { json: () => Promise.resolve({ ok: true, result: { file_path: "videonote/file.mp4" } }) };
208
- }
209
- return { blob: () => Promise.resolve(new Blob(["data"])) };
210
- });
211
-
212
- await handleMediaMessage(ctx, "token", {
213
- message_id: 1,
214
- chat: { id: 123 },
215
- video_note: { file_id: "vn-1", duration: 10 },
216
- from: { id: 1, username: "user1" },
217
- }, { ...defaultConfig, briefAgentChatIds: ["123"] }, "company-1");
218
-
219
- expect(sentMessages.some(m => m.text.includes("Transcription"))).toBe(true);
220
- });
221
-
222
- it("does not transcribe photo messages", async () => {
223
- const ctx = mockCtx();
224
- await handleMediaMessage(ctx, "token", {
225
- message_id: 1,
226
- chat: { id: 123 },
227
- photo: [{ file_id: "photo-1", width: 800, height: 600 }],
228
- caption: "A nice photo",
229
- from: { id: 1, username: "user1" },
230
- }, { ...defaultConfig, briefAgentChatIds: ["123"] }, "company-1");
231
-
232
- // Should not show transcription, but should still process
233
- expect(sentMessages.every(m => !m.text.includes("Transcription"))).toBe(true);
234
- });
235
-
236
- it("does not transcribe document messages", async () => {
237
- const ctx = mockCtx();
238
- await handleMediaMessage(ctx, "token", {
239
- message_id: 1,
240
- chat: { id: 123 },
241
- document: { file_id: "doc-1", file_name: "file.pdf", mime_type: "application/pdf" },
242
- from: { id: 1, username: "user1" },
243
- }, { ...defaultConfig, briefAgentChatIds: ["123"] }, "company-1");
244
-
245
- expect(sentMessages.every(m => !m.text.includes("Transcription"))).toBe(true);
246
- });
247
- });
248
-
249
- describe("Media routing to agents in threads", () => {
250
- it("routes media to most recently active agent in thread", async () => {
251
- stateStore["sessions_456_42"] = [
252
- {
253
- sessionId: "s1",
254
- agentId: "a1",
255
- agentName: "builder",
256
- agentDisplayName: "Builder",
257
- transport: "acp",
258
- spawnedAt: "2026-01-01T00:00:00Z",
259
- status: "active",
260
- lastActivityAt: "2026-01-01T00:00:00Z",
261
- },
262
- {
263
- sessionId: "s2",
264
- agentId: "a2",
265
- agentName: "tester",
266
- agentDisplayName: "Tester",
267
- transport: "acp",
268
- spawnedAt: "2026-01-01T00:00:00Z",
269
- status: "active",
270
- lastActivityAt: "2026-01-02T00:00:00Z", // more recent
271
- },
272
- ];
273
-
274
- const ctx = mockCtx();
275
- await handleMediaMessage(ctx, "token", {
276
- message_id: 1,
277
- chat: { id: 456 },
278
- message_thread_id: 42,
279
- document: { file_id: "doc-1", file_name: "file.txt", mime_type: "text/plain" },
280
- caption: "Check this",
281
- }, { ...defaultConfig, briefAgentChatIds: [] }, "company-1");
282
-
283
- // Should emit ACP event to the most recently active agent (s2)
284
- const acp = emittedEvents.find(e => e.event === "acp-spawn");
285
- expect(acp).toBeDefined();
286
- expect((acp!.payload as Record<string, unknown>).sessionId).toBe("s2");
287
- });
288
-
289
- it("sends to native session when transport is native", async () => {
290
- stateStore["sessions_456_42"] = [{
291
- sessionId: "s1",
292
- agentId: "a1",
293
- agentName: "builder",
294
- agentDisplayName: "Builder",
295
- transport: "native",
296
- spawnedAt: "2026-01-01T00:00:00Z",
297
- status: "active",
298
- lastActivityAt: "2026-01-01T00:00:00Z",
299
- }];
300
-
301
- const ctx = mockCtx();
302
- await handleMediaMessage(ctx, "token", {
303
- message_id: 1,
304
- chat: { id: 456 },
305
- message_thread_id: 42,
306
- document: { file_id: "doc-1", file_name: "file.txt" },
307
- }, { ...defaultConfig, briefAgentChatIds: [] }, "company-1");
308
-
309
- expect(ctx.agents.sessions.sendMessage).toHaveBeenCalled();
310
- });
311
- });
312
-
313
- describe("Returns false for empty media", () => {
314
- it("returns false when no file can be extracted", async () => {
315
- const ctx = mockCtx();
316
- const result = await handleMediaMessage(ctx, "token", {
317
- message_id: 1,
318
- chat: { id: 123 },
319
- // no voice, audio, video_note, document, or photo
320
- }, { ...defaultConfig, briefAgentChatIds: ["123"] }, "company-1");
321
-
322
- expect(result).toBe(false);
323
- });
324
- });
@@ -1,108 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { escapeMarkdownV2, truncateAtWord } from "../src/telegram-api.js";
3
-
4
- describe("escapeMarkdownV2", () => {
5
- it("escapes underscores", () => {
6
- expect(escapeMarkdownV2("hello_world")).toBe("hello\\_world");
7
- });
8
-
9
- it("escapes asterisks", () => {
10
- expect(escapeMarkdownV2("*bold*")).toBe("\\*bold\\*");
11
- });
12
-
13
- it("escapes brackets", () => {
14
- expect(escapeMarkdownV2("[link](url)")).toBe("\\[link\\]\\(url\\)");
15
- });
16
-
17
- it("escapes backticks", () => {
18
- expect(escapeMarkdownV2("`code`")).toBe("\\`code\\`");
19
- });
20
-
21
- it("escapes tildes", () => {
22
- expect(escapeMarkdownV2("~strikethrough~")).toBe("\\~strikethrough\\~");
23
- });
24
-
25
- it("escapes hashes", () => {
26
- expect(escapeMarkdownV2("#heading")).toBe("\\#heading");
27
- });
28
-
29
- it("escapes plus signs", () => {
30
- expect(escapeMarkdownV2("a+b")).toBe("a\\+b");
31
- });
32
-
33
- it("escapes hyphens", () => {
34
- expect(escapeMarkdownV2("a-b")).toBe("a\\-b");
35
- });
36
-
37
- it("escapes equal signs", () => {
38
- expect(escapeMarkdownV2("a=b")).toBe("a\\=b");
39
- });
40
-
41
- it("escapes pipes", () => {
42
- expect(escapeMarkdownV2("a|b")).toBe("a\\|b");
43
- });
44
-
45
- it("escapes curly braces", () => {
46
- expect(escapeMarkdownV2("{a}")).toBe("\\{a\\}");
47
- });
48
-
49
- it("escapes dots", () => {
50
- expect(escapeMarkdownV2("a.b")).toBe("a\\.b");
51
- });
52
-
53
- it("escapes exclamation marks", () => {
54
- expect(escapeMarkdownV2("hello!")).toBe("hello\\!");
55
- });
56
-
57
- it("escapes backslashes", () => {
58
- expect(escapeMarkdownV2("a\\b")).toBe("a\\\\b");
59
- });
60
-
61
- it("escapes greater than", () => {
62
- expect(escapeMarkdownV2("a>b")).toBe("a\\>b");
63
- });
64
-
65
- it("handles multiple special chars in one string", () => {
66
- expect(escapeMarkdownV2("PROJ-42: Fix [bug] #1"))
67
- .toBe("PROJ\\-42: Fix \\[bug\\] \\#1");
68
- });
69
-
70
- it("leaves plain text unchanged", () => {
71
- expect(escapeMarkdownV2("hello world")).toBe("hello world");
72
- });
73
-
74
- it("handles empty string", () => {
75
- expect(escapeMarkdownV2("")).toBe("");
76
- });
77
- });
78
-
79
- describe("truncateAtWord", () => {
80
- it("returns text unchanged if shorter than max", () => {
81
- expect(truncateAtWord("hello", 10)).toBe("hello");
82
- });
83
-
84
- it("returns text unchanged if equal to max", () => {
85
- expect(truncateAtWord("hello", 5)).toBe("hello");
86
- });
87
-
88
- it("truncates at word boundary and adds ellipsis", () => {
89
- const result = truncateAtWord("hello world foo bar baz", 15);
90
- expect(result).toBe("hello world...");
91
- });
92
-
93
- it("falls back to hard cut when no good word boundary", () => {
94
- const result = truncateAtWord("abcdefghijklmnopqrstuvwxyz", 10);
95
- expect(result).toBe("abcdefghij...");
96
- expect(result.length).toBe(13);
97
- });
98
-
99
- it("handles single word longer than max", () => {
100
- const result = truncateAtWord("superlongword", 5);
101
- expect(result).toBe("super...");
102
- });
103
-
104
- it("handles text with trailing space at boundary", () => {
105
- const result = truncateAtWord("aa bb cc dd ee ff", 8);
106
- expect(result).toBe("aa bb cc...");
107
- });
108
- });