Ver código fonte

feat(WecomCenter): one-click environment init (media type/robot user/action/macro) for fresh Zabbix; add install.sh; dark-theme support

DSH Agent 3 dias atrás
pai
commit
c1b84bd2f7

+ 28 - 1
web-modules/README.md

@@ -11,11 +11,37 @@
   - **模板导入**:内置的是**真实的华为模板** `Huawei VRP by SNMP walk`(导出文件在 `templates/`),点“导入”即导入 Zabbix;导入已存在模板会就地更新,导入新模板会新建。
   - **模板切换(迁移设备)**:选“旧模板 → 新模板”,**预览**列出受影响设备 → **确认切换**把每台设备的模板链接替换为新模板(这就是“导入后是否切换”那一步)。
   - **地图标记**:以 **单点(Zabbix server 主机)** 作为代表点,设置 `location_lat` / `location_lon`。
+  - **环境初始化 / 自检**:见下文“全新 Zabbix 两步装好”——一键补媒体类型、机器人用户、动作与 `{$WECOM_KEY}` 宏。
 - **日常维护(Maintenance)**
   - **触发器告警开关**:按名称搜索触发器,勾选 = 启用告警、取消勾选 = 停用该触发器(写 `trigger.status`,0 启用 / 1 停用)。单套环境常有数万个触发器,需先输入关键字搜索(最多显示 200 条)。
 
 所有改动都通过 **Zabbix 官方 API / 受控 SQL** 写入数据库,因此:换机器、重建容器、升级镜像后设置都在;只要“加载插件”即可复用。
 
+## 全新 Zabbix:两步装好
+
+**第一步 —— 装插件本体**(把文件搬进去):
+
+```bash
+# 在解压出来的 web-modules 目录里执行:
+./install.sh                     # 自动找到运行中的 zabbix-* web 容器并复制进去
+# 或指定普通(非 docker)前端模块目录:
+./install.sh /usr/share/zabbix/modules
+```
+
+然后到 **Administration → Modules → Scan directory → 启用 `WecomCenter`**(或执行 README 末尾的 SQL)。
+
+**第二步 —— 点“初始化”**:打开 **WeCom Center** 页面,首次进入会自动显示 **First-time setup**:
+粘贴群机器人 Key → 点 **Initialise / self-check**。它会自动补齐整条链路(缺啥建啥,已存在则复用、可反复点当自检):
+
+| 对象 | 说明 |
+|------|------|
+| `{$WECOM_KEY}` 全局宏 | 存群机器人 webhook key |
+| 媒体类型 `WeCom Group Robot` | type=4 webhook,内置发送脚本(markdown,失败回退 text) |
+| 机器人用户 `wecomrobot` | 挂上面这个媒体类型,severity = High+Disaster |
+| 触发器动作 `Send alarm by Wecom Robot` | 告警/恢复各发一条 markdown 到群里(含 `{$ZBX_URL}` 详情链接) |
+
+装完之后日常调参都在页面上点,不用再碰命令行。
+
 ## 目录结构
 
 ```
@@ -32,7 +58,8 @@ WecomCenter/
 │   └── Helper.php                # 业务逻辑:媒介/账号/key、方案预设、导入、坐标、测试
 ├── locale/                       # 35 个语言文件(见“多语言”)
 ├── templates/Huawei VRP by SNMP walk.yaml  # 内置真实华为模板(导入/切换用)
-└── assets/wecom.center.css
+├── assets/wecom.center.css
+└── install.sh                    # 一步搬运脚本(docker 自动探测 / 指定目录)
 ```
 
 ## 安装(可移植方式)

+ 18 - 0
web-modules/WecomCenter/actions/WecomCenterSave.php

@@ -45,6 +45,7 @@ class WecomCenterSave extends CController {
 					case 'import_template': $this->importTemplate();        break;
 					case 'save_map':        $this->saveMap();               break;
 					case 'apply_switch':    $this->applySwitch();           break;
+					case 'init_env':        $this->initEnv();               break;
 					default: error(I18n::t('msg.unknown_op'));
 				}
 			}
