eiginn
11/8/2012 - 3:42 PM

Updated Prowl module for ZNC

Updated Prowl module for ZNC

/*
 * Copyright (C) 2009 flakes @ EFNet
 * New match logic by Gm4n @ freenode
 * Version 1.0 (2012-08-19)
 *
 * This program is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 as published
 * by the Free Software Foundation.
 */

#define REQUIRESSL

#include "znc.h"
#include "User.h"
#include "Chan.h"
#include "Nick.h"
#include "Modules.h"
#include <string>

#if (!defined(VERSION_MAJOR) || !defined(VERSION_MINOR) || (VERSION_MAJOR == 0 && VERSION_MINOR < 72))
#error This module needs ZNC 0.072 or newer.
#endif

class CProwlMod : public CModule
{
protected:
	// Internal variables
	CString m_host;
	unsigned int m_notificationsSent;
	time_t m_lastActivity;

	// Settings for prowl
	CString m_apiKey;
	int m_priority;
	CString m_subjectSuffix;
	
	// Settings for matching
	int m_idleThreshold;
	CString m_matchMode;
	CString m_chanOverride;
	CString m_privOverride;
	
	VCString m_hilights;
	

public:
	MODCONSTRUCTOR(CProwlMod)
	{
		m_host = "api.prowlapp.com";
		m_notificationsSent = 0;
		m_lastActivity = time(NULL);
		
		// defaults:
		m_priority = 0;
		m_idleThreshold = 5;
		m_matchMode = "basic";
		m_chanOverride = "normal";
		m_privOverride = "normal";
		m_subjectSuffix = "";
		// defaults end.
	}

protected:
	static CString URLEscape(const CString& sStr)
	{
		return sStr.Escape_n(CString::EASCII, CString::EURL);
	}

	// This strips any forms of IRC formatting that google found for me
	static CString StripFormatting(const CString& sStr)
	{
		CString s;
		int state;
		int length = sStr.length();
		char *content = (char *) malloc(length+1), *cptr = content;
		
		// Loop through the source string one character at a time
		for(int i = 0; i < length; i++){
			switch(sStr.data()[i]) {
				case 0x03:
					// Remove color codes
					state = 0;
					i++;
					while(i < length) {
						// If we see a digit in 0 or 1, advance state
						if((state == 0 || state == 1) && (sStr.data()[i] >= 0x30 && sStr.data()[i] <= 0x39)) {
							i++;
							state++;
							continue;
						}

						// If we see a comma in either 1 or 2, move along to 3
						if((state == 1 || state == 2) && sStr.data()[i] == ',') {
							i++;
							state = 3;
							continue;
						}

						// If we're at-or-post comma, a digit is all we need
						if((state == 3 || state == 4) && (sStr.data()[i] >= 0x30 || sStr.data()[i] <= 0x39)) {
							i++;
							state++;
							continue;
						}

						// If no other conditions have matched, this character can't be parsed - abort!
						i--;
						break;
					}

					// And now that we're done looping through this character
					break;
				case 0x02:
					// Remove start/end bold
					break;
				case 0x1F:
					// Remove start/end underline
					break;
				case 0x0F:
					// Remove reset color
					break;
				case 0x0E:
					// Remove italic/reverse
					break;
				default:
					// If this is a normal character, just copy it over
					*cptr = sStr.data()[i];
					cptr++;
					break;
			}
		}

		// Null terminate our string and return
		*cptr = 0x00;
		s = CString(content);
		free(content);
		return s;
	}

	CString BuildRequest(const CString& sEvent, const CString& sDescription)
	{
		CString s;

		s += "GET /publicapi/add";
		s += "?apikey=" + URLEscape(m_apiKey);
		s += "&priority=" + CString(m_priority);
		s += "&application=ZNC";
		s += "&event=" + URLEscape(sEvent);
		s += "&description=" + URLEscape(StripFormatting(sDescription));

		s += " HTTP/1.0\r\n";
		s += "Connection: close\r\n";
		s += "Host: " + m_host + "\r\n";
		s += "User-Agent: " + CZNC::GetTag() + "\r\n";
		s += "\r\n";

		return s;
	}

