Skip to content

Commit d5a3a74

Browse files
authored
Merge pull request #53 from zomglings/rolodex
Implement rolodex: local cache for users and channels
2 parents 942756f + e73457f commit d5a3a74

File tree

15 files changed

+936
-21
lines changed

15 files changed

+936
-21
lines changed

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,40 @@ clacks recent
160160
clacks recent -l 50
161161
```
162162

163+
## Rolodex
164+
165+
Manage aliases for users and channels. Aliases resolve to platform-specific IDs (e.g., Slack user IDs).
166+
167+
Sync from Slack API:
168+
```bash
169+
clacks rolodex sync
170+
```
171+
172+
Add alias manually:
173+
```bash
174+
clacks rolodex add <alias> -t <target-id> -T <target-type>
175+
clacks rolodex add kartik -t U03QPJ2KMJ6 -T user
176+
clacks rolodex add dev-channel -t C08740LGAE6 -T channel
177+
```
178+
179+
List aliases:
180+
```bash
181+
clacks rolodex list
182+
clacks rolodex list -T user
183+
clacks rolodex list -p slack
184+
```
185+
186+
Remove alias:
187+
```bash
188+
clacks rolodex remove <alias> -T <target-type>
189+
```
190+
191+
Show valid target types for a platform:
192+
```bash
193+
clacks rolodex platforminfo -p slack
194+
clacks rolodex platforminfo -p github
195+
```
196+
163197
## Output
164198

165199
All commands output JSON to stdout. Redirect to file:

operations/rolodex/README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Rolodex Implementation
2+
3+
## Overview
4+
5+
Local alias database for resolving human-friendly names to platform-specific IDs. Supports multiple platforms (Slack, GitHub) with platform-specific target types.
6+
7+
## Database Schema
8+
9+
Single table: `aliases`
10+
11+
| Column | Type | Notes |
12+
|--------|------|-------|
13+
| alias | String | Primary key (composite) |
14+
| context | String | Primary key (composite) - auth context name |
15+
| target_type | String | Primary key (composite) - e.g., user, channel |
16+
| platform | String | Platform name (slack, github) |
17+
| target_id | String | Platform-specific ID (e.g., U0876FVQ58C) |
18+
19+
Unique constraint: (alias, context, target_type)
20+
21+
## Platforms and Target Types
22+
23+
Defined in `rolodex/data.py`:
24+
25+
- **slack**: user, channel
26+
- **github**: user, repo, org
27+
28+
## CLI Commands
29+
30+
```bash
31+
clacks rolodex add <alias> -t <target-id> -T <target-type> [-p <platform>]
32+
clacks rolodex list [-p <platform>] [-T <target-type>] [-t <target-id>]
33+
clacks rolodex remove <alias> -T <target-type>
34+
clacks rolodex sync
35+
clacks rolodex platforminfo -p <platform>
36+
```
37+
38+
## Resolution Flow
39+
40+
`resolve_user_id` and `resolve_channel_id` in `messaging/operations.py`:
41+
42+
1. Check if already a Slack ID (U..., C..., D..., G...)
43+
2. Check rolodex aliases (filtered by platform)
44+
3. Fall back to Slack API
45+
46+
## File Structure
47+
48+
```
49+
src/slack_clacks/rolodex/
50+
__init__.py
51+
data.py # Platform and target type constants
52+
models.py # SQLAlchemy Alias model
53+
operations.py # Database operations
54+
cli.py # CLI commands
55+
```
56+
57+
## Related Issues
58+
59+
- #38 - clacks rolodex (design doc)
60+
- #50 - Add user and channel lookup commands
61+
- #18 - Cache channel and user metadata in database

operations/rolodex/checklist.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Rolodex Implementation Checklist
2+
3+
## Schema
4+
5+
Single table: `aliases`
6+
7+
```
8+
aliases:
9+
alias String PK
10+
context String PK
11+
target_type String PK
12+
platform String
13+
target_id String
14+
```
15+
16+
Unique per (alias, context, target_type).
17+
18+
## CLI Commands
19+
20+
- [x] `clacks rolodex add` - add an alias
21+
- [x] `clacks rolodex list` - list aliases with filters (-p platform, -T target_type, -t target_id)
22+
- [x] `clacks rolodex remove` - remove an alias
23+
- [x] `clacks rolodex sync` - sync from Slack API
24+
- [x] `clacks rolodex platforminfo` - show valid target types for a platform
25+
26+
## Operations
27+
28+
- [x] `add_alias()` - add or update an alias
29+
- [x] `get_alias()` - lookup by (alias, context, target_type)
30+
- [x] `list_aliases()` - list with filters
31+
- [x] `remove_alias()` - delete an alias
32+
- [x] `resolve_alias()` - resolve identifier in context
33+
- [x] `sync_from_slack()` - sync users and channels from Slack API
34+
- [x] `validate_platform_target_type()` - validate platform/target_type combinations
35+
- [x] `get_platform_target_types()` - get valid target types for platform
36+
37+
## Resolution
38+
39+
- [x] `resolve_user_id()` checks aliases first, then falls back to Slack API
40+
- [x] `resolve_channel_id()` checks aliases first, then falls back to Slack API
41+
42+
## Finalization
43+
44+
- [x] Run all checks (ruff, mypy, tests)
45+
- [x] Commit and update PR

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "slack-clacks"
3-
version = "0.3.3"
3+
version = "0.4.0"
44
description = "the default mode of degenerate communication."
55
readme = "README.md"
66
license = { text = "MIT" }
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""add aliases table
2+
3+
Revision ID: a1b2c3d4e5f6
4+
Revises: 6713eb6c63d1
5+
Create Date: 2026-01-08 12:00:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = "a1b2c3d4e5f6"
16+
down_revision: Union[str, Sequence[str], None] = "6713eb6c63d1"
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Create aliases table with FK to contexts."""
23+
op.create_table(
24+
"aliases",
25+
sa.Column("alias", sa.String(), nullable=False),
26+
sa.Column("context", sa.String(), nullable=False),
27+
sa.Column("target_type", sa.String(), nullable=False),
28+
sa.Column("platform", sa.String(), nullable=False),
29+
sa.Column("target_id", sa.String(), nullable=False),
30+
sa.ForeignKeyConstraint(
31+
["context"],
32+
["contexts.name"],
33+
ondelete="CASCADE",
34+
),
35+
sa.PrimaryKeyConstraint("alias", "context", "target_type"),
36+
)
37+
op.create_index(
38+
"ix_aliases_platform_target",
39+
"aliases",
40+
["platform", "target_id"],
41+
)
42+
op.create_index(
43+
"ix_aliases_context",
44+
"aliases",
45+
["context"],
46+
)
47+
48+
49+
def downgrade() -> None:
50+
"""Drop aliases table."""
51+
op.drop_index("ix_aliases_context", table_name="aliases")
52+
op.drop_index("ix_aliases_platform_target", table_name="aliases")
53+
op.drop_table("aliases")

src/slack_clacks/cli.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
generate_recent_parser,
1111
generate_send_parser,
1212
)
13+
from slack_clacks.rolodex.cli import generate_cli as generate_rolodex_cli
1314

1415

1516
def generate_cli() -> argparse.ArgumentParser:
@@ -37,6 +38,14 @@ def generate_cli() -> argparse.ArgumentParser:
3738
"auth", parents=[auth_parser], add_help=False, help=auth_parser.description
3839
)
3940

41+
rolodex_parser = generate_rolodex_cli()
42+
subparsers.add_parser(
43+
"rolodex",
44+
parents=[rolodex_parser],
45+
add_help=False,
46+
help=rolodex_parser.description,
47+
)
48+
4049
send_parser = generate_send_parser()
4150
subparsers.add_parser(
4251
"send",

src/slack_clacks/messaging/cli.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ def handle_send(args: argparse.Namespace) -> None:
3838
channel_id = None
3939

4040
if args.channel:
41-
channel_id = resolve_channel_id(client, args.channel)
41+
channel_id = resolve_channel_id(client, args.channel, session, context.name)
4242
elif args.user:
43-
user_id = resolve_user_id(client, args.user)
43+
user_id = resolve_user_id(client, args.user, session, context.name)
4444
channel_id = open_dm_channel(client, user_id)
4545
if channel_id is None:
4646
raise ValueError(f"Failed to open DM with user '{args.user}'.")
@@ -116,7 +116,7 @@ def handle_read(args: argparse.Namespace) -> None:
116116
channel_id = None
117117

118118
if args.channel:
119-
channel_id = resolve_channel_id(client, args.channel)
119+
channel_id = resolve_channel_id(client, args.channel, session, context.name)
120120

121121
scopes = get_scopes_for_mode(context.app_type)
122122
if channel_id.startswith("C"):
@@ -125,7 +125,7 @@ def handle_read(args: argparse.Namespace) -> None:
125125
validate("groups:history", scopes, raise_on_error=True)
126126

127127
elif args.user:
128-
user_id = resolve_user_id(client, args.user)
128+
user_id = resolve_user_id(client, args.user, session, context.name)
129129
channel_id = open_dm_channel(client, user_id)
130130
if channel_id is None:
131131
raise ValueError(f"Failed to open DM with user '{args.user}'.")
@@ -264,9 +264,9 @@ def handle_react(args: argparse.Namespace) -> None:
264264
client = create_client(context.access_token, context.app_type)
265265

266266
if args.channel:
267-
channel_id = resolve_channel_id(client, args.channel)
267+
channel_id = resolve_channel_id(client, args.channel, session, context.name)
268268
else:
269-
user_id = resolve_user_id(client, args.user)
269+
user_id = resolve_user_id(client, args.user, session, context.name)
270270
dm_channel = open_dm_channel(client, user_id)
271271
if dm_channel is None:
272272
raise ValueError(f"Failed to open DM with user '{args.user}'.")
@@ -352,9 +352,9 @@ def handle_delete(args: argparse.Namespace) -> None:
352352
client = create_client(context.access_token, context.app_type)
353353

354354
if args.channel:
355-
channel_id = resolve_channel_id(client, args.channel)
355+
channel_id = resolve_channel_id(client, args.channel, session, context.name)
356356
else:
357-
user_id = resolve_user_id(client, args.user)
357+
user_id = resolve_user_id(client, args.user, session, context.name)
358358
dm_channel = open_dm_channel(client, user_id)
359359
if dm_channel is None:
360360
raise ValueError(f"Failed to open DM with user '{args.user}'.")

src/slack_clacks/messaging/operations.py

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,44 @@
44

55
from slack_sdk import WebClient
66
from slack_sdk.errors import SlackApiError
7+
from sqlalchemy.orm import Session
78

89
from slack_clacks.messaging.exceptions import (
910
ClacksChannelNotFoundError,
1011
ClacksUserNotFoundError,
1112
)
1213

1314

14-
def resolve_channel_id(client: WebClient, channel_identifier: str) -> str:
15+
def resolve_channel_id(
16+
client: WebClient,
17+
channel_identifier: str,
18+
session: Session | None = None,
19+
context_name: str | None = None,
20+
) -> str:
1521
"""
1622
Resolve channel identifier to channel ID.
17-
Accepts channel ID (C...), channel name (#general or general).
23+
Accepts channel ID (C..., D..., G...), channel name (#general or general), or alias.
1824
Returns channel ID or raises ClacksChannelNotFoundError if not found.
25+
26+
Resolution order:
27+
1. Check if already a Slack channel ID (C..., D..., G...)
28+
2. Check aliases (if session and context_name provided)
29+
3. Fall back to Slack API
1930
"""
20-
if channel_identifier.startswith("C") or channel_identifier.startswith("D"):
31+
if channel_identifier.startswith(("C", "D", "G")):
2132
return channel_identifier
2233

2334
channel_name = channel_identifier.lstrip("#")
2435

25-
# TODO(zomglings): Implement pagination via response_metadata.next_cursor
26-
# Currently only searches first page (up to 1000 channels)
27-
# Plan: Cache channel list in database to avoid repeated API calls
36+
# Check aliases first (requires context for security)
37+
if session is not None and context_name is not None:
38+
from slack_clacks.rolodex.operations import resolve_alias
39+
40+
alias = resolve_alias(session, channel_name, context_name, "channel", "slack")
41+
if alias:
42+
return alias.target_id
43+
44+
# Fall back to API call
2845
try:
2946
response = client.conversations_list(
3047
types="public_channel,private_channel", limit=1000
@@ -38,20 +55,36 @@ def resolve_channel_id(client: WebClient, channel_identifier: str) -> str:
3855
raise ClacksChannelNotFoundError(channel_identifier)
3956

4057

41-
def resolve_user_id(client: WebClient, user_identifier: str) -> str:
58+
def resolve_user_id(
59+
client: WebClient,
60+
user_identifier: str,
61+
session: Session | None = None,
62+
context_name: str | None = None,
63+
) -> str:
4264
"""
4365
Resolve user identifier to user ID.
44-
Accepts user ID (U...), username (@username or username), or email.
66+
Accepts user ID (U...), username (@username or username), email, or alias.
4567
Returns user ID or raises ClacksUserNotFoundError if not found.
68+
69+
Resolution order:
70+
1. Check if already a Slack user ID (U...)
71+
2. Check aliases (if session and context_name provided)
72+
3. Fall back to Slack API
4673
"""
4774
if user_identifier.startswith("U"):
4875
return user_identifier
4976

5077
username = user_identifier.lstrip("@")
5178

52-
# TODO(zomglings): Implement pagination via response_metadata.next_cursor
53-
# Currently only searches first page (100-200 users depending on tier)
54-
# Plan: Cache user list in database to avoid repeated API calls
79+
# Check aliases first (requires context for security)
80+
if session is not None and context_name is not None:
81+
from slack_clacks.rolodex.operations import resolve_alias
82+
83+
alias = resolve_alias(session, username, context_name, "user", "slack")
84+
if alias:
85+
return alias.target_id
86+
87+
# Fall back to API call
5588
try:
5689
response = client.users_list()
5790
for user in response["members"]:
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""
2+
Rolodex: Local cache for user and channel metadata.
3+
"""

0 commit comments

Comments
 (0)