Skip to content

Commit aabd8a2

Browse files
authored
Merge pull request #72 from downstairs-dawgs/files-subcommand
Add clacks files subcommand group
2 parents 36eef70 + f480316 commit aabd8a2

File tree

6 files changed

+580
-6
lines changed

6 files changed

+580
-6
lines changed

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.7.0"
3+
version = "0.8.0"
44
description = "the default mode of degenerate communication."
55
readme = "README.md"
66
license = { text = "MIT" }

src/slack_clacks/cli.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from slack_clacks.auth.cli import generate_cli as generate_auth_cli
55
from slack_clacks.configuration.cli import generate_cli as generate_config_cli
6+
from slack_clacks.files.cli import generate_files_cli
67
from slack_clacks.listen.cli import generate_listen_parser
78
from slack_clacks.messaging.cli import (
89
generate_delete_parser,
@@ -13,7 +14,6 @@
1314
)
1415
from slack_clacks.rolodex.cli import generate_cli as generate_rolodex_cli
1516
from slack_clacks.skill.cli import generate_cli as generate_skill_cli
16-
from slack_clacks.upload.cli import generate_upload_parser
1717

1818

1919
def generate_cli() -> argparse.ArgumentParser:
@@ -105,12 +105,12 @@ def generate_cli() -> argparse.ArgumentParser:
105105
help=listen_parser.description,
106106
)
107107

108-
upload_parser = generate_upload_parser()
108+
files_parser = generate_files_cli()
109109
subparsers.add_parser(
110-
"upload",
111-
parents=[upload_parser],
110+
"files",
111+
parents=[files_parser],
112112
add_help=False,
113-
help=upload_parser.description,
113+
help=files_parser.description,
114114
)
115115

116116
return parser

