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+ }
0 commit comments