Outside opening hours your server, not DotSIP, looks at the clock and at the hours table. It returns one of three plans: an announcement and hangup, a gather for a callback number, or a recording ended with hash. The message file stays on the PBX. Your database stores only the file name.
1. Records in DotSIP
- Put closed.wav and callback.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. Digit and recording callbacks are paths on that process.
2. Your database
CREATE TABLE hours (id TEXT PRIMARY KEY, open_hour INTEGER NOT NULL, close_hour INTEGER NOT NULL, mode TEXT NOT NULL);
INSERT INTO hours(id, open_hour, close_hour, mode) VALUES ('desk', 8, 16, 'message');
CREATE TABLE callbacks (call_id TEXT PRIMARY KEY, digits TEXT NOT NULL);
CREATE TABLE recordings (call_id TEXT PRIMARY KEY, file TEXT NOT NULL);
mode is message, callback, or record. Change the row. The server uses it on the next call. Hours are compared to the server clock, hour 0–23. Inside 8–16 the sample hangs up. Outside, it uses mode.
3. 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 hours (id TEXT PRIMARY KEY, open_hour INTEGER NOT NULL, close_hour INTEGER NOT NULL, mode TEXT NOT NULL)");
$db->exec("INSERT OR IGNORE INTO hours(id, open_hour, close_hour, mode) VALUES ('desk', 8, 16, 'message')");
$db->exec("CREATE TABLE IF NOT EXISTS callbacks (call_id TEXT PRIMARY KEY, digits TEXT NOT NULL)");
$db->exec("CREATE TABLE IF NOT EXISTS recordings (call_id TEXT PRIMARY KEY, file TEXT NOT NULL)");
$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.dtmf") {
$stmt = $db->prepare("INSERT OR REPLACE INTO callbacks(call_id, digits) VALUES (?, ?)");
$stmt->execute(array(isset($body["call_id"]) ? $body["call_id"] : "", isset($body["digits"]) ? $body["digits"] : ""));
echo json_encode(array("commands" => array(array("do" => "hangup"))));
return;
}
if ($format === "dotsip.recording") {
$stmt = $db->prepare("INSERT OR REPLACE INTO recordings(call_id, file) VALUES (?, ?)");
$stmt->execute(array(isset($body["call_id"]) ? $body["call_id"] : "", isset($body["file"]) ? $body["file"] : ""));
echo json_encode(array("commands" => array(array("do" => "hangup"))));
return;
}
if ($format !== "dotsip.route") {
http_response_code(400);
echo json_encode(array("error" => "bad request"));
return;
}
$row = $db->query("SELECT open_hour, close_hour, mode FROM hours WHERE id = 'desk'")->fetch(PDO::FETCH_ASSOC);
$hour = (int) date("G");
$open = $hour >= (int) $row["open_hour"] && $hour < (int) $row["close_hour"];
if ($open) {
$commands = array(array("do" => "hangup"));
} elseif ($row["mode"] === "callback") {
$commands = array(
array("do" => "answer"),
array("do" => "play", "media" => "callback.wav", "barge_in" => false),
array("do" => "gather", "max_digits" => 16, "timeout_ms" => 20000, "finish_on" => "#", "callback_url" => "http://127.0.0.1:9099/digits", "wait" => true),
);
} elseif ($row["mode"] === "record") {
$commands = array(
array("do" => "answer"),
array("do" => "play", "media" => "closed.wav", "barge_in" => false),
array("do" => "record", "finish_on" => "#", "callback_url" => "http://127.0.0.1:9099/recording", "wait" => true),
);
} else {
$commands = array(
array("do" => "answer"),
array("do" => "play", "media" => "closed.wav", "barge_in" => false),
array("do" => "hangup"),
);
}
echo json_encode(array("format" => "dotsip.route-result", "format_version" => 2, "commands" => $commands));
import sqlite3
from datetime import datetime
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 hours (id TEXT PRIMARY KEY, open_hour INTEGER NOT NULL, close_hour INTEGER NOT NULL, mode TEXT NOT NULL)")
con.execute("INSERT OR IGNORE INTO hours(id, open_hour, close_hour, mode) VALUES ('desk', 8, 16, 'message')")
con.execute("CREATE TABLE IF NOT EXISTS callbacks (call_id TEXT PRIMARY KEY, digits TEXT NOT NULL)")
con.execute("CREATE TABLE IF NOT EXISTS recordings (call_id TEXT PRIMARY KEY, file TEXT NOT NULL)")
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
open_hour, close_hour, mode = con.execute("SELECT open_hour, close_hour, mode FROM hours WHERE id = ?", ("desk",)).fetchone()
hour = datetime.now().hour
if open_hour <= hour < close_hour:
commands = [{"do": "hangup"}]
elif mode == "callback":
commands = [{"do": "answer"}, {"do": "play", "media": "callback.wav", "barge_in": False},
{"do": "gather", "max_digits": 16, "timeout_ms": 20000, "finish_on": "#", "callback_url": "http://127.0.0.1:9099/digits", "wait": True}]
elif mode == "record":
commands = [{"do": "answer"}, {"do": "play", "media": "closed.wav", "barge_in": False},
{"do": "record", "finish_on": "#", "callback_url": "http://127.0.0.1:9099/recording", "wait": True}]
else:
commands = [{"do": "answer"}, {"do": "play", "media": "closed.wav", "barge_in": False}, {"do": "hangup"}]
return jsonify({"format": "dotsip.route-result", "format_version": 2, "commands": commands})
@app.post("/digits")
def digits():
body = request.get_json(silent=True) or {}
con.execute("INSERT OR REPLACE INTO callbacks(call_id, digits) VALUES (?, ?)", (body.get("call_id") or "", body.get("digits") or ""))
con.commit()
return jsonify({"commands": [{"do": "hangup"}]})
@app.post("/recording")
def recording():
body = request.get_json(silent=True) or {}
con.execute("INSERT OR REPLACE INTO recordings(call_id, file) VALUES (?, ?)", (body.get("call_id") or "", body.get("file") or ""))
con.commit()
return jsonify({"commands": [{"do": "hangup"}]})
const http = require("http");
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync("operator.sqlite");
db.exec("CREATE TABLE IF NOT EXISTS hours (id TEXT PRIMARY KEY, open_hour INTEGER NOT NULL, close_hour INTEGER NOT NULL, mode TEXT NOT NULL)");
db.exec("INSERT OR IGNORE INTO hours(id, open_hour, close_hour, mode) VALUES ('desk', 8, 16, 'message')");
db.exec("CREATE TABLE IF NOT EXISTS callbacks (call_id TEXT PRIMARY KEY, digits TEXT NOT NULL)");
db.exec("CREATE TABLE IF NOT EXISTS recordings (call_id TEXT PRIMARY KEY, file TEXT NOT NULL)");
const hours = db.prepare("SELECT open_hour, close_hour, mode FROM hours WHERE id = ?");
const saveDigits = db.prepare("INSERT OR REPLACE INTO callbacks(call_id, digits) VALUES (?, ?)");
const saveFile = db.prepare("INSERT OR REPLACE INTO recordings(call_id, file) 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 === "/digits" && body.format === "dotsip.dtmf") {
saveDigits.run(String(body.call_id || ""), String(body.digits || ""));
send(res, { commands: [{ do: "hangup" }] });
return;
}
if (req.url === "/recording" && body.format === "dotsip.recording") {
saveFile.run(String(body.call_id || ""), String(body.file || ""));
send(res, { commands: [{ do: "hangup" }] });
return;
}
if (body.format !== "dotsip.route") {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "bad request" }));
return;
}
const row = hours.get("desk");
const hour = new Date().getHours();
let commands;
if (hour >= row.open_hour && hour < row.close_hour) {
commands = [{ do: "hangup" }];
} else if (row.mode === "callback") {
commands = [{ do: "answer" }, { do: "play", media: "callback.wav", barge_in: false },
{ do: "gather", max_digits: 16, timeout_ms: 20000, finish_on: "#", callback_url: "http://127.0.0.1:9099/digits", wait: true }];
} else if (row.mode === "record") {
commands = [{ do: "answer" }, { do: "play", media: "closed.wav", barge_in: false },
{ do: "record", finish_on: "#", callback_url: "http://127.0.0.1:9099/recording", wait: true }];
} else {
commands = [{ do: "answer" }, { do: "play", media: "closed.wav", barge_in: false }, { do: "hangup" }];
}
send(res, { format: "dotsip.route-result", format_version: 2, commands });
});
}).listen(9099);
Set mode to callback and call outside 8–16. Enter the number and press #. callbacks stores the digits. Set mode to record and the voice file is data/recordings/<call_id>.wav on the PBX. recordings.file stores only the name from dotsip.recording. finish_on defaults to #.