Itsjustamit commited on
Commit
b998556
·
verified ·
1 Parent(s): 1482348

Upload 40 files

Browse files
Files changed (40) hide show
  1. Dockerfile +25 -0
  2. README.md +9 -10
  3. README_SETUP.md +176 -0
  4. app/__init__.py +1 -0
  5. app/__pycache__/__init__.cpython-311.pyc +0 -0
  6. app/__pycache__/config.cpython-311.pyc +0 -0
  7. app/__pycache__/main.cpython-311.pyc +0 -0
  8. app/agent/__pycache__/orchestrator.cpython-311.pyc +0 -0
  9. app/agent/orchestrator.py +92 -0
  10. app/config.py +38 -0
  11. app/db/__pycache__/models.cpython-311.pyc +0 -0
  12. app/db/__pycache__/repo.cpython-311.pyc +0 -0
  13. app/db/models.py +78 -0
  14. app/db/repo.py +254 -0
  15. app/llm/__pycache__/client.cpython-311.pyc +0 -0
  16. app/llm/client.py +95 -0
  17. app/main.py +49 -0
  18. app/telegram/__pycache__/handler.cpython-311.pyc +0 -0
  19. app/telegram/handler.py +111 -0
  20. app/tools/__pycache__/mock_paytm.cpython-311.pyc +0 -0
  21. app/tools/mock_paytm.py +57 -0
  22. app/voice/__pycache__/stt.cpython-311.pyc +0 -0
  23. app/voice/stt.py +77 -0
  24. context.md +50 -0
  25. paytm llm api.md +104 -0
  26. paytm_llm_api_docs.md +152 -0
  27. requirements.txt +5 -0
  28. scripts/__pycache__/seed_scenarios.cpython-311.pyc +0 -0
  29. scripts/seed_scenarios.py +14 -0
  30. tests/__pycache__/test_context_memory.cpython-311-pytest-9.0.2.pyc +0 -0
  31. tests/__pycache__/test_mock_tools.cpython-311-pytest-9.0.2.pyc +0 -0
  32. tests/__pycache__/test_orchestrator.cpython-311-pytest-9.0.2.pyc +0 -0
  33. tests/__pycache__/test_smoke.cpython-311-pytest-9.0.2.pyc +0 -0
  34. tests/__pycache__/test_telegram_handler.cpython-311-pytest-9.0.2.pyc +0 -0
  35. tests/test_context_memory.py +148 -0
  36. tests/test_mock_tools.py +28 -0
  37. tests/test_orchestrator.py +30 -0
  38. tests/test_smoke.py +26 -0
  39. tests/test_telegram_handler.py +36 -0
  40. via.sqlite3 +0 -0
Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /code
4
+
5
+ # Copy and install requirements as root
6
+ COPY ./requirements.txt /code/requirements.txt
7
+ RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
8
+
9
+ # Set up a new user named "user" with user ID 1000
10
+ RUN useradd -m -u 1000 user
11
+
12
+ # Switch to the "user" user
13
+ USER user
14
+ ENV HOME=/home/user \
15
+ PATH=/home/user/.local/bin:$PATH
16
+
17
+ WORKDIR $HOME/app
18
+
19
+ # Copy the rest of the app with correct ownership
20
+ COPY --chown=user . $HOME/app
21
+
22
+ # Expose port 7860 which is the default for Hugging Face Spaces
23
+ EXPOSE 7860
24
+
25
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,11 +1,10 @@
1
- ---
2
- title: Via Demo
3
- emoji: 📈
4
- colorFrom: gray
5
- colorTo: red
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
1
+ # Via
 
 
 
 
 
 
 
 
2
 
