diff --git a/GNUmakefile b/GNUmakefile index e8bb0d2..d48d663 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -237,6 +237,9 @@ plugin_chat_only_TARGET := mod_chat_only.so plugin_topic_SOURCES := src/plugins/mod_topic.c plugin_topic_TARGET := mod_topic.so +plugin_ucmd_SOURCES := src/plugins/mod_ucmd.c +plugin_ucmd_TARGET := mod_ucmd.so + # Source to objects libuhub_OBJECTS := $(libuhub_SOURCES:.c=.o) libutils_OBJECTS := $(libutils_SOURCES:.c=.o) @@ -256,7 +259,8 @@ all_plugins := \ $(plugin_welcome_TARGET) \ $(plugin_chat_history_TARGET) \ $(plugin_chat_only_TARGET) \ - $(plugin_topic_TARGET) + $(plugin_topic_TARGET) \ + $(plugin_ucmd_TARGET) all_OBJECTS := \ $(libuhub_OBJECTS) \ @@ -315,6 +319,8 @@ $(plugin_welcome_TARGET): $(plugin_welcome_SOURCES) $(libutils_OBJECTS) $(plugin_topic_TARGET): $(plugin_topic_SOURCES) $(libutils_OBJECTS) $(MSG_CC) $(CC) -shared -fPIC -o $@ $^ $(CFLAGS) +$(plugin_ucmd_TARGET): $(plugin_ucmd_SOURCES) $(libutils_OBJECTS) + $(MSG_CC) $(CC) -shared -fPIC -o $@ $^ $(CFLAGS) $(adcrush_BINARY): $(adcrush_OBJECTS) $(libuhub_OBJECTS) $(libutils_OBJECTS) $(libadc_common_OBJECTS) $(libadc_client_OBJECTS) $(MSG_LD) $(CC) -o $@ $^ $(LDFLAGS) $(LDLIBS) @@ -365,6 +371,7 @@ install: all @if [ ! -f $(UHUB_CONF_DIR)/uhub.conf ]; then cp doc/uhub.conf $(UHUB_CONF_DIR); fi @if [ ! -f $(UHUB_CONF_DIR)/rules.txt ]; then cp doc/rules.txt $(UHUB_CONF_DIR); fi @if [ ! -f $(UHUB_CONF_DIR)/plugins.conf ]; then cp doc/plugins.conf $(UHUB_CONF_DIR); fi + @if [ ! -f $(UHUB_CONF_DIR)/ucmd.conf ]; then cp doc/ucmd.conf $(UHUB_CONF_DIR); fi @if [ ! -d $(UHUB_MOD_DIR) ]; then echo Creating $(UHUB_MOD_DIR); mkdir -p $(UHUB_MOD_DIR); fi @cp -f mod_*.so $(UHUB_MOD_DIR) @touch $(UHUB_CONF_DIR)/motd.txt diff --git a/doc/plugins.conf b/doc/plugins.conf index e45f35c..672d787 100644 --- a/doc/plugins.conf +++ b/doc/plugins.conf @@ -60,3 +60,8 @@ plugin /var/lib/uhub/mod_welcome.so "motd=/etc/uhub/motd.txt rules=/etc/uhub/rul # history_connect: the number of chat history messages to send when users connect (0 = do not send any history) plugin /var/lib/uhub/mod_chat_history.so "history_max=200 history_default=10 history_connect=5" +# A plugin to send user commands. +# +# Parameters: +# file: the file where the list of user commands is stored. +plugin /var/lib/uhub/mod_ucmd.so "file=/etc/uhub/ucmd.conf" diff --git a/doc/ucmd.conf b/doc/ucmd.conf new file mode 100644 index 0000000..6687578 --- /dev/null +++ b/doc/ucmd.conf @@ -0,0 +1,160 @@ +# User command configuration for standard uhub commands. +# +# Each command contains at least two lines: the first defines the command name +# and required credentials, and the following lines define the action(s) taken +# by the client when the user selects the command. +# +# Leading and trailing whitespace is trimmed from each line. Commented lines +# and blank lines are ignored. All keywords in the file are not case sensitive. +# +# Command definition +# ------------------ +# +# The first line of a command defines the credentials required, the context(s) +# that the command should be shown in, and the name it is displayed as in the +# user command menu. It takes the following format: +# +# +# +# The credential level is the minimum credentials required to access the +# command. Anybody with this level or greater will be able to see it. The +# levels are: 0 = none, 1 = bot, 2 = unregistered user, 3 = registered user, +# 4 = operator, 5 = super (not used currently), 6 = link (not used currently), +# 7 = admin. +# +# The contexts are a comma separated list which tell the client where to +# display the command. Possible values are: +# +# * hub: in a general right-click menu for the hub +# * user: in a right-click menu in the user list +# * search: in a search results window +# * filelist: in a file list window +# * all: all of the above +# +# The command name is what is displayed in the client menu and *must* be unique +# as the clients use it as an identifier. +# +# Nested menus can be created by adding backslashes (\) to the name, for example, +# User actions\Kick. NB. the ADC UCMD extension specifies a forward slash (/), +# but all clients appear to use the backslash as per the old NMDC protocol, so +# the backslash is recommended. +# +# Actions +# ------- +# +# Following the command definition, one or more lines are given to specify the +# action or actions the client should take when the user selects the command. +# The order of the actions in this file is the order the client will perform +# them. There are three actions available, each of which can be used multiple +# times in a command: +# +# Chat +# Sends a message in the main chat window. The parameter is 0 or 1, with 1 +# meaning it is formatted as a /me style message by clients. +# +# PM +# Sends a private message. If you have set up a user with a reserved SID, you +# can specify the SID the target. Alternatively, you can use the word Selected +# as the target to specify the currently selected user (this won't work in the +# hub context, as there is no user selected there). The parameter can be +# 0 or 1, with 1 specifying the message should be echoed to the sending user as +# well. +# +# Separator +# Specifies that the entry should be displayed as a separator instead of text, +# meaning there is no 'real' action to run. If this is given, any other actions +# will be ignored. A unique name is still required for each separator. +# +# Substitutions +# ------------- +# +# The client can substitute pieces of text into the actions before it sends +# them. The most useful ones are %[myNI], which is replaced with the nickname +# of the user performing the action, and %[userNI], which is replaced with the +# nickname of the user that was selected in a user list when the command was +# run. The %[userNI] substitution does not work in the hub context, as there is +# no user selected there. +# +# You can also prompt the user for a piece of text with the substitution +# %[line:]. If the same prompt is used multiple times +# within a command (whether in the same or different actions), the user is only +# asked once and the response is used for all instances of the prompt. +# +# For a full list of available substitutions and the contexts they work in, see +# the specification for the UCMD extension, currently available at +# http://adc.sourceforge.net/versions/ADC-EXT-1.0.6.html#_ucmd_user_commands + + +# Basic commands available to all users. +# These don't strictly belong in the user context, but it is often a good idea +# to put them there because the user list is a common place to right-click and +# people probably expect to see them there. +0 hub,user Show my IP +Chat 0 !myip +0 hub,user Hub uptime +Chat 0 !uptime +0 hub,user Hub version +Chat 0 !version + +# Put a separator before operator commands. +4 user OpSeparator +Separator + +# Kick and user info commands. +4 user Kick user +PM 0 Selected You are being kicked: %[line:Reason for kick] +Chat 1 is kicking %[userNI]: %[line:Reason for kick] +Chat 0 !kick %[userNI] +4 user Get user's IP +Chat 0 !getip %[userNI] +4 user,hub Find user with certain IP +Chat 0 !whoip %[line:Enter IP] +4 user,hub Find users within IP range +Chat 0 !whoip %[line:Enter range (can use - or / CIDR notation)] + +# Log/broadcast commands. +4 user,hub LogSeparator +Separator +4 user,hub Show log +Chat 0 !log +4 user,hub Broadcast PM to all users +Chat 0 !broadcast %[line:Enter message to broadcast to all users] + +# Admin commands. +7 user,hub AdminSeparator +Separator +7 user,hub Hub statistics +Chat 0 !stats +7 user,hub Reload configuration files +Chat 0 !reload +7 user,hub Shutdown hub +Chat 0 !shutdown + +# Finish off with the help command after a separator. +0 hub,user HelpSeparator +Separator +0 hub,user Help +Chat 0 !help + +# Sample configuration for a quote recording bot connected to the hub, with +# commands displayed in a sub-menu. +# +# This assumes you have reserved_sids=QuoteBot in your uhub configuration file, +# which will reserve the SID AAAB for the QuoteBot user (remember you must also +# register an account for QuoteBot for this to work). Note that if the bot is +# offline the hub will drop any message destined for the bot. +# +# NB. if you just want to see how this looks, you can uncomment the following +# lines even if you don't have such a bot - the commands will still be sent out +# to users, they just won't do anything. +# +#0 hub,user QuoteBot\Get latest quote +#PM 0 AAAB !latest +#0 hub,user QuoteBot\Get random quote +#PM 0 AAAB !random +#0 hub,user QuoteBot\Separator +#Separator +#0 hub,user QuoteBot\Add quote +#PM 0 AAAB !add %[line:Enter quote to add] +#4 hub,user QuoteBot\Delete quote +#PM 0 AAAB !delete %[line:Enter number of quote to delete] diff --git a/src/plugins/mod_ucmd.c b/src/plugins/mod_ucmd.c new file mode 100644 index 0000000..ca616cb --- /dev/null +++ b/src/plugins/mod_ucmd.c @@ -0,0 +1,500 @@ +/* + * uhub - A tiny ADC p2p connection hub + * + * User command plugin + * Copyright (C) 2012, Blair Bonnett + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "plugin_api/handle.h" +#include "plugin_api/types.h" +#include "util/list.h" +#include "util/misc.h" +#include "util/config_token.h" + +/* Plugin data. Stores a linked list of the commands for each credential level. */ +struct ucmd_data{ + struct linked_list* commands[auth_cred_admin]; /* Don't need to store commands for auth_cred_none. */ +}; + +/* State used to keep track of where we are in parsing the user command file. */ +struct parse_state +{ + struct plugin_handle* plugin; + struct ucmd_data* data; + struct plugin_ucmd* ucmd; /* Command we are currently parsing. */ + enum auth_credentials credentials; /* Credentials the command requires. */ + int actions; /* How many actions have been added to the command so far. */ +}; + +/* Callback run when a user logs in. */ +void on_user_login(struct plugin_handle* plugin, struct plugin_user* user){ + struct plugin_ucmd* ucmd; + + /* 0 = auth_cred_none. Should never be called with a non-logged-in user but + * do a sanity check to be sure. */ + if(!user->credentials) return; + + /* Get the list of commands for this credential level. NB the offset of -1 + * caused by not storing a list for auth_cred_none. */ + struct ucmd_data *data = (struct ucmd_data *)plugin->ptr; + struct linked_list* clist = data->commands[user->credentials - 1]; + + /* Loop through and send all commands to the new user. */ + ucmd = list_get_first(clist); + while(ucmd) + { + plugin->hub.ucmd_send(plugin, user, ucmd); + ucmd = list_get_next(clist); + } +} + +/* Adds a parsed command to the lists for the credential levels that can access it. */ +void add_command(struct ucmd_data* data, struct plugin_ucmd* ucmd, enum auth_credentials min_credential) +{ + /* Don't store auth_cred_none but we can accept it here to mean everybody, + * so fake it as the next level up. */ + if(min_credential == auth_cred_none) min_credential = auth_cred_bot; + + /* Add it to the lists of all matching credentials. */ + int i; + for(i = min_credential - 1; i < auth_cred_admin; i++){ + list_append(data->commands[i], (void*)ucmd); + } +} + +/* Frees up all the memory used in the plugin data structure, including the + * structure itself. */ +void free_data(struct plugin_handle* plugin, struct ucmd_data* data) +{ + if(data != NULL){ + /* Clear up the linked lists. */ + int i, j; + for(i = 0; i < auth_cred_admin; i++) + { + struct plugin_ucmd* ucmd = list_get_first(data->commands[i]); + while(ucmd != NULL) + { + /* Remove the commands in this list from the higher lists (they are + * bound to exist in them due to the way the credentials are + * ordered). This is neccessary to avoid a double-free when + * clearing the higher lists. */ + for(j = i + 1; j < auth_cred_admin; j++) list_remove(data->commands[j], ucmd); + + /* Free the command memory. */ + plugin->hub.ucmd_free(plugin, ucmd); + + /* Remove it from the list and move on to the next one. */ + list_remove(data->commands[i], ucmd); + ucmd = list_get_next(data->commands[i]); + } + + /* Done with this list. */ + list_destroy(data->commands[i]); + } + + /* Done with the data structure. */ + free(data); + } +} + +/* Parses the first line of a command entry, and creates a new user command + * object (if possible) in the state structure. Any existing object is + * overwritten - it is up to the calling function to handle this first. If an + * error occurs, sets an appropriate message and returns -1. Returns 1 on + * success. */ +int parse_first_line(struct parse_state* state, char* line) +{ + /* Check the credential level. */ + if(line[0] < '0' || line[0] > '7') + { + state->plugin->error_msg = "Command must start with a valid credential level."; + return -1; + } + state->credentials = line[0] - '0'; + + /* Check for proper formatting. */ + if(line[1] != ' ') + { + state->plugin->error_msg = "No context (or misformed context) given."; + return -1; + } + + /* Parse the context string. */ + char *start = line + 2; + char *end = start; + enum plugin_ucmd_categories category = 0; + while(1) + { + /* Move to (a) end of string, (b) next space, or (c) next comma. */ + while(*end && *end != ' ' && *end != ',') end++; + + /* Check what category this token corresponds to and OR it into the + * overall category. */ + if(strncasecmp(start, "all", end-start) == 0) category = ucmd_category_all; + else if(strncasecmp(start, "hub", end-start) == 0) category |= ucmd_category_hub; + else if(strncasecmp(start, "user", end-start) == 0) category |= ucmd_category_user; + else if(strncasecmp(start, "search", end-start) == 0) category |= ucmd_category_search; + else if(strncasecmp(start, "file", end-start) == 0) category |= ucmd_category_file; + else + { + state->plugin->error_msg = "Invalid context for command."; + return -1; + } + + /* End of the string ==> no name was given on this line. */ + if(!*end) + { + state->plugin->error_msg = "No name for command."; + return -1; + } + + /* Token ended with a space ==> end of list, next up is the command name. */ + if(*end == ' '){ + start = ++end; + break; + } + + /* Must have been a comma, go through and process the next category given. */ + start = ++end; + } + + /* What is left is the name, so we can create the command. */ + state->ucmd = state->plugin->hub.ucmd_create(state->plugin, start, 50); + state->ucmd->categories = category; + + /* Success. */ + return 1; +} + +/* Parses a chat message action and updates the current user command object. + * Sets an appropriate error message and returns -1 if an error occurs. Returns + * 1 on success. */ +int parse_chat(struct parse_state* state, char* args) +{ + /* Check for 'me' parameter. */ + if(args[0] < '0' || args[0] > '1' || args[1] != ' ') + { + state->plugin->error_msg = "'Me' parameter in chat action must be 0 or 1"; + return -1; + } + int me = args[0] - '0'; + + /* Check for a message. */ + char* message = args + 2; + if(strlen(message) == 0) + { + state->plugin->error_msg = "Chat action requires a message to send"; + return -1; + } + + /* Add the message to the command. */ + int retval = state->plugin->hub.ucmd_add_chat(state->plugin, state->ucmd, message, me); + if(retval) + { + state->actions++; + return 1; + } + return -1; +} + +/* Parses a private message action and adds it to the current user command. + * Sets an appropriate message and returns -1 if an error occurs. Returns 1 on + * success. */ +int parse_pm(struct parse_state* state, char* args) +{ + /* Check for 'echo' parameter. */ + if(args[0] < '0' || args[0] > '1' || args[1] != ' ') + { + state->plugin->error_msg = "'Echo' parameter in PM action must be 0 or 1"; + return -1; + } + int echo = args[0] - '0'; + + /* Decide upon the target. */ + args += 2; + char* target; + if(strncasecmp(args, "selected ", 9) == 0) + { + target = NULL; + args += 9; + } + else + { + /* Check it is a valid SID. */ + if(!is_valid_base32_char(args[0]) || !is_valid_base32_char(args[1]) || + !is_valid_base32_char(args[2]) || !is_valid_base32_char(args[3]) || args[4] != ' ') + { + state->plugin->error_msg = "Invalid target in PM action"; + return -1; + } + args[4] = 0; + target = strdup(args); + args += 5; + } + + /* Add the message. */ + int retval = state->plugin->hub.ucmd_add_pm(state->plugin, state->ucmd, target, args, echo); + if(target != NULL) free(target); + + /* Done. */ + if(retval) + { + state->actions++; + return 1; + } + return -1; +} + +/* Parses a line - designed as a callback for the file_read_lines() function. + * Does not handle blank lines as they are not passed to callbacks. Sets + * appropriate error message and returns -1 on error. Returns 1 on success. The + * data parameter should be a pointer to a parse_state structure. */ +int parse_line(char *line, int line_number, void* data) +{ + struct parse_state* state = (struct parse_state*)data; + + /* Strip off any whitespace and check we still have something to process. */ + line = strip_white_space(line); + if(strlen(line) == 0) return 1; + + /* Ignore comment lines. */ + if(line[0] == '#') return 1; + + /* New command. */ + if(line[0] >= '0' && line[0] <= '9') + { + /* Existing command we need to finish and add. */ + if(state->ucmd != NULL) + { + /* Need at least one action. */ + if(!state->actions) + { + state->plugin->error_msg = "A command needs at least one action to perform."; + return -1; + } + + /* Add the command. */ + add_command(state->data, state->ucmd, state->credentials); + state->ucmd = NULL; + } + + /* Reset the flags. */ + state->actions = 0; + state->credentials = auth_cred_none; + + /* Start the new command. */ + return parse_first_line(state, line); + } + + /* New chat message action. */ + else if(strncasecmp(line, "Chat ", 5) == 0) + { + if(state->ucmd == NULL) + { + state->plugin->error_msg = "Command must be defined before an action."; + return -1; + } + if(!state->ucmd->separator) return parse_chat(state, line+5); + else return 1; + } + + /* New private message action. */ + else if(strncasecmp(line, "PM ", 3) == 0) + { + if(state->ucmd == NULL) + { + state->plugin->error_msg = "Command must be defined before an action."; + return -1; + } + if(!state->ucmd->separator) return parse_pm(state, line+3); + else return 1; + } + + /* Command is actually a separator. */ + else if(strncasecmp(line, "Separator", 9) == 0) + { + if(state->ucmd == NULL) + { + state->plugin->error_msg = "Command must be defined before an action."; + return -1; + } + state->ucmd->separator = 1; + state->actions++; + return 1; + } + + /* Unknown line. */ + else + { + state->plugin->error_msg = "Unknown line in user command file."; + return -1; + } +} + +/* Parses a user command file, creates the user commands, and stores them in + * the given data structure. Returns 1 on success, or -1 (with an appropriate + * error message set in the plugin) on error. */ +int parse_file(struct plugin_handle* plugin, struct ucmd_data* data, const char* filename) +{ + /* Create the parser state. */ + struct parse_state* state = (struct parse_state*)malloc(sizeof(struct parse_state)); + state->plugin = plugin; + state->data = data; + state->ucmd = NULL; + state->credentials = auth_cred_none; + state->actions = 0; + + /* Try to parse the file line by line. */ + int retval = file_read_lines(filename, (void*)state, &parse_line); + + /* Default error message. This probably means the file doesn't exist or we + * do not have permission to open it - our parsing functions all set error + * messages. */ + if(retval < 0 && plugin->error_msg == NULL) plugin->error_msg = "Could not load user commands from file."; + + /* Success; the final command needs to be added to the linked list. */ + if(retval > 0 && state->ucmd != NULL) + { + if(state->actions) + { + add_command(data, state->ucmd, state->credentials); + state->ucmd = NULL; + } + else{ + plugin->error_msg = "A command needs at least one action to perform."; + retval = -1; + } + } + + /* Clean up memory from the state. If ucmd is not null, then there was an + * error and it is a partially-processed object we also need to free. */ + if(state->ucmd != NULL) plugin->hub.ucmd_free(plugin, state->ucmd); + free(state); + + /* Done. */ + return retval; +} + +/* Parse the configuration the plugin was started with and save the + * corresponding ucmd_data structure in the plugin structure. Returns 1 on + * success or -1 (with an appropriate error message set) on failure. */ +int parse_config(struct plugin_handle* plugin, const char* config) +{ + int got_file = 0; + + /* Create space for the data we need. */ + struct ucmd_data *data = (struct ucmd_data *)malloc(sizeof(struct ucmd_data)); + if(data == NULL){ + plugin->error_msg = "Could not allocate data storage."; + return -1; + } + + /* Initialise the linked lists for the commands. */ + int i; + for(i = 0; i < auth_cred_admin; i++) + { + data->commands[i] = list_create(); + if(data->commands[i] == NULL) + { + /* We cannot call the free_data() function here as not all the + * lists have been initialised. */ + int j; + for(j = 0; j < i; j++) list_destroy(data->commands[j]); + free(data); + plugin->error_msg = "Could not allocate data storage."; + return -1; + } + } + + /* Tokenize the config file and loop over each token. */ + struct cfg_tokens* tokens = cfg_tokenize(config); + char* token = cfg_token_get_first(tokens); + while(token) + { + /* Try to split the setting into key and value. */ + struct cfg_settings* setting = cfg_settings_split(token); + if(!setting) + { + plugin->error_msg = "Unable to parse plugin config"; + cfg_tokens_free(tokens); + free_data(plugin, data); + return -1; + } + const char* key = cfg_settings_get_key(setting); + const char* value = cfg_settings_get_value(setting); + + /* Name of file containing user commands. */ + if (strncmp(key, "file", 4) == 0) + { + got_file = 1; + if(parse_file(plugin, data, value) < 0) + { + cfg_settings_free(setting); + cfg_tokens_free(tokens); + free_data(plugin, data); + return -1; + } + } + + /* Unknown setting. */ + else + { + plugin->error_msg = "Unknown setting when parsing plugin config"; + cfg_settings_free(setting); + cfg_tokens_free(tokens); + free_data(plugin, data); + return -1; + } + + /* Move onto next token. */ + cfg_settings_free(setting); + token = cfg_token_get_next(tokens); + } + cfg_tokens_free(tokens); + + /* Make sure we were given at least one user command file. */ + if(!got_file) + { + plugin->error_msg = "No command file given, use file="; + free_data(plugin, data); + return -1; + } + + /* Save the data with the plugin and we're done. */ + plugin->ptr = (void *)data; + return 1; +} + +/* Attempt to load the plugin. Called by the hub when appropriate. */ +int plugin_register(struct plugin_handle *plugin, const char *config){ + PLUGIN_INITIALIZE(plugin, "User command plugin", "0.1", "Provide custom commands to users."); + + /* Attempt to parse the config we were given. */ + if(parse_config(plugin, config) == -1) return -1; + + /* Register our callbacks. */ + plugin->funcs.on_user_login = on_user_login; + + /* Done. */ + return 0; +} + +/* Unload the plugin. Called by the hub when appropriate. */ +int plugin_unregister(struct plugin_handle *plugin){ + free_data(plugin, (struct ucmd_data *)plugin->ptr); + return 0; +}