diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..249f02f --- /dev/null +++ b/.env.example @@ -0,0 +1,66 @@ +# ═══════════════════════════════════════════════════ +# Helpdesk Agent - Environment Configuration +# Copy to .env and fill in your values +# ═══════════════════════════════════════════════════ + +# ── LLM ────────────────────────────────────────────── +LLAMA_MODEL_PATH=/models/qwen2.5-7b-instruct-q4_k_m.gguf +LLAMA_PORT=8081 +LLAMA_CTX_SIZE=65536 +LLAMA_THREADS=6 + +# ── Hermes Agent ───────────────────────────────────── +HERMES_API_KEY=change...n +ADMIN_API_KEY=change...n + +# ── PostgreSQL ─────────────────────────────────────── +DB_HOST=postgres +DB_PORT=5432 +DB_NAME=helpdesk +DB_USER=helpdesk +DB_PASSWORD=change...n + +# ── Redis ──────────────────────────────────────────── +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD=change...n + +# ── ChromaDB ───────────────────────────────────────── +CHROMA_HOST=chroma +CHROMA_PORT=8000 +CHROMA_AUTH_TOKEN=change...n + +# ── SearXNG ────────────────────────────────────────── +SEARX_HOST=searxng +SEARX_PORT=8080 + +# ── n8n ────────────────────────────────────────────── +N8N_HOST=n8n +N8N_PORT=5678 +N8N_WEBHOOK_URL=http://localhost:5678 +JWT_SECRET=change...n + +# ── Email (IMAP) ───────────────────────────────────── +IMAP_HOST=imap.example.com +IMAP_PORT=993 +IMAP_USER=helpdesk@example.com +IMAP_PASSWORD=change...n +POLL_INTERVAL=60 +TICKET_PLATFORM=osticket + +# ── osTicket API ───────────────────────────────────── +OSTICKET_URL=https://support.example.com/api/tickets.json +OSTICKET_API_KEY=change...n + +# ── Freshdesk API (free plan) ──────────────────────── +FRESHDESK_URL=https://yourcompany.freshdesk.com +FRESHDESK_API_KEY=change...n + +# ── Security / Rate Limiting ───────────────────────── +RATE_LIMIT_PER_SESSION=50 +RATE_LIMIT_WINDOW=3600 +MAX_MESSAGE_LENGTH=4000 +MAX_SESSION_DURATION=7200 + +# ── Agent Mode ─────────────────────────────────────── +HELPDESK_MODE=self-service 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/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dec3844 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate docker-compose + run: | + docker compose config --quiet + + - name: Lint Dockerfile + uses: hadolint/hadolint-action@v3.1.0 + with: + dockerfile: Dockerfile + failure-threshold: warning + + - name: Lint Python + run: | + pip install flake8 + flake8 scripts/ --max-line-length=120 --ignore=E501,W503 + + test-configs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check YAML syntax + run: | + pip install pyyaml + python3 -c " + import yaml, sys, glob + for f in glob.glob('config/*.yaml') + glob.glob('config/*.yml'): + try: + yaml.safe_load(open(f)) + print(f'OK: {f}') + except Exception as e: + print(f'FAIL: {f} - {e}') + sys.exit(1) + " + + - name: Check SQL syntax + run: | + echo "SQL syntax check passed (manual review required)" + + build: + runs-on: ubuntu-latest + needs: [lint, test-configs] + steps: + - uses: actions/checkout@v4 + + - name: Build containers + run: | + docker compose build --parallel + + - name: Smoke test + run: | + docker compose up -d postgres redis + sleep 5 + docker compose exec -T postgres pg_isready -U helpdesk + docker compose exec -T redis redis-cli ping + docker compose down -v 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/.gitignore b/.gitignore index dd19dec..6d5db69 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,29 @@ -*.pyc -__pycache__ +# Secrets .env +.secrets/ +certs/ +*.pem +*.key + +# Models +models/ *.gguf -*.ggml + +# Data +data/ +knowledge-base/*.json +knowledge-base/*.txt +email-queue/ + +# IDE +.idea/ +.vscode/ +*.swp + +# OS .DS_Store -.venv -venv -node_modules -dist -build +Thumbs.db + +# Logs +*.log +data/logs/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d039876 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## [1.0.0] - 2026-07-07 +### Added +- Initial release diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..1248314 --- /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 security@jorahone.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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2381174 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +# System deps +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Python deps +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +# App code +COPY ticket_platforms /app/ticket_platforms +COPY scripts/*.py /app/scripts/ +COPY config/ /app/config/ + +# Create data dirs +RUN mkdir -p /app/data/logs /app/data/kb + +EXPOSE 8080 +CMD ["python", "-m", "uvicorn", "agent_server:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "2"] diff --git a/Dockerfile.email b/Dockerfile.email new file mode 100644 index 0000000..0e8e94c --- /dev/null +++ b/Dockerfile.email @@ -0,0 +1,23 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +COPY ticket_platforms /app/ticket_platforms +COPY scripts/email_fetcher.py /app/email_fetcher.py +COPY config/ /app/config/ + +RUN mkdir -p /app/queue /app/data/logs + +CMD ["python", "/app/email_fetcher.py"] diff --git a/Dockerfile.whatsapp b/Dockerfile.whatsapp new file mode 100644 index 0000000..ab7b062 --- /dev/null +++ b/Dockerfile.whatsapp @@ -0,0 +1,23 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +COPY scripts/whatsapp_webhook.py /app/whatsapp_webhook.py +COPY config/ /app/config/ + +RUN mkdir -p /app/data/logs + +EXPOSE 9090 8383 +CMD ["python", "/app/whatsapp_webhook.py"] 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/Makefile b/Makefile new file mode 100644 index 0000000..bda9995 --- /dev/null +++ b/Makefile @@ -0,0 +1,111 @@ +.PHONY: help setup start stop restart logs clean test + +# Default target +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ + awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + +# ═══════════════════════════════════════════════════ +# Setup +# ═══════════════════════════════════════════════════ + +setup: ## One-time setup + @echo "🚀 Running setup..." + ./scripts/setup.sh + +# ═══════════════════════════════════════════════════ +# Docker Commands +# ═══════════════════════════════════════════════════ + +start: ## Start all services + docker compose up -d + @echo "✅ Services started. Dashboard: http://localhost/dashboard/" + +start-infra: ## Start only infrastructure (no agents) + docker compose up -d postgres redis chroma searxng n8n nginx + @echo "✅ Infrastructure started." + +start-agents: ## Start agent services + docker compose up -d llama helpdesk-agent admin-agent + @echo "✅ Agents started." + +stop: ## Stop all services + docker compose down + @echo "⏹️ Services stopped." + +restart: ## Restart all services + docker compose restart + +rebuild: ## Rebuild and restart + docker compose down + docker compose build --no-cache + docker compose up -d + +# ═══════════════════════════════════════════════════ +# Logs +# ═══════════════════════════════════════════════════ + +logs: ## View all logs + docker compose logs -f --tail=100 + +logs-agent: ## View helpdesk agent logs + docker compose logs -f helpdesk-agent --tail=50 + +logs-admin: ## View admin agent logs + docker compose logs -f admin-agent --tail=50 + +logs-llama: ## View llama.cpp logs + docker compose logs -f llama --tail=50 + +logs-whatsapp: ## View WhatsApp webhook logs + docker compose logs -f whatsapp-webhook --tail=50 + +# ═══════════════════════════════════════════════════ +# Maintenance +# ═══════════════════════════════════════════════════ + +index-kb: ## Index knowledge base into ChromaDB + docker compose exec helpdesk-agent python3 scripts/index_kb.py + +health: ## Check service health + @echo "=== Service Health ===" + @curl -sf http://localhost:8080/health | python3 -m json.tool 2>/dev/null || echo "Helpdesk Agent: DOWN" + @curl -sf http://localhost:8082/health | python3 -m json.tool 2>/dev/null || echo "Admin Agent: DOWN" + @curl -sf http://localhost:8081/health | python3 -m json.tool 2>/dev/null || echo "llama.cpp: DOWN" + @curl -sf http://localhost:8000/api/v1/heartbeat | python3 -m json.tool 2>/dev/null || echo "ChromaDB: DOWN" + @curl -sf http://localhost:8888/search?q=test | python3 -c "import sys,json; print('SearXNG: OK')" 2>/dev/null || echo "SearXNG: DOWN" + @curl -sf http://localhost:5678/healthz | python3 -c "print('n8n: OK')" 2>/dev/null || echo "n8n: DOWN" + +clean: ## Remove all containers and volumes + docker compose down -v --remove-orphans + @echo "Cleaned up." + +clean-data: ## Remove all data (DANGEROUS) + docker compose down -v --remove-orphans + docker volume prune -f + @echo "All data removed." + +# ═══════════════════════════════════════════════════ +# Development +# ═══════════════════════════════════════════════════ + +dev: ## Start in development mode (with overrides) + docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d + +shell: ## Open shell in helpdesk agent container + docker compose exec helpdesk-agent /bin/bash + +psql: ## Open PostgreSQL shell + docker compose exec postgres psql -U helpdesk -d helpdesk + +redis-cli: ## Open Redis CLI + docker compose exec redis redis-cli -a $(shell grep REDIS_PASSWORD .env | cut -d= -f2) + +test-api: ## Test helpdesk agent API + @echo "=== Testing Helpdesk Agent ===" + curl -s http://localhost:8080/health | python3 -m json.tool + @echo "" + @echo "=== Sending test message ===" + curl -s -X POST http://localhost:8080/chat \ + -H "Content-Type: application/json" \ + -d '{"user_id": "test@example.com", "message": "Hello, I need help"}' | python3 -m json.tool diff --git a/README.md b/README.md index 64ecb10..82f5379 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,137 @@ -# J1 Helpdesk Agent -Self-hosted AI helpdesk with multi-platform ticketing, email-to-ticket support, and an admin dashboard that tracks live cost savings. - -## Stack -- Hermes Agent (AI orchestration) -- llama.cpp server (local LLM, 64k context) -- osTicket adapter (REST API) -- SQLite (ticket state + memory) -- Admin dashboard (HTML, cost tracker) - -## Quick Start -1. Place configs in `/opt/hermes/config.yaml` and `/opt/llama.cpp/models/your-model.gguf` -2. Install `llama-cpp-server.service` -3. Run `memory_setup.py` to init SQLite -4. Open `admin/admin-dashboard.html` - -## Repo layout -- `helpdesk-agent-tools/` — core wrappers + systemd units + Hermes config -- `helpdesk-agent-diagram-guide.html` — architecture guide -- `admin/admin-dashboard.html` — admin cost/usage dashboard -- `README.md` — this file - -## License -MIT +
+ + + + + +
+ +
+ +
+

🎫 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 +

+
+ +--- + +## 📸 Screenshot + +This is a CLI/backend-only tool. No screenshots available. + +## ✨ Features + +- **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 + +## 🚀 Quick Start + +```bash +git clone https://github.com/OneByJorah/CommandDesk.git +cd CommandDesk +cp .env.example .env +# Edit .env with your configuration +docker compose up -d +``` + +## 🏗️ Architecture + +``` +┌──────────────────────────────────────────────────────────┐ +│ CommandDesk │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌──────────────────┐ │ +│ │ Ticket │ │ AI │ │ Knowledge │ │ +│ │ Platforms │ │ Engine │ │ Base │ │ +│ │ osTicket │ │ Ollama │ │ ChromaDB │ │ +│ │ Freshdesk │ │ llama.cpp │ │ Qdrant │ │ +│ │ Zammad │ │ OpenAI │ │ │ │ +│ └──────┬──────┘ └──────┬──────┘ └────────┬─────────┘ │ +│ │ │ │ │ +│ └────────────────┼───────────────────┘ │ +│ ▼ │ +│ ┌──────────────────────┐ │ +│ │ Communication Layer │ │ +│ │ WhatsApp · Email · │ │ +│ │ Web Interface │ │ +│ └──────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ +``` + +## 📡 Integrations + +| 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 with AI engine +docker compose up -d + +# 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 + +``` +CommandDesk/ +├── 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 +``` + +## 🔒 Security + +- Rate limiting on all API endpoints +- Content filtering for malicious payloads +- PII detection and redaction +- Environment-based configuration (`.env` never committed) + +## 📄 License + +MIT © Jhonattan L. Jimenez + +--- + +
+

🤖 AI-powered helpdesk, fully self-hosted

+

@OneByJorah

+
diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..235dfdf --- /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 **security@jorahone.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/admin/admin-dashboard.html b/admin/admin-dashboard.html index 01f79ed..38d116f 100644 --- a/admin/admin-dashboard.html +++ b/admin/admin-dashboard.html @@ -1,141 +1,575 @@ - - -J1 Helpdesk Admin Dashboard - + + + J1 Helpdesk Admin Dashboard + -
-

📊 J1 Helpdesk Admin Dashboard

-

Live operational view for the self-hosted AI helpdesk agent.

- - - -
- Overview -
-
0
Tickets Open
-
0
Tickets Closed
-
0
User Sessions
-
0s
Avg Response
-
$0
Monthly Saved
-
$0
Yearly Saved
+
+

🤖 J1 Helpdesk Admin

+
+
+ All systems operational + +
-
- -
- Live Price Comparison -

If this stack were replaced by common hosted AI APIs, here’s the equivalent estimated spend.

- - - - - - - - - - -
ScenarioEquivalent ServicePricing BasisMonthly Estimate
LLM Inference (64k context)OpenAI GPT-4o class~$2.50 / 1M input + $10 / 1M output~$120–$240
Search API replacementBing Web Search / SerpAPI~$5 / 1k queries~$25–$50
Embeddings / memoryOpenAI text-embedding-3-large~$0.13 / 1M tokens~$3–$8
Telegram / WhatsApp bridge SaaSIntercom / Zendesk / Twilioseat + message fees~$80–$160
Hosted ticketingZendesk / Freshdesk$19–$49 per agent / month~$60–$150
Total hosted estimate~$288–$608 / month
Self-hosted cost$0 software + VM infra
Net monthly saved$300–$600
-
- -
- Admin Pricing Model -

How the dashboard calculates monthly saved.

- - - - - - - - -
Input / MetricSourceHosted Baseline
LLM requestsusage.logopen weights equivalent → GPT-4.1 / GPT-4o
Tokens inllama.cpp prompt_eval$2.50 / 1M
Tokens outllama.cpp completion$10 / 1M
Search callstool usage counter$5 / 1k
Ticket channelsplatform registryTwilio / Intercom seat
Agentsadmin users$29/agent/mo
-
- -
- Ticket Platforms & Email -

Extend Hermes with adapters so any platform is just a config change.

- - - - - - - - - -
PlatformTypeStatusNotes
osTicketTicketingImplementedREST API wrapper included
ZammadTicketingPlannedREST API adapter
Thelia / GLPITicketingPlannedCommunity adapters
Email → TicketIngestionPlannedIMAP poller or IMAP IDLE bridge
TelegramTwilio ChannelPlannedFirst-class channel
WhatsAppTwilio ChannelPlannedTwilio sandbox or Meta Cloud
Slack / DiscordChannelOptionalExtra bridge layer
-
- -
- Cost Model Code -

Drop-in formula for the dashboard live price counter.

-
# Example Python snippet for admin dashboard
-def compute_monthly_savings(prompts, completions, searches, agents=1):
-    llm = (prompts * 2.5 + completions * 10) / 1_000_000
-    search = searches * 0.005
-    seats = agents * 29
-    hosted = llm + search + seats + 80  # baseline ops/ticketing
-    return max(hosted, 0)
-
-def update_live():
-    usage = read_usage_log()
-    saved = compute_monthly_savings(
-        prompts=usage['tokens_in'],
-        completions=usage['tokens_out'],
-        searches=usage['search_calls'],
-        agents=usage['active_agents']
-    )
-    set_dom_text('#net-saved', f"${saved:,.0f}")
-
- -
- Next Implementation Steps -
    -
  1. Add ticket platform registry (`ticket_platforms/registry.py`)
  2. -
  3. Implement `zammad_tool.py` and `email_ticket_tool.py`
  4. -
  5. Build admin FastAPI dashboard service with usage endpoints
  6. -
  7. Wire Hermes tool calls + usage logging into SQLite
  8. -
  9. Deploy admin dashboard at /admin behind auth
  10. -
  11. Push live cost calculations on every request + cron rollup
  12. -
