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
21 changes: 21 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,27 @@ DATABASE_URL=postgres://user:password@localhost:5432/boost_dashboard
# SLACK_XOXC_TOKEN=xoxc-...
# SLACK_XOXD_TOKEN=...

# --- User OAuth (cppa_slack_tracker/slack_oauth_server.py + IM message sync) ---
# Run the helper: python -m cppa_slack_tracker.slack_oauth_server
# Open https://api.slack.com/apps → your app → OAuth & Permissions:
# - Add User Token Scopes (e.g. im:read, im:history, mpim:read, mpim:history, …).
# - Redirect URLs: must match SLACK_REDIRECT_URI exactly (e.g. https://your-host/slack/oauth/callback).
# After a user visits /slack/connect and authorizes, tokens are stored under
# <directory containing the .env loaded by slack_oauth_server>/credential/slack_user_tokens.json
# (align SLACK_USER_TOKENS_PATH with Django cppa_slack_tracker.user_tokens / BASE_DIR if they differ).
# SLACK_CLIENT_ID=10473611477057.xxxxxxxxx
# SLACK_CLIENT_SECRET=xxxxxxxx
# SLACK_REDIRECT_URI=https://your-public-host.example.com/slack/oauth/callback
# Optional: comma-separated user scopes (defaults in slack_oauth_server match IM + channels + groups + mpim).
# SLACK_USER_SCOPES=channels:history,channels:read,groups:history,groups:read,im:history,im:read,mpim:history,mpim:read
# Optional: override JSON path for user tokens (default: same dir as .env → credential/slack_user_tokens.json).
# SLACK_USER_TOKENS_PATH=/absolute/path/to/slack_user_tokens.json
# Optional: OAuth CSRF state TTL (seconds), server bind, debug.
# OAUTH_STATE_TTL_S=600
# SLACK_OAUTH_PORT=8000
# HOST=127.0.0.1
# SLACK_OAUTH_DEBUG=0

# =============================================================================
# Selenium / Chrome (optional; for Slack xoxc/xoxd token extraction only)
# =============================================================================
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ celerybeat-schedule
celerybeat-schedule-*
celerybeat.pid

# Credentials
credential/

# Environment
.env
.env.local
Expand Down
22 changes: 22 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,30 @@ def __copy__(self):
BaseContext.__copy__ = __copy__


def _django_connection_created_create_slack_private_schema(
sender, connection, **kwargs
):
"""Create slack_private before table sync when pytest uses --no-migrations.

Must be a module-level function: Django's Signal.connect() defaults to weak=True,
so a nested handler can be garbage-collected after pytest_configure returns and
never run on CI (e.g. Python 3.13 + full suite).
"""
if connection.vendor != "postgresql":
return
with connection.cursor() as cursor:
cursor.execute("CREATE SCHEMA IF NOT EXISTS slack_private;")


def _ensure_slack_private_schema_on_postgres_connect():
from django.db.backends.signals import connection_created

connection_created.connect(_django_connection_created_create_slack_private_schema)


def pytest_configure(config): # noqa: F841 (pytest hook; name must match spec)
_patch_django_context_copy_py314()
_ensure_slack_private_schema_on_postgres_connect()


# Load app-level fixture modules so fixtures from each app are available everywhere.
Expand Down
86 changes: 86 additions & 0 deletions cppa_slack_tracker/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@
from .models import (
SlackTeam,
SlackChannel,
SlackChannelPrivate,
SlackMessage,
SlackMessagePrivate,
SlackChannelMembership,
SlackChannelMembershipChangeLog,
SlackChannelMembershipPrivate,
SlackChannelMembershipChangeLogPrivate,
)


Expand Down Expand Up @@ -39,6 +43,23 @@ class SlackChannelAdmin(admin.ModelAdmin):
raw_id_fields = ("team", "creator")


@admin.register(SlackChannelPrivate)
class SlackChannelPrivateAdmin(admin.ModelAdmin):
list_display = (
"channel_id",
"channel_name",
"channel_type",
"team",
"creator",
"created_at",
)
list_filter = ("channel_type", "created_at", "updated_at")
search_fields = ("channel_id", "channel_name", "description")
readonly_fields = ("created_at", "updated_at")
date_hierarchy = "created_at"
raw_id_fields = ("team", "creator")


