diff --git a/.agents/skills/printing/SKILL.md b/.agents/skills/printing/SKILL.md new file mode 100644 index 0000000..cbf0287 --- /dev/null +++ b/.agents/skills/printing/SKILL.md @@ -0,0 +1,315 @@ +# Papercut Print API + +Print anything on a physical receipt printer by posting markdown content to a single endpoint. + +## Endpoint + +``` +POST https://papercut.krabby.dev/webhooks/slack +Content-Type: application/json +``` + +No authentication required. + +## Request Body + +```json +{ + "content": "# Your markdown content here", + "include_header": true, + "include_footer": true, + "footer_url": "https://example.com", + "cut": true +} +``` + +| Field | Type | Required | Default | Description | +|---|---|---|---|---| +| `content` | string | **Yes** | | Markdown-formatted content to print | +| `include_header` | boolean | No | `true` | Print the receipt header (logo, company name, address) | +| `include_footer` | boolean | No | `true` | Print the receipt footer (footer text, optional QR code) | +| `footer_url` | string | No | `null` | URL to encode as a QR code in the footer. If omitted, no QR code is printed. | +| `cut` | boolean | No | `true` | Cut the paper after printing | + +The only required field is `content`. The simplest possible request: + +```json +{"content": "Hello, world!"} +``` + +## Response + +```json +{ + "status": "printed", + "message": "Content sent to printer", + "content_length": 123 +} +``` + +## Receipt Dimensions + +The printer is an 80mm thermal receipt printer. The printable area is: + +- **Text width:** 48 characters per line +- **Image width:** 576 pixels max + +All text is automatically word-wrapped to fit within 48 characters. Images must be sized to 576px wide or smaller by the caller. + +## Two-Column Layout + +To print key-value detail lines (like ticket metadata), use space padding to right-align values. The receipt is exactly **48 characters wide**. Pad each line so the total length is exactly 48 characters: + +``` +spaces = 48 - len(label) - len(value) +line = label + (" " * spaces) + value +``` + +Every line must be **exactly 48 characters total**. No more, no less. If a line is shorter, the value won't be right-aligned. If longer, it will wrap and break the layout. + +### Example calculation + +``` +"ID:" (3 chars) + "ENG-42" (6 chars) = 39 spaces → 48 total +"Team:" (5 chars) + "Engineering" (11 chars) = 32 spaces → 48 total +"Priority:"(9 chars) + "High" (4 chars) = 35 spaces → 48 total +"Status:" (7 chars) + "In Progress" (11 chars) = 30 spaces → 48 total +``` + +### Example output on the receipt + +``` +ID: ENG-42 +Team: Engineering +Priority: High +Status: In Progress +``` + +### Implementation (pseudocode) + +```python +WIDTH = 48 + +def format_detail(label: str, value: str) -> str: + spaces = WIDTH - len(label) - len(value) + return label + (" " * spaces) + value + +details = [ + format_detail("ID:", "ENG-42"), + format_detail("Team:", "Engineering"), + format_detail("Priority:", "High"), +] + +# Join with newlines — each line is exactly 48 chars +detail_block = "\n".join(details) +``` + +## Section Spacing + +Use blank lines (`\n\n`) to separate visual sections. Without them, the receipt looks like one dense blob of text. + +Required spacing for a well-formatted receipt: + +``` +[detail lines — no blank lines between them] + ← blank line +# Title + ← blank line +Description text, bullets, images, etc. +``` + +In the content string, this means: + +``` +...last detail line\n\n# Title\n\nDescription starts here... +``` + +## Supported Markdown + +The `content` field accepts markdown. The following elements are rendered with receipt-appropriate formatting: + +### Headers + +```markdown +# H1 - Bold, double width, double height (use for titles) +## H2 - Bold, double height (use for section identifiers) +### H3 through H6 - Bold +``` + +### Inline Formatting + +```markdown +**bold text** - Prints bold +*italic text* - Prints underlined (thermal printers have no italic) +``` + +### Bullet Lists + +```markdown +- Item one +- Item two +* Also works with asterisks +``` + +### Inline Images + +```markdown +![alt text](https://example.com/image.png) +``` + +Images are downloaded and printed inline within the content flow. They must be: +- **Max 576px wide** (the printer's printable area) +- A supported format: PNG, JPG, GIF, or BMP +- Accessible via a public URL + +If an image fails to download or exceeds the max width, a text placeholder is printed instead. + +### Plain Text + +Anything that isn't a recognized markdown element prints as-is, with automatic word wrapping to 48 characters. + +## Ticket Format Reference + +To replicate the standard ticket receipt layout (matching the Linear ticket format), structure the content as follows: + +### Structure + +``` +[two-column details — space-padded to 48 chars each] +[blank line] +[# Title as H1] +[blank line] +[description — markdown with bullets, bold, etc.] +``` + +### Full example payload + +```json +{ + "content": "ID: ENG-42\nTeam: Engineering\nPriority: High\nStatus: In Progress\nProject: Papercut v2\nAssignee: Jane Doe\nCreator: John Smith\nLabels: bug, frontend\n\n# Fix login page CSS regression\n\nThe login button has a CSS regression causing it to overlap with the password field on mobile viewports under 375px.\n\n- Regression introduced in PR #127\n- Affects Safari and Chrome on iOS\n- **Blocking** v2.1 release", + "footer_url": "https://google.com" +} +``` + +### What prints on the receipt + +``` +[logo] +[company header] +[timestamp] + +ID: ENG-42 +Team: Engineering +Priority: High +Status: In Progress +Project: Papercut v2 +Assignee: Jane Doe +Creator: John Smith +Labels: bug, frontend + + FIX LOGIN PAGE CSS + REGRESSION + +The login button has a CSS regression +causing it to overlap with the password +field on mobile viewports under 375px. + +• Regression introduced in PR #127 +• Affects Safari and Chrome on iOS +• Blocking v2.1 release + + Scan for details: + [QR code → google.com] + [footer text] +``` + +### Building a ticket programmatically + +```python +WIDTH = 48 + +def format_detail(label: str, value: str) -> str: + spaces = WIDTH - len(label) - len(value) + return label + (" " * spaces) + value + +def build_ticket( + details: list[tuple[str, str]], + title: str, + description: str, +) -> str: + lines = [format_detail(label, value) for label, value in details] + detail_block = "\n".join(lines) + return f"{detail_block}\n\n# {title}\n\n{description}" + +# Usage +content = build_ticket( + details=[ + ("ID:", "ENG-42"), + ("Team:", "Engineering"), + ("Priority:", "High"), + ("Status:", "In Progress"), + ("Assignee:", "Jane Doe"), + ("Labels:", "bug, frontend"), + ], + title="Fix login page CSS regression", + description=( + "The login button has a CSS regression.\n\n" + "- Regression in PR #127\n" + "- Affects Safari and Chrome\n" + "- **Blocking** v2.1 release" + ), +) +``` + +## Other Examples + +### Minimal + +```json +{"content": "Just a line of text"} +``` + +### With Inline Image + +```json +{ + "content": "# Daily Report\n\n![chart](https://example.com/chart.png)\n\n**Summary:** All systems operational.", + "include_footer": false +} +``` + +### No Header, No Footer, No Cut + +```json +{ + "content": "Raw content only.\n\nNo header, no footer, no paper cut.\nUseful for chaining multiple prints.", + "include_header": false, + "include_footer": false, + "cut": false +} +``` + +## Content Limits + +Content is truncated at **2000 characters** (configurable server-side). Plan your content within this limit. + +## curl Examples + +Simple print: + +```bash +curl -X POST https://papercut.krabby.dev/webhooks/slack \ + -H "Content-Type: application/json" \ + -d '{"content": "# Hello\n\nThis is a **test** print."}' +``` + +Ticket-style print with two-column details and QR code: + +```bash +curl -X POST https://papercut.krabby.dev/webhooks/slack \ + -H "Content-Type: application/json" \ + -d '{ + "content": "ID: ENG-42\nTeam: Engineering\nPriority: High\nStatus: In Progress\n\n# Fix login page CSS regression\n\nThe login button overlaps the password field on mobile.\n\n- Regression in PR #127\n- **Blocking** v2.1 release", + "footer_url": "https://google.com" + }' +``` diff --git a/config.py b/config.py index 815a2e8..224fae3 100644 --- a/config.py +++ b/config.py @@ -55,6 +55,7 @@ class ProvidersConfig: """Providers configuration - dynamically populated by provider modules.""" linear: Optional[object] = None # Will be LinearProviderConfig from provider module + slack: Optional[object] = None # Will be SlackProviderConfig from provider module @dataclass @@ -220,6 +221,22 @@ def load_config() -> Config: f"Linear provider config error (from {config_file_path}):\n{e}" ) from e + # Slack provider (if available) + try: + from papercut.platforms.slack import ( + load_config_from_toml as load_slack_config, + ) + + providers.slack = load_slack_config(toml_data) + except ImportError: + # Slack provider not installed/available + pass + except ValueError as e: + # Slack config validation failed + raise ValueError( + f"Slack provider config error (from {config_file_path}):\n{e}" + ) from e + # Create config object config = Config( printer=printer, @@ -263,6 +280,15 @@ def load_config() -> Config: logger.info( f" max_description_length = {config.providers.linear.max_description_length}" ) + if config.providers.slack: + logger.info("[providers.slack]") + logger.info(f" disabled = {config.providers.slack.disabled}") + logger.info( + f" auth_token = {'***' if config.providers.slack.auth_token else None}" + ) + logger.info( + f" max_content_length = {config.providers.slack.max_content_length}" + ) logger.info("=" * 60) return config diff --git a/papercut-schema.json b/papercut-schema.json index e50e78f..00a8c1c 100644 --- a/papercut-schema.json +++ b/papercut-schema.json @@ -170,6 +170,34 @@ } }, "additionalProperties": false + }, + "slack": { + "type": "object", + "description": "Slack bot print integration. Receives pre-formatted content and prints it directly.", + "default": { + "disabled": false, + "auth_token": "", + "max_content_length": 2000 + }, + "properties": { + "disabled": { + "type": "boolean", + "description": "Set to true to disable Slack print endpoint", + "default": false + }, + "auth_token": { + "type": "string", + "description": "Optional bearer token for authentication. Leave empty to allow unauthenticated requests. When set, requests must include 'Authorization: Bearer ' header", + "default": "" + }, + "max_content_length": { + "type": "integer", + "description": "Maximum characters for print content before truncation", + "minimum": 1, + "default": 2000 + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/papercut.toml b/papercut.toml index 82a7c65..b45fc76 100644 --- a/papercut.toml +++ b/papercut.toml @@ -32,3 +32,8 @@ disabled = false signing_secret = "" # Required if not disabled - get from Linear webhook settings max_title_length = 96 # Maximum characters for issue title before truncation max_description_length = 480 # Maximum characters for issue description before truncation + +[providers.slack] +disabled = false +auth_token = "" # Optional bearer token for authentication. Leave empty to allow unauthenticated requests. +max_content_length = 2000 # Maximum characters for content before truncation diff --git a/src/papercut/api.py b/src/papercut/api.py index 69a4981..190986c 100644 --- a/src/papercut/api.py +++ b/src/papercut/api.py @@ -31,6 +31,11 @@ class HealthCheckResponse(BaseModel): app.include_router(linear_router, prefix="/webhooks") +if config.providers.slack and not config.providers.slack.disabled: + from papercut.platforms.slack.router import router as slack_router + + app.include_router(slack_router, prefix="/webhooks") + @app.get("/", response_model=HealthCheckResponse, summary="Health Check") async def health_check() -> HealthCheckResponse: diff --git a/src/papercut/core/console.py b/src/papercut/core/console.py index 414464a..f9e838c 100644 --- a/src/papercut/core/console.py +++ b/src/papercut/core/console.py @@ -3,6 +3,9 @@ Formats tickets as ASCII receipts for console logging. """ +import re +from datetime import datetime, timezone + from papercut.core.models import Ticket from papercut.core.utils import wrap_text, truncate_text, utc_to_local from config import ( @@ -195,3 +198,120 @@ def print_console_preview(ticket: Ticket) -> None: _print_border_line(width, "bottom") print() + + +# --- Pattern for markdown images: ![alt](url) --- +_IMAGE_PATTERN = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") + + +def print_raw_console_preview( + content: str, + include_header: bool = True, + include_footer: bool = True, + footer_url: str | None = None, +) -> None: + """ + Print a console preview of raw/pre-formatted receipt content. + + Used for Slack bot and other raw print requests. + Renders the content in an ASCII receipt box, replacing inline + image references with placeholder text. + + Args: + content: Markdown-formatted content to display + include_header: Whether to show the receipt header + include_footer: Whether to show the receipt footer + footer_url: URL shown in footer (if any) + """ + width = RECEIPT_WIDTH + padding = RECEIPT_PADDING + inner_width = RECEIPT_INNER_WIDTH + + print("\n") + _print_border_line(width, "top") + _print_line("", width) + + # Header (reuse same logic as ticket preview) + if include_header: + if config.header.company_name is not None: + _print_line( + " " * padding + + config.header.company_name.center(inner_width) + + " " * padding, + width, + ) + + if config.header.address_line1 is not None: + _print_line( + " " * padding + + config.header.address_line1.center(inner_width) + + " " * padding, + width, + ) + if config.header.address_line2 is not None: + _print_line( + " " * padding + + config.header.address_line2.center(inner_width) + + " " * padding, + width, + ) + + if config.header.phone is not None: + _print_line( + " " * padding + + f"Tel: {config.header.phone}".center(inner_width) + + " " * padding, + width, + ) + + if config.header.url is not None: + _print_line( + " " * padding + config.header.url.center(inner_width) + " " * padding, + width, + ) + + _print_line("", width) + + # Timestamp + now = datetime.now(timezone.utc) + local_time = utc_to_local(now) + timestamp = local_time.strftime("%b %d, %Y at %I:%M %p").center(inner_width) + _print_line(" " * padding + timestamp + " " * padding, width) + _print_line("", width) + + # Content - replace image references with placeholder text + display_content = _IMAGE_PATTERN.sub(lambda m: f"[IMAGE: {m.group(2)}]", content) + + # Render content lines with wrapping + for line in display_content.split("\n"): + if line.strip() == "": + _print_line("", width) + else: + wrapped = wrap_text(line, inner_width) + for wrapped_line in wrapped: + _print_line(" " * padding + wrapped_line + " " * padding, width) + + _print_line("", width) + + # Footer section + if include_footer and not config.footer.disabled: + if footer_url: + if config.footer.qr_code_title is not None: + _print_line(config.footer.qr_code_title.center(inner_width), width) + _print_line("", width) + + if not config.footer.qr_code_disabled: + _print_line("QR CODE HERE".center(inner_width), width) + _print_line("", width) + + if config.footer.footer_text is not None: + _print_line( + " " * padding + + config.footer.footer_text.center(inner_width) + + " " * padding, + width, + ) + _print_line("", width) + + _print_border_line(width, "bottom") + print() diff --git a/src/papercut/core/printer.py b/src/papercut/core/printer.py index 8b6e6b3..bba68d9 100644 --- a/src/papercut/core/printer.py +++ b/src/papercut/core/printer.py @@ -4,6 +4,11 @@ """ import logging +import re +import urllib.request +from io import BytesIO +from datetime import datetime, timezone + from escpos.printer import Usb from escpos.exceptions import USBNotFoundError, Error as EscposError from papercut.core.models import Ticket @@ -301,3 +306,158 @@ def print_to_printer(ticket: Ticket) -> None: logger.debug("Printer connection closed successfully") except Exception as e: logger.warning(f"Error closing printer connection: {e}") + + +# --- Image pattern for markdown inline images: ![alt](url) --- +_IMAGE_PATTERN = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") + + +def _download_and_print_image(p, url: str, center: bool = True) -> None: + """ + Download an image from a URL and print it on the receipt printer. + + Downloads the image into memory, opens it with PIL (already available + via python-escpos), and prints it. If the download or printing fails, + a placeholder text line is printed instead. + + Args: + p: ESC/POS printer instance + url: URL of the image to download + center: Whether to center the image on the receipt + """ + try: + req = urllib.request.Request(url, headers={"User-Agent": "Papercut/0.1.0"}) + with urllib.request.urlopen(req, timeout=15) as response: + image_data = response.read() + + from PIL import Image + + img = Image.open(BytesIO(image_data)) + + p.image(img, center=center) + p.ln() + + logger.debug(f"Printed image from {url} ({img.size[0]}x{img.size[1]})") + + except Exception as e: + logger.warning(f"Failed to download/print image from '{url}': {e}") + # Print placeholder text so content flow isn't silently broken + p.set_with_default(align="center") + p.textln(f"[Image: {url}]") + p.set_with_default() + + +def _render_content_with_images(p, content: str) -> None: + """ + Render markdown content that may contain inline images. + + Splits the content on markdown image syntax ![alt](url), renders + each text segment as markdown, and downloads/prints each image + inline in the content flow. + + Args: + p: ESC/POS printer instance + content: Markdown content potentially containing ![alt](url) references + """ + from papercut.core.markdown import render_markdown_to_receipt + + last_end = 0 + for match in _IMAGE_PATTERN.finditer(content): + # Render text before this image + text_before = content[last_end : match.start()] + if text_before.strip(): + render_markdown_to_receipt(p, text_before) + + # Download and print the image + image_url = match.group(2) + logger.info(f"Processing inline image: {image_url}") + _download_and_print_image(p, image_url) + + last_end = match.end() + + # Render remaining text after last image (or all text if no images) + remaining = content[last_end:] + if remaining.strip(): + render_markdown_to_receipt(p, remaining) + + +def print_raw_receipt( + content: str, + include_header: bool = True, + include_footer: bool = True, + footer_url: str | None = None, + cut: bool = True, +) -> None: + """ + Print pre-formatted content on the receipt printer. + + Used for raw/pre-formatted print requests (e.g., from a Slack bot) + where the caller controls the content layout. Supports markdown + formatting and inline images via ![alt](url) syntax. + + Args: + content: Markdown-formatted content to print + include_header: Whether to print the receipt header (logo, company info) + include_footer: Whether to print the receipt footer + footer_url: URL to encode as QR code in footer (if None, QR code is skipped) + cut: Whether to cut the paper after printing + + Raises: + USBNotFoundError: If USB printer not found + EscposError: For other printer errors + """ + p = None + try: + p = _get_printer() + + # Initialize printer to clean state + p.hw("INIT") + + # Header + if include_header: + _print_header(p) + + # Timestamp + p.set_with_default(align="center") + now = datetime.now(timezone.utc) + p.textln(utc_to_local(now).strftime("%b %d, %Y at %I:%M %p")) + p.set_with_default() + p.ln() + + # Render content (markdown + inline images) + _render_content_with_images(p, content) + + # Footer + if include_footer and not config.footer.disabled: + if footer_url: + _print_footer(p, footer_url) + else: + # Print footer text without QR code + p.ln(2) + p.set_with_default(align="center") + if config.footer.footer_text is not None: + p.set(underline=True) + p.textln(config.footer.footer_text) + p.set_with_default() + + if cut: + p.cut() + + logger.info("Successfully printed raw receipt") + + except USBNotFoundError: + logger.error("Failed to print raw receipt: USB printer not found") + raise + except EscposError as e: + logger.error(f"Failed to print raw receipt: {e}") + raise + except Exception as e: + logger.error(f"Unexpected error printing raw receipt: {e}") + raise + finally: + if p is not None: + try: + p.close() + logger.debug("Printer connection closed successfully") + except Exception as e: + logger.warning(f"Error closing printer connection: {e}") diff --git a/src/papercut/platforms/__init__.py b/src/papercut/platforms/__init__.py index 6cbb4e5..704e4f8 100644 --- a/src/papercut/platforms/__init__.py +++ b/src/papercut/platforms/__init__.py @@ -1,5 +1,6 @@ """Platform-specific webhook adapters and routers.""" from papercut.platforms import linear +from papercut.platforms import slack -__all__ = ["linear"] +__all__ = ["linear", "slack"] diff --git a/src/papercut/platforms/slack/__init__.py b/src/papercut/platforms/slack/__init__.py new file mode 100644 index 0000000..7a3bb1d --- /dev/null +++ b/src/papercut/platforms/slack/__init__.py @@ -0,0 +1,9 @@ +"""Slack platform integration.""" + +from papercut.platforms.slack.config import ( + SlackProviderConfig, + load_config_from_toml, + validate_config, +) + +__all__ = ["SlackProviderConfig", "load_config_from_toml", "validate_config"] diff --git a/src/papercut/platforms/slack/config.py b/src/papercut/platforms/slack/config.py new file mode 100644 index 0000000..b795b4d --- /dev/null +++ b/src/papercut/platforms/slack/config.py @@ -0,0 +1,65 @@ +""" +Slack provider configuration and validation. +Self-contained config schema for the Slack integration. +""" + +from dataclasses import dataclass +from typing import Optional +from papercut.core.utils import normalize_optional_string as _normalize_optional_string + + +@dataclass +class SlackProviderConfig: + """Slack provider configuration.""" + + disabled: bool + auth_token: Optional[str] + max_content_length: int + + +def validate_config(config: SlackProviderConfig) -> None: + """ + Validate Slack provider configuration. + + Args: + config: Slack provider configuration to validate + + Raises: + ValueError: If configuration is invalid + """ + if config.max_content_length <= 0: + raise ValueError("max_content_length must be positive") + + +def load_config_from_toml(toml_data: dict) -> SlackProviderConfig: + """ + Load and validate Slack config from TOML data. + + Args: + toml_data: Raw TOML dictionary with providers.slack section + + Returns: + Validated SlackProviderConfig + + Raises: + ValueError: If config is invalid or missing required fields + """ + providers_data = toml_data.get("providers", {}) + slack_data = providers_data.get("slack", {}) + + try: + config = SlackProviderConfig( + disabled=slack_data["disabled"], + auth_token=_normalize_optional_string(slack_data.get("auth_token")), + max_content_length=slack_data["max_content_length"], + ) + except KeyError as e: + raise ValueError( + f"Missing required Slack config field: {e}\n" + "Check [providers.slack] section in your config file" + ) from e + + # Validate the loaded config + validate_config(config) + + return config diff --git a/src/papercut/platforms/slack/models.py b/src/papercut/platforms/slack/models.py new file mode 100644 index 0000000..28c51a0 --- /dev/null +++ b/src/papercut/platforms/slack/models.py @@ -0,0 +1,47 @@ +""" +Slack print request models. +Pydantic models for Slack bot print payloads. +""" + +from typing import Optional +from pydantic import BaseModel, Field + + +class SlackPrintRequest(BaseModel): + """ + Print request from a Slack bot. + + The Slack bot sends pre-formatted content ready to print. + Content is markdown with optional inline images using ![alt](url) syntax. + + Example payload: + { + "content": "# Order Receipt\\n\\nItem: Widget\\nPrice: $9.99\\n\\n![logo](https://example.com/logo.png)", + "include_header": true, + "include_footer": true, + "footer_url": "https://example.com/order/123", + "cut": true + } + """ + + content: str = Field( + ..., + description="Markdown-formatted content to print. " + "Supports headers, bold, italic, bullet lists, and inline images via ![alt](url) syntax.", + ) + include_header: bool = Field( + True, + description="Whether to print the receipt header (logo, company info)", + ) + include_footer: bool = Field( + True, + description="Whether to print the receipt footer", + ) + footer_url: Optional[str] = Field( + None, + description="URL to encode as QR code in footer. If omitted, QR code is skipped.", + ) + cut: bool = Field( + True, + description="Whether to cut the paper after printing", + ) diff --git a/src/papercut/platforms/slack/router.py b/src/papercut/platforms/slack/router.py new file mode 100644 index 0000000..911c02b --- /dev/null +++ b/src/papercut/platforms/slack/router.py @@ -0,0 +1,117 @@ +""" +Slack webhook router. +Handles incoming print requests from a Slack bot. +""" + +import logging +from typing import Optional +from fastapi import APIRouter, HTTPException, Header +from pydantic import BaseModel, Field + +from papercut.core.console import print_raw_console_preview +from papercut.core.printer import print_raw_receipt +from papercut.platforms.slack.models import SlackPrintRequest +from config import config + +logger = logging.getLogger(__name__) + + +class SlackWebhookResponse(BaseModel): + """Response from Slack webhook endpoint""" + + status: str = Field(..., description="Status of print request") + message: Optional[str] = Field(None, description="Status message") + content_length: Optional[int] = Field( + None, description="Length of content received" + ) + + +def _verify_auth_token(authorization: str | None) -> None: + """ + Verify bearer token authentication if configured. + + Args: + authorization: Authorization header value + + Raises: + HTTPException: If auth is required but token is missing or invalid + """ + expected_token = config.providers.slack.auth_token + + # No auth configured - allow all requests + if not expected_token: + return + + if not authorization: + raise HTTPException(status_code=401, detail="Missing Authorization header") + + # Expect "Bearer " format + parts = authorization.split(" ", 1) + if len(parts) != 2 or parts[0].lower() != "bearer": + raise HTTPException( + status_code=401, + detail="Invalid Authorization header format. Expected: Bearer ", + ) + + if parts[1] != expected_token: + logger.warning("Invalid auth token - rejecting request") + raise HTTPException(status_code=401, detail="Invalid auth token") + + +# Slack webhook router +router = APIRouter(tags=["Slack"]) + + +@router.post( + "/slack", response_model=SlackWebhookResponse, summary="Slack Print Webhook" +) +async def handle_slack_print( + request: SlackPrintRequest, + authorization: Optional[str] = Header(None), +) -> SlackWebhookResponse: + """ + Handle print requests from a Slack bot. + + Receives pre-formatted markdown content and prints it on the receipt printer. + Supports inline images via markdown ![alt](url) syntax. + + Security: + - Optional bearer token authentication (if auth_token is configured) + """ + # Verify auth token if configured + _verify_auth_token(authorization) + + # Truncate content if needed + max_length = config.providers.slack.max_content_length + content = request.content + if len(content) > max_length: + content = content[:max_length] + logger.info( + f"Content truncated from {len(request.content)} to {max_length} characters" + ) + + logger.info(f"Received Slack print request ({len(content)} chars)") + + # Print to console and printer + try: + print_raw_console_preview( + content=content, + include_header=request.include_header, + include_footer=request.include_footer, + footer_url=request.footer_url, + ) + print_raw_receipt( + content=content, + include_header=request.include_header, + include_footer=request.include_footer, + footer_url=request.footer_url, + cut=request.cut, + ) + except Exception as e: + logger.error(f"Error printing Slack content: {e}") + + return SlackWebhookResponse( + status="printed", + message="Content sent to printer", + content_length=len(content), + )