Skip to content

Commit db0ac66

Browse files
committed
chore: final type safety and DX enhancements
- Improve type safety in TelegramExecutionContext reply methods - Add prepare script to automatically set up hooks - Fix test tsconfig to correctly include vitest-pool-workers - Remove legacy workers-types from test config
1 parent ce8c1b7 commit db0ac66

8 files changed

Lines changed: 231 additions & 13 deletions

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232
"docs": "typedoc --options typedoc.json",
3333
"deploy:docs": "npm run docs && wrangler pages deploy docs",
3434
"ncu": "npx npm-check-updates -u --root",
35-
"ncu:interactive": "npx npm-check-updates -i --root"
35+
"ncu:interactive": "npx npm-check-updates -i --root",
36+
"prepare": "./setup_hooks.sh"
3637
},
3738
"author": "codebam",
3839
"license": "Apache-2.0",

src/telegram_execution_context.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ export default class TelegramExecutionContext {
289289
}
290290
}
291291

292-
async replyVideo(video: string, options: Record<string, number | string | boolean> = {}) {
292+
async replyVideo(video: string, options: Partial<SendVideoParams> = {}) {
293293
const params: SendVideoParams = {
294294
...options,
295295
chat_id: this.getChatId(),
@@ -336,7 +336,7 @@ export default class TelegramExecutionContext {
336336
* @param options - any additional options to pass to sendPhoto
337337
* @returns Promise with the API response
338338
*/
339-
async replyPhoto(photo: string, caption = '', options: Record<string, number | string | boolean> = {}) {
339+
async replyPhoto(photo: string, caption = '', options: Partial<SendPhotoParams> = {}) {
340340
const params: SendPhotoParams = {
341341
...options,
342342
chat_id: this.getChatId(),
@@ -372,7 +372,7 @@ export default class TelegramExecutionContext {
372372
* @param options - any additional options to pass to sendVoice
373373
* @returns Promise with the API response
374374
*/
375-
async replyVoice(voice: string, caption = '', options: Record<string, number | string | boolean> = {}) {
375+
async replyVoice(voice: string, caption = '', options: Partial<SendVoiceParams> = {}) {
376376
const params: SendVoiceParams = {
377377
...options,
378378
chat_id: this.getChatId(),
@@ -539,7 +539,7 @@ export default class TelegramExecutionContext {
539539
message: string,
540540
draft_id: number,
541541
parse_mode = '',
542-
options: Record<string, number | string | boolean | object> = {},
542+
options: Partial<SendMessageDraftParams> = {},
543543
finish = false,
544544
) {
545545
if (this.update_type === 'guest_message') {
@@ -550,11 +550,11 @@ export default class TelegramExecutionContext {
550550
}
551551

552552
if (finish) {
553-
return await this.reply(message, parse_mode, true, options as unknown as Record<string, string | number | boolean>);
553+
return await this.reply(message, parse_mode, true, options as unknown as Partial<SendMessageParams>);
554554
}
555555

556556
const params: SendMessageDraftParams = {
557-
...(options as unknown as SendMessageDraftParams),
557+
...(options as SendMessageDraftParams),
558558
chat_id: this.getChatId(),
559559
message_thread_id: this.getThreadId(),
560560
text: message,
@@ -569,7 +569,7 @@ export default class TelegramExecutionContext {
569569
return await this.withBusinessFallback(params, (api, data) => this.api.sendMessageDraft(api, data));
570570
}
571571

572-
async reply(message: string, parse_mode = '', reply = true, options: Record<string, number | string | boolean> = {}) {
572+
async reply(message: string, parse_mode = '', reply = true, options: Partial<SendMessageParams> = {}) {
573573
if (this.update_type === 'guest_message') {
574574
return await this.answerGuestQueryText(message, parse_mode);
575575
}

test/telegram_bot.spec.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export {};

test/telegram_bot.spec.js

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import TelegramBot from '../src/telegram_bot';
3+
import TelegramApi from '../src/telegram_api';
4+
import Webhook from '../src/webhook';
5+
describe('telegram bot', () => {
6+
// Test for inline query handling
7+
it('inline response', async () => {
8+
const bot = new TelegramBot('123456789').on(':message', async () => {
9+
return Promise.resolve(new Response('ok'));
10+
});
11+
const request = new Request('http://example.com/123456789', {
12+
method: 'POST',
13+
body: JSON.stringify({ inline_query: { query: 'hello' } }),
14+
});
15+
expect(await (await bot.handle(request)).text()).toBe('ok');
16+
expect(bot.currentContext.update_type).toBe('inline');
17+
});
18+
// Test for message handling
19+
it('message response', async () => {
20+
const bot = new TelegramBot('123456789').on(':message', async () => {
21+
return Promise.resolve(new Response('ok'));
22+
});
23+
const request = new Request('http://example.com/123456789', {
24+
method: 'POST',
25+
body: JSON.stringify({ message: { text: 'hello' } }),
26+
});
27+
expect(await (await bot.handle(request)).text()).toBe('ok');
28+
expect(bot.currentContext.update_type).toBe('message');
29+
});
30+
// Test for guest message handling
31+
it('guest message response', async () => {
32+
const bot = new TelegramBot('123456789').on(':guest_message', async () => {
33+
return Promise.resolve(new Response('ok'));
34+
});
35+
const request = new Request('http://example.com/123456789', {
36+
method: 'POST',
37+
body: JSON.stringify({ guest_message: { text: 'hello', guest_query_id: 'guest123', chat: { id: 123, type: 'private' } } }),
38+
});
39+
expect(await (await bot.handle(request)).text()).toBe('ok');
40+
expect(bot.currentContext.update_type).toBe('guest_message');
41+
});
42+
it('throws error on non-200 telegram api response', async () => {
43+
const api = new TelegramApi();
44+
// Mock global fetch
45+
const originalFetch = globalThis.fetch;
46+
globalThis.fetch = vi.fn().mockResolvedValue(new Response('Error', { status: 400, statusText: 'Bad Request' }));
47+
try {
48+
await api.sendMessage('https://api.telegram.org/bot123456789', { chat_id: 123, text: 'hello', parse_mode: 'HTML' });
49+
expect.fail('Should have thrown an error');
50+
}
51+
catch (e) {
52+
expect(e.message).toContain('Telegram API error: 400 Bad Request');
53+
}
54+
finally {
55+
globalThis.fetch = originalFetch;
56+
}
57+
});
58+
it('throws error on non-200 telegram file api response', async () => {
59+
const api = new TelegramApi();
60+
const originalFetch = globalThis.fetch;
61+
// First fetch for getFile (returns file path)
62+
// Second fetch for the actual file
63+
globalThis.fetch = vi
64+
.fn()
65+
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, result: { file_path: 'foo/bar.jpg' } }), { status: 200 }))
66+
.mockResolvedValueOnce(new Response('Error', { status: 404, statusText: 'Not Found' }));
67+
try {
68+
await api.getFile('https://api.telegram.org/bot123456789', { file_id: '123' }, '123456789');
69+
expect.fail('Should have thrown an error');
70+
}
71+
catch (e) {
72+
expect(e.message).toContain('Telegram File API error: 404 Not Found');
73+
}
74+
finally {
75+
globalThis.fetch = originalFetch;
76+
}
77+
});
78+
it('throws error on non-200 webhook set response', async () => {
79+
const webhook = new Webhook('123456789', new Request('https://example.com/123456789'));
80+
const originalFetch = globalThis.fetch;
81+
globalThis.fetch = vi.fn().mockResolvedValue(new Response('Error', { status: 500, statusText: 'Internal Server Error' }));
82+
try {
83+
await webhook.set();
84+
expect.fail('Should have thrown an error');
85+
}
86+
catch (e) {
87+
expect(e.message).toContain('Telegram API error (setWebhook): 500 Internal Server Error');
88+
}
89+
finally {
90+
globalThis.fetch = originalFetch;
91+
}
92+
});
93+
it('throws error on non-200 webhook delete response', async () => {
94+
const webhook = new Webhook('123456789', new Request('https://example.com/123456789'));
95+
const originalFetch = globalThis.fetch;
96+
globalThis.fetch = vi.fn().mockResolvedValue(new Response('Error', { status: 403, statusText: 'Forbidden' }));
97+
try {
98+
await webhook.delete();
99+
expect.fail('Should have thrown an error');
100+
}
101+
catch (e) {
102+
expect(e.message).toContain('Telegram API error (deleteWebhook): 403 Forbidden');
103+
}
104+
finally {
105+
globalThis.fetch = originalFetch;
106+
}
107+
});
108+
// Test for business message handling
109+
it('business message from owner should be skipped', async () => {
110+
const handler = vi.fn().mockResolvedValue(new Response('handler_called'));
111+
const bot = new TelegramBot('123456789').on(':message', handler);
112+
const ownerId = 999;
113+
const connectionId = 'conn123';
114+
const originalFetch = globalThis.fetch;
115+
globalThis.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
116+
ok: true,
117+
result: {
118+
user: { id: ownerId },
119+
can_reply: true,
120+
},
121+
}), { status: 200 }));
122+
const request = new Request('http://example.com/123456789', {
123+
method: 'POST',
124+
body: JSON.stringify({
125+
business_message: {
126+
business_connection_id: connectionId,
127+
from: { id: ownerId },
128+
chat: { id: 123, type: 'private' },
129+
text: 'Hello from owner',
130+
message_id: 1,
131+
},
132+
}),
133+
});
134+
const response = await bot.handle(request);
135+
expect(await response.text()).toBe('handler_called');
136+
expect(handler).toHaveBeenCalled();
137+
globalThis.fetch = originalFetch;
138+
});
139+
it('business message from user should be processed', async () => {
140+
const handler = vi.fn().mockResolvedValue(new Response('handler_called'));
141+
const bot = new TelegramBot('123456789').on(':message', handler);
142+
const ownerId = 999;
143+
const userId = 123;
144+
const connectionId = 'conn456';
145+
const originalFetch = globalThis.fetch;
146+
globalThis.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
147+
ok: true,
148+
result: {
149+
user: { id: ownerId },
150+
can_reply: true,
151+
},
152+
}), { status: 200 }));
153+
const request = new Request('http://example.com/123456789', {
154+
method: 'POST',
155+
body: JSON.stringify({
156+
business_message: {
157+
business_connection_id: connectionId,
158+
from: { id: userId },
159+
chat: { id: userId, type: 'private' },
160+
text: 'Hello from user',
161+
message_id: 2,
162+
},
163+
}),
164+
});
165+
const response = await bot.handle(request);
166+
expect(await response.text()).toBe('handler_called');
167+
expect(handler).toHaveBeenCalled();
168+
globalThis.fetch = originalFetch;
169+
});
170+
});

test/tsconfig.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
"extends": "../tsconfig.json",
33
"compilerOptions": {
44
"moduleResolution": "bundler",
5-
"types": ["@cloudflare/workers-types/experimental"],
5+
"types": [
6+
"@cloudflare/vitest-pool-workers"
7+
],
68
"rootDir": ".."
79
},
8-
"include": ["./**/*.ts", "../src/env.d.ts"]
10+
"include": ["./**/*.ts"]
911
}

test/utils.spec.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export {};

test/utils.spec.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { markdownToHtml } from '../src/utils';
3+
describe('markdownToHtml', () => {
4+
it('escapes inline code', async () => {
5+
const input = '`command1 < hello`';
6+
const output = await markdownToHtml(input);
7+
expect(output).toContain('<code>command1 &lt; hello</code>');
8+
});
9+
it('escapes multiple < in inline code', async () => {
10+
const input = '`command1 <<< hello`';
11+
const output = await markdownToHtml(input);
12+
expect(output).toContain('<code>command1 &lt;&lt;&lt; hello</code>');
13+
});
14+
it('escapes href in links', async () => {
15+
const input = '[link](https://example.com?a=1&b=2)';
16+
const output = await markdownToHtml(input);
17+
expect(output).toContain('<a href="https://example.com?a=1&amp;b=2">link</a>');
18+
});
19+
it('escapes image alt text and href', async () => {
20+
const input = '![image <tag>](https://example.com/img.png?x=1&y=2)';
21+
const output = await markdownToHtml(input);
22+
expect(output).toContain('<a href="https://example.com/img.png?x=1&amp;y=2">image &lt;tag&gt;</a>');
23+
});
24+
it('escapes unsupported HTML tags', async () => {
25+
const input = 'Testing <unsupported> tag';
26+
const output = await markdownToHtml(input);
27+
expect(output).toContain('Testing &lt;unsupported&gt; tag');
28+
});
29+
it('allows supported HTML tags', async () => {
30+
const input = 'Testing <b>supported</b> tag';
31+
const output = await markdownToHtml(input);
32+
expect(output).toContain('Testing <b>supported</b> tag');
33+
});
34+
it('escapes ampersands in text', async () => {
35+
const input = 'Rock & Roll';
36+
const output = await markdownToHtml(input);
37+
expect(output).toContain('Rock &amp; Roll');
38+
});
39+
it('escapes special characters in headings', async () => {
40+
const input = '# Heading <with> & symbols';
41+
const output = await markdownToHtml(input);
42+
expect(output).toContain('<b>Heading &lt;with&gt; &amp; symbols</b>');
43+
});
44+
});

tsconfig.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
{
2-
"include": ["src/**/*", "worker-configuration.d.ts", "test/**/*"],
2+
"include": ["src/**/*", "worker-configuration.d.ts"],
33
"compilerOptions": {
44
"strict": true,
55
"target": "esnext",
66
"module": "esnext",
77
"lib": ["esnext"],
88
"types": [
99
"node",
10-
"./worker-configuration.d.ts",
11-
"@cloudflare/vitest-pool-workers"
10+
"./worker-configuration.d.ts"
1211
],
1312
"esModuleInterop": true,
1413
"allowSyntheticDefaultImports": true,

0 commit comments

Comments
 (0)