@admin.register(SlackMessage)
class SlackMessageAdmin(admin.ModelAdmin):
list_display = (
Expand Down Expand Up @@ -69,6 +90,36 @@ def message_preview(self, obj):
return obj.message[:50] + "..." if len(obj.message) > 50 else obj.message


@admin.register(SlackMessagePrivate)
class SlackMessagePrivateAdmin(admin.ModelAdmin):
list_display = (
"ts",
"channel",
"user",
"message_preview",
"slack_message_created_at",
)
list_filter = ("slack_message_created_at", "slack_message_updated_at")
search_fields = (
"ts",
"message",
"channel__channel_name",
"user__username",
)
readonly_fields = (
"ts",
"slack_message_created_at",
"slack_message_updated_at",
)
date_hierarchy = "slack_message_created_at"
raw_id_fields = ("channel", "user")

@admin.display(description="Message Preview")
def message_preview(self, obj):
"""Return a short preview of the message."""
return obj.message[:50] + "..." if len(obj.message) > 50 else obj.message


@admin.register(SlackChannelMembership)
class SlackChannelMembershipAdmin(admin.ModelAdmin):
list_display = (
Expand Down Expand Up @@ -102,3 +153,38 @@ class SlackChannelMembershipChangeLogAdmin(admin.ModelAdmin):
readonly_fields = ("created_at",)
date_hierarchy = "created_at"
raw_id_fields = ("channel", "user")


@admin.register(SlackChannelMembershipPrivate)
class SlackChannelMembershipPrivateAdmin(admin.ModelAdmin):
list_display = (
"channel",
"user",
"is_restricted",
"is_deleted",
"created_at",
"updated_at",
)
list_filter = ("is_restricted", "is_deleted", "created_at", "updated_at")
search_fields = (
"channel__channel_name",
"user__username",
"user__display_name",
)
readonly_fields = ("created_at", "updated_at")
date_hierarchy = "created_at"
raw_id_fields = ("channel", "user")


@admin.register(SlackChannelMembershipChangeLogPrivate)
class SlackChannelMembershipChangeLogPrivateAdmin(admin.ModelAdmin):
list_display = ("channel", "user", "is_joined", "created_at")
list_filter = ("is_joined", "created_at")
search_fields = (
"channel__channel_name",
"user__username",
"user__display_name",
)
readonly_fields = ("created_at",)
date_hierarchy = "created_at"
raw_id_fields = ("channel", "user")
37 changes: 36 additions & 1 deletion cppa_slack_tracker/fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,14 @@ def fetch_team_info(
def fetch_channel_list(
_team_id: str,
*,
types: str = "public_channel",
types: str = "public_channel,private_channel,mpim,im",
exclude_archived: bool = False,
client=None,
) -> list[dict]:
"""
Fetch channel list for the workspace (team_id).
The bot token is scoped to one workspace. Returns list of channel dicts (id, name, ...).
Default types include public, private, multi-party IM, and IM (see Slack conversations.list).
"""
if client is None:
client = get_slack_client(team_id=_team_id)
Expand All @@ -136,6 +137,40 @@ def fetch_channel_list(
return channels


def fetch_im_channel_list_for_user(
team_id: str,
user_token: str,
*,
exclude_archived: bool = False,
) -> list[dict]:
"""
List IM (direct message) conversations visible to the authorizing user.

Uses a user OAuth token (``xoxp-``); the bot token cannot see user-to-user DMs.
Paginates ``conversations.list`` with ``types=im`` only.
"""
client = get_slack_client(bot_token=user_token, team_id=team_id)
channels: list[dict] = []
cursor = None
while True:
data = client.conversations_list(
types="im",
exclude_archived=exclude_archived,
limit=500,
cursor=cursor,
)
if not data.get("ok"):
logger.warning(
"conversations.list (im) failed: %s", data.get("error", "unknown")
)
break
channels.extend(data.get("channels", []))
cursor = (data.get("response_metadata") or {}).get("next_cursor")
if not cursor:
break
return channels


def _ts_to_utc_date(ts: Optional[str]) -> Optional[date]:
"""Convert Slack ts string to UTC date, or None if invalid."""
if not ts:
Expand Down
Loading
Loading