	void SendNotification(const CString& sEvent, const CString& sMessage)
	{
		if(!m_apiKey.empty())
		{
			CSocket *p = new CSocket(this);
			p->Connect(m_host, 443, true); // connect to host at port 443 using SSL
			p->Write(BuildRequest(sEvent, sMessage));
			p->Close(Csock::CLT_AFTERWRITE); // discard the response...
			AddSocket(p);

			m_notificationsSent++;
		}
	}

	// Called internally to do "is this a hilight" logic
	bool CheckHilight(const CString& sMessage)
	{
		// Do case insensitive matching:
		const CString sLcMessage = sMessage.AsLower();
		
		// Do some setup:
		const CString sLcNick = m_pUser->GetCurNick();
		// Prepend current nick to list of hilights
		m_hilights.push_back(sLcNick);
		
		// Iterate through all the hilights, attempting one match at a time
		bool retval = false;
		for(VCString::iterator it = m_hilights.begin();
			it != m_hilights.end();
			it++)
		{
			// If the string is too short to match, do nothing
			if(sLcMessage.length() < (*it).length())
				continue;

			// If lengths are equal, comparison is simple
			if(sLcMessage.length() == (*it).length()) {
				// If we're a direct match, we win.
				if(sLcMessage.CaseCmp(*it) == 0) {
					retval = true;
					break;
				}

				// If we don't match, this loop failed
				continue;
			}

			// Check message if we're using "addressed" type matching:
			if(m_matchMode.StrCmp("addressed") == 0) {
				// If the message starts with our nick
				if(strncmp(sLcMessage.data(), (*it).data(), (*it).length()) == 0) {
					// See if we have an immediate delimiter:
					char c = sLcMessage.data()[(*it).length()];
					if(c == ' ' || c == ':' || c == ',') {
						retval = true;
						break;
					}
				}
				continue;
			}
			
			// Check message if we're using "basic" type matching:
			if(m_matchMode.StrCmp("basic") == 0) {
				// Check for a plain old match
				if(sLcMessage.find((*it).AsLower()) != CString::npos) {
					retval = true;
					break;
				}
			}
		}
		
		// Clean up, and return our result
		m_hilights.erase(m_hilights.begin());
		return retval;
	}
	
	void ParsePrivMsg(const CString& sNick, const CString& sMessage)
	{
		// Sanity check
		if(!m_pUser) return;
		
		// If user is attached and recently active, return
		if (m_pUser->IsUserAttached() &&
				m_lastActivity > time(NULL) - m_idleThreshold * 60)
					return;

		// Check if we have an override preventing match
		if (m_privOverride == "disable")
			return;

		// If we're here, send if override forces match, or if match
		if(m_privOverride == "force" || CheckHilight(sMessage)) {
			return SendNotification("Private message" + m_subjectSuffix, "<" + sNick + ">: " + sMessage);
		}
	}
	
	void ParseChanMsg(const CString& sNick, const CString &chan, const CString& sMessage)
	{
		// Sanity check
		if(!m_pUser) return;
		
		// If idle is non-zero and user is attached, user must be idle
		if (m_idleThreshold != 0 && m_pUser->IsUserAttached())
			if (m_lastActivity > time(NULL) - m_idleThreshold * 60)
				return;

		// Check if we have an override preventing match
		if (m_chanOverride == "disable")
			return;
		
		// If we're here, send if override forces match, or if match
		if(m_chanOverride == "force" || CheckHilight(sMessage)) {
			return SendNotification(chan + " hilight" + m_subjectSuffix, "<" + sNick + ">: " + sMessage);
		}
	}
	
