Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 74 additions & 7 deletions src/firefighter/slack/models/incident_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ def set_incident_channel_topic(

@slack_client
def invite_users(
self, users_mapped: list[User], client: WebClient = DefaultWebClient
self, users_mapped: list[User], client: WebClient = DefaultWebClient, slack_user_id: str | None = None
) -> None:
"""Invite users to the conversation, if they have a Slack user linked and are active.

Expand All @@ -121,7 +121,7 @@ def invite_users(
users_with_slack: list[User] = self._get_active_slack_users(users_mapped)
users_with_slack = list(set(users_with_slack)) # Remove duplicates

user_id_list: set[str] = self._get_slack_id_list(users_with_slack)
user_id_list: set[str] = self._get_slack_id_list(users_with_slack, slack_user_id)
if not user_id_list:
logger.info(f"No users to invite to the conversation {self}.")
return
Expand Down Expand Up @@ -161,19 +161,70 @@ def _get_active_slack_users(users_mapped: list[User]) -> list[User]:
return users_with_slack

@staticmethod
def _get_slack_id_list(users_with_slack: list[User]) -> set[str]:
return {u.slack_user.slack_id for u in users_with_slack if u.slack_user}
def _get_slack_id_list(users_with_slack: list[User], slack_user_id: str | None = None) -> set[str]:
from firefighter.firefighter.settings.settings_utils import (
config,
)

test_mode = config("TEST_MODE", default="False", cast=str).lower() == "true"
slack_ids = set()

for user in users_with_slack:
if not user:
continue

# In test mode, handle user ID mapping more carefully
if test_mode and hasattr(user, "slack_user") and user.slack_user and user.slack_user.slack_id:
stored_slack_id = user.slack_user.slack_id

# Skip users with old production IDs that don't exist in test workspace
# Old production IDs are alphabetic only, while test IDs contain numbers
if stored_slack_id.startswith("U") and not any(c.isdigit() for c in stored_slack_id):
logger.info(f"Test mode: Skipping user {user.id} with production slack_id {stored_slack_id}")
continue
# Valid test ID, use it
slack_ids.add(stored_slack_id)
elif hasattr(user, "slack_user") and user.slack_user and user.slack_user.slack_id:
# Non-test mode: use stored slack_id
slack_ids.add(user.slack_user.slack_id)

# In test mode, ensure the current user (from the event) is included if provided
if test_mode and slack_user_id and slack_user_id not in slack_ids:
logger.info(f"Test mode: Adding current user's Slack ID {slack_user_id} to invitation list")
slack_ids.add(slack_user_id)

return slack_ids

def _invite_users_to_conversation(
self, user_id_list: set[str], client: WebClient
) -> set[str]:
# In test mode, filter out invalid user IDs to prevent errors
from firefighter.firefighter.settings.settings_utils import (
config,
)
test_mode = config("TEST_MODE", default="False", cast=str).lower() == "true"

if test_mode:
logger.info(f"Test mode: Attempting to invite users with IDs: {user_id_list}")

try:
done = self._invite_users_with_slack_id(user_id_list, client)
except SlackApiError:
except SlackApiError as e:
logger.warning(
f"Could not batch import Slack users! Slack IDs: {user_id_list}",
exc_info=True,
)

# In test mode, if batch invite fails due to user_not_found, skip individual retries
if test_mode and e.response.get("error") == "user_not_found":
logger.info("Test mode: Skipping individual invitations for user_not_found errors")
return set()

# If batch invite fails due to already_in_channel, consider all users as successfully invited
if e.response.get("error") == "already_in_channel":
logger.debug("All users already in channel - this is expected")
return user_id_list

done = self._invite_users_to_conversation_individually(user_id_list, client)
return done

Expand All @@ -193,14 +244,30 @@ def _invite_users_with_slack_id(
def _invite_users_to_conversation_individually(
self, slack_user_ids: set[str], client: WebClient
) -> set[str]:
from firefighter.firefighter.settings.settings_utils import (
config,
)
test_mode = config("TEST_MODE", default="False", cast=str).lower() == "true"

done = set()
for slack_user_id in slack_user_ids:
try:
self._invite_users_with_slack_id({slack_user_id}, client)
done.add(slack_user_id)
except SlackApiError:
except SlackApiError as e:
error_type = e.response.get("error", "unknown_error")

if test_mode and error_type == "user_not_found":
logger.info(f"Test mode: Skipping user_not_found error for user ID {slack_user_id}")
continue

if error_type == "already_in_channel":
logger.debug(f"User {slack_user_id} is already in channel - this is expected")
done.add(slack_user_id) # Consider as successfully "invited"
continue

logger.warning(
f"Could not import Slack user! User ID: {slack_user_id}",
f"Could not import Slack user! User ID: {slack_user_id} - {error_type}",
exc_info=True,
)
return done
Expand Down
58 changes: 48 additions & 10 deletions src/firefighter/slack/signals/create_incident_conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import logging
import os
from typing import TYPE_CHECKING, Any

from django.dispatch import receiver
Expand All @@ -18,14 +19,15 @@
SlackMessageIncidentDeclaredAnnouncement,
SlackMessageIncidentDeclaredAnnouncementGeneral,
)
from firefighter.slack.models.conversation import Conversation
from firefighter.slack.models.incident_channel import IncidentChannel
from firefighter.slack.models.user import SlackUser
from firefighter.slack.rules import (
should_publish_in_general_channel,
should_publish_in_it_deploy_channel,
)
from firefighter.slack.signals import incident_channel_done
from firefighter.slack.slack_app import DefaultWebClient
from firefighter.slack.utils.test_channels import get_or_create_test_conversation

if TYPE_CHECKING:
from firefighter.incidents.models.incident import Incident
Expand All @@ -35,7 +37,7 @@


@receiver(signal=create_incident_conversation)
def create_incident_slack_conversation(
def create_incident_slack_conversation( # noqa: PLR0912, PLR0915
incident: Incident,
*_args: Any,
**_kwargs: Any,
Expand Down Expand Up @@ -73,34 +75,70 @@ def create_incident_slack_conversation(
channel.set_incident_channel_topic()

# Add the person that opened the incident in the channel
invited_creator = False
creator_slack_id = None
if (
incident.created_by
and hasattr(incident.created_by, "slack_user")
and incident.created_by.slack_user
and incident.created_by.slack_user.slack_id
):
creator_slack_id = incident.created_by.slack_user.slack_id
try:
# Check if the invitation was successful by checking if the user is now a member
members_before = set(channel.incident.members.all())
channel.invite_users([incident.created_by])
members_after = set(channel.incident.members.all())

# If the user was added to members, invitation succeeded
if incident.created_by in members_after and incident.created_by not in members_before:
invited_creator = True
logger.info(f"Successfully invited creator {creator_slack_id}")
else:
logger.warning(f"Creator {creator_slack_id} was not added to incident members - invitation may have failed")

except SlackApiError:
logger.warning(
f"Could not import Slack opener user! Slack ID: {incident.created_by.slack_user.slack_id}, User {incident.created_by}, Channel ID {new_channel_id}",
f"Could not import Slack opener user! Slack ID: {creator_slack_id}, User {incident.created_by}, Channel ID {new_channel_id}",
exc_info=True,
)
else:
logger.warning("Could not find user Slack ID for opener_user!")

# In test mode, if creator invitation failed, try to get the Slack user ID from kwargs
test_mode = os.getenv("TEST_MODE", "False").lower() == "true"
slack_user_id = _kwargs.get("slack_user_id")

logger.info(f"Test mode: {test_mode}, invited_creator: {invited_creator}")
logger.info(f"Creator stored slack_id: {creator_slack_id}, event slack_user_id: {slack_user_id}")
if test_mode and not invited_creator and slack_user_id:
logger.info(f"Test mode: Attempting to invite creator using event Slack ID {slack_user_id}")
try:
# Invite directly using the Slack user ID from the event
from firefighter.slack.slack_app import slack_client

@slack_client
def invite_test_user(channel_instance, user_id, client=DefaultWebClient):
return channel_instance._invite_users_to_conversation({user_id}, client)

invite_test_user(channel, slack_user_id)
logger.info(f"Test mode: Successfully invited creator via Slack ID {slack_user_id}")
except SlackApiError:
logger.warning(
f"Test mode: Could not invite creator via Slack ID {slack_user_id}",
exc_info=True,
)

# Send message in the created channel
channel.send_message_and_save(
SlackMessageIncidentDeclaredAnnouncement(incident), pin=True
SlackMessageIncidentDeclaredAnnouncement(incident, slack_user_id=slack_user_id), pin=True
)

# Post in general channel #tech-incidents if needed
if should_publish_in_general_channel(incident, incident_update=None):
announcement_general = SlackMessageIncidentDeclaredAnnouncementGeneral(incident)
announcement_general = SlackMessageIncidentDeclaredAnnouncementGeneral(incident, slack_user_id=slack_user_id)

tech_incidents_conversation = Conversation.objects.get_or_none(
tag="tech_incidents"
)
tech_incidents_conversation = get_or_create_test_conversation("tech_incidents")
if tech_incidents_conversation:
tech_incidents_conversation.send_message_and_save(announcement_general)
else:
Expand All @@ -112,14 +150,14 @@ def create_incident_slack_conversation(
users_list: list[User] = incident.build_invite_list()

# Invite all users
incident.conversation.invite_users(users_list)
incident.conversation.invite_users(users_list, slack_user_id=slack_user_id)

# Post in #it-deploy if needed
if should_publish_in_it_deploy_channel(incident):
announcement_it_deploy = SlackMessageDeployWarning(incident)
announcement_it_deploy.id = f"{announcement_it_deploy.id}_{incident.id}"

it_deploy_conversation = Conversation.objects.get_or_none(tag="it_deploy")
it_deploy_conversation = get_or_create_test_conversation("it_deploy")
if it_deploy_conversation:
it_deploy_conversation.send_message_and_save(announcement_it_deploy)
else:
Expand Down
34 changes: 31 additions & 3 deletions src/firefighter/slack/slack_templating.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,41 @@ def date_time(date: datetime | None) -> str:
return localtime(date).strftime("%Y-%m-%d %H:%M")


def user_slack_handle_or_name(user: User | None) -> str:
"""Returns the Slack handle of the user in Slack MD format (`<@SLACK_ID>`) or the user full name."""
def user_slack_handle_or_name(user: User | None, slack_user_id: str | None = None) -> str:
"""Returns the Slack handle of the user in Slack MD format (`<@SLACK_ID>`) or the user full name.

Args:
user: The user to display
slack_user_id: Optional Slack user ID from current event context (used in TEST_MODE for the current action performer only)

Returns:
Slack handle format '<@USER_ID>' in production or test mode, or user full name as fallback
"""
if user is None:
return "∅"

if hasattr(user, "slack_user") and user.slack_user:
# In test mode: if slack_user_id is provided, it's only for the current action performer
# For other users (assigned to roles), use their stored slack_id if valid or fallback to name
from firefighter.firefighter.settings.settings_utils import config # noqa: PLC0415
test_mode = config("TEST_MODE", default="False", cast=str).lower() == "true"
if test_mode and slack_user_id:
# This is specifically for the "Updated by" context where we use the current action performer's ID
return f"<@{slack_user_id}>"

# In test mode: check if the user has a valid slack_id (not production ID)
if test_mode and hasattr(user, "slack_user") and user.slack_user and user.slack_user.slack_id:
# Skip production IDs that don't exist in test workspace - fallback to user name
if user.slack_user.slack_id.startswith("U") and len(user.slack_user.slack_id) >= 9:
# This looks like a production ID, fallback to user name in test mode
return user.full_name
# Valid test environment slack_id
return f"<@{user.slack_user.slack_id}>"

# In production: use the stored user.slack_user.slack_id from database
if hasattr(user, "slack_user") and user.slack_user and user.slack_user.slack_id:
return f"<@{user.slack_user.slack_id}>"

# Fallback to user full name
return user.full_name


Expand Down
Loading
Loading