Implement freshdesk frontend

This commit is contained in:
Skylar Kesselring 2024-10-23 12:58:15 -04:00
parent 8f9d4335ce
commit a1680fac2f
14 changed files with 170 additions and 4 deletions

View File

@ -128,6 +128,7 @@ class DocumentSource(str, Enum):
OCI_STORAGE = "oci_storage"
XENFORO = "xenforo"
NOT_APPLICABLE = "not_applicable"
FRESHDESK = "freshdesk"
DocumentSourceRequiringTenantContext: list[DocumentSource] = [DocumentSource.FILE]

View File

@ -46,6 +46,7 @@ from danswer.connectors.wikipedia.connector import WikipediaConnector
from danswer.connectors.xenforo.connector import XenforoConnector
from danswer.connectors.zendesk.connector import ZendeskConnector
from danswer.connectors.zulip.connector import ZulipConnector
from danswer.connectors.freshdesk.connector import FreshdeskConnector
from danswer.db.credentials import backend_update_credential_json
from danswer.db.models import Credential
@ -101,6 +102,7 @@ def identify_connector_class(
DocumentSource.GOOGLE_CLOUD_STORAGE: BlobStorageConnector,
DocumentSource.OCI_STORAGE: BlobStorageConnector,
DocumentSource.XENFORO: XenforoConnector,
DocumentSource.FRESHDESK: FreshdeskConnector,
}
connector_by_source = connector_map.get(source, {})

View File

@ -0,0 +1,116 @@
import requests
import json
from datetime import datetime, timezone
from typing import Any, List, Optional
from bs4 import BeautifulSoup # Add this import for HTML parsing
from danswer.configs.app_configs import INDEX_BATCH_SIZE
from danswer.configs.constants import DocumentSource
from danswer.connectors.interfaces import GenerateDocumentsOutput, PollConnector
from danswer.connectors.models import ConnectorMissingCredentialError, Document, Section
from danswer.utils.logger import setup_logger
logger = setup_logger()
class FreshdeskConnector(PollConnector):
def __init__(self, api_key: str, domain: str, password: str, batch_size: int = INDEX_BATCH_SIZE) -> None:
self.api_key = api_key
self.domain = domain
self.password = password
self.batch_size = batch_size
def ticket_link(self, tid: int) -> str:
return f"https://{self.domain}.freshdesk.com/helpdesk/tickets/{tid}"
def build_doc_sections_from_ticket(self, ticket: dict) -> List[Section]:
# Use list comprehension for building sections
return [
Section(
link=self.ticket_link(int(ticket["id"])),
text=json.dumps({
key: value
for key, value in ticket.items()
if isinstance(value, str)
}, default=str),
)
]
def strip_html_tags(self, html: str) -> str:
soup = BeautifulSoup(html, 'html.parser')
return soup.get_text()
def load_credentials(self, credentials: dict[str, Any]) -> Optional[dict[str, Any]]:
logger.info("Loading credentials")
self.api_key = credentials.get("freshdesk_api_key")
self.domain = credentials.get("freshdesk_domain")
self.password = credentials.get("freshdesk_password")
return None
def _process_tickets(self, start: datetime, end: datetime) -> GenerateDocumentsOutput:
logger.info("Processing tickets")
if any([self.api_key, self.domain, self.password]) is None:
raise ConnectorMissingCredentialError("freshdesk")
freshdesk_url = f"https://{self.domain}.freshdesk.com/api/v2/tickets?include=description"
response = requests.get(freshdesk_url, auth=(self.api_key, self.password))
response.raise_for_status() # raises exception when not a 2xx response
if response.status_code!= 204:
tickets = json.loads(response.content)
logger.info(f"Fetched {len(tickets)} tickets from Freshdesk API")
doc_batch: List[Document] = []
for ticket in tickets:
# Convert the "created_at", "updated_at", and "due_by" values to ISO 8601 strings
for date_field in ["created_at", "updated_at", "due_by"]:
ticket[date_field] = datetime.fromisoformat(ticket[date_field]).strftime("%Y-%m-%d %H:%M:%S")
# Convert all other values to strings
ticket = {
key: str(value) if not isinstance(value, str) else value
for key, value in ticket.items()
}
# Checking for overdue tickets
today = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
ticket["overdue"] = "true" if today > ticket["due_by"] else "false"
# Mapping the status field values
status_mapping = {2: "open", 3: "pending", 4: "resolved", 5: "closed"}
ticket["status"] = status_mapping.get(ticket["status"], str(ticket["status"]))
# Stripping HTML tags from the description field
ticket["description"] = self.strip_html_tags(ticket["description"])
# Remove extra white spaces from the description field
ticket["description"] = " ".join(ticket["description"].split())
# Use list comprehension for building sections
sections = self.build_doc_sections_from_ticket(ticket)
created_at = datetime.fromisoformat(ticket["created_at"])
today = datetime.now()
if (today - created_at).days / 30.4375 <= 2:
doc = Document(
id=ticket["id"],
sections=sections,
source=DocumentSource.FRESHDESK,
semantic_identifier=ticket["subject"],
metadata={
key: value
for key, value in ticket.items()
if isinstance(value, str) and key not in ["description", "description_text"]
},
)
doc_batch.append(doc)
if len(doc_batch) >= self.batch_size:
yield doc_batch
doc_batch = []
if doc_batch:
yield doc_batch
def poll_source(self, start: datetime, end: datetime) -> GenerateDocumentsOutput:
yield from self._process_tickets(start, end)

