Skip to content

Commit 3e0fa4b

Browse files
chatman-mediaclaude
andcommitted
Migrate to TypeScript with strict typing and comprehensive testing
- Add TypeScript configuration with strict type checking - Create comprehensive type definitions in types/index.ts - Rewrite API handler with full TypeScript types and error handling - Update test suite with TypeScript and improved coverage - Add GitHub Actions CI workflow for testing and type checking - Configure Jest for TypeScript with proper mocking - Update Vercel configuration for TypeScript deployment 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 34ebc60 commit 3e0fa4b

File tree

14 files changed

+1178
-33
lines changed

14 files changed

+1178
-33
lines changed

.github/workflows/ci.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ main, develop ]
6+
pull_request:
7+
branches: [ main ]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
13+
strategy:
14+
matrix:
15+
node-version: [18.x, 20.x]
16+
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- name: Use Node.js ${{ matrix.node-version }}
21+
uses: actions/setup-node@v4
22+
with:
23+
node-version: ${{ matrix.node-version }}
24+
cache: 'npm'
25+
26+
- name: Install dependencies
27+
run: npm ci
28+
29+
- name: Run type check
30+
run: npm run type-check
31+
32+
- name: Run tests
33+
run: npm test
34+
35+
- name: Build project
36+
run: npm run build

README.md

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@
44

55
## 🚀 Демо
66

