diff --git a/backend/danswer/configs/constants.py b/backend/danswer/configs/constants.py
index 9858f2354..bb0674218 100644
--- a/backend/danswer/configs/constants.py
+++ b/backend/danswer/configs/constants.py
@@ -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]
diff --git a/backend/danswer/connectors/factory.py b/backend/danswer/connectors/factory.py
index 52fb0194a..a22c32045 100644
--- a/backend/danswer/connectors/factory.py
+++ b/backend/danswer/connectors/factory.py
@@ -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, {})
diff --git a/backend/danswer/connectors/freshdesk/__init__,py b/backend/danswer/connectors/freshdesk/__init__,py
new file mode 100644
index 000000000..e69de29bb
diff --git a/backend/danswer/connectors/freshdesk/connector.py b/backend/danswer/connectors/freshdesk/connector.py
new file mode 100644
index 000000000..be2b1e0e3
--- /dev/null
+++ b/backend/danswer/connectors/freshdesk/connector.py
@@ -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)
\ No newline at end of file
diff --git a/backend/danswer/db/models.py b/backend/danswer/db/models.py
index bee693534..0ebd79102 100644
--- a/backend/danswer/db/models.py
+++ b/backend/danswer/db/models.py
@@ -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
diff --git a/backend/danswer/file_store/models.py b/backend/danswer/file_store/models.py
index d944a2fd2..0a3b513bd 100644
--- a/backend/danswer/file_store/models.py
+++ b/backend/danswer/file_store/models.py
@@ -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
diff --git a/backend/model_server/main.py b/backend/model_server/main.py
index 5505bbc8c..50cf9f8b9 100644
--- a/backend/model_server/main.py
+++ b/backend/model_server/main.py
@@ -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()
diff --git a/web/public/Freshdesk.png b/web/public/Freshdesk.png
new file mode 100644
index 000000000..a3343ceb0
Binary files /dev/null and b/web/public/Freshdesk.png differ
diff --git a/web/src/app/api/[...path]/route.ts b/web/src/app/api/[...path]/route.ts
index 6ca13aba1..8c3d4522b 100644
--- a/web/src/app/api/[...path]/route.ts
+++ b/web/src/app/api/[...path]/route.ts
@@ -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
diff --git a/web/src/components/icons/icons.tsx b/web/src/components/icons/icons.tsx
index 8a7179a06..30424644c 100644
--- a/web/src/components/icons/icons.tsx
+++ b/web/src/components/icons/icons.tsx
@@ -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) => ;
+export const FreshdeskIcon = ({
+ size = 16,
+ className = defaultTailwindCSS,
+}: IconProps) => (
+
+);
+
/*
EE Icons
*/
diff --git a/web/src/lib/connectors/connectors.tsx b/web/src/lib/connectors/connectors.tsx
index d722fcf98..dd06a3436 100644
--- a/web/src/lib/connectors/connectors.tsx
+++ b/web/src/lib/connectors/connectors.tsx
@@ -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;
}
diff --git a/web/src/lib/connectors/credentials.ts b/web/src/lib/connectors/credentials.ts
index d7bcef0ad..de6b6094a 100644
--- a/web/src/lib/connectors/credentials.ts
+++ b/web/src/lib/connectors/credentials.ts
@@ -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 = {
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 = {
// 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;
diff --git a/web/src/lib/sources.ts b/web/src/lib/sources.ts
index 18bc3336a..3c1cebe00 100644
--- a/web/src/lib/sources.ts
+++ b/web/src/lib/sources.ts
@@ -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: {
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index d4f43a024..d92b6a0d1 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -263,6 +263,7 @@ const validSources = [
"oci_storage",
"not_applicable",
"ingestion_api",
+ "freshdesk",
] as const;
export type ValidSources = (typeof validSources)[number];