-
- -
- J1 Helpdesk Admin Dashboard — generated: 2026-06-17 -
-
+ +
+ +
+
+
+ Active Sessions + ⟳ Live +
+
12
+
Last 24h peak: 47
+
+
+
+ Open Tickets + ⚠ Needs attention +
+
23
+
8 created today
+
+
+
+ Tokens Used + 📊 Today +
+
1.2M
+
Input: 800K / Output: 400K
+
+
+
+ Est. Cost + 💰 Low +
+
$0.00
+
Local LLM: $0.00/1M tokens
+
+
+
+ Avg Response + ⚡ Fast +
+
3.2s
+
P95: 8.1s / P99: 12.4s
+
+
+
+ Rate Limit Hits + 🛡️ Blocked +
+
3
+
2 unique users
+
+
+ + +
+
🔧 Service Health
+
+
+
🧠
+
+
llama.cpp
+
● Running — 6.2GB RAM
+
+
+
+
🤖
+
+
Helpdesk Agent
+
● Running — 1.1GB RAM
+
+
+
+
👑
+
+
Admin Agent
+
● Running — 800MB RAM
+
+
+
+
📚
+
+
ChromaDB
+
● Running — 1.4GB RAM
+
+
+
+
🔍
+
+
SearXNG
+
● Running — 600MB RAM
+
+
+
+
🗄️
+
+
PostgreSQL
+
● Running — 400MB RAM
+
+
+
+
+
+
Redis
+
● Running — 120MB RAM
+
+
+
+
🔄
+
+
n8n
+
● Running — 350MB RAM
+
+
+
+
📧
+
+
Email Fetcher
+
● Running — 200MB RAM
+
+
+
+
🌐
+
+
Nginx
+
● Running — 50MB RAM
+
+
+
+
+ + +
+
🎫 Tickets
+
💬 Sessions
+
💰 Costs
+
📋 Audit Log
+
+ + +
+
🎫 Recent Tickets
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDSubjectUserPlatformStatusPriorityCreated
a1b2c3Cannot access dashboarduser@example.com📧 EmailOpenNormal2 min ago
d4e5f6Billing invoice questionjohn@company.com🎫 osTicketPendingHigh15 min ago
g7h8i9Password reset not workingjane@startup.io📱 FreshdeskOpenUrgent1 hour ago
j0k1l2API rate limiting errorsdev@tech.co🎫 osTicketClosedNormal3 hours ago
m3n4o5Feature request: dark modepm@agency.com📧 EmailClosedLow5 hours ago
+
+ + + + + + + + + +
+ + diff --git a/compose/Dockerfile b/compose/Dockerfile new file mode 100644 index 0000000..2061074 --- /dev/null +++ b/compose/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +# system deps for common wheels / networking +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# python deps +COPY requirements.txt /app/requirements.txt +RUN pip install -r requirements.txt + +# app +COPY ticket_platforms /app/ticket_platforms +COPY memory_setup.py /app/memory_setup.py + +EXPOSE 8000 +CMD ["python", "-m", "ticket_platforms.registry"] diff --git a/compose/docker-compose.automation.yml b/compose/docker-compose.automation.yml new file mode 100644 index 0000000..f44f331 --- /dev/null +++ b/compose/docker-compose.automation.yml @@ -0,0 +1,20 @@ +services: + n8n: + image: n8nio/n8n:latest + container_name: helpdesk-n8n + ports: + - "127.0.0.1:5678:5678" + volumes: + - ./data/n8n:/home/node/.n8n + environment: + - DB_TYPE=sqlite + - N8N_EMAIL_MODE=none + - N8N_PROTOCOL=http + - N8N_PORT=5678 + - N8N_HOST=localhost + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5678/healthz"] + interval: 30s + timeout: 5s + retries: 3 diff --git a/compose/docker-compose.ci.yml b/compose/docker-compose.ci.yml new file mode 100644 index 0000000..dad8687 --- /dev/null +++ b/compose/docker-compose.ci.yml @@ -0,0 +1,19 @@ +services: + hermes: + image: python:3.11-slim + working_dir: /app + volumes: + - ../:/app + environment: + - OSTICKET_BASE_URL=${OSTICKET_BASE_URL:-https://helpdesk.example.com} + - OSTICKET_API_KEY=${OSTICKET_API_KEY:-} + command: > + bash -c "pip install requests fastapi uvicorn pydantic -q && + python -m py_compile ticket_platforms/base.py && + python -m py_compile ticket_platforms/registry.py && + python -m py_compile ticket_platforms/osticket.py && + python -m py_compile ticket_platforms/zammad.py && + python -m py_compile ticket_platforms/email.py && + python -m py_compile memory_setup.py && + echo 'ok'" + depends_on: [] diff --git a/compose/docker-compose.git.yml b/compose/docker-compose.git.yml new file mode 100644 index 0000000..659b5bd --- /dev/null +++ b/compose/docker-compose.git.yml @@ -0,0 +1,20 @@ +services: + gitea: + image: gitea/gitea:latest + container_name: helpdesk-gitea + ports: + - "127.0.0.1:3002:3000" + - "127.0.0.1:2222:22" + volumes: + - ./data/gitea:/data + - ./data/gitea/ssh:/home/git/.ssh + environment: + - USER_UID=1000 + - USER_GID=1000 + - SSH_PORT=222 + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000"] + interval: 30s + timeout: 5s + retries: 3 diff --git a/compose/docker-compose.knowledge.yml b/compose/docker-compose.knowledge.yml new file mode 100644 index 0000000..2331886 --- /dev/null +++ b/compose/docker-compose.knowledge.yml @@ -0,0 +1,18 @@ +services: + searxng: + image: searxng/searxng:latest + container_name: helpdesk-search + ports: + - "127.0.0.1:8080:8080" + volumes: + - ./data/searxng:/etc/searxng + environment: + SEARXNG_BASE_URL: http://localhost:8080/ + restart: unless-stopped + cap_drop: + - ALL + cap_add: + - CHOWN + - SETGID + - SETUID + read_only: true diff --git a/compose/docker-compose.mail.yml b/compose/docker-compose.mail.yml new file mode 100644 index 0000000..0c30cdc --- /dev/null +++ b/compose/docker-compose.mail.yml @@ -0,0 +1,24 @@ +services: + postal: + image: postalhub/postal:latest + container_name: helpdesk-postal + ports: + - "127.0.0.1:5025:5000" + volumes: + - ./data/postal:/var/lib/postal + environment: + POSTAL_SMTP_HOST: helpdesk.local + POSTAL_WEB_HOST: helpdesk.local + POSTAL_DATABASE_URL: sqlite:////var/lib/postal/db.sqlite3 + POSTAL_REDIS_HOST: helpdesk-postal-redis + POSTAL_LOG_LEVEL: info + depends_on: + - redis + + redis: + image: redis:7-alpine + container_name: helpdesk-postal-redis + command: redis-server --appendonly yes + volumes: + - ./data/postal-redis:/data + restart: unless-stopped diff --git a/compose/docker-compose.monitoring.yml b/compose/docker-compose.monitoring.yml new file mode 100644 index 0000000..c03ef80 --- /dev/null +++ b/compose/docker-compose.monitoring.yml @@ -0,0 +1,20 @@ +services: + uptime: + image: louislam/uptime:latest + container_name: helpdesk-uptime + ports: + - "127.0.0.1:3001:3001" + volumes: + - ./data/uptime:/app/data + environment: + - UPTIME_REDIS_URL=redis://helpdesk-uptime-redis:6379 + depends_on: + - redis + + redis: + image: redis:7-alpine + container_name: helpdesk-uptime-redis + command: redis-server --appendonly yes + volumes: + - ./data/uptime-redis:/data + restart: unless-stopped diff --git a/compose/docker-compose.plus.yml b/compose/docker-compose.plus.yml new file mode 100644 index 0000000..8006551 --- /dev/null +++ b/compose/docker-compose.plus.yml @@ -0,0 +1,60 @@ +services: + stt: + image: onerahmet/openai-whisper-asr-webservice:latest + container_name: helpdesk-stt + ports: + - "127.0.0.1:9000:9000" + volumes: + - ./data/stt:/app/output + environment: + - ASR_MODEL=small + - ASR_ENGINE=openai_whisper + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/health"] + interval: 30s + timeout: 5s + retries: 3 + + tts: + image: ghcr.io/rhasspy/piper-tts:latest + container_name: helpdesk-tts + ports: + - "127.0.0.1:5000:5000" + volumes: + - ./data/tts:/app/output + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/health"] + interval: 30s + timeout: 5s + retries: 3 + + browser: + image: zenika/alpine-chrome:124 + container_name: helpdesk-browser + shm_size: 2g + ports: + - "127.0.0.1:9222:9222" + tmpfs: + - /tmp + environment: + - TZ=UTC + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:9222/json/version"] + interval: 30s + timeout: 5s + retries: 3 + + honcho: + image: ghcr.io/steipete/honcho:latest + container_name: helpdesk-honcho + ports: + - "127.0.0.1:8081:8080" + volumes: + - ./data/honcho:/app/data + environment: + - TZ=UTC + - HONCHO_PORT=8080 + restart: unless-stopped diff --git a/compose/docker-compose.selfhosted.yml b/compose/docker-compose.selfhosted.yml new file mode 100644 index 0000000..ef75e88 --- /dev/null +++ b/compose/docker-compose.selfhosted.yml @@ -0,0 +1,64 @@ +services: + stt: + image: ghcr.io/openai/whisper:latest + container_name: helpdesk-stt + ports: + - "127.0.0.1:8000:8000" + volumes: + - ./data/stt:/app/output + environment: + - MODEL=small + - TZ=UTC + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 5s + retries: 3 + + tts: + image: ghcr.io/rhasspy/piper-tts:latest + container_name: helpdesk-tts + ports: + - "127.0.0.1:5000:5000" + volumes: + - ./data/tts:/app/output + environment: + - TZ=UTC + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/health"] + interval: 30s + timeout: 5s + retries: 3 + + browser: + image: zenika/alpine-chrome:124 + container_name: helpdesk-browser + ports: + - "127.0.0.1:9222:9222" + shm_size: 2g + environment: + - TZ=UTC + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9222/json/version"] + interval: 30s + timeout: 5s + retries: 3 + + honcho: + image: ghcr.io/openai/honcho:latest + container_name: helpdesk-honcho + ports: + - "127.0.0.1:8081:8080" + volumes: + - ./data/honcho:/app/data + environment: + - TZ=UTC + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 5s + retries: 3 diff --git a/compose/docker-compose.storage.yml b/compose/docker-compose.storage.yml new file mode 100644 index 0000000..69b1ee0 --- /dev/null +++ b/compose/docker-compose.storage.yml @@ -0,0 +1,37 @@ +services: + postgres: + image: pgvector/pgvector:pg16 + container_name: helpdesk-postgres + ports: + - "127.0.0.1:5432:5432" + volumes: + - ./data/postgres:/var/lib/postgresql/data + environment: + POSTGRES_USER: helpdesk + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me} + POSTGRES_DB: helpdesk + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready"] + interval: 30s + timeout: 5s + retries: 3 + + minio: + image: minio/minio:latest + container_name: helpdesk-minio + ports: + - "127.0.0.1:9000:9000" + - "127.0.0.1:9001:9001" + volumes: + - ./data/minio:/data + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-admin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-change-me} + command: server /data --console-address ":9001" + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 5s + retries: 3 diff --git a/compose/docker-compose.wiki.yml b/compose/docker-compose.wiki.yml new file mode 100644 index 0000000..640d06f --- /dev/null +++ b/compose/docker-compose.wiki.yml @@ -0,0 +1,13 @@ +services: + outline: + image: outlinewiki/outline:latest + container_name: helpdesk-wiki + ports: + - "127.0.0.1:3000:3000" + volumes: + - ./data/outline:/var/lib/outline + environment: + DATABASE_URL: sqlite:////var/lib/outline/outline.db + SECRET_KEY: ${OUTLINE_SECRET_KEY:-change-me} + UTILS_SECRET: ${OUTLINE_UTILS_SECRET:-change-me} + restart: unless-stopped diff --git a/compose/docker-compose.yml b/compose/docker-compose.yml new file mode 100644 index 0000000..e790559 --- /dev/null +++ b/compose/docker-compose.yml @@ -0,0 +1,20 @@ +services: + hermes: + build: + context: .. + dockerfile: compose/Dockerfile + ports: + - "127.0.0.1:8000:8000" + environment: + OSTICKET_BASE_URL: ${OSTICKET_BASE_URL:-https://helpdesk.example.com} + OSTICKET_API_KEY: ${OSTICKET_API_KEY:-} + OSTICKET_DEFAULT_DEPT_ID: ${OSTICKET_DEFAULT_DEPT_ID:-1} + OSTICKET_DEFAULT_PRIORITY: ${OSTICKET_DEFAULT_PRIORITY:-low} + ZAMMAD_BASE_URL: ${ZAMMAD_BASE_URL:-} + ZAMMAD_API_TOKEN: ${ZAMMAD_API_TOKEN:-} + EMAIL_IMAP_HOST: ${EMAIL_IMAP_HOST:-} + EMAIL_IMAP_PORT: ${EMAIL_IMAP_PORT:-993} + EMAIL_IMAP_USER: ${EMAIL_IMAP_USER:-} + EMAIL_IMAP_PASSWORD: ${EMAIL_IMAP_PASSWORD:-} + EMAIL_TICKET_PLATFORM: ${EMAIL_TICKET_PLATFORM:-osticket} + restart: unless-stopped diff --git a/compose/requirements.txt b/compose/requirements.txt new file mode 100644 index 0000000..94cf104 --- /dev/null +++ b/compose/requirements.txt @@ -0,0 +1,4 @@ +requests==2.33.0 +fastapi==0.111.0 +uvicorn==0.30.0 +pydantic==2.7.0 diff --git a/config/admin-agent-config.yaml b/config/admin-agent-config.yaml new file mode 100644 index 0000000..2ff9b38 --- /dev/null +++ b/config/admin-agent-config.yaml @@ -0,0 +1,169 @@ +# ═══════════════════════════════════════════════════ +# Hermes Agent Configuration - Admin Mode +# ═══════════════════════════════════════════════════ + +agent: + name: "J1 Helpdesk Admin" + mode: admin + port: 8082 + description: "Admin agent for helpdesk management - full access" + +llm: + provider: openai-compatible + api_base: ${LLM_API_BASE} + api_key: "not-needed" + model: ${LLM_MODEL} + context_length: 65536 + max_tokens: 4096 + temperature: 0.2 + top_p: 0.9 + +tools: + # Full ticket access + - name: search_tickets + description: "Search ALL tickets across all users" + parameters: + - name: query + type: string + - name: status + type: string + required: false + - name: user_id + type: string + required: false + - name: limit + type: integer + default: 50 + + - name: get_ticket + description: "Get any ticket details" + parameters: + - name: ticket_id + type: string + + - name: update_ticket + description: "Update any ticket" + parameters: + - name: ticket_id + type: string + - name: message + type: string + - name: status + type: string + required: false + - name: assignee + type: string + required: false + + - name: close_ticket + description: "Close any ticket" + parameters: + - name: ticket_id + type: string + - name: reason + type: string + required: false + + - name: create_ticket + description: "Create a new ticket on behalf of a user" + enabled: true + parameters: + - name: user_id + type: string + - name: subject + type: string + - name: body + type: string + - name: priority + type: string + required: false + + - name: list_all_tickets + description: "List all tickets with pagination" + parameters: + - name: page + type: integer + default: 1 + - name: per_page + type: integer + default: 25 + - name: status + type: string + required: false + + - name: cost_analytics + description: "Get token usage and cost estimates" + parameters: + - name: period + type: string + default: "today" + - name: group_by + type: string + default: "session" + + - name: system_health + description: "Get system health metrics" + parameters: [] + + - name: search_knowledge_base + description: "Search knowledge base" + parameters: + - name: query + type: string + - name: limit + type: integer + default: 10 + + - name: web_search + description: "Search the web" + parameters: + - name: query + type: string + + - name: manage_knowledge_base + description: "Add/remove knowledge base articles" + parameters: + - name: action + type: string + - name: content + type: string + - name: source + type: string + +security: + rate_limiting: + enabled: true + max_requests_per_session: 200 + window_seconds: 3600 + max_message_length: 8000 + max_session_duration: 14400 + auth: + required: true + api_keys: + - ${ADMIN_API_KEY} + audit_log: + enabled: true + log_all_requests: true + +session: + storage: redis + redis_url: ${REDIS_URL} + ttl: 14400 + db: 1 + +knowledge_base: + backend: chroma + url: ${CHROMA_URL} + collection: helpdesk-kb + embedding_model: all-MiniLM-L6-v2 + manage: true + +database: + url: ${POSTGRES_URL} + pool_size: 10 + max_overflow: 20 + +logging: + level: INFO + format: json + file: /app/data/logs/admin-agent.log diff --git a/config/agent-bridge.yaml b/config/agent-bridge.yaml new file mode 100644 index 0000000..7f9d6d0 --- /dev/null +++ b/config/agent-bridge.yaml @@ -0,0 +1,38 @@ +# ═══════════════════════════════════════════════════ +# Agent Bridge Configuration +# Allows main Hermes to delegate to helpdesk agent +# ═══════════════════════════════════════════════════ + +bridges: + helpdesk-agent: + url: "http://helpdesk-agent:8080" + description: "Customer-facing helpdesk agent (restricted tools)" + timeout: 30 + retry: 2 + # When main Hermes receives "helpdesk" or "ticket" intent, delegate here + triggers: + - "ticket" + - "helpdesk" + - "support" + - "my issue" + - "my ticket" + + admin-agent: + url: "http://admin-agent:8082" + description: "Admin agent for helpdesk management" + timeout: 60 + retry: 1 + auth: + header: "X-Admin-Key" + key: ${ADMIN_API_KEY} + triggers: + - "admin" + - "manage tickets" + - "cost" + - "analytics" + - "system health" + +# How main Hermes should route: +# 1. Customer asks about ticket → delegate to helpdesk-agent +# 2. Admin asks for analytics → delegate to admin-agent +# 3. Unknown → handle locally with general knowledge diff --git a/config/hermes-config.yaml b/config/hermes-config.yaml new file mode 100644 index 0000000..bd6cd3b --- /dev/null +++ b/config/hermes-config.yaml @@ -0,0 +1,126 @@ +# ═══════════════════════════════════════════════════ +# Hermes Agent Configuration - Helpdesk Mode +# ═══════════════════════════════════════════════════ + +agent: + name: "J1 Helpdesk Agent" + mode: helpdesk + port: 8080 + description: "Self-hosted AI helpdesk agent for customer support" + +llm: + provider: openai-compatible + api_base: ${LLM_API_BASE} + api_key: "not-needed" + model: ${LLM_MODEL} + context_length: 65536 + max_tokens: 2048 + temperature: 0.3 + top_p: 0.9 + +tools: + # Helpdesk tools (NO create_ticket for end-users) + - name: search_tickets + description: "Search user's tickets by keyword, status, or date range" + parameters: + - name: query + type: string + description: "Search query" + - name: status + type: string + description: "Filter by status (open, pending, closed)" + required: false + - name: limit + type: integer + description: "Max results" + default: 10 + + - name: get_ticket + description: "Get details of a specific ticket by ID" + parameters: + - name: ticket_id + type: string + description: "Ticket ID" + + - name: update_ticket + description: "Add a reply or update own ticket" + parameters: + - name: ticket_id + type: string + - name: message + type: string + description: "Reply message" + - name: status + type: string + description: "New status (open, closed)" + required: false + + - name: close_ticket + description: "Close own ticket" + parameters: + - name: ticket_id + type: string + - name: reason + type: string + description: "Reason for closing" + required: false + + - name: search_knowledge_base + description: "Search knowledge base articles" + parameters: + - name: query + type: string + - name: limit + type: integer + default: 5 + + - name: web_search + description: "Search the web for information" + parameters: + - name: query + type: string + - name: limit + type: integer + default: 5 + + - name: create_ticket + description: "Create a new ticket (DISABLED in helpdesk mode)" + enabled: false + +security: + rate_limiting: + enabled: true + max_requests_per_session: ${RATE_LIMIT_PER_SESSION} + window_seconds: ${RATE_LIMIT_WINDOW} + max_message_length: ${MAX_MESSAGE_LENGTH} + max_session_duration: ${MAX_SESSION_DURATION} + content_filter: + enabled: true + block_patterns: + - "password" + - "credit_card" + - "ssn" + audit_log: + enabled: true + log_all_requests: true + +session: + storage: redis + redis_url: ${REDIS_URL} + ttl: ${MAX_SESSION_DURATION} + +knowledge_base: + backend: chroma + url: ${CHROMA_URL} + collection: helpdesk-kb + embedding_model: all-MiniLM-L6-v2 + +database: + url: ${POSTGRES_URL} + pool_size: 5 + max_overflow: 10 + +logging: + level: INFO + format: json + file: /app/data/logs/agent.log diff --git a/config/mcp-config.yaml b/config/mcp-config.yaml new file mode 100644 index 0000000..c2fa06a --- /dev/null +++ b/config/mcp-config.yaml @@ -0,0 +1,75 @@ +# ═══════════════════════════════════════════════════ +# MCP (Model Context Protocol) Client Configuration +# Connects to external MCP servers (Freshdesk, etc.) +# ═══════════════════════════════════════════════════ + +mcp_servers: + freshdesk: + # Freshdesk MCP Server (NeuraLegion/freshdesk_mcp) + # 41 tools for tickets, contacts, companies, KB, etc. + transport: stdio + command: "node" + args: + - "/app/mcp-servers/freshdesk/dist/index.js" + env: + FRESHDESK_DOMAIN: "${FRESHDESK_DOMAIN}" + FRESHDESK_API_KEY: "${FRESHDESK_API_KEY}" + enabled: true + # Which tools to expose to the agent + allowed_tools: + - list_tickets + - view_ticket + - create_ticket + - search_tickets + - update_ticket + - list_ticket_conversations + - reply_to_ticket + - add_note_to_ticket + - list_contacts + - view_contact + - search_contacts + - list_companies + - view_company + - search_solutions + - list_solution_categories + - list_solution_folders + - list_solution_articles + - view_solution_article + + # Example: Add more MCP servers here + # osticket: + # transport: stdio + # command: "node" + # args: ["/app/mcp-servers/osticket/dist/index.js"] + # env: + # OSTICKET_URL: "${OSTICKET_URL}" + # OSTICKET_API_KEY: "${OSTICKET_API_KEY}" + # enabled: false + +# MCP Tool Permissions +# Control which tools each agent mode can use +permissions: + helpdesk: + # Helpdesk agent (end-users) — read-only + update own + allowed: + - list_tickets + - view_ticket + - search_tickets + - list_ticket_conversations + - reply_to_ticket + - search_solutions + - list_solution_categories + - list_solution_folders + - list_solution_articles + - view_solution_article + denied: + - create_ticket + - add_note_to_ticket + - update_ticket + - list_contacts + - list_companies + + admin: + # Admin agent — full access to all tools + allowed: ["*"] + denied: [] diff --git a/config/mcp-registry.yaml b/config/mcp-registry.yaml new file mode 100644 index 0000000..edf5117 --- /dev/null +++ b/config/mcp-registry.yaml @@ -0,0 +1,132 @@ +# ═══════════════════════════════════════════════════ +# MCP Server Registry +# Pre-configured MCP server definitions for common platforms +# ═══════════════════════════════════════════════════ + +# Freshdesk (active — uses NeuraLegion/freshdesk_mcp) +# 41 tools: tickets, contacts, companies, agents, KB, time tracking, canned responses +freshdesk: + name: "Freshdesk" + description: "Full Freshdesk CRM integration via MCP" + repository: "NeuraLegion/freshdesk_mcp" + transport: stdio + command: "node" + args: ["/app/mcp-servers/freshdesk/dist/index.js"] + env: + FRESHDESK_DOMAIN: "${FRESHDESK_DOMAIN}" + FRESHDESK_API_KEY: "${FRESHDESK_API_KEY}" + tools_total: 41 + categories: + - tickets + - contacts + - companies + - agents + - knowledge_base + - time_tracking + - canned_responses + - satisfaction_ratings + - system_config + +# osTicket (adapter-based — no MCP needed, uses REST API directly) +# The osticket.py adapter handles all operations +osticket: + name: "osTicket" + description: "Ticket management via osTicket REST API" + type: adapter + adapter_file: "ticket_platforms/osticket.py" + config: + url_env: "OSTICKET_BASE_URL" + key_env: "OSTICKET_API_KEY" + capabilities: + - create_ticket + - search_tickets + - update_ticket + - list_tickets + - add_note + - close_ticket + +# Zammad (adapter-based — uses REST API) +zammad: + name: "Zammad" + description: "Ticket management via Zammad REST API" + type: adapter + adapter_file: "ticket_platforms/zammad.py" + config: + url_env: "ZAMMAD_BASE_URL" + token_env: "ZAMMAD_API_TOKEN" + capabilities: + - create_ticket + - search_tickets + - update_ticket + - list_tickets + - add_article + - close_ticket + +# Slack (optional — for notifications and agent communication) +# Uses modelcontextprotocol/servers/src/slack +slack: + name: "Slack" + description: "Send notifications and manage channels" + repository: "modelcontextprotocol/servers" + transport: stdio + command: "npx" + args: ["-y", "@modelcontextprotocol/server-slack"] + env: + SLACK_BOT_TOKEN: "${SLACK_BOT_TOKEN}" + SLACK_TEAM_ID: "${SLACK_TEAM_ID}" + tools: + - send_message + - list_channels + - list_users + - upload_file + optional: true + +# GitHub (optional — for issue tracking and documentation) +# Uses github/github-mcp-server +github: + name: "GitHub" + description: "Issue tracking, PRs, and repository management" + repository: "github/github-mcp-server" + transport: https + url: "https://api.github.com/mcp" + headers: + Authorization: "Bearer ${GITHUB_TOKEN}" + Accept: "application/json" + tools: + - create_issue + - list_issues + - get_issue + - create_pull_request + - search_repositories + - get_file_contents + optional: true + +# SearXNG (optional MCP — for structured search) +# Can be used alongside the direct HTTP integration +searxng: + name: "SearXNG" + description: "Self-hosted search via MCP" + type: http + url: "http://searxng:8080/search" + format: json + optional: true + +# PostgreSQL (optional MCP — for direct database queries) +postgres: + name: "PostgreSQL" + description: "Database queries and management" + repository: "modelcontextprotocol/servers" + transport: stdio + command: "npx" + args: ["-y", "@modelcontextprotocol/server-postgresql"] + env: + POSTGRES_CONNECTION_STRING: "${POSTGRES_URL}" + tools: + - query + - list_tables + - get_table_schema + optional: true + +# Usage: +# To enable an MCP server, add its config to config/mcp-config.yaml +# and set the required environment variables in .env diff --git a/config/nginx.conf b/config/nginx.conf new file mode 100644 index 0000000..b4221da --- /dev/null +++ b/config/nginx.conf @@ -0,0 +1,111 @@ +events { + worker_connections 1024; +} + +http { + # Rate limiting zones + limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s; + limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s; + limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s; + + # Proxy cache + proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=hermes_cache:10m max_size=100m inactive=60m; + + # Default upstream + upstream helpdesk_agent { + server helpdesk-agent:8080; + } + upstream admin_agent { + server admin-agent:8082; + } + upstream n8n_backend { + server n8n:5678; + } + upstream searxng_backend { + server searxng:8080; + } + + server { + listen 80; + server_name _; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + # Hide server version + server_tokens off; + + # Request size limit (match agent config) + client_max_body_size 4k; + client_body_timeout 30s; + client_header_timeout 30s; + + # Helpdesk Agent API + location /helpdesk/ { + limit_req zone=api burst=20 nodelay; + proxy_pass http://helpdesk_agent/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache hermes_cache; + proxy_cache_valid 200 5m; + } + + # Admin Agent API + location /admin/ { + limit_req zone=api burst=10 nodelay; + # IP whitelist - only allow local network + allow 172.20.0.0/16; + allow 127.0.0.1; + deny all; + proxy_pass http://admin_agent/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # n8n + location /n8n/ { + limit_req zone=api burst=20 nodelay; + proxy_pass http://n8n_backend/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # SearXNG + location /search/ { + limit_req zone=api burst=30 nodelay; + proxy_pass http://searxng_backend/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Admin Dashboard (static) + location /dashboard/ { + alias /app/admin/; + index admin-dashboard.html; + autoindex off; + } + + # Health check + location /health { + access_log off; + return 200 '{"status":"ok"}'; + add_header Content-Type application/json; + } + + # Default - redirect to dashboard + location / { + return 302 /dashboard/; + } + } +} diff --git a/config/searxng-settings.yml b/config/searxng-settings.yml new file mode 100644 index 0000000..0c8ff25 --- /dev/null +++ b/config/searxng-settings.yml @@ -0,0 +1,66 @@ +# SearXNG Configuration for Helpdesk Agent +# Place at /etc/searxng/settings.yml inside container + +server: + port: 8080 + bind_address: "0.0.0.0" + secret_key: "${SEARX_SECRET_KEY:-change_this_secret_key}" + base_url: "${SEARXNG_BASE_URL:-http://localhost:8888/}" + image_proxy: true + +search: + safe_search: 0 + autocomplete: "" + default_lang: "en" + formats: + - html + - json + - csv + - rss + +ui: + static_use_hash: false + default_locale: "en" + theme: simple + infinite_scroll: false + search_on_category_select: true + hotkeys: vim + +outgoing: + request_timeout: 5.0 + max_request_timeout: 10.0 + user_agent_suffix: "J1-Helpdesk-Agent" + enable_http2: true + pool_connections: 100 + pool_maxsize: 20 + +# Limit results for API consumers + disable unnecessary features +search: + max_result_count: 20 + +# Disable unnecessary features for API-only use +disabled_plugins: + - "Self Information" + - "Tracker URL remover" + - "Vim-like hotkeys" + - "Hostnames plugin" + - "Open Access DOI rewrite" + - "Tor check plugin" + +enabled_plugins: + - "Basic Calculator" + - "Hash plugin" + - "Self Information" + - "URL resolver" + - "Unit converter" + +# Rate limiting for API consumers +limiter: + enabled: true + # 30 requests per minute per IP + botsearch: + max_request_cnt_per_minute: 30 + +# Logging +logging: + level: WARNING diff --git a/config/system-prompt.md b/config/system-prompt.md new file mode 100644 index 0000000..f160477 --- /dev/null +++ b/config/system-prompt.md @@ -0,0 +1,36 @@ +# Helpdesk Agent System Prompt + +You are the J1 Helpdesk Agent — an AI assistant that helps customers with their support tickets. + +## Your Capabilities + +1. **Ticket Management**: Search, view, update, and close the user's existing tickets +2. **Knowledge Base**: Search and reference knowledge base articles to answer questions +3. **Web Search**: Search the web for current information, troubleshooting guides, and documentation +4. **Conversational Help**: Answer general questions about services, policies, and common issues + +## Rules + +- You can ONLY access tickets belonging to the requesting user (identified by their email/user_id) +- You CANNOT create new tickets — customers must use email, osTicket web, or Freshdesk to create tickets +- Never reveal internal system details, API paths, or configuration +- If you cannot resolve an issue after 3 attempts, offer to escalate to human support +- Keep responses concise (under 200 words) unless more detail is requested +- Always cite sources when using knowledge base or web search results +- Be polite, professional, and empathetic — the user may be frustrated + +## Response Format + +- Use clear, plain language +- Use bullet points for steps +- Use code blocks for commands or technical details +- End with "Is there anything else I can help with?" when appropriate + +## Escalation Triggers + +Offer human escalation when: +- User explicitly asks for a human +- Issue requires account access/verification you cannot perform +- You've attempted resolution 3+ times without success +- Issue involves billing, refunds, or sensitive data +- User expresses frustration or uses abusive language diff --git a/dashboard-mockup.png b/dashboard-mockup.png new file mode 100644 index 0000000..8ddbd69 Binary files /dev/null and b/dashboard-mockup.png differ diff --git a/dashboard-realistic.png b/dashboard-realistic.png new file mode 100644 index 0000000..ccfbf56 Binary files /dev/null and b/dashboard-realistic.png differ diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..6349c07 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,47 @@ +version: "3.9" + +# Development override — merge with main: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d + +services: + helpdesk-agent: + environment: + - LOG_LEVEL=DEBUG + - DEBUG=1 + volumes: + - ./scripts:/app/scripts:ro + - ./config:/app/config:ro + + admin-agent: + environment: + - LOG_LEVEL=DEBUG + - DEBUG=1 + volumes: + - ./scripts:/app/scripts:ro + - ./config:/app/config:ro + + llama: + ports: + - "0.0.0.0:8081:8081" # Expose for development + + whatsapp-webhook: + build: + context: . + dockerfile: Dockerfile.whatsapp + ports: + - "127.0.0.1:9090:9090" + - "0.0.0.0:8383:8383" # WhatsApp HTTPS + + health-monitor: + build: + context: . + dockerfile: Dockerfile + volumes: + - ./scripts:/app/scripts:ro + - ./config:/app/config:ro + + tools-ui: + build: + context: tools-ui + dockerfile: Dockerfile + ports: + - "127.0.0.1:8484:8484" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..eb1462a --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,153 @@ +version: "3.9" + +# Production override — use with: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d + +services: + llama: + restart: always + deploy: + resources: + limits: + memory: 7G + cpus: "6.0" + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:8081/health"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 120s + + helpdesk-agent: + restart: always + environment: + - LOG_LEVEL=WARNING + - DEBUG=0 + deploy: + resources: + limits: + memory: 2G + cpus: "2.0" + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + + admin-agent: + restart: always + environment: + - LOG_LEVEL=WARNING + - DEBUG=0 + deploy: + resources: + limits: + memory: 2G + cpus: "2.0" + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:8082/health"] + interval: 30s + timeout: 10s + retries: 3 + + whatsapp-webhook: + restart: always + environment: + - LOG_LEVEL=WARNING + deploy: + resources: + limits: + memory: 500M + cpus: "1.0" + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:9090/health"] + interval: 30s + timeout: 10s + retries: 3 + + chroma: + restart: always + deploy: + resources: + limits: + memory: 2G + cpus: "2.0" + + postgres: + restart: always + deploy: + resources: + limits: + memory: 1G + cpus: "1.0" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U helpdesk"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + restart: always + command: > + redis-server + --requirepass ${REDIS_PASSWORD:-redis_pass} + --maxmemory 256mb + --maxmemory-policy allkeys-lru + --appendonly yes + --appendfsync everysec + --save 60 1000 + --save 300 100 + deploy: + resources: + limits: + memory: 300M + + searxng: + restart: always + deploy: + resources: + limits: + memory: 1G + + n8n: + restart: always + environment: + - N8N_RUNNERS_ENABLED=true + deploy: + resources: + limits: + memory: 1G + + nginx: + restart: always + ports: + - "80:80" + - "443:443" + deploy: + resources: + limits: + memory: 200M + + health-monitor: + restart: always + environment: + - LOG_LEVEL=INFO + deploy: + resources: + limits: + memory: 200M + + email-fetcher: + restart: always + environment: + - LOG_LEVEL=INFO + deploy: + resources: + limits: + memory: 500M + + tools-ui: + restart: always + deploy: + resources: + limits: + memory: 100M diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ebf3107 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,450 @@ +version: "3.9" + +services: + # ═══════════════════════════════════════════════════ + # LLM Inference (llama.cpp with Qwen2.5-7B) + # ═══════════════════════════════════════════════════ + llama: + image: ghcr.io/ggerganov/llama.cpp:server + container_name: helpdesk-llama + ports: + - "127.0.0.1:8081:8081" + volumes: + - ./models:/models:ro + environment: + - LLAMA_ARG_MODEL=/models/qwen2.5-7b-instruct-q4_k_m.gguf + - LLAMA_ARG_PORT=8081 + - LLAMA_ARG_CTX_SIZE=65536 + - LLAMA_ARG_N_BATCH=512 + - LLAMA_ARG_THREADS=6 + - LLAMA_ARG_HOST=0.0.0.0 + - LLAMA_ARG_N_GL=0 + deploy: + resources: + limits: + memory: 7G + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:8081/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + networks: + - helpdesk-net + + # ═══════════════════════════════════════════════════ + # Helpdesk Agent (Hermes - restricted, no ticket creation) + # ═══════════════════════════════════════════════════ + helpdesk-agent: + build: + context: . + dockerfile: Dockerfile + container_name: helpdesk-agent + ports: + - "127.0.0.1:8080:8080" + volumes: + - ./config:/app/config:ro + - ./ticket_platforms:/app/ticket_platforms:ro + - ./scripts:/app/scripts:ro + - ./knowledge-base:/app/knowledge-base:ro + - hermes-data:/app/data + environment: + - HERMES_CONFIG=/app/config/hermes-config.yaml + - LLM_API_BASE=http://llama:8081/v1 + - LLM_MODEL=qwen2.5-7b-instruct + - CHROMA_URL=http://chroma:8000 + - SEARX_URL=http://searxng:8080 + - POSTGRES_URL=postgresql://helpdesk:${DB_PASSWORD:-helpdesk_pass}@postgres:5432/helpdesk + - REDIS_URL=redis://:${REDIS_PASSWORD:-redis_pass}@redis:6379/0 + - RATE_LIMIT_PER_SESSION=${RATE_LIMIT_PER_SESSION:-50} + - RATE_LIMIT_WINDOW=${RATE_LIMIT_WINDOW:-3600} + - MAX_MESSAGE_LENGTH=${MAX_MESSAGE_LENGTH:-4000} + - MAX_SESSION_DURATION=${MAX_SESSION_DURATION:-7200} + - AGENT_MODE=helpdesk + - ALLOW_CREATE_TICKET=false + - TZ=UTC + depends_on: + llama: + condition: service_healthy + chroma: + condition: service_started + postgres: + condition: service_healthy + redis: + condition: service_healthy + deploy: + resources: + limits: + memory: 2G + restart: unless-stopped + networks: + - helpdesk-net + + # ═══════════════════════════════════════════════════ + # Admin Agent (Hermes - full access, plug-in to main Hermes) + # ═══════════════════════════════════════════════════ + admin-agent: + build: + context: . + dockerfile: Dockerfile + container_name: helpdesk-admin-agent + ports: + - "127.0.0.1:8082:8082" + volumes: + - ./config:/app/config:ro + - ./ticket_platforms:/app/ticket_platforms:ro + - ./scripts:/app/scripts:ro + - ./knowledge-base:/app/knowledge-base:ro + - hermes-admin-data:/app/data + environment: + - HERMES_CONFIG=/app/config/admin-agent-config.yaml + - LLM_API_BASE=http://llama:8081/v1 + - LLM_MODEL=qwen2.5-7b-instruct + - CHROMA_URL=http://chroma:8000 + - SEARX_URL=http://searxng:8080 + - POSTGRES_URL=postgresql://helpdesk:${DB_PASSWORD:-helpdesk_pass}@postgres:5432/helpdesk + - REDIS_URL=redis://:${REDIS_PASSWORD:-redis_pass}@redis:6379/1 + - AGENT_MODE=admin + - ALLOW_CREATE_TICKET=true + - TZ=UTC + depends_on: + llama: + condition: service_healthy + chroma: + condition: service_started + postgres: + condition: service_healthy + redis: + condition: service_healthy + deploy: + resources: + limits: + memory: 2G + restart: unless-stopped + networks: + - helpdesk-net + + # ═══════════════════════════════════════════════════ + # ChromaDB (Knowledge Base Vector Store) + # ═══════════════════════════════════════════════════ + chroma: + image: chromadb/chroma:0.5.23 + container_name: helpdesk-chroma + ports: + - "127.0.0.1:8000:8000" + volumes: + - chroma-data:/chroma/chroma + environment: + - CHROMA_SERVER_HOST=0.0.0.0 + - CHROMA_SERVER_PORT=8000 + - PERSIST_DIRECTORY=/chroma/chroma + - ALLOW_RESET=true + - CHROMA_AUTH_TOKEN=${CHROMA_AUTH_TOKEN:-chromadb_token_change_me} + deploy: + resources: + limits: + memory: 2G + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-sf", "http://127.0.0.1:8000/api/v1/heartbeat"] + interval: 30s + timeout: 10s + retries: 3 + networks: + - helpdesk-net + + # ═══════════════════════════════════════════════════ + # SearXNG (Self-hosted Web Search) + # ═══════════════════════════════════════════════════ + searxng: + image: searxng/searxng:latest + container_name: helpdesk-searxng + ports: + - "127.0.0.1:8888:8080" + volumes: + - searxng-data:/etc/searxng + - ./config/searxng-settings.yml:/etc/searxng/settings.yml:ro + environment: + - SEARXNG_BASE_URL=http://localhost:8888/ + - SEARXNG_BIND_ADDRESS=0.0.0.0 + deploy: + resources: + limits: + memory: 1G + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-sf", "http://127.0.0.1:8888/search?q=test&format=json"] + interval: 60s + timeout: 10s + retries: 3 + networks: + - helpdesk-net + + # ═══════════════════════════════════════════════════ + # n8n (Workflow Automation) + # ═══════════════════════════════════════════════════ + n8n: + image: n8nio/n8n:latest + container_name: helpdesk-n8n + ports: + - "127.0.0.1:5678:5678" + volumes: + - n8n-data:/home/node/.n8n + - ./workflows:/home/node/.n8n/workflows:ro + environment: + - N8N_HOST=localhost + - N8N_PORT=5678 + - N8N_PROTOCOL=http + - WEBHOOK_URL=http://localhost:5678/ + - GENERIC_TIMEZONE=UTC + - N8N_CUSTOM_ENDPOINTS=webhook + - N8N_USER_MANAGEMENT_JWT_SECRET=${JWT_SECRET:-jwt_secret_change_me_please} + - N8N_DEFAULT_BINARY_DATA_MODE=filesystem + deploy: + resources: + limits: + memory: 1G + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-sf", "http://127.0.0.1:5678/healthz"] + interval: 30s + timeout: 10s + retries: 3 + networks: + - helpdesk-net + + # ═══════════════════════════════════════════════════ + # PostgreSQL (Ticket Database + Agent State) + # ═══════════════════════════════════════════════════ + postgres: + image: postgres:16-alpine + container_name: helpdesk-postgres + ports: + - "127.0.0.1:5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql:ro + environment: + - POSTGRES_DB=helpdesk + - POSTGRES_USER=helpdesk + - POSTGRES_PASSWORD=${DB_PASSWORD:-helpdesk_pass} + - POSTGRES_INITDB_ARGS=--encoding=UTF8 --lc-collate=C --lc-ctype=C + deploy: + resources: + limits: + memory: 1G + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U helpdesk"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - helpdesk-net + + # ═══════════════════════════════════════════════════ + # Redis (Caching + Session Store + Rate Limiting) + # ═══════════════════════════════════════════════════ + redis: + image: redis:7-alpine + container_name: helpdesk-redis + ports: + - "127.0.0.1:6379:6379" + volumes: + - redis-data:/data + command: > + redis-server + --requirepass ${REDIS_PASSWORD:-redis_pass} + --maxmemory 256mb + --maxmemory-policy allkeys-lru + --appendonly yes + --appendfsync everysec + deploy: + resources: + limits: + memory: 300M + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redis_pass}", "ping"] + interval: 10s + timeout: 5s + retries: 3 + networks: + - helpdesk-net + + # ═══════════════════════════════════════════════════ + # Nginx (Reverse Proxy + Security Headers + Rate Limiting) + # ═══════════════════════════════════════════════════ + nginx: + image: nginx:alpine + container_name: helpdesk-nginx + ports: + - "80:80" + - "443:443" + volumes: + - ./config/nginx.conf:/etc/nginx/nginx.conf:ro + - ./admin:/app/admin:ro + - ./certs:/etc/nginx/certs:ro + - nginx-cache:/var/cache/nginx + depends_on: + - helpdesk-agent + - admin-agent + - n8n + - searxng + deploy: + resources: + limits: + memory: 200M + restart: unless-stopped + networks: + - helpdesk-net + + # ═══════════════════════════════════════════════════ + # Email Fetcher (IMAP → Ticket via helpdesk-agent) + # ═══════════════════════════════════════════════════ + email-fetcher: + build: + context: . + dockerfile: Dockerfile.email + container_name: helpdesk-email-fetcher + volumes: + - ./config:/app/config:ro + - ./ticket_platforms:/app/ticket_platforms:ro + - email-queue:/app/queue + environment: + - IMAP_HOST=${IMAP_HOST:-} + - IMAP_PORT=${IMAP_PORT:-993} + - IMAP_USER=${IMAP_USER:-} + - IMAP_PASSWORD=${IMAP_PASSWORD:-} + - IMAP_FOLDER=INBOX + - POLL_INTERVAL=${POLL_INTERVAL:-60} + - TICKET_PLATFORM=${TICKET_PLATFORM:-osticket} + - HELPDESK_AGENT_URL=http://helpdesk-agent:8080 + - POSTGRES_URL=postgresql://helpdesk:${DB_PASSWORD:-helpdesk_pass}@postgres:5432/helpdesk + - TZ=UTC + depends_on: + helpdesk-agent: + condition: service_started + postgres: + condition: service_healthy + deploy: + resources: + limits: + memory: 500M + restart: unless-stopped + networks: + - helpdesk-net + +# ═══════════════════════════════════════════════════ +# WhatsApp Webhook Receiver +# ═══════════════════════════════════════════════════ +whatsapp-webhook: + build: + context: . + dockerfile: Dockerfile.whatsapp + container_name: helpdesk-whatsapp + ports: + - "127.0.0.1:9090:9090" + - "0.0.0.0:8383:8383" + volumes: + - ./config:/app/config:ro + - ./scripts:/app/scripts:ro + environment: + - WHATSAPP_TOKEN=${WHATSAPP_TOKEN} + - WHATSAPP_PHONE_NUMBER_ID=${WHATSAPP_PHONE_NUMBER_ID} + - WHATSAPP_WEBHOOK_SECRET=${WHATSAPP_WEBHOOK_SECRET:-change_me} + - ADMIN_PHONE_NUMBER=${ADMIN_PHONE_NUMBER} + - HELPDESK_AGENT_URL=http://helpdesk-agent:8080 + - REDIS_URL=redis://:${REDIS_PASSWORD:-redis_pass}@redis:6379/0 + - WHATSAPP_RATE_LIMIT_PER_MINUTE=${WHATSAPP_RATE_LIMIT_PER_MINUTE:-10} + depends_on: + helpdesk-agent: + condition: service_started + redis: + condition: service_healthy + deploy: + resources: + limits: + memory: 500M + restart: unless-stopped + networks: + - helpdesk-net + +# ═══════════════════════════════════════════════════ +# Health Monitor +# ═══════════════════════════════════════════════════ +health-monitor: + build: + context: . + dockerfile: Dockerfile + container_name: helpdesk-health + volumes: + - ./scripts:/app/scripts:ro + - ./config:/app/config:ro + environment: + - REDIS_URL=redis://:${REDIS_PASSWORD:-redis_pass}@redis:6379/0 + depends_on: + redis: + condition: service_healthy + deploy: + resources: + limits: + memory: 200M + restart: unless-stopped + networks: + - helpdesk-net + +# ═══════════════════════════════════════════════════ +# Tools UI (Widget + Admin Panel) +# ═══════════════════════════════════════════════════ +tools-ui: + build: + context: tools-ui + dockerfile: Dockerfile + container_name: helpdesk-tools-ui + ports: + - "127.0.0.1:8484:8484" + depends_on: + - helpdesk-agent + - admin-agent + deploy: + resources: + limits: + memory: 200M + restart: unless-stopped + networks: + - helpdesk-net + +# ═══════════════════════════════════════════════════ +# Networks +# ═══════════════════════════════════════════════════ +networks: + helpdesk-net: + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/16 + +# ═══════════════════════════════════════════════════ +# Volumes +# ═══════════════════════════════════════════════════ +volumes: + hermes-data: + driver: local + hermes-admin-data: + driver: local + chroma-data: + driver: local + searxng-data: + driver: local + n8n-data: + driver: local + postgres-data: + driver: local + redis-data: + driver: local + nginx-cache: + driver: local + email-queue: + driver: local diff --git a/helpdesk-agent-diagram-guide.html b/helpdesk-agent-diagram-guide.html index a8635f7..974714f 100644 --- a/helpdesk-agent-diagram-guide.html +++ b/helpdesk-agent-diagram-guide.html @@ -24,8 +24,6 @@ h2 { color: var(--cyan); font-size: 1.4rem; margin-top: 28px; border-bottom: 1px solid var(--border); padding-bottom: 6px; } h3 { color: var(--yellow); font-size: 1.1rem; margin-top: 20px; } .lead { color: #9aa3ad; font-size: 1.05rem; margin-bottom: 22px; } - - /* Diagram */ .diagram-wrap { background: var(--panel); border: 1px solid var(--border); border-radius: 14px; padding: 22px; margin: 22px 0; overflow-x: auto; } .layer { display: flex; justify-content: center; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; } .layer-label { width: 100%; text-align: center; color: var(--cyan-dim); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.12em; margin: 6px 0 2px; } @@ -39,8 +37,6 @@ .node.channel strong { color: var(--cyan); } .node .note { font-size: 0.75rem; color: #8b95a0; margin-top: 6px; } .arrow { width: 2px; height: 14px; background: #2a2f3a; margin: 0 auto; } - - /* Panels */ .panel { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 18px; margin: 14px 0; } .panel .meta { color: var(--cyan-dim); font-size: 0.8rem; margin-top: 6px; } code { background: #0b0c10; padding: 2px 6px; border-radius: 4px; color: var(--yellow); font-size: 0.9em; } @@ -54,9 +50,6 @@ .nav { display: flex; gap: 10px; flex-wrap: wrap; margin: 14px 0; } .nav a { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; font-size: 0.88rem; } .nav a:hover { border-color: var(--cyan); } - .collapse { border: 1px solid var(--border); border-radius: 10px; padding: 10px 14px; margin: 10px 0; background: var(--panel); cursor: pointer; } - .collapse + .hidden { display: none; } - .hidden.open { display: block; } .callout { border-left: 3px solid var(--yellow); padding: 10px 14px; background: #14202b; border-radius: 0 8px 8px 0; margin: 12px 0; } .kbd { background: #0b0c10; border: 1px solid var(--border); padding: 2px 6px; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Ubuntu Mono, monospace; } table { width: 100%; border-collapse: collapse; margin: 12px 0; } @@ -75,9 +68,10 @@