	void LoadSettings()
	{
		for(MCString::iterator it = BeginNV(); it != EndNV(); it++)
		{
			// Settings for prowl
			if(it->first == "api:key")
			{
				m_apiKey = it->second;
			}
			else if(it->first == "api:priority")
			{
				m_priority = it->second.ToInt();
			}

			// Settings for matching
			else if(it->first == "u:idle")
			{
				m_idleThreshold = it->second.ToInt();
			}
			else if(it->first == "u:matchmode")
			{
				m_matchMode = it->second;
			}
			else if(it->first == "u:privoverride")
			{
				m_privOverride = it->second;
			}
			else if(it->first == "u:chanoverride")
			{
				m_chanOverride = it->second;
			}
			else if(it->first == "u:suffix")
			{
				m_subjectSuffix = it->second;
			}
			else if(it->first == "u:hilights")
			{
				it->second.Split("\n", m_hilights, false);
			}
		}
	}

	void SaveSettings()
	{
		ClearNV();

		// Settings for prowl
		SetNV("api:key", m_apiKey, false);
		SetNV("api:priority", CString(m_priority), false);

		// Settings for matching
		SetNV("u:idle", CString(m_idleThreshold), false);
		SetNV("u:matchmode", m_matchMode, false);
		SetNV("u:privoverride", m_privOverride, false);
		SetNV("u:chanoverride", m_chanOverride, false);
		SetNV("u:suffix", m_subjectSuffix, false);
		
		CString sTmp;
		for(VCString::const_iterator it = m_hilights.begin(); it != m_hilights.end(); it++) { sTmp += *it + "\n"; }
		SetNV("u:hilights", sTmp, true);
	}

