-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwebhook_server.py
More file actions
604 lines (494 loc) · 17.7 KB
/
webhook_server.py
File metadata and controls
604 lines (494 loc) · 17.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
"""
FastAPI webhook server for receiving GitHub webhook events.
This module provides a REST API endpoint that receives GitHub webhook events,
validates them, and forwards formatted notifications to the Discord bot.
"""
import hashlib
import hmac
import json
import logging
from typing import Dict, Any, Optional
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
from fastapi.responses import JSONResponse
import discord
from discord_bot import get_bot
from config import config
# Set up logging
logger = logging.getLogger(__name__)
# Initialize FastAPI app
app = FastAPI(
title="Patchy - GitHub Discord Webhook Bot",
description="Patchy's webhook server for receiving GitHub events and sending Discord notifications",
version="1.0.0"
)
def verify_github_signature(payload: bytes, signature: str) -> bool:
"""
Verify the GitHub webhook signature to ensure authenticity.
Args:
payload (bytes): The raw request payload
signature (str): The X-Hub-Signature-256 header value
Returns:
bool: True if the signature is valid, False otherwise
"""
if not config.GITHUB_WEBHOOK_SECRET:
logger.warning("GitHub webhook secret not configured. Skipping signature verification.")
return True
if not signature.startswith('sha256='):
logger.error("Invalid signature format")
return False
try:
expected_signature = hmac.new(
config.GITHUB_WEBHOOK_SECRET.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
received_signature = signature[7:] # Remove 'sha256=' prefix
return hmac.compare_digest(expected_signature, received_signature)
except Exception as e:
logger.error(f"Error verifying GitHub signature: {e}")
return False
async def process_github_event(event_type: str, payload: Dict[str, Any]) -> None:
"""
Process a GitHub webhook event and send appropriate Discord notification.
Args:
event_type (str): The type of GitHub event (push, pull_request, etc.)
payload (Dict[str, Any]): The GitHub webhook payload
"""
try:
bot = await get_bot()
# Create appropriate embed based on event type
embed = await create_event_embed(event_type, payload)
if embed:
success = await bot.send_github_notification(embed)
if not success:
logger.error(f"Failed to send notification for {event_type} event")
else:
logger.warning(f"No embed created for {event_type} event")
except Exception as e:
logger.error(f"Error processing {event_type} event: {e}")
# Try to send error notification to Discord
try:
bot = await get_bot()
await bot.send_error_notification(str(e), event_type)
except Exception as notify_error:
logger.error(f"Failed to send error notification: {notify_error}")
async def create_event_embed(event_type: str, payload: Dict[str, Any]) -> Optional[discord.Embed]:
"""
Create a Discord embed based on the GitHub event type and payload.
Args:
event_type (str): The type of GitHub event
payload (Dict[str, Any]): The GitHub webhook payload
Returns:
Optional[discord.Embed]: The formatted embed or None if event type is not supported
"""
try:
if event_type == "push":
return await create_push_embed(payload)
elif event_type == "pull_request":
return await create_pull_request_embed(payload)
elif event_type == "issues":
return await create_issue_embed(payload)
elif event_type == "release":
return await create_release_embed(payload)
elif event_type == "create":
return await create_create_embed(payload)
elif event_type == "delete":
return await create_delete_embed(payload)
else:
logger.info(f"Unsupported event type: {event_type}")
return None
except Exception as e:
logger.error(f"Error creating embed for {event_type}: {e}")
return None
async def create_push_embed(payload: Dict[str, Any]) -> discord.Embed:
"""
Create a Discord embed for push events.
Args:
payload (Dict[str, Any]): The GitHub push event payload
Returns:
discord.Embed: The formatted push event embed
"""
repository = payload.get("repository", {})
pusher = payload.get("pusher", {})
commits = payload.get("commits", [])
ref = payload.get("ref", "")
compare_url = payload.get("compare", "")
# Determine branch name
branch = ref.replace("refs/heads/", "") if ref.startswith("refs/heads/") else ref
# Create embed
embed = discord.Embed(
title=f"📝 New Push to {branch}",
description=f"**{len(commits)} commit(s)** pushed to `{repository.get('name', 'Unknown')}`",
color=0x28a745, # Green color
url=compare_url
)
# Add repository info
embed.add_field(
name="Repository",
value=f"[{repository.get('full_name', 'Unknown')}]({repository.get('html_url', '#')})",
inline=True
)
# Add pusher info
embed.add_field(
name="Pushed by",
value=pusher.get("name", "Unknown"),
inline=True
)
# Add commit count
embed.add_field(
name="Commits",
value=str(len(commits)),
inline=True
)
# Add latest commit info if available
if commits:
latest_commit = commits[-1]
commit_message = latest_commit.get("message", "No message")
# Truncate long commit messages
if len(commit_message) > 100:
commit_message = commit_message[:97] + "..."
embed.add_field(
name="Latest Commit",
value=f"[{commit_message}]({latest_commit.get('url', '#')})",
inline=False
)
# Set author and timestamp
embed.set_author(
name=pusher.get("name", "Unknown"),
icon_url=pusher.get("avatar_url", "")
)
embed.set_footer(text="Patchy - GitHub Push Event")
embed.timestamp = discord.utils.utcnow()
return embed
async def create_pull_request_embed(payload: Dict[str, Any]) -> discord.Embed:
"""
Create a Discord embed for pull request events.
Args:
payload (Dict[str, Any]): The GitHub pull request event payload
Returns:
discord.Embed: The formatted pull request event embed
"""
pr = payload.get("pull_request", {})
action = payload.get("action", "")
repository = payload.get("repository", {})
# Determine color based on action
color_map = {
"opened": 0x28a745, # Green
"closed": 0xdc3545, # Red
"merged": 0x6f42c1, # Purple
"reopened": 0x17a2b8, # Blue
}
color = color_map.get(action, 0x6c757d) # Default gray
# Create embed
embed = discord.Embed(
title=f"🔀 Pull Request {action.title()}",
description=f"**{pr.get('title', 'No title')}**",
color=color,
url=pr.get("html_url", "#")
)
# Add PR number and status
embed.add_field(
name="PR #" + str(pr.get("number", "?")),
value=f"**{pr.get('state', 'unknown').title()}**",
inline=True
)
# Add repository
embed.add_field(
name="Repository",
value=f"[{repository.get('full_name', 'Unknown')}]({repository.get('html_url', '#')})",
inline=True
)
# Add author
user = pr.get("user", {})
embed.add_field(
name="Author",
value=user.get("login", "Unknown"),
inline=True
)
# Add description if available
body = pr.get("body", "")
if body:
# Truncate long descriptions
if len(body) > 500:
body = body[:497] + "..."
embed.add_field(
name="Description",
value=body,
inline=False
)
# Set author and timestamp
embed.set_author(
name=user.get("login", "Unknown"),
icon_url=user.get("avatar_url", "")
)
embed.set_footer(text="Patchy - GitHub Pull Request Event")
embed.timestamp = discord.utils.utcnow()
return embed
async def create_issue_embed(payload: Dict[str, Any]) -> discord.Embed:
"""
Create a Discord embed for issue events.
Args:
payload (Dict[str, Any]): The GitHub issue event payload
Returns:
discord.Embed: The formatted issue event embed
"""
issue = payload.get("issue", {})
action = payload.get("action", "")
repository = payload.get("repository", {})
# Determine color based on action
color_map = {
"opened": 0xdc3545, # Red
"closed": 0x28a745, # Green
"reopened": 0x17a2b8, # Blue
}
color = color_map.get(action, 0x6c757d) # Default gray
# Create embed
embed = discord.Embed(
title=f"🐛 Issue {action.title()}",
description=f"**{issue.get('title', 'No title')}**",
color=color,
url=issue.get("html_url", "#")
)
# Add issue number and state
embed.add_field(
name="Issue #" + str(issue.get("number", "?")),
value=f"**{issue.get('state', 'unknown').title()}**",
inline=True
)
# Add repository
embed.add_field(
name="Repository",
value=f"[{repository.get('full_name', 'Unknown')}]({repository.get('html_url', '#')})",
inline=True
)
# Add author
user = issue.get("user", {})
embed.add_field(
name="Author",
value=user.get("login", "Unknown"),
inline=True
)
# Add labels if available
labels = issue.get("labels", [])
if labels:
label_names = [label.get("name", "") for label in labels[:5]] # Limit to 5 labels
embed.add_field(
name="Labels",
value=", ".join(label_names),
inline=False
)
# Set author and timestamp
embed.set_author(
name=user.get("login", "Unknown"),
icon_url=user.get("avatar_url", "")
)
embed.set_footer(text="Patchy - GitHub Issue Event")
embed.timestamp = discord.utils.utcnow()
return embed
async def create_release_embed(payload: Dict[str, Any]) -> discord.Embed:
"""
Create a Discord embed for release events.
Args:
payload (Dict[str, Any]): The GitHub release event payload
Returns:
discord.Embed: The formatted release event embed
"""
release = payload.get("release", {})
action = payload.get("action", "")
repository = payload.get("repository", {})
# Create embed
embed = discord.Embed(
title=f"🚀 Release {action.title()}",
description=f"**{release.get('name', release.get('tag_name', 'No title'))}**",
color=0xffc107, # Yellow/Orange color
url=release.get("html_url", "#")
)
# Add tag name
embed.add_field(
name="Tag",
value=release.get("tag_name", "No tag"),
inline=True
)
# Add repository
embed.add_field(
name="Repository",
value=f"[{repository.get('full_name', 'Unknown')}]({repository.get('html_url', '#')})",
inline=True
)
# Add author
author = release.get("author", {})
embed.add_field(
name="Author",
value=author.get("login", "Unknown"),
inline=True
)
# Add description if available
body = release.get("body", "")
if body:
# Truncate long descriptions
if len(body) > 500:
body = body[:497] + "..."
embed.add_field(
name="Description",
value=body,
inline=False
)
# Set author and timestamp
embed.set_author(
name=author.get("login", "Unknown"),
icon_url=author.get("avatar_url", "")
)
embed.set_footer(text="Patchy - GitHub Release Event")
embed.timestamp = discord.utils.utcnow()
return embed
async def create_create_embed(payload: Dict[str, Any]) -> discord.Embed:
"""
Create a Discord embed for create events (branches, tags).
Args:
payload (Dict[str, Any]): The GitHub create event payload
Returns:
discord.Embed: The formatted create event embed
"""
ref = payload.get("ref", "")
ref_type = payload.get("ref_type", "")
repository = payload.get("repository", {})
sender = payload.get("sender", {})
# Determine emoji based on ref type
emoji_map = {
"branch": "🌿",
"tag": "🏷️",
}
emoji = emoji_map.get(ref_type, "📝")
# Create embed
embed = discord.Embed(
title=f"{emoji} {ref_type.title()} Created",
description=f"**{ref}** created in `{repository.get('name', 'Unknown')}`",
color=0x17a2b8, # Blue color
url=repository.get("html_url", "#")
)
# Add repository
embed.add_field(
name="Repository",
value=f"[{repository.get('full_name', 'Unknown')}]({repository.get('html_url', '#')})",
inline=True
)
# Add creator
embed.add_field(
name="Created by",
value=sender.get("login", "Unknown"),
inline=True
)
# Set author and timestamp
embed.set_author(
name=sender.get("login", "Unknown"),
icon_url=sender.get("avatar_url", "")
)
embed.set_footer(text="Patchy - GitHub Create Event")
embed.timestamp = discord.utils.utcnow()
return embed
async def create_delete_embed(payload: Dict[str, Any]) -> discord.Embed:
"""
Create a Discord embed for delete events (branches, tags).
Args:
payload (Dict[str, Any]): The GitHub delete event payload
Returns:
discord.Embed: The formatted delete event embed
"""
ref = payload.get("ref", "")
ref_type = payload.get("ref_type", "")
repository = payload.get("repository", {})
sender = payload.get("sender", {})
# Determine emoji based on ref type
emoji_map = {
"branch": "🗑️",
"tag": "🗑️",
}
emoji = emoji_map.get(ref_type, "🗑️")
# Create embed
embed = discord.Embed(
title=f"{emoji} {ref_type.title()} Deleted",
description=f"**{ref}** deleted from `{repository.get('name', 'Unknown')}`",
color=0xdc3545, # Red color
url=repository.get("html_url", "#")
)
# Add repository
embed.add_field(
name="Repository",
value=f"[{repository.get('full_name', 'Unknown')}]({repository.get('html_url', '#')})",
inline=True
)
# Add deleter
embed.add_field(
name="Deleted by",
value=sender.get("login", "Unknown"),
inline=True
)
# Set author and timestamp
embed.set_author(
name=sender.get("login", "Unknown"),
icon_url=sender.get("avatar_url", "")
)
embed.set_footer(text="Patchy - GitHub Delete Event")
embed.timestamp = discord.utils.utcnow()
return embed
@app.get("/")
async def root():
"""Health check endpoint."""
return {"message": "Patchy - GitHub Discord Webhook Bot is running!", "status": "healthy"}
@app.get("/health")
async def health_check():
"""Detailed health check endpoint."""
return {
"status": "healthy",
"service": "Patchy - GitHub Discord Webhook Bot",
"version": "1.0.0"
}
@app.post("/webhook")
async def github_webhook(request: Request, background_tasks: BackgroundTasks):
"""
Main webhook endpoint for receiving GitHub events.
This endpoint receives GitHub webhook events, validates the signature,
and processes the event in the background.
"""
try:
# Get the raw body for signature verification
body = await request.body()
# Get headers
signature = request.headers.get("X-Hub-Signature-256", "")
event_type = request.headers.get("X-GitHub-Event", "")
# Verify signature
if not verify_github_signature(body, signature):
logger.warning("Invalid GitHub webhook signature")
raise HTTPException(status_code=401, detail="Invalid signature")
# Parse JSON payload
try:
payload = json.loads(body.decode('utf-8'))
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON payload: {e}")
raise HTTPException(status_code=400, detail="Invalid JSON payload")
# Log the event
logger.info(f"Received {event_type} event from GitHub")
# Process the event in the background
background_tasks.add_task(process_github_event, event_type, payload)
return {"message": "Event received and queued for processing", "event_type": event_type}
except HTTPException:
raise
except Exception as e:
logger.error(f"Unexpected error in webhook endpoint: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
"""Custom HTTP exception handler with logging."""
logger.warning(f"HTTP {exc.status_code}: {exc.detail} - {request.url}")
return JSONResponse(
status_code=exc.status_code,
content={"error": exc.detail, "status_code": exc.status_code}
)
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
"""General exception handler for unexpected errors."""
logger.error(f"Unhandled exception: {exc} - {request.url}")
return JSONResponse(
status_code=500,
content={"error": "Internal server error", "status_code": 500}
)