Files
Stefan Hacker a5b1de275d first commit
2026-06-09 10:17:37 +02:00

501 lines
19 KiB
Java

import de.vertico.starface.module.core.model.VariableType;
import de.vertico.starface.module.core.model.Visibility;
import de.vertico.starface.module.core.runtime.IBaseExecutable;
import de.vertico.starface.module.core.runtime.IRuntimeEnvironment;
import de.vertico.starface.module.core.runtime.annotations.Function;
import de.vertico.starface.module.core.runtime.annotations.InputVar;
import de.vertico.starface.module.core.runtime.annotations.OutputVar;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
/**
* BlacklistBlock - STARFACE Custom Block
*
* Prüft bei jedem eingehenden Anruf die Anrufernummer gegen eine konfigurierbare
* Blacklist und führt eine wählbare Aktion aus (auflegen, besetzt, umleiten, Ansage).
* Anonyme Anrufe (unterdrückte Rufnummer) werden separat behandelt.
*
* Das Modul muss als Typ "Call-Processing" mit Aktivierung "bei allen eingehenden
* Anrufen" konfiguriert werden, damit der Block im Anruf-Kontext läuft.
*
* Für STARFACE 8.x, 9.x, 10.x (Java 21)
*/
@Function(
visibility = Visibility.Public,
modifiesCalls = true,
description = "Prüft eingehende Anrufe gegen eine Blacklist und behandelt anonyme Anrufe"
)
public class BlacklistBlock implements IBaseExecutable {
// Voll qualifizierte Namen der STARFACE-internen Blöcke (per Reflection genutzt,
// damit der Block über STARFACE-Versionen hinweg robust bleibt).
private static final String CN_AGI_RUNTIME =
"de.vertico.starface.module.core.runtime.IAGIRuntimeEnvironment";
private static final String CN_GET_CALLER =
"de.vertico.starface.module.core.runtime.functions.callHandling.call.GetCaller2";
private static final String CN_HANGUP =
"de.vertico.starface.module.core.runtime.functions.callHandling.call.Hangup";
private static final String CN_BUSY =
"de.vertico.starface.module.core.runtime.functions.callHandling.call.Busy";
private static final String CN_CALL_NUMBER =
"de.vertico.starface.module.core.runtime.functions.callHandling.call.CallPhonenumber2";
private static final String CN_ANSWER =
"de.vertico.starface.module.core.runtime.functions.callHandling.call.Answer";
private static final String CN_PLAYBACK =
"de.vertico.starface.module.core.runtime.functions.callHandling.audio.PlaybackResourceFile2";
// ============================================================
// INPUT VARIABLEN - werden im Module Designer konfiguriert
// ============================================================
@InputVar(
label = "Blacklist-Einträge",
description = "Eine Nummer pro Zeile (oder durch Komma getrennt). Pro Zeile optional eine "
+ "eigene Aktion und ein Ziel im Format: nummer;AKTION;ziel\n"
+ "Beispiele:\n"
+ "+49301234567 -> Standard-Aktion\n"
+ "0190;BUSY -> dieser Eintrag immer besetzt\n"
+ "004989;REDIRECT;100 -> auf Nebenstelle 100 umleiten\n"
+ "+4972112345;ALLOW -> Ausnahme (durchlassen)\n"
+ "AKTIONEN: HANGUP, BUSY, REDIRECT, ANNOUNCE, ALLOW. "
+ "Zeilen mit '#' am Anfang sind Kommentare.",
type = VariableType.STRING,
guiHints = { "multiline" }
)
public String blacklistEntries = "";
@InputVar(
label = "Standard-Aktion (Blacklist)",
description = "Aktion für Blacklist-Einträge ohne eigene Aktion. "
+ "HANGUP = auflegen, BUSY = besetzt, REDIRECT = umleiten, ANNOUNCE = Ansage + auflegen",
type = VariableType.STRING,
possibleValues = { "HANGUP", "BUSY", "REDIRECT", "ANNOUNCE" }
)
public String defaultAction = "HANGUP";
@InputVar(
label = "Standard-Umleitungsziel",
description = "Zielrufnummer/Nebenstelle für REDIRECT-Einträge ohne eigenes Ziel (z.B. 100 oder +4972199999)",
type = VariableType.STRING
)
public String defaultRedirectTarget = "";
@InputVar(
label = "Ansage-Datei",
description = "Name der hochgeladenen Sound-Ressource für die Aktion ANNOUNCE (ohne Pfad). "
+ "Die Datei muss als Ressource im Modul hinterlegt sein.",
type = VariableType.STRING
)
public String announcementFile = "";
@InputVar(
label = "Aktion bei anonymen Anrufen",
description = "Behandlung von Anrufen mit unterdrückter Rufnummer. "
+ "NONE = nichts tun (Anruf normal durchstellen), sonst HANGUP/BUSY/REDIRECT/ANNOUNCE",
type = VariableType.STRING,
possibleValues = { "NONE", "HANGUP", "BUSY", "REDIRECT", "ANNOUNCE" }
)
public String anonymousAction = "NONE";
@InputVar(
label = "Umleitungsziel für anonyme Anrufe",
description = "Zielrufnummer/Nebenstelle, falls die Aktion für anonyme Anrufe REDIRECT ist",
type = VariableType.STRING
)
public String anonymousRedirectTarget = "";
@InputVar(
label = "Ländervorwahl",
description = "Eigene Ländervorwahl ohne führende Null/Plus (Deutschland = 49). "
+ "Wird genutzt, um nationale Nummern (0721...) und internationale (+49721...) einheitlich zu vergleichen.",
type = VariableType.STRING
)
public String countryCode = "49";
@InputVar(
label = "Präfix-/Bereichssperre erlauben",
description = "Wenn aktiv, sperrt ein Eintrag auch alle Nummern, die mit ihm beginnen "
+ "(z.B. '0190' sperrt den gesamten Bereich). Wenn aus, wird nur exakt verglichen.",
type = VariableType.BOOLEAN
)
public Boolean prefixMatch = true;
// ============================================================
// OUTPUT VARIABLEN - Ergebnis der Prüfung
// ============================================================
@OutputVar(
label = "Anrufernummer",
description = "Die erkannte Anrufernummer (international normalisiert)",
type = VariableType.STRING
)
public String caller = "";
@OutputVar(
label = "Anonym",
description = "true, wenn der Anrufer die Rufnummer unterdrückt hat",
type = VariableType.BOOLEAN
)
public Boolean isAnonymous = false;
@OutputVar(
label = "Geblockt",
description = "true, wenn der Anruf durch die Blacklist oder die Anonym-Regel behandelt wurde",
type = VariableType.BOOLEAN
)
public Boolean blocked = false;
@OutputVar(
label = "Ausgeführte Aktion",
description = "Die tatsächlich ausgeführte Aktion (HANGUP/BUSY/REDIRECT/ANNOUNCE/ALLOW/NONE)",
type = VariableType.STRING
)
public String actionTaken = "NONE";
@OutputVar(
label = "Status",
description = "Status-Meldung der Prüfung",
type = VariableType.STRING
)
public String statusMessage = "";
// Runtime
private IRuntimeEnvironment runtime;
private org.apache.logging.log4j.Logger log;
// Ermittelte Anrufer-Daten (statt innerer Klasse, um separate .class-Dateien zu vermeiden)
private String callerRaw = "";
private String callerNameVal = "";
@Override
public void execute(IRuntimeEnvironment runtime) throws Exception {
this.runtime = runtime;
this.log = runtime.getLog();
// Sicherstellen, dass wir im Anruf-Kontext laufen (AGI-Runtime)
Class<?> agiClass;
try {
agiClass = Class.forName(CN_AGI_RUNTIME);
} catch (ClassNotFoundException e) {
statusMessage = "Fehler: AGI-Runtime nicht verfügbar";
log.error("Blacklist: " + statusMessage);
return;
}
if (!agiClass.isInstance(runtime)) {
statusMessage = "Übersprungen - kein Anruf-Kontext (Modul muss 'Call-Processing' / "
+ "'bei allen eingehenden Anrufen' sein)";
log.warn("Blacklist: " + statusMessage);
return;
}
// 1. Anrufer-Informationen ermitteln (füllt callerRaw, callerNameVal, caller, isAnonymous)
if (!getCaller()) {
statusMessage = "Anrufer konnte nicht ermittelt werden - Anruf wird durchgestellt";
log.warn("Blacklist: " + statusMessage);
return;
}
log.info("Blacklist: Eingehender Anruf - Nummer='" + callerRaw
+ "' (intl='" + caller + "'), anonym=" + isAnonymous
+ ", Name='" + callerNameVal + "'");
// 2. Anonyme Anrufe separat behandeln
if (Boolean.TRUE.equals(isAnonymous)) {
String act = norm(anonymousAction);
if (act.isEmpty() || act.equals("NONE") || act.equals("ALLOW")) {
statusMessage = "Anonymer Anruf - keine Aktion, wird durchgestellt";
actionTaken = "NONE";
log.info("Blacklist: " + statusMessage);
return;
}
performAction(act, anonymousRedirectTarget);
blocked = true;
statusMessage = "Anonymer Anruf -> " + actionTaken;
log.info("Blacklist: " + statusMessage);
return;
}
// 3. Nummer gegen Blacklist prüfen.
// matchBlacklist() füllt matchAction/matchTarget/matchEntry, Rückgabe = Treffer ja/nein.
if (!matchBlacklist(caller)) {
statusMessage = "Nummer nicht in Blacklist - wird durchgestellt";
actionTaken = "NONE";
log.info("Blacklist: " + statusMessage);
return;
}
if (matchAction.equals("ALLOW")) {
statusMessage = "Treffer auf Ausnahme (ALLOW) - wird durchgestellt";
actionTaken = "ALLOW";
log.info("Blacklist: " + statusMessage + " [Regel: " + matchEntry + "]");
return;
}
performAction(matchAction, !matchTarget.isEmpty() ? matchTarget : defaultRedirectTarget);
blocked = true;
statusMessage = "Blacklist-Treffer [" + matchEntry + "] -> " + actionTaken;
log.info("Blacklist: " + statusMessage);
}
// ============================================================
// Anrufer ermitteln über GetCaller2
// ============================================================
/** Füllt callerRaw, callerNameVal, caller (intl) und isAnonymous. Rückgabe false bei Fehler. */
private boolean getCaller() {
try {
Object getCaller = newBlock(CN_GET_CALLER);
execBlock(getCaller);
callerNameVal = strField(getCaller, "callerName");
Object anon = field(getCaller, "isAnonymous");
isAnonymous = (anon instanceof Boolean) && (Boolean) anon;
// Externe Nummer bevorzugen, dann Signalisierung, dann interne Nummer
String ext = strField(getCaller, "callerExtNumber");
String sig = strField(getCaller, "callerSignallingNumber");
String intn = strField(getCaller, "callerIntNumber");
callerRaw = !ext.isEmpty() ? ext : (!sig.isEmpty() ? sig : intn);
caller = toIntl(callerRaw);
// Wenn keinerlei Nummer da ist, gilt der Anruf praktisch als anonym
if (callerRaw.isEmpty() && !isAnonymous) {
log.debug("Blacklist: Keine Anrufernummer vorhanden - als anonym behandelt");
isAnonymous = true;
}
return true;
} catch (Exception e) {
log.error("Blacklist: Fehler beim Ermitteln des Anrufers: " + e.getMessage(), e);
return false;
}
}
// ============================================================
// Blacklist-Matching
// ============================================================
// Ergebnis des letzten Treffers (statt innerer Klasse)
private String matchAction = "";
private String matchTarget = "";
private String matchEntry = "";
/** Prüft callerIntl gegen die Einträge. Bei Treffer werden matchAction/Target/Entry gesetzt. */
private boolean matchBlacklist(String callerIntl) {
if (blacklistEntries == null || blacklistEntries.trim().isEmpty()) {
return false;
}
if (callerIntl == null || callerIntl.isEmpty()) {
return false;
}
// Trennung an Zeilenumbrüchen und Kommas
String[] lines = blacklistEntries.split("[\\r\\n,]+");
for (String line : lines) {
String entry = line.trim();
if (entry.isEmpty() || entry.startsWith("#")) {
continue;
}
String[] parts = entry.split(";");
String number = parts[0].trim();
String action = parts.length > 1 ? norm(parts[1]) : norm(defaultAction);
String target = parts.length > 2 ? parts[2].trim() : "";
if (action.isEmpty()) {
action = "HANGUP";
}
String entryIntl = toIntl(number);
if (entryIntl.isEmpty()) {
continue;
}
boolean hit;
if (callerIntl.equals(entryIntl)) {
hit = true;
} else if (Boolean.TRUE.equals(prefixMatch)
&& entryIntl.length() >= 3
&& callerIntl.startsWith(entryIntl)) {
hit = true;
} else {
hit = false;
}
if (hit) {
matchAction = action;
matchTarget = target;
matchEntry = entry;
return true;
}
}
return false;
}
// ============================================================
// Aktionen ausführen
// ============================================================
private void performAction(String action, String target) {
try {
switch (action) {
case "HANGUP":
case "REJECT":
execBlock(newBlock(CN_HANGUP));
actionTaken = "HANGUP";
break;
case "BUSY":
execBlock(newBlock(CN_BUSY));
actionTaken = "BUSY";
break;
case "REDIRECT":
case "FORWARD":
if (target == null || target.trim().isEmpty()) {
log.error("Blacklist: REDIRECT ohne Ziel - lege stattdessen auf");
execBlock(newBlock(CN_HANGUP));
actionTaken = "HANGUP";
} else {
redirect(target.trim());
actionTaken = "REDIRECT";
}
break;
case "ANNOUNCE":
announce();
actionTaken = "ANNOUNCE";
break;
default:
log.warn("Blacklist: Unbekannte Aktion '" + action + "' - lege auf");
execBlock(newBlock(CN_HANGUP));
actionTaken = "HANGUP";
}
} catch (Exception e) {
log.error("Blacklist: Fehler beim Ausführen der Aktion '" + action + "': " + e.getMessage(), e);
// Im Zweifel auflegen
try {
execBlock(newBlock(CN_HANGUP));
actionTaken = "HANGUP";
} catch (Exception ignore) {
// nichts mehr möglich
}
}
}
private void redirect(String target) throws Exception {
Object call = newBlock(CN_CALL_NUMBER);
setField(call, "phoneNumber", target);
// Ursprünglichen Anrufer mitsignalisieren, damit das Ziel sieht, wer anruft
setField(call, "callerName", callerNameVal != null ? callerNameVal : "");
setField(call, "callerNumber", callerRaw != null ? callerRaw : "");
setField(call, "timeout", 30);
execBlock(call);
log.info("Blacklist: Anruf von " + caller + " umgeleitet auf " + target);
}
private void announce() throws Exception {
// Anruf annehmen, Ansage abspielen, dann auflegen
Object answer = newBlock(CN_ANSWER);
setField(answer, "delay", 0);
execBlock(answer);
if (announcementFile != null && !announcementFile.trim().isEmpty()) {
Object playback = newBlock(CN_PLAYBACK);
setField(playback, "defaultFile", announcementFile.trim());
execBlock(playback);
} else {
log.warn("Blacklist: ANNOUNCE ohne Ansage-Datei konfiguriert");
}
execBlock(newBlock(CN_HANGUP));
}
// ============================================================
// Reflection-Hilfen
// ============================================================
private Object newBlock(String className) throws Exception {
return Class.forName(className).getDeclaredConstructor().newInstance();
}
/** Führt einen Call-Handling-Block mit der aktuellen AGI-Runtime aus. */
private void execBlock(Object block) throws Exception {
Class<?> agiClass = Class.forName(CN_AGI_RUNTIME);
Method m = block.getClass().getMethod("execute", agiClass);
m.invoke(block, runtime);
}
private void setField(Object obj, String name, Object value) {
try {
Field f = obj.getClass().getField(name);
f.set(obj, value);
} catch (NoSuchFieldException e) {
log.debug("Blacklist: Feld '" + name + "' nicht vorhanden in " + obj.getClass().getName());
} catch (Exception e) {
log.warn("Blacklist: Konnte Feld '" + name + "' nicht setzen: " + e.getMessage());
}
}
private Object field(Object obj, String name) {
try {
Field f = obj.getClass().getField(name);
return f.get(obj);
} catch (Exception e) {
return null;
}
}
private String strField(Object obj, String name) {
Object v = field(obj, name);
return v != null ? v.toString() : "";
}
// ============================================================
// Nummern-Normalisierung
// ============================================================
/**
* Normalisiert eine Rufnummer in eine einheitliche internationale Ziffernform.
* Beispiele (countryCode=49):
* +49 721 12345 -> 4972112345
* 0049 721 12345 -> 4972112345
* 0721 12345 -> 4972112345
* 100 -> 100 (interne Nebenstelle)
*/
private String toIntl(String raw) {
if (raw == null) {
return "";
}
String r = raw.trim();
if (r.isEmpty()) {
return "";
}
boolean plus = r.startsWith("+");
String digits = r.replaceAll("[^0-9]", "");
if (digits.isEmpty()) {
return "";
}
String cc = countryCode != null ? countryCode.replaceAll("[^0-9]", "") : "";
if (plus) {
return digits;
}
if (digits.startsWith("00")) {
return digits.substring(2);
}
if (digits.startsWith("0")) {
return cc + digits.substring(1);
}
return digits;
}
private String norm(String s) {
return s == null ? "" : s.trim().toUpperCase();
}
}