	bool OnLoad(const CString& sArgs, CString& sMessage)
	{
		LoadSettings();
		return true;
	}
public:
	void OnModCommand(const CString& sCommand)
	{
		const CString sCmd = sCommand.Token(0).AsUpper();

		if(sCmd == "HELP")
		{
			CTable CmdTable;
			CmdTable.AddColumn("Command");
			CmdTable.AddColumn("Description");

			CmdTable.AddRow();
			CmdTable.SetCell("Command", "SET [<variable> [<value>]]");
			CmdTable.SetCell("Description", "View and set configuration variables.");

			CmdTable.AddRow();
			CmdTable.SetCell("Command", "STATUS");
			CmdTable.SetCell("Description", "Show module status information.");
			
			CmdTable.AddRow();
			CmdTable.SetCell("Command", "HIGHLIGHTS");
			CmdTable.SetCell("Description", "Shows words (besides your nick) that trigger a notification.");

			CmdTable.AddRow();
			CmdTable.SetCell("Command", "HIGHLIGHTS ADD <word>");
			CmdTable.SetCell("Description", "Adds a word or string to match and notify.");

			CmdTable.AddRow();
			CmdTable.SetCell("Command", "HIGHLIGHTS REMOVE <index>");
			CmdTable.SetCell("Description", "Removes a word from the hilights list by index number.");

			CmdTable.AddRow();
			CmdTable.SetCell("Command", "HELP");
			CmdTable.SetCell("Description", "This help message.");


			PutModule(CmdTable);
			return;
		}
		
		const CString sSubCmd = sCommand.Token(1).AsLower();

		if(sCmd == "SET" || sCmd == "CHANGE")
		{
			if(sSubCmd == "")
			{
				CTable CmdTable;
				CmdTable.AddColumn("Setting");
				CmdTable.AddColumn("Description");

				CmdTable.AddRow();
				CmdTable.SetCell("Setting", "apikey <key>");
				CmdTable.SetCell("Description", "Your prowl API key.");

				CmdTable.AddRow();
				CmdTable.SetCell("Setting", "priority <number>");
				CmdTable.SetCell("Description", "The priority of the delivered prowl notification.");

				CmdTable.AddRow();
				CmdTable.SetCell("Setting", "idle <minutes>");
				CmdTable.SetCell("Description", "Only send notifications if idle for <minutes> or not connected. 0 disables this check.");

				CmdTable.AddRow();
				CmdTable.SetCell("Setting", "matchmode basic");
				CmdTable.SetCell("Description", "Match any message containing a hilighted term (nickname or highlights list).");

				CmdTable.AddRow();
				CmdTable.SetCell("Setting", "matchmode addressed");
				CmdTable.SetCell("Description", "Match if line begins with a hilighted term and is followed by a space, comma or colon.");

				CmdTable.AddRow();
				CmdTable.SetCell("Setting", "privoverride <normal|disable|force>");
				CmdTable.SetCell("Description", "For PMs, optionally disable hilights or force all messages to hilight.");
				
				CmdTable.AddRow();
				CmdTable.SetCell("Setting", "chanoverride <normal|disable|force>");
				CmdTable.SetCell("Description", "For channel messages, optionally disable hilights or force all messages to hilight.");
				
				CmdTable.AddRow();
				CmdTable.SetCell("Setting", "suffix [string]");
				CmdTable.SetCell("Description", "An optional suffix for message subjects to differentiate accounts. Eg 'on Freenode'.");

				PutModule(CmdTable);
			}

			if(sSubCmd == "apikey")
			{
				if(sCommand.Token(2) == "")
				{
					PutModule("Your API key is '" + m_apiKey + "'.");
				} else {
					m_apiKey = sCommand.Token(2).AsLower();
					PutModule("Your API key is now '" + m_apiKey + "'.");
				}
			}
			else if(sSubCmd == "priority")
			{
				if(sCommand.Token(2) == "")
				{
					PutModule("Your priority is " + CString(m_priority) + ".");
				} else {
					m_priority = sCommand.Token(2).ToInt();
					PutModule("Your priority is now " + CString(m_priority) + ".");
				}
			}
			else if(sSubCmd == "idle")
			{
				if(sCommand.Token(2) == "")
				{
					PutModule("Your idle time is " + CString(m_idleThreshold) + " minutes.");
				} else {
					m_idleThreshold = sCommand.Token(2).ToInt();
					PutModule("Your idle time is now " + CString(m_idleThreshold) + " minutes.");
				}
			}
			else if(sSubCmd == "matchmode")
			{
				if(sCommand.Token(2) == "")
				{
					PutModule("Your matchmode is '" + m_matchMode + "'.");
				} else {
					const CString tmp = sCommand.Token(2).AsLower();
					if(tmp == "basic" || tmp == "addressed")
					{
						m_matchMode = tmp;
						PutModule("Your matchmode is now '" + m_matchMode + "'.");
					} else {
						PutModule("Invalid matchmode '" + tmp + "'!");
					}
				}
			}
			else if(sSubCmd == "privoverride")
			{
				if(sCommand.Token(2) == "")
				{
					PutModule("Your privoverride is '" + m_privOverride + "'.");
				} else {
					const CString tmp = sCommand.Token(2).AsLower();
					if(tmp == "normal" || tmp == "disable" || tmp == "force")
					{
						m_privOverride = tmp;
						PutModule("Your privoverride is now '" + m_privOverride + "'.");
					} else {
						PutModule("Invalid privoverride '" + tmp + "'!");
					}
				}
			}
			else if(sSubCmd == "chanoverride")
			{
				if(sCommand.Token(2) == "")
				{
					PutModule("Your chanoverride is '" + m_chanOverride + "'.");
				} else {
					const CString tmp = sCommand.Token(2).AsLower();
					if(tmp == "normal" || tmp == "disable" || tmp == "force")
					{
						m_chanOverride = tmp;
						PutModule("Your chanoverride is now '" + m_chanOverride + "'.");
					} else {
						PutModule("Invalid chanoverride '" + tmp + "'!");
					}
				}
			}
			else if(sSubCmd == "suffix")
			{
				if(sCommand.Token(2) == "")
				{
					PutModule("Your suffix is" + m_subjectSuffix + ". To unset, use suffix 'unset'.");
				}
				else if(sCommand.Token(2) == "unset") {
					m_subjectSuffix = "";
					PutModule("Your suffix is now" + m_subjectSuffix + ".");
				} else {
					m_subjectSuffix = sCommand.LeftChomp_n(10); // strip off the "set suffix"
					PutModule("Your suffix is now" + m_subjectSuffix + ".");
				}
			}
			else if(sSubCmd != "")
			{
				PutModule("Unknown setting. Use SET to list available settings.");
			}
			
			// Save what we've done, so reboot doesn't lose anything
			SaveSettings();
		}

		else if(sCmd == "HIGHLIGHTS" || sCmd == "HIGHLIGHT" || sCmd == "HILIGHTS" || sCmd == "HILIGHT")
		{
			if(sSubCmd == "")
			{
				size_t iIndex = 1;

				PutModule("Active additional hilights:");

				for(VCString::const_iterator it = m_hilights.begin(); it != m_hilights.end(); it++)
				{
					PutModule(CString(iIndex) + ": " + *it);
					iIndex++;
				}

				PutModule("--End of list");
			}
			else if(sSubCmd == "add")
			{
				const CString sParam = sCommand.Token(2, true);

				if(!sParam.empty())
				{
					m_hilights.push_back(sParam);
					PutModule("Entry '" + sParam + "' added.");
					SaveSettings();
				}
				else
				{
					PutModule("Usage: HIGHTLIGHTS ADD <string>");
				}
			}
			else if(sSubCmd == "remove" || sSubCmd == "delete")
			{
				size_t iIndex = sCommand.Token(2).ToUInt();

				if(iIndex > 0 && iIndex <= m_hilights.size())
				{
					m_hilights.erase(m_hilights.begin() + iIndex - 1);
					PutModule("Entry removed.");
					SaveSettings();
				}
				else
				{
					PutModule("Invalid list index.");
				}
			}
			else
			{
				PutModule("Unknown action. Try HELP.");
			}
		}

		else if(sCmd == "STATUS" || sCmd == "SHOW")
		{
			CTable CmdTable;

			CmdTable.AddColumn("Status Item");
			CmdTable.AddColumn("Value");

			CmdTable.AddRow();
			CmdTable.SetCell("Status Item", "Additional Hilights");
			CmdTable.SetCell("Value", CString(m_hilights.size()));

			CmdTable.AddRow();
			CmdTable.SetCell("Status Item", "Notifications Sent");
			CmdTable.SetCell("Value", CString(m_notificationsSent));

			PutModule(CmdTable);
		}
		else
		{
			PutModule("Unknown command! Try HELP.");
		}
	}

