]> OzVa Git service - conf-dialer/commitdiff
init
authorMax Value <greenwoodw50@gmail.com>
Thu, 25 Dec 2025 21:46:47 +0000 (21:46 +0000)
committerMax Value <greenwoodw50@gmail.com>
Thu, 25 Dec 2025 21:46:47 +0000 (21:46 +0000)
config.ini [new file with mode: 0644]
main.py [new file with mode: 0755]
players.json [new file with mode: 0644]
requirements.txt [new file with mode: 0644]
secrets.json [new file with mode: 0644]
sounds/test.sln32 [new file with mode: 0644]
static/script.js [new file with mode: 0644]
static/style.css [new file with mode: 0644]
templates/panel.html [new file with mode: 0644]
test.json [new file with mode: 0644]
test.py [new file with mode: 0644]

diff --git a/config.ini b/config.ini
new file mode 100644 (file)
index 0000000..c89f10c
--- /dev/null
@@ -0,0 +1,4 @@
+[asterisk]
+host=127.0.0.1
+username=asterisk
+secret=test
diff --git a/main.py b/main.py
new file mode 100755 (executable)
index 0000000..3156f21
--- /dev/null
+++ b/main.py
@@ -0,0 +1,329 @@
+#!.venv/bin/python
+
+from werkzeug.security import generate_password_hash, check_password_hash
+from flask import Flask, request, render_template, jsonify
+from requests.auth import HTTPBasicAuth as requests_auth
+from panoramisk.call_manager import CallManager
+from flask_socketio import SocketIO, emit
+from flask_httpauth import HTTPBasicAuth
+from panoramisk import Manager
+import threading
+import requests
+import asyncio
+import json
+import os
+
+from websockets.sync.client import connect
+
+app = Flask(__name__)
+auth = HTTPBasicAuth()
+socketio = SocketIO(app)
+
+
+
+# ==============================================================================
+# SETUP
+
+# get the player data ready
+with open(f"{app.root_path}/players.json", "r", encoding="utf-8") as f:
+       players = json.loads(f.read())
+
+# get the secrets ready
+with open(f"{app.root_path}/secrets.json", "r", encoding="utf-8") as f:
+       users_raw = json.loads(f.read())
+users = {k: generate_password_hash(v) for (k, v) in users_raw.items()}
+basic = requests_auth('controller', users_raw['controller'])
+
+@auth.verify_password
+def verify_password(username, password):
+    if username in users and \
+            check_password_hash(users.get(username), password):
+        return username
+
+
+
+# ==============================================================================
+# FRONTEND
+
+@app.route("/", methods=["GET", "POST"])
+@auth.login_required
+def main():
+       data = {
+               "players": players,
+               "sounds": [x.rsplit(".", maxsplit=1)[0] for x in os.listdir(f"{app.root_path}/sounds")],
+               "admin": "+447594768180"
+               }
+
+       return render_template("panel.html", data=data)
+
+
+
+# ==============================================================================
+# UTILS
+
+async def get_channel(number):
+       manager = Manager.from_config(f"{app.root_path}/config.ini")
+       await manager.connect()
+
+       callers = await manager.send_action({
+               'Action': 'ConfbridgeList',
+               'Conference': 'blood',
+       })
+
+       channel = None
+
+       if type(callers) == list:
+               for message in callers:
+                       channel_num = message.get("CallerIDNum") #"Channel"
+                       if channel_num == number:
+                               channel = message.channel
+
+       manager.close()
+
+       return channel
+
+
+
+# ==============================================================================
+# DIALPLAN
+
+@app.route("/call", methods=["GET", "POST"])
+@auth.login_required
+async def call():
+       number = request.json["number"]
+
+       manager = Manager.from_config(f"{app.root_path}/config.ini")
+       await manager.connect()
+
+       phonecall = await manager.send_action({
+               "Action": "Originate",
+               "Channel": f"PJSIP/{number}@gotrunk",
+               "Exten": "blood",
+               "Context": "from-internal",
+               "Priority": "1",
+               # "Application": <value>,
+               # "Data": <value>,
+               # "Timeout": 10,
+               "CallerID": "Departed",
+               "Variable": f"CALLERID(num)={number}",
+               # "Account": <value>,
+               # "EarlyMedia": <value>,
+               "Async": "true",
+               # "Codecs": <value>,
+               # "ChannelId": <value>,
+               # "OtherChannelId": <value>,
+               # "PreDialGoSub": <value>
+               })
+
+       manager.close()
+
+       return "", 200
+
+
+@app.route("/kick", methods=["GET", "POST"])
+@auth.login_required
+async def kick():
+       number = request.json["number"]
+
+       if number == "participants":
+               channel = number
+       else:
+               channel = await get_channel(number)
+
+       if channel is None:
+               return f"Caller is not connected ({number})", 422
+
+       manager = Manager.from_config(f"{app.root_path}/config.ini")
+       await manager.connect()
+
+       result = await manager.send_action({
+               "Action": "ConfbridgeKick",
+               "Conference": "blood",
+               "Channel": channel
+               })
+
+       manager.close()
+       return "", 200
+
+
+@app.route("/mute", methods=["GET", "POST"])
+@auth.login_required
+async def mute():
+       number = request.json["number"]
+
+       if number == "participants":
+               channel = number
+       else:
+               channel = await get_channel(number)
+
+       if channel is None:
+               return f"Caller is not connected ({number})", 422
+
+       manager = Manager.from_config(f"{app.root_path}/config.ini")
+       await manager.connect()
+
+       result = await manager.send_action({
+               "Action": "ConfbridgeMute",
+               "Conference": "blood",
+               "Channel": channel
+               })
+
+       manager.close()
+       return "", 200
+
+
+@app.route("/unmute", methods=["GET", "POST"])
+@auth.login_required
+async def unmute():
+       number = request.json["number"]
+
+       if number == "participants":
+               channel = number
+       else:
+               channel = await get_channel(number)
+
+       if channel is None:
+               return "Caller is not connected", 200
+
+       manager = Manager.from_config(f"{app.root_path}/config.ini")
+       await manager.connect()
+
+       result = await manager.send_action({
+               "Action": "ConfbridgeUnmute",
+               "Conference": "blood",
+               "Channel": channel
+               })
+
+       manager.close()
+       return "", 200
+
+
+@app.route("/play", methods=["GET", "POST"])
+@auth.login_required
+async def play():
+       path = request.json["path"]
+
+       manager = Manager.from_config(f"{app.root_path}/config.ini")
+       await manager.connect()
+
+       phonecall = await manager.send_action({
+               "Action": "Originate",
+               "Channel": "Local/bloodadmin@from-internal",
+               "Application": "Playback",
+               "Data": f"/home/will/{path}",
+               "Async": "true",
+               "CallerID": f"file_{path}"
+               })
+
+       manager.close()
+
+       return "", 200
+
+
+@app.route("/stopall")
+@auth.login_required
+async def stopall():
+       manager = Manager.from_config(f"{app.root_path}/config.ini")
+       await manager.connect()
+
+       callers = await manager.send_action({
+               'Action': 'ConfbridgeList',
+               'Conference': 'blood',
+       })
+
+       if type(callers) == list:
+               for message in callers:
+                       if message.CallerIDName[:4] == "file":
+                               result = await manager.send_action({
+                                       "Action": "ConfbridgeKick",
+                                       "Conference": "blood",
+                                       "Channel": message.channel
+                                       })
+
+       manager.close()
+       return "", 200
+
+
+
+# ==============================================================================
+# EVENTS
+
+watcher = Manager.from_config(f"{app.root_path}/config.ini")
+
+def start_watch():
+       loop = asyncio.new_event_loop()
+       watcher.loop = loop
+
+       print(" * Event watcher started")
+       watcher.connect(run_forever=True)
+
+
+def send_emit(data):
+       with app.test_request_context('/'):
+               emit("status", data, json=True, broadcast=True, namespace="/")
+
+
+@watcher.register_event('DialState')  # Register all events
+async def dialstate_callback(manager, message):
+       send_emit({
+                       "number": message.DestCallerIDNum,
+                       "status": "Ringing",
+                       "mute": True
+               })
+
+
+@watcher.register_event('ConfbridgeJoin')  # Register all events
+async def confbridgejoin_callback(manager, message):
+       if message.CallerIDName[:4] == "file":
+               number = message.CallerIDName[5:]
+       else:
+               number = message.CallerIDNum
+
+       send_emit({
+                       "number": number,
+                       "status": "Connected",
+                       "mute": message.Muted == "yes"
+               })
+
+
+@watcher.register_event('ConfbridgeLeave')  # Register all events
+async def confbridgeleave_callback(manager, message):
+       if message.CallerIDName[:4] == "file":
+               number = message.CallerIDName[5:]
+       else:
+               number = message.CallerIDNum
+
+       send_emit({
+                       "number": number,
+                       "status": "Disconnected",
+                       "mute": True
+               })
+
+
+@watcher.register_event('ConfbridgeMute')  # Register all events
+async def confbridgemute_callback(manager, message):
+       send_emit({
+                       "number": message.CallerIDNum,
+                       "status": "Connected",
+                       "mute": True
+               })
+
+
+@watcher.register_event('ConfbridgeUnmute')  # Register all events
+async def confbridgeunmute_callback(manager, message):
+       send_emit({
+                       "number": message.CallerIDNum,
+                       "status": "Connected",
+                       "mute": False
+               })
+
+
+
+# ==============================================================================
+
+if __name__ == "__main__":
+       # start the event watch manager
+       thread = threading.Thread(target=start_watch, daemon=True)
+       thread.start()
+
+       socketio.run(app, host='127.0.0.1', port=8000, debug=True)
diff --git a/players.json b/players.json
new file mode 100644 (file)
index 0000000..ec8c528
--- /dev/null
@@ -0,0 +1,3 @@
+[
+       {"name": "test_name", "number":"+447594768180"}
+]
diff --git a/requirements.txt b/requirements.txt
new file mode 100644 (file)
index 0000000..521f904
--- /dev/null
@@ -0,0 +1,6 @@
+flask_socketio
+flask_httpauth
+flask[async]
+panoramisk
+werkzeug
+requests
diff --git a/secrets.json b/secrets.json
new file mode 100644 (file)
index 0000000..e47a7e0
--- /dev/null
@@ -0,0 +1,4 @@
+{
+       "controller": "test",
+       "will": "test"
+}
diff --git a/sounds/test.sln32 b/sounds/test.sln32
new file mode 100644 (file)
index 0000000..99c587c
Binary files /dev/null and b/sounds/test.sln32 differ
diff --git a/static/script.js b/static/script.js
new file mode 100644 (file)
index 0000000..f086d32
--- /dev/null
@@ -0,0 +1,67 @@
+const timeOut = 5000; // ms
+
+async function play (file) {
+       response = await fetch("/play",
+               {
+                       method: "POST",
+                       headers: {
+                               "Content-Type": "application/json",
+                       },
+
+                       body: JSON.stringify({"path": file})
+               }
+       );
+}
+
+async function call (num) {
+       response = await fetch("/call",
+               {
+                       method: "POST",
+                       headers: {"Content-Type": "application/json"},
+                       body: JSON.stringify({"number": num})
+               }
+       );
+}
+
+async function kick (num) {
+       response = await fetch("/kick",
+               {
+                       method: "POST",
+                       headers: {"Content-Type": "application/json"},
+                       body: JSON.stringify({"number": num})
+               }
+       );
+}
+
+async function mute (num) {
+       response = await fetch("/mute",
+               {
+                       method: "POST",
+                       headers: {"Content-Type": "application/json"},
+                       body: JSON.stringify({"number": num})
+               }
+       );
+}
+
+async function unmute (num) {
+       response = await fetch("/unmute",
+               {
+                       method: "POST",
+                       headers: {"Content-Type": "application/json"},
+                       body: JSON.stringify({"number": num})
+               }
+       );
+}
+
+async function callAll () {
+       for (e of document.getElementById("players").children) {
+               if (document.getElementById(`${e.id}_enabled`).checked) {
+                       call(e.id);
+               }
+       }
+}
+
+
+async function stopAll () {
+       response = await fetch("/stopall");
+}
diff --git a/static/style.css b/static/style.css
new file mode 100644 (file)
index 0000000..54eea35
--- /dev/null
@@ -0,0 +1,72 @@
+.admin, #sounds, #players, #bar {
+       position: absolute;
+       top: 0;
+       left: 0;
+       bottom: 0;
+       right: 0;
+
+       display: grid;
+       overflow: scroll;
+}
+.admin {
+       top: 95vh;
+       padding: 2px;
+
+       margin: 0;
+
+       grid-template-columns: 1fr 1fr;
+}
+#sounds {
+       top: 60vh;
+       bottom: 5vh;
+       border-bottom: 2px solid gray;
+
+       padding: 2px;
+
+       grid-template-columns: 1fr 1fr 1fr 1fr;
+}
+#players {
+       top: 5vh;
+       bottom: 40vh;
+       border-bottom: 2px solid gray;
+
+       padding: 2px;
+
+       grid-template-columns: 1fr 1fr 1fr;
+}
+#bar {
+       bottom: 95vh;
+       border-bottom: 2px solid gray;
+
+       padding: 2px;
+
+       grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
+}
+
+#sounds > button { margin: 2px; }
+
+#players > div {
+       margin: 2px;
+       padding: 2px;
+
+       text-align: center;
+
+       border: 2px solid gray;
+}
+
+#players > div > p {
+       margin: 2px;
+}
+
+#players > div > button {
+       width: calc(50% - 4px);
+}
+
+
+
+
+#readout {
+       position: absolute;
+       left: 0;
+       bottom: 0;
+}
diff --git a/templates/panel.html b/templates/panel.html
new file mode 100644 (file)
index 0000000..134d2e3
--- /dev/null
@@ -0,0 +1,87 @@
+<!DOCTYPE html>
+<html lang="">
+       <head>
+               <meta charset="utf-8">
+               <title>Dialer Control Pannel</title>
+               <script src="static/script.js"></script>
+               <script type="module">
+
+import { io } from "https://cdn.socket.io/4.8.1/socket.io.esm.min.js";
+
+// sockets
+
+var socket = io('/');
+socket.on('status', function (data) {
+       console.log(data.number);
+
+       switch (data.status) {
+               case "Disconnected":
+                       document.getElementById(data.number).style.backgroundColor = "";
+                       break;
+
+               case "Ringing":
+                       document.getElementById(data.number).style.backgroundColor = "orange";
+                       break;
+
+               case "Connected":
+                       document.getElementById(data.number).style.backgroundColor = "green";
+                       break;
+       }
+
+       document.getElementById(`${data.number}_status`).innerHTML = data.status;
+
+       let mute = "Unmuted";
+       if (data.mute) {
+               mute = "Muted";
+       }
+       document.getElementById(`${data.number}_mute`).innerHTML = mute;
+});
+
+               </script>
+               <link rel="stylesheet" href="static/style.css">
+       </head>
+       <body>
+               <div class="admin" id="{{ data.admin }}">
+                       <span style="text-align: right; padding-right: 0.5em;">Admin status: </span>
+                       <span id="{{ data.admin }}_status">Disconnected</span>
+               </div>
+               <div id="sounds">
+
+                       {% for sound in data.sounds %}
+                               <button id="{{ sound }}" onclick="play('{{ sound }}');">
+                                       {{ sound }}
+                               </button>
+                       {% endfor %}
+
+               </div>
+               <div id="players">
+
+                       {% for player in data.players %}
+                               <div id="{{ player.number }}">
+                                       <label><b>The {{ player.name|replace("_", " ")|title }}</b></label>
+                                               {% if "spare" in player.name %}
+                                                       <input type="checkbox" id="{{ player.number }}_enabled" /><br>
+                                               {% else %}
+                                                       <input type="checkbox" id="{{ player.number }}_enabled" checked="checked" /><br>
+                                               {% endif %}
+                                       <p>Status:
+                                               <span id="{{ player.number }}_status">Disconnected</span>
+                                       </p>
+                                       <p id="{{ player.number }}_mute">Muted</p>
+                                       <button onclick="call('{{ player.number }}');">Call</button>
+                                       <button onclick="mute('{{ player.number }}');">Mute</button><br>
+                                       <button onclick="kick('{{ player.number }}');">Kick</button>
+                                       <button onclick="unmute('{{ player.number }}');">Unmute</button>
+                               </div>
+                       {% endfor %}
+
+               </div>
+               <div id="bar">
+                       <button onclick="callAll();">Call all</button>
+                       <button onclick="kick('participants');">Kick all</button>
+                       <button onclick="mute('participants');">Mute all</button>
+                       <button onclick="unmute('participants');">Unmute all</button>
+                       <button onclick="stopAll();">Stop sounds</button>
+               </div>
+       </body>
+</html>
diff --git a/test.json b/test.json
new file mode 100644 (file)
index 0000000..38add34
--- /dev/null
+++ b/test.json
@@ -0,0 +1,6 @@
+[
+       {"name": "dad", "number": "+447976097042"},
+       {"name": "mum", "number": "+447759246551"},
+       {"name": "grandma", "number": "+447396554079"},
+       {"name": "lola", "number": "+447925041171"}
+]
diff --git a/test.py b/test.py
new file mode 100644 (file)
index 0000000..f5647a6
--- /dev/null
+++ b/test.py
@@ -0,0 +1,28 @@
+import asyncio
+from panoramisk import Manager
+
+async def extension_status():
+    manager = Manager(loop=asyncio.get_event_loop(),
+                      host='127.0.0.1', port=5038,
+                      username='asterisk', secret='test')
+
+    await manager.connect()
+    action = {
+        'Action': 'ConfbridgeList',
+        'Conference': 'blood',
+    }
+
+    extension = await manager.send_action(action)
+    print(extension)
+    manager.close()
+
+
+def main():
+    loop = asyncio.get_event_loop()
+    loop.run_until_complete(extension_status())
+    loop.close()
+
+
+if __name__ == '__main__':
+    main()
+