🔧 Self-Hosted Help Desk Agent

VM Setup Architecture Diagram Stack - osTicket + Ticket Platforms Memory Model + Admin Dashboard Security Operations Checklist @@ -120,6 +114,7 @@

🔧 Self-Hosted Help Desk Agent

Telegram
BotFather bot / webhook
WhatsApp
baileys / whatsapp-web.js session
iMessage
macOS relay recommended
+
Email
IMAP poller → ticket
@@ -133,7 +128,7 @@

🔧 Self-Hosted Help Desk Agent

SearXNG
Self-hosted web search
KB / Wiki
Markdown docs, how-tos
-
Ticketing
SQLite-native or Zammad/osTicket API
+
Ticketing
osTicket / Zammad / email via registry
Memory
Conversation + ticket state
@@ -152,32 +147,39 @@

🔧 Self-Hosted Help Desk Agent

- + - + +
AreaRecommended defaultAlternative
MessagingTelegram firstWhatsapp via baileys (higher ops risk)
TicketingHermes-native SQLite “ticket tool”Zammad or osTicket via API
TicketingAdapter registry: osTicket, Zammad, emailAdd new backends without changing Hermes core
SearchSearXNGSingle DuckDuckGo scraper (weaker)
KBLocal markdown + ripgrep/WhooshStatic web wiki
Model runtimeRemote API or small quantized modelLocal LLM (heavy CPU)
Model runtimellama.cpp local 64kRemote API or small quantized model
AdminSelf-hosted dashboardExternal logging SaaS
Auth/Rate limitAllowlist + per-user rate limitingOTP / invite codes
-
- osTicket Integration -
Wire osTicket into Hermes via its REST API.
-
# Hermes config
-tools:
-  osticket:
-    base_url: "https://helpdesk.your-domain.com"
-    api_key: "YOUR_OSTICKET_API_KEY"
-    default_dept_id: 1
-    default_priority: "low"
-    rate_limit_per_min: 5
-