	EModRet OnPrivMsg(CNick& Nick, CString& sMessage)
	{
		ParsePrivMsg(Nick.GetNick(), sMessage);
		return CONTINUE;
	}
	EModRet OnPrivAction(CNick& Nick, CString& sMessage)
	{
		ParsePrivMsg(Nick.GetNick(), sMessage);
		return CONTINUE;
	}
	
	EModRet OnChanMsg(CNick& Nick, CChan& Channel, CString& sMessage)
	{
		ParseChanMsg(Nick.GetNick(), Channel.GetName(), sMessage);
		return CONTINUE;
	}
	EModRet OnChanAction(CNick& Nick, CChan& Channel, CString& sMessage)
	{
		ParseChanMsg(Nick.GetNick(), Channel.GetName(), sMessage);
		return CONTINUE;
	}
	
	EModRet OnUserAction(CString& sTarget, CString& sMessage) { m_lastActivity = time(NULL); return CONTINUE; }
	EModRet OnUserMsg(CString& sTarget, CString& sMessage) { m_lastActivity = time(NULL); return CONTINUE; }
	EModRet OnUserNotice(CString& sTarget, CString& sMessage) { m_lastActivity = time(NULL); return CONTINUE; }
	EModRet OnUserJoin(CString& sChannel, CString& sKey) { m_lastActivity = time(NULL); return CONTINUE; }
	EModRet OnUserPart(CString& sChannel, CString& sMessage) { m_lastActivity = time(NULL); return CONTINUE; }
};

MODULEDEFS(CProwlMod, "Forwards hilights and PMs to prowl.")