/** * ZNC Push Module * * Allows the user to enter a Push user and API token, and sends * channel highlights and personal messages to Push. * * Copyright (c) 2011 John Reese * Licensed under the MIT license */ #define REQUIRESSL #define PUSHVERSION "dev" #include #include #include #include #include #include #include #include "time.h" #include #ifdef USE_CURL #include #endif // USE_CURL // Forward declaration class CPushMod; /** * Shorthand for encoding a string for a URL. * * @param str String to be encoded * @return Encoded string */ CString urlencode(const CString& str) { return str.Escape_n(CString::EASCII, CString::EURL); } #ifndef USE_CURL /** * Socket class for generating HTTP requests. */ class CPushSocket : public CSocket { public: CPushSocket(CModule *p) : CSocket(p) { EnableReadLine(); parent = (CPushMod*) p; first = true; crlf = "\r\n"; user_agent = "ZNC Push/" + CString(PUSHVERSION); } // Implemented after CPushMod void Request(bool post, const CString& host, const CString& url, MCString& parameters, const CString& auth=""); virtual void ReadLine(const CString& data); virtual void Disconnected(); private: CPushMod *parent; bool first; // Too lazy to add CString("\r\n\") everywhere CString crlf; // User agent to use CString user_agent; }; #else // forward declaration CURLcode make_curl_request(const CString& service_host, const CString& service_url, const CString& service_auth, MCString& params, int port, bool use_ssl, bool use_post, bool debug); #endif // USE_CURL /** * Push notification module. */ class CPushMod : public CModule { protected: // Application name CString app; // Time last notification was sent for a given context std::map last_notification_time; // Time of last message by user to a given context std::map last_reply_time; // Time of last activity by user for a given context std::map last_active_time; // Time of last activity by user in any context time_t idle_time; // User object CUser *user; // Configuration options MCString options; MCString defaults; public: MODCONSTRUCTOR(CPushMod) { #ifdef USE_CURL curl_global_init(CURL_GLOBAL_DEFAULT); #endif app = "ZNC"; idle_time = time(NULL); // Current user user = GetUser(); // Push service information defaults["service"] = ""; defaults["username"] = ""; defaults["secret"] = ""; defaults["target"] = ""; // Notification settings defaults["message_content"] = "{message}"; defaults["message_length"] = "100"; defaults["message_title"] = "{title}"; defaults["message_uri"] = ""; defaults["message_uri_title"] = ""; defaults["message_priority"] = "0"; defaults["message_sound"] = ""; // Notification conditions defaults["away_only"] = "no"; defaults["client_count_less_than"] = "0"; defaults["highlight"] = ""; defaults["idle"] = "0"; defaults["last_active"] = "180"; defaults["last_notification"] = "300"; defaults["nick_blacklist"] = ""; defaults["replied"] = "yes"; // Advanced defaults["channel_conditions"] = "all"; defaults["query_conditions"] = "all"; defaults["debug"] = "off"; } virtual ~CPushMod() { #ifdef USE_CURL curl_global_cleanup(); #endif } public: /** * Debugging messages. Prints to *push when the debug option is enabled. * * @param data Debug message */ void PutDebug(const CString& data) { if (options["debug"] == "on") { PutModule(data); } } protected: /** * Performs string expansion on a set of keywords. * Given an initial string and a dictionary of string replacments, * iterate over the dictionary, expanding keywords one-by-one. * * @param content String contents * @param replace Dictionary of string replacements * @return Result of string replacements */ CString expand(const CString& content, MCString& replace) { CString result = content.c_str(); for(MCString::iterator i = replace.begin(); i != replace.end(); i++) { result.Replace(i->first, i->second); } return result; } /** * Verifies whether a given string contains only numbers. * * @param content String to verify */ bool is_number(const CString& content) { CString::const_iterator it = content.begin(); while(it != content.end() && std::isdigit(*it)) ++it; return !content.empty() && it == content.end(); } /** * Send a message to the currently-configured push service. * Requires (and assumes) that the user has already configured their * username and API secret using the 'set' command. * * @param message Message to be sent to the user * @param title Message title to use * @param context Channel or nick context */ void send_message(const CString& message, const CString& title="New Message", const CString& context="*push", const CNick& nick=CString("*push")) { // Set the last notification time last_notification_time[context] = time(NULL); // Shorten message if needed unsigned int message_length = options["message_length"].ToUInt(); CString short_message = message; if (message_length > 0) { short_message = message.Ellipsize(message_length); } // Generate an ISO8601 date string time_t rawtime; struct tm * timeinfo; time(&rawtime); timeinfo = localtime(&rawtime); char iso8601 [20]; strftime(iso8601, 20, "%Y-%m-%d %H:%M:%S", timeinfo); // Message string replacements MCString replace; replace["{context}"] = context; replace["{nick}"] = nick.GetNick(); replace["{datetime}"] = CString(iso8601); replace["{unixtime}"] = CString(time(NULL)); replace["{message}"] = short_message; replace["{title}"] = title; replace["{username}"] = options["username"]; replace["{secret}"] = options["secret"]; CString message_uri = expand(options["message_uri"], replace); CString message_title = expand(options["message_title"], replace); CString message_content = expand(options["message_content"], replace); // Set up the connection profile CString service = options["service"]; bool use_post = true; int use_port = 443; bool use_ssl = true; CString service_host; CString service_url; CString service_auth; MCString params; // Service-specific profiles if (service == "pushbullet") { if (options["target"] == "" || options["secret"] == "") { PutModule("Error: target (device id) or secret (api key) not set"); return; } service_host = "api.pushbullet.com"; service_url = "/api/pushes"; // BASIC auth, base64-encoded APIKey: service_auth = options["secret"] + CString(":"); service_auth.Base64Encode(); // Pushbullet uses numeric device_id but they // are transitioning to an alphanumeric device_iden if (is_number(options["target"])) { params["device_id"] = options["target"]; } else { params["device_iden"] = options["target"]; } params["type"] = "note"; params["title"] = message_title; params["body"] = message_content; } else if (service == "boxcar") { if (options["username"] == "") { PutModule("Error: username not set"); return; } CString boxcar_api_key = "puSd2qp2gCDZO7nWkvb9"; CString boxcar_api_secret = "wLQQKSyGybIOkggbiKipefeYGLni9B3FPZabopHp"; service_host = "boxcar.io"; service_url = "/devices/providers/" + boxcar_api_key + "/notifications"; params["email"] = options["username"]; params["notification[from_screen_name]"] = context; params["notification[message]"] = message_content; params["notification[source_url]"] = message_uri; } else if (service == "nma") { if (options["secret"] == "") { PutModule("Error: secret not set"); return; } service_host = "www.notifymyandroid.com"; service_url = "/publicapi/notify"; params["apikey"] = options["secret"]; params["application"] = app; params["event"] = message_title; params["description"] = message_content; params["url"] = message_uri; } else if (service == "pushover") { if (options["username"] == "") { PutModule("Error: username (user key) not set"); return; } if (options["secret"] == "") { PutModule("Error: secret (application token/key) not set"); return; } service_host = "api.pushover.net"; service_url = "/1/messages.json"; params["token"] = options["secret"]; params["user"] = options["username"]; params["title"] = message_title; params["message"] = message_content; if (message_uri != "") { params["url"] = message_uri; } if ( options["message_uri_title"] != "" ) { params["url_title"] = options["message_uri_title"]; } if (options["target"] != "") { params["device"] = options["target"]; } if ( options["message_sound"] != "" ) { params["sound"] = options["message_sound"]; } if (options["message_priority"] != "") { params["priority"] = options["message_priority"]; } } else if (service == "prowl") { if (options["secret"] == "") { PutModule("Error: secret not set"); return; } service_host = "api.prowlapp.com"; service_url = "/publicapi/add"; params["apikey"] = options["secret"]; params["application"] = app; params["event"] = message_title; params["description"] = message_content; params["url"] = message_uri; } else if (service == "supertoasty") { if (options["secret"] == "") { PutModule("Error: secret (device id) not set"); return; } use_post = false; use_port = 80; use_ssl = false; service_host = "api.supertoasty.com"; service_url = "/notify/"+options["secret"]; params["title"] = message_title; params["text"] = message_content; params["image"] = "https://github.com/jreese/znc-push/raw/supertoasty/logo.png"; params["sender"] = "ZNC Push"; } else if (service == "url") { if (options["message_uri"] == "") { PutModule("Error: message_uri not set"); return; } CString::size_type count; VCString parts; CString url = options["message_uri"]; // Verify that the URL begins with either http:// or https:// count = url.Split("://", parts, false); if (count != 2) { PutModule("Error: invalid url format"); return; } use_post = false; if (parts[0] == "https") { use_ssl = true; use_port = 443; } else if (parts[0] == "http") { use_ssl = false; use_port = 80; } else { PutModule("Error: invalid url schema"); return; } // Process the remaining portion of the URL url = parts[1]; // Split out the host and optional port number; this breaks with raw IPv6 addresses CString host = url.Token(0, false, "/"); count = host.Split(":", parts, false); if (count > 1) { use_port = parts[1].ToInt(); } service_host = parts[0]; // Split remaining URL into path and query components url = "/" + url.Token(1, true, "/"); service_url = expand(url.Token(0, false, "?"), replace); // Parse and expand query parameter values url = url.Token(1, true, "?"); url.URLSplit(params); for (MCString::iterator i = params.begin(); i != params.end(); i++) { i->second = expand(i->second, replace); } } else if (service == "airgram") { if (options["target"] == "") { PutModule("Error: target (email) not set"); return; } service_host = "api.airgramapp.com"; service_url = "/1/send_as_guest"; params["email"] = options["target"]; params["msg"] = message_content; } else { PutModule("Error: service type not selected"); return; } #ifdef USE_CURL make_curl_request(service_host, service_url, service_auth, params, use_port, use_ssl, use_post, options["debug"] == "on"); #else // Create the socket connection, write to it, and add it to the queue CPushSocket *sock = new CPushSocket(this); sock->Connect(service_host, use_port, use_ssl); sock->Request(use_post, service_host, service_url, params, service_auth); AddSocket(sock); #endif } /** * Evaluate a boolean expression using condition values. * All tokens must be separated by spaces, using "and" and "or" for * boolean operators, "(" and ")" to enclose sub-expressions, and * condition option names to evaluate each condition. * * @param expression Boolean expression string * @param context Notification context * @param nick Sender nick * @param message Message contents * @return Result of boolean evaluation */ bool eval(const CString& expression, const CString& context=CString(""), const CNick& nick=CNick(""), const CString& message=" ") { CString padded = expression.Replace_n("(", " ( "); padded.Replace(")", " ) "); VCString tokens; padded.Split(" ", tokens, false); PutDebug("Evaluating message: <" + nick.GetNick() + "> " + message); bool result = eval_tokens(tokens.begin(), tokens.end(), context, nick, message); return result; } #define expr(x, y) else if (token == x) { \ bool result = y; \ dbg += CString(x) + "/" + CString(result ? "true" : "false") + " "; \ value = oper ? value && result : value || result; \ } /** * Evaluate a tokenized boolean expression, or sub-expression. * * @param pos Token vector iterator current position * @param end Token vector iterator end position * @param context Notification context * @param nick Sender nick * @param message Message contents * @return Result of boolean expression */ bool eval_tokens(VCString::iterator pos, VCString::iterator end, const CString& context, const CNick& nick, const CString& message) { bool oper = true; bool value = true; CString dbg = ""; for(; pos != end; pos++) { CString token = pos->AsLower(); if (token == "(") { // recursively evaluate sub-expressions bool inner = eval_tokens(++pos, end, context, nick, message); dbg += "( inner/" + CString(inner ? "true" : "false") + " ) "; value = oper ? value && inner : value || inner; // search ahead to the matching parenthesis token unsigned int parens = 1; while(pos != end) { if (*pos == "(") { parens++; } else if (*pos == ")") { parens--; } if (parens == 0) { break; } pos++; } } else if (token == ")") { pos++; PutDebug(dbg); return value; } else if (token == "and") { dbg += "and "; oper = true; } else if (token == "or") { dbg += "or "; oper = false; } expr("true", true) expr("false", false) expr("away_only", away_only()) expr("client_count_less_than", client_count_less_than()) expr("highlight", highlight(message)) expr("idle", idle()) expr("last_active", last_active(context)) expr("last_notification", last_notification(context)) expr("nick_blacklist", nick_blacklist(nick)) expr("replied", replied(context)) else { PutModule("Error: Unexpected token \"" + token + "\""); } } PutDebug(dbg); return value; } #undef expr protected: /** * Check if the away status condition is met. * * @return True if away_only is not "yes" or away status is set */ bool away_only() { CString value = options["away_only"].AsLower(); return value != "yes" || GetNetwork()->IsIRCAway(); } /** * Check how many clients are connected to ZNC. * * @return Number of connected clients */ size_t client_count() { return GetNetwork()->GetClients().size(); } /** * Check if the client_count condition is met. * * @return True if client_count is less than client_count_less_than or if client_count_less_than is zero */ bool client_count_less_than() { unsigned int value = options["client_count_less_than"].ToUInt(); return value == 0 || client_count() < value; } /** * Determine if the given message matches any highlight rules. * * @param message Message contents * @return True if message matches a highlight */ bool highlight(const CString& message) { CString msg = " " + message.AsLower() + " "; VCString values; options["highlight"].Split(" ", values, false); values.push_back("%nick%"); for (VCString::iterator i = values.begin(); i != values.end(); i++) { CString value = i->AsLower(); char prefix = value[0]; bool push = true; if (prefix == '-') { push = false; value.LeftChomp(1); } else if (prefix == '_') { value = " " + value.LeftChomp_n(1) + " "; } // Expand substrings like %nick% if (m_pNetwork) { value = m_pNetwork->ExpandString(value); } else { value = GetUser()->ExpandString(value); } value = "*" + value.AsLower() + "*"; if (msg.WildCmp(value)) { return push; } } return false; } /** * Check if the idle condition is met. * * @return True if idle is less than or equal to zero or elapsed time is greater than idle */ bool idle() { unsigned int value = options["idle"].ToUInt(); time_t now = time(NULL); return value == 0 || difftime(now, idle_time) >= value; } /** * Check if the last_active condition is met. * * @param context Channel or nick context * @return True if last_active is less than or equal to zero or elapsed time is greater than last_active */ bool last_active(const CString& context) { unsigned int value = options["last_active"].ToUInt(); time_t now = time(NULL); return value == 0 || last_active_time.count(context) < 1 || difftime(now, last_active_time[context]) >= value; } /** * Check if the last_notification condition is met. * * @param context Channel or nick context * @return True if last_notification is less than or equal to zero or elapsed time is greater than last_nofication */ bool last_notification(const CString& context) { unsigned int value = options["last_notification"].ToUInt(); time_t now = time(NULL); return value == 0 || last_notification_time.count(context) < 1 || difftime(now, last_notification_time[context]) >= value; } /** * Check if the nick_blacklist condition is met. * * @param nick Nick that sent the message * @return True if nick is not in the blacklist */ bool nick_blacklist(const CNick& nick) { VCString blacklist; options["nick_blacklist"].Split(" ", blacklist, false); CString name = nick.GetNick().AsLower(); for (VCString::iterator i = blacklist.begin(); i != blacklist.end(); i++) { CString value; // Expand substrings like %nick% if (m_pNetwork) { value = m_pNetwork->ExpandString(*i); } else { value = GetUser()->ExpandString(*i); } if (name.WildCmp(value.AsLower())) { return false; } } return true; } /** * Check if the replied condition is met. * * @param context Channel or nick context * @return True if last_reply_time > last_notification_time or if replied is not "yes" */ bool replied(const CString& context) { CString value = options["replied"].AsLower(); return value != "yes" || last_notification_time[context] == 0 || last_notification_time[context] < last_reply_time[context]; } /** * Determine when to notify the user of a channel message. * * @param nick Nick that sent the message * @param channel Channel the message was sent to * @param message Message contents * @return Notification should be sent */ bool notify_channel(const CNick& nick, const CChan& channel, const CString& message) { CString context = channel.GetName(); CString expression = options["channel_conditions"].AsLower(); if (expression != "all") { return eval(expression, context, nick, message); } return away_only() && client_count_less_than() && highlight(message) && idle() && last_active(context) && last_notification(context) && nick_blacklist(nick) && replied(context) && true; } /** * Determine when to notify the user of a private message. * * @param nick Nick that sent the message * @return Notification should be sent */ bool notify_pm(const CNick& nick, const CString& message) { CString context = nick.GetNick(); CString expression = options["query_conditions"].AsLower(); if (expression != "all") { return eval(expression, context, nick, message); } return away_only() && client_count_less_than() && idle() && last_active(context) && last_notification(context) && nick_blacklist(nick) && replied(context) && true; } protected: /** * Handle the plugin being loaded. Retrieve plugin config values. * * @param args Plugin arguments * @param message Message to show the user after loading */ bool OnLoad(const CString& args, CString& message) { for (MCString::iterator i = defaults.begin(); i != defaults.end(); i++) { CString value = GetNV(i->first); if (value != "") { options[i->first] = value; } else { options[i->first] = defaults[i->first]; } } return true; } /** * Handle channel messages. * * @param nick Nick that sent the message * @param channel Channel the message was sent to * @param message Message contents */ EModRet OnChanMsg(CNick& nick, CChan& channel, CString& message) { if (notify_channel(nick, channel, message)) { CString title = "Highlight"; CString msg = channel.GetName(); msg += ": [" + nick.GetNick(); msg += "] " + message; send_message(msg, title, channel.GetName()); } return CONTINUE; } /** * Handle channel actions. * * @param nick Nick that sent the action * @param channel Channel the message was sent to * @param message Message contents */ EModRet OnChanAction(CNick& nick, CChan& channel, CString& message) { if (notify_channel(nick, channel, message)) { CString title = "Highlight"; CString msg = channel.GetName(); msg += ": " + nick.GetNick(); msg += " " + message; send_message(msg, title, channel.GetName()); } return CONTINUE; } /** * Handle a private message. * * @param nick Nick that sent the message * @param message Message contents */ EModRet OnPrivMsg(CNick& nick, CString& message) { if (notify_pm(nick, message)) { CString title = "Private Message"; CString msg = "From " + nick.GetNick(); msg += ": " + message; send_message(msg, title, nick.GetNick()); } return CONTINUE; } /** * Handle a private action. * * @param nick Nick that sent the action * @param message Message contents */ EModRet OnPrivAction(CNick& nick, CString& message) { if (notify_pm(nick, message)) { CString title = "Private Message"; CString msg = "* " + nick.GetNick(); msg += " " + message; send_message(msg, title, nick.GetNick()); } return CONTINUE; } /** * Handle a message sent by the user. * * @param target Target channel or nick * @param message Message contents */ EModRet OnUserMsg(CString& target, CString& message) { last_reply_time[target] = last_active_time[target] = idle_time = time(NULL); return CONTINUE; } /** * Handle an action sent by the user. * * @param target Target channel or nick * @param message Message contents */ EModRet OnUserAction(CString& target, CString& message) { last_reply_time[target] = last_active_time[target] = idle_time = time(NULL); return CONTINUE; } /** * Handle the user joining a channel. * * @param channel Channel name * @param key Channel key */ EModRet OnUserJoin(CString& channel, CString& key) { idle_time = time(NULL); return CONTINUE; } /** * Handle the user parting a channel. * * @param channel Channel name * @param message Part message */ EModRet OnUserPart(CString& channel, CString& message) { idle_time = time(NULL); return CONTINUE; } /** * Handle the user setting the channel topic. * * @param channel Channel name * @param topic Topic message */ EModRet OnUserTopic(CString& channel, CString& topic) { idle_time = time(NULL); return CONTINUE; } /** * Handle the user requesting the channel topic. * * @param channel Channel name */ EModRet OnUserTopicRequest(CString& channel) { idle_time = time(NULL); return CONTINUE; } /** * Handle direct commands to the *push virtual user. * * @param command Command string */ void OnModCommand(const CString& command) { VCString tokens; CString::size_type token_count = command.Split(" ", tokens, false); if (token_count < 1) { return; } CString action = tokens[0].AsLower(); // SET command if (action == "set") { if (token_count < 3) { PutModule("Usage: set