The route asks DotSIP to transfer to extension 200. Before REFER, DotSIP posts the line status. Your server looks up the announcement for that status. When the status is busy, it returns a play plan, REFER is not sent, and the call stays up.
1. Records in DotSIP
- 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.
- Under SIP phones, user 200, name Director, enabled, with a password.
- Under Routes, route-tools URL http://127.0.0.1:9099/route. The line callback in the plan is http://127.0.0.1:9099/line on the same process.
- Put busy.wav in data/media. Mono PCM16, 8000, 16000, or 24000 Hz.
2. Your database
CREATE TABLE line_media (status TEXT PRIMARY KEY, media TEXT NOT NULL);
INSERT INTO line_media(status, media) VALUES ('busy', 'busy.wav');
3. What DotSIP posts
First the route request. Your server returns the transfer command. Then, because callback_url is set and wait is true, DotSIP posts dotsip.line before REFER.
{
"format": "dotsip.line",
"format_version": 1,
"call_id": "c-7f3a",
"phone": "200",
"status": "busy",
"caller": { "number": "+421901234567" }
}
status is idle, busy, unavailable, or unknown. ringing and in_use arrive as busy. If your reply is a plan, that plan runs and REFER is not sent. If status is busy or unavailable and you do not return a plan, on_busy runs and the call stays up.
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 line_media (status TEXT PRIMARY KEY, media TEXT NOT NULL)");
$db->exec("INSERT OR IGNORE INTO line_media(status, media) VALUES ('busy', 'busy.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" => "transfer",
"phone" => "200",
"callback_url" => "http://127.0.0.1:9099/line",
"wait" => true,
"on_busy" => array(array("do" => "play", "media" => "busy.wav", "barge_in" => false)),
)),
));
return;
}
if ($format === "dotsip.line") {
$status = isset($body["status"]) ? $body["status"] : "";
$media = $db->query("SELECT media FROM line_media WHERE status = " . $db->quote($status))->fetchColumn();
if ($media) {
echo json_encode(array(
"format" => "dotsip.route-result",
"format_version" => 2,
"commands" => array(array("do" => "play", "media" => $media, "barge_in" => false)),
));
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 line_media (status TEXT PRIMARY KEY, media TEXT NOT NULL)")
con.execute("INSERT OR IGNORE INTO line_media(status, media) VALUES ('busy', 'busy.wav')")
con.commit()
def plan_for(status):
row = con.execute("SELECT media FROM line_media WHERE status = ?", (status,)).fetchone()
if not row:
return None
return {"format": "dotsip.route-result", "format_version": 2, "commands": [
{"do": "play", "media": row[0], "barge_in": False}
]}
@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": "transfer", "phone": "200", "callback_url": "http://127.0.0.1:9099/line", "wait": True,
"on_busy": [{"do": "play", "media": "busy.wav", "barge_in": False}]},
],
})
@app.post("/line")
def line():
body = request.get_json(silent=True) or {}
if body.get("format") != "dotsip.line":
return jsonify({"error": "bad request"}), 400
plan = plan_for(body.get("status") or "")
if not plan:
return jsonify({}), 200
return jsonify(plan)
const http = require("http");
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync("operator.sqlite");
db.exec("CREATE TABLE IF NOT EXISTS line_media (status TEXT PRIMARY KEY, media TEXT NOT NULL)");
db.exec("INSERT OR IGNORE INTO line_media(status, media) VALUES ('busy', 'busy.wav')");
const mediaFor = db.prepare("SELECT media FROM line_media WHERE status = ?");
function send(res, code, payload) {
res.writeHead(code, { "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, 200, {
format: "dotsip.route-result", format_version: 2,
commands: [
{ do: "answer" },
{ do: "transfer", phone: "200", callback_url: "http://127.0.0.1:9099/line", wait: true,
on_busy: [{ do: "play", media: "busy.wav", barge_in: false }] }
]
});
return;
}
if (req.url === "/line" && body.format === "dotsip.line") {
const row = mediaFor.get(String(body.status || ""));
if (!row) { send(res, 200, {}); return; }
send(res, 200, { format: "dotsip.route-result", format_version: 2, commands: [
{ do: "play", media: row.media, barge_in: false }
]});
return;
}
send(res, 400, { error: "bad request" });
});
}).listen(9099);
Call the trunk while extension 200 is already in a call. You hear busy.wav and the caller is not transferred. The row in line_media is what chose the file.