View File

@ -3,7 +3,7 @@ import json
from enum import Enum as PyEnum
from typing import Any
from typing import Literal
from typing import NotRequired
from typing_extensions import NotRequired
from typing import Optional
from uuid import uuid4
from typing_extensions import TypedDict # noreorder

View File

@ -1,6 +1,6 @@
import base64
from enum import Enum
from typing import NotRequired
from typing_extensions import NotRequired
from typing_extensions import TypedDict # noreorder
from pydantic import BaseModel

View File

@ -28,7 +28,7 @@ os.environ["TOKENIZERS_PARALLELISM"] = "false"
os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1"
HF_CACHE_PATH = Path("/root/.cache/huggingface/")
TEMP_HF_CACHE_PATH = Path("/root/.cache/temp_huggingface/")
TEMP_HF_CACHE_PATH = Path.home() / ".cache" / "temp_huggingface"
transformer_logging.set_verbosity_error()

BIN
web/public/Freshdesk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -75,9 +75,13 @@ async function handleRequest(request: NextRequest, path: string[]) {
backendUrl.searchParams.append(key, value);
});
// Create a new headers object, omitting the 'connection' header
const headers = new Headers(request.headers);
headers.delete('connection');
const response = await fetch(backendUrl, {
method: request.method,
headers: request.headers,
headers: headers,
body: request.body,
signal: request.signal,
// @ts-ignore

View File

@ -75,6 +75,7 @@ import slackIcon from "../../../public/Slack.png";
import s3Icon from "../../../public/S3.png";
import r2Icon from "../../../public/r2.png";
import salesforceIcon from "../../../public/Salesforce.png";
import freshdeskIcon from "../../../public/Freshdesk.png";
import sharepointIcon from "../../../public/Sharepoint.png";
import teamsIcon from "../../../public/Teams.png";
@ -1301,6 +1302,13 @@ export const AsanaIcon = ({
className = defaultTailwindCSS,
}: IconProps) => <LogoIcon size={size} className={className} src={asanaIcon} />;
export const FreshdeskIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => (
<LogoIcon size={size} className={className} src={freshdeskIcon} />
);
/*
EE Icons
*/

View File

@ -922,6 +922,12 @@ For example, specifying .*-support.* as a "channel" will cause the connector to
],
advanced_values: [],
},
freshdesk: {
description: "Configure Freshdesk connector",
values: [],
advanced_values: [],
},
};
export function createConnectorInitialValues(
connector: ConfigurableSources
@ -1180,6 +1186,11 @@ export interface AsanaConfig {
asana_team_id?: string;
}
export interface FreshdeskConfig {
requested_objects?: string[];
}
export interface MediaWikiConfig extends MediaWikiBaseConfig {
hostname: string;
}

View File

@ -186,6 +186,12 @@ export interface AxeroCredentialJson {
axero_api_token: string;
}
export interface FreshdeskCredentialJson {
freshdesk_domain: string;
freshdesk_password: string;
freshdesk_api_key: string;
}
export interface MediaWikiCredentialJson {}
export interface WikipediaCredentialJson extends MediaWikiCredentialJson {}
@ -289,6 +295,11 @@ export const credentialTemplates: Record<ValidSources, any> = {
access_key_id: "",
secret_access_key: "",
} as OCICredentialJson,
freshdesk: {
freshdesk_domain: "",
freshdesk_password: "",
freshdesk_api_key: "",
} as FreshdeskCredentialJson,
xenforo: null,
google_sites: null,
file: null,
@ -435,6 +446,11 @@ export const credentialDisplayNames: Record<string, string> = {
// Axero
base_url: "Axero Base URL",
axero_api_token: "Axero API Token",
// Freshdesk
freshdesk_domain: "Freshdesk Domain",
freshdesk_password: "Freshdesk Password",
freshdesk_api_key: "Freshdesk API Key",
};
export function getDisplayNameForCredentialKey(key: string): string {
return credentialDisplayNames[key] || key;

View File

@ -37,6 +37,7 @@ import {
GoogleStorageIcon,
ColorSlackIcon,
XenforoIcon,
FreshdeskIcon,
} from "@/components/icons/icons";
import { ValidSources } from "./types";
import {
@ -289,6 +290,12 @@ const SOURCE_METADATA_MAP: SourceMap = {
displayName: "Ingestion",
category: SourceCategory.Other,
},
freshdesk: {
icon: FreshdeskIcon,
displayName: "Freshdesk",
category: SourceCategory.CustomerSupport,
docs: "https://docs.danswer.dev/connectors/freshdesk",
},
// currently used for the Internet Search tool docs, which is why
// a globe is used
not_applicable: {

View File

@ -263,6 +263,7 @@ const validSources = [
"oci_storage",
"not_applicable",
"ingestion_api",
"freshdesk",
] as const;
export type ValidSources = (typeof validSources)[number];