feat: phone webhook integration (callstart/callend on port 8081)
- New webhook server on port 8081 for phone system (PBX)
- Endpoints: GET/POST /{extension}/callstart and /{extension}/callend
- New 'phone_extension' field on users (Durchwahl)
- Only activates when user is anwesend or homeoffice (AIDA)
- Sets WLED to phone_active color (red) on callstart
- Restores previous Teams/AIDA color on callend
- Polling skips WLED update while phone is active
- Dashboard shows phone badge when user is in a call
- New color rule 'phone_active' (configurable via web UI)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
47210cb068
commit
7f2858aab6
27
app.py
27
app.py
@ -78,6 +78,7 @@ async def dashboard(request: Request):
|
|||||||
row = conn.execute("""
|
row = conn.execute("""
|
||||||
SELECT u.display_name, u.azure_id,
|
SELECT u.display_name, u.azure_id,
|
||||||
s.last_aida_status, s.last_teams_status,
|
s.last_aida_status, s.last_teams_status,
|
||||||
|
COALESCE(s.phone_active, 0) as phone_active,
|
||||||
cr_aida.r as ar, cr_aida.g as ag, cr_aida.b as ab,
|
cr_aida.r as ar, cr_aida.g as ag, cr_aida.b as ab,
|
||||||
cr_teams.r as tr, cr_teams.g as tg, cr_teams.b as tb
|
cr_teams.r as tr, cr_teams.g as tg, cr_teams.b as tb
|
||||||
FROM assignments a
|
FROM assignments a
|
||||||
@ -89,8 +90,12 @@ async def dashboard(request: Request):
|
|||||||
""", (dev["id"], seg_idx)).fetchone()
|
""", (dev["id"], seg_idx)).fetchone()
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
# Use teams color if anwesend, otherwise aida color
|
phone = row["phone_active"]
|
||||||
if row["last_aida_status"] == "anwesend" and row["tr"] is not None:
|
if phone:
|
||||||
|
# Phone active: show phone color
|
||||||
|
pc = conn.execute("SELECT r,g,b FROM color_rules WHERE status='phone_active'").fetchone()
|
||||||
|
r, g, b = (pc["r"], pc["g"], pc["b"]) if pc else (255, 0, 0)
|
||||||
|
elif row["last_aida_status"] == "anwesend" and row["tr"] is not None:
|
||||||
r, g, b = row["tr"], row["tg"], row["tb"]
|
r, g, b = row["tr"], row["tg"], row["tb"]
|
||||||
elif row["ar"] is not None:
|
elif row["ar"] is not None:
|
||||||
r, g, b = row["ar"], row["ag"], row["ab"]
|
r, g, b = row["ar"], row["ag"], row["ab"]
|
||||||
@ -101,6 +106,7 @@ async def dashboard(request: Request):
|
|||||||
"user": row["display_name"],
|
"user": row["display_name"],
|
||||||
"aida": row["last_aida_status"] or "-",
|
"aida": row["last_aida_status"] or "-",
|
||||||
"teams": row["last_teams_status"] or "-",
|
"teams": row["last_teams_status"] or "-",
|
||||||
|
"phone": phone,
|
||||||
"r": r, "g": g, "b": b,
|
"r": r, "g": g, "b": b,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
@ -167,12 +173,13 @@ async def sync_users():
|
|||||||
async def update_user(
|
async def update_user(
|
||||||
azure_id: str,
|
azure_id: str,
|
||||||
personal_nr: str = Form(""),
|
personal_nr: str = Form(""),
|
||||||
|
phone_extension: str = Form(""),
|
||||||
active: str = Form("0"),
|
active: str = Form("0"),
|
||||||
):
|
):
|
||||||
conn = database.get_db()
|
conn = database.get_db()
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE users SET personal_nr=?, active=? WHERE azure_id=?",
|
"UPDATE users SET personal_nr=?, phone_extension=?, active=? WHERE azure_id=?",
|
||||||
(personal_nr, 1 if active == "1" else 0, azure_id),
|
(personal_nr, phone_extension, 1 if active == "1" else 0, azure_id),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
@ -471,8 +478,20 @@ def cli_auth():
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
import threading
|
||||||
|
|
||||||
if len(sys.argv) > 1 and sys.argv[1] == "--auth":
|
if len(sys.argv) > 1 and sys.argv[1] == "--auth":
|
||||||
cli_auth()
|
cli_auth()
|
||||||
else:
|
else:
|
||||||
|
# Start webhook server on port 8081 in background thread
|
||||||
|
from webhook import webhook_app
|
||||||
|
|
||||||
|
def run_webhook():
|
||||||
|
uvicorn.run(webhook_app, host="0.0.0.0", port=8081, log_level="info")
|
||||||
|
|
||||||
|
webhook_thread = threading.Thread(target=run_webhook, daemon=True)
|
||||||
|
webhook_thread.start()
|
||||||
|
logger.info("Webhook-Server gestartet auf Port 8081")
|
||||||
|
|
||||||
|
# Main web UI on port 8080
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8080)
|
uvicorn.run(app, host="0.0.0.0", port=8080)
|
||||||
|
|||||||
11
database.py
11
database.py
@ -25,6 +25,7 @@ def init_db():
|
|||||||
display_name TEXT NOT NULL DEFAULT '',
|
display_name TEXT NOT NULL DEFAULT '',
|
||||||
email TEXT DEFAULT '',
|
email TEXT DEFAULT '',
|
||||||
personal_nr TEXT DEFAULT '',
|
personal_nr TEXT DEFAULT '',
|
||||||
|
phone_extension TEXT DEFAULT '',
|
||||||
active INTEGER NOT NULL DEFAULT 0
|
active INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -60,6 +61,7 @@ def init_db():
|
|||||||
user_azure_id TEXT PRIMARY KEY REFERENCES users(azure_id) ON DELETE CASCADE,
|
user_azure_id TEXT PRIMARY KEY REFERENCES users(azure_id) ON DELETE CASCADE,
|
||||||
last_aida_status TEXT DEFAULT '',
|
last_aida_status TEXT DEFAULT '',
|
||||||
last_teams_status TEXT DEFAULT '',
|
last_teams_status TEXT DEFAULT '',
|
||||||
|
phone_active INTEGER DEFAULT 0,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -92,6 +94,14 @@ def init_db():
|
|||||||
conn.execute("ALTER TABLE wled_devices ADD COLUMN last_check TIMESTAMP")
|
conn.execute("ALTER TABLE wled_devices ADD COLUMN last_check TIMESTAMP")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE users ADD COLUMN phone_extension TEXT DEFAULT ''")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE state ADD COLUMN phone_active INTEGER DEFAULT 0")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
defaults = {
|
defaults = {
|
||||||
"azure_client_id": "",
|
"azure_client_id": "",
|
||||||
@ -129,6 +139,7 @@ def init_db():
|
|||||||
("teams_inactive", 0, 255, 0, ""),
|
("teams_inactive", 0, 255, 0, ""),
|
||||||
("teams_offwork", 0, 255, 0, ""),
|
("teams_offwork", 0, 255, 0, ""),
|
||||||
("teams_outofoffice", 0, 255, 0, ""),
|
("teams_outofoffice", 0, 255, 0, ""),
|
||||||
|
("phone_active", 255, 0, 0, ""),
|
||||||
]
|
]
|
||||||
for status, r, g, b, effect in default_colors:
|
for status, r, g, b, effect in default_colors:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@ -90,7 +90,8 @@ def poll_status():
|
|||||||
users_with_assignments = conn.execute("""
|
users_with_assignments = conn.execute("""
|
||||||
SELECT u.azure_id, u.display_name, u.personal_nr,
|
SELECT u.azure_id, u.display_name, u.personal_nr,
|
||||||
a.segment, d.hostname,
|
a.segment, d.hostname,
|
||||||
s.last_aida_status, s.last_teams_status
|
s.last_aida_status, s.last_teams_status,
|
||||||
|
COALESCE(s.phone_active, 0) as phone_active
|
||||||
FROM users u
|
FROM users u
|
||||||
JOIN assignments a ON a.user_azure_id = u.azure_id
|
JOIN assignments a ON a.user_azure_id = u.azure_id
|
||||||
JOIN wled_devices d ON d.id = a.wled_device_id
|
JOIN wled_devices d ON d.id = a.wled_device_id
|
||||||
@ -130,6 +131,7 @@ def _process_user(user, aida_lines, settings, conn):
|
|||||||
segment = user["segment"]
|
segment = user["segment"]
|
||||||
last_aida = user["last_aida_status"] or ""
|
last_aida = user["last_aida_status"] or ""
|
||||||
last_teams = user["last_teams_status"] or ""
|
last_teams = user["last_teams_status"] or ""
|
||||||
|
phone_active = user["phone_active"]
|
||||||
|
|
||||||
# Immer Teams-Status abfragen (fuer Dashboard-Anzeige)
|
# Immer Teams-Status abfragen (fuer Dashboard-Anzeige)
|
||||||
teams_status = ""
|
teams_status = ""
|
||||||
@ -144,8 +146,8 @@ def _process_user(user, aida_lines, settings, conn):
|
|||||||
if aida_status == last_aida and teams_status == last_teams:
|
if aida_status == last_aida and teams_status == last_teams:
|
||||||
return # No change
|
return # No change
|
||||||
|
|
||||||
# --- WLED nur steuern wenn AIDA-Status bekannt ---
|
# --- WLED nur steuern wenn AIDA-Status bekannt und kein aktives Telefonat ---
|
||||||
wled_changed = aida_status is not None and (
|
wled_changed = aida_status is not None and not phone_active and (
|
||||||
aida_status != last_aida or
|
aida_status != last_aida or
|
||||||
(aida_status == "anwesend" and teams_status != last_teams)
|
(aida_status == "anwesend" and teams_status != last_teams)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -81,6 +81,10 @@
|
|||||||
<span class="badge bg-light text-dark"><i class="bi bi-microsoft-teams"></i> {{ seg.teams }}</span>
|
<span class="badge bg-light text-dark"><i class="bi bi-microsoft-teams"></i> {{ seg.teams }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if seg.phone %}
|
||||||
|
<span class="badge bg-danger"><i class="bi bi-telephone-fill"></i> Telefoniert</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@ -35,6 +35,7 @@
|
|||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>E-Mail</th>
|
<th>E-Mail</th>
|
||||||
<th>Personal-Nr (AIDA)</th>
|
<th>Personal-Nr (AIDA)</th>
|
||||||
|
<th>Durchwahl</th>
|
||||||
<th>Zuordnung</th>
|
<th>Zuordnung</th>
|
||||||
<th>Aktionen</th>
|
<th>Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -57,6 +58,19 @@
|
|||||||
class="form-control form-control-sm" style="width: 120px;"
|
class="form-control form-control-sm" style="width: 120px;"
|
||||||
placeholder="~~00000~~">
|
placeholder="~~00000~~">
|
||||||
<input type="hidden" name="active" value="{{ '1' if user.active else '0' }}">
|
<input type="hidden" name="active" value="{{ '1' if user.active else '0' }}">
|
||||||
|
<input type="hidden" name="phone_extension" value="{{ user.phone_extension or '' }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-check"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/api/users/{{ user.azure_id }}/update" class="d-flex gap-1">
|
||||||
|
<input type="text" name="phone_extension" value="{{ user.phone_extension or '' }}"
|
||||||
|
class="form-control form-control-sm" style="width: 80px;"
|
||||||
|
placeholder="z.B. 123">
|
||||||
|
<input type="hidden" name="personal_nr" value="{{ user.personal_nr or '' }}">
|
||||||
|
<input type="hidden" name="active" value="{{ '1' if user.active else '0' }}">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||||
<i class="bi bi-check"></i>
|
<i class="bi bi-check"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
126
webhook.py
Normal file
126
webhook.py
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
"""Webhook server for phone system integration (runs on port 8081)."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import PlainTextResponse
|
||||||
|
|
||||||
|
from database import get_db, log_activity
|
||||||
|
from wled_client import build_wled_url, call_wled
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
webhook_app = FastAPI(title="Busylight Webhook")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_by_extension(extension, conn):
|
||||||
|
"""Find user and their assignment by phone extension."""
|
||||||
|
return conn.execute("""
|
||||||
|
SELECT u.azure_id, u.display_name,
|
||||||
|
a.segment, d.hostname,
|
||||||
|
s.last_aida_status, s.last_teams_status, s.phone_active
|
||||||
|
FROM users u
|
||||||
|
JOIN assignments a ON a.user_azure_id = u.azure_id
|
||||||
|
JOIN wled_devices d ON d.id = a.wled_device_id
|
||||||
|
LEFT JOIN state s ON s.user_azure_id = u.azure_id
|
||||||
|
WHERE u.phone_extension = ? AND u.active = 1
|
||||||
|
""", (extension,)).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_color(status, conn):
|
||||||
|
"""Get color for a status."""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT r, g, b, effect FROM color_rules WHERE status = ?", (status,)
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return dict(row)
|
||||||
|
return {"r": 200, "g": 200, "b": 200, "effect": ""}
|
||||||
|
|
||||||
|
|
||||||
|
@webhook_app.get("/{extension}/callstart", response_class=PlainTextResponse)
|
||||||
|
@webhook_app.post("/{extension}/callstart", response_class=PlainTextResponse)
|
||||||
|
async def call_start(extension: str):
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
user = _get_user_by_extension(extension, conn)
|
||||||
|
if not user:
|
||||||
|
logger.warning("Webhook callstart: Durchwahl %s nicht gefunden", extension)
|
||||||
|
return f"Unknown extension: {extension}"
|
||||||
|
|
||||||
|
aida = user["last_aida_status"] or ""
|
||||||
|
# Nur wenn anwesend oder homeoffice
|
||||||
|
if aida not in ("anwesend", "homeoffice"):
|
||||||
|
logger.info("Webhook callstart: %s ignoriert (AIDA=%s)", user["display_name"], aida)
|
||||||
|
return f"Ignored: {user['display_name']} not in office (aida={aida})"
|
||||||
|
|
||||||
|
# WLED auf Telefon-Farbe setzen
|
||||||
|
color = _get_color("phone_active", conn)
|
||||||
|
url = build_wled_url(
|
||||||
|
user["hostname"], color["r"], color["g"], color["b"],
|
||||||
|
segment=user["segment"], effect=color.get("effect", ""),
|
||||||
|
)
|
||||||
|
success, result_text = call_wled(url)
|
||||||
|
|
||||||
|
# phone_active Flag setzen
|
||||||
|
conn.execute("""
|
||||||
|
UPDATE state SET phone_active=1, updated_at=? WHERE user_azure_id=?
|
||||||
|
""", (datetime.now(), user["azure_id"]))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
log_activity(
|
||||||
|
user["azure_id"], user["display_name"],
|
||||||
|
aida, "phone_active", url, result_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Webhook callstart: %s -> %s", user["display_name"], result_text)
|
||||||
|
return f"OK: {user['display_name']} call started"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@webhook_app.get("/{extension}/callend", response_class=PlainTextResponse)
|
||||||
|
@webhook_app.post("/{extension}/callend", response_class=PlainTextResponse)
|
||||||
|
async def call_end(extension: str):
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
user = _get_user_by_extension(extension, conn)
|
||||||
|
if not user:
|
||||||
|
logger.warning("Webhook callend: Durchwahl %s nicht gefunden", extension)
|
||||||
|
return f"Unknown extension: {extension}"
|
||||||
|
|
||||||
|
aida = user["last_aida_status"] or ""
|
||||||
|
if aida not in ("anwesend", "homeoffice"):
|
||||||
|
return f"Ignored: {user['display_name']} not in office"
|
||||||
|
|
||||||
|
# Farbe zuruecksetzen basierend auf aktuellem Status
|
||||||
|
teams = user["last_teams_status"] or ""
|
||||||
|
if aida == "homeoffice":
|
||||||
|
color = _get_color("homeoffice", conn)
|
||||||
|
elif teams:
|
||||||
|
color = _get_color(teams, conn)
|
||||||
|
else:
|
||||||
|
color = _get_color("available", conn)
|
||||||
|
|
||||||
|
url = build_wled_url(
|
||||||
|
user["hostname"], color["r"], color["g"], color["b"],
|
||||||
|
segment=user["segment"], effect=color.get("effect", ""),
|
||||||
|
)
|
||||||
|
success, result_text = call_wled(url)
|
||||||
|
|
||||||
|
# phone_active Flag zuruecksetzen
|
||||||
|
conn.execute("""
|
||||||
|
UPDATE state SET phone_active=0, updated_at=? WHERE user_azure_id=?
|
||||||
|
""", (datetime.now(), user["azure_id"]))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
log_activity(
|
||||||
|
user["azure_id"], user["display_name"],
|
||||||
|
aida, f"phone_end -> {teams}", url, result_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Webhook callend: %s -> %s (%s)", user["display_name"], result_text, teams)
|
||||||
|
return f"OK: {user['display_name']} call ended"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
Loading…
x
Reference in New Issue
Block a user