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(); } }