7-
![Telegram Group Members](https://your-vercel-app.vercel.app/api/telegram-badge)
8-
9-
> Замените `your-vercel-app` на ваш актуальный Vercel-деплой.
7+
![Telegram Group Members](https://telegram-badge.vercel.app/api/telegram-badge)
108

119
---
1210

@@ -65,11 +63,9 @@ bun dev
6563
Добавьте следующую строку в ваш README.md:
6664

6765
```markdown
68-
![Telegram Group Badge](https://your-vercel-app.vercel.app/api/telegram-badge)
66+
![Telegram Group Badge](https://telegram-badge.vercel.app/api/telegram-badge)
6967
```
7068

71-
Замените `your-vercel-app` на ваш актуальный Vercel-деплой.
72-
7369
### 🎨 Параметры стилизации
7470

7571
Вы можете настроить внешний вид бейджа с помощью следующих параметров:
@@ -93,27 +89,27 @@ bun dev
9389

9490
Стандартный бейдж:
9591
```
96-
https://your-vercel-app.vercel.app/api/telegram-badge
92+
https://telegram-badge.vercel.app/api/telegram-badge
9793
```
9894

9995
Бейдж с кастомной меткой:
10096
```
101-
https://your-vercel-app.vercel.app/api/telegram-badge?label=Our%20Group
97+
https://telegram-badge.vercel.app/api/telegram-badge?label=Our%20Group
10298
```
10399

104100
Бейдж с кастомным цветом:
105101
```
106-
https://your-vercel-app.vercel.app/api/telegram-badge?color=FF0000
102+
https://telegram-badge.vercel.app/api/telegram-badge?color=FF0000
107103
```
108104

109105
Бейдж с кастомным стилем:
110106
```
111-
https://your-vercel-app.vercel.app/api/telegram-badge?style=for-the-badge
107+
https://telegram-badge.vercel.app/api/telegram-badge?style=for-the-badge
112108
```
113109

114110
Полностью кастомизированный бейдж:
115111
```
116-
https://your-vercel-app.vercel.app/api/telegram-badge?style=social&label=Join%20Us&color=FF5733&labelColor=333333
112+
https://telegram-badge.vercel.app/api/telegram-badge?style=social&label=Join%20Us&color=FF5733&labelColor=333333
117113
```
118114

119115
## 🧠 Возможности

api/telegram-badge.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { makeBadge } from 'badge-maker';
2-
import crypto from 'crypto';
1+
const { makeBadge } = require('badge-maker');
2+
const crypto = require('crypto');
33

44
// Простая функция логирования с уровнями
55
const logger = {
@@ -35,7 +35,7 @@ const validateEnvironment = () => {
3535
return { token, chatId };
3636
};
3737

38-
export default async function (req, res) {
38+
module.exports = async function (req, res) {
3939
// Логируем входящий запрос
4040
logger.info('Received badge request', {
4141
query: req.query,

api/telegram-badge.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import { makeBadge } from 'badge-maker';
2+
import * as crypto from 'crypto';
3+
import {
4+
BadgeOptions,
5+
BadgeFormat,
6+
TelegramApiResponse,
7+
Logger,
8+
Request,
9+
Response,
10+
Environment
11+
} from '../types';
12+
13+
const logger: Logger = {
14+
info: (message: string, data: Record<string, any> = {}): void => {
15+
console.log(`[INFO] ${message}`, data);
16+
},
17+
warn: (message: string, data: Record<string, any> = {}): void => {
18+
console.warn(`[WARN] ${message}`, data);
19+
},
20+
error: (message: string, error: any = null): void => {
21+
console.error(`[ERROR] ${message}`, error);
22+
},
23+
debug: (message: string, data: Record<string, any> = {}): void => {
24+
if (process.env.DEBUG) {
25+
console.log(`[DEBUG] ${message}`, data);
26+
}
27+
}
28+
};
29+
30+
const validateEnvironment = (): Environment => {
31+
const token = process.env.BOT_TOKEN;
32+
const chatId = process.env.CHAT_ID;
33+
34+
if (!token) {
35+
throw new Error("Missing BOT_TOKEN environment variable");
36+
}
37+
38+
if (!chatId) {
39+
throw new Error("Missing CHAT_ID environment variable");
40+
}
41+
42+
return { token, chatId };
43+
};
44+
45+
const getMemberCount = async (token: string, chatId: string): Promise<number> => {
46+
const apiUrl = `https://api.telegram.org/bot${token}/getChatMemberCount?chat_id=${encodeURIComponent(chatId)}`;
47+
logger.debug('Fetching member count', { chatId });
48+
49+
const controller = new AbortController();
50+
const timeoutId = setTimeout(() => controller.abort(), 5000);
51+
52+
try {
53+
const response = await fetch(apiUrl, {
54+
signal: controller.signal,
55+
headers: {
56+
'Accept': 'application/json',
57+
'User-Agent': 'TelegramBadgeGenerator/1.0'
58+
}
59+
});
60+
61+
clearTimeout(timeoutId);
62+
63+
if (!response.ok) {
64+
throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
65+
}
66+
67+
const data = await response.json() as TelegramApiResponse;
68+
69+
if (!data.ok) {
70+
throw new Error(`Telegram API error: ${data.description}`);
71+
}
72+
73+
logger.debug('Member count received', { count: data.result });
74+
return data.result!;
75+
} catch (error) {
76+
if (error instanceof Error && error.name === 'AbortError') {
77+
logger.error('Request timeout', error);
78+
throw new Error('Request timeout: Telegram API took too long to respond');
79+
}
80+
logger.error('Error fetching member count', error);
81+
throw error;
82+
}
83+
};
84+
85+
const validateStyleOptions = (options: BadgeOptions): Required<BadgeOptions> => {
86+
const validStyles: BadgeOptions['style'][] = ['flat', 'plastic', 'flat-square', 'for-the-badge', 'social'];
87+
88+
let style = options.style || 'flat';
89+
if (!validStyles.includes(style)) {
90+
logger.warn(`Invalid style: ${style}, using default 'flat'`);
91+
style = 'flat';
92+
}
93+
94+
const label = options.label || 'Telegram';
95+
const color = options.color || '2AABEE';
96+
const labelColor = options.labelColor || '555555';
97+
98+
return { style, label, color, labelColor };
99+
};
100+
101+
const createBadge = (members: number, options: BadgeOptions): string => {
102+
const { style, label, color, labelColor } = validateStyleOptions(options);
103+
logger.debug('Creating badge', { style, label, color, labelColor });
104+
105+
const normalizedColor = color.replace(/^#/, '');
106+
const normalizedLabelColor = labelColor.replace(/^#/, '');
107+
108+
const format: BadgeFormat = {
109+
label,
110+
message: `${members} members`,
111+
color: `#${normalizedColor}`,
112+
labelColor: `#${normalizedLabelColor}`,
113+
style
114+
};
115+
116+
return makeBadge(format);
117+
};
118+
119+
const createErrorBadge = (errorMessage: string): string => {
120+
const format: BadgeFormat = {
121+
label: 'Error',
122+
message: errorMessage,
123+
color: '#e05d44',
124+
labelColor: '#555555',
125+
style: 'flat'
126+
};
127+
128+
return makeBadge(format);
129+
};
130+
131+
const setCacheHeaders = (res: Response, svg: string): void => {
132+
res.setHeader("Content-Type", "image/svg+xml");
133+
134+
res.setHeader(
135+
"Cache-Control",
136+
"max-age=300, s-maxage=600, stale-while-revalidate=86400"
137+
);
138+
139+
const etag = crypto
140+
.createHash('md5')
141+
.update(svg)
142+
.digest('hex');
143+
res.setHeader("ETag", `"${etag}"`);
144+
145+
const expiresDate = new Date();
146+
expiresDate.setSeconds(expiresDate.getSeconds() + 300);
147+
res.setHeader("Expires", expiresDate.toUTCString());
148+
149+
logger.debug('Cache headers set');
150+
};
151+
152+
export default async function handler(req: Request, res: Response): Promise<void> {
153+
logger.info('Received badge request', {
154+
query: req.query,
155+
userAgent: req.headers['user-agent'],
156+
referer: req.headers['referer'] || 'unknown'
157+
});
158+
159+
try {
160+
const { token, chatId } = validateEnvironment();
161+
logger.debug('Environment validated', { chatId });
162+
163+
const ifNoneMatch = req.headers['if-none-match'];
164+
165+
const requestEtag = `"${crypto
166+
.createHash('md5')
167+
.update(JSON.stringify({ token, chatId, query: req.query, time: Math.floor(Date.now() / 300000) }))
168+
.digest('hex')}"`;
169+
170+
if (ifNoneMatch && ifNoneMatch === requestEtag) {
171+
logger.info('Returning 304 Not Modified');
172+
res.status(304).end();
173+
return;
174+
}
175+
176+
const members = await getMemberCount(token, chatId);
177+
logger.info('Member count fetched', { members });
178+
179+
const badgeOptions: BadgeOptions = {
180+
style: req.query.style as BadgeOptions['style'],
181+
label: req.query.label,
182+
color: req.query.color,
183+
labelColor: req.query.labelColor
184+
};
185+
186+
const svg = createBadge(members, badgeOptions);
187+
logger.debug('Badge created');
188+
189+
setCacheHeaders(res, svg);
190+
res.status(200).send(svg);
191+
logger.info('Badge sent successfully');
192+
193+
} catch (err) {
194+
logger.error('Error processing request', err);
195+
196+
let errorBadge: string;
197+
let statusCode = 500;
198+
199+
if (err instanceof Error) {
200+
if (err.message.includes("Missing BOT_TOKEN") || err.message.includes("Missing CHAT_ID")) {
201+
errorBadge = createErrorBadge('Configuration Error');
202+
logger.error(`Configuration error: ${err.message}`);
203+
} else if (err.message.includes("Telegram API error")) {
204+
errorBadge = createErrorBadge('API Error');
205+
logger.error(`Telegram API error: ${err.message}`);
206+
} else if (err.message.includes("Request timeout")) {
207+
errorBadge = createErrorBadge('Timeout');
208+
statusCode = 503;
209+
logger.error(`Timeout error: ${err.message}`);
210+
} else {
211+
errorBadge = createErrorBadge('Server Error');
212+
logger.error(`Server error: ${err.message}`);
213+
}
214+
} else {
215+
errorBadge = createErrorBadge('Server Error');
216+
logger.error('Unknown error occurred');
217+
}
218+
219+
res.setHeader("Content-Type", "image/svg+xml");
220+
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
221+
res.status(statusCode).send(errorBadge);
222+
logger.info(`Error badge sent with status ${statusCode}`);
223+
}
224+
}

jest.config.js

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
1-
export default {
2-
transform: {},
3-
// Удаляем extensionsToTreatAsEsm, так как .js уже определен в package.json
1+
module.exports = {
2+
preset: 'ts-jest/presets/default',
3+
testEnvironment: 'node',
4+
roots: ['<rootDir>/tests'],
5+
testMatch: [
6+
'**/__tests__/**/*.+(ts|tsx)',
7+
'**/*.(test|spec).+(ts|tsx)'
8+
],
9+
transform: {
10+
'^.+\\.(ts|tsx)$': 'ts-jest',
11+
},
412
moduleNameMapper: {
5-
'^(\\.{1,2}/.*)\\.js$': '$1'
13+
'^badge-maker$': '<rootDir>/tests/__mocks__/badge-maker.js'
614
},
7-
testEnvironment: 'node'
15+
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
16+
coverageDirectory: 'coverage',
17+
collectCoverageFrom: [
18+
'api/**/*.{ts}',
19+
'!api/**/*.d.ts',
20+
],
821
};

0 commit comments

Comments
 (0)