src/slack_clacks/files/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""
2+
File download, listing, and info operations.
3+
"""

src/slack_clacks/files/cli.py

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
"""
2+
CLI commands for file operations.
3+
"""
4+
5+
import argparse
6+
import json
7+
import sys
8+
from pathlib import Path
9+
from typing import cast
10+
11+
from slack_clacks.auth.client import create_client
12+
from slack_clacks.auth.validation import get_scopes_for_mode, validate
13+
from slack_clacks.configuration.database import (
14+
ensure_db_updated,
15+
get_current_context,
16+
get_session,
17+
)
18+
from slack_clacks.files.operations import (
19+
download_file_to_path,
20+
download_file_to_stdout,
21+
extract_file_id_from_permalink,
22+
get_file_info,
23+
list_files,
24+
)
25+
from slack_clacks.messaging.operations import resolve_channel_id, resolve_user_id
26+
from slack_clacks.upload.cli import generate_upload_parser
27+
28+
29+
def handle_download(args: argparse.Namespace) -> None:
30+
ensure_db_updated(config_dir=args.config_dir)
31+
with get_session(args.config_dir) as session:
32+
context = get_current_context(session)
33+
if context is None:
34+
raise ValueError(
35+
"No active authentication context. Authenticate with: clacks auth login"
36+
)
37+
38+
scopes = get_scopes_for_mode(context.app_type)
39+
validate("files:read", scopes, raise_on_error=True)
40+
41+
client = create_client(context.access_token, context.app_type)
42+
43+
# Resolve file ID
44+
if args.file_id:
45+
file_id = args.file_id
46+
else:
47+
file_id = extract_file_id_from_permalink(args.permalink)
48+
49+
# Get file metadata
50+
info = cast(dict, get_file_info(client, file_id))
51+
file_data = info.get("file", {})
52+
filename = file_data.get("name", file_id)
53+
download_url = file_data.get("url_private_download") or file_data.get(
54+
"url_private"
55+
)
56+
57+
if not download_url:
58+
raise ValueError(f"No download URL available for file {file_id}")
59+
60+
# Download to stdout
61+
if args.write == "-":
62+
nbytes = download_file_to_stdout(
63+
download_url, context.access_token, context.app_type
64+
)
65+
print(f"{filename}: {nbytes} bytes", file=sys.stderr)
66+
return
67+
68+
# Determine output path
69+
if args.write:
70+
output_path = Path(args.write)
71+
else:
72+
output_path = Path(filename)
73+
74+
# Check for existing file
75+
if output_path.exists() and not args.force:
76+
raise FileExistsError(
77+
f"File already exists: {output_path}. Use --force to overwrite."
78+
)
79+
80+
nbytes = download_file_to_path(
81+
download_url, context.access_token, context.app_type, output_path
82+
)
83+
print(f"{output_path}: {nbytes} bytes", file=sys.stderr)
84+
85+
86+
def handle_list(args: argparse.Namespace) -> None:
87+
ensure_db_updated(config_dir=args.config_dir)
88+
with get_session(args.config_dir) as session:
89+
context = get_current_context(session)
90+
if context is None:
91+
raise ValueError(
92+
"No active authentication context. Authenticate with: clacks auth login"
93+
)
94+
95+
scopes = get_scopes_for_mode(context.app_type)
96+
validate("files:read", scopes, raise_on_error=True)
97+
98+
client = create_client(context.access_token, context.app_type)
99+
100+
# Resolve channel/user identifiers to IDs
101+
channel_id = None
102+
if args.channel:
103+
channel_id = resolve_channel_id(client, args.channel, session, context.name)
104+
105+
user_id = None
106+
if args.user:
107+
user_id = resolve_user_id(client, args.user, session, context.name)
108+
109+
result = list_files(
110+
client, channel=channel_id, user=user_id, limit=args.limit, page=args.page
111+
)
112+
json.dump(result, sys.stdout)
113+
114+
115+
def handle_info(args: argparse.Namespace) -> None:
116+
ensure_db_updated(config_dir=args.config_dir)
117+
with get_session(args.config_dir) as session:
118+
context = get_current_context(session)
119+
if context is None:
120+
raise ValueError(
121+
"No active authentication context. Authenticate with: clacks auth login"
122+
)
123+
124+
scopes = get_scopes_for_mode(context.app_type)
125+
validate("files:read", scopes, raise_on_error=True)
126+
127+
client = create_client(context.access_token, context.app_type)
128+
129+
result = get_file_info(client, args.file_id)
130+
json.dump(result, sys.stdout)
131+
132+
133+
def generate_files_cli() -> argparse.ArgumentParser:
134+
parser = argparse.ArgumentParser(
135+
description="Upload, download, list, and inspect files"
136+
)
137+
parser.set_defaults(func=lambda _: parser.print_help())
138+
139+
subparsers = parser.add_subparsers(dest="files_command")
140+
141+
# --- download ---
142+
dl_parser = subparsers.add_parser("download", help="Download a file from Slack")
143+
dl_parser.add_argument(
144+
"-D",
145+
"--config-dir",
146+
type=str,
147+
default=None,
148+
help="Configuration directory",
149+
)
150+
id_group = dl_parser.add_mutually_exclusive_group(required=True)
151+
id_group.add_argument(
152+
"-i",
153+
"--file-id",
154+
type=str,
155+
help="Slack file ID (e.g., F2147483862)",
156+
)
157+
id_group.add_argument(
158+
"--permalink",
159+
type=str,
160+
help="Slack file permalink URL",
161+
)
162+
dl_parser.add_argument(
163+
"-w",
164+
"--write",
165+
type=str,
166+
default=None,
167+
help="Output path (default: original filename in CWD, use - for stdout)",
168+
)
169+
dl_parser.add_argument(
170+
"--force",
171+
action="store_true",
172+
help="Overwrite existing file without prompting",
173+
)
174+
dl_parser.set_defaults(func=handle_download)
175+
176+
# --- list ---
177+
list_parser = subparsers.add_parser("list", help="List files")
178+
list_parser.add_argument(
179+
"-D",
180+
"--config-dir",
181+
type=str,
182+
default=None,
183+
help="Configuration directory",
184+
)
185+
list_parser.add_argument(
186+
"-c",
187+
"--channel",
188+
type=str,
189+
help="Filter by channel",
190+
)
191+
list_parser.add_argument(
192+
"-u",
193+
"--user",
194+
type=str,
195+
help="Filter by user",
196+
)
197+
list_parser.add_argument(
198+
"-l",
199+
"--limit",
200+
type=int,
201+
default=20,
202+
help="Max files to list (default: 20)",
203+
)
204+
list_parser.add_argument(
205+
"-p",
206+
"--page",
207+
type=int,
208+
default=1,
209+
help="Page number (default: 1)",
210+
)
211+
list_parser.set_defaults(func=handle_list)
212+
213+
# --- info ---
214+
info_parser = subparsers.add_parser("info", help="Show file metadata")
215+
info_parser.add_argument(
216+
"-D",
217+
"--config-dir",
218+
type=str,
219+
default=None,
220+
help="Configuration directory",
221+
)
222+
info_parser.add_argument(
223+
"-i",
224+
"--file-id",
225+
type=str,
226+
required=True,
227+
help="Slack file ID (e.g., F2147483862)",
228+
)
229+
info_parser.set_defaults(func=handle_info)
230+
231+
# --- upload ---
232+
upload_parser = generate_upload_parser()
233+
subparsers.add_parser(
234+
"upload",
235+
parents=[upload_parser],
236+
add_help=False,
237+
help=upload_parser.description,
238+
)
239+
240+
return parser
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""
2+
Core file operations using Slack Web API.
3+
"""
4+
5+
import re
6+
import sys
7+
import urllib.request
8+
from pathlib import Path
9+
10+
from slack_sdk import WebClient
11+
12+
from slack_clacks.auth.constants import MODE_COOKIE
13+
14+
_CHUNK_SIZE = 8192
15+
16+
17+
def get_file_info(client: WebClient, file_id: str) -> dict | bytes:
18+
"""Get file metadata from Slack."""
19+
response = client.files_info(file=file_id)
20+
return response.data
21+
22+
23+
def list_files(
24+
client: WebClient,
25+
channel: str | None = None,
26+
user: str | None = None,
27+
limit: int = 20,
28+
page: int = 1,
29+
) -> dict | bytes:
30+
"""List files, optionally filtered by channel and/or user."""
31+
kwargs: dict = {"count": limit, "page": page}
32+
if channel:
33+
kwargs["channel"] = channel
34+
if user:
35+
kwargs["user"] = user
36+
response = client.files_list(**kwargs)
37+
return response.data
38+
39+
40+
def extract_file_id_from_permalink(url: str) -> str:
41+
"""
42+
Extract a Slack file ID from a permalink URL.
43+
44+
Supports formats like:
45+
- https://workspace.slack.com/files/U.../F2147483862/filename.txt
46+
- https://files.slack.com/files-pri/T.../F2147483862/filename.txt
47+
"""
48+
match = re.search(r"/(F[A-Z0-9]+)", url)
49+
if not match:
50+
raise ValueError(f"Could not extract file ID from URL: {url}")
51+
return match.group(1)
52+
53+
54+
def _build_download_headers(access_token: str, app_type: str) -> dict[str, str]:
55+
"""Build HTTP headers for downloading a private Slack file."""
56+
if app_type == MODE_COOKIE:
57+
if "|" in access_token:
58+
token, cookie = access_token.split("|", 1)
59+
return {
60+
"Authorization": f"Bearer {token}",
61+
"Cookie": f"d={cookie}",
62+
}
63+
else:
64+
raise ValueError(
65+
"Cookie mode requires token in format: xoxc-token|d-cookie-value"
66+
)
67+
return {"Authorization": f"Bearer {access_token}"}
68+
69+
70+
def download_file_to_path(
71+
url: str, access_token: str, app_type: str, output_path: Path
72+
) -> int:
73+
"""
74+
Download a file from Slack to a local path.
75+
Returns the number of bytes written.
76+
"""
77+
headers = _build_download_headers(access_token, app_type)
78+
req = urllib.request.Request(url, headers=headers)
79+
total = 0
80+
with urllib.request.urlopen(req) as resp, open(output_path, "wb") as f:
81+
while True:
82+
chunk = resp.read(_CHUNK_SIZE)
83+
if not chunk:
84+
break
85+
f.write(chunk)
86+
total += len(chunk)
87+
return total
88+
89+
90+
def download_file_to_stdout(url: str, access_token: str, app_type: str) -> int:
91+
"""
92+
Download a file from Slack and write it to stdout.
93+
Returns the number of bytes written.
94+
"""
95+
headers = _build_download_headers(access_token, app_type)
96+
req = urllib.request.Request(url, headers=headers)
97+
total = 0
98+
with urllib.request.urlopen(req) as resp:
99+
while True:
100+
chunk = resp.read(_CHUNK_SIZE)
101+
if not chunk:
102+
break
103+
sys.stdout.buffer.write(chunk)
104+
total += len(chunk)
105+
return total

0 commit comments

Comments
 (0)