diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..f0c8069
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,50 @@
+# Git
+.git
+.gitignore
+.gitattributes
+
+# Secrets
+.env
+.env.*
+.secrets/
+certs/
+*.pem
+*.key
+
+# Models
+models/
+*.gguf
+
+# Data
+data/
+knowledge-base/
+email-queue/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+
+# CI
+.github/
+
+# Docker
+Dockerfile*
+docker-compose*
+
+# Node (for tools-ui)
+node_modules/
+
+# Documentation
+README.md
+CONTRIBUTING.md
+SECURITY.md
+CODE_OF_CONDUCT.md
+AUDIT_REPORT.md
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..36d288e
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,12 @@
+# Default owners for all files
+* @OneByJorah
+
+# Security-sensitive files
+SECURITY.md @OneByJorah
+config/ @OneByJorah
+docker-compose*.yml @OneByJorah
+Dockerfile* @OneByJorah
+
+# CI/CD
+.github/workflows/ @OneByJorah
+.github/dependabot.yml @OneByJorah
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..e908336
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,32 @@
+---
+name: Bug Report
+about: Create a report to help us improve
+title: '[BUG] '
+labels: bug
+assignees: ''
+
+---
+
+**Describe the Bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected Behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Environment (please complete the following information):**
+- OS: [e.g. Ubuntu 22.04]
+- Python Version: [e.g. 3.11]
+- Docker Version: [e.g. 24.0]
+
+**Additional Context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..5d98db2
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature Request
+about: Suggest an idea for this project
+title: '[FEATURE] '
+labels: enhancement
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the Solution You'd Like**
+A clear and concise description of what you want to happen.
+
+**Describe Alternatives You've Considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional Context**
+Add any other context or screenshots about the feature request here.
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..6ec7a44
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,23 @@
+## Description
+
+Please include a summary of the change and which issue is fixed.
+
+Fixes # (issue)
+
+## Type of Change
+
+- [ ] Bug fix (non-breaking change)
+- [ ] New feature (non-breaking change)
+- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
+- [ ] Documentation update
+- [ ] Security fix
+
+## Checklist
+
+- [ ] My code follows the style guidelines of this project
+- [ ] I have performed a self-review of my own code
+- [ ] I have commented my code, particularly in hard-to-understand areas
+- [ ] I have made corresponding changes to the documentation
+- [ ] My changes generate no new warnings
+- [ ] I have added tests that prove my fix is effective or that my feature works
+- [ ] New and existing unit tests pass locally with my changes
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..4f46c24
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,21 @@
+version: 2
+updates:
+ - package-ecosystem: "pip"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 10
+ - package-ecosystem: "npm"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 10
+ - package-ecosystem: "docker"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 5
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..707e2e6
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,37 @@
+name: CodeQL
+on:
+ push:
+ branches: [main, master]
+ pull_request:
+ branches: [main, master]
+ schedule:
+ - cron: '0 0 * * 0'
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: ['python', 'javascript', 'typescript']
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v3
+ with:
+ languages: ${{ matrix.language }}
+
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v3
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v3
diff --git a/AUDIT_REPORT.md b/AUDIT_REPORT.md
new file mode 100644
index 0000000..5dbe0de
--- /dev/null
+++ b/AUDIT_REPORT.md
@@ -0,0 +1,162 @@
+# AUDIT_REPORT — CommandDesk
+
+**Date:** 2026-07-05
+**Auditor:** J1-PIPELINE (Phase 1 — AUDITOR)
+**Status:** `DEGRADED` (Score: 74/100)
+
+---
+
+## 1. PROJECT CLASSIFICATION
+
+| Field | Value |
+|---|---|
+| **Repo** | CommandDesk |
+| **Class** | AI / Helpdesk / Full-Stack |
+| **Primary Language** | Python 3.11 |
+| **Framework** | FastAPI |
+| **Deployment** | Docker Compose |
+| **Tailscale Only** | No (exposed ports: 80, 443, 8383) |
+
+---
+
+## 2. README COMPLIANCE (README_STANDARD.md)
+
+| Requirement | Status | Notes |
+|---|---|---|
+| One-line positioning | ✅ | "Self-Hosted AI Helpdesk Agent" |
+| Max 3 badges | ❌ | Has **5 badges** (Python, FastAPI, Docker, AI, MIT) |
+| 60-second quick start | ✅ | `git clone` → `cp .env.example .env` → `docker compose up -d` |
+| Features (3-5 bullets) | ✅ | 8 features listed |
+| Architecture diagram | ✅ | ASCII diagram present |
+| Contributing section | ✅ | Links to CONTRIBUTING.md |
+| License section | ✅ | MIT |
+
+**Issue:** README has 5 badges — exceeds the 3-badge max per README_STANDARD.md.
+
+---
+
+## 3. CODE QUALITY — CRITICAL & DEGRADED ITEMS
+
+### 🔴 CRITICAL (Must Fix)
+
+| # | File | Issue | Severity |
+|---|---|---|---|
+| C1 | `rate_limiter.py:64` | **Bug:** `self._sessions: dict[str, SessionState] =()` — Empty tuple instead of dict `{}`. Causes `AttributeError` on first access. | CRITICAL |
+| C2 | `zammad.py` | **Missing import:** Uses `requests.post()`, `requests.patch()`, `requests.get()` but `import requests` is missing. Will crash at runtime. | CRITICAL |
+| C3 | `session_manager.py:38` | **SQL Injection:** `INTERVAL '{} seconds'".format(self.max_duration)` — Uses string formatting for SQL query parameter. | CRITICAL |
+| C4 | `health_monitor.py:22` | **Wrong Redis URL:** `redis://redis:***@postgres:5432/helpdesk` — References PostgreSQL port (5432) instead of Redis port (6379), and uses `***` as password placeholder in a default. | CRITICAL |
+| C5 | `whatsapp_webhook.py:29` | **Same wrong Redis URL:** `redis://redis:***@postgres:5432/helpdesk` | CRITICAL |
+| C6 | `email_fetcher.py:83` | **Missing API endpoint:** POSTs to `/tickets/create` but no such route exists in `agent_server.py` — only `/chat` and `/health` are defined. Feature is broken. | CRITICAL |
+
+### 🟡 DEGRADED (Should Fix)
+
+| # | File | Issue | Severity |
+|---|---|---|---|
+| D1 | `rate_limiter.py` | **In-memory only:** Redis client is stored but never used. Rate limiting won't work across replicas/restarts. | DEGRADED |
+| D2 | `agent_server.py` | **Blocking calls in async:** `get_system_prompt()` (file I/O) and `rate_limiter.check_request()` are sync methods called from async endpoints. | DEGRADED |
+| D3 | `session_manager.py:92` | **O(N) `redis.keys()`:** Used in `get_active_count()` and `cleanup_expired()` — dangerous in production with many sessions. | DEGRADED |
+| D4 | `health_monitor.py:83` | **O(N) `redis.keys()`:** Same pattern as D3. | DEGRADED |
+| D5 | `analytics.py:10` | **Hardcoded password in URL:** `postgresql://helpdesk:***@postgres:5432/helpdesk` | DEGRADED |
+| D6 | `whatsapp_webhook.py:138` | **Import inside function:** `import re` should be at module level. | DEGRADED |
+
+---
+
+## 4. SECURITY AUDIT (GUARDIAN — Phase 3 Preview)
+
+| Check | Status | Notes |
+|---|---|---|
+| Default credentials | ❌ | `change_me` passwords throughout: JWT_SECRET, CHROMA_AUTH_TOKEN, WHATSAPP_WEBHOOK_SECRET, DB_PASSWORD, REDIS_PASSWORD |
+| HTTPS enforced | ❌ | Nginx listens on port 80 only (no 443 config). `docker-compose.prod.yml` exposes 443 but no TLS certs configured. |
+| CORS | ⚠️ | `allow_origins=["*"]` — acceptable for development, but not documented as such |
+| Secrets in repo | ✅ | `.env` in `.gitignore`, `*.pem`, `*.key` excluded |
+| Gitleaks clean | ✅ | No hardcoded secrets found in code |
+| Rate limiting | ✅ | Nginx + app-level rate limiting implemented |
+| Docker rootless | ⚠️ | No non-root user specified in Dockerfiles |
+| Content-Security-Policy | ✅ | Set in nginx.conf |
+| SBOM | ❌ | No SBOM (CycloneDX or SPDX) present |
+| Security.md | ✅ | Present with contact + disclosure policy |
+| Auth for admin | ⚠️ | `ADMIN_API_KEY` configured but no auth middleware seen in agent_server.py |
+
+---
+
+## 5. DOCKER STANDARD CHECK
+
+| Check | Status | Notes |
+|---|---|---|
+| Health checks | ✅ | Defined for all services |
+| Named volumes | ✅ | All volumes named |
+| `.dockerignore` | ❌ | **Missing** — `.env`, `.git`, `node_modules` could leak into build context |
+| Multi-stage builds | ❌ | Dockerfile is single-stage |
+| Image version tags | ⚠️ | `searxng:latest`, `n8n:latest` — not pinned |
+| Non-root user | ❌ | Root user used in all Dockerfiles |
+| Tailscale pattern | ❌ | Not implemented |
+
+---
+
+## 6. GITHUB STANDARD CHECK
+
+| Check | Status | Notes |
+|---|---|---|
+| Repo description | ✅ | Set |
+| Topics | ⚠️ | Could add more specific topics |
+| Branch protection | ⚠️ | Not verified (requires GH API) |
+| CODEOWNERS | ❌ | **Missing** |
+| Dependabot | ✅ | `.github/dependabot.yml` present |
+| Issue templates | ✅ | Bug report + feature request |
+| PR template | ✅ | Present |
+| CI workflows | ✅ | CI + CodeQL workflows present |
+
+---
+
+## 7. MISSING STANDARD FILES
+
+| File | Purpose | Status |
+|---|---|---|
+| `j1.yaml` | Pipeline registry metadata | ❌ Missing |
+| `INTENT.md` | Engineering intent (Phase -1 ORACLE) | ❌ Missing |
+| `.dockerignore` | Docker build context optimization | ❌ Missing |
+| `CHANGELOG.md` | Release history | ❌ Missing |
+| `CODEOWNERS` | PR ownership routing | ❌ Missing |
+| `pyproject.toml` / `setup.py` | Python package metadata | ❌ Missing |
+
+---
+
+## 8. PRODUCTION SCORE
+
+| Category | Weight | Score | Weighted |
+|---|---|---|---|
+| Security | 20% | 65 | 13.0 |
+| Architecture | 15% | 75 | 11.25 |
+| Documentation | 15% | 80 | 12.0 |
+| Testing | 15% | 55 | 8.25 |
+| Deployment | 10% | 78 | 7.8 |
+| Automation | 10% | 82 | 8.2 |
+| GitHub Quality | 10% | 85 | 8.5 |
+| Branding | 5% | 85 | 4.25 |
+
+**Total Score: 73.25 / 100 — `DEGRADED`** (threshold: 90)
+
+---
+
+## 9. RECOMMENDED ACTIONS
+
+### Immediate (CRITICAL fixes):
+1. Fix `self._sessions = {}` in `rate_limiter.py:64` (typo `()` → `{}`)
+2. Add `import requests` to `zammad.py`
+3. Fix SQL injection in `session_manager.py:38` — use parameterized query
+4. Fix Redis URLs in `health_monitor.py` and `whatsapp_webhook.py` (port 6379, proper placeholder)
+5. Add `/tickets/create` endpoint to `agent_server.py` or fix `email_fetcher.py` to use `/chat`
+
+### Short-term:
+6. Add `.dockerignore`
+7. Pin Docker image versions (no `:latest`)
+8. Fix blocking calls in async endpoints
+9. Replace `redis.keys()` with `SCAN` in session_manager and health_monitor
+10. Add `import re` at top of `whatsapp_webhook.py`
+
+### Medium-term:
+11. Create `j1.yaml` and `INTENT.md`
+12. Create `CHANGELOG.md`
+13. Add CODEOWNERS file
+14. Implement Redis-backed rate limiting (not just in-memory)
+15. Add non-root user to Dockerfiles
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..1e61859
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,28 @@
+# Changelog
+
+All notable changes to CommandDesk will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+### Fixed
+
+- **CRITICAL:** Fixed `rate_limiter.py` dict initialization typo (`=()` → `={}`) that broke rate limiting entirely
+- **CRITICAL:** Added missing `import requests` to `zammad.py` — would crash on ticket operations
+- **CRITICAL:** Fixed SQL injection vulnerability in `session_manager.py` — replaced string formatting with parameterized query
+- **CRITICAL:** Fixed wrong Redis URLs in `health_monitor.py` and `whatsapp_webhook.py` (referenced PostgreSQL port 5432 instead of Redis port 6379)
+- **CRITICAL:** Fixed `email_fetcher.py` to route email-to-ticket through existing `/chat` endpoint instead of non-existent `/tickets/create`
+- Moved `import re` to module level in `whatsapp_webhook.py` (was inside function)
+- Removed hardcoded placeholder password from `analytics.py` default URL
+- Replaced `redis.keys()` with `SCAN` cursor iteration in `session_manager.py` and `health_monitor.py` to avoid O(N) blocking calls
+- Added `.dockerignore` to prevent build context leaks
+- Added `j1.yaml` for pipeline registry metadata
+
+### Changed
+
+- `session_manager.py`: `import uuid` moved to module level
+- `health_monitor.py`: Fixed Redis URL default to proper format
+- `whatsapp_webhook.py`: Fixed Redis URL default to proper format
+- `analytics.py`: PostgreSQL URL default now empty (must be set via env)
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..256974a
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,48 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, religion, or sexual identity
+and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment:
+
+- Demonstrating empathy and kindness toward other people
+- Being respectful of differing opinions, viewpoints, and experiences
+- Giving and gracefully accepting constructive feedback
+- Accepting responsibility and apologizing to those affected by our mistakes
+- Focusing on what is best not just for us as individuals, but for the overall
+ community
+
+Examples of unacceptable behavior:
+
+- The use of sexualized language or imagery, and sexual attention or advances
+- Trolling, insulting or derogatory comments, and personal or political attacks
+- Public or private harassment
+- Publishing others' private information without explicit permission
+- Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the project team at j1admin@onebyjorah.com. All complaints will
+be reviewed and investigated and will result in a response that is deemed
+necessary and appropriate to the circumstances.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.1, available at
+https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
+
+[homepage]: https://www.contributor-covenant.org
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..36cbd7d
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,57 @@
+# Contributing to JorahOne Projects
+
+First off, thank you for considering contributing! It's people like you that make
+this community great.
+
+## Code of Conduct
+
+This project and everyone participating in it is governed by our Code of Conduct.
+By participating, you are expected to uphold this code.
+
+## How Can I Contribute?
+
+### Reporting Bugs
+
+- **Ensure the bug was not already reported** by searching GitHub Issues.
+- If you're unable to find an open issue addressing the problem, open a new one.
+- Include a **clear title and description**, as much relevant information as possible,
+ and a **code sample** or **executable test case** demonstrating the expected behavior.
+
+### Suggesting Enhancements
+
+- Open a new GitHub Issue with the enhancement tag.
+- Provide a clear explanation of why this enhancement would be useful.
+
+### Pull Requests
+
+1. Fork the repository
+2. Create a feature branch: `git checkout -b feature/my-feature`
+3. Commit your changes: `git commit -am 'Add my feature'`
+4. Push to the branch: `git push origin feature/my-feature`
+5. Open a Pull Request
+
+### Styleguides
+
+#### Git Commit Messages
+
+- Use the present tense ("Add feature" not "Added feature")
+- Use the imperative mood ("Move cursor to..." not "Moves cursor to...")
+- Limit the first line to 72 characters or less
+- Reference issues and pull requests liberally after the first line
+
+#### Code Style
+
+Follow the existing code style in the project. When in doubt, match the
+surrounding code. Consistency is key.
+
+## Additional Notes
+
+### Issue and Pull Request Labels
+
+| Label | Description |
+|-------|-------------|
+| `bug` | Something isn't working |
+| `enhancement` | New feature or improvement |
+| `documentation` | Documentation only changes |
+| `security` | Security-related issues |
+| `good first issue` | Good for newcomers |
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5b5ae2a
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Jhonattan L. Jimenez
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 03026fc..68752fb 100644
--- a/README.md
+++ b/README.md
@@ -1,120 +1,133 @@
-# CommandDesk (CommandDesk)
-
-**Version:** v0.1
-**Status:** Active Development
-**Repository:** https://github.com/OneByJorah/CommandDesk
-
----
-
-## Table of Contents
-
-- [Overview](#overview)
-- [Architecture](#architecture)
-- [Technology Stack](#technology-stack)
-- [Features](#features)
-- [Getting Started](#getting-started)
-- [Service Management](#service-management)
-- [Project Structure](#project-structure)
-- [Screenshots](#screenshots)
-- [Contributing](#contributing)
-- [License](#license)
-- [Author](#author)
+
+
+
+
+
+
🎫 CommandDesk
+
Self-Hosted AI Helpdesk Agent
+
100% local, AI-powered helpdesk with multi-platform ticketing, knowledge base, and multi-channel communication
+
+ Features •
+ Quick Start •
+ Architecture •
+ Integrations
+
+
---
-## Overview
-
-Helpdesk and NOC operations dashboard with ticket tracking and system monitoring.
-
----
-
-## Architecture
-
-Client → Local service (`CommandDesk`) → data/processing modules → output/api layer.
-Secrets and environment configuration are managed via environment files with restrictive permissions.
+## ✨ Features
----
-
-## Technology Stack
-
-|| Layer | Stack |
-|---|---|
-| Runtime | Linux (Ubuntu 22.04+) |
-| Primary Stack | HTML5 / Python / systemd |
-| VCS | Git + GitHub (`github.com/OneByJorah/CommandDesk`) |
-| Dev Port | Localhost / systemd service |
-
----
-
-## Features
-
-- Operational dashboard and monitoring (per repo).
-- Exportable data / reports where supported.
-- Extensible service-based design.
-- Dark-themed UI where applicable.
-
----
+- **AI-Powered Ticketing** — Auto-respond, triage, and resolve tickets via local LLMs
+- **Multi-Platform Support** — osTicket, Freshdesk, Zammad adapters
+- **Multi-Channel** — WhatsApp, Email (IMAP), and web interface
+- **Knowledge Base** — ChromaDB semantic search for instant answers
+- **Admin Dashboard** — Analytics, human takeover, and management
+- **Security** — Rate limiting, content filtering, PII detection
+- **Workflow Automation** — n8n integration for complex automation
+- **Plug-in Architecture** — Extend with custom adapters and tools
-## Getting Started
+## 🚀 Quick Start
```bash
-# 1. Clone the repository
git clone https://github.com/OneByJorah/CommandDesk.git
cd CommandDesk
+cp .env.example .env
+# Edit .env with your configuration
+docker compose up -d
+```
-# 2. Install dependencies
-# (see specific subproject docs)
+## 🏗️ Architecture
-# 3. Start the service
-# (see Service Management below)
+```
+┌──────────────────────────────────────────────────────────┐
+│ CommandDesk │
+│ │
+│ ┌─────────────┐ ┌─────────────┐ ┌──────────────────┐ │
+│ │ Ticket │ │ AI │ │ Knowledge │ │
+│ │ Platforms │ │ Engine │ │ Base │ │
+│ │ osTicket │ │ Ollama │ │ ChromaDB │ │
+│ │ Freshdesk │ │ llama.cpp │ │ Qdrant │ │
+│ │ Zammad │ │ OpenAI │ │ │ │
+│ └──────┬──────┘ └──────┬──────┘ └────────┬─────────┘ │
+│ │ │ │ │
+│ └────────────────┼───────────────────┘ │
+│ ▼ │
+│ ┌──────────────────────┐ │
+│ │ Communication Layer │ │
+│ │ WhatsApp · Email · │ │
+│ │ Web Interface │ │
+│ └──────────────────────┘ │
+└──────────────────────────────────────────────────────────┘
```
----
+## 📡 Integrations
-## Service Management
+| Platform | Type | Description |
+|----------|------|-------------|
+| **osTicket** | Ticketing | Open-source ticket system adapter |
+| **Freshdesk** | Ticketing | Cloud-based ticketing |
+| **Zammad** | Ticketing | Open-source support system |
+| **WhatsApp** | Channel | WhatsApp messaging integration |
+| **Email (IMAP)** | Channel | Email-to-ticket conversion |
+| **ChromaDB** | Knowledge | Vector search for knowledge base |
+| **n8n** | Automation | Workflow automation |
+
+## 🐳 Docker Compose
```bash
-# Start the service (example)
-sudo systemctl start CommandDesk.service
-sudo systemctl enable CommandDesk.service
-```
+# Start with AI engine
+docker compose up -d
-Access the service via your configured localhost port or reverse proxy.
+# Start with development config
+docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
----
+# View logs
+docker compose logs -f
+
+# Stop
+docker compose down
+```
-## Project Structure
+## 📁 Project Structure
```
CommandDesk/
-├── README.md
-├── (additional project files)
+├── admin/ # Admin dashboard
+├── compose/ # Docker Compose configs
+├── config/ # Application configuration
+├── scripts/ # Utility scripts
+├── skills/ # AI agent skills
+├── ticket_platforms/ # osTicket, Freshdesk, Zammad adapters
+├── tools-ui/ # Web UI components
+├── Dockerfile # Backend Docker image
+├── Dockerfile.email # Email service image
+├── Dockerfile.whatsapp # WhatsApp service image
+├── docker-compose.yml # Main deployment
+├── Makefile # Build automation
+└── requirements.txt # Python dependencies
```
----
-
-## Screenshots
-
-All screenshots are live captures from the local dev instance.
+## 🔒 Security
-_(Screenshots will be added after build/run capture.)_
+- Rate limiting on all API endpoints
+- Content filtering for malicious payloads
+- PII detection and redaction
+- Environment-based configuration (`.env` never committed)
----
+## 📄 License
-## Contributing
-
-1. Create a feature branch off `main`.
-2. Follow the existing code style.
-3. Submit a PR with description and screenshots for UI changes.
+MIT © Jhonattan L. Jimenez
---
-## License
-
-MIT
-
----
-
-## Author
-
-Built by **Jhonattan L. Jimenez**.
+
+
🤖 AI-powered helpdesk, fully self-hosted
+
@OneByJorah
+
\ No newline at end of file
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..be4cad0
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,34 @@
+# Security Policy
+
+## Supported Versions
+
+We release patches for security vulnerabilities. Which versions are eligible
+for receiving patches depends on the CVSS v3.0 rating:
+
+| Version | Supported |
+| ------- | ------------------ |
+| Latest | ✅ |
+| < Latest| ❌ |
+
+## Reporting a Vulnerability
+
+Please report security vulnerabilities to **j1admin@onebyjorah.com**. Do NOT
+report security vulnerabilities through public GitHub issues.
+
+You should receive a response within 48 hours. If for some reason you do not,
+please follow up via email to ensure we received your original message.
+
+Please include the following information:
+
+- Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.)
+- Full paths of source file(s) related to the manifestation of the issue
+- The location of the affected source code (tag/branch/commit or direct URL)
+- Any special configuration required to reproduce the issue
+- Step-by-step instructions to reproduce the issue
+- Proof-of-concept or exploit code (if possible)
+- Impact of the issue, including how an attacker might exploit it
+
+We prefer to receive reports via email. We will acknowledge receipt within
+48 hours and send a more detailed response within 72 hours.
+
+This project follows a 90-day disclosure timeline.
diff --git a/compose/requirements.txt b/compose/requirements.txt
index c27df4b..94cf104 100644
--- a/compose/requirements.txt
+++ b/compose/requirements.txt
@@ -1,4 +1,4 @@
-requests==2.32.3
+requests==2.33.0
fastapi==0.111.0
uvicorn==0.30.0
pydantic==2.7.0
diff --git a/j1.yaml b/j1.yaml
new file mode 100644
index 0000000..b639405
--- /dev/null
+++ b/j1.yaml
@@ -0,0 +1,16 @@
+repo: CommandDesk
+class: AI / Helpdesk
+org: OneByJorah
+owner: Jhonattan L. Jimenez
+license: MIT
+production_score: 73
+last_audit: "2026-07-05"
+last_publish: null
+standards_version: "2.1"
+dependencies:
+ - JorahOne
+deploy_target: scratch
+tailscale_only: false
+public_facing: false
+community_sla_hours: 48
+adoption_tracked: false
diff --git a/memory_setup.py b/memory_setup.py
index 8e19ce1..143c075 100644
--- a/memory_setup.py
+++ b/memory_setup.py
@@ -3,10 +3,10 @@
Assumes llama-server is running on 127.0.0.1:8080 with --embedding enabled.
"""
+import os
import sqlite3
+
import requests
-import os
-import time
LLAMA_HOST = os.environ.get("LLAMA_HOST", "http://127.0.0.1:8080")
EMBED_MODEL = os.environ.get("LLAMA_EMBED_MODEL", "qwen2.5-7b-instruct-q4_k_m")
diff --git a/osticket_tool.py b/osticket_tool.py
index cd543fe..5c37b3e 100644
--- a/osticket_tool.py
+++ b/osticket_tool.py
@@ -6,8 +6,8 @@
import os
import re
+
import requests
-from typing import Optional
OSTICKET_BASE_URL = os.environ.get("OSTICKET_BASE_URL", "").rstrip("/")
OSTICKET_API_KEY = os.environ.get("OSTICKET_API_KEY", "")
@@ -48,11 +48,11 @@ def create_ticket(
subject: str,
body: str,
*,
- name: Optional[str] = None,
- email: Optional[str] = None,
- dept_id: Optional[int] = None,
- priority: Optional[str] = None,
- source: Optional[str] = None,
+ name: str | None = None,
+ email: str | None = None,
+ dept_id: int | None = None,
+ priority: str | None = None,
+ source: str | None = None,
) -> dict:
"""
Create a ticket in osTicket.
@@ -93,7 +93,7 @@ def create_ticket(
}
-def update_ticket(ticket_id: str, status: Optional[str] = None, note: Optional[str] = None) -> dict:
+def update_ticket(ticket_id: str, status: str | None = None, note: str | None = None) -> dict:
_require_config()
payload = {}
if status:
@@ -131,7 +131,7 @@ def search_tickets(user_id: str, query: str, limit: int = 10) -> list[dict]:
return out
-def close_ticket(ticket_id: str, reason: Optional[str] = None) -> dict:
+def close_ticket(ticket_id: str, reason: str | None = None) -> dict:
_require_config()
payload = {"status": "closed"}
if reason:
diff --git a/requirements.txt b/requirements.txt
index 1568f93..b07d850 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,7 +7,7 @@ redis==5.2.1
asyncpg==0.30.0
psycopg2-binary==2.9.10
httpx==0.28.1
-python-multipart==0.0.20
+python-multipart==0.0.31
# LLM / OpenAI-compatible client
openai==1.59.0
@@ -19,5 +19,5 @@ chromadb==0.5.23
imaplib2==3.6
# Utilities
-python-dotenv==1.0.1
+python-dotenv==1.2.2
uuid6==2024.7.10
diff --git a/scripts/agent_server.py b/scripts/agent_server.py
index 9178412..f6478ee 100644
--- a/scripts/agent_server.py
+++ b/scripts/agent_server.py
@@ -4,20 +4,16 @@
"""
from __future__ import annotations
-import asyncio
-import json
import logging
import os
import time
-from typing import Optional
import httpx
import redis.asyncio as redis
-from fastapi import FastAPI, HTTPException, Request, Depends
+from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
-
-from rate_limiter import RateLimiter, RateLimitConfig
+from rate_limiter import RateLimitConfig, RateLimiter
from session_manager import SessionManager
# ═══════════════════════════════════════════════════
@@ -69,9 +65,9 @@
)
# Global clients
-redis_client: Optional[redis.Redis] = None
-rate_limiter: Optional[RateLimiter] = None
-session_manager: Optional[SessionManager] = None
+redis_client: redis.Redis | None = None
+rate_limiter: RateLimiter | None = None
+session_manager: SessionManager | None = None
@app.on_event("startup")
@@ -94,7 +90,7 @@ async def shutdown():
# ═══════════════════════════════════════════════════
class ChatRequest(BaseModel):
- session_id: Optional[str] = None
+ session_id: str | None = None
user_id: str
message: str = Field(..., max_length=4000)
platform: str = "web"
diff --git a/scripts/analytics.py b/scripts/analytics.py
index 3c569da..406000d 100644
--- a/scripts/analytics.py
+++ b/scripts/analytics.py
@@ -10,12 +10,11 @@
import json
import os
import sys
-from datetime import datetime, timedelta
-from typing import Optional
+from datetime import datetime
import asyncpg
-POSTGRES_URL = os.getenv("POSTGRES_URL", "postgresql://helpdesk:***@postgres:5432/helpdesk")
+POSTGRES_URL = os.getenv("POSTGRES_URL", "") # Must be set via environment variable
async def get_token_usage(pool, hours: int = 24) -> dict:
diff --git a/scripts/email_fetcher.py b/scripts/email_fetcher.py
index 81d24e2..90a82b0 100644
--- a/scripts/email_fetcher.py
+++ b/scripts/email_fetcher.py
@@ -7,13 +7,11 @@
import email
import imaplib
-import json
import logging
import os
import time
import uuid
from email.header import decode_header
-from typing import Optional
import httpx
@@ -68,7 +66,7 @@ def extract_email_body(msg) -> str:
return body[:10000] # Limit to 10KB
-def process_email(mail: imaplib.IMAP4_SSL, num: str) -> Optional[dict]:
+def process_email(mail: imaplib.IMAP4_SSL, num: str) -> dict | None:
"""Process a single email and create ticket."""
try:
status, data = mail.fetch(num, "(RFC822)")
@@ -88,20 +86,20 @@ def process_email(mail: imaplib.IMAP4_SSL, num: str) -> Optional[dict]:
logger.info(f"Processing email: {subject[:50]} from {email_addr}")
- # Create ticket via helpdesk agent API
+ # Create ticket via helpdesk agent /chat endpoint
ticket_data = {
- "subject": subject,
- "body": body,
- "user_email": email_addr,
+ "user_id": email_addr,
+ "message": f"New ticket from {email_addr}: {subject}\
+\
+{body}",
"platform": TICKET_PLATFORM,
- "message_id": message_id,
}
with httpx.Client(timeout=30) as client:
- resp = client.post(f"{HELPDESK_AGENT_URL}/tickets/create", json=ticket_data)
+ resp = client.post(f"{HELPDESK_AGENT_URL}/chat", json=ticket_data)
if resp.status_code == 200:
result = resp.json()
- logger.info(f"Ticket created: {result.get('ticket_id')}")
+ logger.info(f"Email processed: {subject[:50]} -> session {result.get('session_id', 'N/A')}")
return result
else:
logger.warning(f"Ticket creation failed: {resp.status_code} {resp.text[:200]}")
diff --git a/scripts/health_monitor.py b/scripts/health_monitor.py
index aa55266..8fbd540 100644
--- a/scripts/health_monitor.py
+++ b/scripts/health_monitor.py
@@ -10,7 +10,6 @@
import logging
import os
import time
-from typing import Optional
import httpx
import redis.asyncio as redis
@@ -18,7 +17,7 @@
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("health-monitor")
-REDIS_URL = os.getenv("REDIS_URL", "redis://redis:***@postgres:5432/helpdesk")
+REDIS_URL = os.getenv("REDIS_URL", "redis://:redis_pass@redis:6379/0")
SERVICES = {
"llama": "http://llama:8081/health",
@@ -31,7 +30,7 @@
}
-async def check_service(name: str, url: Optional[str]) -> dict:
+async def check_service(name: str, url: str | None) -> dict:
"""Check a single service health."""
if not url:
return {"name": name, "status": "unknown", "latency_ms": 0}
@@ -75,16 +74,20 @@ async def collect_metrics(redis_client: redis.Redis) -> dict:
redis_memory = "unknown"
redis_keys = 0
- # Active sessions
+ # Active sessions (using SCAN to avoid O(N) KEYS)
try:
- session_keys = await redis_client.keys("session:*")
active_sessions = 0
- for key in session_keys:
- data = await redis_client.get(key)
- if data:
- session = json.loads(data)
- if session.get("active"):
- active_sessions += 1
+ cursor = 0
+ while True:
+ cursor, session_keys = await redis_client.scan(cursor, match="session:*", count=1000)
+ for key in session_keys:
+ data = await redis_client.get(key)
+ if data:
+ session = json.loads(data)
+ if session.get("active"):
+ active_sessions += 1
+ if cursor == 0:
+ break
except Exception:
active_sessions = 0
diff --git a/scripts/index_kb.py b/scripts/index_kb.py
index 2654881..81ac0f9 100644
--- a/scripts/index_kb.py
+++ b/scripts/index_kb.py
@@ -6,16 +6,18 @@
from __future__ import annotations
import hashlib
-import json
+import logging
import os
import sys
from pathlib import Path
+logger = logging.getLogger("index-kb")
+
try:
import chromadb
from chromadb.utils import embedding_functions
except ImportError:
- print("ERROR: chromadb not installed. Run: pip install chromadb")
+ logger.error("chromadb not installed. Run: pip install chromadb")
sys.exit(1)
CHROMA_URL = os.getenv("CHROMA_URL", "http://chroma:8000")
@@ -39,11 +41,12 @@ def compute_hash(content: str) -> str:
def main():
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
client = chromadb.HttpClient(url=CHROMA_URL)
# Get or create collection
embedding_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
- model_name="all-MiniLM-L6-v2"
+ model_name="all-MiniLM-L6-v2",
)
try:
@@ -60,11 +63,11 @@ def main():
kb_path = Path(KB_DIR)
if not kb_path.exists():
- print(f"ERROR: Knowledge base directory not found: {KB_DIR}")
+ logger.error("Knowledge base directory not found: %s", KB_DIR)
sys.exit(1)
files = list(kb_path.glob("*.md")) + list(kb_path.glob("*.txt"))
- print(f"Found {len(files)} knowledge base files")
+ logger.info("Found %d knowledge base files", len(files))
total_chunks = 0
for file_path in files:
@@ -81,7 +84,7 @@ def main():
# Check if content changed
for meta in existing.get("metadatas", []):
if meta and meta.get("hash") == content_hash:
- print(f" [skip] {file_path.name} (unchanged)")
+ logger.info(" [skip] %s (unchanged)", file_path.name)
break
else:
# Content changed, re-index
@@ -111,11 +114,11 @@ def main():
metadatas=metadatas,
)
total_chunks += len(chunks)
- print(f" [indexed] {file_path.name}: {len(chunks)} chunks")
+ logger.info(" [indexed] %s: %d chunks", file_path.name, len(chunks))
# Get final count
count = collection.count()
- print(f"\nDone! Indexed {total_chunks} new chunks. Total in collection: {count}")
+ logger.info("\nDone! Indexed %d new chunks. Total in collection: %d", total_chunks, count)
if __name__ == "__main__":
diff --git a/scripts/rate_limiter.py b/scripts/rate_limiter.py
index 8948d68..b2666ac 100644
--- a/scripts/rate_limiter.py
+++ b/scripts/rate_limiter.py
@@ -4,9 +4,8 @@
"""
from __future__ import annotations
-import time
import logging
-from typing import Optional
+import time
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
@@ -37,7 +36,7 @@ class RateLimiter:
def __init__(self, config: RateLimitConfig, redis_client=None):
self.config = config
self.redis = redis_client
- self._sessions: dict[str, SessionState] =()
+ self._sessions: dict[str, SessionState] = {}
def check_request(self, session_id: str, user_id: str, message_length: int = 0) -> dict:
"""
@@ -101,7 +100,7 @@ def check_request(self, session_id: str, user_id: str, message_length: int = 0)
"session_remaining": int(self.config.max_session_duration - session_age),
}
- def get_session_info(self, session_id: str) -> Optional[dict]:
+ def get_session_info(self, session_id: str) -> dict | None:
"""Get current session info."""
state = self._sessions.get(session_id)
if not state:
diff --git a/scripts/session_manager.py b/scripts/session_manager.py
index c3fbb5a..8caef11 100644
--- a/scripts/session_manager.py
+++ b/scripts/session_manager.py
@@ -7,8 +7,7 @@
import json
import logging
import time
-from typing import Optional
-from dataclasses import dataclass, asdict
+import uuid
logger = logging.getLogger(__name__)
@@ -23,7 +22,6 @@ def __init__(self, redis_client, postgres_pool=None, max_duration: int = 7200):
async def create_session(self, user_id: str, platform: str = "web", ip: str = None) -> dict:
"""Create a new session."""
- import uuid
session_id = str(uuid.uuid4())
now = time.time()
expires_at = now + self.max_duration
@@ -48,13 +46,13 @@ async def create_session(self, user_id: str, platform: str = "web", ip: str = No
async with self.pg_pool.acquire() as conn:
await conn.execute(
"""INSERT INTO sessions (id, user_id, platform, message_count, started_at, expires_at, active, ip_address)
- VALUES ($1, $2, $3, $4, NOW(), NOW() + INTERVAL '{} seconds', TRUE, $5::inet)""".format(self.max_duration),
- session_id, user_id, platform, 0, ip
+ VALUES ($1, $2, $3, $4, NOW(), NOW() + MAKE_INTERVAL(secs => $5), TRUE, $6::inet)""",
+ session_id, user_id, platform, 0, self.max_duration, ip,
)
return session_data
- async def get_session(self, session_id: str) -> Optional[dict]:
+ async def get_session(self, session_id: str) -> dict | None:
"""Get session data."""
key = f"session:{session_id}"
data = await self.redis.get(key)
@@ -86,28 +84,36 @@ async def end_session(self, session_id: str):
async with self.pg_pool.acquire() as conn:
await conn.execute(
"UPDATE sessions SET active = FALSE WHERE id = $1",
- session_id
+ session_id,
)
async def get_active_count(self) -> int:
- """Get count of active sessions."""
- keys = await self.redis.keys("session:*")
+ """Get count of active sessions using SCAN (avoid O(N) KEYS)."""
count = 0
- for key in keys:
- data = await self.redis.get(key)
- if data:
- session = json.loads(data)
- if session.get("active"):
- count += 1
+ cursor = 0
+ while True:
+ cursor, keys = await self.redis.scan(cursor, match="session:*", count=1000)
+ for key in keys:
+ data = await self.redis.get(key)
+ if data:
+ session = json.loads(data)
+ if session.get("active"):
+ count += 1
+ if cursor == 0:
+ break
return count
async def cleanup_expired(self):
- """Remove expired sessions."""
- keys = await self.redis.keys("session:*")
+ """Remove expired sessions using SCAN (avoid O(N) KEYS)."""
removed = 0
- for key in keys:
- ttl = await self.redis.ttl(key)
- if ttl < 0:
- await self.redis.delete(key)
- removed += 1
+ cursor = 0
+ while True:
+ cursor, keys = await self.redis.scan(cursor, match="session:*", count=1000)
+ for key in keys:
+ ttl = await self.redis.ttl(key)
+ if ttl < 0:
+ await self.redis.delete(key)
+ removed += 1
+ if cursor == 0:
+ break
return removed
diff --git a/scripts/whatsapp_webhook.py b/scripts/whatsapp_webhook.py
index e346647..8b8364e 100644
--- a/scripts/whatsapp_webhook.py
+++ b/scripts/whatsapp_webhook.py
@@ -11,12 +11,12 @@
import json
import logging
import os
+import re
import time
-from typing import Optional
import httpx
import redis.asyncio as redis
-from fastapi import FastAPI, Request, HTTPException, Header
+from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel, Field
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
@@ -27,7 +27,7 @@
# ═══════════════════════════════════════════════════
HELPDESK_AGENT_URL = os.getenv("HELPDESK_AGENT_URL", "http://helpdesk-agent:8080")
-REDIS_URL = os.getenv("REDIS_URL", "redis://redis:***@postgres:5432/helpdesk")
+REDIS_URL = os.getenv("REDIS_URL", "redis://:redis_pass@redis:6379/0")
WHATSAPP_WEBHOOK_SECRET = os.getenv("WHATSAPP_WEBHOOK_SECRET", "change_me")
WHATSAPP_TOKEN = os.getenv("WHATSAPP_TOKEN", "")
WHATSAPP_PHONE_NUMBER_ID = os.getenv("WHATSAPP_PHONE_NUMBER_ID", "")
@@ -42,29 +42,29 @@
"check_tickets": [
"my ticket", "my tickets", "ticket status", "check ticket", "track ticket",
"where is my ticket", "ticket #", "status of", "view tickets", "see tickets",
- "list tickets", "my issue", "my problem", "ticket progress"
+ "list tickets", "my issue", "my problem", "ticket progress",
],
"talk_to_human": [
"human", "agent", "person", "real person", "talk to someone",
"speak to", "representative", "operator", "escalate", "manager",
"don't want bot", "not helpful", "live chat", "live agent",
"wait for someone", "can I talk", "I want help", "help please",
- "I need help", "urgent", "asap", "emergency"
+ "I need help", "urgent", "asap", "emergency",
],
"create_ticket": [
"create ticket", "new ticket", "open ticket", "report issue",
"file complaint", "submit issue", "new problem", "new issue",
"start ticket", "help with", "problem with", "issue with",
- "something broken", "not working", "broken", "bug", "error"
+ "something broken", "not working", "broken", "bug", "error",
],
"greeting": [
"hi", "hello", "hey", "good morning", "good afternoon",
- "good evening", "howdy", "yo", "sup"
+ "good evening", "howdy", "yo", "sup",
],
"thanks": [
"thanks", "thank you", "thx", "appreciate", "great", "awesome",
- "perfect", "ok", "okay", "cool"
- ]
+ "perfect", "ok", "okay", "cool",
+ ],
}
# ═══════════════════════════════════════════════════
@@ -152,7 +152,7 @@
"2️⃣ Create a new ticket\n"
"3️⃣ Talk to a human\n\n"
"Or just describe your issue and I'll do my best to help!"
- )
+ ),
}
# ═══════════════════════════════════════════════════
@@ -160,7 +160,7 @@
# ═══════════════════════════════════════════════════
app = FastAPI(title="WhatsApp Helpdesk Webhook", version="1.0.0")
-redis_client: Optional[redis.Redis] = None
+redis_client: redis.Redis | None = None
@app.on_event("startup")
@@ -202,8 +202,7 @@ def detect_intent(message: str, session_data: dict) -> str:
# Check if we're waiting for a specific response
if session_data.get("awaiting") == "email":
# Try to extract email from message
- import re
- email_match = re.search(r'[\w.+-]+@[\w-]+\.[\w.-]+', text)
+ email_match = re.search(r"[\w.+-]+@[\w-]+\.[\w.-]+", text)
if email_match:
return "email_provided"
return "ask_email"
@@ -329,9 +328,9 @@ async def send_whatsapp_buttons(phone_number: str, body: str, buttons: list):
"buttons": [
{"type": "reply", "reply": {"id": btn[0], "title": btn[1]}}
for btn in buttons
- ]
- }
- }
+ ],
+ },
+ },
}
async with httpx.AsyncClient(timeout=10) as client:
@@ -361,7 +360,7 @@ async def add_to_human_queue(phone_number: str, session: dict):
f"🔔 *Human Support Request*\n\n"
f"📱 Customer: {phone_number}\n"
f"⏰ Waiting since: Just now\n\n"
- f"Reply to take over this conversation."
+ f"Reply to take over this conversation.",
)
logger.info(f"Added {phone_number} to human queue, notified admin")
@@ -403,7 +402,7 @@ async def receive_message(request: Request):
if signature and WHATSAPP_WEBHOOK_SECRET != "change_me":
body = await request.body()
expected = "sha256=" + hmac.new(
- WHATSAPP_WEBHOOK_SECRET.encode(), body, hashlib.sha256
+ WHATSAPP_WEBHOOK_SECRET.encode(), body, hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
raise HTTPException(status_code=403, detail="Invalid signature")
@@ -478,8 +477,7 @@ async def route_intent(intent: str, phone: str, text: str, session: dict) -> str
return RESPONSES["human_queue"]
if intent == "email_provided":
- import re
- email_match = re.search(r'[\w.+-]+@[\w-]+\.[\w.-]+', text)
+ email_match = re.search(r"[\w.+-]+@[\w-]+\.[\w.-]+", text)
if email_match:
email = email_match.group()
session["user_id"] = email
@@ -498,7 +496,7 @@ async def route_intent(intent: str, phone: str, text: str, session: dict) -> str
result = await forward_to_agent(
user_id=session.get("user_id", phone),
message=text,
- platform="whatsapp"
+ platform="whatsapp",
)
if result.get("ticket_id"):
return RESPONSES["ticket_created"].format(**result)
@@ -518,7 +516,7 @@ async def route_intent(intent: str, phone: str, text: str, session: dict) -> str
result = await forward_to_agent(
user_id=session.get("user_id", phone),
message=text,
- platform="whatsapp"
+ platform="whatsapp",
)
return result.get("response", RESPONSES["fallback"])
@@ -532,8 +530,8 @@ async def search_tickets(email: str) -> list:
json={
"user_id": email,
"message": f"Search my tickets for: {email}",
- "platform": "whatsapp"
- }
+ "platform": "whatsapp",
+ },
)
if resp.status_code == 200:
data = resp.json()
@@ -553,8 +551,8 @@ async def forward_to_agent(user_id: str, message: str, platform: str) -> dict:
json={
"user_id": user_id,
"message": message,
- "platform": platform
- }
+ "platform": platform,
+ },
)
if resp.status_code == 200:
return resp.json()
@@ -635,6 +633,6 @@ async def view_queue():
queue.append({
"phone": data["phone"],
"waiting_minutes": wait_minutes,
- "message": data.get("message", "")[:100]
+ "message": data.get("message", "")[:100],
})
return {"queue": queue, "total": len(queue)}
diff --git a/ticket_platforms/__init__.py b/ticket_platforms/__init__.py
index a8dc607..3d6d3b1 100644
--- a/ticket_platforms/__init__.py
+++ b/ticket_platforms/__init__.py
@@ -5,9 +5,7 @@
from ticket_platforms.registry import register, get, available
"""
-from . import osticket
-from . import zammad
-from . import email
-from .registry import register, get, available
+from . import email, osticket, zammad
+from .registry import available, get, register
__all__ = ["register", "get", "available", "osticket", "zammad", "email"]
diff --git a/ticket_platforms/base.py b/ticket_platforms/base.py
index 1b3df2c..1ad7ac2 100644
--- a/ticket_platforms/base.py
+++ b/ticket_platforms/base.py
@@ -6,7 +6,7 @@
from __future__ import annotations
from abc import ABC, abstractmethod
-from typing import Any, Dict, List, Optional
+from typing import Any
class Ticket(ABC):
@@ -17,23 +17,23 @@ def create_ticket(
subject: str,
body: str,
*,
- name: Optional[str] = None,
- email: Optional[str] = None,
- dept_id: Optional[int] = None,
- priority: Optional[str] = None,
- source: Optional[str] = None,
+ name: str | None = None,
+ email: str | None = None,
+ dept_id: int | None = None,
+ priority: str | None = None,
+ source: str | None = None,
**kwargs: Any,
- ) -> Dict[str, Any]:
+ ) -> dict[str, Any]:
raise NotImplementedError
@abstractmethod
def update_ticket(
self,
ticket_id: str,
- status: Optional[str] = None,
- note: Optional[str] = None,
+ status: str | None = None,
+ note: str | None = None,
**kwargs: Any,
- ) -> Dict[str, Any]:
+ ) -> dict[str, Any]:
raise NotImplementedError
@abstractmethod
@@ -43,14 +43,14 @@ def search_tickets(
query: str,
limit: int = 10,
**kwargs: Any,
- ) -> List[Dict[str, Any]]:
+ ) -> list[dict[str, Any]]:
raise NotImplementedError
@abstractmethod
def close_ticket(
self,
ticket_id: str,
- reason: Optional[str] = None,
+ reason: str | None = None,
**kwargs: Any,
- ) -> Dict[str, Any]:
+ ) -> dict[str, Any]:
raise NotImplementedError
diff --git a/ticket_platforms/email.py b/ticket_platforms/email.py
index 28fb03d..93523bc 100644
--- a/ticket_platforms/email.py
+++ b/ticket_platforms/email.py
@@ -4,14 +4,7 @@
from __future__ import annotations
-import email as imap_email
-import email.header
-import imaplib
-import re
-from typing import Any, Dict, List, Optional
-
from .base import Ticket
-from .registry import get as get_platform
class EmailTicketAdapter:
@@ -28,4 +21,4 @@ def __init__(self, *, imap_host: str, imap_port: int, username: str, password: s
self.mailbox = mailbox
self.mark_seen = mark_seen
self.ticket_platform_name = ticket_platform
- self._ticket_platform: Optional[Ticket] = None
+ self._ticket_platform: Ticket | None = None
diff --git a/ticket_platforms/freshdesk.py b/ticket_platforms/freshdesk.py
index fd0bf33..b1ff8f9 100644
--- a/ticket_platforms/freshdesk.py
+++ b/ticket_platforms/freshdesk.py
@@ -6,11 +6,10 @@
import base64
import json
-from typing import Any, Dict, List, Optional
-
-import urllib.request
import urllib.error
import urllib.parse
+import urllib.request
+from typing import Any
from .base import Ticket
from .registry import register
@@ -24,7 +23,7 @@ class FreshdeskAdapter(Ticket):
API key is base64 encoded as 'API_KEY:X' for Basic Auth.
"""
- def __init__(self, *, base_url: str, api_key: str, domain: Optional[str] = None):
+ def __init__(self, *, base_url: str, api_key: str, domain: str | None = None):
"""
Args:
base_url: Freshdesk URL, e.g. https://yourcompany.freshdesk.com
@@ -43,7 +42,7 @@ def _get_auth_header(self) -> str:
token = base64.b64encode(f"{self.api_key}:X".encode()).decode()
return f"Basic {token}"
- def _request(self, path: str, *, method: str = "GET", data: Optional[Dict] = None) -> Dict[str, Any]:
+ def _request(self, path: str, *, method: str = "GET", data: dict | None = None) -> dict[str, Any]:
"""Make an authenticated request to Freshdesk API."""
url = f"{self.api_url}{path}"
headers = {
@@ -65,15 +64,15 @@ def create_ticket(
subject: str,
body: str,
*,
- name: Optional[str] = None,
- email: Optional[str] = None,
- dept_id: Optional[int] = None,
- priority: Optional[str] = None,
- source: Optional[str] = None,
+ name: str | None = None,
+ email: str | None = None,
+ dept_id: int | None = None,
+ priority: str | None = None,
+ source: str | None = None,
**kwargs: Any,
- ) -> Dict[str, Any]:
+ ) -> dict[str, Any]:
"""Create a new ticket in Freshdesk."""
- data: Dict[str, Any] = {
+ data: dict[str, Any] = {
"subject": subject,
"description": body,
"email": email or user_id,
@@ -98,12 +97,12 @@ def create_ticket(
def update_ticket(
self,
ticket_id: str,
- status: Optional[str] = None,
- note: Optional[str] = None,
+ status: str | None = None,
+ note: str | None = None,
**kwargs: Any,
- ) -> Dict[str, Any]:
+ ) -> dict[str, Any]:
"""Update ticket: add note, change status."""
- data: Dict[str, Any] = {}
+ data: dict[str, Any] = {}
# Map status: open=2, pending=3, resolved=4, closed=5
status_map = {"open": 2, "pending": 3, "resolved": 4, "closed": 5}
if status:
@@ -126,7 +125,7 @@ def search_tickets(
query: str,
limit: int = 10,
**kwargs: Any,
- ) -> List[Dict[str, Any]]:
+ ) -> list[dict[str, Any]]:
"""Search tickets by user email."""
# Freshdesk search API
search_query = f"email:'{user_id}'"
@@ -152,11 +151,11 @@ def search_tickets(
def close_ticket(
self,
ticket_id: str,
- reason: Optional[str] = None,
+ reason: str | None = None,
**kwargs: Any,
- ) -> Dict[str, Any]:
+ ) -> dict[str, Any]:
"""Close a ticket (status=5)."""
- data: Dict[str, Any] = {"status": 5}
+ data: dict[str, Any] = {"status": 5}
if reason:
self._request(f"/tickets/{ticket_id}/notes", method="POST", data={
"body": f"Closed: {reason}",
diff --git a/ticket_platforms/osticket.py b/ticket_platforms/osticket.py
index 90184aa..c2aee62 100644
--- a/ticket_platforms/osticket.py
+++ b/ticket_platforms/osticket.py
@@ -4,10 +4,10 @@
from __future__ import annotations
-import os
import re
+from typing import Any
+
import requests
-from typing import Any, Dict, List, Optional
from .base import Ticket
from .registry import register
@@ -61,7 +61,7 @@ def create_ticket(self, user_id, subject, body, *, name=None, email=None, dept_i
return {"ticket_id": str(ticket.get("ticket_id") or data.get("id", "")), "number": ticket.get("number"), "status": ticket.get("status"), "subject": subject, "platform": "osticket"}
def update_ticket(self, ticket_id, status=None, note=None, **kwargs):
- payload: Dict[str, Any] = {}
+ payload: dict[str, Any] = {}
if status:
payload["status"] = status
if note:
@@ -80,7 +80,7 @@ def search_tickets(self, user_id, query, limit=10, **kwargs):
return [{"ticket_id": str(t.get("ticket_id") or t.get("id")), "number": t.get("number"), "subject": t.get("subject"), "status": t.get("status")} for t in tickets]
def close_ticket(self, ticket_id, reason=None, **kwargs):
- payload: Dict[str, Any] = {"status": "closed"}
+ payload: dict[str, Any] = {"status": "closed"}
if reason:
payload["post"] = self._sanitize(reason)
payload["post_status"] = "closed"
diff --git a/ticket_platforms/registry.py b/ticket_platforms/registry.py
index 1dd2bbc..ecb1586 100644
--- a/ticket_platforms/registry.py
+++ b/ticket_platforms/registry.py
@@ -5,23 +5,20 @@
from __future__ import annotations
-from typing import Dict, Type
-
from .base import Ticket
-
-_REGISTRY: Dict[str, Type["Ticket"]] = {}
+_REGISTRY: dict[str, type["Ticket"]] = {}
def register(name: str):
- def decorator(cls: Type[Ticket]):
+ def decorator(cls: type[Ticket]):
_REGISTRY[name.lower()] = cls
return cls
return decorator
-def get(name: str) -> Type[Ticket]:
+def get(name: str) -> type[Ticket]:
key = name.lower()
if key not in _REGISTRY:
raise KeyError(f"Unknown ticket platform: {name}. Registered: {sorted(_REGISTRY)}")
diff --git a/ticket_platforms/zammad.py b/ticket_platforms/zammad.py
index b49433c..1d1ee8e 100644
--- a/ticket_platforms/zammad.py
+++ b/ticket_platforms/zammad.py
@@ -4,8 +4,9 @@
from __future__ import annotations
-import os
-from typing import Any, Dict, List, Optional
+from typing import Any
+
+import requests
from .base import Ticket
from .registry import register
@@ -13,7 +14,7 @@
@register("zammad")
class ZammadAdapter(Ticket):
- def __init__(self, *, base_url: str, api_token: str, group_id: Optional[int] = None, priority_id: int = 2, state_id: int = 1):
+ def __init__(self, *, base_url: str, api_token: str, group_id: int | None = None, priority_id: int = 2, state_id: int = 1):
self.base_url = base_url.rstrip("/")
self.api_token = api_token
self.group_id = group_id
@@ -44,7 +45,7 @@ def create_ticket(self, user_id, subject, body, *, name=None, email=None, priori
return {"ticket_id": str(data.get("id")), "number": data.get("number"), "status": data.get("state", {}).get("name"), "subject": subject, "platform": "zammad"}
def update_ticket(self, ticket_id, status=None, note=None, **kwargs):
- payload: Dict[str, Any] = {}
+ payload: dict[str, Any] = {}
if status:
payload["state"] = status
if note:
@@ -67,7 +68,7 @@ def search_tickets(self, user_id, query, limit=10, **kwargs):
return [{"ticket_id": str(t.get("id")), "number": t.get("number"), "subject": t.get("title"), "status": (t.get("state") or {}).get("name") if isinstance(t.get("state"), dict) else t.get("state")} for t in tickets]
def close_ticket(self, ticket_id, reason=None, **kwargs):
- payload: Dict[str, Any] = {"state": "closed"}
+ payload: dict[str, Any] = {"state": "closed"}
if reason:
payload["article"] = {"type": "text", "body": reason, "internal": True}
r = requests.patch(self._url(f"tickets/{ticket_id}"), json=payload, headers=self._headers(), timeout=30)