From 9f888914571855a83513a14ffefacc30c05e68ae Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Sun, 12 Jul 2026 00:31:45 +0200 Subject: [PATCH] feat(freshservice): new Freshservice ITSM integration Freshservice API v2, 7 commands: create/get/update/list tickets, add note (public/private), list agents. API-key (Basic) auth, stdlib-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- integrations/freshservice/manifest.yaml | 93 +++++++++++++++++++ integrations/freshservice/scripts/add_note.py | 65 +++++++++++++ .../freshservice/scripts/create_ticket.py | 76 +++++++++++++++ .../freshservice/scripts/get_ticket.py | 57 ++++++++++++ .../freshservice/scripts/list_agents.py | 56 +++++++++++ .../freshservice/scripts/list_tickets.py | 60 ++++++++++++ .../freshservice/scripts/test_connection.py | 53 +++++++++++ .../freshservice/scripts/update_ticket.py | 72 ++++++++++++++ 8 files changed, 532 insertions(+) create mode 100644 integrations/freshservice/manifest.yaml create mode 100644 integrations/freshservice/scripts/add_note.py create mode 100644 integrations/freshservice/scripts/create_ticket.py create mode 100644 integrations/freshservice/scripts/get_ticket.py create mode 100644 integrations/freshservice/scripts/list_agents.py create mode 100644 integrations/freshservice/scripts/list_tickets.py create mode 100644 integrations/freshservice/scripts/test_connection.py create mode 100644 integrations/freshservice/scripts/update_ticket.py diff --git a/integrations/freshservice/manifest.yaml b/integrations/freshservice/manifest.yaml new file mode 100644 index 0000000..2ce5157 --- /dev/null +++ b/integrations/freshservice/manifest.yaml @@ -0,0 +1,93 @@ +id: freshservice +name: Freshservice +version: 1.0.0 +description: "Freshservice (REST API v2) — IT service management: create, read, update and list tickets, add notes (public or private), and list agents. API-key (Basic) authentication; stdlib-only, no extra Python dependencies." +changelog: "1.0.0 — Initial release: create/get/update/list tickets, add note, list agents." +category: ticketing + +# Per-instance configuration. Auth is HTTP Basic with ':X'. +config_schema: + properties: + domain: + type: string + description: "Freshservice domain (the X in https://X.freshservice.com)" + api_key: + type: string + description: "Freshservice API key" + x-soar-sensitive: true + required: + - domain + - api_key + +commands: + - id: create_ticket + name: freshservice-create-ticket + description: "Create a ticket." + inputs_schema: + properties: + subject: { type: string, description: "Ticket subject" } + description: { type: string, description: "Ticket description (HTML allowed)" } + email: { type: string, description: "Requester email" } + priority: { type: number, description: "1 (low) - 4 (urgent), default 1" } + status: { type: number, description: "2 (open) - 5 (closed), default 2" } + tags: { type: string, description: "Comma-separated tags" } + required: [subject, description, email] + outputs_schema: { properties: {} } + - id: get_ticket + name: freshservice-get-ticket + description: "Get a ticket by ID." + risk: read + inputs_schema: + properties: + ticket_id: { type: string, description: "Ticket ID" } + required: [ticket_id] + outputs_schema: { properties: {} } + - id: update_ticket + name: freshservice-update-ticket + description: "Update a ticket (status, priority, or assignment)." + inputs_schema: + properties: + ticket_id: { type: string, description: "Ticket ID" } + status: { type: number, description: "2 (open) - 5 (closed)" } + priority: { type: number, description: "1-4" } + responder_id: { type: string, description: "Assigned agent ID" } + required: [ticket_id] + outputs_schema: { properties: {} } + - id: list_tickets + name: freshservice-list-tickets + description: "List tickets." + risk: read + inputs_schema: + properties: + page: { type: number, description: "Page number (default 1)" } + per_page: { type: number, description: "Page size (default 30, max 100)" } + required: [] + outputs_schema: { properties: {} } + - id: add_note + name: freshservice-add-note + description: "Add a note to a ticket (public or private)." + inputs_schema: + properties: + ticket_id: { type: string, description: "Ticket ID" } + body: { type: string, description: "Note body (HTML allowed)" } + private: { type: boolean, description: "Private note (default true)" } + required: [ticket_id, body] + outputs_schema: { properties: {} } + - id: list_agents + name: freshservice-list-agents + description: "List agents." + risk: read + inputs_schema: + properties: + email: { type: string, description: "Optional email filter" } + required: [] + outputs_schema: { properties: {} } + + - id: test_connection + name: freshservice-test-connection + description: "Verify connectivity and the API key (used by the Test button)." + risk: read + inputs_schema: + properties: {} + required: [] + outputs_schema: { properties: {} } diff --git a/integrations/freshservice/scripts/add_note.py b/integrations/freshservice/scripts/add_note.py new file mode 100644 index 0000000..84ee93b --- /dev/null +++ b/integrations/freshservice/scripts/add_note.py @@ -0,0 +1,65 @@ +import json, os, sys, base64, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def _base(cfg): + return "https://" + str(cfg.get("domain", "")) + ".freshservice.com/api/v2" + + +def _auth(cfg): + raw = str(cfg.get("api_key", "")) + ":X" + return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8") + + +def request(method, path, cfg, body=None, params=None): + url = _base(cfg) + path + if params: + clean = {k: v for k, v in params.items() if v not in (None, "")} + if clean: + url += "?" + urllib.parse.urlencode(clean) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Authorization": _auth(cfg), "Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + ticket_id = inputs.get("ticket_id") + if not ticket_id: + raise Exception("ticket_id is required") + note_body = inputs.get("body") + if not note_body: + raise Exception("body is required") + q = lambda v: urllib.parse.quote(str(v), safe="") + + private = inputs.get("private") + private_val = True if private is None else bool(private) + + body = {"body": note_body, "private": private_val} + + return request("POST", "/tickets/" + q(ticket_id) + "/notes", cfg, body=body) + + +_run(main) diff --git a/integrations/freshservice/scripts/create_ticket.py b/integrations/freshservice/scripts/create_ticket.py new file mode 100644 index 0000000..2faa82f --- /dev/null +++ b/integrations/freshservice/scripts/create_ticket.py @@ -0,0 +1,76 @@ +import json, os, sys, base64, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def _base(cfg): + return "https://" + str(cfg.get("domain", "")) + ".freshservice.com/api/v2" + + +def _auth(cfg): + raw = str(cfg.get("api_key", "")) + ":X" + return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8") + + +def request(method, path, cfg, body=None, params=None): + url = _base(cfg) + path + if params: + clean = {k: v for k, v in params.items() if v not in (None, "")} + if clean: + url += "?" + urllib.parse.urlencode(clean) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Authorization": _auth(cfg), "Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + subject = inputs.get("subject") + if not subject: + raise Exception("subject is required") + description = inputs.get("description") + if not description: + raise Exception("description is required") + email = inputs.get("email") + if not email: + raise Exception("email is required") + priority = inputs.get("priority") + status = inputs.get("status") + tags_raw = inputs.get("tags") + tags = [s.strip() for s in str(tags_raw).split(",") if s.strip()] if tags_raw else [] + + body = { + "subject": subject, + "description": description, + "email": email, + "priority": int(priority or 1), + "status": int(status or 2), + } + if tags: + body["tags"] = tags + + return request("POST", "/tickets", cfg, body=body) + + +_run(main) diff --git a/integrations/freshservice/scripts/get_ticket.py b/integrations/freshservice/scripts/get_ticket.py new file mode 100644 index 0000000..0f17767 --- /dev/null +++ b/integrations/freshservice/scripts/get_ticket.py @@ -0,0 +1,57 @@ +import json, os, sys, base64, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def _base(cfg): + return "https://" + str(cfg.get("domain", "")) + ".freshservice.com/api/v2" + + +def _auth(cfg): + raw = str(cfg.get("api_key", "")) + ":X" + return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8") + + +def request(method, path, cfg, body=None, params=None): + url = _base(cfg) + path + if params: + clean = {k: v for k, v in params.items() if v not in (None, "")} + if clean: + url += "?" + urllib.parse.urlencode(clean) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Authorization": _auth(cfg), "Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + ticket_id = inputs.get("ticket_id") + if not ticket_id: + raise Exception("ticket_id is required") + q = lambda v: urllib.parse.quote(str(v), safe="") + + return request("GET", "/tickets/" + q(ticket_id), cfg) + + +_run(main) diff --git a/integrations/freshservice/scripts/list_agents.py b/integrations/freshservice/scripts/list_agents.py new file mode 100644 index 0000000..ffc6706 --- /dev/null +++ b/integrations/freshservice/scripts/list_agents.py @@ -0,0 +1,56 @@ +import json, os, sys, base64, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def _base(cfg): + return "https://" + str(cfg.get("domain", "")) + ".freshservice.com/api/v2" + + +def _auth(cfg): + raw = str(cfg.get("api_key", "")) + ":X" + return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8") + + +def request(method, path, cfg, body=None, params=None): + url = _base(cfg) + path + if params: + clean = {k: v for k, v in params.items() if v not in (None, "")} + if clean: + url += "?" + urllib.parse.urlencode(clean) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Authorization": _auth(cfg), "Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + email = inputs.get("email") + + params = {"email": email} + + return request("GET", "/agents", cfg, params=params) + + +_run(main) diff --git a/integrations/freshservice/scripts/list_tickets.py b/integrations/freshservice/scripts/list_tickets.py new file mode 100644 index 0000000..99eabe3 --- /dev/null +++ b/integrations/freshservice/scripts/list_tickets.py @@ -0,0 +1,60 @@ +import json, os, sys, base64, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def _base(cfg): + return "https://" + str(cfg.get("domain", "")) + ".freshservice.com/api/v2" + + +def _auth(cfg): + raw = str(cfg.get("api_key", "")) + ":X" + return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8") + + +def request(method, path, cfg, body=None, params=None): + url = _base(cfg) + path + if params: + clean = {k: v for k, v in params.items() if v not in (None, "")} + if clean: + url += "?" + urllib.parse.urlencode(clean) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Authorization": _auth(cfg), "Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + page = inputs.get("page") + per_page = inputs.get("per_page") + + params = { + "page": int(page or 1), + "per_page": int(per_page or 30), + } + + return request("GET", "/tickets", cfg, params=params) + + +_run(main) diff --git a/integrations/freshservice/scripts/test_connection.py b/integrations/freshservice/scripts/test_connection.py new file mode 100644 index 0000000..fd5bfce --- /dev/null +++ b/integrations/freshservice/scripts/test_connection.py @@ -0,0 +1,53 @@ +import json, os, sys, base64, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def _base(cfg): + return "https://" + str(cfg.get("domain", "")) + ".freshservice.com/api/v2" + + +def _auth(cfg): + raw = str(cfg.get("api_key", "")) + ":X" + return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8") + + +def request(method, path, cfg, body=None, params=None): + url = _base(cfg) + path + if params: + clean = {k: v for k, v in params.items() if v not in (None, "")} + if clean: + url += "?" + urllib.parse.urlencode(clean) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Authorization": _auth(cfg), "Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + request("GET", "/tickets", cfg, params={"per_page": 1}) + return {"ok": True} + + +_run(main) diff --git a/integrations/freshservice/scripts/update_ticket.py b/integrations/freshservice/scripts/update_ticket.py new file mode 100644 index 0000000..7ad64fa --- /dev/null +++ b/integrations/freshservice/scripts/update_ticket.py @@ -0,0 +1,72 @@ +import json, os, sys, base64, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def _base(cfg): + return "https://" + str(cfg.get("domain", "")) + ".freshservice.com/api/v2" + + +def _auth(cfg): + raw = str(cfg.get("api_key", "")) + ":X" + return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8") + + +def request(method, path, cfg, body=None, params=None): + url = _base(cfg) + path + if params: + clean = {k: v for k, v in params.items() if v not in (None, "")} + if clean: + url += "?" + urllib.parse.urlencode(clean) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Authorization": _auth(cfg), "Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + ticket_id = inputs.get("ticket_id") + if not ticket_id: + raise Exception("ticket_id is required") + q = lambda v: urllib.parse.quote(str(v), safe="") + + status = inputs.get("status") + priority = inputs.get("priority") + responder_id = inputs.get("responder_id") + + body = {} + if status not in (None, ""): + body["status"] = int(status) + if priority not in (None, ""): + body["priority"] = int(priority) + if responder_id not in (None, ""): + body["responder_id"] = int(responder_id) + + if not body: + raise Exception("at least one field to update is required") + + return request("PUT", "/tickets/" + q(ticket_id), cfg, body=body) + + +_run(main)