3
+ Via is a Python service for handling Telegram workflows and backend integrations.
4
+
5
+ ## Quick Start
6
+
7
+ 1. Create and activate a virtual environment.
8
+ 2. Install dependencies from `requirements.txt`.
9
+ 3. Copy `.env.example` to `.env` and fill in required values.
10
+ 4. Run the app according to project scripts and setup docs.
README_SETUP.md ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Via Telegram Setup Guide (Simple V1)
2
+
3
+ This guide gets your bot working on Telegram with:
4
+ - mocked Paytm-like MCP tools (local only)
5
+ - Groq inference APIs for LLM (and optional STT)
6
+
7
+ No real Paytm payment APIs are used.
8
+
9
+ ## 1) Prerequisites
10
+
11
+ - Python 3.11+
12
+ - A Telegram account
13
+ - A bot token from [@BotFather](https://t.me/BotFather)
14
+ - A public HTTPS URL for webhook testing (for local dev use [ngrok](https://ngrok.com/))
15
+ - A Groq API key from [Groq Console](https://console.groq.com/keys)
16
+
17
+ ## 2) Install and configure
18
+
19
+ From project root:
20
+
21
+ ```bash
22
+ python3 -m venv .venv
23
+ source .venv/bin/activate
24
+ python3 -m pip install -r requirements.txt
25
+ cp .env.example .env
26
+ ```
27
+
28
+ Set `.env` values:
29
+
30
+ ```env
31
+ APP_ENV=dev
32
+ TELEGRAM_BOT_TOKEN=<your_telegram_bot_token>
33
+ TELEGRAM_WEBHOOK_SECRET=<long_random_string>
34
+
35
+ GROQ_API_KEY=<your_groq_api_key>
36
+ LLM_INFERENCE_URL=https://api.groq.com/openai/v1/chat/completions
37
+ LLM_INFERENCE_API_KEY=
38
+ LLM_MODEL=llama-3.3-70b-versatile
39
+
40
+ STT_INFERENCE_URL=https://api.groq.com/openai/v1/audio/transcriptions
41
+ STT_API_KEY=
42
+ STT_MODEL=whisper-large-v3-turbo
43
+
44
+ DB_PATH=via.sqlite3
45
+ ```
46
+
47
+ ## 3) Run the API
48
+
49
+ ```bash
50
+ source .venv/bin/activate
51
+ uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
52
+ ```
53
+
54
+ Health check:
55
+
56
+ ```bash
57
+ curl http://127.0.0.1:8000/health
58
+ ```
59
+
60
+ Expected:
61
+
62
+ ```json
63
+ {"status":"ok","env":"dev"}
64
+ ```
65
+
66
+ ## 4) Expose local server to Telegram
67
+
68
+ In a second terminal:
69
+
70
+ ```bash
71
+ ngrok http 8000
72
+ ```
73
+
74
+ Copy the HTTPS forwarding URL, e.g. `https://abc123.ngrok-free.app`.
75
+
76
+ ## 5) Register Telegram webhook
77
+
78
+ Replace placeholders and run:
79
+
80
+ ```bash
81
+ curl -X POST "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \
82
+ -H "Content-Type: application/json" \
83
+ -d '{
84
+ "url": "https://<YOUR_PUBLIC_HOST>/telegram/webhook",
85
+ "secret_token": "<TELEGRAM_WEBHOOK_SECRET>"
86
+ }'
87
+ ```
88
+
89
+ Verify webhook:
90
+
91
+ ```bash
92
+ curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/getWebhookInfo"
93
+ ```
94
+
95
+ `url` should point to `/telegram/webhook` and `last_error_message` should be empty.
96
+
97
+ ## 6) Test from Telegram app
98
+
99
+ Open your bot chat and send:
100
+
101
+ - `show recent orders`
102
+ - `show settlement summary`
103
+ - `create payment link 250`
104
+ - `refund txn-2001 amount 50`
105
+ - `confirm`
106
+ - `/debug last_update_type` (shows current Telegram update type and last processed type)
107
+
108
+ Voice test:
109
+ - Send a voice note like: `refund txn-2001 amount 30`
110
+ - Bot should reply with transcript + action prompt.
111
+
112
+ ## 7) Expected behavior
113
+
114
+ - Orders/links/refunds/settlements come from local mock DB.
115
+ - Refund mutation is gated:
116
+ - first message prepares action
117
+ - `confirm` executes it
118
+ - Tool calls are audited in `tool_audit` table.
119
+
120
+ ## 8) Quick troubleshooting
121
+
122
+ - `401 invalid webhook secret`
123
+ - `.env` secret and Telegram `setWebhook secret_token` must match exactly.
124
+
125
+ - Telegram webhook not hitting local app
126
+ - ensure ngrok URL is alive and `/telegram/webhook` is reachable.
127
+ - re-run `setWebhook` each time ngrok URL changes.
128
+
129
+ - LLM fallback generic/error response
130
+ - verify `GROQ_API_KEY` is valid.
131
+ - verify `LLM_INFERENCE_URL` is `.../chat/completions`.
132
+ - check model name in `LLM_MODEL`.
133
+
134
+ - Voice transcription unavailable
135
+ - verify `STT_INFERENCE_URL` ends with `/audio/transcriptions`.
136
+ - verify `STT_API_KEY` (or `GROQ_API_KEY`) is present.
137
+ - ensure your bot received a real Telegram `voice` message (not audio/file attachment).
138
+
139
+ ## 9) Local validation before Telegram
140
+
141
+ Run tests:
142
+
143
+ ```bash
144
+ python3 -m pytest -q
145
+ ```
146
+
147
+ Seed richer mock scenarios (recommended before Telegram testing):
148
+
149
+ ```bash
150
+ python3 -m scripts.seed_scenarios
151
+ ```
152
+
153
+ This seeds multiple situations:
154
+ - healthy collections day
155
+ - UPI failure dip day
156
+ - pending + successful refunds
157
+ - multiple settlement payouts
158
+ - active/expired payment links
159
+
160
+ Manual webhook simulation:
161
+
162
+ ```bash
163
+ curl -X POST http://127.0.0.1:8000/telegram/webhook \
164
+ -H "Content-Type: application/json" \
165
+ -H "X-Telegram-Bot-Api-Secret-Token: <TELEGRAM_WEBHOOK_SECRET>" \
166
+ -d '{
167
+ "message": {
168
+ "chat": {"id": 12345},
169
+ "text": "show settlement summary"
170
+ }
171
+ }'
172
+ ```
173
+
174
+ ---
175
+
176
+ If you want, next step can be a production-ready `README.md` with Render deploy instructions and persistent Postgres instead of SQLite.
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Via application package."""
app/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (187 Bytes). View file
 
app/__pycache__/config.cpython-311.pyc ADDED
Binary file (2.24 kB). View file
 
app/__pycache__/main.cpython-311.pyc ADDED
Binary file (2.91 kB). View file
 
app/agent/__pycache__/orchestrator.cpython-311.pyc ADDED
Binary file (6.91 kB). View file
 
app/agent/orchestrator.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from typing import Any
3
+
4
+ from app.db.repo import Repository
5
+ from app.llm.client import LlmClient
6
+ from app.tools.mock_paytm import MockPaytmTools
7
+
8
+
9
+ class Orchestrator:
10
+ def __init__(self, repo: Repository, tools: MockPaytmTools, llm: LlmClient) -> None:
11
+ self.repo = repo
12
+ self.tools = tools
13
+ self.llm = llm
14
+
15
+ def handle_user_text(self, telegram_id: str, text: str, input_type: str = "text") -> str:
16
+ user_id = self.repo.ensure_user(telegram_id)
17
+ cleaned = (text or "").strip()
18
+ lowered = cleaned.lower()
19
+
20
+ if lowered.startswith("confirm"):
21
+ response = self._handle_confirmation(telegram_id)
22
+ self.repo.save_message(user_id, input_type, cleaned, response)
23
+ return response
24
+
25
+ if "settlement" in lowered:
26
+ summary = self.tools.get_settlement_summary()
27
+ s = summary["summary"]
28
+ response = (
29
+ f"📊 <b>Settlement</b>\n"
30
+ f"Gross: <b>₹{s['gross']:,.0f}</b> | Fee: ₹{s['fee']:,.0f} | Net: <b>₹{s['net']:,.0f}</b>"
31
+ )
32
+ self.repo.save_message(user_id, input_type, cleaned, response)
33
+ return response
34
+
35
+ if "order" in lowered:
36
+ data = self.tools.fetch_order_list()
37
+ top = ", ".join(f"<code>{o['order_id']}</code> {o['status']}" for o in data["orders"][:3])
38
+ response = f"📋 <b>{data['count']} orders</b>\n{top}"
39
+ self.repo.save_message(user_id, input_type, cleaned, response)
40
+ return response
41
+
42
+ if "link" in lowered and any(ch.isdigit() for ch in lowered):
43
+ amount = self._extract_first_amount(cleaned) or 100.0
44
+ link = self.tools.create_link(amount=amount)
45
+ response = f"✅ Payment link <code>{link['link_id']}</code> created for <b>₹{link['amount']:,.0f}</b>"
46
+ self.repo.save_message(user_id, input_type, cleaned, response)
47
+ return response
48
+
49
+ if "refund" in lowered:
50
+ txn_id = self._extract_token(cleaned, r"(TXN[-_ ]?\d+)")
51
+ amount = self._extract_first_amount(cleaned) or 100.0
52
+ if not txn_id:
53
+ response = "⚠️ Need a transaction ID to refund — e.g. <code>TXN-2001</code>"
54
+ self.repo.save_message(user_id, input_type, cleaned, response)
55
+ return response
56
+ self.repo.upsert_pending_action(
57
+ telegram_id,
58
+ action_type="initiate_refund",
59
+ payload={"txn_id": txn_id.replace(" ", "-").replace("_", "-"), "amount": amount},
60
+ )
61
+ response = f"💸 Refund <code>{txn_id}</code> for <b>₹{amount:,.0f}</b>\n\nReply <b>confirm</b> to proceed."
62
+ self.repo.save_message(user_id, input_type, cleaned, response)
63
+ return response
64
+
65
+ context = self.repo.get_recent_context(user_id)
66
+ llm_text = self.llm.generate(cleaned, history=context)
67
+ self.repo.save_message(user_id, input_type, cleaned, llm_text)
68
+ return llm_text
69
+
70
+ def _handle_confirmation(self, telegram_id: str) -> str:
71
+ pending = self.repo.pop_pending_action(telegram_id)
72
+ if not pending:
73
+ return "Nothing pending to confirm."
74
+ if pending["action_type"] == "initiate_refund":
75
+ payload = pending["payload"]
76
+ result = self.tools.initiate_refund(payload["txn_id"], float(payload["amount"]))
77
+ return f"✅ Refund initiated\n<code>{result['refund_id']}</code> — {result['status']}"
78
+ return "Pending action type not supported."
79
+
80
+ @staticmethod
81
+ def _extract_first_amount(text: str) -> float | None:
82
+ match = re.search(r"(\d+(?:\.\d{1,2})?)", text)
83
+ if not match:
84
+ return None
85
+ return float(match.group(1))
86
+
87
+ @staticmethod
88
+ def _extract_token(text: str, pattern: str) -> str | None:
89
+ match = re.search(pattern, text, re.IGNORECASE)
90
+ if not match:
91
+ return None
92
+ return str(match.group(1)).upper()
app/config.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class Settings:
7
+ app_env: str
8
+ telegram_bot_token: str
9
+ telegram_webhook_secret: str
10
+ llm_inference_url: str
11
+ llm_inference_api_key: str
12
+ llm_model: str
13
+ stt_inference_url: str
14
+ stt_api_key: str
15
+ stt_model: str
16
+ db_path: str
17
+
18
+
19
+ def load_settings() -> Settings:
20
+ groq_key = os.getenv("GROQ_API_KEY", "")
21
+ llm_key = os.getenv("LLM_INFERENCE_API_KEY", "") or groq_key
22
+ stt_key = os.getenv("STT_API_KEY", "") or llm_key or groq_key
23
+ return Settings(
24
+ app_env=os.getenv("APP_ENV", "dev"),
25
+ telegram_bot_token=os.getenv("TELEGRAM_BOT_TOKEN", ""),
26
+ telegram_webhook_secret=os.getenv("TELEGRAM_WEBHOOK_SECRET", "local-secret"),
27
+ llm_inference_url=os.getenv(
28
+ "LLM_INFERENCE_URL", "https://api.groq.com/openai/v1/chat/completions"
29
+ ),
30
+ llm_inference_api_key=llm_key,
31
+ llm_model=os.getenv("LLM_MODEL", "llama-3.3-70b-versatile"),
32
+ stt_inference_url=os.getenv(
33
+ "STT_INFERENCE_URL", "https://api.groq.com/openai/v1/audio/transcriptions"
34
+ ),
35
+ stt_api_key=stt_key,
36
+ stt_model=os.getenv("STT_MODEL", "whisper-large-v3-turbo"),
37
+ db_path=os.getenv("DB_PATH", "via.sqlite3"),
38
+ )
app/db/__pycache__/models.cpython-311.pyc ADDED
Binary file (2.32 kB). View file
 
app/db/__pycache__/repo.cpython-311.pyc ADDED
Binary file (20.6 kB). View file
 
app/db/models.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ SCHEMA_SQL = """
2
+ CREATE TABLE IF NOT EXISTS users (
3
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
4
+ telegram_id TEXT UNIQUE NOT NULL,
5
+ preferred_language TEXT DEFAULT 'en'
6
+ );
7
+
8
+ CREATE TABLE IF NOT EXISTS messages (
9
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
10
+ user_id INTEGER NOT NULL,
11
+ input_type TEXT NOT NULL,
12
+ transcript_text TEXT,
13
+ response_text TEXT,
14
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
15
+ FOREIGN KEY(user_id) REFERENCES users(id)
16
+ );
17
+
18
+ CREATE TABLE IF NOT EXISTS orders (
19
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
20
+ order_id TEXT UNIQUE NOT NULL,
21
+ amount REAL NOT NULL,
22
+ status TEXT NOT NULL,
23
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
24
+ );
25
+
26
+ CREATE TABLE IF NOT EXISTS payments (
27
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
28
+ txn_id TEXT UNIQUE NOT NULL,
29
+ order_id TEXT NOT NULL,
30
+ mode TEXT NOT NULL,
31
+ status TEXT NOT NULL,
32
+ gateway TEXT NOT NULL,
33
+ amount REAL NOT NULL
34
+ );
35
+
36
+ CREATE TABLE IF NOT EXISTS payment_links (
37
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
38
+ link_id TEXT UNIQUE NOT NULL,
39
+ amount REAL NOT NULL,
40
+ customer_name TEXT,
41
+ status TEXT NOT NULL DEFAULT 'ACTIVE',
42
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
43
+ );
44
+
45
+ CREATE TABLE IF NOT EXISTS refunds (
46
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
47
+ refund_id TEXT UNIQUE NOT NULL,
48
+ txn_id TEXT NOT NULL,
49
+ amount REAL NOT NULL,
50
+ status TEXT NOT NULL,
51
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
52
+ );
53
+
54
+ CREATE TABLE IF NOT EXISTS settlements (
55
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
56
+ payout_id TEXT UNIQUE NOT NULL,
57
+ payout_date TEXT NOT NULL,
58
+ gross REAL NOT NULL,
59
+ fee REAL NOT NULL,
60
+ net REAL NOT NULL
61
+ );
62
+
63
+ CREATE TABLE IF NOT EXISTS tool_audit (
64
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
65
+ tool_name TEXT NOT NULL,
66
+ args_json TEXT NOT NULL,
67
+ result_json TEXT NOT NULL,
68
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
69
+ );
70
+
71
+ CREATE TABLE IF NOT EXISTS pending_actions (
72
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
73
+ telegram_id TEXT UNIQUE NOT NULL,
74
+ action_type TEXT NOT NULL,
75
+ payload_json TEXT NOT NULL,
76
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
77
+ );
78
+ """
app/db/repo.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import sqlite3
3
+ from contextlib import contextmanager
4
+ from typing import Any
5
+
6
+ from app.db.models import SCHEMA_SQL
7
+
8
+
9
+ class Repository:
10
+ def __init__(self, db_path: str) -> None:
11
+ self.db_path = db_path
12
+ self._initialize()
13
+
14
+ @contextmanager
15
+ def _connect(self):
16
+ conn = sqlite3.connect(self.db_path)
17
+ conn.row_factory = sqlite3.Row
18
+ try:
19
+ yield conn
20
+ finally:
21
+ conn.close()
22
+
23
+ def _initialize(self) -> None:
24
+ with self._connect() as conn:
25
+ conn.executescript(SCHEMA_SQL)
26
+ conn.commit()
27
+
28
+ def ensure_user(self, telegram_id: str) -> int:
29
+ with self._connect() as conn:
30
+ row = conn.execute(
31
+ "SELECT id FROM users WHERE telegram_id = ?", (telegram_id,)
32
+ ).fetchone()
33
+ if row:
34
+ return int(row["id"])
35
+ cursor = conn.execute(
36
+ "INSERT INTO users(telegram_id) VALUES(?)",
37
+ (telegram_id,),
38
+ )
39
+ conn.commit()
40
+ return int(cursor.lastrowid)
41
+
42
+ def save_message(
43
+ self, user_id: int, input_type: str, transcript_text: str, response_text: str
44
+ ) -> None:
45
+ with self._connect() as conn:
46
+ conn.execute(
47
+ """
48
+ INSERT INTO messages(user_id, input_type, transcript_text, response_text)
49
+ VALUES(?, ?, ?, ?)
50
+ """,
51
+ (user_id, input_type, transcript_text, response_text),
52
+ )
53
+ conn.commit()
54
+
55
+ def get_last_input_type_for_telegram_user(self, telegram_id: str) -> str | None:
56
+ with self._connect() as conn:
57
+ row = conn.execute(
58
+ """
59
+ SELECT m.input_type
60
+ FROM messages m
61
+ JOIN users u ON u.id = m.user_id
62
+ WHERE u.telegram_id = ?
63
+ ORDER BY m.id DESC
64
+ LIMIT 1
65
+ """,
66
+ (telegram_id,),
67
+ ).fetchone()
68
+ return str(row["input_type"]) if row else None
69
+
70
+ def get_recent_context(
71
+ self, user_id: int, limit: int = 20, max_age_minutes: int = 5,
72
+ ) -> list[dict[str, str]]:
73
+ """Return recent messages as LLM-ready [{role, content}, ...] list.
74
+
75
+ Only includes messages from the last *max_age_minutes* to avoid
76
+ injecting stale context from earlier sessions.
77
+ """
78
+ with self._connect() as conn:
79
+ rows = conn.execute(
80
+ """
81
+ SELECT transcript_text, response_text
82
+ FROM messages
83
+ WHERE user_id = ?
84
+ AND created_at >= datetime('now', ?)
85
+ ORDER BY id DESC
86
+ LIMIT ?
87
+ """,
88
+ (user_id, f"-{max_age_minutes} minutes", limit),
89
+ ).fetchall()
90
+ messages: list[dict[str, str]] = []
91
+ for row in reversed(rows):
92
+ messages.append({"role": "user", "content": row["transcript_text"]})
93
+ messages.append({"role": "assistant", "content": row["response_text"]})
94
+ return messages
95
+
96
+ def log_tool(self, tool_name: str, args: dict[str, Any], result: dict[str, Any]) -> None:
97
+ with self._connect() as conn:
98
+ conn.execute(
99
+ """
100
+ INSERT INTO tool_audit(tool_name, args_json, result_json)
101
+ VALUES(?, ?, ?)
102
+ """,
103
+ (tool_name, json.dumps(args), json.dumps(result)),
104
+ )
105
+ conn.commit()
106
+
107
+ def upsert_pending_action(
108
+ self, telegram_id: str, action_type: str, payload: dict[str, Any]
109
+ ) -> None:
110
+ payload_json = json.dumps(payload)
111
+ with self._connect() as conn:
112
+ conn.execute(
113
+ """
114
+ INSERT INTO pending_actions(telegram_id, action_type, payload_json)
115
+ VALUES(?, ?, ?)
116
+ ON CONFLICT(telegram_id) DO UPDATE SET
117
+ action_type = excluded.action_type,
118
+ payload_json = excluded.payload_json
119
+ """,
120
+ (telegram_id, action_type, payload_json),
121
+ )
122
+ conn.commit()
123
+
124
+ def pop_pending_action(self, telegram_id: str) -> dict[str, Any] | None:
125
+ with self._connect() as conn:
126
+ row = conn.execute(
127
+ "SELECT action_type, payload_json FROM pending_actions WHERE telegram_id = ?",
128
+ (telegram_id,),
129
+ ).fetchone()
130
+ if not row:
131
+ return None
132
+ conn.execute("DELETE FROM pending_actions WHERE telegram_id = ?", (telegram_id,))
133
+ conn.commit()
134
+ return {
135
+ "action_type": row["action_type"],
136
+ "payload": json.loads(row["payload_json"]),
137
+ }
138
+
139
+ def create_payment_link(self, link_id: str, amount: float, customer_name: str) -> dict[str, Any]:
140
+ with self._connect() as conn:
141
+ conn.execute(
142
+ "INSERT INTO payment_links(link_id, amount, customer_name) VALUES(?, ?, ?)",
143
+ (link_id, amount, customer_name),
144
+ )
145
+ conn.commit()
146
+ return {"link_id": link_id, "amount": amount, "customer_name": customer_name, "status": "ACTIVE"}
147
+
148
+ def get_payment_link(self, link_id: str) -> dict[str, Any] | None:
149
+ with self._connect() as conn:
150
+ row = conn.execute(
151
+ "SELECT link_id, amount, customer_name, status FROM payment_links WHERE link_id = ?",
152
+ (link_id,),
153
+ ).fetchone()
154
+ if not row:
155
+ return None
156
+ return dict(row)
157
+
158
+ def list_orders(self, limit: int = 10) -> list[dict[str, Any]]:
159
+ with self._connect() as conn:
160
+ rows = conn.execute(
161
+ "SELECT order_id, amount, status, created_at FROM orders ORDER BY id DESC LIMIT ?",
162
+ (limit,),
163
+ ).fetchall()
164
+ return [dict(row) for row in rows]
165
+
166
+ def create_refund(self, refund_id: str, txn_id: str, amount: float, status: str) -> dict[str, Any]:
167
+ with self._connect() as conn:
168
+ conn.execute(
169
+ "INSERT INTO refunds(refund_id, txn_id, amount, status) VALUES(?, ?, ?, ?)",
170
+ (refund_id, txn_id, amount, status),
171
+ )
172
+ conn.commit()
173
+ return {"refund_id": refund_id, "txn_id": txn_id, "amount": amount, "status": status}
174
+
175
+ def get_refund(self, refund_id: str) -> dict[str, Any] | None:
176
+ with self._connect() as conn:
177
+ row = conn.execute(
178
+ "SELECT refund_id, txn_id, amount, status FROM refunds WHERE refund_id = ?",
179
+ (refund_id,),
180
+ ).fetchone()
181
+ return dict(row) if row else None
182
+
183
+ def list_refunds(self, limit: int = 10) -> list[dict[str, Any]]:
184
+ with self._connect() as conn:
185
+ rows = conn.execute(
186
+ "SELECT refund_id, txn_id, amount, status, created_at FROM refunds ORDER BY id DESC LIMIT ?",
187
+ (limit,),
188
+ ).fetchall()
189
+ return [dict(row) for row in rows]
190
+
191
+ def get_settlement_summary(self) -> dict[str, Any]:
192
+ with self._connect() as conn:
193
+ row = conn.execute(
194
+ "SELECT COALESCE(SUM(gross), 0) AS gross, COALESCE(SUM(fee), 0) AS fee, COALESCE(SUM(net), 0) AS net FROM settlements"
195
+ ).fetchone()
196
+ return dict(row)
197
+
198
+ def list_settlement_details(self, limit: int = 20) -> list[dict[str, Any]]:
199
+ with self._connect() as conn:
200
+ rows = conn.execute(
201
+ "SELECT payout_id, payout_date, gross, fee, net FROM settlements ORDER BY payout_date DESC LIMIT ?",
202
+ (limit,),
203
+ ).fetchall()
204
+ return [dict(row) for row in rows]
205
+
206
+ def clear_mock_data(self) -> None:
207
+ with self._connect() as conn:
208
+ conn.execute("DELETE FROM tool_audit")
209
+ conn.execute("DELETE FROM pending_actions")
210
+ conn.execute("DELETE FROM messages")
211
+ conn.execute("DELETE FROM payment_links")
212
+ conn.execute("DELETE FROM refunds")
213
+ conn.execute("DELETE FROM payments")
214
+ conn.execute("DELETE FROM settlements")
215
+ conn.execute("DELETE FROM orders")
216
+ conn.commit()
217
+
218
+ def seed_demo_data(self, force: bool = False) -> None:
219
+ with self._connect() as conn:
220
+ has_orders = conn.execute("SELECT COUNT(*) AS c FROM orders").fetchone()["c"]
221
+ if has_orders and not force:
222
+ return
223
+ if force:
224
+ conn.execute("DELETE FROM payment_links")
225
+ conn.execute("DELETE FROM refunds")
226
+ conn.execute("DELETE FROM payments")
227
+ conn.execute("DELETE FROM settlements")
228
+ conn.execute("DELETE FROM orders")
229
+
230
+ # Scenario A: Healthy day with mostly successful UPI/card payments.
231
+ conn.execute("INSERT INTO orders(order_id, amount, status) VALUES ('ORD-1001', 799.0, 'SUCCESS')")
232
+ conn.execute("INSERT INTO orders(order_id, amount, status) VALUES ('ORD-1002', 1299.0, 'SUCCESS')")
233
+ conn.execute("INSERT INTO payments(txn_id, order_id, mode, status, gateway, amount) VALUES ('TXN-2001', 'ORD-1001', 'UPI', 'SUCCESS', 'MOCK_GATEWAY_A', 799.0)")
234
+ conn.execute("INSERT INTO payments(txn_id, order_id, mode, status, gateway, amount) VALUES ('TXN-2002', 'ORD-1002', 'CC', 'SUCCESS', 'MOCK_GATEWAY_A', 1299.0)")
235
+
236
+ # Scenario B: UPI dip and checkout failures.
237
+ conn.execute("INSERT INTO orders(order_id, amount, status) VALUES ('ORD-1003', 499.0, 'FAILED')")
238
+ conn.execute("INSERT INTO orders(order_id, amount, status) VALUES ('ORD-1004', 650.0, 'FAILED')")
239
+ conn.execute("INSERT INTO payments(txn_id, order_id, mode, status, gateway, amount) VALUES ('TXN-2003', 'ORD-1003', 'UPI', 'FAILED', 'MOCK_GATEWAY_B', 499.0)")
240
+ conn.execute("INSERT INTO payments(txn_id, order_id, mode, status, gateway, amount) VALUES ('TXN-2004', 'ORD-1004', 'UPI', 'FAILED', 'MOCK_GATEWAY_B', 650.0)")
241
+
242
+ # Scenario C: Refund queue with mixed statuses.
243
+ conn.execute("INSERT INTO refunds(refund_id, txn_id, amount, status) VALUES ('RFND-3001', 'TXN-2001', 99.0, 'SUCCESS')")
244
+ conn.execute("INSERT INTO refunds(refund_id, txn_id, amount, status) VALUES ('RFND-3002', 'TXN-2004', 300.0, 'PENDING')")
245
+
246
+ # Scenario D: Settlement delays and payout variances.
247
+ conn.execute("INSERT INTO settlements(payout_id, payout_date, gross, fee, net) VALUES ('PAYOUT-4001', '2026-03-20', 2098.0, 42.0, 2056.0)")
248
+ conn.execute("INSERT INTO settlements(payout_id, payout_date, gross, fee, net) VALUES ('PAYOUT-4002', '2026-03-19', 1450.0, 31.0, 1419.0)")
249
+ conn.execute("INSERT INTO settlements(payout_id, payout_date, gross, fee, net) VALUES ('PAYOUT-4003', '2026-03-18', 980.0, 22.0, 958.0)")
250
+
251
+ # Scenario E: Existing payment links for follow-up checks.
252
+ conn.execute("INSERT INTO payment_links(link_id, amount, customer_name, status) VALUES ('LINK-5001', 550.0, 'Aman Traders', 'ACTIVE')")
253
+ conn.execute("INSERT INTO payment_links(link_id, amount, customer_name, status) VALUES ('LINK-5002', 1200.0, 'Neha Retail', 'EXPIRED')")
254
+ conn.commit()
app/llm/__pycache__/client.cpython-311.pyc ADDED
Binary file (5.25 kB). View file
 
app/llm/client.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any
2
+
3
+ import requests
4
+
5
+
6
+ class LlmClient:
7
+ def __init__(self, inference_url: str, api_key: str, model: str) -> None:
8
+ self.inference_url = inference_url
9
+ self.api_key = api_key
10
+ self.model = model
11
+
12
+ def generate(self, prompt: str, history: list[dict[str, str]] | None = None) -> str:
13
+ if not self.inference_url:
14
+ return "I can help with orders, links, refunds, and settlements. Tell me what you want to check."
15
+ try:
16
+ payload = {
17
+ "model": self.model,
18
+ "messages": [
19
+ {"role": "system", "content": (
20
+ "You are Via, a merchant's payment copilot on Telegram.\n\n"
21
+ "RULES:\n"
22
+ "- Be brief. 2-3 lines by default. Only expand if the user asks "
23
+ "\"tell me more\", \"details\", or \"explain\".\n"
24
+ "- Use Telegram HTML formatting ONLY (never markdown):\n"
25
+ " <b>bold</b> for key numbers and labels,\n"
26
+ " <i>italic</i> for emphasis,\n"
27
+ " <code>TXN-2001</code> for IDs.\n"
28
+ "- Lead with the answer. No preamble like \"Here are my insights:\" "
29
+ "or \"Based on the data:\".\n"
30
+ "- Sound human — like a sharp assistant texting, not an AI writing "
31
+ "a report. No bullet-point essays.\n"
32
+ "- Use ₹ for currency (Indian merchants).\n"
33
+ "- If data shows a problem, state it plainly and offer ONE actionable "
34
+ "next step — not a 10-point plan.\n"
35
+ "- Use line breaks to separate thoughts. Never use markdown "
36
+ "(**, ##, -). Only HTML tags.\n"
37
+ "- Emojis sparingly: ✅ ❌ ⚠️ 📊 for status indicators only.\n"
38
+ "- Never end with \"Let me know if you'd like me to elaborate\" or "
39
+ "similar filler.\n"
40
+ "- NEVER make up data, transaction IDs, or order details.\n"
41
+ "- If you do not have the requested information in your chat history, "
42
+ "explicitly state that you don't know or ask the user to provide the exact ID."
43
+ )},
44
+ *(history or []),
45
+ {"role": "user", "content": prompt},
46
+ ],
47
+ "temperature": 0.0,
48
+ }
49
+ headers: dict[str, Any] = {"Content-Type": "application/json"}
50
+ if self.api_key:
51
+ headers["Authorization"] = f"Bearer {self.api_key}"
52
+ response = requests.post(
53
+ self._normalize_chat_url(self.inference_url),
54
+ json=payload,
55
+ headers=headers,
56
+ timeout=20,
57
+ )
58
+ response.raise_for_status()
59
+ data = response.json()
60
+ except requests.HTTPError as exc:
61
+ status = exc.response.status_code if exc.response is not None else "unknown"
62
+ return (
63
+ f"LLM provider returned HTTP {status}. "
64
+ "Please check API key permissions/quota. I can still run mocked tools "
65
+ "for orders, links, refunds, and settlements."
66
+ )
67
+ except requests.RequestException:
68
+ return (
69
+ "LLM provider is currently unreachable. "
70
+ "I can still run mocked tools for orders, links, refunds, and settlements."
71
+ )
72
+
73
+ if isinstance(data, dict) and "success" in data:
74
+ if not data.get("success"):
75
+ return (
76
+ f"LLM provider error: {data.get('error', 'unknown_error')}. "
77
+ "I can still run mocked tools for orders, links, refunds, and settlements."
78
+ )
79
+ try:
80
+ return data["data"]["choices"][0]["message"]["content"]
81
+ except (KeyError, IndexError, TypeError):
82
+ return str(data.get("data", data))
83
+ try:
84
+ return data["choices"][0]["message"]["content"]
85
+ except (KeyError, IndexError, TypeError):
86
+ return str(data)
87
+
88
+ @staticmethod
89
+ def _normalize_chat_url(url: str) -> str:
90
+ normalized = (url or "").rstrip("/")
91
+ if normalized.endswith("/chat/completions"):
92
+ return normalized
93
+ if normalized.endswith("/openai/v1"):
94
+ return f"{normalized}/chat/completions"
95
+ return normalized
app/main.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, Header, HTTPException
2
+
3
+ from app.agent.orchestrator import Orchestrator
4
+ from app.config import load_settings
5
+ from app.db.repo import Repository
6
+ from app.llm.client import LlmClient
7
+ from app.telegram.handler import TelegramHandler
8
+ from app.tools.mock_paytm import MockPaytmTools
9
+ from app.voice.stt import SttClient
10
+
11
+ settings = load_settings()
12
+ repo = Repository(settings.db_path)
13
+ repo.seed_demo_data()
14
+ tools = MockPaytmTools(repo)
15
+ llm_client = LlmClient(
16
+ inference_url=settings.llm_inference_url,
17
+ api_key=settings.llm_inference_api_key,
18
+ model=settings.llm_model,
19
+ )
20
+ stt_client = SttClient(
21
+ settings.stt_inference_url,
22
+ settings.stt_api_key,
23
+ model=settings.stt_model,
24
+ )
25
+ orchestrator = Orchestrator(repo, tools, llm_client)
26
+ telegram_handler = TelegramHandler(orchestrator, stt_client, settings.telegram_bot_token)
27
+
28
+ app = FastAPI(title="Via Copilot API", version="0.1.0")
29
+
30
+
31
+ @app.get("/")
32
+ def read_root() -> dict[str, str]:
33
+ return {"message": "Via Copilot webhook server is running!"}
34
+
35
+
36
+ @app.get("/health")
37
+ def health() -> dict[str, str]:
38
+ return {"status": "ok", "env": settings.app_env}
39
+
40
+
41
+ @app.post("/telegram/webhook")
42
+ def telegram_webhook(
43
+ update: dict,
44
+ x_telegram_bot_api_secret_token: str | None = Header(default=None),
45
+ ) -> dict:
46
+ expected_secret = settings.telegram_webhook_secret
47
+ if expected_secret and x_telegram_bot_api_secret_token != expected_secret:
48
+ raise HTTPException(status_code=401, detail="invalid webhook secret")
49
+ return telegram_handler.process_update(update)
app/telegram/__pycache__/handler.cpython-311.pyc ADDED
Binary file (6.02 kB). View file
 
app/telegram/handler.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any
2
+
3
+ import requests
4
+
5
+ from app.agent.orchestrator import Orchestrator
6
+ from app.voice.stt import SttClient, SttProviderError
7
+
8
+
9
+ class TelegramHandler:
10
+ def __init__(
11
+ self,
12
+ orchestrator: Orchestrator,
13
+ stt_client: SttClient,
14
+ bot_token: str,
15
+ ) -> None:
16
+ self.orchestrator = orchestrator
17
+ self.stt_client = stt_client
18
+ self.bot_token = bot_token
19
+
20
+ def process_update(self, update: dict[str, Any]) -> dict[str, Any]:
21
+ message = update.get("message") or {}
22
+ chat = message.get("chat") or {}
23
+ chat_id = chat.get("id")
24
+ if chat_id is None:
25
+ return {"ok": True, "skipped": "no_chat"}
26
+ telegram_id = str(chat_id)
27
+
28
+ if "text" in message:
29
+ text = str(message.get("text", ""))
30
+ if text.strip().lower() == "/debug last_update_type":
31
+ last_type = self.orchestrator.repo.get_last_input_type_for_telegram_user(
32
+ telegram_id
33
+ )
34
+ current_type = "text"
35
+ debug_msg = (
36
+ f"debug current_update_type={current_type}, "
37
+ f"last_processed_input_type={last_type or 'none'}"
38
+ )
39
+ self._send_message(chat_id, debug_msg)
40
+ return {
41
+ "ok": True,
42
+ "input_type": "text",
43
+ "reply": debug_msg,
44
+ "debug": True,
45
+ }
46
+ reply = self.orchestrator.handle_user_text(telegram_id, text, input_type="text")
47
+ self._send_message(chat_id, reply)
48
+ return {"ok": True, "input_type": "text", "reply": reply}
49
+
50
+ if "voice" in message:
51
+ transcript = self._transcribe_media(message["voice"], "voice.ogg")
52
+ reply = self.orchestrator.handle_user_text(
53
+ telegram_id, transcript, input_type="voice"
54
+ )
55
+ self._send_message(chat_id, f"Transcribed: {transcript}\n\n{reply}")
56
+ return {"ok": True, "input_type": "voice", "reply": reply, "transcript": transcript}
57
+
58
+ if "audio" in message:
59
+ transcript = self._transcribe_media(message["audio"], "audio.m4a")
60
+ reply = self.orchestrator.handle_user_text(
61
+ telegram_id, transcript, input_type="voice"
62
+ )
63
+ self._send_message(chat_id, f"Transcribed: {transcript}\n\n{reply}")
64
+ return {"ok": True, "input_type": "voice", "reply": reply, "transcript": transcript}
65
+
66
+ return {"ok": True, "skipped": "unsupported_message_type"}
67
+
68
+ def _transcribe_media(self, media_obj: dict[str, Any], default_filename: str) -> str:
69
+ file_id = media_obj.get("file_id")
70
+ if not file_id or not self.bot_token:
71
+ return "voice transcription unavailable in local mode"
72
+ try:
73
+ file_resp = requests.get(
74
+ f"https://api.telegram.org/bot{self.bot_token}/getFile",
75
+ params={"file_id": file_id},
76
+ timeout=20,
77
+ )
78
+ file_resp.raise_for_status()
79
+ file_path = file_resp.json().get("result", {}).get("file_path")
80
+ if not file_path:
81
+ return "unable to fetch media file path from Telegram"
82
+
83
+ dl_resp = requests.get(
84
+ f"https://api.telegram.org/file/bot{self.bot_token}/{file_path}", timeout=30
85
+ )
86
+ dl_resp.raise_for_status()
87
+ filename = file_path.split("/")[-1] if "/" in file_path else default_filename
88
+ if "." not in filename:
89
+ filename = default_filename
90
+ transcript = self.stt_client.transcribe_bytes(dl_resp.content, filename=filename)
91
+ return transcript or "empty transcription"
92
+ except SttProviderError as exc:
93
+ return (
94
+ "voice transcription failed. "
95
+ f"provider_detail={exc}. "
96
+ "Tip: Telegram voice notes are usually OGG; if this keeps failing, send an audio file in m4a/mp3."
97
+ )
98
+ except requests.RequestException:
99
+ return "voice transcription failed due to Telegram network issue"
100
+
101
+ def _send_message(self, chat_id: int, text: str) -> None:
102
+ if not self.bot_token:
103
+ return
104
+ try:
105
+ requests.post(
106
+ f"https://api.telegram.org/bot{self.bot_token}/sendMessage",
107
+ json={"chat_id": chat_id, "text": text, "parse_mode": "HTML"},
108
+ timeout=20,
109
+ )
110
+ except requests.RequestException:
111
+ return
app/tools/__pycache__/mock_paytm.cpython-311.pyc ADDED
Binary file (5.05 kB). View file
 
app/tools/mock_paytm.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from typing import Any
3
+
4
+ from app.db.repo import Repository
5
+
6
+
7
+ class MockPaytmTools:
8
+ def __init__(self, repo: Repository) -> None:
9
+ self.repo = repo
10
+
11
+ def create_link(self, amount: float, customer_name: str = "merchant_customer") -> dict[str, Any]:
12
+ link_id = f"LINK-{int(time.time())}"
13
+ result = self.repo.create_payment_link(link_id=link_id, amount=amount, customer_name=customer_name)
14
+ self.repo.log_tool("create_link", {"amount": amount, "customer_name": customer_name}, result)
15
+ return result
16
+
17
+ def fetch_link(self, link_id: str) -> dict[str, Any]:
18
+ result = self.repo.get_payment_link(link_id)
19
+ payload = result or {"error": "link_not_found", "link_id": link_id}
20
+ self.repo.log_tool("fetch_link", {"link_id": link_id}, payload)
21
+ return payload
22
+
23
+ def fetch_order_list(self, limit: int = 10) -> dict[str, Any]:
24
+ rows = self.repo.list_orders(limit=limit)
25
+ result = {"orders": rows, "count": len(rows)}
26
+ self.repo.log_tool("fetch_order_list", {"limit": limit}, result)
27
+ return result
28
+
29
+ def initiate_refund(self, txn_id: str, amount: float) -> dict[str, Any]:
30
+ refund_id = f"RFND-{int(time.time())}"
31
+ result = self.repo.create_refund(refund_id=refund_id, txn_id=txn_id, amount=amount, status="PENDING")
32
+ self.repo.log_tool("initiate_refund", {"txn_id": txn_id, "amount": amount}, result)
33
+ return result
34
+
35
+ def check_refund_status(self, refund_id: str) -> dict[str, Any]:
36
+ result = self.repo.get_refund(refund_id)
37
+ payload = result or {"error": "refund_not_found", "refund_id": refund_id}
38
+ self.repo.log_tool("check_refund_status", {"refund_id": refund_id}, payload)
39
+ return payload
40
+
41
+ def fetch_refund_list(self, limit: int = 10) -> dict[str, Any]:
42
+ rows = self.repo.list_refunds(limit=limit)
43
+ result = {"refunds": rows, "count": len(rows)}
44
+ self.repo.log_tool("fetch_refund_list", {"limit": limit}, result)
45
+ return result
46
+
47
+ def get_settlement_summary(self) -> dict[str, Any]:
48
+ summary = self.repo.get_settlement_summary()
49
+ result = {"summary": summary}
50
+ self.repo.log_tool("get_settlement_summary", {}, result)
51
+ return result
52
+
53
+ def get_settlement_detail(self, limit: int = 20) -> dict[str, Any]:
54
+ rows = self.repo.list_settlement_details(limit=limit)
55
+ result = {"settlements": rows, "count": len(rows)}
56
+ self.repo.log_tool("get_settlement_detail", {"limit": limit}, result)
57
+ return result
app/voice/__pycache__/stt.cpython-311.pyc ADDED
Binary file (4.84 kB). View file
 
app/voice/stt.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any
2
+
3
+ import requests
4
+
5
+
6
+ class SttProviderError(Exception):
7
+ pass
8
+
9
+
10
+ class SttClient:
11
+ def __init__(self, inference_url: str, api_key: str, model: str = "whisper-large-v3") -> None:
12
+ self.inference_url = inference_url
13
+ self.api_key = api_key
14
+ self.model = model
15
+
16
+ def transcribe_bytes(self, audio_bytes: bytes, filename: str = "voice.ogg") -> str:
17
+ if not self.inference_url:
18
+ return "voice transcription unavailable in local mode"
19
+ headers: dict[str, Any] = {}
20
+ if self.api_key:
21
+ headers["Authorization"] = f"Bearer {self.api_key}"
22
+ data = {
23
+ "model": self.model,
24
+ "response_format": "verbose_json",
25
+ "temperature": "0",
26
+ }
27
+ safe_filename = self._ensure_supported_extension(filename)
28
+ mime_candidates = [None] + self._mime_candidates(safe_filename)
29
+ last_error: str | None = None
30
+ for mime in mime_candidates:
31
+ try:
32
+ if mime is None:
33
+ files = {"file": (safe_filename, audio_bytes)}
34
+ else:
35
+ files = {"file": (safe_filename, audio_bytes, mime)}
36
+ response = requests.post(
37
+ self.inference_url, headers=headers, files=files, data=data, timeout=40
38
+ )
39
+ response.raise_for_status()
40
+ payload = response.json()
41
+ text = str(payload.get("text", "")).strip()
42
+ if text:
43
+ return text
44
+ return "empty transcription"
45
+ except requests.HTTPError as exc:
46
+ status = exc.response.status_code if exc.response is not None else "unknown"
47
+ body = ""
48
+ if exc.response is not None:
49
+ body = exc.response.text[:300]
50
+ last_error = f"stt_http_{status}: {body}"
51
+ except requests.RequestException as exc:
52
+ last_error = f"stt_network_error: {exc}"
53
+ raise SttProviderError(last_error or "stt_unknown_error")
54
+
55
+ @staticmethod
56
+ def _mime_candidates(filename: str) -> list[str]:
57
+ lower = (filename or "").lower()
58
+ if lower.endswith(".ogg") or lower.endswith(".oga"):
59
+ # Telegram voice notes are typically OGG/OPUS.
60
+ return ["audio/ogg", "audio/webm", "application/octet-stream"]
61
+ if lower.endswith(".opus"):
62
+ return ["audio/ogg", "application/octet-stream"]
63
+ if lower.endswith(".mp3"):
64
+ return ["audio/mpeg", "application/octet-stream"]
65
+ if lower.endswith(".m4a"):
66
+ return ["audio/mp4", "application/octet-stream"]
67
+ if lower.endswith(".wav"):
68
+ return ["audio/wav", "application/octet-stream"]
69
+ return ["application/octet-stream"]
70
+
71
+ @staticmethod
72
+ def _ensure_supported_extension(filename: str) -> str:
73
+ lower = (filename or "").lower()
74
+ supported = (".flac", ".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".ogg", ".opus", ".wav", ".webm")
75
+ if any(lower.endswith(ext) for ext in supported):
76
+ return filename
77
+ return "voice.ogg"
context.md ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Via: Paytm Merchant Copilot - Project Context
2
+
3
+ ## 1. Problem Statement
4
+ SMB merchants on the Paytm platform struggle with payment failures, delayed settlements, manual reconciliation, and invoicing/collections. Existing dashboards are passive, overwhelming, and require proactive monitoring. When things go wrong (e.g., a drop in UPI success rate or a pending settlement), merchants often lack the time or expertise to analyze the data and take immediate action.
5
+
6
+ ## 2. The Solution: 'Via'
7
+ **Via** is an Actionable Intelligence Agent (an MCP Client) accessed directly through Telegram. It serves as a conversational, voice-first control layer over merchants' Paytm infrastructure.
8
+ Instead of just being a Q&A chatbot, Via follows a core philosophy of:
9
+ **Insight → Reason → Action → Execution**
10
+
11
+ For example, when asked "Why was yesterday's collection low?", Via doesn't just return a number. It gives the insight (collection dropped by 18%), the reason (UPI success rate dropped), and immediately offers an actionable execution (e.g., "Would you like me to send payment links for the failed orders?").
12
+
13
+ ## 3. UX & Interface
14
+ - **Platform:** Telegram Bot
15
+ - **Input Types:** Text and Voice (`.ogg` files)
16
+ - **Voice UX:** SMB merchants (often speaking Hindi or Hinglish) can simply send voice notes like *"refund last payment"*. The bot uses Telegram's voice capabilities smoothly to deliver a natural, low-friction experience.
17
+
18
+ ## 4. Multi-Model AI Architecture
19
+ To balance cost, speed, and intelligence, Via utilizes a layered model stack:
20
+ 1. **Input Layer (Speech-to-Text):** `whisper-large-v3-turbo`
21
+ - Transcribes Telegram `.ogg` voice notes, excellent for Hinglish natively.
22
+ 2. **Intent & Routing (Fast & Cheap):** `llama-3.1-8b-instant`
23
+ - Rapidly classifies the merchant's intent, extracts entities, and routes queries.
24
+ 3. **Reasoning & Execution (The "Brain"):** `gpt-oss-20b` or **Gemini**
25
+ - Handles the complex financial logic, analyzes data patterns, generates insights, and formats the final responses.
26
+ 4. **Safety Verification (Optional/Critical Tasks):** `gpt-oss-safeguard-20b`
27
+ - Ensures sensitive operations like "initiate refund" are double-checked for authorization and intent.
28
+
29
+ ## 5. Backend Architecture & Tool Integration
30
+ - **Stack:** Python, FastAPI, python-telegram-bot.
31
+ - **MCP Mock Tools (Simulating Paytm APIs):**
32
+ - **Orders:** `fetch_order_list`
33
+ - **Payment Links:** `create_link`, `fetch_link`, `fetch_transaction`
34
+ - **Refunds:** `initiate_refund`, `check_refund_status`, `fetch_refund_list`
35
+ - **Settlements:** `get_settlement_summary`, `get_settlement_detail`
36
+
37
+ *Note: The backend avoids heavy frameworks like React in favor of a clean, instantly demo-able chat interface that highlights the actual value: the MCP tool-calling and the multi-model intelligence.*
38
+
39
+ ## 6. Conversational Memory & Context Awareness
40
+ To ensure Via is not a stateless bot, it maintains a **rolling conversational memory** per user (using Telegram's `chat_id`):
41
+ - **Short-Term Memory (Session State):** A fast, in-memory buffer (e.g., Python `dict` or Redis) stores the last 20 interactions or can store the summary till this point in chat of all the conversations. This payload is passed to the LLM's `messages` array so the AI always remembers the *tone* of the user, *recent tool results*, and *previously asked questions*.
42
+ - **Tool-Call Continuity:** If a user says "Refund the last one we talked about," the AI can inspect the most recent `initiate_refund` or `fetch_transaction` MCP tool execution to determine precisely which order the user meant.Just ensure that its not stale data... like time wise... can use last 5 mins
43
+ - **Context Injection:** When an order or settlement requires action, the backend injects system prompts framing the exact state: *"The user just saw a drop in UPI success; guide them to routing updates."*
44
+
45
+ - **Sensitive stuff** redirect to relevent section of paytm app, so that user continues from there...
46
+
47
+
48
+ ## 7. Voice UX & Hinglish Support
49
+
50
+
paytm llm api.md ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+
3
+ # Configuration
4
+ API_KEY = "<YOUR_API_KEY>"
5
+ BASE_URL = "https://api.paytmai.com"
6
+
7
+ # Audio file
8
+ audio_file = open("audio.mp3", "rb")
9
+
10
+ # Request payload
11
+ files = {"file": audio_file}
12
+ data = {
13
+ "model": "whisper-large-v3",
14
+ "response_format": "json",
15
+ "temperature": 0
16
+ }
17
+
18
+ headers = {"Authorization": f"Bearer {API_KEY}"}
19
+ response = requests.post(f"{BASE_URL}/v1/audio/transcriptions", files=files, data=data, headers=headers)
20
+
21
+ result = response.json()
22
+ print(result["text"])
23
+
24
+
25
+ THE ABOVE IS FOR WHISPER...
26
+
27
+
28
+ import requests
29
+
30
+ # Configuration
31
+ API_KEY = "<YOUR_API_KEY>" # Get from /v1/user/me/api-key
32
+ BASE_URL = "https://api.inference.paytm.com/v1"
33
+
34
+ # Request payload
35
+ payload = {
36
+ "model": "llama-3.1-8b-instant",
37
+ "messages": [
38
+ {"role": "system", "content": "You are a helpful assistant."},
39
+ {"role": "user", "content": "Your message here"}
40
+ ],
41
+ "temperature": 0.7,
42
+ "max_tokens": 1024,
43
+ "top_p": 0.95,
44
+ "top_k": 50,
45
+ "frequency_penalty": 0,
46
+ "presence_penalty": 0,
47
+ "repetition_penalty": 1,
48
+ "stream": True
49
+ }
50
+
51
+ # Make request
52
+ headers = {
53
+ "Authorization": f"Bearer {API_KEY}",
54
+ "Content-Type": "application/json"
55
+ }
56
+ response = requests.post(
57
+ f"{BASE_URL}/ai/playground",
58
+ json=payload,
59
+ headers=headers
60
+ )
61
+
62
+ result = response.json()
63
+ if result["success"]:
64
+ print(result["data"]["choices"][0]["message"]["content"])
65
+ else:
66
+ print(f"Error: {result['error']}")
67
+
68
+
69
+ THE ABOVE IS FOR LLM
70
+
71
+
72
+
73
+ BELOW ARE CURL
74
+
75
+
76
+ curl -X POST https://api.inference.paytm.com/v1/ai/playground \
77
+ -H "Authorization: Bearer <YOUR_API_KEY>" \
78
+ -H "Content-Type: application/json" \
79
+ -d '{
80
+ "model": "llama-3.1-8b-instant",
81
+ "messages": [
82
+ {"role": "system", "content": "You are a helpful assistant."},
83
+ {"role": "user", "content": "Your message here"}
84
+ ],
85
+ "temperature": 0.7,
86
+ "max_tokens": 1024,
87
+ "top_p": 0.95,
88
+ "top_k": 50,
89
+ "frequency_penalty": 0,
90
+ "presence_penalty": 0,
91
+ "repetition_penalty": 1,
92
+ "stream": true
93
+ }'
94
+
95
+
96
+ curl -X POST https://api.paytmai.com/v1/audio/transcriptions \
97
+ -H "Authorization: Bearer <YOUR_API_KEY>" \
98
+ -F "file=@audio.mp3" \
99
+ -F "model=whisper-large-v3" \
100
+ -F "response_format=json" \
101
+ -F "temperature=0"
102
+
103
+
104
+ ![alt text](image.png)
paytm_llm_api_docs.md ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Paytm Payment MCP Server & AI Router API Documentation
2
+
3
+ This documentation is curated for Large Language Models (LLMs) and developers integrating Paytm's Model Context Protocol (MCP) Server and AI Router services. It covers everything from conversational AI integration to specific transactional workflows and routing configurations.
4
+
5
+ ---
6
+
7
+ ## Part 1: Paytm MCP Server
8
+
9
+ Paytm MCP Server enables AI agents and developers to securely access Paytm's Payments and Business Payments APIs securely via the Model Context Protocol (MCP).
10
+
11
+ ### Main Features
12
+ - **Smart Payment Ops**: Automate refund workflows, settlement tracking, and transaction status checks.
13
+ - **Context-Aware AI Assistants**: Trigger queries via natural language (e.g. \"Create a ₹500 payment link\").
14
+ - **Agentic AI Payments**: Provide enhanced automated and dynamic shopping experiences.
15
+
16
+ ### MCP Tools Available
17
+ The MCP exposes a set of tools mapped to Paytm's underlying REST APIs. An LLM agent can invoke these exact tools:
18
+
19
+ | Tool Name | Description | Underlying API Context |
20
+ |--------------------------------------|---------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------|
21
+ | `create_link` | Creates a new payment link. | Create Link API |
22
+ | `fetch_link` | Fetches details of a created payment link. | Fetch Link API |
23
+ | `fetch_transaction` | Fetches transaction details specific to a payment link. | Fetch Transaction Link API |
24
+ | `fetch_order_list` | Fetches list of orders within a 30-day date range. | Order List API |
25
+ | `initiate_refund` | Initiates a refund for a specific transaction. | Initiate Refund API |
26
+ | `check_refund_status` | Checks the status of a previously initiated refund. | Check Refund Status API |
27
+ | `fetch_refund_list` | Fetches a list of refunds within a 30-day date range. | Fetch Refund List API |
28
+ | `get_settlement_summary` | Retrieves overall summary details of payouts for a date range or payout ID. | Get Settlement Summary API |
29
+ | `get_settlement_detail` | Retrieves a granular/transactional view of all settled transactions at the payout level. | Get Settlement Details API |
30
+ | `get_settlement_order_details` | Retrieves settlement details at an order level (needs payout date and order ID). | Get Settlement Order Details API |
31
+ | `get_settlement_transaction_details` | Retrieves settlement details of a specific order based on transaction ID. | Get Settlement Transaction Details API |
32
+
33
+ ### Deployment Modes
34
+ 1. **Remote MCP (Recommended)**: Hosted by Paytm. Requires requesting a `Client ID` and `Secret Key` from `devsupport@paytmpayments.com`. You will use your `PAYTM_MID`. Cursor setup via `generate_mcp_token_cursor.sh` to generate a JWT token.
35
+ 2. **Local MCP (Self-Hosted)**: Run locally via `Claude Desktop`.
36
+ - Requires Python 3.12+, `uv`, and Claude Desktop.
37
+ - Env variables required: `PAYTM_MID`, `PAYTM_KEY_SECRET`.
38
+ - `claude_desktop_config.json` uses `uv path --directory <dir> run paytm_mcp.py`.
39
+
40
+ ---
41
+
42
+ ## Part 2: AI Router Payment Flow
43
+
44
+ The AI Router serves to centralize the checkout process and smartly route payments across configured payment aggregators (PAs)/payment gateways (PGs).
45
+
46
+ ### Standard Transaction Journey
47
+ 1. **Initiation**: Merchant initiates the transaction by calling the **Create Order API**.
48
+ 2. **Token Generation**: AI Router creates an order and returns a **Transaction Token** along with an Order ID.
49
+ 3. **Fetch Options**: Merchant calls **Fetch Payment Options API** to retrieve available payment methods.
50
+ 4. **Checkout Rendering**: AI Router provides the configured options, which populate the cashier page.
51
+ 5. **Execution**: User selects an option and clicks Pay. The **Pay API** is then called.
52
+ 6. **Routing**: AI Router intelligently evaluates and routes the transaction to the most appropriate PA/PG.
53
+ 7. **Processing**: Gateway processes the transaction and returns status to AI Router.
54
+ 8. **Verification**:
55
+ - Polling: Merchant polls the **Order Status API** (using the Order ID).
56
+ - Server-to-Server: Alternatively, AI Router triggers a real-time **Payment Webhook** with the updated payment status.
57
+
58
+ ---
59
+
60
+ ## Part 3: AI Router Routing Rule Configuration
61
+
62
+ If no rules are created, the AI Router defaults to selecting the gateway with the highest historical/real-time success probability. Merchants can explicitly define routing constraints via the dashboard or APIs.
63
+
64
+ ### 1. Gateway Routing Configuration
65
+ Routing can be applied to One-Time payments and Subscription payments.
66
+ - **UPI Routing**: Filtered by UPI Intent vs. UPI Collect, and optionally limited by transaction amount ranges.
67
+ - **Bank Mandates Routing**: Filtered by Mandate Type (Netbanking vs Debit Card) and applied against specific Issuing Banks.
68
+ - **Cards Routing**: Rules set by card attributes (Credit, Debit, Prepaid, Visa, Mastercard, RuPay, Amex, Diners, Issuing Bank).
69
+ - **Cost-Based Routing**: Optimizes for gateway costs.
70
+ - *Relative Cost*: Ranked priority preference according to cheapest.
71
+ - *Absolute Cost*: Flat value or percentage based. Minimum Success Rate baselines can also be configured so cheap traffic ensures minimum reliability.
72
+
73
+ ### 2. API Based / Enforced Routing
74
+ Transaction-level control that overrides dashboard configurations, primarily used for targeted business campaigns/promotions.
75
+ - **How to Use**: Pass special parameter variables via code during runtime.
76
+ - **Payload**: Provide `enforcedRoutingParam` inside the `txnRoutingParams` object.
77
+ - **Endpoint Injection**: This object must be pushed in either the **Create Order API** or **Pay API**. (If a conflict exists between the two, parameters sent via the Pay API take precedence).
78
+
79
+ ---
80
+
81
+ ## Part 4: General Developer Implementation Notes & FAQs
82
+ - **Gateways requirement**: Merchants must independently onboard with third-party payment aggregators. Once keys are received, submit them statically inside the Paytm AI Router Dashboard.
83
+ - **Fallback Logic**: If the lowest cost gateway drops below the success rate baseline, AI Router will auto-skip to the next cheapest that meets strict success margins.
84
+ - **Integration**: The unified AI Router architecture prevents merchants from having to build split/custom integrations for every gateway backend. Call the AI Router suite uniformly, and routing configs isolate the complexities.
85
+
86
+ ---
87
+
88
+ ## Part 5: Core Transaction APIs
89
+ 1. **Initiate Transaction API**: Used to create an order and retrieve a transaction token.
90
+ - Endpoint: `POST /theia/api/v1/initiateTransaction?mid={MID}&orderId={ORDERID}`
91
+ - Body contains: `requestType` (Payment), `txnAmount` (value, currency), `userInfo` (custId), `callbackUrl`
92
+ 2. **Process Transaction API**: Used to process the payment using the generated token.
93
+ - Body contains: `paymentMode` (e.g., `CC`, `DC`, `NET_BANKING`, `UPI`, `BALANCE`), `cardInfo`, `channelCode`.
94
+ 3. **Transaction Status API**: Query the status of a specific order.
95
+ - Body contains: `ORDERID`, `MID`. Returns `STATUS` (e.g., `TXN_SUCCESS`, `TXN_FAILURE`).
96
+
97
+ ---
98
+
99
+ ## Part 6: JS Checkout Integration
100
+ A customized frontend layer that natively displays payment options on the merchant's site.
101
+ 1. Inject the script: `<script src="https://securegw.paytm.in/merchantpgpui/checkoutjs/merchants/{MID}.js"></script>`
102
+ 2. Initialize and Invoke:
103
+ ```javascript
104
+ var config = { "root": "", "flow": "DEFAULT", "data": { "orderId": "{ORDERID}", "token": "{TXN_TOKEN}", "tokenType": "TXN_TOKEN", "amount": "{AMOUNT}" }, "handler": { "notifyMerchant": function(eventName,data){ ... } } };
105
+ window.Paytm.CheckoutJS.init(config).then(function() {
106
+ window.Paytm.CheckoutJS.invoke();
107
+ });
108
+ ```
109
+
110
+ ---
111
+
112
+ ## Part 7: Subscriptions
113
+ - **Initiate Subscription API**: Used to create a subscription mandate.
114
+ - **List Subscriptions API**: Used to view active/inactive mandates.
115
+ - **JS Checkout for Subscriptions**: Extends JS Checkout by passing subscription-specific flags (e.g., `requestType: "SUBSCRIBE"`) during checkout initialization.
116
+
117
+ ---
118
+
119
+ ## Part 8: Post-Transaction & Financials
120
+ 1. **Refund API**: Initiate a partial or full refund against a `TXNID`.
121
+ - Requires: `ORDERID`, `TXNID`, `refId` (merchant-generated unique refund ID), `refundAmount`.
122
+ 2. **Settlement APIs**:
123
+ - **Settlement Summary API**: Fetches the gross/net settlement summary against the Payout Date.
124
+ - **Settlement Detail API**: Provides transactional-level granularity of an executed settlement.
125
+
126
+ ---
127
+
128
+ ## Part 9: Webhooks & Callbacks
129
+ 1. **Payment Status Webhook**: Paytm pushes real-time server-to-server updates when an order transitions to a terminal state (Success/Failure).
130
+ - Payload includes: `ORDERID`, `TXNID`, `TXNAMOUNT`, `STATUS`, `RESPCODE`, `RESPMSG`, `CHECKSUMHASH`.
131
+ - Merchants must validate the `CHECKSUMHASH` against their `PAYTM_KEY_SECRET` to prevent spoofing.
132
+ - Webhook URL is setup via the Merchant Dashboard.
133
+
134
+ ---
135
+
136
+ ## Part 10: AI Router Analytics & Reports
137
+ Paytm provides a rich dashboard for visualizing and analyzing transaction data. This is particularly useful when building merchant-facing LLM agents that need to query or interpret performance graphs, failures, and reconciliations.
138
+
139
+ ### 1. Analytics Dashboards
140
+ - **Successful Payments Analysis**: Tracks successful payment volumes. Supports Historical Benchmarking (Yesterday, Current Week vs Last Week, Current Month comparisons) and Historic Trends. Can overlay **Gateways**, **Payment Source**, and **Moving Averages**.
141
+ - **Total Collection Analysis**: Similar to volume tracking, but tracks the Total Collection Amount in INR.
142
+ - **Success Rate Analysis**: Tracks the transaction success percentage. Includes **Failure Reasons Analytics** (identifies if the failure was caused by the user, bank, or the router) and highlights the top failure contributors.
143
+
144
+ ### 2. Standard Reports
145
+ - **Payments Report**: Offers a List View (Transaction ID, Date, Order ID, Payment Source, Gateway, Amount) and a Detailed View (Response Codes, Customer details). Allows single-click refund initiation.
146
+ - **Refunds Report**: Offers List View and Detailed View (shows RRN [Refund Reference Number]).
147
+ - **Bulk Refunds**: Merchants can upload a CSV (containing `TXN_ID`, `REFUND_AMOUNT`, and `REFUND_REASON`) to process mass bulk refunds directly from the dashboard panel.
148
+
149
+ ### 3. Report Generation and Downloads
150
+ - **Exporting**: Reports can be downloaded offline (CSV/Excel/PDF) or emailed directly.
151
+ - **Constraints**: Maximum querying duration for a single report export is 3 months.
152
+ - **Filters Supported**: Date/Time Range, Specific Payment Gateway, Payment Source (CC/DC/UPI), Status (Success, Pending, Failed), Order ID, etc.
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ requests
4
+ pytest
5
+ httpx
scripts/__pycache__/seed_scenarios.cpython-311.pyc ADDED
Binary file (901 Bytes). View file
 
scripts/seed_scenarios.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.config import load_settings
2
+ from app.db.repo import Repository
3
+
4
+
5
+ def main() -> None:
6
+ settings = load_settings()
7
+ repo = Repository(settings.db_path)
8
+ repo.seed_demo_data(force=True)
9
+ print("Mock scenarios seeded successfully.")
10
+ print(f"Database path: {settings.db_path}")
11
+
12
+
13
+ if __name__ == "__main__":
14
+ main()
tests/__pycache__/test_context_memory.cpython-311-pytest-9.0.2.pyc ADDED
Binary file (20.3 kB). View file
 
tests/__pycache__/test_mock_tools.cpython-311-pytest-9.0.2.pyc ADDED
Binary file (4.59 kB). View file
 
tests/__pycache__/test_orchestrator.cpython-311-pytest-9.0.2.pyc ADDED
Binary file (4.77 kB). View file
 
tests/__pycache__/test_smoke.cpython-311-pytest-9.0.2.pyc ADDED
Binary file (4.46 kB). View file
 
tests/__pycache__/test_telegram_handler.cpython-311-pytest-9.0.2.pyc ADDED
Binary file (4.67 kB). View file
 
tests/test_context_memory.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the new conversational-memory / rolling-context feature."""
2
+ from pathlib import Path
3
+ from unittest.mock import patch, MagicMock
4
+
5
+ from app.agent.orchestrator import Orchestrator
6
+ from app.db.repo import Repository
7
+ from app.llm.client import LlmClient
8
+ from app.tools.mock_paytm import MockPaytmTools
9
+
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Helpers
13
+ # ---------------------------------------------------------------------------
14
+
15
+ def _make_repo(tmp_path: Path) -> Repository:
16
+ repo = Repository(str(tmp_path / "ctx.sqlite3"))
17
+ repo.seed_demo_data()
18
+ return repo
19
+
20
+
21
+ def _make_orchestrator(tmp_path: Path) -> tuple[Orchestrator, Repository, LlmClient]:
22
+ repo = _make_repo(tmp_path)
23
+ tools = MockPaytmTools(repo)
24
+ llm = LlmClient("", "", "test-model")
25
+ orch = Orchestrator(repo, tools, llm)
26
+ return orch, repo, llm
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # repo.get_recent_context
31
+ # ---------------------------------------------------------------------------
32
+
33
+ def test_get_recent_context_returns_history(tmp_path: Path) -> None:
34
+ repo = _make_repo(tmp_path)
35
+ uid = repo.ensure_user("u1")
36
+
37
+ repo.save_message(uid, "text", "hello", "hi there")
38
+ repo.save_message(uid, "text", "show orders", "found 4 orders")
39
+ repo.save_message(uid, "voice", "refund status", "no pending refunds")
40
+
41
+ ctx = repo.get_recent_context(uid)
42
+ # 3 exchanges -> 6 messages (user + assistant each)
43
+ assert len(ctx) == 6
44
+ assert ctx[0] == {"role": "user", "content": "hello"}
45
+ assert ctx[1] == {"role": "assistant", "content": "hi there"}
46
+ assert ctx[-2] == {"role": "user", "content": "refund status"}
47
+ assert ctx[-1] == {"role": "assistant", "content": "no pending refunds"}
48
+
49
+
50
+ def test_get_recent_context_empty_for_new_user(tmp_path: Path) -> None:
51
+ repo = _make_repo(tmp_path)
52
+ uid = repo.ensure_user("brand-new-user")
53
+ ctx = repo.get_recent_context(uid)
54
+ assert ctx == []
55
+
56
+
57
+ def test_get_recent_context_respects_limit(tmp_path: Path) -> None:
58
+ repo = _make_repo(tmp_path)
59
+ uid = repo.ensure_user("u2")
60
+
61
+ for i in range(10):
62
+ repo.save_message(uid, "text", f"msg-{i}", f"reply-{i}")
63
+
64
+ ctx = repo.get_recent_context(uid, limit=3)
65
+ # limit=3 means 3 DB rows -> 6 role messages, but only the most recent 3 exchanges
66
+ assert len(ctx) == 6
67
+ assert ctx[0]["content"] == "msg-7" # oldest of the 3 kept
68
+ assert ctx[-1]["content"] == "reply-9" # newest
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # LlmClient.generate – payload structure
73
+ # ---------------------------------------------------------------------------
74
+
75
+ def test_generate_builds_payload_with_history(tmp_path: Path) -> None:
76
+ llm = LlmClient("https://example.com/v1/chat/completions", "key", "model")
77
+ history = [
78
+ {"role": "user", "content": "hi"},
79
+ {"role": "assistant", "content": "hello"},
80
+ ]
81
+
82
+ with patch("app.llm.client.requests.post") as mock_post:
83
+ mock_resp = MagicMock()
84
+ mock_resp.status_code = 200
85
+ mock_resp.json.return_value = {
86
+ "choices": [{"message": {"content": "test reply"}}]
87
+ }
88
+ mock_resp.raise_for_status = MagicMock()
89
+ mock_post.return_value = mock_resp
90
+
91
+ result = llm.generate("what now?", history=history)
92
+
93
+ assert result == "test reply"
94
+ called_payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
95
+ msgs = called_payload["messages"]
96
+ # system + 2 history + current user = 4
97
+ assert len(msgs) == 4
98
+ assert msgs[0]["role"] == "system"
99
+ assert msgs[1] == {"role": "user", "content": "hi"}
100
+ assert msgs[2] == {"role": "assistant", "content": "hello"}
101
+ assert msgs[3] == {"role": "user", "content": "what now?"}
102
+
103
+
104
+ def test_generate_works_without_history() -> None:
105
+ """Backward compat: calling generate without history still works."""
106
+ llm = LlmClient("https://example.com/v1/chat/completions", "key", "model")
107
+
108
+ with patch("app.llm.client.requests.post") as mock_post:
109
+ mock_resp = MagicMock()
110
+ mock_resp.status_code = 200
111
+ mock_resp.json.return_value = {
112
+ "choices": [{"message": {"content": "ok"}}]
113
+ }
114
+ mock_resp.raise_for_status = MagicMock()
115
+ mock_post.return_value = mock_resp
116
+
117
+ result = llm.generate("just a prompt")
118
+
119
+ assert result == "ok"
120
+ called_payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
121
+ msgs = called_payload["messages"]
122
+ # system + user = 2 (no history)
123
+ assert len(msgs) == 2
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Orchestrator integration – context flows through
128
+ # ---------------------------------------------------------------------------
129
+
130
+ def test_orchestrator_passes_context_to_llm(tmp_path: Path) -> None:
131
+ """When the orchestrator falls through to the LLM path, it should
132
+ include prior context in the generate() call."""
133
+ orch, repo, llm = _make_orchestrator(tmp_path)
134
+ uid = repo.ensure_user("t-100")
135
+
136
+ # Seed a prior message so there's context to retrieve
137
+ repo.save_message(uid, "text", "show orders", "found 4 orders")
138
+
139
+ with patch.object(llm, "generate", return_value="mocked reply") as mock_gen:
140
+ reply = orch.handle_user_text("t-100", "tell me more about the first one")
141
+
142
+ assert reply == "mocked reply"
143
+ # Verify generate was called with history kwarg containing prior context
144
+ args, kwargs = mock_gen.call_args
145
+ assert "history" in kwargs
146
+ assert len(kwargs["history"]) == 2 # 1 prior exchange = 2 messages
147
+ assert kwargs["history"][0]["content"] == "show orders"
148
+ assert kwargs["history"][1]["content"] == "found 4 orders"
tests/test_mock_tools.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ from app.db.repo import Repository
4
+ from app.tools.mock_paytm import MockPaytmTools
5
+
6
+
7
+ def test_create_and_fetch_link(tmp_path: Path) -> None:
8
+ repo = Repository(str(tmp_path / "mock.sqlite3"))
9
+ tools = MockPaytmTools(repo)
10
+
11
+ created = tools.create_link(250.0, "Amit")
12
+ fetched = tools.fetch_link(created["link_id"])
13
+
14
+ assert fetched["link_id"] == created["link_id"]
15
+ assert fetched["amount"] == 250.0
16
+ assert fetched["customer_name"] == "Amit"
17
+
18
+
19
+ def test_refund_workflow(tmp_path: Path) -> None:
20
+ repo = Repository(str(tmp_path / "refund.sqlite3"))
21
+ tools = MockPaytmTools(repo)
22
+
23
+ initiated = tools.initiate_refund("TXN-2001", 99.0)
24
+ status = tools.check_refund_status(initiated["refund_id"])
25
+
26
+ assert status["refund_id"] == initiated["refund_id"]
27
+ assert status["txn_id"] == "TXN-2001"
28
+ assert status["status"] == "PENDING"
tests/test_orchestrator.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ from app.agent.orchestrator import Orchestrator
4
+ from app.db.repo import Repository
5
+ from app.llm.client import LlmClient
6
+ from app.tools.mock_paytm import MockPaytmTools
7
+
8
+
9
+ def build_orchestrator(tmp_path: Path) -> Orchestrator:
10
+ repo = Repository(str(tmp_path / "orch.sqlite3"))
11
+ repo.seed_demo_data()
12
+ tools = MockPaytmTools(repo)
13
+ llm = LlmClient("", "", "llama-3.1-8b-instant")
14
+ return Orchestrator(repo, tools, llm)
15
+
16
+
17
+ def test_order_query_returns_summary(tmp_path: Path) -> None:
18
+ orchestrator = build_orchestrator(tmp_path)
19
+ response = orchestrator.handle_user_text("123", "show recent orders")
20
+ assert "orders" in response
21
+ assert "<b>" in response
22
+
23
+
24
+ def test_refund_requires_confirm_then_executes(tmp_path: Path) -> None:
25
+ orchestrator = build_orchestrator(tmp_path)
26
+ preview = orchestrator.handle_user_text("123", "refund txn-2001 amount 20")
27
+ assert "confirm" in preview.lower()
28
+
29
+ confirmed = orchestrator.handle_user_text("123", "confirm")
30
+ assert "Refund initiated" in confirmed
tests/test_smoke.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi.testclient import TestClient
2
+
3
+ from app.main import app
4
+
5
+
6
+ def test_health() -> None:
7
+ client = TestClient(app)
8
+ response = client.get("/health")
9
+ assert response.status_code == 200
10
+ assert response.json()["status"] == "ok"
11
+
12
+
13
+ def test_webhook_text_message() -> None:
14
+ client = TestClient(app)
15
+ payload = {
16
+ "message": {"chat": {"id": 101}, "text": "show settlement summary"},
17
+ }
18
+ response = client.post(
19
+ "/telegram/webhook",
20
+ json=payload,
21
+ headers={"X-Telegram-Bot-Api-Secret-Token": "local-secret"},
22
+ )
23
+ assert response.status_code == 200
24
+ data = response.json()
25
+ assert data["ok"] is True
26
+ assert data["input_type"] == "text"
tests/test_telegram_handler.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ from app.agent.orchestrator import Orchestrator
4
+ from app.db.repo import Repository
5
+ from app.llm.client import LlmClient
6
+ from app.telegram.handler import TelegramHandler
7
+ from app.tools.mock_paytm import MockPaytmTools
8
+ from app.voice.stt import SttClient
9
+
10
+
11
+ def build_handler(tmp_path: Path) -> TelegramHandler:
12
+ repo = Repository(str(tmp_path / "telegram.sqlite3"))
13
+ repo.seed_demo_data(force=True)
14
+ orchestrator = Orchestrator(repo, MockPaytmTools(repo), LlmClient("", "", "llama"))
15
+ stt = SttClient("", "", "whisper-large-v3-turbo")
16
+ return TelegramHandler(orchestrator, stt, bot_token="")
17
+
18
+
19
+ def test_voice_update_without_bot_token_falls_back(tmp_path: Path) -> None:
20
+ handler = build_handler(tmp_path)
21
+ result = handler.process_update(
22
+ {"message": {"chat": {"id": 999}, "voice": {"file_id": "abc123"}}}
23
+ )
24
+ assert result["ok"] is True
25
+ assert result["input_type"] == "voice"
26
+ assert "transcription unavailable" in result["transcript"]
27
+
28
+
29
+ def test_audio_update_is_supported(tmp_path: Path) -> None:
30
+ handler = build_handler(tmp_path)
31
+ result = handler.process_update(
32
+ {"message": {"chat": {"id": 999}, "audio": {"file_id": "xyz123"}}}
33
+ )
34
+ assert result["ok"] is True
35
+ assert result["input_type"] == "voice"
36
+ assert "transcription unavailable" in result["transcript"]
via.sqlite3 ADDED
Binary file (98.3 kB). View file