Add plugin to send user commands.

Adds a mod_ucmd plugin which uses the previously added user command
plugin functions to send user commands when users log in. These are read
from a text file in a simple format. They can be restricted to users
with certain credentials, and can have multiple actions (e.g., send a PM
and a message in the main chat).

A sample user command file for the standard uhub commands is at
doc/ucmd.conf, and will be automatically installed if appropriate.

This currently won't send commands to existing users (e.g., if the
plugin is (re)loaded after the hub is already up and running). To
support this, a plugin function to list all current users would need to
be added.

I'm not sure how efficient this would be due to the volume of
messages needed to delete the existing commands on shutdown and send
them again on startup. As the commands are reloaded properly within the
plugin, it may be easier to require users to reconnect after the
configuration changes (and, of course, an admin could force this by
restarting the hub).
This commit is contained in:
Blair Bonnett 2012-08-08 19:19:27 +12:00
parent 30ec879cd2
commit 2e7f0f9c12
4 changed files with 673 additions and 1 deletions

View File

@ -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

View File

@ -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"

160
doc/ucmd.conf Normal file
View File

@ -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:
#
# <credential level required> <list of contexts> <command name>
#
# 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 <me> <message>
# Sends a message in the main chat window. The <me> parameter is 0 or 1, with 1
# meaning it is formatted as a /me <action> style message by clients.
#
# PM <echo> <target> <message>
# 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 <echo> 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:<message shown in prompt>]. 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 <start>-<end> or <ip>/<mask> 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]

500
src/plugins/mod_ucmd.c Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
*
*/
#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=<filename>";
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;
}