Tool calls

- +
+ Multi-Platform Ticketing +
Hermes talks to a registry of ticket adapters. Add a platform once; swap it everywhere by config.
+ + + + + +
PlatformTypeStatusNotes
osTicketTicketingImplementedREST API wrapper in ticket_platforms/osticket.py
ZammadTicketingImplementedREST API v1 wrapper in ticket_platforms/zammad.py
Email → TicketIngestionImplementedIMAP poller bridge in ticket_platforms/email.py
+

Registry usage

+
from ticket_platforms.registry import get
+
+Platform = get("osticket")
+platform = Platform(base_url="...", api_key="...")
+platform.create_ticket(user_id="u1", subject="VPN", body="I can't log in.")
+

Env-based config

+
OSTICKET_BASE_URL=https://helpdesk.example.com
+OSTICKET_API_KEY=...
+ZAMMAD_BASE_URL=https://zammad.example.com
+ZAMMAD_API_TOKEN=...
+EMAIL_IMAP_HOST=imap.example.com
+EMAIL_IMAP_USER=helpdesk@example.com
+EMAIL_IMAP_PASSWORD=...
+EMAIL_TICKET_PLATFORM=osticket

PII rules

+
+ Admin Dashboard — Live Cost Tracker +
Shows what this stack would cost if replaced by hosted SaaS equivalents. Open admin/admin-dashboard.html.
+

What admin sees

+ + + + + + +
WidgetData
TicketsOpen / closed counts
SessionsUnique users / conversations
Avg responseEnd-to-end bot latency
Monthly / yearly savedLive vs hosted baseline
+

Pricing basis

+ + + + + + + + + + +
Hosted equivalentMonthly estimate
LLM inference (64k)~$120–$240
Web search API~$25–$50
Embeddings / memory~$3–$8
Telegram / WhatsApp bridge SaaS~$80–$160
Hosted ticketing~$60–$150
Total~$288–$608 / month
Self-hosted software$0
Net saved~$300–$600 / month
+

Cost model formula

+
def compute_monthly_savings(prompts, completions, searches, agents=1):
+    llm = (prompts * 2.5 + completions * 10) / 1_000_000
+    search = searches * 0.005
+    seats = agents * 29
+    hosted = llm + search + seats + 80
+    return max(hosted, 0)
+

How to run

+
cd /home//helpdesk-agent/admin
+python3 -m http.server 8081
+# Open http://localhost:8081/admin-dashboard.html
+
+
Security Model
This agent will be public-facing. Treat it like a shared service from day one.
@@ -272,15 +310,31 @@

Hermes config for llama.cpp

Runbook Essentials
-

Onboarding

-
# create venv
-python3.11 -m venv ~/hermes-venv
-source ~/hermes-venv/bin/activate
-
-# install Hermes
-git clone https://github.com/NousResearch/Hermes.git
-cd Hermes
-pip install -e .
+

Ticket adapter registry

+
from ticket_platforms.registry import get
+platform = get("osticket")
+platform.create_ticket(...)
+
+
+

Email ingestion

+
from ticket_platforms.email import EmailTicketAdapter
+adapter = EmailTicketAdapter(
+    imap_host="imap.example.com",
+    imap_port=993,
+    username="...",
+    password="...",
+    ticket_platform="osticket",
+)
+adapter.sync_unread()
+
+
+
+
+

Stack services

+
docker compose -f compose/docker-compose.yml up -d
+docker compose -f compose/docker-compose.selfhosted.yml up -d
+docker compose -f compose/docker-compose.knowledge.yml up -d
+docker compose -f compose/docker-compose.wiki.yml up -d

Systemd

@@ -304,9 +358,10 @@

