|
4 | 4 |
|
5 | 5 | import asyncio |
6 | 6 | import logging |
| 7 | + |
| 8 | +from datetime import datetime, timedelta, timezone |
| 9 | +from typing import Any |
| 10 | + |
7 | 11 | from datetime import datetime |
8 | 12 | from typing import Any, Optional |
9 | 13 |
|
| 14 | + |
10 | 15 | import discord |
11 | 16 | from discord import app_commands |
12 | 17 |
|
|
15 | 20 | from ghdcbot.config.loader import load_config |
16 | 21 | from ghdcbot.core.errors import ConfigError |
17 | 22 | from ghdcbot.engine.identity_linking import IdentityLinkService, LinkClaim |
18 | | -from ghdcbot.engine.metrics import build_contribution_summary_message |
| 23 | +from ghdcbot.engine.metrics import ( |
| 24 | + build_contribution_summary_message, |
| 25 | + get_contribution_metrics, |
| 26 | + rank_by_activity, |
| 27 | + get_rank_for_user, |
| 28 | +) |
19 | 29 | from ghdcbot.engine.issue_assignment import ( |
20 | 30 | resolve_discord_to_github, |
21 | 31 | ) |
@@ -435,21 +445,159 @@ async def profile_cmd( |
435 | 445 | target = interaction.user if viewing_self else contributor |
436 | 446 | assert target is not None |
437 | 447 | discord_user_id = str(target.id) |
438 | | - lines: list[str] = [] |
439 | | - if not viewing_self: |
440 | | - lines.append(f"**Profile for {target.mention}**") |
441 | 448 |
|
442 | | - lines.extend( |
443 | | - _identity_status_lines(storage, config, discord_user_id, viewing_self) |
| 449 | + # 1. Fetch identity status from storage |
| 450 | + get_status = getattr(storage, "get_identity_status", None) |
| 451 | + max_age_days = None |
| 452 | + if getattr(config, "identity", None) is not None: |
| 453 | + max_age_days = getattr(config.identity, "verified_max_age_days", None) |
| 454 | + status = get_status(discord_user_id, max_age_days=max_age_days) if callable(get_status) else {} |
| 455 | + |
| 456 | + github_user = status.get("github_user") |
| 457 | + st = status.get("status") or "not_linked" |
| 458 | + |
| 459 | + # 2. Determine verification badge & color |
| 460 | + if st == "verified": |
| 461 | + status_label = "Verified ✅" |
| 462 | + color = discord.Color.green() |
| 463 | + elif st == "verified_stale": |
| 464 | + status_label = "Verified ⚠️ (Stale)" |
| 465 | + color = discord.Color.gold() |
| 466 | + elif st == "pending": |
| 467 | + status_label = "Pending ⏳" |
| 468 | + color = discord.Color.orange() |
| 469 | + else: |
| 470 | + status_label = "Not linked ❌" |
| 471 | + color = discord.Color.red() |
| 472 | + |
| 473 | + # 3. Create the Embed |
| 474 | + embed = discord.Embed( |
| 475 | + color=color, |
| 476 | + timestamp=datetime.now(timezone.utc) |
444 | 477 | ) |
445 | | - lines.append( |
446 | | - await _format_social_profiles_line(social_service, discord_user_id, viewing_self) |
| 478 | + |
| 479 | + # Show github user link and avatar if linked, and set author details |
| 480 | + if github_user: |
| 481 | + github_profile_url = f"https://github.com/{github_user}" |
| 482 | + embed.set_thumbnail(url=f"https://github.com/{github_user}.png") |
| 483 | + embed.set_author( |
| 484 | + name=f"{target.display_name} (@{github_user})", |
| 485 | + icon_url=target.display_avatar.url if target.display_avatar else None, |
| 486 | + url=github_profile_url, |
| 487 | + ) |
| 488 | + embed.add_field( |
| 489 | + name="GitHub Account", |
| 490 | + value=f"[{github_user}]({github_profile_url})", |
| 491 | + inline=True, |
| 492 | + ) |
| 493 | + else: |
| 494 | + embed.set_author( |
| 495 | + name=f"{target.display_name}", |
| 496 | + icon_url=target.display_avatar.url if target.display_avatar else None, |
| 497 | + ) |
| 498 | + embed.add_field(name="GitHub Account", value="Not Linked ❌", inline=True) |
| 499 | + |
| 500 | + embed.add_field(name="Verification Status", value=status_label, inline=True) |
| 501 | + |
| 502 | + # Add verification date if verified |
| 503 | + verified_at = status.get("verified_at") |
| 504 | + if verified_at: |
| 505 | + try: |
| 506 | + dt = datetime.fromisoformat(verified_at.replace("Z", "+00:00")) |
| 507 | + verified_at_str = dt.strftime("%Y-%m-%d %H:%M UTC") |
| 508 | + except (ValueError, TypeError): |
| 509 | + verified_at_str = verified_at |
| 510 | + embed.add_field(name="Verified At", value=verified_at_str, inline=True) |
| 511 | + elif github_user: |
| 512 | + embed.add_field(name="Verified At", value="—", inline=True) |
| 513 | + |
| 514 | + # Add warnings in the description if verification is stale |
| 515 | + if status.get("is_stale"): |
| 516 | + if viewing_self: |
| 517 | + embed.description = "⚠️ **Warning:** Your identity verification is stale. Use `/verify-link` to refresh it." |
| 518 | + else: |
| 519 | + embed.description = "⚠️ **Warning:** Their identity verification is stale." |
| 520 | + |
| 521 | + # 4. Fetch contribution metrics if GitHub user exists |
| 522 | + if github_user: |
| 523 | + metrics_available = True |
| 524 | + try: |
| 525 | + now_utc = datetime.now(timezone.utc) |
| 526 | + start_30 = now_utc - timedelta(days=30) |
| 527 | + |
| 528 | + def _fetch_metrics(): |
| 529 | + metrics_list = get_contribution_metrics(storage, start_30, now_utc) |
| 530 | + user_metrics = next((m for m in metrics_list if m.github_user == github_user), None) |
| 531 | + ranked_30 = rank_by_activity(metrics_list) |
| 532 | + rank = get_rank_for_user(ranked_30, github_user) |
| 533 | + return user_metrics, rank |
| 534 | + |
| 535 | + user_metrics, rank = await asyncio.to_thread(_fetch_metrics) |
| 536 | + except Exception as e: |
| 537 | + logging.getLogger("ghdcbot.bot").debug( |
| 538 | + "Error fetching contribution metrics for /profile: %s", e |
| 539 | + ) |
| 540 | + metrics_available = False |
| 541 | + user_metrics, rank = None, None |
| 542 | + |
| 543 | + if user_metrics: |
| 544 | + metrics_lines = [ |
| 545 | + f"📝 **PRs:** {user_metrics.prs_opened} Opened / {user_metrics.prs_merged} Merged", |
| 546 | + f"👀 **Reviews:** {user_metrics.reviews_submitted} Submitted", |
| 547 | + f"💬 **Issues & Comments:** {user_metrics.issues_opened} Issues / {user_metrics.comments} Comments", |
| 548 | + ] |
| 549 | + if rank is not None: |
| 550 | + metrics_lines.append(f"🏆 **Rank:** #{rank} in server activity") |
| 551 | + |
| 552 | + embed.add_field( |
| 553 | + name="📊 Contributions (Last 30 Days)", |
| 554 | + value="\n".join(metrics_lines), |
| 555 | + inline=False, |
| 556 | + ) |
| 557 | + elif metrics_available: |
| 558 | + embed.add_field( |
| 559 | + name="📊 Contributions (Last 30 Days)", |
| 560 | + value="No activity recorded in the last 30 days.", |
| 561 | + inline=False, |
| 562 | + ) |
| 563 | + else: |
| 564 | + embed.add_field( |
| 565 | + name="📊 Contributions (Last 30 Days)", |
| 566 | + value="Contribution data is temporarily unavailable.", |
| 567 | + inline=False, |
| 568 | + ) |
| 569 | + def _truncate_embed_field_value(value: str, limit: int = 1024) -> str: |
| 570 | + if len(value) <= limit: |
| 571 | + return value |
| 572 | + return f"{value[:limit - 1]}…" |
| 573 | + # 5. Fetch and add Social Profiles |
| 574 | + socials_line = await _format_social_profiles_line(social_service, discord_user_id, viewing_self) |
| 575 | + if socials_line.startswith("**Social Profiles:**"): |
| 576 | + socials_value = socials_line.replace("**Social Profiles:**", "").strip() |
| 577 | + else: |
| 578 | + socials_value = socials_line.strip() |
| 579 | + embed.add_field( |
| 580 | + name="Connected Socials", |
| 581 | + value=_truncate_embed_field_value(socials_value), |
| 582 | + inline=False, |
447 | 583 | ) |
448 | | - lines.append(await _format_roles_line(discord_reader, discord_user_id)) |
449 | | - await interaction.followup.send( |
450 | | - "\n".join(lines), ephemeral=True, suppress_embeds=True |
| 584 | + # 6. Fetch and add Discord Roles |
| 585 | + roles_line = await _format_roles_line(discord_reader, discord_user_id) |
| 586 | + if roles_line.startswith("**Roles:**"): |
| 587 | + roles_value = roles_line.replace("**Roles:**", "").strip() |
| 588 | + else: |
| 589 | + roles_value = roles_line.strip() |
| 590 | + |
| 591 | + embed.add_field( |
| 592 | + name="Server Roles", |
| 593 | + value=_truncate_embed_field_value(roles_value), |
| 594 | + inline=False, |
451 | 595 | ) |
452 | 596 |
|
| 597 | + embed.set_footer(text="Gitcord Automation Engine") |
| 598 | + |
| 599 | + await interaction.followup.send(embed=embed, ephemeral=True) |
| 600 | + |
453 | 601 | @tree.command( |
454 | 602 | name="summary", |
455 | 603 | description="Show contribution metrics (last 7 and 30 days; optional Discord member)", |
|
0 commit comments