On answer, DotSIP notifies your CRM and does not wait. After the caller presses a digit, DotSIP waits, your server looks up the announcement for that digit, and DotSIP plays it.
1. Records in DotSIP
- Put menu.wav and choice.wav in data/media. Mono PCM16, 8000, 16000, or 24000 Hz.
- Under SIP accounts, the trunk that receives the call. Set its inbound route to route-tools, or leave that field empty so the call uses route-tools.
- route-tools URL http://127.0.0.1:9099/route. The hook and the digit callback are paths on that same process.
2. Your database
CREATE TABLE crm_log (call_id TEXT PRIMARY KEY, event TEXT NOT NULL, caller TEXT);
CREATE TABLE digit_media (digit TEXT PRIMARY KEY, media TEXT NOT NULL);
INSERT INTO digit_media(digit, media) VALUES ('1', 'choice.wav');
3. The plan
hook wait false posts dotsip.hook and ignores the body. The call goes on to the recording. gather wait true posts dotsip.dtmf and runs the plan you return.
{
"format": "dotsip.hook",
"format_version": 1,
"call_id": "c-7f3a",
"event": "answered",
"caller": { "number": "+421901234567" }
}
{
"format": "dotsip.dtmf",
"format_version": 1,
"call_id": "c-7f3a",
"digits": "1",
"timeout": false,
"caller": { "number": "+421901234567" }
}
4. The server
<?php
$db = new PDO("sqlite:" . __DIR__ . "/operator.sqlite");
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec("CREATE TABLE IF NOT EXISTS crm_log (call_id TEXT PRIMARY KEY, event TEXT NOT NULL, caller TEXT)");
$db->exec("CREATE TABLE IF NOT EXISTS digit_media (digit TEXT PRIMARY KEY, media TEXT NOT NULL)");
$db->exec("INSERT OR IGNORE INTO digit_media(digit, media) VALUES ('1', 'choice.wav')");
$body = json_decode(file_get_contents("php://input"), true);
header("Content-Type: application/json");
$format = is_array($body) && isset($body["format"]) ? $body["format"] : "";
if ($format === "dotsip.route") {
echo json_encode(array("format" => "dotsip.route-result", "format_version" => 2, "commands" => array(
array("do" => "answer"),
array("do" => "hook", "url" => "http://127.0.0.1:9099/crm", "event" => "answered", "wait" => false),
array("do" => "play", "media" => "menu.wav", "barge_in" => false),
array("do" => "gather", "max_digits" => 1, "timeout_ms" => 8000, "callback_url" => "http://127.0.0.1:9099/digit", "wait" => true),
)));
return;
}
if ($format === "dotsip.hook") {
$stmt = $db->prepare("INSERT OR REPLACE INTO crm_log(call_id, event, caller) VALUES (?, ?, ?)");
$caller = isset($body["caller"]["number"]) ? $body["caller"]["number"] : "";
$stmt->execute(array(isset($body["call_id"]) ? $body["call_id"] : "", isset($body["event"]) ? $body["event"] : "", $caller));
echo "{}";
return;
}
if ($format === "dotsip.dtmf") {
$digit = isset($body["digits"]) ? (string) $body["digits"] : "";
$media = $db->query("SELECT media FROM digit_media WHERE digit = " . $db->quote($digit))->fetchColumn();
if (!$media) {
echo json_encode(array("commands" => array(array("do" => "hangup"))));
return;
}
echo json_encode(array("format" => "dotsip.route-result", "format_version" => 2, "commands" => array(
array("do" => "play", "media" => $media, "barge_in" => false),
array("do" => "hangup"),
)));
return;
}
http_response_code(400);
echo json_encode(array("error" => "bad request"));
import sqlite3
from flask import Flask, request, jsonify
app = Flask(__name__)
con = sqlite3.connect("operator.sqlite", check_same_thread=False)
con.execute("CREATE TABLE IF NOT EXISTS crm_log (call_id TEXT PRIMARY KEY, event TEXT NOT NULL, caller TEXT)")
con.execute("CREATE TABLE IF NOT EXISTS digit_media (digit TEXT PRIMARY KEY, media TEXT NOT NULL)")
con.execute("INSERT OR IGNORE INTO digit_media(digit, media) VALUES ('1', 'choice.wav')")
con.commit()
@app.post("/route")
def route():
body = request.get_json(silent=True) or {}
if body.get("format") != "dotsip.route":
return jsonify({"error": "bad request"}), 400
return jsonify({"format": "dotsip.route-result", "format_version": 2, "commands": [
{"do": "answer"},
{"do": "hook", "url": "http://127.0.0.1:9099/crm", "event": "answered", "wait": False},
{"do": "play", "media": "menu.wav", "barge_in": False},
{"do": "gather", "max_digits": 1, "timeout_ms": 8000, "callback_url": "http://127.0.0.1:9099/digit", "wait": True},
]})
@app.post("/crm")
def crm():
body = request.get_json(silent=True) or {}
caller = (body.get("caller") or {}).get("number") or ""
con.execute("INSERT OR REPLACE INTO crm_log(call_id, event, caller) VALUES (?, ?, ?)", (body.get("call_id") or "", body.get("event") or "", caller))
con.commit()
return jsonify({})
@app.post("/digit")
def digit():
body = request.get_json(silent=True) or {}
row = con.execute("SELECT media FROM digit_media WHERE digit = ?", (str(body.get("digits") or ""),)).fetchone()
if not row:
return jsonify({"commands": [{"do": "hangup"}]})
return jsonify({"format": "dotsip.route-result", "format_version": 2, "commands": [
{"do": "play", "media": row[0], "barge_in": False},
{"do": "hangup"},
]})
const http = require("http");
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync("operator.sqlite");
db.exec("CREATE TABLE IF NOT EXISTS crm_log (call_id TEXT PRIMARY KEY, event TEXT NOT NULL, caller TEXT)");
db.exec("CREATE TABLE IF NOT EXISTS digit_media (digit TEXT PRIMARY KEY, media TEXT NOT NULL)");
db.exec("INSERT OR IGNORE INTO digit_media(digit, media) VALUES ('1', 'choice.wav')");
const mediaOf = db.prepare("SELECT media FROM digit_media WHERE digit = ?");
const log = db.prepare("INSERT OR REPLACE INTO crm_log(call_id, event, caller) VALUES (?, ?, ?)");
function send(res, payload) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(payload));
}
http.createServer((req, res) => {
let raw = "";
req.on("data", (c) => { raw += c; });
req.on("end", () => {
let body = {};
try { body = JSON.parse(raw || "{}"); } catch (e) { body = {}; }
if (req.url === "/route" && body.format === "dotsip.route") {
send(res, { format: "dotsip.route-result", format_version: 2, commands: [
{ do: "answer" },
{ do: "hook", url: "http://127.0.0.1:9099/crm", event: "answered", wait: false },
{ do: "play", media: "menu.wav", barge_in: false },
{ do: "gather", max_digits: 1, timeout_ms: 8000, callback_url: "http://127.0.0.1:9099/digit", wait: true }
]});
return;
}
if (req.url === "/crm" && body.format === "dotsip.hook") {
const caller = body.caller && body.caller.number ? body.caller.number : "";
log.run(String(body.call_id || ""), String(body.event || ""), caller);
send(res, {});
return;
}
if (req.url === "/digit" && body.format === "dotsip.dtmf") {
const row = mediaOf.get(String(body.digits || ""));
send(res, row
? { format: "dotsip.route-result", format_version: 2, commands: [{ do: "play", media: row.media, barge_in: false }, { do: "hangup" }] }
: { commands: [{ do: "hangup" }] });
return;
}
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "bad request" }));
});
}).listen(9099);
Press 1. The server finds choice.wav for digit 1 and DotSIP plays it, then hangs up. The CRM row is written even though that reply is ignored. A digit with no row hangs up.