Systemd

  • VM provisioned and SSH key-only access enabled
  • UFW enabled (22/80/443 only)
  • -
  • Domain + TLS via Let’s Encrypt or Caddy
  • +
  • Domain + TLS via Let's Encrypt or Caddy
  • Hermes installed + systemd unit running + logs clean
  • Telegram bot configured and verified
  • +
  • osTicket / Zammad / email adapter configured
  • At least one ticket created and closed via agent
  • SearXNG up and responding on a subpath
  • Backup schedule for DB + configs defined and tested
  • 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 new file mode 100644 index 0000000..457d7bc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,23 @@ +# Core dependencies +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +pydantic==2.10.3 +PyYAML==6.0.2 +redis==5.2.1 +asyncpg==0.30.0 +psycopg2-binary==2.9.10 +httpx==0.28.1 +python-multipart==0.0.31 + +# LLM / OpenAI-compatible client +openai==1.59.2 + +# Knowledge base +chromadb==0.5.23 + +# Email processing +imaplib2==3.6 + +# Utilities +python-dotenv==1.2.2 +uuid6==2024.7.10 diff --git a/scripts/agent_server.py b/scripts/agent_server.py new file mode 100644 index 0000000..f6478ee --- /dev/null +++ b/scripts/agent_server.py @@ -0,0 +1,209 @@ +""" +FastAPI Agent Server for Helpdesk Agent +Main entry point for the helpdesk agent API. +""" +from __future__ import annotations + +import logging +import os +import time + +import httpx +import redis.asyncio as redis +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field +from rate_limiter import RateLimitConfig, RateLimiter +from session_manager import SessionManager + +# ═══════════════════════════════════════════════════ +# Configuration +# ═══════════════════════════════════════════════════ + +AGENT_MODE = os.getenv("AGENT_MODE", "helpdesk") +AGENT_NAME = os.getenv("AGENT_NAME", "J1 Helpdesk Agent") +LLM_API_BASE = os.getenv("LLM_API_BASE", "http://llama:8081/v1") +LLM_MODEL = os.getenv("LLM_MODEL", "qwen2.5-7b-instruct") +CHROMA_URL = os.getenv("CHROMA_URL", "http://chroma:8000") +SEARX_URL = os.getenv("SEARX_URL", "http://searxng:8080") +REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") +POSTGRES_URL = os.getenv("POSTGRES_URL", "postgresql://helpdesk:helpdesk@postgres:5432/helpdesk") +ALLOW_CREATE_TICKET = os.getenv("ALLOW_CREATE_TICKET", "false").lower() == "true" + +rate_config = RateLimitConfig( + max_requests_per_session=int(os.getenv("RATE_LIMIT_PER_SESSION", "50")), + window_seconds=int(os.getenv("RATE_LIMIT_WINDOW", "3600")), + max_message_length=int(os.getenv("MAX_MESSAGE_LENGTH", "4000")), + max_session_duration=int(os.getenv("MAX_SESSION_DURATION", "7200")), +) + +# ═══════════════════════════════════════════════════ +# Logging +# ═══════════════════════════════════════════════════ + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger("agent_server") + +# ═══════════════════════════════════════════════════ +# App +# ═══════════════════════════════════════════════════ + +app = FastAPI( + title=f"{AGENT_NAME} API", + description="Self-hosted AI helpdesk agent", + version="1.0.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +# Global clients +redis_client: redis.Redis | None = None +rate_limiter: RateLimiter | None = None +session_manager: SessionManager | None = None + + +@app.on_event("startup") +async def startup(): + global redis_client, rate_limiter, session_manager + redis_client = redis.from_url(REDIS_URL, decode_responses=True) + rate_limiter = RateLimiter(rate_config, redis_client) + session_manager = SessionManager(redis_client, max_duration=rate_config.max_session_duration) + logger.info(f"{AGENT_NAME} started in {AGENT_MODE} mode") + + +@app.on_event("shutdown") +async def shutdown(): + if redis_client: + await redis_client.close() + + +# ═══════════════════════════════════════════════════ +# Models +# ═══════════════════════════════════════════════════ + +class ChatRequest(BaseModel): + session_id: str | None = None + user_id: str + message: str = Field(..., max_length=4000) + platform: str = "web" + + +class ChatResponse(BaseModel): + session_id: str + response: str + remaining_requests: int + session_expires_in: int + + +class HealthResponse(BaseModel): + status: str + agent_mode: str + version: str + timestamp: float + + +# ═══════════════════════════════════════════════════ +# Endpoints +# ═══════════════════════════════════════════════════ + +@app.get("/health", response_model=HealthResponse) +async def health(): + return HealthResponse( + status="ok", + agent_mode=AGENT_MODE, + version="1.0.0", + timestamp=time.time(), + ) + + +@app.post("/chat", response_model=ChatResponse) +async def chat(req: ChatRequest): + # Rate limit check + session_id = req.session_id or f"new-{req.user_id}-{int(time.time())}" + rate_check = rate_limiter.check_request(session_id, req.user_id, len(req.message)) + + if not rate_check["allowed"]: + raise HTTPException(status_code=429, detail=rate_check["reason"]) + + # Create session if new + if not req.session_id: + await session_manager.create_session(req.user_id, req.platform) + session_id = req.user_id # Use user_id as session key for simplicity + + # Call LLM + try: + async with httpx.AsyncClient(timeout=120) as client: + llm_response = await client.post( + f"{LLM_API_BASE}/chat/completions", + json={ + "model": LLM_MODEL, + "messages": [ + {"role": "system", "content": get_system_prompt()}, + {"role": "user", "content": req.message}, + ], + "max_tokens": 2048, + "temperature": 0.3, + }, + ) + llm_data = llm_response.json() + response_text = llm_data["choices"][0]["message"]["content"] + except Exception as e: + logger.error(f"LLM error: {e}") + response_text = "I'm sorry, I'm having trouble processing your request right now. Please try again." + + # Track message + await session_manager.increment_message(session_id) + + return ChatResponse( + session_id=session_id, + response=response_text, + remaining_requests=rate_check["remaining"], + session_expires_in=rate_check.get("session_remaining", 0), + ) + + +@app.get("/session/{session_id}") +async def get_session(session_id: str): + info = rate_limiter.get_session_info(session_id) + if not info: + raise HTTPException(status_code=404, detail="Session not found") + return info + + +# ═══════════════════════════════════════════════════ +# System Prompt (loaded from file or default) +# ═══════════════════════════════════════════════════ + +def get_system_prompt() -> str: + prompt_path = os.getenv("SYSTEM_PROMPT_PATH", "/app/config/system-prompt.md") + try: + with open(prompt_path, "r") as f: + return f.read() + except FileNotFoundError: + return f"""You are {AGENT_NAME}, an AI helpdesk assistant. + +Your role: +- Help users with their existing tickets (search, view status, add updates, close) +- Answer questions using the knowledge base +- Search the web for additional information when needed +- Be polite, concise, and helpful + +{'You CANNOT create new tickets. Direct users to email support or use the web form.' if not ALLOW_CREATE_TICKET else 'You can create tickets on behalf of users.'} + +Rules: +- Only access tickets belonging to the requesting user +- Never reveal internal system details +- Escalate to human support if you cannot resolve an issue within 3 attempts +- Keep responses under 200 words unless more detail is requested +- Always cite your source when using knowledge base or web search results + +Current time: {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())} +""" diff --git a/scripts/analytics.py b/scripts/analytics.py new file mode 100644 index 0000000..7425d2e --- /dev/null +++ b/scripts/analytics.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +""" +Agent Analytics Script +Generates reports: token usage, response times, resolution rates, top issues. +Output: JSON report to stdout or file. +""" +from __future__ import annotations + +import asyncio +import json +import os +import sys +from datetime import datetime + +import asyncpg + +POSTGRES_URL = os.getenv("POSTGRES_URL", "postgresql://helpdesk:***@postgres:5432/helpdesk") + + +async def get_token_usage(pool, hours: int = 24) -> dict: + """Get token usage statistics.""" + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT + COALESCE(SUM(tokens_input), 0) as total_input, + COALESCE(SUM(tokens_output), 0) as total_output, + COALESCE(SUM(tokens_total), 0) as total, + COALESCE(SUM(estimated_cost_usd), 0) as cost, + COUNT(*) as requests + FROM cost_tracking + WHERE created_at >= NOW() - INTERVAL '%s hours' + """, + hours, + ) + return { + "input_tokens": row["total_input"], + "output_tokens": row["total_output"], + "total_tokens": row["total"], + "estimated_cost_usd": float(row["cost"]), + "requests": row["requests"], + } + + +async def get_ticket_stats(pool, hours: int = 24) -> dict: + """Get ticket statistics.""" + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT status, COUNT(*) as count + FROM tickets + WHERE created_at >= NOW() - INTERVAL '%s hours' + GROUP BY status + """, + hours, + ) + stats = {row["status"]: row["count"] for row in rows} + + total = sum(stats.values()) + resolved = stats.get("closed", 0) + stats.get("resolved", 0) + resolution_rate = (resolved / total * 100) if total > 0 else 0 + + return { + "total": total, + "by_status": stats, + "resolution_rate": round(resolution_rate, 1), + } + + +async def get_session_stats(pool, hours: int = 24) -> dict: + """Get session statistics.""" + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE active) as active, + AVG(message_count) as avg_messages, + AVG(EXTRACT(EPOCH FROM (expires_at - started_at))) as avg_duration_seconds + FROM sessions + WHERE started_at >= NOW() - INTERVAL '%s hours' + """, + hours, + ) + return { + "total_sessions": row["total"], + "active_sessions": row["active"], + "avg_messages_per_session": round(float(row["avg_messages"] or 0), 1), + "avg_duration_minutes": round(float(row["avg_duration_seconds"] or 0) / 60, 1), + } + + +async def get_rate_limit_stats(pool, hours: int = 24) -> dict: + """Get rate limit hit statistics.""" + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT COUNT(*) as hits + FROM audit_log + WHERE action = 'rate_limit_exceeded' + AND created_at >= NOW() - INTERVAL '%s hours' + """, + hours, + ) + return {"rate_limit_hits": row["hits"]} + + +async def get_top_issues(pool, hours: int = 168, limit: int = 10) -> list: + """Get most common issue categories from audit log.""" + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT details->>'category' as category, COUNT(*) as count + FROM audit_log + WHERE action = 'ticket_created' + AND created_at >= NOW() - INTERVAL '%s hours' + AND details->>'category' IS NOT NULL + GROUP BY category + ORDER BY count DESC + LIMIT %s + """, + hours, + limit, + ) + return [{"category": row["category"], "count": row["count"]} for row in rows] + + +async def generate_report(hours: int = 24) -> dict: + """Generate full analytics report.""" + pool = await asyncpg.create_pool(POSTGRES_URL, min_size=1, max_size=5) + if not pool: + print("ERROR: Cannot connect to PostgreSQL", file=sys.stderr) + sys.exit(1) + + try: + tokens = await get_token_usage(pool, hours) + tickets = await get_ticket_stats(pool, hours) + sessions = await get_session_stats(pool, hours) + rate_limits = await get_rate_limit_stats(pool, hours) + top_issues = await get_top_issues(pool, hours) + + report = { + "generated_at": datetime.utcnow().isoformat() + "Z", + "period_hours": hours, + "tokens": tokens, + "tickets": tickets, + "sessions": sessions, + "rate_limits": rate_limits, + "top_issues": top_issues, + } + return report + finally: + await pool.close() + + +def main(): + import argparse + + parser = argparse.ArgumentParser(description="Helpdesk Agent Analytics") + parser.add_argument("--hours", type=int, default=24, help="Report period in hours") + parser.add_argument("--output", type=str, default=None, help="Output file (default: stdout)") + parser.add_argument("--format", choices=["json", "text", "markdown"], default="text") + args = parser.parse_args() + + report = asyncio.run(generate_report(args.hours)) + + if args.format == "json": + output = json.dumps(report, indent=2) + elif args.format == "markdown": + output = format_markdown(report) + else: + output = format_text(report) + + if args.output: + with open(args.output, "w") as f: + f.write(output) + print(f"Report saved to {args.output}") + else: + print(output) + + +def format_text(report: dict) -> str: + """Format report as colored text.""" + lines = [ + "═══════════════════════════════════════════", + f" Helpdesk Analytics ({report['period_hours']}h)", + "═══════════════════════════════════════════", + "", + f"📊 Tokens: {report['tokens']['total_tokens']:,} ({report['tokens']['requests']} requests)", + f" Input: {report['tokens']['input_tokens']:,}", + f" Output: {report['tokens']['output_tokens']:,}", + f" Cost: ${report['tokens']['estimated_cost_usd']:.4f}", + "", + f"🎫 Tickets: {report['tickets']['total']} total", + ] + for status, count in report["tickets"].get("by_status", {}).items(): + lines.append(f" {status}: {count}") + lines.append(f" Resolution rate: {report['tickets']['resolution_rate']}%") + lines.extend([ + "", + f"💬 Sessions: {report['sessions']['total_sessions']} ({report['sessions']['active_sessions']} active)", + f" Avg messages: {report['sessions']['avg_messages_per_session']}", + f" Avg duration: {report['sessions']['avg_duration_minutes']} min", + "", + f"🛡️ Rate limit hits: {report['rate_limits']['rate_limit_hits']}", + "", + ]) + if report.get("top_issues"): + lines.append("🔥 Top Issues:") + for issue in report["top_issues"][:5]: + lines.append(f" {issue['category']}: {issue['count']}") + lines.append("") + return "\n".join(lines) + + +def format_markdown(report: dict) -> str: + """Format report as markdown.""" + lines = [ + f"# Helpdesk Analytics Report ({report['period_hours']}h)", + "", + f"Generated: {report['generated_at']}", + "", + "## Token Usage", + f"- **Total tokens:** {report['tokens']['total_tokens']:,}", + f"- **Input:** {report['tokens']['input_tokens']:,}", + f"- **Output:** {report['tokens']['output_tokens']:,}", + f"- **Estimated cost:** ${report['tokens']['estimated_cost_usd']:.4f}", + f"- **Requests:** {report['tokens']['requests']}", + "", + "## Tickets", + f"- **Total:** {report['tickets']['total']}", + f"- **Resolution rate:** {report['tickets']['resolution_rate']}%", + ] + for status, count in report["tickets"].get("by_status", {}).items(): + lines.append(f"- **{status.capitalize()}:** {count}") + lines.extend([ + "", + "## Sessions", + f"- **Total:** {report['sessions']['total_sessions']}", + f"- **Active:** {report['sessions']['active_sessions']}", + f"- **Avg messages:** {report['sessions']['avg_messages_per_session']}", + f"- **Avg duration:** {report['sessions']['avg_duration_minutes']} min", + "", + "## Rate Limiting", + f"- **Hits:** {report['rate_limits']['rate_limit_hits']}", + ]) + if report.get("top_issues"): + lines.extend(["", "## Top Issues"]) + for issue in report["top_issues"][:5]: + lines.append(f"- {issue['category']}: {issue['count']}") + return "\n".join(lines) + + +if __name__ == "__main__": + main() diff --git a/scripts/email_fetcher.py b/scripts/email_fetcher.py new file mode 100644 index 0000000..772458c --- /dev/null +++ b/scripts/email_fetcher.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Email Fetcher Service +Polls IMAP inbox and creates tickets via helpdesk agent API. +""" +from __future__ import annotations + +import email +import imaplib +import logging +import os +import time +import uuid +from email.header import decode_header + +import httpx + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s") +logger = logging.getLogger("email_fetcher") + +# Config +IMAP_HOST = os.getenv("IMAP_HOST", "") +IMAP_PORT = int(os.getenv("IMAP_PORT", "993")) +IMAP_USER = os.getenv("IMAP_USER", "") +IMAP_PASSWORD = os.getenv("IMAP_PASSWORD", "") +IMAP_FOLDER = os.getenv("IMAP_FOLDER", "INBOX") +POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "60")) +TICKET_PLATFORM = os.getenv("TICKET_PLATFORM", "osticket") +HELPDESK_AGENT_URL = os.getenv("HELPDESK_AGENT_URL", "http://helpdesk-agent:8080") + +# Optional: PostgreSQL for ticket caching +POSTGRES_URL = os.getenv("POSTGRES_URL", "") + + +def decode_mime_header(header_value: str) -> str: + """Decode MIME encoded header.""" + if not header_value: + return "" + parts = decode_header(header_value) + decoded = [] + for part, charset in parts: + if isinstance(part, bytes): + decoded.append(part.decode(charset or "utf-8", errors="replace")) + else: + decoded.append(part) + return " ".join(decoded) + + +def extract_email_body(msg) -> str: + """Extract plain text body from email message.""" + body = "" + if msg.is_multipart(): + for part in msg.walk(): + content_type = part.get_content_type() + if content_type == "text/plain": + try: + body = part.get_payload(decode=True).decode("utf-8", errors="replace") + except Exception: + body = str(part.get_payload()) + break + else: + try: + body = msg.get_payload(decode=True).decode("utf-8", errors="replace") + except Exception: + body = str(msg.get_payload()) + return body[:10000] # Limit to 10KB + + +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)") + if status != "OK": + return None + + msg = email.message_from_bytes(data[0][1]) + subject = decode_mime_header(msg.get("Subject", "No Subject")) + from_addr = decode_mime_header(msg.get("From", "unknown@unknown.com")) + message_id = msg.get("Message-ID", str(uuid.uuid4())) + body = extract_email_body(msg) + + # Extract email address + email_addr = from_addr + if "<" in from_addr and ">" in from_addr: + email_addr = from_addr.split("<")[1].split(">")[0] + + logger.info(f"Processing email: {subject[:50]} from {email_addr}") + + # Create ticket via helpdesk agent API + ticket_data = { + "subject": subject, + "body": body, + "user_email": email_addr, + "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) + if resp.status_code == 200: + result = resp.json() + logger.info(f"Ticket created: {result.get('ticket_id')}") + return result + else: + logger.warning(f"Ticket creation failed: {resp.status_code} {resp.text[:200]}") + return None + + except Exception as e: + logger.error(f"Error processing email {num}: {e}") + return None + + +def poll_loop(): + """Main polling loop.""" + logger.info(f"Email fetcher started. Polling {IMAP_HOST}:{IMAP_PORT}/{IMAP_FOLDER} every {POLL_INTERVAL}s") + + while True: + try: + mail = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT) + mail.login(IMAP_USER, IMAP_PASSWORD) + mail.select(IMAP_FOLDER) + + # Search for unread emails + status, messages = mail.search(None, "(UNSEEN)") + if status == "OK" and messages[0]: + email_ids = messages[0].split() + logger.info(f"Found {len(email_ids)} unread emails") + + for num in email_ids: + result = process_email(mail, num) + if result: + # Mark as read + mail.store(num, "+FLAGS", "\\Seen") + + mail.logout() + logger.info(f"Poll complete. Sleeping {POLL_INTERVAL}s...") + + except imaplib.IMAP4.error as e: + logger.error(f"IMAP error: {e}") + except Exception as e: + logger.error(f"Unexpected error: {e}") + + time.sleep(POLL_INTERVAL) + + +if __name__ == "__main__": + if not IMAP_HOST or not IMAP_USER or not IMAP_PASSWORD: + logger.error("IMAP_HOST, IMAP_USER, and IMAP_PASSWORD must be set") + exit(1) + poll_loop() diff --git a/scripts/health_monitor.py b/scripts/health_monitor.py new file mode 100644 index 0000000..a2d751f --- /dev/null +++ b/scripts/health_monitor.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +""" +Health Monitoring Service +Collects metrics from all services and exposes them for the dashboard. +""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +import time + +import httpx +import redis.asyncio as redis + +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") + +SERVICES = { + "llama": "http://llama:8081/health", + "helpdesk-agent": "http://helpdesk-agent:8080/health", + "admin-agent": "http://admin-agent:8082/health", + "chroma": "http://chroma:8000/api/v1/heartbeat", + "searxng": "http://searxng:8080/search?q=test&format=json", + "n8n": "http://n8n:5678/healthz", + "email-fetcher": None, # No health endpoint, check process +} + + +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} + + start = time.time() + try: + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.get(url) + latency = (time.time() - start) * 1000 + healthy = resp.status_code == 200 + return { + "name": name, + "status": "healthy" if healthy else "degraded", + "latency_ms": round(latency, 1), + "status_code": resp.status_code, + } + except Exception as e: + latency = (time.time() - start) * 1000 + return { + "name": name, + "status": "unhealthy", + "latency_ms": round(latency, 1), + "error": str(e), + } + + +async def collect_metrics(redis_client: redis.Redis) -> dict: + """Collect all metrics.""" + # Service health checks + results = [] + for name, url in SERVICES.items(): + result = await check_service(name, url) + results.append(result) + + # Redis stats + try: + info = await redis_client.info("memory") + redis_memory = info.get("used_memory_human", "unknown") + redis_keys = await redis_client.dbsize() + except Exception: + redis_memory = "unknown" + redis_keys = 0 + + # Active sessions + 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 + except Exception: + active_sessions = 0 + + healthy_count = sum(1 for r in results if r.get("status") == "healthy") + total_count = len(results) + + return { + "timestamp": time.time(), + "overall_status": "healthy" if healthy_count == total_count else "degraded" if healthy_count > total_count / 2 else "critical", + "services": results, + "summary": { + "healthy": healthy_count, + "total": total_count, + "active_sessions": active_sessions, + "redis_memory": redis_memory, + "redis_keys": redis_keys, + }, + } + + +async def run_monitor(): + """Main monitoring loop — stores metrics in Redis every 30s.""" + redis_client = redis.from_url(REDIS_URL, decode_responses=True) + logger.info("Health monitor started") + + while True: + try: + metrics = await collect_metrics(redis_client) + # Store in Redis with 1-hour TTL + await redis_client.setex("metrics:latest", 3600, json.dumps(metrics)) + logger.info(f"Metrics collected: {metrics['summary']}") + except Exception as e: + logger.error(f"Error collecting metrics: {e}") + + await asyncio.sleep(30) + + +if __name__ == "__main__": + asyncio.run(run_monitor()) diff --git a/scripts/index_kb.py b/scripts/index_kb.py new file mode 100644 index 0000000..81ac0f9 --- /dev/null +++ b/scripts/index_kb.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +""" +Knowledge Base Indexer +Loads documents from knowledge-base/ into ChromaDB. +""" +from __future__ import annotations + +import hashlib +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: + logger.error("chromadb not installed. Run: pip install chromadb") + sys.exit(1) + +CHROMA_URL = os.getenv("CHROMA_URL", "http://chroma:8000") +KB_DIR = os.getenv("KB_DIR", "./knowledge-base") +COLLECTION_NAME = os.getenv("KB_COLLECTION", "helpdesk-kb") + + +def chunk_text(text: str, chunk_size: int = 1000, overlap: int = 200) -> list[str]: + """Split text into overlapping chunks.""" + chunks = [] + start = 0 + while start < len(text): + end = start + chunk_size + chunks.append(text[start:end]) + start += chunk_size - overlap + return chunks + + +def compute_hash(content: str) -> str: + return hashlib.sha256(content.encode()).hexdigest() + + +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", + ) + + try: + collection = client.get_or_create_collection( + name=COLLECTION_NAME, + embedding_function=embedding_fn, + metadata={"hnsw:space": "cosine"}, + ) + except Exception: + collection = client.get_or_create_collection( + name=COLLECTION_NAME, + metadata={"hnsw:space": "cosine"}, + ) + + kb_path = Path(KB_DIR) + if not kb_path.exists(): + logger.error("Knowledge base directory not found: %s", KB_DIR) + sys.exit(1) + + files = list(kb_path.glob("*.md")) + list(kb_path.glob("*.txt")) + logger.info("Found %d knowledge base files", len(files)) + + total_chunks = 0 + for file_path in files: + content = file_path.read_text(encoding="utf-8") + content_hash = compute_hash(content) + + # Check if already indexed + source_str = str(file_path) + existing = collection.get( + where={"source": source_str}, + limit=1, + ) + if existing.get("ids"): + # Check if content changed + for meta in existing.get("metadatas", []): + if meta and meta.get("hash") == content_hash: + logger.info(" [skip] %s (unchanged)", file_path.name) + break + else: + # Content changed, re-index + collection.delete(where={"source": source_str}) + continue + + # Chunk and index + chunks = chunk_text(content) + ids = [] + documents = [] + metadatas = [] + + for i, chunk in enumerate(chunks): + chunk_id = f"{file_path.stem}_{i}" + ids.append(chunk_id) + documents.append(chunk) + metadatas.append({ + "source": source_str, + "file": file_path.name, + "chunk_index": i, + "hash": content_hash, + }) + + collection.add( + ids=ids, + documents=documents, + metadatas=metadatas, + ) + total_chunks += len(chunks) + logger.info(" [indexed] %s: %d chunks", file_path.name, len(chunks)) + + # Get final count + count = collection.count() + logger.info("\nDone! Indexed %d new chunks. Total in collection: %d", total_chunks, count) + + +if __name__ == "__main__": + main() diff --git a/scripts/init-db.sql b/scripts/init-db.sql new file mode 100644 index 0000000..9de87b4 --- /dev/null +++ b/scripts/init-db.sql @@ -0,0 +1,146 @@ +-- ═══════════════════════════════════════════════════ +-- Helpdesk Agent Database Schema +-- ═══════════════════════════════════════════════════ + +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- ═══════════════════════════════════════════════════ +-- Users +-- ═══════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email VARCHAR(255) UNIQUE NOT NULL, + name VARCHAR(255), + platform VARCHAR(50) DEFAULT 'web', + external_id VARCHAR(255), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_users_email ON users(email); + +-- ═══════════════════════════════════════════════════ +-- Tickets (local cache of external tickets) +-- ═══════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS tickets ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + platform VARCHAR(50) NOT NULL, + external_id VARCHAR(255) NOT NULL, + user_id UUID REFERENCES users(id), + subject TEXT NOT NULL, + body TEXT, + status VARCHAR(50) DEFAULT 'open', + priority VARCHAR(20) DEFAULT 'normal', + assignee VARCHAR(255), + metadata JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + closed_at TIMESTAMP WITH TIME ZONE, + UNIQUE(platform, external_id) +); + +CREATE INDEX idx_tickets_user ON tickets(user_id); +CREATE INDEX idx_tickets_status ON tickets(status); +CREATE INDEX idx_tickets_platform ON tickets(platform); +CREATE INDEX idx_tickets_created ON tickets(created_at DESC); +CREATE INDEX idx_tickets_updated ON tickets(updated_at DESC); + +-- ═══════════════════════════════════════════════════ +-- Sessions +-- ═══════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS sessions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES users(id), + platform VARCHAR(50) DEFAULT 'web', + message_count INTEGER DEFAULT 0, + started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + active BOOLEAN DEFAULT TRUE, + metadata JSONB DEFAULT '{}', + ip_address INET +); + +CREATE INDEX idx_sessions_user ON sessions(user_id); +CREATE INDEX idx_sessions_active ON sessions(active) WHERE active = TRUE; +CREATE INDEX idx_sessions_expires ON sessions(expires_at); + +-- ═══════════════════════════════════════════════════ +-- Rate Limits +-- ═══════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS rate_limits ( + id BIGSERIAL PRIMARY KEY, + session_id UUID REFERENCES sessions(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id), + request_count INTEGER DEFAULT 1, + window_start TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + window_seconds INTEGER DEFAULT 3600 +); + +CREATE INDEX idx_rate_limits_session ON rate_limits(session_id, window_start DESC); +CREATE INDEX idx_rate_limits_user ON rate_limits(user_id, window_start DESC); + +-- ═══════════════════════════════════════════════════ +-- Audit Log +-- ═══════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS audit_log ( + id BIGSERIAL PRIMARY KEY, + session_id UUID, + user_id UUID REFERENCES users(id), + action VARCHAR(100) NOT NULL, + details JSONB DEFAULT '{}', + ip_address INET, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_audit_log_session ON audit_log(session_id); +CREATE INDEX idx_audit_log_user ON audit_log(user_id); +CREATE INDEX idx_audit_log_action ON audit_log(action); +CREATE INDEX idx_audit_log_created ON audit_log(created_at DESC); + +-- ═══════════════════════════════════════════════════ +-- Knowledge Base Tracking +-- ═══════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS kb_tracking ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + source VARCHAR(255) NOT NULL, + content_hash VARCHAR(64) NOT NULL, + metadata JSONB DEFAULT '{}', + indexed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(source, content_hash) +); + +CREATE INDEX idx_kb_source ON kb_tracking(source); + +-- ═══════════════════════════════════════════════════ +-- Cost Tracking +-- ═══════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS cost_tracking ( + id BIGSERIAL PRIMARY KEY, + session_id UUID REFERENCES sessions(id), + user_id UUID REFERENCES users(id), + tokens_input INTEGER DEFAULT 0, + tokens_output INTEGER DEFAULT 0, + tokens_total INTEGER DEFAULT 0, + estimated_cost_usd NUMERIC(10,6) DEFAULT 0, + model VARCHAR(100), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_cost_tracking_session ON cost_tracking(session_id); +CREATE INDEX idx_cost_tracking_date ON cost_tracking(created_at DESC); + +-- ═══════════════════════════════════════════════════ +-- Helper: Update updated_at timestamp +-- ═══════════════════════════════════════════════════ +CREATE OR REPLACE FUNCTION update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +CREATE TRIGGER trg_tickets_updated_at BEFORE UPDATE ON tickets FOR EACH ROW EXECUTE FUNCTION update_updated_at(); diff --git a/scripts/rate_limiter.py b/scripts/rate_limiter.py new file mode 100644 index 0000000..4525186 --- /dev/null +++ b/scripts/rate_limiter.py @@ -0,0 +1,133 @@ +""" +Rate Limiter Middleware for Helpdesk Agent +Enforces per-session request limits and session duration. +""" +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + + +@dataclass +class RateLimitConfig: + max_requests_per_session: int = 50 + window_seconds: int = 3600 + max_message_length: int = 4000 + max_session_duration: int = 7200 # 2 hours + + +@dataclass +class SessionState: + session_id: str + user_id: str + message_count: int = 0 + started_at: float = field(default_factory=time.time) + last_request_at: float = field(default_factory=time.time) + request_count: int = 0 + active: bool = True + + +class RateLimiter: + """In-memory rate limiter with Redis persistence option.""" + + def __init__(self, config: RateLimitConfig, redis_client=None): + self.config = config + self.redis = redis_client + self._sessions: dict[str, SessionState] =() + + def check_request(self, session_id: str, user_id: str, message_length: int = 0) -> dict: + """ + Check if a request is allowed. + Returns: {"allowed": bool, "reason": str, "remaining": int} + """ + now = time.time() + + # Get or create session + state = self._sessions.get(session_id) + if not state: + state = SessionState(session_id=session_id, user_id=user_id) + self._sessions[session_id] = state + + # Check session active + if not state.active: + return {"allowed": False, "reason": "Session expired or deactivated", "remaining": 0} + + # Check session duration + session_age = now - state.started_at + if session_age > self.config.max_session_duration: + state.active = False + return { + "allowed": False, + "reason": f"Session expired ({self.config.max_session_duration}s max)", + "remaining": 0, + } + + # Check message length + if message_length > self.config.max_message_length: + return { + "allowed": False, + "reason": f"Message too long ({message_length} > {self.config.max_message_length} chars)", + "remaining": self.config.max_requests_per_session - state.request_count, + } + + # Check rate limit (sliding window) + window_start = now - self.config.window_seconds + if state.last_request_at < window_start: + # Reset window + state.request_count = 1 + else: + state.request_count += 1 + + if state.request_count > self.config.max_requests_per_session: + return { + "allowed": False, + "reason": f"Rate limit exceeded ({self.config.max_requests_per_session} req/{self.config.window_seconds}s)", + "remaining": 0, + } + + # Allowed + state.message_count += 1 + state.last_request_at = now + remaining = self.config.max_requests_per_session - state.request_count + + return { + "allowed": True, + "reason": "OK", + "remaining": remaining, + "session_remaining": int(self.config.max_session_duration - session_age), + } + + def get_session_info(self, session_id: str) -> dict | None: + """Get current session info.""" + state = self._sessions.get(session_id) + if not state: + return None + return { + "session_id": state.session_id, + "user_id": state.user_id, + "message_count": state.message_count, + "request_count": state.request_count, + "active": state.active, + "session_age_seconds": int(time.time() - state.started_at), + "remaining_requests": max(0, self.config.max_requests_per_session - state.request_count), + } + + def end_session(self, session_id: str): + """End a session immediately.""" + state = self._sessions.get(session_id) + if state: + state.active = False + + def cleanup_expired(self): + """Remove expired sessions from memory.""" + now = time.time() + expired = [ + sid for sid, state in self._sessions.items() + if not state.active or (now - state.started_at) > self.config.max_session_duration + ] + for sid in expired: + del self._sessions[sid] + return len(expired) diff --git a/scripts/session_manager.py b/scripts/session_manager.py new file mode 100644 index 0000000..4a9dfbe --- /dev/null +++ b/scripts/session_manager.py @@ -0,0 +1,111 @@ +""" +Session Manager for Helpdesk Agent +Tracks active sessions in Redis with PostgreSQL persistence. +""" +from __future__ import annotations + +import json +import logging +import time + +logger = logging.getLogger(__name__) + + +class SessionManager: + """Manage user sessions with Redis cache and PostgreSQL persistence.""" + + def __init__(self, redis_client, postgres_pool=None, max_duration: int = 7200): + self.redis = redis_client + self.pg_pool = postgres_pool + self.max_duration = max_duration + + 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 + + session_data = { + "session_id": session_id, + "user_id": user_id, + "platform": platform, + "message_count": 0, + "started_at": now, + "expires_at": expires_at, + "active": True, + "ip": ip, + } + + # Store in Redis + key = f"session:{session_id}" + await self.redis.setex(key, self.max_duration, json.dumps(session_data)) + + # Store in PostgreSQL for persistence + if self.pg_pool: + 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, + ) + + return session_data + + async def get_session(self, session_id: str) -> dict | None: + """Get session data.""" + key = f"session:{session_id}" + data = await self.redis.get(key) + if data: + return json.loads(data) + return None + + async def increment_message(self, session_id: str): + """Increment message count.""" + key = f"session:{session_id}" + data = await self.redis.get(key) + if data: + session = json.loads(data) + session["message_count"] += 1 + ttl = await self.redis.ttl(key) + if ttl > 0: + await self.redis.setex(key, ttl, json.dumps(session)) + + async def end_session(self, session_id: str): + """End a session.""" + key = f"session:{session_id}" + data = await self.redis.get(key) + if data: + session = json.loads(data) + session["active"] = False + await self.redis.setex(key, 300, json.dumps(session)) # Keep for 5 min for audit + + if self.pg_pool: + async with self.pg_pool.acquire() as conn: + await conn.execute( + "UPDATE sessions SET active = FALSE WHERE id = $1", + session_id, + ) + + async def get_active_count(self) -> int: + """Get count of active sessions.""" + keys = await self.redis.keys("session:*") + count = 0 + for key in keys: + data = await self.redis.get(key) + if data: + session = json.loads(data) + if session.get("active"): + count += 1 + return count + + async def cleanup_expired(self): + """Remove expired sessions.""" + keys = await self.redis.keys("session:*") + removed = 0 + for key in keys: + ttl = await self.redis.ttl(key) + if ttl < 0: + await self.redis.delete(key) + removed += 1 + return removed diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..aa4af1d --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# ═══════════════════════════════════════════════════ +# Helpdesk Agent - Setup Script +# One-time initialization +# ═══════════════════════════════════════════════════ + +set -euo pipefail + +echo "═══════════════════════════════════════════" +echo " J1 Helpdesk Agent - Setup" +echo "═══════════════════════════════════════════" + +# Check prerequisites +command -v docker >/dev/null 2>&1 || { echo "ERROR: docker not found. Install docker first."; exit 1; } +command -v docker compose >/dev/null 2>&1 || command -v docker-compose >/dev/null 2>&1 || { echo "ERROR: docker compose not found."; exit 1; } + +# Create directories +mkdir -p models config scripts knowledge-base workflows certs data/logs queue + +# Generate .env if not exists +if [ ! -f .env ]; then + echo "[✓] Creating .env from template..." + cp .env.example .env + echo " → Edit .env with your credentials before starting!" +else + echo "[✓] .env already exists" +fi + +# Generate self-signed cert for HTTPS (optional) +if [ ! -f certs/cert.pem ]; then + echo "[✓] Generating self-signed certificate..." + openssl req -x509 -newkey rsa:4096 -keyout certs/key.pem -out certs/cert.pem -days 365 -nodes -subj "/CN=helpdesk.local" 2>/dev/null + echo " → Self-signed cert generated. Replace with real certs for production." +fi + +# Download model if not exists +MODEL_FILE="models/qwen2.5-7b-instruct-q4_k_m.gguf" +if [ ! -f "$MODEL_FILE" ]; then + echo "[✓] Downloading Qwen2.5-7B model (Q4_K_M)..." + echo " This may take 10-20 minutes depending on your connection." + if command -v huggingface-cli >/dev/null 2>&1; then + huggingface-cli download Qwen/Qwen2.5-7B-Instruct-GGUF "$MODEL_FILE" --local-dir models/ + else + wget -q --show-progress "https://huggingface.co/Qwen/Qwen2.5-7B-Instruct-GGUF/resolve/main/qwen2.5-7b-instruct-q4_k_m.gguf" -O "$MODEL_FILE" + fi + echo " → Model downloaded: $MODEL_FILE" +else + echo "[✓] Model already exists: $MODEL_FILE" +fi + +# Initialize knowledge base +if [ -d knowledge-base ] && [ "$(ls -A knowledge-base/*.md knowledge-base/*.txt 2>/dev/null)" ]; then + echo "[✓] Knowledge base files found" +else + echo "[?] Adding sample knowledge base article..." + cat > knowledge-base/welcome.md << 'EOF' +# Welcome to J1 Helpdesk + +## How to get help + +1. **Email**: Send your question to helpdesk@example.com +2. **Web portal**: Visit https://support.example.com +3. **Chat**: Use this AI assistant to search your existing tickets + +## Common Issues + +### Password Reset +Go to https://support.example.com/reset and enter your email address. You'll receive a reset link within 5 minutes. + +### Billing Questions +Contact billing@example.com or call +1-555-0123. Have your account number ready (format: ACC-XXXXXX). + +### Service Status +Check current service status at https://status.example.com. +EOF +fi + +echo "" +echo "═══════════════════════════════════════════" +echo " Setup Complete!" +echo "═══════════════════════════════════════════" +echo "" +echo "Next steps:" +echo " 1. Edit .env with your credentials" +echo " 2. Run: docker compose up -d" +echo " 3. Open http://localhost for dashboard" +echo " 4. API available at http://localhost/helpdesk/" +echo "" +echo "For osTicket/Freshdesk integration:" +echo " - Set OSTICKET_URL and OSTICKET_API_KEY in .env" +echo " - Set FRESHDESK_URL and FRESHDESK_API_KEY in .env" +echo "" +echo "To add knowledge base articles:" +echo " - Place .md or .txt files in knowledge-base/" +echo " - Run: python3 scripts/index_kb.py" +echo "" diff --git a/scripts/whatsapp_webhook.py b/scripts/whatsapp_webhook.py new file mode 100644 index 0000000..261dfdf --- /dev/null +++ b/scripts/whatsapp_webhook.py @@ -0,0 +1,639 @@ +#!/usr/bin/env python3 +""" +WhatsApp Webhook Receiver + Intent Router +Handles incoming WhatsApp messages, routes to helpdesk agent, +and manages the human takeover flow. +""" +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import os +import time + +import httpx +import redis.asyncio as redis +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") +logger = logging.getLogger("whatsapp-webhook") + +# ═══════════════════════════════════════════════════ +# Configuration +# ═══════════════════════════════════════════════════ + +HELPDESK_AGENT_URL = os.getenv("HELPDESK_AGENT_URL", "http://helpdesk-agent:8080") +REDIS_URL = os.getenv("REDIS_URL", "redis://redis:***@postgres:5432/helpdesk") +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", "") +ADMIN_PHONE_NUMBER = os.getenv("ADMIN_PHONE_NUMBER", "") # Your personal WhatsApp number +RATE_LIMIT_PER_MINUTE = int(os.getenv("WHATSAPP_RATE_LIMIT_PER_MINUTE", "10")) + +# ═══════════════════════════════════════════════════ +# Intent Detection Patterns +# ═══════════════════════════════════════════════════ + +INTENT_PATTERNS = { + "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", + ], + "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", + ], + "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", + ], + "greeting": [ + "hi", "hello", "hey", "good morning", "good afternoon", + "good evening", "howdy", "yo", "sup", + ], + "thanks": [ + "thanks", "thank you", "thx", "appreciate", "great", "awesome", + "perfect", "ok", "okay", "cool", + ], +} + +# ═══════════════════════════════════════════════════ +# Response Templates (WhatsApp formatted) +# ═══════════════════════════════════════════════════ + +RESPONSES = { + "greeting": ( + "👋 Welcome to J1 Support!\n\n" + "I can help you with:\n" + "🎫 Check your existing tickets\n" + "📝 Create a new ticket\n" + "👤 Talk to a human agent\n\n" + "What would you like to do? Just type your question!" + ), + "menu": ( + "📋 *How can I help you?*\n\n" + "1️⃣ Check my tickets\n" + "2️⃣ Create a new ticket\n" + "3️⃣ Talk to a human\n\n" + "Reply with a number or just describe your issue!" + ), + "ask_email": ( + "To check your tickets, I need your email address.\n" + "Please reply with the email associated with your account 📧" + ), + "human_queue": ( + "⏳ *Connecting you to a human agent...*\n\n" + "I've notified the team. Someone will be with you shortly.\n\n" + "⏱️ Average wait time: ~5 minutes\n" + "💬 Feel free to describe your issue while you wait." + ), + "human_takeover": ( + "🔄 *You're now chatting with a human agent.*\n\n" + "The AI assistant has been paused. A support agent will take it from here." + ), + "ticket_created": ( + "✅ *Ticket Created Successfully!*\n\n" + "📋 Ticket ID: `{ticket_id}`\n" + "📧 You'll receive updates via email\n" + "⏱️ Response time: ~4 hours\n\n" + "Is there anything else I can help with?" + ), + "ticket_not_found": ( + "🔍 I couldn't find any tickets associated with `{identifier}`.\n\n" + "Would you like to:\n" + "1️⃣ Try a different email\n" + "2️⃣ Create a new ticket\n" + "3️⃣ Talk to a human agent" + ), + "ticket_list": ( + "🎫 *Your Tickets ({count} total):*\n\n" + "{ticket_list}\n\n" + "Reply with a ticket ID for more details, or:" + ), + "ticket_details": ( + "📋 *Ticket #{ticket_id}*\n\n" + "📝 Subject: {subject}\n" + "📊 Status: {status}\n" + "⚡ Priority: {priority}\n" + "📅 Created: {created_at}\n" + "🔄 Updated: {updated_at}\n\n" + "💬 Last reply:\n{last_reply}" + ), + "create_ticket_prompt": ( + "📝 *Let's create a new ticket!*\n\n" + "Please describe your issue in detail. Include:\n" + "• What happened?\n" + "• When did it happen?\n" + "• Any error messages?\n\n" + "The more detail, the faster we can help! 🚀" + ), + "rate_limited": ( + "⚠️ You've sent too many messages.\n" + "Please wait a minute before trying again." + ), + "goodbye": ( + "👋 Thanks for contacting J1 Support!\n\n" + "If you need help again, just message us anytime.\n" + "Have a great day! 🌟" + ), + "fallback": ( + "I'm not sure I understood that. Let me help you with one of these:\n\n" + "1️⃣ Check my tickets\n" + "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!" + ), +} + +# ═══════════════════════════════════════════════════ +# App +# ═══════════════════════════════════════════════════ + +app = FastAPI(title="WhatsApp Helpdesk Webhook", version="1.0.0") +redis_client: redis.Redis | None = None + + +@app.on_event("startup") +async def startup(): + global redis_client + redis_client = redis.from_url(REDIS_URL, decode_responses=True) + logger.info("WhatsApp webhook started") + + +@app.on_event("shutdown") +async def shutdown(): + if redis_client: + await redis_client.close() + + +# ═══════════════════════════════════════════════════ +# Models +# ═══════════════════════════════════════════════════ + +class WhatsAppMessage(BaseModel): + from_: str = Field(..., alias="from") + text: str + timestamp: str + message_id: str + + +class WebhookPayload(BaseModel): + entry: list + + +# ═══════════════════════════════════════════════════ +# Intent Detection +# ═══════════════════════════════════════════════════ + +def detect_intent(message: str, session_data: dict) -> str: + """Detect user intent from message text.""" + text = message.lower().strip() + + # 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) + if email_match: + return "email_provided" + return "ask_email" + + if session_data.get("awaiting") == "ticket_details": + return "create_ticket_message" + + if session_data.get("awaiting") == "menu_selection": + if text in ["1", "1️⃣", "ticket", "tickets", "check"]: + return "check_tickets" + if text in ["2", "2️⃣", "create", "new"]: + return "create_ticket" + if text in ["3", "3️⃣", "human", "person", "agent"]: + return "talk_to_human" + + # Pattern matching + for intent, patterns in INTENT_PATTERNS.items(): + for pattern in patterns: + if pattern in text: + if intent == "greeting": + return "greeting" + if intent == "thanks": + return "thanks" + return intent + + # If user is in human queue, keep them there + if session_data.get("in_human_queue"): + return "human_queue_message" + + return "fallback" + + +# ═══════════════════════════════════════════════════ +# Session Management +# ═══════════════════════════════════════════════════ + +async def get_session(phone_number: str) -> dict: + """Get or create session for a WhatsApp user.""" + key = f"wa_session:{phone_number}" + data = await redis_client.get(key) + if data: + return json.loads(data) + return { + "phone": phone_number, + "user_id": None, + "awaiting": None, + "in_human_queue": False, + "messages_count": 0, + "started_at": time.time(), + "last_message": None, + } + + +async def save_session(phone_number: str, session: dict): + """Save session data.""" + key = f"wa_session:{phone_number}" + session["last_message"] = time.time() + await redis_client.setex(key, 7200, json.dumps(session)) # 2hr TTL + + +# ═══════════════════════════════════════════════════ +# Rate Limiting +# ═══════════════════════════════════════════════════ + +async def check_rate_limit(phone_number: str) -> bool: + """Check if user is rate limited.""" + key = f"wa_ratelimit:{phone_number}" + count = await redis_client.incr(key) + if count == 1: + await redis_client.expire(key, 60) + return count > RATE_LIMIT_PER_MINUTE + + +# ═══════════════════════════════════════════════════ +# WhatsApp API Helpers +# ═══════════════════════════════════════════════════ + +async def send_whatsapp_message(phone_number: str, message: str): + """Send a message via WhatsApp Business API.""" + if not WHATSAPP_TOKEN or not WHATSAPP_PHONE_NUMBER_ID: + logger.warning(f"WhatsApp credentials not set. Would send to {phone_number}: {message[:100]}") + return + + url = f"https://graph.facebook.com/v18.0/{WHATSAPP_PHONE_NUMBER_ID}/messages" + headers = { + "Authorization": f"Bearer {WHATSAPP_TOKEN}", + "Content-Type": "application/json", + } + payload = { + "messaging_product": "whatsapp", + "to": phone_number, + "type": "text", + "text": {"body": message}, + } + + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post(url, json=payload, headers=headers) + if resp.status_code != 200: + logger.error(f"WhatsApp send failed: {resp.status_code} {resp.text[:200]}") + else: + logger.info(f"Message sent to {phone_number}") + + +async def send_whatsapp_buttons(phone_number: str, body: str, buttons: list): + """Send interactive buttons via WhatsApp.""" + if not WHATSAPP_TOKEN or not WHATSAPP_PHONE_NUMBER_ID: + logger.warning(f"WhatsApp credentials not set. Would send buttons to {phone_number}") + return + + url = f"https://graph.facebook.com/v18.0/{WHATSAPP_PHONE_NUMBER_ID}/messages" + headers = { + "Authorization": f"Bearer {WHATSAPP_TOKEN}", + "Content-Type": "application/json", + } + payload = { + "messaging_product": "whatsapp", + "to": phone_number, + "type": "interactive", + "interactive": { + "type": "button", + "body": {"text": body}, + "action": { + "buttons": [ + {"type": "reply", "reply": {"id": btn[0], "title": btn[1]}} + for btn in buttons + ], + }, + }, + } + + async with httpx.AsyncClient(timeout=10) as client: + await client.post(url, json=payload, headers=headers) + + +# ═══════════════════════════════════════════════════ +# Human Queue Management +# ═══════════════════════════════════════════════════ + +async def add_to_human_queue(phone_number: str, session: dict): + """Add user to human agent queue and notify admin.""" + queue_key = "human_queue:pending" + await redis_client.lpush(queue_key, json.dumps({ + "phone": phone_number, + "user_id": session.get("user_id"), + "started_at": time.time(), + "message": session.get("last_customer_message", ""), + })) + # Keep queue for 1 hour + await redis_client.expire(queue_key, 3600) + + # Notify admin + if ADMIN_PHONE_NUMBER: + await send_whatsapp_message( + ADMIN_PHONE_NUMBER, + 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.", + ) + logger.info(f"Added {phone_number} to human queue, notified admin") + + +async def check_human_queue(phone_number: str) -> dict: + """Check user's position in human queue.""" + queue_key = "human_queue:pending" + items = await redis_client.lrange(queue_key, 0, -1) + for i, item in enumerate(items): + data = json.loads(item) + if data["phone"] == phone_number: + return {"position": i + 1, "in_queue": True} + return {"position": 0, "in_queue": False} + + +# ═══════════════════════════════════════════════════ +# Webhook Endpoints +# ═══════════════════════════════════════════════════ + +@app.get("/webhook/whatsapp") +async def verify_webhook( + hub_mode: str = "", + hub_challenge: int = 0, + hub_verify_token: str = "", +): + """WhatsApp webhook verification (GET).""" + if hub_mode == "subscribe" and hub_verify_token == WHATSAPP_WEBHOOK_SECRET: + return hub_challenge + raise HTTPException(status_code=403, detail="Verification failed") + + +@app.post("/webhook/whatsapp") +async def receive_message(request: Request): + """Handle incoming WhatsApp messages.""" + payload = await request.json() + + # Verify signature if provided + signature = request.headers.get("X-Hub-Signature-256", "") + if signature and WHATSAPP_WEBHOOK_SECRET != "change_me": + body = await request.body() + expected = "sha256=" + hmac.new( + WHATSAPP_WEBHOOK_SECRET.encode(), body, hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(expected, signature): + raise HTTPException(status_code=403, detail="Invalid signature") + + # Parse message + try: + entries = payload.get("entry", []) + for entry in entries: + changes = entry.get("changes", []) + for change in changes: + messages = change.get("value", {}).get("messages", []) + for msg in messages: + from_number = msg.get("from", "") + text = msg.get("text", {}).get("body", "") + message_id = msg.get("msg_id", "") + + if not from_number or not text: + continue + + # Rate limit check + if await check_rate_limit(from_number): + await send_whatsapp_message(from_number, RESPONSES["rate_limited"]) + continue + + # Get session + session = await get_session(from_number) + session["messages_count"] += 1 + + # Detect intent + intent = detect_intent(text, session) + + # Route based on intent + response = await route_intent(intent, from_number, text, session) + + # Save session + await save_session(from_number, session) + + # Send response + await send_whatsapp_message(from_number, response) + + except Exception as e: + logger.error(f"Error processing webhook: {e}", exc_info=True) + + return {"status": "ok"} + + +# ═══════════════════════════════════════════════════ +# Intent Router +# ═══════════════════════════════════════════════════ + +async def route_intent(intent: str, phone: str, text: str, session: dict) -> str: + """Route detected intent to appropriate handler.""" + + if intent == "greeting": + session["awaiting"] = "menu_selection" + return RESPONSES["greeting"] + + if intent == "menu_selection" or intent in ["check_tickets", "create_ticket", "talk_to_human"]: + if intent == "check_tickets" or intent == "1": + session["awaiting"] = "email" + return RESPONSES["ask_email"] + + if intent == "create_ticket" or intent == "2": + session["awaiting"] = "ticket_details" + return RESPONSES["create_ticket_prompt"] + + if intent == "talk_to_human" or intent == "3": + session["in_human_queue"] = True + session["awaiting"] = None + await add_to_human_queue(phone, session) + session["last_customer_message"] = text + return RESPONSES["human_queue"] + + if intent == "email_provided": + import re + email_match = re.search(r"[\w.+-]+@[\w-]+\.[\w.-]+", text) + if email_match: + email = email_match.group() + session["user_id"] = email + session["awaiting"] = None + # Search tickets for this email + tickets = await search_tickets(email) + if tickets: + session["tickets"] = tickets + return format_ticket_list(tickets) + else: + return RESPONSES["ticket_not_found"].format(identifier=email) + + if intent == "create_ticket_message": + session["awaiting"] = None + # Forward to helpdesk agent for ticket creation + result = await forward_to_agent( + user_id=session.get("user_id", phone), + message=text, + platform="whatsapp", + ) + if result.get("ticket_id"): + return RESPONSES["ticket_created"].format(**result) + return "I've received your issue and will create a ticket shortly. You'll receive a confirmation via WhatsApp." + + if intent == "human_queue_message": + queue_status = await check_human_queue(phone) + if queue_status["in_queue"]: + return f"⏳ You're #{queue_status['position']} in queue. An agent will be with you soon!" + return "🔔 A human agent has been notified. You'll be connected shortly." + + if intent == "thanks": + session["awaiting"] = None + return RESPONSES["goodbye"] + + # Fallback: forward to helpdesk agent for general questions + result = await forward_to_agent( + user_id=session.get("user_id", phone), + message=text, + platform="whatsapp", + ) + return result.get("response", RESPONSES["fallback"]) + + +async def search_tickets(email: str) -> list: + """Search tickets via helpdesk agent API.""" + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + f"{HELPDESK_AGENT_URL}/chat", + json={ + "user_id": email, + "message": f"Search my tickets for: {email}", + "platform": "whatsapp", + }, + ) + if resp.status_code == 200: + data = resp.json() + # Parse ticket results from response + return data.get("tickets", []) + except Exception as e: + logger.error(f"Error searching tickets: {e}") + return [] + + +async def forward_to_agent(user_id: str, message: str, platform: str) -> dict: + """Forward message to helpdesk agent.""" + try: + async with httpx.AsyncClient(timeout=120) as client: + resp = await client.post( + f"{HELPDESK_AGENT_URL}/chat", + json={ + "user_id": user_id, + "message": message, + "platform": platform, + }, + ) + if resp.status_code == 200: + return resp.json() + except Exception as e: + logger.error(f"Error forwarding to agent: {e}") + return {} + + +def format_ticket_list(tickets: list) -> str: + """Format ticket list for WhatsApp.""" + if not tickets: + return RESPONSES["ticket_not_found"].format(identifier="") + + lines = [] + for t in tickets[:5]: + status_emoji = {"open": "🟢", "pending": "🟡", "closed": "✅"}.get(t.get("status", ""), "⚪") + lines.append(f"{status_emoji} *{t.get('ticket_id', 'N/A')}* — {t.get('subject', 'No subject')}") + + ticket_list = "\n".join(lines) + return RESPONSES["ticket_list"].format(count=len(tickets), ticket_list=ticket_list) + + +# ═══════════════════════════════════════════════════ +# Admin Endpoints (for taking over conversations) +# ───────────────────────────────────────────────────── +# POST /admin/takeover/{phone} +# - Pauses the bot for this user +# - You (admin) take over the WhatsApp conversation +# POST /admin/resume/{phone} +# - Re-enables the bot after manual intervention +# ═══════════════════════════════════════════════════ + +@app.post("/admin/takeover/{phone}") +async def admin_takeover(phone: str): + """Admin takes over conversation with a customer.""" + session = await get_session(phone) + session["in_human_queue"] = False + session["awaiting"] = None + session["taken_over"] = True + await save_session(phone, session) + + # Notify customer + await send_whatsapp_message(phone, RESPONSES["human_takeover"]) + + # Remove from queue + queue_key = "human_queue:pending" + items = await redis_client.lrange(queue_key, 0, -1) + for item in items: + data = json.loads(item) + if data["phone"] == phone: + await redis_client.lrem(queue_key, 1, item) + break + + return {"status": "taken_over", "phone": phone} + + +@app.post("/admin/resume/{phone}") +async def admin_resume(phone: str): + """Re-enable bot after manual intervention.""" + session = await get_session(phone) + session["taken_over"] = False + session["awaiting"] = "menu_selection" + await save_session(phone, session) + + await send_whatsapp_message(phone, "🤖 Bot re-enabled. How else can I help you?") + return {"status": "resumed", "phone": phone} + + +@app.get("/admin/queue") +async def view_queue(): + """View human support queue.""" + queue_key = "human_queue:pending" + items = await redis_client.lrange(queue_key, 0, -1) + queue = [] + for item in items: + data = json.loads(item) + wait_minutes = int((time.time() - data.get("started_at", time.time())) / 60) + queue.append({ + "phone": data["phone"], + "waiting_minutes": wait_minutes, + "message": data.get("message", "")[:100], + }) + return {"queue": queue, "total": len(queue)} diff --git a/skills/manifest.yaml b/skills/manifest.yaml new file mode 100644 index 0000000..7efe27b --- /dev/null +++ b/skills/manifest.yaml @@ -0,0 +1,26 @@ +skills: + - id: j1-helpdesk + name: J1 Helpdesk Agent + description: Self-hosted AI helpdesk with multi-platform ticketing, email-to-ticket, and a cost-tracking admin dashboard. + version: 0.1.0 + entrypoints: + - tool: ticket_platforms.registry.get + description: Resolve a ticket platform adapter by name. + - tool: ticket_platforms.email.EmailTicketAdapter.sync_unread + description: Sync unread IMAP mail into tickets. + config: + required: + - OSTICKET_BASE_URL + - OSTICKET_API_KEY + optional: + - ZAMMAD_BASE_URL + - ZAMMAD_API_TOKEN + - EMAIL_IMAP_HOST + - EMAIL_IMAP_PORT + - EMAIL_IMAP_USER + - EMAIL_IMAP_PASSWORD + - id: persona-customer-support + name: Customer Support Persona + source: hazelugo/fav_gits + description: Manage customer support conversations — track tickets, respond, escalate, and maintain a support persona. + version: 0.1.0 diff --git a/skills/persona-customer-support/.skillfish.json b/skills/persona-customer-support/.skillfish.json new file mode 100644 index 0000000..e590364 --- /dev/null +++ b/skills/persona-customer-support/.skillfish.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "name": "persona-customer-support", + "owner": "hazelugo", + "repo": "fav_gits", + "path": "cli-main/skills/persona-customer-support", + "branch": "main", + "sha": "9ef5a7a791ce011bd4e1c59f2c043c21f923d784", + "source": "manual" +} \ No newline at end of file diff --git a/skills/persona-customer-support/SKILL.md b/skills/persona-customer-support/SKILL.md new file mode 100644 index 0000000..c78725c --- /dev/null +++ b/skills/persona-customer-support/SKILL.md @@ -0,0 +1,34 @@ +--- +name: persona-customer-support +version: 1.0.0 +description: "Manage customer support — track tickets, respond, escalate issues." +metadata: + openclaw: + category: "persona" + requires: + bins: ["gws"] + skills: ["gws-gmail", "gws-sheets", "gws-chat", "gws-calendar"] +--- + +# Customer Support Agent + +> **PREREQUISITE:** Load the following utility skills to operate as this persona: `gws-gmail`, `gws-sheets`, `gws-chat`, `gws-calendar` + +Manage customer support — track tickets, respond, escalate issues. + +## Relevant Workflows +- `gws workflow +email-to-task` +- `gws workflow +standup-report` + +## Instructions +- Triage the support inbox with `gws gmail +triage --query 'label:support'`. +- Convert customer emails into support tasks with `gws workflow +email-to-task`. +- Log ticket status updates in a tracking sheet with `gws sheets +append`. +- Escalate urgent issues to the team Chat space. +- Schedule follow-up calls with customers using `gws calendar +insert`. + +## Tips +- Use `gws gmail +triage --labels` to see email categories at a glance. +- Set up Gmail filters for auto-labeling support requests. +- Use `--format table` for quick status dashboard views. + diff --git a/ticket_platforms/__init__.py b/ticket_platforms/__init__.py new file mode 100644 index 0000000..3d6d3b1 --- /dev/null +++ b/ticket_platforms/__init__.py @@ -0,0 +1,11 @@ +""" +Ticket platform adapters. + +Quick usage: + from ticket_platforms.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 new file mode 100644 index 0000000..1ad7ac2 --- /dev/null +++ b/ticket_platforms/base.py @@ -0,0 +1,56 @@ +""" +Base adapter for ticket platforms. +Every adapter must implement create_ticket, update_ticket, search_tickets, close_ticket. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + + +class Ticket(ABC): + @abstractmethod + def create_ticket( + self, + user_id: str, + subject: str, + body: str, + *, + 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]: + raise NotImplementedError + + @abstractmethod + def update_ticket( + self, + ticket_id: str, + status: str | None = None, + note: str | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def search_tickets( + self, + user_id: str, + query: str, + limit: int = 10, + **kwargs: Any, + ) -> list[dict[str, Any]]: + raise NotImplementedError + + @abstractmethod + def close_ticket( + self, + ticket_id: str, + reason: str | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + raise NotImplementedError diff --git a/ticket_platforms/email.py b/ticket_platforms/email.py new file mode 100644 index 0000000..93523bc --- /dev/null +++ b/ticket_platforms/email.py @@ -0,0 +1,24 @@ +""" +Email-to-ticket adapter for Hermes helpdesk agent. +""" + +from __future__ import annotations + +from .base import Ticket + + +class EmailTicketAdapter: + """ + Non-Ticket adapter: acts as an entrypoint that creates tickets + in a configured downstream platform. + """ + + def __init__(self, *, imap_host: str, imap_port: int, username: str, password: str, mailbox: str = "INBOX", mark_seen: bool = True, ticket_platform: str = "osticket"): + self.imap_host = imap_host + self.imap_port = int(imap_port) + self.username = username + self.password = password + self.mailbox = mailbox + self.mark_seen = mark_seen + self.ticket_platform_name = ticket_platform + self._ticket_platform: Ticket | None = None diff --git a/ticket_platforms/freshdesk.py b/ticket_platforms/freshdesk.py new file mode 100644 index 0000000..b1ff8f9 --- /dev/null +++ b/ticket_platforms/freshdesk.py @@ -0,0 +1,165 @@ +""" +Freshdesk Adapter for Helpdesk Agent +Supports Freshdesk Free Plan (REST API v2) +""" +from __future__ import annotations + +import base64 +import json +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + +from .base import Ticket +from .registry import register + + +@register("freshdesk") +class FreshdeskAdapter(Ticket): + """ + Adapter for Freshdesk Free Plan. + Uses REST API v2 with API key authentication. + API key is base64 encoded as 'API_KEY:X' for Basic Auth. + """ + + def __init__(self, *, base_url: str, api_key: str, domain: str | None = None): + """ + Args: + base_url: Freshdesk URL, e.g. https://yourcompany.freshdesk.com + api_key: Freshdesk API key + domain: Freshdesk subdomain (alternative to base_url) + """ + if domain: + self.base_url = f"https://{domain}.freshdesk.com" + else: + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.api_url = f"{self.base_url}/api/v2" + + def _get_auth_header(self) -> str: + """Generate Basic Auth header for Freshdesk API.""" + token = base64.b64encode(f"{self.api_key}:X".encode()).decode() + return f"Basic {token}" + + 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 = { + "Authorization": self._get_auth_header(), + "Content-Type": "application/json", + } + body = json.dumps(data).encode() if data else None + req = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + error_body = e.read().decode() if e.fp else "" + raise Exception(f"Freshdesk API error {e.code}: {error_body}") + + def create_ticket( + self, + user_id: str, + subject: str, + body: str, + *, + 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]: + """Create a new ticket in Freshdesk.""" + data: dict[str, Any] = { + "subject": subject, + "description": body, + "email": email or user_id, + } + # Map priority: low=1, normal=2, high=3, urgent=4 + priority_map = {"low": 1, "normal": 2, "medium": 2, "high": 3, "urgent": 4} + if priority: + data["priority"] = priority_map.get(priority.lower(), 2) + if dept_id: + data["group_id"] = dept_id + if source: + data["source"] = 2 if source == "email" else 7 # 2=email, 7=portal + + result = self._request("/tickets", method="POST", data=data) + return { + "ticket_id": str(result.get("id")), + "subject": result.get("subject"), + "status": "open", + "url": f"{self.base_url}/helpdesk/tickets/{result.get('id')}", + } + + def update_ticket( + self, + ticket_id: str, + status: str | None = None, + note: str | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + """Update ticket: add note, change status.""" + 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: + data["status"] = status_map.get(status.lower(), 2) + + if note: + self._request(f"/tickets/{ticket_id}/notes", method="POST", data={ + "body": note, + "private": False, + }) + + if data: + self._request(f"/tickets/{ticket_id}", method="PUT", data=data) + + return {"ticket_id": ticket_id, "updated": True} + + def search_tickets( + self, + user_id: str, + query: str, + limit: int = 10, + **kwargs: Any, + ) -> list[dict[str, Any]]: + """Search tickets by user email.""" + # Freshdesk search API + search_query = f"email:'{user_id}'" + if query: + search_query += f" AND \"{query}\"" + + try: + result = self._request(f"/search/tickets?query={urllib.parse.quote(search_query)}") + tickets = [] + for item in result.get("results", [])[:limit]: + tickets.append({ + "ticket_id": str(item.get("id")), + "subject": item.get("subject"), + "status": item.get("status"), + "priority": item.get("priority"), + "created_at": item.get("created_at"), + "updated_at": item.get("updated_at"), + }) + return tickets + except Exception: + return [] + + def close_ticket( + self, + ticket_id: str, + reason: str | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + """Close a ticket (status=5).""" + data: dict[str, Any] = {"status": 5} + if reason: + self._request(f"/tickets/{ticket_id}/notes", method="POST", data={ + "body": f"Closed: {reason}", + "private": True, + }) + self._request(f"/tickets/{ticket_id}", method="PUT", data=data) + return {"ticket_id": ticket_id, "status": "closed", "reason": reason} diff --git a/ticket_platforms/osticket.py b/ticket_platforms/osticket.py new file mode 100644 index 0000000..c2aee62 --- /dev/null +++ b/ticket_platforms/osticket.py @@ -0,0 +1,89 @@ +""" +osTicket adapter for Hermes helpdesk agent. +""" + +from __future__ import annotations + +import re +from typing import Any + +import requests + +from .base import Ticket +from .registry import register + + +@register("osticket") +class osTicketAdapter(Ticket): + def __init__(self, *, base_url: str, api_key: str, dept_id: int = 1, priority: str = "low", source: str = "Web", rate_limit_per_min: int = 5): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.dept_id = dept_id + self.priority = priority + self.source = source + self.rate_limit_per_min = rate_limit_per_min + + def _headers(self): + return {"X-API-Key": self.api_key, "Content-Type": "application/json"} + + @staticmethod + def _sanitize(text: str) -> str: + if not text: + return "" + text = re.sub(r"<[^>]+>", "", text) + text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", text) + return text.strip() + + def _url(self, path: str) -> str: + return f"{self.base_url}/api/http.php/{path}" + + def create_ticket(self, user_id, subject, body, *, name=None, email=None, dept_id=None, priority=None, source=None, **kwargs): + subject = self._sanitize(subject) + body = self._sanitize(body) + name = name or f"User {user_id}" + email = email or f"telegram+{user_id}@helpdesk.local" + payload = { + "name": name, + "email": email, + "phone": "", + "subject": subject, + "message": body, + "ip": "", + "priority": priority or self.priority, + "status": "open", + "deptId": int(dept_id or self.dept_id), + "source": source or self.source, + } + r = requests.post(self._url("tickets.json"), json=payload, headers=self._headers(), timeout=30) + r.raise_for_status() + data = r.json() + ticket = data.get("ticket", {}) + 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] = {} + if status: + payload["status"] = status + if note: + payload["post"] = self._sanitize(note) + payload["post_status"] = "open" + r = requests.put(self._url(f"tickets/{ticket_id}.json"), json=payload, headers=self._headers(), timeout=30) + r.raise_for_status() + return {"ticket_id": ticket_id, "status": status or "updated", "platform": "osticket"} + + def search_tickets(self, user_id, query, limit=10, **kwargs): + q = self._sanitize(query) + r = requests.get(self._url("tickets.json"), params={"query": q, "limit": limit}, headers=self._headers(), timeout=30) + r.raise_for_status() + data = r.json() + tickets = data.get("tickets", []) if isinstance(data, dict) else (data or []) + 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"} + if reason: + payload["post"] = self._sanitize(reason) + payload["post_status"] = "closed" + r = requests.put(self._url(f"tickets/{ticket_id}.json"), json=payload, headers=self._headers(), timeout=30) + r.raise_for_status() + return {"ticket_id": ticket_id, "status": "closed", "platform": "osticket"} diff --git a/ticket_platforms/registry.py b/ticket_platforms/registry.py new file mode 100644 index 0000000..ecb1586 --- /dev/null +++ b/ticket_platforms/registry.py @@ -0,0 +1,29 @@ +""" +Registry for ticket platforms. +Adapters register themselves so Hermes can dispatch calls by platform name. +""" + +from __future__ import annotations + +from .base import Ticket + +_REGISTRY: dict[str, type["Ticket"]] = {} + + +def register(name: str): + def decorator(cls: type[Ticket]): + _REGISTRY[name.lower()] = cls + return cls + + return decorator + + +def get(name: str) -> type[Ticket]: + key = name.lower() + if key not in _REGISTRY: + raise KeyError(f"Unknown ticket platform: {name}. Registered: {sorted(_REGISTRY)}") + return _REGISTRY[key] + + +def available() -> list[str]: + return sorted(_REGISTRY) diff --git a/ticket_platforms/zammad.py b/ticket_platforms/zammad.py new file mode 100644 index 0000000..d680c72 --- /dev/null +++ b/ticket_platforms/zammad.py @@ -0,0 +1,74 @@ +""" +Zammad adapter for Hermes helpdesk agent. +""" + +from __future__ import annotations + +from typing import Any + +from .base import Ticket +from .registry import register + + +@register("zammad") +class ZammadAdapter(Ticket): + 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 + self.priority_id = priority_id + self.state_id = state_id + + def _headers(self): + return {"Authorization": f"Token token={self.api_token}", "Content-Type": "application/json"} + + def _url(self, path: str) -> str: + return f"{self.base_url}/api/v1/{path}" + + def create_ticket(self, user_id, subject, body, *, name=None, email=None, priority=None, source=None, **kwargs): + customer = email or f"telegram+{user_id}@helpdesk.local" + payload = { + "title": subject, + "group_id": self.group_id or 1, + "priority_id": self.priority_id, + "state_id": self.state_id, + "article": {"type": "text", "body": body or subject, "internal": False}, + "customer": customer, + } + if name: + payload["customer"] = {"firstname": name, "email": customer} + r = requests.post(self._url("tickets"), json=payload, headers=self._headers(), timeout=30) + r.raise_for_status() + data = r.json() + 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] = {} + if status: + payload["state"] = status + if note: + payload["article"] = {"type": "text", "body": note, "internal": bool(kwargs.get("internal", False))} + r = requests.patch(self._url(f"tickets/{ticket_id}"), json=payload, headers=self._headers(), timeout=30) + r.raise_for_status() + return {"ticket_id": ticket_id, "status": status or "updated", "platform": "zammad"} + + def search_tickets(self, user_id, query, limit=10, **kwargs): + q = f"{query} user:{user_id}" + r = requests.get(self._url("tickets/search"), params={"query": q, "per_page": limit}, headers=self._headers(), timeout=30) + r.raise_for_status() + data = r.json() + if isinstance(data, list): + tickets = data + elif isinstance(data, dict): + tickets = data.get("tickets", data.get("assets", [])) + else: + tickets = [] + 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"} + 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) + r.raise_for_status() + return {"ticket_id": ticket_id, "status": "closed", "platform": "zammad"} diff --git a/tools-ui/Dockerfile b/tools-ui/Dockerfile new file mode 100644 index 0000000..9d95d17 --- /dev/null +++ b/tools-ui/Dockerfile @@ -0,0 +1,7 @@ +FROM nginx:alpine + +COPY tools-ui/index.html /usr/share/nginx/html/index.html +COPY tools-ui/widget.js /usr/share/nginx/html/widget.js +COPY tools-ui/style.css /usr/share/nginx/html/style.css + +EXPOSE 8484 diff --git a/tools-ui/dashboard.html b/tools-ui/dashboard.html new file mode 100644 index 0000000..48996fb --- /dev/null +++ b/tools-ui/dashboard.html @@ -0,0 +1,661 @@ + + + + + + + + J1 Dev Ops Dashboard + + + +
    +
    +

    🤖 J1 Dev Ops

    +
    + + +
    +
    +
    +
    3 Ready
    +
    1 Running
    +
    0 Blocked
    +
    5 Done
    +
    9 Total
    +
    +
    + + +
    + +
    +
    + 📋 Ready + 3 +
    +
    +
    +
    + t_f4a2b1c3 +
    +
    +
    Add Freshdesk MCP server as Docker service
    +
    +
    +
    D
    + agent-dev +
    + Ready +
    +
    +
    +
    + t_b7e5d2a1 +
    +
    +
    Write API documentation for WhatsApp webhook endpoints
    +
    +
    +
    W
    + docs-writer +
    + Ready +
    +
    +
    +
    + t_c8d3e4f5 +
    +
    +
    Security audit: rate limiting bypass attempts
    +
    +
    +
    Q
    + qa-reviewer +
    + Ready +
    +
    +
    +
    + + +
    +
    + ⚡ Running + 1 +
    +
    +
    +
    + t_a1b2c3d4 +
    +
    +
    Fix nginx SSL cert renewal for WhatsApp webhook
    +
    +
    +
    I
    + infra-mgr +
    + Running 2h +
    +
    +
    +
    + + +
    +
    + 🚫 Blocked + 0 +
    +
    + +
    +
    + + +
    +
    + ✅ Done + 5 +
    +
    +
    +
    + t_d4e5f6a7 +
    +
    +
    Setup J1 Dev Ops team profiles and workspace
    +
    +
    +
    O
    + orchestrator +
    + Done +
    +
    +
    +
    + t_e5f6a7b8 +
    +
    +
    Create docker-compose.prod.yml with health checks
    +
    +
    +
    I
    + infra-mgr +
    + Done +
    +
    +
    +
    + t_f6a7b8c9 +
    +
    +
    Add Freshdesk adapter to ticket_platforms
    +
    +
    +
    D
    + agent-dev +
    + Done +
    +
    +
    +
    + t_a7b8c9d0 +
    +
    +
    Implement WhatsApp webhook with human takeover
    +
    +
    +
    D
    + agent-dev +
    + Done +
    +
    +
    +
    + t_b8c9d0e1 +
    +
    +
    Build admin dashboard with real-time monitoring
    +
    +
    +
    D
    + agent-dev +
    + Done +
    +
    +
    +
    +
    + + +
    +
    +
    + infra-mgr: Fixing SSL cert renewal (2h 15m) +
    +
    +
    + agent-dev: Completed WhatsApp webhook ✓ +
    +
    +
    + qa-reviewer: Approved rate limiting PR ✓ +
    +
    + + + + + + + diff --git a/tools-ui/index.html b/tools-ui/index.html new file mode 100644 index 0000000..a3f8c72 --- /dev/null +++ b/tools-ui/index.html @@ -0,0 +1,141 @@ + + + + + + J1 Helpdesk - Widget + + + +
    +

    🤖 J1 Helpdesk Widget

    +

    Preview and configure the WhatsApp chat widget for your website.

    + +
    +
    +

    📱 Live Preview

    +
    + + + +
    +
    + +
    +

    ⚙️ Configuration

    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    + +
    +

    🔧 Embed Code

    +

    Add this to your website HTML to enable the WhatsApp chat widget:

    +
    <!-- J1 Helpdesk Widget --> +<script src="https://your-server.com/widget.js"></script> +<script> + J1Widget.init({ + agentUrl: 'https://your-server.com', + phone: '+1234567890', + welcomeMessage: '👋 Welcome to J1 Support!', + theme: 'green' + }); +</script>
    +
    + +
    +

    📊 Quick Stats

    +
    +
    +
    12
    +
    Active Chats
    +
    +
    +
    47
    +
    Tickets Resolved
    +
    +
    +
    3.2s
    +
    Avg Response
    +
    +
    +
    2
    +
    In Queue
    +
    +
    +
    +
    + + diff --git a/tools-ui/manifest.json b/tools-ui/manifest.json new file mode 100644 index 0000000..c948284 --- /dev/null +++ b/tools-ui/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "J1 Helpdesk Admin", + "short_name": "J1 Helpdesk", + "description": "AI Helpdesk Admin Dashboard", + "start_url": "/", + "display": "standalone", + "background_color": "#0f172a", + "theme_color": "#0f172a", + "orientation": "portrait", + "icons": [ + { "src": "data:image/svg+xml,🤖", "sizes": "any" } + ] +} diff --git a/tools-ui/mobile-app.html b/tools-ui/mobile-app.html new file mode 100644 index 0000000..902ed42 --- /dev/null +++ b/tools-ui/mobile-app.html @@ -0,0 +1,452 @@ + + + + + + + + + J1 Helpdesk Admin + + + + +
    +

    🤖 J1 Helpdesk

    +
    Online
    +
    + +
    + +
    +
    +
    +
    12
    +
    Active Sessions
    +
    +
    +
    23
    +
    Open Tickets
    +
    +
    +
    1.2M
    +
    Tokens Today
    +
    +
    +
    $0.00
    +
    Cost Today
    +
    +
    + +
    +
    📊 Tickets This Week
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + MonTueWedThuFriSatSun +
    +
    + +
    +
    🔧 Service Health
    +
    +
    +
    llama.cpp
    +
    6.2GB
    +
    +
    +
    +
    Helpdesk Agent
    +
    1.1GB
    +
    +
    +
    +
    Admin Agent
    +
    800MB
    +
    +
    +
    +
    ChromaDB
    +
    1.4GB
    +
    +
    +
    +
    PostgreSQL
    +
    400MB
    +
    +
    +
    +
    Redis
    +
    120MB
    +
    +
    +
    +
    WhatsApp Webhook
    +
    200MB
    +
    +
    +
    + + +
    +
    +
    + 🎫 Open Tickets 23 +
    +
    +
    + #a1b2c3 + Open +
    +
    Cannot access dashboard
    +
    user@example.com · 2 min ago
    +
    +
    +
    + #d4e5f6 + Pending +
    +
    Billing invoice question
    +
    john@company.com · 15 min ago
    +
    +
    +
    + #g7h8i9 + Open +
    +
    Password reset not working
    +
    jane@startup.io · 1 hour ago
    +
    +
    +
    + #j0k1l2 + Closed +
    +
    API rate limiting errors
    +
    dev@tech.co · 3 hours ago
    +
    +
    +
    + + +
    +
    +
    👤 Human Queue 2
    +
    +
    + +1 555-0123 + Waiting 3m +
    +
    Urgent: Production down
    +
    Waiting for agent
    + +
    +
    +
    + +1 555-0456 + Waiting 8m +
    +
    Need help with refund
    +
    Waiting for agent
    + +
    +
    +
    + + +
    +
    +
    ⚙️ Configuration
    +
    +
    Rate Limit
    +
    50 req/hour
    +
    +
    +
    Session Duration
    +
    2 hours
    +
    +
    +
    Max Message Length
    +
    4000 chars
    +
    +
    +
    LLM Model
    +
    qwen2.5-7b-instruct
    +
    +
    +
    Ticket Platform
    +
    osTicket + Freshdesk
    +
    +
    + +
    +
    🔌 MCP Servers
    +
    +
    +
    Freshdesk
    +
    41 tools
    +
    +
    +
    +
    osTicket
    +
    Adapter
    +
    +
    +
    +
    Slack
    +
    Not configured
    +
    +
    + + +
    +
    + + + + + + + diff --git a/workflows/auto-escalation.json b/workflows/auto-escalation.json new file mode 100644 index 0000000..fc3b866 --- /dev/null +++ b/workflows/auto-escalation.json @@ -0,0 +1,74 @@ +{ + "name": "Helpdesk - Auto Escalation", + "nodes": [ + { + "parameters": {}, + "id": "webhook-trigger", + "name": "Webhook Trigger", + "type": "n8n-nodes-base.webhook", + "typeVersion": 1, + "position": [250, 300], + "webhookId": "escalation-trigger" + }, + { + "parameters": { + "conditions": { + "string": [ + { + "value1": "={{ $json.escalation_reason }}", + "operation": "isNotEmpty" + } + ] + } + }, + "id": "check-escalation", + "name": "Check Escalation", + "type": "n8n-nodes-base.if", + "typeVersion": 1, + "position": [450, 300] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.ADMIN_AGENT_URL }}/chat", + "jsonParameters": true, + "bodyParametersJson": "={{ JSON.stringify({ message: 'ESCALATION: ' + $json.ticket.subject + ' - Reason: ' + $json.escalation_reason + '\\n\\nTicket ID: ' + $json.ticket.id + '\\nUser: ' + $json.ticket.user_id }) }}", + "options": {} + }, + "id": "notify-admin", + "name": "Notify Admin Agent", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 3, + "position": [650, 200] + }, + { + "parameters": { + "channel": "#helpdesk-escalations", + "text": "🚨 Ticket Escalated\n\n**Ticket:** {{ $json.ticket.subject }}\n**User:** {{ $json.ticket.user_id }}\n**Reason:** {{ $json.escalation_reason }}\n\nAdmin Agent has been notified." + }, + "id": "slack-notification", + "name": "Send Notification", + "type": "n8n-nodes-base.slack", + "typeVersion": 2, + "position": [850, 200] + } + ], + "connections": { + "Webhook Trigger": { + "main": [ + [{ "node": "Check Escalation", "type": "main", "index": 0 }] + ] + }, + "Check Escalation": { + "main": [ + [{ "node": "Notify Admin Agent", "type": "main", "index": 0 }], + [] + ] + }, + "Notify Admin Agent": { + "main": [ + [{ "node": "Send Notification", "type": "main", "index": 0 }] + ] + } + } +} diff --git a/workflows/daily-digest.json b/workflows/daily-digest.json new file mode 100644 index 0000000..336c62d --- /dev/null +++ b/workflows/daily-digest.json @@ -0,0 +1,82 @@ +{ + "name": "Helpdesk - Daily Digest", + "nodes": [ + { + "parameters": {}, + "id": "schedule-trigger", + "name": "Daily at 9AM", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1, + "position": [250, 300], + "interval": [{ "field": "cronExpression", "expression": "0 9 * * 1-5" }] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $.env.POSTGRES_URL }}", + "operation": "executeQuery", + "query": "SELECT status, COUNT(*) as count FROM tickets WHERE created_at >= NOW() - INTERVAL '24 hours' GROUP BY status" + }, + "id": "query-tickets", + "name": "Query Ticket Stats", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2, + "position": [450, 300], + "additionalFields": {} + }, + { + "parameters": { + "method": "POST", + "url": "={{ $.env.POSTGRES_URL }}", + "operation": "executeQuery", + "query": "SELECT COUNT(*) as active_sessions FROM sessions WHERE active = TRUE" + }, + "id": "query-sessions", + "name": "Query Active Sessions", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2, + "position": [450, 450], + "additionalFields": {} + }, + { + "parameters": { + "functionCode": "const tickets = $input.all()[0].json;\nconst sessions = $input.all()[1].json;\n\nconst open = tickets.find(r => r.status === 'open')?.count || 0;\nconst pending = tickets.find(r => r.status === 'pending')?.count || 0;\nconst closed = tickets.find(r => r.status === 'closed')?.count || 0;\n\nconst message = `📊 *Daily Helpdesk Digest*\\n\\n` +\n `🎫 Tickets (24h):\\n` +\n ` • Open: ${open}\\n` +\n ` • Pending: ${pending}\\n` +\n ` • Closed: ${closed}\\n\\n` +\n `💬 Active sessions: ${sessions[0]?.active_sessions || 0}\\n\\n` +\n `⚡Avg response time: 3.2s\\n` +\n `💰 Cost today: $0.00 (local LLM)`;\n\nreturn [{ json: { message } }];" + }, + "id": "format-digest", + "name": "Format Digest", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [650, 375] + }, + { + "parameters": { + "channel": "#helpdesk-daily", + "text": "={{ $json.message }}" + }, + "id": "send-digest", + "name": "Send to Slack/WhatsApp", + "type": "n8n-nodes-base.slack", + "typeVersion": 2, + "position": [850, 375] + } + ], + "connections": { + "Daily at 9AM": { + "main": [ + [ + { "node": "Query Ticket Stats", "type": "main", "index": 0 }, + { "node": "Query Active Sessions", "type": "main", "index": 0 } + ] + ] + }, + "Query Ticket Stats": { + "main": [[{ "node": "Format Digest", "type": "main", "index": 0 }]] + }, + "Query Active Sessions": { + "main": [[{ "node": "Format Digest", "type": "main", "index": 0 }]] + }, + "Format Digest": { + "main": [[{ "node": "Send to Slack/WhatsApp", "type": "main", "index": 0 }]] + } + } +} diff --git a/workflows/satisfaction-survey.json b/workflows/satisfaction-survey.json new file mode 100644 index 0000000..1792a02 --- /dev/null +++ b/workflows/satisfaction-survey.json @@ -0,0 +1,77 @@ +{ + "name": "Helpdesk - Satisfaction Survey", + "nodes": [ + { + "parameters": {}, + "id": "schedule-trigger", + "name": "Every 4 hours", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1, + "position": [250, 300], + "interval": [{ "field": "cronExpression", "expression": "0 */4 * * *" }] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $.env.POSTGRES_URL }}", + "operation": "executeQuery", + "query": "SELECT t.id, t.user_id, t.subject FROM tickets t WHERE t.status = 'closed' AND t.closed_at >= NOW() - INTERVAL '4 hours' AND NOT EXISTS (SELECT 1 FROM audit_log a WHERE a.details->>'ticket_id' = t.id::text AND a.action = 'satisfaction_sent')" + }, + "id": "find-closed-tickets", + "name": "Find Recently Closed Tickets", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2, + "position": [450, 300], + "additionalFields": {} + }, + { + "parameters": { + "functionCode": "const tickets = $input.all();\nconst results = [];\nfor (const item of tickets) {\n const ticket = item.json;\n results.push({\n json: {\n user_id: ticket.user_id,\n ticket_id: ticket.id,\n subject: ticket.subject,\n surveyMessage: `Hi! Your ticket *${ticket.subject}* was recently closed. How was your experience?\\n\\n⭐ Rate from 1-5:\\n1️⃣ Poor\\n2️⃣ Fair\\n3️⃣ Good\\n4️⃣ Great\\n5️⃣ Excellent`\n }\n });\n}\nreturn results;" + }, + "id": "format-survey", + "name": "Format Survey Messages", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [650, 300] + }, + { + "parameters": { + "channel": "#helpdesk-satisfaction", + "text": "={{ $json.surveyMessage }}" + }, + "id": "send-survey", + "name": "Send Survey", + "type": "n8n-nodes-base.slack", + "typeVersion": 2, + "position": [850, 300] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $.env.POSTGRES_URL }}", + "operation": "executeQuery", + "query": "INSERT INTO audit_log (session_id, action, details) VALUES (NULL, 'satisfaction_sent', $1::jsonb)" + }, + "id": "mark-sent", + "name": "Mark Survey Sent", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2, + "position": [850, 450], + "additionalFields": {} + } + ], + "connections": { + "Every 4 hours": { + "main": [[{ "node": "Find Recently Closed Tickets", "type": "main", "index": 0 }]] + }, + "Find Recently Closed Tickets": { + "main": [[{ "node": "Format Survey Messages", "type": "main", "index": 0 }]] + }, + "Format Survey Messages": { + "main": [[{ "node": "Send Survey", "type": "main", "index": 0 }]] + }, + "Send Survey": { + "main": [[{ "node": "Mark Survey Sent", "type": "main", "index": 0 }]] + } + } +} diff --git a/workflows/ticket-created.json b/workflows/ticket-created.json new file mode 100644 index 0000000..33f24b1 --- /dev/null +++ b/workflows/ticket-created.json @@ -0,0 +1,74 @@ +{ + "name": "Helpdesk - Ticket Created Notification", + "nodes": [ + { + "parameters": {}, + "id": "webhook-trigger", + "name": "Webhook Trigger", + "type": "n8n-nodes-base.webhook", + "typeVersion": 1, + "position": [250, 300], + "webhookId": "ticket-created" + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.HELPDESK_AGENT_URL }}/chat", + "jsonParameters": true, + "bodyParametersJson": "={{ JSON.stringify({ message: 'A new ticket has been created: ' + $json.subject + '\\n\\n' + $json.body + '\\n\\nPlease acknowledge and categorize this ticket.', user_id: $json.user_id, platform: 'system' }) }}", + "options": {} + }, + "id": "process-ticket", + "name": "Process via Helpdesk Agent", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 3, + "position": [450, 300] + }, + { + "parameters": { + "conditions": { + "string": [ + { + "value1": "={{ $json.response }}", + "operation": "isNotEmpty" + } + ] + } + }, + "id": "check-response", + "name": "Check Response", + "type": "n8n-nodes-base.if", + "typeVersion": 1, + "position": [650, 300] + }, + { + "parameters": { + "channel": "#helpdesk-tickets", + "text": "🎫 New Ticket Created\n\n**Subject:** {{ $json.subject }}\n**User:** {{ $json.user_id }}\n\nAgent Response: {{ $json.response }}" + }, + "id": "notify-channel", + "name": "Send Notification", + "type": "n8n-nodes-base.slack", + "typeVersion": 2, + "position": [850, 200] + } + ], + "connections": { + "Webhook Trigger": { + "main": [ + [{ "node": "Process via Helpdesk Agent", "type": "main", "index": 0 }] + ] + }, + "Process via Helpdesk Agent": { + "main": [ + [{ "node": "Check Response", "type": "main", "index": 0 }] + ] + }, + "Check Response": { + "main": [ + [{ "node": "Send Notification", "type": "main", "index": 0 }], + [] + ] + } + } +} diff --git a/workflows/ticket-routing.json b/workflows/ticket-routing.json new file mode 100644 index 0000000..3d7383b --- /dev/null +++ b/workflows/ticket-routing.json @@ -0,0 +1,80 @@ +{ + "name": "Helpdesk - Smart Ticket Routing", + "nodes": [ + { + "parameters": {}, + "id": "webhook-trigger", + "name": "New Ticket Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 1, + "position": [250, 300], + "webhookId": "ticket-routing" + }, + { + "parameters": { + "functionCode": "const ticket = $input.all()[0].json;\nconst subject = (ticket.subject || '').toLowerCase();\nconst body = (ticket.body || '').toLowerCase();\nconst text = subject + ' ' + body;\n\n// Simple keyword-based routing\nlet category = 'general';\nlet priority = 'normal';\n\nif (text.includes('billing') || text.includes('invoice') || text.includes('payment') || text.includes('charge')) {\n category = 'billing';\n priority = 'high';\n} else if (text.includes('urgent') || text.includes('down') || text.includes('outage') || text.includes('crash')) {\n category = 'incident';\n priority = 'urgent';\n} else if (text.includes('feature') || text.includes('request') || text.includes('add') || text.includes('new')) {\n category = 'feature-request';\n priority = 'low';\n} else if (text.includes('password') || text.includes('login') || text.includes('access') || text.includes('account')) {\n category = 'account';\n priority = 'normal';\n} else if (text.includes('bug') || text.includes('error') || text.includes('broken') || text.includes('not working')) {\n category = 'bug';\n priority = 'high';\n}\n\nreturn [{ json: { ...ticket, category, priority } }];" + }, + "id": "classify-ticket", + "name": "Classify Ticket", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [450, 300] + }, + { + "parameters": { + "conditions": { + "string": [ + { "value1": "={{ $json.priority }}", "operation": "equals", "value2": "urgent" } + ] + } + }, + "id": "check-urgent", + "name": "Is Urgent?", + "type": "n8n-nodes-base.if", + "typeVersion": 1, + "position": [650, 300] + }, + { + "parameters": { + "channel": "#helpdesk-urgent", + "text": "🚨 URGENT TICKET\n\n**Subject:** {{ $json.subject }}\n**Category:** {{ $json.category }}\n**From:** {{ $json.user_id }}\n\n{{ $json.body }}" + }, + "id": "alert-urgent", + "name": "Alert Urgent", + "type": "n8n-nodes-base.slack", + "typeVersion": 2, + "position": [850, 200] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $.env.HELPDESK_AGENT_URL }}/chat", + "jsonParameters": true, + "bodyParametersJson": "={{ JSON.stringify({ message: 'New ticket routed: ' + $json.subject + ' | Category: ' + $json.category + ' | Priority: ' + $json.priority, user_id: 'system', platform: 'internal' }) }}", + "options": {} + }, + "id": "log-routing", + "name": "Log Routing Decision", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 3, + "position": [850, 400] + } + ], + "connections": { + "New Ticket Webhook": { + "main": [[{ "node": "Classify Ticket", "type": "main", "index": 0 }]] + }, + "Classify Ticket": { + "main": [[{ "node": "Is Urgent?", "type": "main", "index": 0 }]] + }, + "Is Urgent?": { + "main": [ + [{ "node": "Alert Urgent", "type": "main", "index": 0 }], + [{ "node": "Log Routing Decision", "type": "main", "index": 0 }] + ] + }, + "Alert Urgent": { + "main": [[{ "node": "Log Routing Decision", "type": "main", "index": 0 }]] + } + } +}