arcade-mcp/toolkits/zendesk/arcade_zendesk/utils.py
Eric Gustin ca11ec9fa3
Add toolkits (#514)
Add Linkedin, Zendesk, Math, and Postgres toolkits
2025-07-25 15:44:06 -07:00

216 lines
6.5 KiB
Python

import logging
import re
from typing import Any
import httpx
from arcade_tdk import ToolContext
from arcade_tdk.errors import ToolExecutionError
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
DEFAULT_MAX_BODY_LENGTH = 500 # Default max length for article body content
async def fetch_paginated_results(
client: httpx.AsyncClient,
url: str,
headers: dict[str, str],
params: dict[str, Any],
offset: int,
limit: int,
) -> dict[str, Any]:
"""
Fetch paginated results using offset and limit pattern.
This function internally manages pagination to fulfill the requested offset and limit,
fetching multiple pages as needed.
Args:
client: The HTTP client to use
url: The API endpoint URL
headers: Request headers including authorization
params: Base query parameters (without pagination params)
offset: Number of items to skip
limit: Number of items to return
Returns:
Dict containing:
- results: List of fetched items
- count: Number of items returned
- next_offset: Present only if more results are available
"""
# Calculate pagination parameters
# Most Zendesk APIs use 1-based page numbering
items_per_page = params.get("per_page", 100) # Use per_page from params or default to 100
start_page = (offset // items_per_page) + 1
start_index = offset % items_per_page
# Collect results across multiple pages if needed
all_results = []
current_page = start_page
items_collected = 0
has_more = False
last_page_had_more_items = False
while items_collected < limit:
# Set the current page
page_params = params.copy()
page_params["page"] = current_page
response = await client.get(url, headers=headers, params=page_params, timeout=30.0)
response.raise_for_status()
page_data = response.json()
# Extract results from current page (handle both "results" and "tickets" keys)
page_results = page_data.get("results", page_data.get("tickets", []))
# If this is the first page, skip to the start index
if current_page == start_page:
page_results = page_results[start_index:]
# Take only what we need to reach the limit
items_needed = limit - items_collected
results_to_add = page_results[:items_needed]
all_results.extend(results_to_add)
items_collected += len(results_to_add)
# Check if we left items on this page
if len(page_results) > items_needed:
last_page_had_more_items = True
# Check if there are more pages
has_more = page_data.get("next_page") is not None
# Stop if we've collected enough or no more pages
if items_collected >= limit or not has_more:
break
current_page += 1
# Build the response
result = {
"results": all_results,
"count": len(all_results),
}
# Add next_offset if there might be more results
# This happens when:
# 1. We got exactly the limit requested AND (there are more pages OR we left items on the page)
# 2. We didn't get the full limit but there are more pages available
if (len(all_results) == limit and (has_more or last_page_had_more_items)) or (
len(all_results) < limit and has_more
):
result["next_offset"] = offset + len(all_results)
return result
def clean_html_text(text: str | None) -> str:
"""Remove HTML tags and clean up text."""
if not text:
return ""
soup = BeautifulSoup(text, "html.parser")
clean_text: str = soup.get_text(separator=" ")
clean_text = re.sub(r"\n+", "\n", clean_text)
clean_text = re.sub(r"\s+", " ", clean_text)
clean_text = "\n".join(line.strip() for line in clean_text.split("\n"))
return clean_text.strip()
def truncate_text(
text: str | None, max_length: int, suffix: str = " ... [truncated]"
) -> str | None:
"""Truncate text to a maximum length with a suffix."""
if not text or len(text) <= max_length:
return text
truncate_at = max_length - len(suffix)
if truncate_at <= 0:
return suffix
return text[:truncate_at] + suffix
def process_article_body(body: str | None, max_length: int | None = None) -> str | None:
"""Process article body by cleaning HTML and optionally truncating."""
if not body:
return None
cleaned_text: str = clean_html_text(body)
if max_length and len(cleaned_text) > max_length:
result = truncate_text(cleaned_text, max_length)
return result
return cleaned_text
def process_search_results(
results: list[dict[str, Any]],
include_body: bool = False,
max_body_length: int | None = DEFAULT_MAX_BODY_LENGTH,
) -> list[dict[str, Any]]:
"""Process search results to clean up data and restructure with content and metadata."""
processed_results = []
for result in results:
body_content = result.get("body", "")
cleaned_content = None
if include_body and body_content:
cleaned_content = process_article_body(body_content, max_body_length)
processed_result: dict[str, Any] = {"content": cleaned_content, "metadata": {}}
for key, value in result.items():
if key != "body":
processed_result["metadata"][key] = value
processed_results.append(processed_result)
return processed_results
def validate_date_format(date_string: str) -> bool:
"""Validate that a date string matches YYYY-MM-DD format and is a valid date."""
from datetime import datetime
try:
parsed_date = datetime.strptime(date_string, "%Y-%m-%d")
# Ensure the input matches the expected format exactly
return parsed_date.strftime("%Y-%m-%d") == date_string
except ValueError:
return False
def get_zendesk_subdomain(context: ToolContext) -> str:
"""
Get the Zendesk subdomain from secrets with proper error handling.
Args:
context: The tool context containing secrets
Returns:
The Zendesk subdomain
Raises:
ToolExecutionError: If the subdomain secret is not configured
"""
try:
subdomain = context.get_secret("ZENDESK_SUBDOMAIN")
except ValueError:
raise ToolExecutionError(
message="Zendesk subdomain is not set.",
developer_message=(
"Zendesk subdomain is not set. Make sure to set the "
"'ZENDESK_SUBDOMAIN' secret in the Arcade Dashboard."
),
) from None
else:
return subdomain