@@ -196,4 +197,21 @@ class WecomCenterSave extends CController {
 		$n = Helper::switchApply($src, $dst);
 		info(I18n::t('msg.switch_applied', [$n]));
 	}
+
+	private function initEnv(): void {
+		$key = trim((string) ($_REQUEST['key'] ?? $_REQUEST['webhook_url'] ?? ''));
+		$proto = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
+		$host = (string) ($_SERVER['HTTP_HOST'] ?? '');
+		$zbxUrl = $host !== '' ? $proto.'://'.$host : '';
+		$r = Helper::initializeEnvironment($key, $zbxUrl);
+
+		$parts = [];
+		$parts[] = $r['created_mediatype'] ? I18n::t('msg.init_mediatype_new') : I18n::t('msg.init_mediatype_ok');
+		$parts[] = $r['created_user'] ? I18n::t('msg.init_user_new') : I18n::t('msg.init_user_ok');
+		$parts[] = $r['created_action'] ? I18n::t('msg.init_action_new') : I18n::t('msg.init_action_ok');
+		if ($r['key_set']) {
+			$parts[] = I18n::t('msg.init_key_set');
+		}
+		info(I18n::t('msg.init_done', [implode(' · ', $parts)]));
+	}
 }

+ 47 - 0
web-modules/WecomCenter/assets/wecom.center.css

