survives image upgrades and is portable. */ namespace Modules\WecomCenter\Lib; use API; use Exception; class Helper { // Zabbix trigger severity index => media severity bit (1 << index). public const SEVERITY_BITS = [1, 2, 4, 8, 16, 32]; // Built-in alert schemes: name => [severity_mask, active(0=enabled,1=disabled)] public const SCHEMES = [ 'standard' => [48, 0], // High + Disaster (recommended) 'warnplus' => [56, 0], // Average + High + Disaster 'all' => [63, 0], // everything 'quiet' => [32, 0], // Disaster only 'off' => [63, 1] // alerting disabled ]; /* ------------------------------------------------------------------ */ /* Media types / robot */ /* ------------------------------------------------------------------ */ /** * All webhook media types (type == 4). */ public static function webhookMediatypes(): array { try { $res = API::Mediatype()->get([ 'output' => ['mediatypeid', 'name', 'type', 'status'], 'selectParameters' => 'extend', 'filter' => ['type' => 4] ]); return is_array($res) ? $res : []; } catch (\Throwable $e) { return []; } } public static function getMediatype(string $mediatypeid): ?array { try { $res = API::Mediatype()->get([ 'output' => ['mediatypeid', 'name', 'type', 'status'], 'selectParameters' => 'extend', 'mediatypeids' => [$mediatypeid] ]); return (is_array($res) && $res) ? $res[0] : null; } catch (\Throwable $e) { return null; } } // Fixed WeCom group-robot endpoint; the per-bot secret lives in {$WECOM_KEY}. public const WECOM_BASE = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key='; public const KEY_MACRO = '{$WECOM_KEY}'; /** Read a global macro value; null when missing. */ public static function getGlobalMacro(string $name): ?string { try { $res = API::UserMacro()->get([ 'globalmacro' => true, 'output' => ['globalmacroid', 'macro', 'value'], 'filter' => ['macro' => $name] ]); return ($res && isset($res[0])) ? (string) $res[0]['value'] : null; } catch (\Throwable $e) { return null; } } /** Create or update a global macro. */ public static function setGlobalMacro(string $name, string $value): void { $res = API::UserMacro()->get([ 'globalmacro' => true, 'output' => ['globalmacroid', 'macro'], 'filter' => ['macro' => $name] ]); if ($res && isset($res[0])) { API::UserMacro()->updateGlobal([ ['globalmacroid' => $res[0]['globalmacroid'], 'value' => $value] ]); } else { API::UserMacro()->createGlobal([ ['macro' => $name, 'value' => $value] ]); } } /** * Current robot key: {$WECOM_KEY} global macro, falling back to any legacy * value still stored in the media type parameters. */ public static function webhookKey(?array $mediatype = null): string { $v = self::getGlobalMacro(self::KEY_MACRO); if ($v !== null && $v !== '') { return $v; } foreach (($mediatype['parameters'] ?? []) as $p) { $n = strtolower($p['name']); $val = (string) $p['value']; if ($n === 'key' && strpos($val, '{') === false) { return $val; } if (in_array($n, ['webhook_url', 'url', 'webhook'], true)) { if (strpos($val, 'key=') !== false) { return substr($val, strpos($val, 'key=') + 4); } if (strpos($val, 'http') !== 0) { return $val; } } } return ''; } /** Persist the robot key into the {$WECOM_KEY} global macro (managed here, not in the media type). */ public static function setWebhookKey(string $key): void { self::setGlobalMacro(self::KEY_MACRO, $key); } /** Full webhook URL for this bot = fixed base + {$WECOM_KEY}. */ public static function webhookUrl(?array $mediatype = null): string { $key = self::webhookKey($mediatype); return $key === '' ? '' : self::WECOM_BASE.$key; } /* ------------------------------------------------------------------ */ /* Robot user + media (severity / active) */ /* ------------------------------------------------------------------ */ /** * Find the dedicated robot user with its media. */ public static function robotUser(string $username = 'wecomrobot'): ?array { $res = API::User()->get([ 'output' => ['userid', 'username'], 'selectMedias' => 'extend', 'filter' => ['username' => $username] ]); return $res ? $res[0] : null; } /** * Current severity mask + active flag of a user's media for a media type. * Returns ['active'=>int, 'severity'=>int] or null when media absent. */ public static function currentMedia(?array $user, string $mediatypeid): ?array { if (!$user || empty($user['medias'])) { return null; } foreach ($user['medias'] as $m) { if ((string) $m['mediatypeid'] === (string) $mediatypeid) { return [ 'active' => (int) $m['active'], 'severity' => (int) $m['severity'], 'period' => $m['period'] ?? '1-7,00:00-24:00', 'sendto' => $m['sendto'] ?? '' ]; } } return null; } /** * Ensure a robot user exists; create one (no frontend access) if missing. */ public static function ensureRobotUser(string $username): array { $user = self::robotUser($username); if ($user) { return $user; } // best-effort group / role for a non-login notification account $grp = API::UserGroup()->get([ 'output' => ['usrgrpid', 'name'], 'filter' => ['name' => 'No access to the frontend'], 'limit' => 1 ]); $usrgrpid = $grp ? $grp[0]['usrgrpid'] : null; if ($usrgrpid === null) { $all = API::UserGroup()->get(['output' => ['usrgrpid'], 'limit' => 1]); $usrgrpid = $all ? $all[0]['usrgrpid'] : null; } $role = API::Role()->get([ 'output' => ['roleid', 'name'], 'filter' => ['name' => 'User role'], 'limit' => 1 ]); $roleid = $role ? $role[0]['roleid'] : null; $create = [ 'username' => $username, 'passwd' => bin2hex(random_bytes(16)).'Aa1!', 'usrgrps' => [['usrgrpid' => $usrgrpid]] ]; if ($roleid !== null) { $create['roleid'] = $roleid; } $res = API::User()->create($create); return self::robotUser($username); } /** * Apply severity mask + active flag to a user's media for a media type. * Rebuilds the whole media list (API user.update replaces medias). */ public static function applyMedia(string $userid, string $mediatypeid, int $severity, int $active, string $sendto = 'WeCom') { $res = API::User()->get([ 'output' => ['userid'], 'selectMedias' => 'extend', 'userids' => [$userid] ]); $medias = []; if ($res) { foreach (($res[0]['medias'] ?? []) as $m) { $medias[] = [ 'mediatypeid' => $m['mediatypeid'], 'sendto' => $m['sendto'], 'severity' => (string) $m['severity'], 'active' => (string) $m['active'], 'period' => $m['period'] ?: '1-7,00:00-24:00' ]; } } $hit = false; foreach ($medias as &$m) { if ((string) $m['mediatypeid'] === (string) $mediatypeid) { $m['severity'] = (string) $severity; $m['active'] = (string) $active; if ($sendto !== '') { $m['sendto'] = $sendto; } $hit = true; } } unset($m); if (!$hit) { $medias[] = [ 'mediatypeid' => $mediatypeid, 'sendto' => $sendto !== '' ? $sendto : 'WeCom', 'severity' => (string) $severity, 'active' => (string) $active, 'period' => '1-7,00:00-24:00' ]; } API::User()->update(['userid' => $userid, 'medias' => $medias]); } /* ------------------------------------------------------------------ */ /* Send test message */ /* ------------------------------------------------------------------ */ public static function testWebhook(string $url, string $text): array { $payload = json_encode(['msgtype' => 'text', 'text' => ['content' => $text]], JSON_UNESCAPED_UNICODE); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_TIMEOUT => 15, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0 ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); return ['status' => $status, 'body' => $body !== false ? $body : $err]; } /* ------------------------------------------------------------------ */ /* Templates */ /* ------------------------------------------------------------------ */ /** * Bundled templates shipped inside modules/WecomCenter/templates/. */ public static function bundledTemplates(string $module_dir): array { $dir = rtrim($module_dir, '/').'/templates'; $out = []; if (!is_dir($dir)) { return $out; } foreach (scandir($dir) as $f) { if ($f === '.' || $f === '..') { continue; } $ext = strtolower(pathinfo($f, PATHINFO_EXTENSION)); if (in_array($ext, ['xml', 'yaml', 'yml', 'json'], true)) { $out[] = ['file' => $f, 'name' => pathinfo($f, PATHINFO_FILENAME)]; } } return $out; } public static function importTemplate(string $module_dir, string $file): array { $dir = rtrim($module_dir, '/').'/templates'; $file = basename($file); $path = $dir.'/'.$file; if (!is_file($path)) { throw new Exception('Template file not found: '.$file); } $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); $format = ['xml' => 'xml', 'json' => 'json', 'yaml' => 'yaml', 'yml' => 'yaml'][$ext] ?? 'yaml'; $source = file_get_contents($path); $result = API::Configuration()->import([ 'format' => $format, 'source' => $source, 'rules' => [ 'template_groups' => ['createMissing' => true, 'updateExisting' => true], 'templates' => ['createMissing' => true, 'updateExisting' => true], 'items' => ['createMissing' => true, 'updateExisting' => true], 'triggers' => ['createMissing' => true, 'updateExisting' => true] ] ]); return is_array($result) ? $result : []; } /* ------------------------------------------------------------------ */ /* Map coordinates */ /* ------------------------------------------------------------------ */ public static function hosts(): array { return API::Host()->get([ 'output' => ['hostid', 'name'], 'sortfield' => 'name', 'limit' => 1000 ]); } public static function setHostLocation(string $hostid, string $lat, string $lon): void { API::Host()->update([ 'hostid' => $hostid, 'inventory_mode' => 0, // manual 'inventory' => [ 'location_lat' => $lat, 'location_lon' => $lon ] ]); } public static function hostLocation(string $hostid): array { $res = API::Host()->get([ 'output' => ['hostid', 'name'], 'selectInventory' => ['location_lat', 'location_lon'], 'hostids' => [$hostid] ]); return $res ? $res[0] : []; } /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ public static function severityMask(array $indices): int { $mask = 0; foreach ($indices as $i) { $i = (int) $i; if ($i >= 1 && $i <= 6) { $mask |= self::SEVERITY_BITS[$i - 1]; } } return $mask; } /* ------------------------------------------------------------------ */ /* Triggers (per-trigger alerting switch) */ /* ------------------------------------------------------------------ */ /** * Search triggers by description. An empty search returns nothing on * purpose - the instance has tens of thousands of triggers, listing all * is pointless. Results are capped. */ public static function triggers(string $search, int $limit = 200): array { $search = trim($search); if ($search === '') { return []; } try { $res = API::Trigger()->get([ 'output' => ['triggerid', 'description', 'status', 'priority'], 'selectHosts' => ['host'], 'search' => ['description' => $search], 'limit' => $limit ]); return is_array($res) ? $res : []; } catch (\Throwable $e) { return []; } } public static function triggerCount(): int { try { $n = API::Trigger()->get(['countOutput' => true]); return (int) $n; } catch (\Throwable $e) { return 0; } } /** * Set trigger status. $map = [triggerid => status(0 enabled,1 disabled)]. * NB: this Zabbix version's trigger.update does NOT accept a "triggers" * array, so we update one trigger at a time. */ public static function setTriggersStatus(array $map): int { $changed = 0; foreach ($map as $tid => $status) { $res = API::Trigger()->update([ 'triggerid' => (string) $tid, 'status' => (string) ((int) $status) ]); if ($res === false) { throw new Exception('trigger.update failed for '.$tid); } $changed++; } return $changed; } }