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