@@ -119,3 +119,50 @@ select.wc-input { appearance: auto; }
 }
 .wc-tab:hover { color: #2f6fdd; background: #f2f6ff; }
 .wc-tab.is-active { color: #2f6fdd; border-bottom-color: #2f6fdd; background: #eef4ff; }
+
+/* ==================================================================
+   Dark theme (Zabbix sets theme/color-scheme on <html>)
+   ================================================================== */
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-card {
+	background: #1b2230; border-color: #2b3441; box-shadow: 0 1px 3px rgba(0,0,0,.45);
+}
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-card__head {
+	background: #212a38; border-bottom-color: #2b3441;
+}
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-card__title { color: #e7ebf1; }
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-card__sub { color: #8b95a3; }
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-hint { color: #94a0b0; }
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-field label { color: #aab4c2; }
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-input {
+	background: #121924; color: #e7ebf1; border-color: #36414f;
+}
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-input:focus {
+	border-color: #4f8bf0; box-shadow: 0 0 0 3px rgba(79,139,240,.25);
+}
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-btn--ghost {
+	background: #212a38; color: #7fb0ff; border-color: #36414f;
+}
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-btn--ghost:hover { background: #263140; }
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-pill--muted { background: #263140; color: #aab4c2; }
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-preset { border-bottom-color: #28313d; }
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-preset__name { color: #e7ebf1; }
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-preset__desc { color: #8b95a3; }
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-table th {
+	background: #212a38; color: #aab4c2; border-bottom-color: #2b3441;
+}
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-table td {
+	color: #d5dbe3; border-bottom-color: #28313d;
+}
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-table tr.wc-tr-total td { background: #212a38; }
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-code__label { color: #aab4c2; }
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-chip { background: #20304a; color: #9cc0ff; }
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-divider { background: #28313d; }
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-muted,
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-empty { color: #8b95a3; }
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-checks label { color: #d5dbe3; }
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-tabs { border-bottom-color: #2b3441; }
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-tab { color: #9aa6b5; }
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-tab:hover { background: #212a38; color: #7fb0ff; }
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-tab.is-active {
+	color: #7fb0ff; border-bottom-color: #4f8bf0; background: #1f2b3d;
+}

+ 163 - 0
web-modules/WecomCenter/lib/Helper.php

@@ -832,4 +832,167 @@ class Helper {
 		}
 		return $n;
 	}
+
+	/* ================================================================== */
+	/*  ONE-CLICK ENVIRONMENT INITIALISATION (fresh Zabbix bootstrap)     */
+	/*  Creates whatever is missing so the module works out of the box:   */
+	/*  {$WECOM_KEY} macro, webhook media type, robot user + media,       */
+	/*  and the trigger action. Existing objects are reused, never duped. */
+	/* ================================================================== */
+
+	public const MEDIATYPE_NAME = 'WeCom Group Robot';
+	public const ROBOT_USERNAME = 'wecomrobot';
+	public const ACTION_NAME    = 'Send alarm by Wecom Robot';
+	public const ZBX_URL_MACRO  = '{$ZBX_URL}';
+
+	public const WEBHOOK_SCRIPT = <<<'JS'
+var params = JSON.parse(value);
+var content = params.message || 'Zabbix';
+var url = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=' + params.key;
+function send(payload){
+  var r = new HttpRequest();
+  r.addHeader('Content-Type: application/json');
+  var resp = r.post(url, JSON.stringify(payload));
+  return {status: r.getStatus(), body: resp};
+}
+var res = send({msgtype: 'markdown', markdown: {content: content}});
+if (res.status !== 200) {
+  var res2 = send({msgtype: 'text', text: {content: content}});
+  if (res2.status !== 200) {
+    Zabbix.Log(4, 'WeCom webhook error HTTP ' + res.status + ' body: ' + res.body);
+    throw 'HTTP ' + res.status + ' => ' + res.body;
+  }
+}
+return 'Message sent successfully';
+JS;
+
+	/** Find an existing WeCom webhook media type (ours by name, else one bound to {$WECOM_KEY}). */
+	public static function findWebhookMediatype(): ?array {
+		$mts = self::webhookMediatypes();
+		foreach ($mts as $m) {
+			if (isset($m['name']) && $m['name'] === self::MEDIATYPE_NAME) {
+				return $m;
+			}
+		}
+		foreach ($mts as $m) {
+			foreach (($m['parameters'] ?? []) as $p) {
+				if (strtolower((string) $p['name']) === 'key' && strpos((string) $p['value'], 'WECOM_KEY') !== false) {
+					return $m;
+				}
+			}
+		}
+		foreach ($mts as $m) {
+			if (stripos((string) $m['name'], 'wecom robot') !== false) {
+				return $m;
+			}
+		}
+		return null;
+	}
+
+	/** Ensure the WeCom webhook media type exists. @return array{0:string,1:bool} [mediatypeid, created] */
+	public static function ensureMediatype(): array {
+		$found = self::findWebhookMediatype();
+		if ($found) {
+			return [(string) $found['mediatypeid'], false];
+		}
+		$res = API::Mediatype()->create([
+			'name'           => self::MEDIATYPE_NAME,
+			'type'           => 4, // webhook
+			'status'         => 0, // enabled
+			'message_format' => 1, // markdown
+			'description'    => 'Created by WeCom Center: posts alerts to a WeCom group robot.',
+			'timeout'        => '30s',
+			'script'         => self::WEBHOOK_SCRIPT,
+			'parameters'     => [
+				['name' => 'message', 'value' => '{ALERT.MESSAGE}'],
+				['name' => 'key',     'value' => self::KEY_MACRO]
+			]
+		]);
+		return [(string) $res['mediatypeids'][0], true];
+	}
+
+	/** Ensure the trigger action exists and points to the robot user + media type. */
+	public static function ensureAction(string $mediatypeid, string $userid): bool {
+		$ex = API::Action()->get([
+			'output' => ['actionid'],
+			'filter' => ['name' => self::ACTION_NAME]
+		]);
+		if ($ex) {
+			return false;
+		}
+		$problem = "<font color=\"warning\">**🔴 {TRIGGER.SEVERITY} · {HOST.NAME}**</font>\n"
+			."> 问题:{TRIGGER.NAME}\n"
+			."> 时间:{EVENT.DATE} {EVENT.TIME}   |   持续:{EVENT.AGE}\n"
+			."> 当前值:{ITEM.LASTVALUE}\n"
+			."[查看详情]({$ZBX_URL}/tr_events.php?triggerid={TRIGGER.ID}&eventid={EVENT.ID})";
+		$recovery = "<font color=\"info\">**🟢 已恢复 · {HOST.NAME}**</font>\n"
+			."> 问题:{TRIGGER.NAME}\n"
+			."> 恢复时间:{EVENT.RECOVERY.DATE} {EVENT.RECOVERY.TIME}\n"
+			."> 故障持续:{EVENT.DURATION}\n"
+			."[查看详情]({$ZBX_URL}/tr_events.php?triggerid={TRIGGER.ID}&eventid={EVENT.ID})";
+
+		API::Action()->create([
+			'name'        => self::ACTION_NAME,
+			'eventsource' => 0, // trigger actions
+			'status'      => 0,
+			'esc_period'  => '10m',
+			'filter'      => ['evaltype' => 0, 'conditions' => []],
+			'operations'  => [[
+				'operationtype' => 0,
+				'esc_step_from' => 1,
+				'esc_step_to'   => 1,
+				'opmessage'     => [
+					'default_msg' => 0,
+					'subject'     => '[Zabbix Alarm] {TRIGGER.NAME}',
+					'message'     => $problem,
+					'mediatypeid' => $mediatypeid
+				],
+				'opmessage_usr' => [['userid' => $userid]]
+			]],
+			'recovery_operations' => [[
+				'operationtype' => 0,
+				'opmessage'     => [
+					'default_msg' => 0,
+					'subject'     => '[Zabbix Recovery] {TRIGGER.NAME}',
+					'message'     => $recovery,
+					'mediatypeid' => $mediatypeid
+				],
+				'opmessage_usr' => [['userid' => $userid]]
+			]]
+		]);
+		return true;
+	}
+
+	/**
+	 * Idempotent bootstrap. Safe to run repeatedly.
+	 * @return array{key_set:bool,zbx_url:bool,mediatypeid:string,created_mediatype:bool,created_user:bool,created_action:bool}
+	 */
+	public static function initializeEnvironment(string $key, string $zbxUrl = ''): array {
+		$out = [
+			'key_set' => false, 'zbx_url' => false, 'mediatypeid' => '',
+			'created_mediatype' => false, 'created_user' => false, 'created_action' => false
+		];
+		if ($key !== '') {
+			self::setWebhookKey($key);
+			$out['key_set'] = true;
+		}
+		if ($zbxUrl !== '' && self::getGlobalMacro(self::ZBX_URL_MACRO) === null) {
+			self::setGlobalMacro(self::ZBX_URL_MACRO, $zbxUrl);
+			$out['zbx_url'] = true;
+		}
+
+		[$mtid, $createdMt] = self::ensureMediatype();
+		$out['mediatypeid'] = $mtid;
+		$out['created_mediatype'] = $createdMt;
+
+		$existed = self::robotUser(self::ROBOT_USERNAME) !== null;
+		$user = self::ensureRobotUser(self::ROBOT_USERNAME);
+		$out['created_user'] = !$existed;
+		$uid = $user['userid'] ?? '';
+		if ($uid !== '') {
+			self::applyMedia((string) $uid, $mtid, 48, 0); // High + Disaster, enabled
+			$out['created_action'] = self::ensureAction($mtid, (string) $uid);
+		}
+		return $out;
+	}
 }

+ 16 - 0
web-modules/WecomCenter/locale/en.php

@@ -134,4 +134,20 @@ return [
 	'msg.need_both'   => 'Please choose both source and target template.',
 	'msg.same_tpl'    => 'Source and target template must differ.',
 	'msg.switch_applied' => 'Switched {1} device(s) to the new template.',
+
+	// one-click environment initialisation
+	'sec.init'           => 'Environment initialisation',
+	'lbl.init_hint'      => 'On a brand-new Zabbix click this once — it creates the WeCom webhook media type, the robot user with its media, the trigger action and the {$WECOM_KEY} macro. Existing objects are reused (safe to re-run as a self-check).',
+	'lbl.init_key'       => 'Group-robot key (Webhook key)',
+	'btn.init'           => 'Initialise / self-check',
+	'init.title'         => 'First-time setup',
+	'init.sub'           => 'This Zabbix has no WeCom media type yet. Paste your group-robot key and initialise — the media type, robot user, trigger action and macro will all be created automatically, then alerts start flowing to the group.',
+	'msg.init_done'         => 'Initialisation complete: {1}.',
+	'msg.init_mediatype_new' => 'media type created',
+	'msg.init_mediatype_ok'  => 'media type reused',
+	'msg.init_user_new'      => 'robot user created',
+	'msg.init_user_ok'       => 'robot user reused',
+	'msg.init_action_new'    => 'trigger action created',
+	'msg.init_action_ok'     => 'trigger action exists',
+	'msg.init_key_set'       => 'key saved',
 ];

+ 16 - 0
web-modules/WecomCenter/locale/zh_CN.php

@@ -121,4 +121,20 @@ return [
 	'msg.need_both'   => '请同时选择源模板与目标模板。',
 	'msg.same_tpl'    => '源模板与目标模板必须不同。',
 	'msg.switch_applied' => '已切换 {1} 台设备到新模板。',
+
+	// 一键环境初始化
+	'sec.init'           => '环境初始化',
+	'lbl.init_hint'      => '在全新的 Zabbix 上点一次即可:自动创建企业微信 Webhook 媒体类型、机器人用户及其媒介、触发器动作,以及 {$WECOM_KEY} 宏。已存在的对象会复用(可重复点,当作自检)。',
+	'lbl.init_key'       => '群机器人 Key(Webhook key)',
+	'btn.init'           => '初始化 / 自检',
+	'init.title'         => '首次初始化',
+	'init.sub'           => '当前 Zabbix 还没有企业微信媒体类型。粘贴群机器人 Key 并初始化——媒体类型、机器人用户、触发器动作和宏都会自动建好,之后告警就会发到群里。',
+	'msg.init_done'         => '初始化完成:{1}。',
+	'msg.init_mediatype_new' => '媒体类型已创建',
+	'msg.init_mediatype_ok'  => '媒体类型已复用',
+	'msg.init_user_new'      => '机器人用户已创建',
+	'msg.init_user_ok'       => '机器人用户已复用',
+	'msg.init_action_new'    => '触发器动作已创建',
+	'msg.init_action_ok'     => '触发器动作已存在',
+	'msg.init_key_set'       => 'Key 已保存',
 ];

+ 22 - 3
web-modules/WecomCenter/views/wecom.center.view.php

@@ -41,8 +41,18 @@ $tabHref = function (string $id) use ($data) {
 $html_page = (new CHtmlPage())->setTitle(I18n::t('page.title'));
 
 if (!$data['mediatype']) {
-	$html_page->addItem($raw('<div class="wc-wrap"><div class="wc-card"><div class="wc-card__body">'
-		.$t('msg.no_mediatype').'</div></div></div>'));
+	$initBody = '<div class="wc-hint">'.$t('init.sub').'</div>'
+		.'<form method="post" action="'.$save.'">'
+		.'<div class="wc-field"><label>'.$t('lbl.init_key').'</label>'
+		.'<input class="wc-input wc-input--mono" type="text" name="key" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"></div>'
+		.'<div class="wc-actions"><button class="wc-btn wc-btn--primary" type="submit" name="op" value="init_env">'.$t('btn.init').'</button></div>'
+		.'</form>';
+	$initCard = '<div class="wc-card"><div class="wc-card__head"><span class="wc-card__ico">&#9889;</span>'
+		.'<span class="wc-card__title">'.$t('init.title').'</span></div>'
+		.'<div class="wc-card__body">'.$initBody.'</div></div>';
+	$heroI = '<div class="wc-hero"><div class="wc-hero__title">'.$t('page.title').'</div>'
+		.'<div class="wc-hero__sub">'.$t('init.sub').'</div></div>';
+	$html_page->addItem($raw('<div class="wc-wrap">'.$heroI.$initCard.'</div>'));
 	$html_page->show();
 	return;
 }
@@ -236,7 +246,16 @@ if ($data['switch_preview'] !== null) {
 }
 $switchCard = $card('&#128257;', $t('sec.switch'), $switchBody, ' wc-card--wide');
 
-$tab_opt = '<div class="wc-grid">'.$tuningCard.$importCard.$mapCard.$switchCard.'</div>';
+$initBody2 = '<div class="wc-hint">'.$t('lbl.init_hint').'</div>'
+	.'<form method="post" action="'.$save.'">'
+	.'<input type="hidden" name="tab" value="opt">'
+	.'<div class="wc-field"><label>'.$t('lbl.init_key').'</label>'
+	.'<input class="wc-input wc-input--mono" type="text" name="key" value="'.$e($data['webhook_url']).'"></div>'
+	.'<div class="wc-actions"><button class="wc-btn wc-btn--ghost" type="submit" name="op" value="init_env">'.$t('btn.init').'</button></div>'
+	.'</form>';
+$initCard2 = $card('&#9889;', $t('sec.init'), $initBody2);
+
+$tab_opt = '<div class="wc-grid">'.$tuningCard.$importCard.$mapCard.$initCard2.$switchCard.'</div>';
 
 /* ======================== TAB 3: MAINTENANCE ========================== */
 

+ 45 - 0
web-modules/install.sh

@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+# ------------------------------------------------------------------
+# WeCom Center — one-step installer for a new Zabbix frontend
+#
+# Usage:
+#   ./install.sh                 # auto-detect a running zabbix-* web container
+#   ./install.sh /path/to/modules# copy into a plain (non-docker) modules dir
+#
+# After this script finishes, open Zabbix -> Administration -> Modules,
+# press "Scan directory", then Enable "WecomCenter". Then open the
+# WeCom Center page and use the "First-time setup / Initialise" button.
+# ------------------------------------------------------------------
+set -euo pipefail
+
+HERE="$(cd "$(dirname "$0")" && pwd)"
+SRC="$HERE/WecomCenter"
+
+if [ ! -d "$SRC" ]; then
+  echo "ERROR: WecomCenter folder not found next to install.sh" >&2
+  exit 1
+fi
+
+if [ $# -ge 1 ]; then
+  DEST="$1"
+  mkdir -p "$DEST"
+  rm -rf "$DEST/WecomCenter"
+  cp -r "$SRC" "$DEST/WecomCenter"
+  echo "OK: copied to $DEST/WecomCenter"
+else
+  C="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -iE 'zabbix.*(web|nginx|apache|frontend)' | head -n1 || true)"
+  if [ -z "$C" ]; then
+    echo "ERROR: no running Zabbix frontend container detected." >&2
+    echo "       Pass the modules directory explicitly, e.g.:  ./install.sh /opt/zabbix/zabbix-server/web-modules" >&2
+    exit 1
+  fi
+  docker cp "$SRC" "$C:/usr/share/zabbix/modules/"
+  docker exec "$C" sh -c 'chown -R www-data:www-data /usr/share/zabbix/modules/WecomCenter 2>/dev/null || true'
+  echo "OK: copied into container '$C' -> /usr/share/zabbix/modules/WecomCenter"
+fi
+
+echo
+echo "Next steps:"
+echo "  1) Zabbix -> Administration -> Modules -> 'Scan directory' -> Enable 'WecomCenter'"
+echo "  2) Open 'WeCom Center' -> First-time setup -> paste your group-robot key -> Initialise"
+echo "     (it creates the media type, robot user, trigger action and {\$WECOM_KEY} macro)"