Scripts:dc ass
Introduction
The DontCamp Administrative Support System is a completely server-side, python based admin system for Battlefield 2 that uses in-game chat commands and cd keyhash authentication to provide easy to use and powerful administrative functions.
Some of the better features of dc_ass are:
- kicking by playername, partial playername, or player ID
- single command multiple kick options against clan tags or team number (only used for evil, I know)
- variable userlevels via a PB style power points system
- allows the general public to vote kick someone without the annoying prompts
- completely an in-game and fast admin system so you can spend your time playing
The code
dc_ass.py
""" DontCamp.com Administrative Support System v. 1.2.1 written by: [email protected] Changelog: 1.2 beta: * added support for multiple arguments in a chat command * no longer spews so much to the screen when someone is kicked 1.1.1 beta: * playernames with prefixs can now issue commands 1.1 beta: * added userlevels via a kick points system a la PB power users * added general public vote kicking * using keyhash rather than PB GUID for auth * moved to more OO codebase * extensions can now act on anything in the dc_ass object Acknowledgements: Thanks to [email protected] for significant coding contributions. Thanks to [email protected] for his conceptual contributions. Thanks to my brother for helping me with fundamental coding concepts. Thanks to the guy that was willing to sell me a T40 ThinkPad on eBay for that insanely low price! Dear God, thanks be to O'Reilly! And a REALLY BIG thanks to Kevin Lockitt of blackbagops.com. This software is clearly a big stinkin' rip off of BFSM, a program that did as much as anything to make DontCamp.com the successful gaming community that it is. Purpose: To provide an in-game chat based admin system similar to Kevin Lockitt's Battlefield Server Manager while also providing a kick vote system similar to PunkBuster's Poweruser system. Requirements: 1. dc_debug.py - a common debugging function for all DontCamp.com BF2 python level tools 2. dc_ass_extensions.py - this file is currently required to exist but can be empty. See dc_ass_extensions Instructions. 3. dc_ass_cmds.ini - the configuration file for all in-game admin commands 4. power.dat - a list of your admins' power levels and keyhashs Known issues: 1. writeToLogfile() is not currently tested or up-to-date and is therefore disabled. 2. In general be very careful what you code in an extension. The BF2 server may lag or crash when running poorly written extensions. 3. dc_ass currently requires punkbuster to be enabled. 4. There is almost no sanity checking for poorly configured INI files. 5. Non permanent bans are not maintained between server restarts. Setup: 1. Drop dc_ass.py and dc_debug.py into the admin/standard_admin directory of your BF2 installation. 2. Edit admin/standard_admin/__init__.py file to include these lines: import dc_ass dc_ass.init() 3. Create an empty dc_ass_extensions.py file or populate it as per the dc_ass_extensions instructions below. 4. Create a power.dat file as per the instructions below. 5. Create / edit a dc_ass_cmds.ini file as per the instructions below. power.dat Instructions: This file is simply a space delimited file with two values on each line that must exist in the admin/standard_admin directory. The first value is the power level assignment and the second value is the keyhash. Currently dc_ass is lacking some flexibily in that 10 kick points always causes a player to be kicked. Keep that in mind when assigning power levels. The power level must be a positive integer but can otherwise be whatever you like. The keyhash for a user is a 32 digit hexidecimal number that be found when executing admin.listplayers in the console. I have written a dc_ass extensions that prints the keyhash for a user while in-game and I will like make that extensions available in the future. Please note that BF2 keyhashes are NOT the same thing as PB GUID's. An example power.dat file: 10 352a507c78da1dea6dd42a5867d3c2cc 4 2365c8ef755723efc10b425fffb65ebe This examples shows that the player with the keyhash: 352a507c78da1dea6dd42a5867d3c2cc can apply 10 kick points to each player and that the player with the keyhash: 2365c8ef755723efc10b425fffb65ebe can apply 4 kick points to each player. Since 10 kick points kicks a user, we can assume that any admin with a kick power of 10 is a full admin and therefore they can run "admin" commands. See the section on the dc_ass_cmds.ini file for more info. dc_ass_cmds.ini Instructions: This file follows standard INI file formatting. Each section name is the name of a command that can be run from in-game text chat. Each section must have a "type" option. Supported types are: kick - a simple kick ban - a permanent ban (or possibly temporary) rcon - user defined rcon command execution extension - user defined python function execution Depending on the type, different options are required. Each type supports the "admin" option. When admin = 1, the user must have a kick power of 10 to execute that command. When admin = 0, any user on your BF2 server is allowed to execute that command. The admin option is optional and defaults to 1. (* = required) Type: kick Options: *reason = (a non-delimited string contained the reason for this kick) length = (an integer representing how many minutes before the the offender will be allowed back into the game. If this option is missing, the default value will be 2.) Type: ban Options: *reason = (same as above) length = (same as above) (see known issue #5) Type: rcon Options: *command = (a non-delimited string contained the rcon command you want to issue) Type: extension Options: *function = (the name of the function in dc_ass_extensions.py you want this command to run WITHOUT any (). See dc_ass_extensions Instructions.) An example dc_ass_cmds.ini file might look like this: [kfms] type = kick reason = for firing from a vehicle in/into an uncapturable spawn length = 5 [brsc] type = ban reason = for a racist or slanderous comment [restart] type = rcon command = admin.restartmap [slap] type = extension function = slapPlayer [nextmap] type = extension function = sayNextMap admin = 0 NOTE: Changes made to the dc_ass_cmds.ini file are in effect immediately and do not require a server restart. dc_ass_extensions.py Instructions: The extensions provide a means to do more complex actions that cannot be achieved through a simple rcon command. This file works similarly to other python files inside the BF2 environment. It's simply a list of functions as well as the proper imports to provide the facility the functions require. NOTE: Each function that is run as an extension must accept one and only one parameter. That parameter will be the dc_ass admin object most noteably containing these properties: victim_id - a list of all integers of the victim IDs found via the parameter for this the command executed victim_tracker - a dictionary of all the people currently being tracked by dc_ass (in other words, people that have had kick points issued against them). This is a multidemensional dictionary where the top level key is the player_id / slot number and value is another dictionary where the key is the keyhash of the issuer of the points and the value is the number of points issued by said issuer. issuer - an integer representing the player_id of the person that executed the current command. keyhash - a string that is the hexidecimal keyhash of the person that executed the current command. See http://dontcamp.com/code/ for an example dc_ass_extenions.py file. Note: Changes made to the dc_ass_extensions.py file are in effect immediately and do not require a server restart. How to use all this once you've got it setup: All commands are issued with in-game chat, NOT console commands. To my knowledge, these commands should work in any chat mode. Each command MUST be prefixed with an ! and optionally suffixed with a space followed by a case insensitive playername, partial playername, or a . followed by a player number. Additionally, you may prefex an @ in front of a case insensitive string to execute an action against all players containing that string in their playername. You may also use a % followed by a team number to execute a command against an entire team. Using our previous example INI file and given that the server has two players on it: Butter (in slot 0) and MuttDog (in slot 1) here's how the following commands would react... !kfms [ Whenever no parameter for a victim is given, dc_ass will assume the player ID of the admin issuing the command. In this case the admin will be kicked for 5 minutes for firing into a main spawn. ] !kfms .0 [ This will kick Butter for 5 minutes for firing into a main spawn because butter is player number 0 / in slot 0. ] !kfms butter [ This will also kick Butter because butter matches Butter in our case insensitive string comparison. ] !kfms ter [ This will also kick Butter because ter is a string match in Butter's name and ONLY in Butter's name. ] !kfms utt [ This will kick no one but print a global message in green at the top left of the screen that utt is ambiguous. ] !kfms dst [ This will kick no one and print a global message saying "Could not find ID for dst". ] !kfms @t [ This will kick both dst and butter as they both have a t in their their playername. ] !kfms @T [ This will still kick both dst and butter because our text search is case insensitive. ] !kfms %2 [ This will kick everyone on team 2. ] """ import bf2 import host import re import ConfigParser import time from dc_debug import decho from dc_debug import showLevel import dc_ass_extensions class ass: # some init vars victim_tracker = {} admin_level = 10 default_weight = 2 kick_threshold = 10 """ _getPlayerList(): returns a dictionary of players currently playing where the key is their player player_id/index and the value is list containing their playername, IP, outbound port, and CD-key hash. Thanks to Woody for doing all the hard work on this one. """ def _getPlayerList(self): rawData = host.rcon_invoke( 'admin.listplayers' ) # this patern is for line 0 of the output for each player # pattern1 = re.compile(r'''^Id:\ +(\d+)\ -\ (\S+)\ is\ remote\ ip:\ (\d+\.\d+\.\d+\.\d+):(\d+)''', re.VERBOSE) pattern1 = re.compile(r'''^Id:\ +(\d+)\ -\ (.*?)\ is\ remote\ ip:\ (\d+\.\d+\.\d+\.\d+):(\d+)''', re.VERBOSE) # this patern is for line 1 of the output for each player pattern2 = re.compile(r'''(?:.*hash:\ (\w{32}))?''', re.VERBOSE) players = {} i = 0 for line in rawData.split("\n"): # if we're looking at a "line 0" if i == 0: matches = pattern1.findall(line) if len(matches) != 0: p_id = int(matches[0][0]) players[p_id] = [] players[p_id].append(matches[0][1]) players[p_id].append(matches[0][2]) players[p_id].append(matches[0][3]) # if we're looking at a "line 1" elif i == 1: matches = pattern2.findall(line) players[p_id].append(matches[0]) # flop the value of the iter i ^= 1 return players """ _getWeight(): finds the admin level of the user at index returns true on success, false on failure (the true case being that a user is found at index) assigns string self.keyhash as the keyhash of the user at index assigns int self.user_weight as the admin level of the user at index """ def _getWeight(self, index): return_value = False players = self._getPlayerList() self.user_weight = self.default_weight if players.has_key(index): return_value = True self.keyhash = players[index][3] # assigns the default weight in case this user is not found in the pbpower.dat file decho( "dc_ass: Attempting to authenticate keyhash: %s" % self.keyhash, 5 ) # just in case there is no pbpower.dat file try: for line in open('admin/standard_admin/power.dat', 'r'): if self.keyhash == line.split(' ')[1].strip(): self.user_weight = int(line.split(' ')[0].strip()) decho( "dc_ass: %s is level %d in power.dat!" % (self.keyhash, self.user_weight), 5 ) break except IOError: decho( "dc_ass: unable to open pb/pbpower.dat file", 5 ) return return_value """ _getVictimId(): finds all victims from the in-game chat command parameter returns true on success and false on failure (the failure case is not being able to determine with certainly which victims the command is for) assigns list self.victim_id """ def _getVictimId(self, parameter): # returns true/false on success/failure and assigns self.victim_id if possible # self.victim_id is a list of all victims found # sorry for the silly parameter name, but it does make sense in this case. self.victim_id = [] result = False # default value for result decho("dc_ass: Attempting to find victim ID...", 5) # if the parameter is the player number if parameter[0:1] == ".": id = int(parameter[1:]) for p in bf2.playerManager.getPlayers(): if p.index == id: self.victim_id = [id] decho( "dc_ass: parameter was an int, and id = %d" % self.victim_id[0], 5 ) result = True break if not result: decho( "dc_ass: (ERROR) no player found with ID: %d" % id, 1 ) # if the parameter is a wildcard / clantag elif parameter[0:1] == "@": decho( "dc_ass: finding ALL matching players with parameter...", 5 ) decho( "dc_ass: parameter was NOT an int", 5 ) # for all players connected... for p in bf2.playerManager.getPlayers(): decho( "dc_ass: checking player: %s" % p.getName(), 5 ) # if we find a name that loosely matches... if p.getName().lower().find( parameter[1:].lower() ) != -1: decho( "dc_ass: %s loosely matches %s" % ( parameter[1:], p.getName() ), 5 ) self.victim_id.append(p.index) result = True if result: decho( "dc_ass: Victim ID(s) found @ %r" % self.victim_id, 5 ) else: decho( "dc_ass: (ERROR) no players found with %s in their name" % parameter[1:], 1 ) # if the parameter is a team number elif parameter[0:1] == "%": decho( "dc_ass: finding ALL matching players on team matching paramenter...", 5 ) # for all players connected... for p in bf2.playerManager.getPlayers(): decho( "dc_ass: checking player: %s" % p.getName(), 5 ) # if we find a player on the team supplied if p.getTeam() == int(parameter[1:]): decho( "dc_ass: %s is on team %s" % ( p.getName(), p.getTeam() ), 5 ) self.victim_id.append(p.index) result = True if result: decho( "dc_ass: Victim ID(s) found @ %r" % self.victim_id, 5 ) else: decho( "dc_ass: (ERROR) there seems to be no one on team %s" % parameter[1:], 1 ) # if the parameter is a playername or partial playername else: id = -1 decho("dc_ass: parameter was NOT an int", 5) # for all players connected... for p in bf2.playerManager.getPlayers(): decho( "dc_ass: checking player: %s" % p.getName(), 5 ) # if we find a name that loosely matches... if p.getName().lower().find( parameter.lower() ) != -1: decho( "dc_ass: %s loosely matches %s" % ( parameter, p.getName() ), 5 ) # if this is the first victim we've found... if id == -1: decho("dc_ass: found %s in %s" % ( parameter, p.getName() ), 5) id = [int(p.index)] result = True # if we've gotten another possible match... else: result = False break if result: self.victim_id = id decho( "dc_ass: Victim ID found @ %d" % self.victim_id[0], 5 ) else: decho( "dc_ass: %s is ambiguous." % parameter, 1 ) return result """ def writeLogFile(self, victim_id, admin_id, type, reason, length): v = bf2.PlayerManager.Player(victim_id) a = bf2.PlayerManager.Player(admin_id) decho("\"%s\",%s,%d,%s,%d,%s,%s,%d,%s\n" % (v.getName(), v.getAddress(), v.getProfileId(), a.getName(), int(time.time()), bf2.gameLogic.getMapName(), type, reason, length * 60), 5) fo = open('admin/standard_admin/adminlog.csv', 'a') # v.name, v.ip, v.profileId, a.name, timestamp, map, type, length, reason fo.write("\"%s\",%s,%d,%s,%d,%s,%s,%d,%s\n" % (v.getName(), v.getAddress(), v.getProfileId(), a.getName(), int(time.time()), bf2.gameLogic.getMapName(), type, reason, length * 60)) fo.close() """ """ _stripPrefix(): removes context prefixes from a chati lines and returns the trimmed chat line """ def _stripPrefix(self, text): text = text.replace( "HUD_TEXT_CHAT_TEAM", "" ) text = text.replace( "HUD_TEXT_CHAT_SQUAD", "" ) text = text.replace( "HUD_CHAT_DEADPREFIX", "" ) text = text.replace( "*\xA71DEAD\xA70*", "" ) return text.strip() def addPoints(self): # add entries in the victim_tracker foreach victim for v_id in self.victim_id: # check to see if we're already tracking the joker if self.victim_tracker.has_key(v_id): decho( "dc_ass: already tracking %d" % v_id, 5 ) # check to see if this admin has already applied their weight to the victim if not self.victim_tracker[v_id].has_key(self.keyhash): self.victim_tracker[v_id][self.keyhash] = self.user_weight # added this conditional to make it a bit less noisy when someone is just kicked with one action if self.user_weight < self.kick_threshold: decho( "dc_ass: applied %d points from %s to player %d" % (self.user_weight, self.keyhash, v_id), 5 ) # this elif was really added to deal with an extension used at DontCamp.com but it makes sense to have anyway elif self.user_weight == self.kick_threshold: self.victim_tracker[v_id][self.keyhash] = self.user_weight else: decho( "dc_ass: %s has already applied their points to %s" % (bf2.PlayerManager.Player(self.issuer).getName(), bf2.PlayerManager.Player(v_id).getName()), 1 ) else: self.victim_tracker[v_id] = {} decho( "dc_ass: now tracking player: %d" % v_id, 5 ) self.victim_tracker[v_id][self.keyhash] = self.user_weight decho( "dc_ass: applied %d points from %s to player %d" % (self.user_weight, self.keyhash, v_id), 5 ) def checkPoints(self): for index, tracking in self.victim_tracker.iteritems(): # reset total_points for this victim total_points = 0 # added up all the points being applied to this victim for unused_key, points in tracking.iteritems(): total_points += points if index in self.victim_id: decho( "dc_ass: %s now has %d of %d kick points" % (bf2.PlayerManager.Player(index).getName(), total_points, self.kick_threshold), 1 ) if total_points >= self.kick_threshold: self.reason += " - see dontcamp.com/forums for discussion" # issue the kick command decho( "dc_ass: Kicking player '%s' (%d) %s" % (bf2.PlayerManager.Player(index).getName(), index, self.reason), 1 ) unused = host.rcon_invoke('pb_sv_kick %d %d %s' % (index + 1, self.length, self.reason) ) # it might be helpful to create a new object to pass to extensions that # has a reference to the admin object. In that new object, that I hope # would only exist during the execution of a command, things like the # issuer and keyhash properties could be assigned. def onChatMessage(self, player_id, text, channel, flags): self.issuer = player_id # pull the potential prefix off the text line text = self._stripPrefix(text) # unless the first character is ! don't do anything if text[0:1] == "!": decho( "dc_ass: the first character of %s was !" % text, 5 ) # As BF2 playernames cannot contain spaces except what is printed # between the prefix and the name, it is probably safe to assume # that we don't need to accept spaces in playernames for this # parameter. Therefore, I could use the space as a separator # between the victim name and another command parameter, like # a custom reason. # grab the parts of the chatline I need with a REGEX pattern = re.compile(r'!(\w*) ?(\S*) ?(.*)') matches = pattern.findall(text) command = matches[0][0] # set an arguments property for future use or for extensions self.arguments = matches[0][2] decho( "dc_ass: command = %s" % command, 5 ) # in case the command doesn't require a victim parameter... if matches[0][1] != "": parameter = matches[0][1] decho( "dc_ass: parameter = %s" % parameter, 5 ) else: parameter = None decho( "dc_ass: no parameter given", 5 ) try: # read in the commands INI file self.config = ConfigParser.ConfigParser() self.config.read('admin/standard_admin/dc_ass_cmds.ini') # is this a defined command in the INI file if command in self.config.sections(): decho( "dc_ass: Command found in INI file!", 5 ) # define/declare some vars for this admin event (I really wouldn't need to do this if I know how to check if a var is defined or even exists type = "" # these values will be members of the object because they may be needed in an extension self.length = 2 # the default length for a kick self.reason = "" admin = 1 # this makes all commands default to requiring admin privs # get all possible options # I could add sanity checks in here for poorly formated lines in the INI file for option in self.config.options(command): if option == "type": type = self.config.get(command, option).strip() elif option == "reason": self.reason = self.config.get(command, option).strip() elif option == "length": self.length = int(self.config.get(command, option)) elif option == "command": rcon_string = self.config.get(command, option).strip() elif option == "admin": admin = int(self.config.get(command, option)) elif option == "function": function = self.config.get(command, option).strip() # make sure we have a type if type == "": decho( "dc_ass: (ERROR) No type value for %s in dc_ass_cmds.ini file!" % command, 1 ) # good, we're all set else: decho( "dc_ass: type: %s" % type, 5 ) # to get their keyhash and admin level if self._getWeight(player_id): # see if we can/need to get a victim ID to move forward if ( parameter != None and self._getVictimId(parameter) ) or parameter == None: # for non-perma bans, change type to kick_ban - this will be useful when we start to log this stuff if type == "ban" and self.length != "": decho( "dc_ass: Length value given for ban. Converting type to kick_ban.", 5 ) type = "kick_ban" # if no parameter was given, make the command reflect on the issuer if parameter == None: decho( "dc_ass: No parameter given... assigning victim_id to issuer.", 5 ) self.victim_id = [player_id] if ( admin and self.user_weight == self.admin_level ) or not admin: # the main switch like section that gets the shit done (if only I had switches) # Are reasons optional in these PB kicks/bans? If so, I guess I don't need to worry about sanity checking them if type == "kick" or type == "kick_ban": self.addPoints() self.checkPoints() elif type == "ban": for v_id in self.victim_id: decho( "dc_ass: Banning player '%s' (%d) %s" % (bf2.PlayerManager.Player(v_id).getName(), v_id, self.reason), 1 ) unused = host.rcon_invoke( 'pb_sv_ban %d %s' % (v_id + 1, self.reason) ) elif type == "rcon": decho( "dc_ass: Running rcon command: %s" % rcon_string, 2 ) unused = host.rcon_invoke( rcon_string ) elif type == "extension": reload( dc_ass_extensions ) if function in dc_ass_extensions.__dict__: decho( "dc_ass: executing %s() extension" % function, 2 ) # send the entire object to the extensions exec( "dc_ass_extensions.%s(%s)" % (function, "self") ) else: decho( "dc_ass: %s() extension not found." % function, 2 ) else: decho( "dc_ass: No such type: %s" % type, 1 ) else: decho ( "dc_ass: User in slot %d is not authorized for the requested action" % player_id, 1 ) else: decho( "dc_ass: Victim ID not findable given the parameter", 2 ) else: decho( "dc_ass: ERROR failed to get keyhash for player: %d" % player_id, 1 ) else: # end of is command in INI file conditional decho( "dc_ass: (ERROR) %s command not found!" % command, 1 ) except IOError: print "dc_ass: (FATAL ERROR) could not open one or more required files: (admin_profile_ids.csv | dc_ass_cmds.ini)" else: # a debug line just so I can see all the chat if I need to decho( "dc_ass: player %d: %s" % (player_id, text), 5 ) def onPlayerDisconnect(self, p): # if they disconnect, which might have happened if they were kicked, drop them from the tracker, if they're even in it if self.victim_tracker.has_key(p.index): del(self.victim_tracker[p.index]) decho( "dc_ass: player %d disconnected and no longer tracked" % p.index, 2 ) admin = ass() def init(): decho('dc_ass: initializing DontCamp.com Admin Support System', 2) host.registerHandler('ChatMessage', admin.onChatMessage, 1) host.registerHandler('PlayerDisconnect', admin.onPlayerDisconnect, 1) showLevel()
The source is downloadable from here.
Change Log
1.2 beta
- added support for multiple arguments in a chat command
- no longer spews so much to the screen when someone is kicked
1.1.1 beta
- playernames with prefixs can now issue commands
1.1 beta
- added userlevels via a kick points system a la PB power users
- added general public vote kicking
- using keyhash rather than PB GUID for auth * moved to more OO codebase
- extensions can now act on anything in the dc_ass object
dc_debug.py
dc_debug.py is required for nearly all DontCamp BF2 python code. It manages the debuging and logging output of our software.
""" DontCamp.com Common Debugging Function written by: [email protected] Revision: $Revision: 1.11 $ Purpose: To provide a central way for all DontCamp BF2 python code to output debugging and informational text. Setup: (defaults should work well) 1. Create a admin/standard_admin/dc_debug.level file and put the debug level you want in the first character of the first line of said file. Valid levels are 1-5. 2. Set log_to_file to either False or True if you want a seperate dc_debug log file for all DontCamp BF2 python code debug output. 3. Set log_filename to whatever filename and path you like (relative to your BF2 home directory). This is only necessary if log_to_file is set to True. 4. Set log_to_stdout to either True or False if you want dc_debug to send out its output to the python stdout interface. This is useful if you are using BF2CC and want dc_debug's output in the pythonlog.txt file. """ import host import time try: debug_level_fh = open('admin/standard_admin/dc_debug.level', 'r') debug_level = int(debug_level_fh.read(1)) debug_level_fh.close() except: debug_level = 5 log_to_file = True log_filename = 'dc_debug.log' log_to_stdout = False def decho(msg, level = 1): # 1 = info w/ in-game feedback # 2 = info # 5 = debug if debug_level > 0 and debug_level >= level: string = time.strftime('%Y-%m-%d %H:%M:%S') + ' - ' + msg if log_to_stdout: print string if log_to_file: fh = open(log_filename, 'a') fh.write(string + '\n') fh.close() if level == 1: host.rcon_invoke('game.sayall "%s"' % msg) def showLevel(): decho('dc_debug: The current debug level is %d' % debug_level, 2)
The source is downloadable from here