Skip to content

Commit 08ee978

Browse files
zzstoatzzclaudeCopilot
authored
feat(slackbot): upload long code blocks as Slack snippets (#1262)
* feat(slackbot): upload long code blocks as Slack snippets - Add structured response model (StructuredResponse) with text/code sections - Implement Slack file upload API (files.getUploadURLExternal + completeUploadExternal) - Agent now returns structured output instead of plain string - Long code blocks (>15 lines) are uploaded as Slack file snippets for proper formatting - Short code stays inline in messages - Fallback to inline code if snippet upload fails This addresses escaping issues with markdown code blocks in Slack messages. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: address PR review feedback - Fix docstring: "two-step" → "three-step" process - Use proper logging instead of print() for error messages - Use SNIPPET_LINE_THRESHOLD constant instead of hardcoded default - Catch specific exceptions (httpx.HTTPError, ValueError, KeyError) instead of bare Exception - Add min_length=1 validation to sections field - Improve filename detection: check for valid extension, no spaces, reasonable length 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent f280a8b commit 08ee978

5 files changed

Lines changed: 324 additions & 12 deletions

File tree

examples/slackbot/src/slackbot/_internal/templates.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,24 @@
3434

3535
DEFAULT_SYSTEM_PROMPT = """You are Marvin, an AI assistant for the Prefect data engineering platform. Your responses should be clear, helpful, accurate, and professional. Your primary goal is to provide excellent support to users.
3636
37-
## Output Context
38-
Your responses will be displayed in Slack. Format accordingly:
39-
- Use ``` for code blocks (WITHOUT language identifiers like python/js/etc - Slack doesn't support them)
37+
## Output Format
38+
Your responses are structured with interleaved text and code sections. This allows longer code blocks to be displayed as Slack file snippets with proper syntax highlighting.
39+
40+
**For text sections:**
4041
- Use single backticks for inline code
4142
- Bold text uses *asterisks*
4243
- Links should be formatted as <url|text>
4344
45+
**For code sections:**
46+
- Provide a descriptive title (e.g., "prefect.yaml", "build_bundle.sh", "example usage")
47+
- Specify the language for syntax highlighting (e.g., "python", "yaml", "bash")
48+
- Short code snippets (a few lines) can stay inline in text sections using single backticks
49+
- Longer code examples should be separate code sections - they'll be uploaded as Slack snippets for better readability
50+
51+
**When to use code sections vs inline code:**
52+
- Use a CODE SECTION for: complete files, multi-line examples, configuration blocks, shell scripts
53+
- Use INLINE CODE (in text) for: short one-liners, command names, variable names, brief examples
54+
4455
## Your Mission
4556
Your role is to act as the primary assistant for the user. You will receive raw information from specialized tools. Your job is to synthesize this info as usefully as possible.
4657
Sometimes the information will not be good enough, and you should use the research agent again or ask the user for more information.

examples/slackbot/src/slackbot/api.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
from slackbot.assets import summarize_thread
3030
from slackbot.core import (
3131
Database,
32-
UserContext,
3332
build_user_context,
3433
create_agent,
3534
)
@@ -40,8 +39,10 @@
4039
get_channel_name,
4140
get_workspace_domain,
4241
post_slack_message,
42+
post_structured_response,
4343
)
4444
from slackbot.strings import count_tokens, slice_tokens
45+
from slackbot.types import StructuredResponse, UserContext
4546
from slackbot.wrap import WatchToolCalls, _progress_message, _tool_usage_counts
4647

4748
BOT_MENTION = r"<@(\w+)>"
@@ -75,7 +76,7 @@ async def run_agent(
7576
channel_id: str,
7677
thread_ts: str,
7778
decorator_settings: dict[str, Any] | None = None,
78-
) -> AgentRunResult[str]:
79+
) -> AgentRunResult[StructuredResponse]:
7980
if decorator_settings is None:
8081
decorator_settings = {
8182
"cache_policy": NONE,
@@ -266,8 +267,8 @@ async def handle_message(payload: SlackPayload, db: Database):
266267
await db.add_thread_messages(thread_ts, result.new_messages())
267268
conversation.extend(result.new_messages())
268269
assert event.channel is not None, "No channel found"
269-
await task(post_slack_message)(
270-
message=result.output,
270+
await post_structured_response(
271+
response=result.output,
271272
channel_id=event.channel,
272273
thread_ts=thread_ts,
273274
)

examples/slackbot/src/slackbot/core.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
read_github_issues,
4343
)
4444
from slackbot.settings import settings
45-
from slackbot.types import UserContext
45+
from slackbot.types import StructuredResponse, UserContext
4646

4747
GITHUB_API_TOKEN = Secret.load(settings.github_token_secret_name, _sync=True).get()
4848

@@ -162,7 +162,7 @@ def build_user_context(
162162

163163
def create_agent(
164164
model: KnownModelName | Model | None = None,
165-
) -> Agent[UserContext, str]:
165+
) -> Agent[UserContext, StructuredResponse]:
166166
logger = get_run_logger()
167167
logger.info("Creating new agent")
168168
ai_model = model or AnthropicModel(
@@ -174,10 +174,11 @@ def create_agent(
174174
),
175175
)
176176
agent = Agent[
177-
UserContext, str
177+
UserContext, StructuredResponse
178178
](
179179
model=ai_model,
180180
model_settings=ModelSettings(temperature=settings.temperature),
181+
output_type=StructuredResponse,
181182
tools=[
182183
research_prefect_topic, # Tool for researching Prefect topics
183184
read_github_issues, # For searching GitHub issues

examples/slackbot/src/slackbot/slack.py

Lines changed: 243 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
"""Module for Slack-related utilities."""
22

3+
from __future__ import annotations
4+
35
import re
4-
from typing import Any, List, Union
6+
from typing import TYPE_CHECKING, Any, List, Union
7+
8+
if TYPE_CHECKING:
9+
from slackbot.types import StructuredResponse
510

611
import httpx
12+
from prefect.logging.loggers import get_logger
713
from pydantic import BaseModel, ValidationInfo, field_validator, model_validator
814

915
from slackbot.settings import settings
16+
from slackbot.types import SNIPPET_LINE_THRESHOLD
17+
18+
logger = get_logger(__name__)
1019

1120

1221
class EventBlockElement(BaseModel):
@@ -356,3 +365,236 @@ async def create_progress_message(
356365
progress = ProgressMessage(channel_id, thread_ts)
357366
await progress.start(initial_text)
358367
return progress
368+
369+
370+
# --- Slack File Upload API (files.getUploadURLExternal + completeUploadExternal) ---
371+
372+
373+
async def upload_snippet(
374+
content: str,
375+
filename: str,
376+
channel_id: str,
377+
thread_ts: str | None = None,
378+
title: str | None = None,
379+
filetype: str | None = None,
380+
) -> dict[str, Any]:
381+
"""Upload a code snippet as a Slack file using the new external upload API.
382+
383+
Uses the three-step process:
384+
1. files.getUploadURLExternal - get upload URL and file ID
385+
2. POST content to that URL
386+
3. files.completeUploadExternal - finalize and share in channel
387+
388+
Args:
389+
content: The snippet content to upload
390+
filename: Filename for the snippet (e.g., "example.py")
391+
channel_id: Channel to share the snippet in
392+
thread_ts: Thread timestamp to share in (optional)
393+
title: Display title for the snippet (optional, defaults to filename)
394+
filetype: File type hint (e.g., "python", "yaml") - Slack auto-detects if not provided
395+
396+
Returns:
397+
dict with file info from Slack API
398+
"""
399+
content_bytes = content.encode("utf-8")
400+
length = len(content_bytes)
401+
402+
async with httpx.AsyncClient() as client:
403+
# Step 1: Get upload URL
404+
get_url_params: dict[str, Any] = {
405+
"filename": filename,
406+
"length": length,
407+
}
408+
if filetype:
409+
get_url_params["snippet_type"] = filetype
410+
411+
response = await client.get(
412+
"https://slack.com/api/files.getUploadURLExternal",
413+
headers={"Authorization": f"Bearer {settings.slack_api_token}"},
414+
params=get_url_params,
415+
)
416+
response.raise_for_status()
417+
url_data = response.json()
418+
419+
if not url_data.get("ok"):
420+
raise ValueError(
421+
f"Failed to get upload URL: {url_data.get('error', 'unknown error')}"
422+
)
423+
424+
upload_url = url_data["upload_url"]
425+
file_id = url_data["file_id"]
426+
427+
# Step 2: Upload content to the provided URL
428+
upload_response = await client.post(
429+
upload_url,
430+
content=content_bytes,
431+
headers={"Content-Type": "application/octet-stream"},
432+
)
433+
if upload_response.status_code != 200:
434+
raise ValueError(
435+
f"Failed to upload file content: {upload_response.status_code}"
436+
)
437+
438+
# Step 3: Complete the upload and share in channel
439+
complete_payload: dict[str, Any] = {
440+
"files": [{"id": file_id, "title": title or filename}],
441+
"channel_id": channel_id,
442+
}
443+
if thread_ts:
444+
complete_payload["thread_ts"] = thread_ts
445+
446+
complete_response = await client.post(
447+
"https://slack.com/api/files.completeUploadExternal",
448+
headers={
449+
"Authorization": f"Bearer {settings.slack_api_token}",
450+
"Content-Type": "application/json",
451+
},
452+
json=complete_payload,
453+
)
454+
complete_response.raise_for_status()
455+
complete_data = complete_response.json()
456+
457+
if not complete_data.get("ok"):
458+
raise ValueError(
459+
f"Failed to complete upload: {complete_data.get('error', 'unknown error')}"
460+
)
461+
462+
return complete_data
463+
464+
465+
# Language to file extension mapping for snippet filenames
466+
LANGUAGE_EXTENSIONS: dict[str, str] = {
467+
"python": "py",
468+
"py": "py",
469+
"javascript": "js",
470+
"js": "js",
471+
"typescript": "ts",
472+
"ts": "ts",
473+
"yaml": "yaml",
474+
"yml": "yaml",
475+
"json": "json",
476+
"bash": "sh",
477+
"sh": "sh",
478+
"shell": "sh",
479+
"sql": "sql",
480+
"html": "html",
481+
"css": "css",
482+
"go": "go",
483+
"rust": "rs",
484+
"java": "java",
485+
"c": "c",
486+
"cpp": "cpp",
487+
"c++": "cpp",
488+
"ruby": "rb",
489+
"php": "php",
490+
"swift": "swift",
491+
"kotlin": "kt",
492+
"scala": "scala",
493+
"r": "r",
494+
"dockerfile": "dockerfile",
495+
"docker": "dockerfile",
496+
"toml": "toml",
497+
"ini": "ini",
498+
"xml": "xml",
499+
"markdown": "md",
500+
"md": "md",
501+
"text": "txt",
502+
"txt": "txt",
503+
"plaintext": "txt",
504+
}
505+
506+
507+
def get_extension_for_language(language: str | None) -> str:
508+
"""Get file extension for a language identifier."""
509+
if not language:
510+
return "txt"
511+
return LANGUAGE_EXTENSIONS.get(language.lower(), "txt")
512+
513+
514+
async def post_structured_response(
515+
response: StructuredResponse,
516+
channel_id: str,
517+
thread_ts: str | None = None,
518+
snippet_line_threshold: int = SNIPPET_LINE_THRESHOLD,
519+
) -> None:
520+
"""Post a structured response to Slack, uploading long code blocks as snippets.
521+
522+
This renders the response by:
523+
1. Collecting text and short code into messages
524+
2. Uploading long code blocks as Slack file snippets
525+
3. Maintaining the order of sections for readability
526+
527+
Args:
528+
response: The structured response to post
529+
channel_id: Slack channel ID
530+
thread_ts: Thread timestamp (optional)
531+
snippet_line_threshold: Code blocks with more lines than this are uploaded as snippets
532+
"""
533+
534+
accumulated_text: list[str] = []
535+
snippet_counter = 0
536+
537+
async def flush_text() -> None:
538+
"""Post accumulated text as a message."""
539+
nonlocal accumulated_text
540+
if accumulated_text:
541+
text = "\n\n".join(accumulated_text)
542+
await post_slack_message(
543+
message=text,
544+
channel_id=channel_id,
545+
thread_ts=thread_ts,
546+
)
547+
accumulated_text = []
548+
549+
for section in response.sections:
550+
if section.type == "text":
551+
accumulated_text.append(section.content)
552+
elif section.type == "code":
553+
code_lines = section.content.count("\n") + 1
554+
555+
if code_lines <= snippet_line_threshold:
556+
# Short code: inline it in the text
557+
# Slack doesn't support language identifiers in code blocks
558+
accumulated_text.append(f"```\n{section.content}\n```")
559+
else:
560+
# Long code: flush text first, then upload as snippet
561+
await flush_text()
562+
563+
snippet_counter += 1
564+
ext = get_extension_for_language(section.language)
565+
566+
# Use title as filename only if it looks like a valid filename
567+
# (ends with .ext pattern, no spaces, reasonable length)
568+
title_is_filename = (
569+
section.title
570+
and "." in section.title
571+
and section.title.rsplit(".", 1)[-1].lower()
572+
in LANGUAGE_EXTENSIONS.values()
573+
and " " not in section.title
574+
and len(section.title) < 100
575+
)
576+
filename = (
577+
section.title
578+
if title_is_filename
579+
else f"snippet_{snippet_counter}.{ext}"
580+
)
581+
title = section.title or f"Code snippet {snippet_counter}"
582+
583+
try:
584+
await upload_snippet(
585+
content=section.content,
586+
filename=filename,
587+
channel_id=channel_id,
588+
thread_ts=thread_ts,
589+
title=title,
590+
filetype=section.language,
591+
)
592+
except (httpx.HTTPError, ValueError, KeyError) as e:
593+
# Fallback: post as regular code block if upload fails
594+
logger.warning(
595+
f"Failed to upload snippet '{title}', falling back to inline: {e}"
596+
)
597+
accumulated_text.append(f"*{title}*\n```\n{section.content}\n```")
598+
599+
# Flush any remaining text
600+
await flush_text()

0 commit comments

Comments
 (0)