|
64 | 64 | register_job_post_channel, |
65 | 65 | unregister_job_post_channel, |
66 | 66 | ) |
| 67 | +from five08.job_leads import ( |
| 68 | + JobLead, |
| 69 | + JobLeadStatus, |
| 70 | + list_job_leads, |
| 71 | + mark_job_lead_posted, |
| 72 | + review_job_lead, |
| 73 | +) |
67 | 74 | from five08.job_match import ( |
68 | 75 | DISCORD_ROLES_EXCLUDE_FROM_SYNC, |
69 | 76 | JobRequirements, |
@@ -3405,6 +3412,271 @@ async def on_message(self, message: discord.Message) -> None: |
3405 | 3412 | if not recorded_interest: |
3406 | 3413 | await self._persist_thread_reply_activity(message) |
3407 | 3414 |
|
| 3415 | + @staticmethod |
| 3416 | + def _truncate_job_lead_text(value: str, limit: int) -> str: |
| 3417 | + """Trim lead text for Discord command responses.""" |
| 3418 | + normalized = value.replace("\r", " ").strip() |
| 3419 | + if limit <= 0: |
| 3420 | + return "" |
| 3421 | + if limit <= 3: |
| 3422 | + return normalized[:limit] |
| 3423 | + if len(normalized) <= limit: |
| 3424 | + return normalized |
| 3425 | + return f"{normalized[: limit - 3]}..." |
| 3426 | + |
| 3427 | + @classmethod |
| 3428 | + def _format_job_lead_review_line(cls, index: int, lead: JobLead) -> str: |
| 3429 | + tags = ", ".join(lead.tags[:5]) if lead.tags else "untagged" |
| 3430 | + title = cls._truncate_job_lead_text(lead.title, 120) |
| 3431 | + return ( |
| 3432 | + f"{index}. `{lead.id[:8]}` **{title}** " |
| 3433 | + f"({lead.confidence:.0%}; {tags})\n{lead.source_url}" |
| 3434 | + ) |
| 3435 | + |
| 3436 | + @classmethod |
| 3437 | + def _format_job_lead_thread_content(cls, lead: JobLead) -> str: |
| 3438 | + lines = [ |
| 3439 | + f"Source: {lead.source_url}", |
| 3440 | + f"Status: {lead.status.value}", |
| 3441 | + ] |
| 3442 | + if lead.organization: |
| 3443 | + lines.append(f"Organization: {lead.organization}") |
| 3444 | + if lead.location: |
| 3445 | + lines.append(f"Location: {lead.location}") |
| 3446 | + if lead.apply_url: |
| 3447 | + lines.append(f"Apply/contact: {lead.apply_url}") |
| 3448 | + if lead.tags: |
| 3449 | + lines.append(f"Lead tags: {', '.join(lead.tags)}") |
| 3450 | + metadata = "\n".join(lines) |
| 3451 | + separator = "\n\n" |
| 3452 | + body_limit = ( |
| 3453 | + settings.discord_sendmsg_character_limit - len(metadata) - len(separator) |
| 3454 | + ) |
| 3455 | + if body_limit <= 0: |
| 3456 | + return cls._truncate_job_lead_text( |
| 3457 | + metadata, |
| 3458 | + settings.discord_sendmsg_character_limit, |
| 3459 | + ) |
| 3460 | + body = cls._truncate_job_lead_text(lead.body_normalized, body_limit) |
| 3461 | + return f"{metadata}{separator}{body}" |
| 3462 | + |
| 3463 | + @staticmethod |
| 3464 | + def _job_lead_allowed_mentions() -> discord.AllowedMentions: |
| 3465 | + """Disable mention parsing for untrusted external lead content.""" |
| 3466 | + return discord.AllowedMentions.none() |
| 3467 | + |
| 3468 | + @staticmethod |
| 3469 | + def _resolve_job_lead_forum_tags( |
| 3470 | + channel: discord.ForumChannel, |
| 3471 | + lead: JobLead, |
| 3472 | + extra_tag_names: str | None, |
| 3473 | + ) -> list[discord.ForumTag]: |
| 3474 | + requested = {tag.casefold() for tag in lead.tags} |
| 3475 | + if lead.remote: |
| 3476 | + requested.add("remote") |
| 3477 | + for raw_name in (extra_tag_names or "").split(","): |
| 3478 | + normalized = raw_name.strip().casefold() |
| 3479 | + if normalized: |
| 3480 | + requested.add(normalized) |
| 3481 | + if not requested: |
| 3482 | + return [] |
| 3483 | + selected: list[discord.ForumTag] = [] |
| 3484 | + for tag in channel.available_tags: |
| 3485 | + normalized_name = tag.name.strip().casefold() |
| 3486 | + if normalized_name in requested: |
| 3487 | + selected.append(tag) |
| 3488 | + return selected[:5] |
| 3489 | + |
| 3490 | + @app_commands.command( |
| 3491 | + name="list-job-leads", |
| 3492 | + description="List pending externally sourced job leads awaiting review.", |
| 3493 | + ) |
| 3494 | + @app_commands.describe(limit="Number of pending leads to show, up to 10.") |
| 3495 | + @require_role("Steering Committee") |
| 3496 | + async def list_sourced_job_leads( |
| 3497 | + self, |
| 3498 | + interaction: discord.Interaction, |
| 3499 | + limit: int = 5, |
| 3500 | + ) -> None: |
| 3501 | + """Show pending scraped leads without publishing anything to Discord.""" |
| 3502 | + await interaction.response.defer(ephemeral=True) |
| 3503 | + safe_limit = max(1, min(limit, 10)) |
| 3504 | + try: |
| 3505 | + leads = await asyncio.to_thread( |
| 3506 | + list_job_leads, |
| 3507 | + settings, |
| 3508 | + status=JobLeadStatus.PENDING, |
| 3509 | + limit=safe_limit, |
| 3510 | + ) |
| 3511 | + except Exception as exc: |
| 3512 | + logger.warning("Failed listing job leads: %s", exc) |
| 3513 | + await interaction.followup.send( |
| 3514 | + "❌ Failed to load pending job leads.", |
| 3515 | + ephemeral=True, |
| 3516 | + ) |
| 3517 | + return |
| 3518 | + |
| 3519 | + if not leads: |
| 3520 | + await interaction.followup.send( |
| 3521 | + "No pending job leads found.", |
| 3522 | + ephemeral=True, |
| 3523 | + ) |
| 3524 | + return |
| 3525 | + |
| 3526 | + lines = [ |
| 3527 | + self._format_job_lead_review_line(index, lead) |
| 3528 | + for index, lead in enumerate(leads, start=1) |
| 3529 | + ] |
| 3530 | + await interaction.followup.send( |
| 3531 | + "Pending job leads:\n\n" + "\n\n".join(lines), |
| 3532 | + ephemeral=True, |
| 3533 | + ) |
| 3534 | + |
| 3535 | + @app_commands.command( |
| 3536 | + name="reject-job-lead", |
| 3537 | + description="Reject a sourced job lead so it will not be posted.", |
| 3538 | + ) |
| 3539 | + @app_commands.describe(lead_id="Lead UUID or unambiguous UUID prefix.") |
| 3540 | + @require_role("Steering Committee") |
| 3541 | + async def reject_sourced_job_lead( |
| 3542 | + self, |
| 3543 | + interaction: discord.Interaction, |
| 3544 | + lead_id: str, |
| 3545 | + ) -> None: |
| 3546 | + """Reject a scraped lead without publishing it.""" |
| 3547 | + await interaction.response.defer(ephemeral=True) |
| 3548 | + try: |
| 3549 | + lead = await asyncio.to_thread( |
| 3550 | + review_job_lead, |
| 3551 | + settings, |
| 3552 | + lead_id=lead_id, |
| 3553 | + status=JobLeadStatus.REJECTED, |
| 3554 | + reviewer_discord_user_id=str(interaction.user.id), |
| 3555 | + ) |
| 3556 | + except Exception as exc: |
| 3557 | + logger.warning("Failed rejecting job lead %s: %s", lead_id, exc) |
| 3558 | + await interaction.followup.send( |
| 3559 | + "❌ Failed to reject this job lead.", |
| 3560 | + ephemeral=True, |
| 3561 | + ) |
| 3562 | + return |
| 3563 | + |
| 3564 | + if lead is None: |
| 3565 | + await interaction.followup.send( |
| 3566 | + "⚠️ Could not find a pending/approved lead with that ID.", |
| 3567 | + ephemeral=True, |
| 3568 | + ) |
| 3569 | + return |
| 3570 | + |
| 3571 | + await interaction.followup.send( |
| 3572 | + f"✅ Rejected job lead `{lead.id[:8]}`.", |
| 3573 | + ephemeral=True, |
| 3574 | + ) |
| 3575 | + |
| 3576 | + @app_commands.command( |
| 3577 | + name="approve-job-lead", |
| 3578 | + description="Approve a sourced job lead and create a Discord forum thread.", |
| 3579 | + ) |
| 3580 | + @app_commands.describe( |
| 3581 | + lead_id="Lead UUID or unambiguous UUID prefix.", |
| 3582 | + channel="Forum channel where the approved lead should be posted.", |
| 3583 | + tags="Optional comma-separated Discord forum tag names to apply.", |
| 3584 | + ) |
| 3585 | + @require_role("Steering Committee") |
| 3586 | + async def approve_sourced_job_lead( |
| 3587 | + self, |
| 3588 | + interaction: discord.Interaction, |
| 3589 | + lead_id: str, |
| 3590 | + channel: discord.ForumChannel, |
| 3591 | + tags: str | None = None, |
| 3592 | + ) -> None: |
| 3593 | + """Approve a scraped lead and publish it to Discord.""" |
| 3594 | + guild = interaction.guild |
| 3595 | + if guild is None: |
| 3596 | + await interaction.response.send_message( |
| 3597 | + "⚠️ This command must be used inside a server.", |
| 3598 | + ephemeral=True, |
| 3599 | + ) |
| 3600 | + return |
| 3601 | + |
| 3602 | + await interaction.response.defer(ephemeral=True) |
| 3603 | + try: |
| 3604 | + lead = await asyncio.to_thread( |
| 3605 | + review_job_lead, |
| 3606 | + settings, |
| 3607 | + lead_id=lead_id, |
| 3608 | + status=JobLeadStatus.APPROVED, |
| 3609 | + reviewer_discord_user_id=str(interaction.user.id), |
| 3610 | + ) |
| 3611 | + except Exception as exc: |
| 3612 | + logger.warning("Failed approving job lead %s: %s", lead_id, exc) |
| 3613 | + await interaction.followup.send( |
| 3614 | + "❌ Failed to approve this job lead.", |
| 3615 | + ephemeral=True, |
| 3616 | + ) |
| 3617 | + return |
| 3618 | + |
| 3619 | + if lead is None: |
| 3620 | + await interaction.followup.send( |
| 3621 | + "⚠️ Could not find a pending/approved lead with that ID.", |
| 3622 | + ephemeral=True, |
| 3623 | + ) |
| 3624 | + return |
| 3625 | + |
| 3626 | + applied_tags = self._resolve_job_lead_forum_tags(channel, lead, tags) |
| 3627 | + content = self._format_job_lead_thread_content(lead) |
| 3628 | + try: |
| 3629 | + created = await channel.create_thread( |
| 3630 | + name=self._truncate_job_lead_text(lead.title, 100), |
| 3631 | + content=content, |
| 3632 | + applied_tags=applied_tags, |
| 3633 | + allowed_mentions=self._job_lead_allowed_mentions(), |
| 3634 | + reason=f"Approved sourced job lead by {interaction.user}", |
| 3635 | + ) |
| 3636 | + except discord.Forbidden: |
| 3637 | + await interaction.followup.send( |
| 3638 | + "❌ Lead approved, but I do not have permission to create a thread in that forum.", |
| 3639 | + ephemeral=True, |
| 3640 | + ) |
| 3641 | + return |
| 3642 | + except discord.HTTPException as exc: |
| 3643 | + logger.warning("Failed posting approved job lead %s: %s", lead.id, exc) |
| 3644 | + await interaction.followup.send( |
| 3645 | + "❌ Lead approved, but Discord rejected the thread creation.", |
| 3646 | + ephemeral=True, |
| 3647 | + ) |
| 3648 | + return |
| 3649 | + |
| 3650 | + thread = getattr(created, "thread", created) |
| 3651 | + thread_id = str(getattr(thread, "id", "")) |
| 3652 | + if not thread_id: |
| 3653 | + await interaction.followup.send( |
| 3654 | + "⚠️ Lead approved and Discord returned success, but I could not record the thread ID.", |
| 3655 | + ephemeral=True, |
| 3656 | + ) |
| 3657 | + return |
| 3658 | + |
| 3659 | + posted = await asyncio.to_thread( |
| 3660 | + mark_job_lead_posted, |
| 3661 | + settings, |
| 3662 | + lead_id=lead.id, |
| 3663 | + reviewer_discord_user_id=str(interaction.user.id), |
| 3664 | + guild_id=str(guild.id), |
| 3665 | + channel_id=str(channel.id), |
| 3666 | + thread_id=thread_id, |
| 3667 | + ) |
| 3668 | + if posted is None: |
| 3669 | + await interaction.followup.send( |
| 3670 | + f"⚠️ Created <#{thread_id}>, but could not mark lead `{lead.id[:8]}` as posted.", |
| 3671 | + ephemeral=True, |
| 3672 | + ) |
| 3673 | + return |
| 3674 | + |
| 3675 | + await interaction.followup.send( |
| 3676 | + f"✅ Posted approved lead `{lead.id[:8]}` to <#{thread_id}>.", |
| 3677 | + ephemeral=True, |
| 3678 | + ) |
| 3679 | + |
3408 | 3680 | @app_commands.command( |
3409 | 3681 | name="register-jobs-channel", |
3410 | 3682 | description="Register a forum channel for automatic job-post matching.", |
|
0 commit comments