PowerBot.psm1
## This script requires Meebey.SmartIrc4net.dll which you can get as part of SmartIrc4net ## http://voxel.dl.sourceforge.net/sourceforge/smartirc4net/SmartIrc4net-0.4.0.bin.tar.bz2 ## And the docs are at http://smartirc4net.meebey.net/docs/0.4.0/html/ ############################################################################################ ## You should configure the PrivateData in the PowerBot.psd1 file ############################################################################################ ## You should really configure the PrivateData in the PowerBot.psd1 file ############################################################################################ ## You need to configure the PrivateData in the PowerBot.psd1 file ############################################################################################ ## Set some default ParametersValues for inside PowerBot $PSDefaultParameterValues."Out-String:Stream" = $true $PSDefaultParameterValues."Format-Table:Auto" = $true ## Store the PSScriptRoot $global:PowerBotScriptRoot = Get-Variable PSScriptRoot -ErrorAction SilentlyContinue | ForEach-Object { $_.Value } if(!$global:PowerBotScriptRoot) { $global:PowerBotScriptRoot = Split-Path $MyInvocation.MyCommand.Path -Parent } $DataDir = Join-Path $Env:ProgramData "PowerBot" if(!(Test-Path $DataDir)) { Write-Warning "No PowerBot Settings Directory. Creating '$DataDir'" mkdir $DataDir } ## If Jim Christopher's SQLite module is available, we'll use it Import-Module SQLitePSProvider -ErrorAction SilentlyContinue if(!(Test-Path data:) -and (Microsoft.PowerShell.Core\Get-Command Mount-SQLite)) { $BotDataFile = Join-Path $DataDir "botdata.sqlite" Mount-SQLite -Name data -DataSource ${BotDataFile} } elseif(!(Test-Path data:)) { Write-Warning "No data drive, UserTracking and Roles disabled" } function Get-PowerBotIrcClient { $script:irc } function Get-Setting { #.Synopsis # Read a Setting off the PrivateData param( # The setting name to retrieve [Parameter(Position=0, Mandatory=$true)] $Name ) $PrivateData = $MyInvocation.MyCommand.Module.PrivateData foreach($Level in $Name -split '\.') { $PrivateData = $PrivateData.$Level } return $PrivateData } function Send-Message { #.Synopsis # Sends a message to the IRC server [CmdletBinding()] param( # Who to send the message to (a channel or nickname) [Parameter(Position=0)] [String]$To, # The message to send [Parameter(Position=1, ValueFromPipeline=$true)] [String]$Message, # How to send the message (as a Message or a Notice) [ValidateSet("Message","Notice")] [String]$Type = "Message" ) process { Write-Verbose "Send-Message $Message" if($Message.Contains("`n")) { $Message.Trim().Split("`n") | Send-Message -To $To -Type $Type } else { $Message = $Message.Trim() Write-Verbose "SendMessage( '$Type', '$To', '$Message' )" $irc.SendMessage($Type, $To, $Message) } } } function Start-PowerBot { #.Synopsis # Start PowerBot and connect to the specified channel/network #.Description # Starts an IRC client and hooks it up to event handlers to enable the bot functionality. # # All of the parameters on this method use defaults from the PrivateData hashtable in the module manifest. [CmdletBinding()] param( # The nickname to use (usually you should provide an alternate) # NOTE, the FIRST nick should be associated with the password, if any [Parameter(Position=0)] [string[]]$Nick = $( $Default = Get-Setting Nick if($Default.Length -gt 0 -and $Default[0].Length -gt 0) { $Default } else { "PowerBot{0:D4}" -f (Get-Random -Maximum 9999) "PowerBot{0:D4}" -f (Get-Random -Maximum 9999) }), # The IRC channel(s) to connect to [string[]]$Channels = $(Get-Setting Channels), # The nickserv password to use (will be sent in a PRIVMSG to NICKSERV to IDENTIFY) [string]$Password = $(Get-Setting Password), # The server to connect to [string]$Server = $(Get-Setting Server), # The port to use for connection [int]$Port = $(Get-Setting Port), # The "real name" to be returned to queries from the IRC server [string]$RealName = $( if($Default = Get-Setting RealName) { $Default } else { "PowerBot http://github.org/Jaykul/PowerBot" }), # The proxy server [string]$ProxyServer = $(Get-Setting ProxyServer), # The port for the proxy server [int]$ProxyPort = $(Get-Setting ProxyPort), # The proxy username (if required) [string]$ProxyUserName = $(Get-Setting ProxyUserName), # The proxy password (if required) [string]$ProxyPassword = $(Get-Setting ProxyPassword), # Recreate the IRC client even if it already exists [switch]$Force ) # The bot owner(s) have access to all commands [String[]]$Owner = $(Get-Setting Owner) $script:Password = $Password if($Force -or !(Test-Path -Path Variable:Script:Irc)) { $script:irc = New-Object Meebey.SmartIrc4net.IrcClient # TODO: Expose these options to configuration $script:irc.AutoRejoin = $true $script:irc.AutoRejoinOnKick = $false $script:irc.AutoRelogin = $true $script:irc.AutoReconnect = $true $script:irc.AutoRetry = $true $script:irc.AutoRetryDelay = 60 $script:irc.SendDelay = 400 $script:irc.Encoding = [Text.Encoding]::UTF8 # SmartIrc will track channels for us $script:irc.ActiveChannelSyncing = $true if($ProxyServer) { $script:irc.ProxyHost = $ProxyServer $script:irc.ProxyPort = $ProxyPort $script:irc.ProxyUserName = $ProxyUserName $script:irc.ProxyPassword = $ProxyPassword } # There are a few things I need to store for command modules Add-Member -Input $irc NoteProperty BotOwner $Owner Add-Member -Input $irc NoteProperty BotChannels $Channels Add-Member -Input $irc NoteProperty EventHooks @{} # This causes errors to show up in the console $script:irc.Add_OnError( {Write-Error $_.ErrorMessage} ) # This give us the option of seeing every line as verbose output $script:irc.Add_OnReadLine( {Write-Verbose $_.Line} ) ## UserModeChange (this happens, among other things, when we first go online) $script:irc.Add_OnUserModeChange( {OnUserModeChange_TrackOurselves} ) # We handle commands on query (private) messages or on channel messages $script:irc.Add_OnQueryMessage( {OnQueryMessage_ProcessCommands} ) $script:irc.Add_OnChannelMessage( {OnChannelMessage_ProcessCommands} ) } # Connect to the server $script:irc.Connect($server, $port) # Login to the server if($Password) { $script:irc.Login(([string[]]$nick), $realname, 0, $nick[0], $password) } else { $script:irc.Login(([string[]]$nick), $realname, 0, $nick[0]) } Resume-PowerBot # Shortcut so starting this thing up only takes one command } ## Note that PowerBot stops listening if you press Q ... ## You have to run Resume-Powerbot to get him to listen again ## That's the safe way to reload all the PowerBot commands function Resume-PowerBot { #.Synopsis # Reimport all command modules and restart the main listening loop [CmdletBinding()]param([switch]$Force) if(!(Test-Path -Path Variable:Script:Irc)) { throw "You must call Start-PowerBot before you call Resume-Powerbot" } . $PowerBotScriptRoot\UpdateCommands.ps1 -Force:$Force # Initialize the command array (only commands in this list will be heeded) while($Host.UI.RawUI.ReadKey().Character -ne "Q") { while(!$Host.UI.RawUI.KeyAvailable) { $irc.ListenOnce($false) } } } function Stop-PowerBot { #.Synopsis # Disconnect from IRC completely, with the specified quit message [CmdletBinding()] param( # The message to send on quit [Parameter(Position=0)] [string]$QuitMessage = "If people listened to themselves more often, they would talk less." ) $irc.RfcQuit($QuitMessage) for($i=0;$i -lt 30;$i++) { $irc.Listen($false) } $irc.Disconnect() } #################################################################################################### ## Event Handlers #################################################################################################### ## Event handlers in powershell have TWO automatic variables: $This and $_ ## In the case of SmartIrc4Net: ## $This - usually the connection, and such ... ## $_ - the IrcEventArgs, which just has the Data member: ## $InternalVariables = "Channel", "From", "Hostname", "Ident", "Message", "Nick" function Test-Command { [CmdletBinding()]param([Parameter(ValueFromRemainingArguments)][String]$ScriptString) Protect-Script -Script $ScriptString -AllowedModule PowerBotCommands -AllowedVariable $InternalVariables -WarningVariable warnings } function OnUserModeChange_TrackOurselves { #.Synopsis # Handles the UserModeChange event to deal with authentication and joining channels # ${This} is the $irc object $Nick = $This.NicknameList[0] # If we know a password if($This.Password) { # Manual login to nickserv: Send-Message -To "Nickserv" -Message "IDENTIFY $Nick $($This.Password)" # TODO: The "REGAIN" command may only work on freenode if($This.Nickname -ne $Nick) { Send-Message -To "Nickserv" -Message "REGAIN $Nick $($This.Password)" } } # Remove our hook. We don't need to track this anymore $irc.Remove_OnUserModeChange( {OnUserModeChange_TrackOurselves} ) foreach($chan in $irc.BotChannels) { $irc.RfcJoin( $chan ) } } function OnQueryMessage_ProcessCommands { # If it's not prefixed, then we don't process it, because it's not a command if($_.Data.Message[0] -eq $Prefix -and $_.Data.Message.Length -gt 1) { Process-Message -Data $_.Data -Sender $_.Data.Nick } } function OnChannelMessage_ProcessCommands { # If it's not prefixed, then we don't process it, because it's not a command if($_.Data.Message[0] -eq $Prefix -and $_.Data.Message.Length -gt 1) { Process-Message -Data $_.Data -Sender $_.Data.Channel } } $Prefix = Get-Setting CommandPrefix function Process-Message { param($Data, $Sender) Write-Verbose ("Message: " + $Data.Message) $ScriptString = $Data.Message.SubString(1) $global:Channel = $Data.Channel $global:From = $Data.From $global:Hostname = $Data.Host $global:Ident = $Data.Ident $global:Message = $Data.Message $global:Nick = $Data.Nick # The default role for users with no roles set is Guest # EVERYONE gets the Guest role, no matter what $global:Roles = @("Guest") if(Microsoft.PowerShell.Core\Get-Command Get-Role) { $global:Roles = @(Get-Role -Nick $global:Nick) | Select -Unique } else { # If there's no Access Control module loaded, then we also allow everyone the "User" role # Because that's where most of the commands are ... $global:Roles = @("User") } $ReAuthorize = $False do { # ReAuthorize is true if we think we might have gotten the default response back $ReAuthorize = !$ReAuthorize -and ($global:Roles.Length -eq 1 -and $global:Roles[0] -eq "Guest") -and (Microsoft.PowerShell.Core\Get-Command Get-Role) Write-Host "Lookup Roles: ${global:Roles} (ReAuthorize: ${ReAuthorize})" -Fore Magenta # Figure out which modules the user is allowed to use. # Everyone gets access to the "Guest" commands $AllowedModule = @( "PowerBotGuestCommands" # They may get other roles ... foreach($Role in $global:Roles) { "PowerBot${Role}Commands" } # Hack to allow recognizing the owner purely by hostmask if($From -eq $irc.BotOwner) { "PowerBotOwnerCommands" } ) | Select-Object -Unique Write-Verbose "Protect-Script -Script $ScriptString -AllowedModule ''$($AllowedModule -join '','')'' -AllowedVariable $($InternalVariables -join ', ') -WarningVariable warnings" Write-Host "AllowedModules: ${AllowedModule}" $AllowedCommands = (Get-Module $AllowedModule).ExportedCommands.Values | % { $_.ModuleName + '\' + $_.Name } $Script = Protect-Script -Script $ScriptString -AllowedModule $AllowedModule -AllowedVariable $InternalVariables -WarningVariable warnings # If the script was invalid and they were authenticated as "Guest" let's check their authentication again # This second check allows the UserTracking module to test authentication asynchronously if necessary # If we still get back Guest, then they're probably really a guest if(!$Script -and $ReAuthorize) { $global:Roles = @(Get-Role -Nick $global:Nick) | Select -Unique $ReAuthorize = ($global:Roles.Length -gt 1 -or $global:Roles[0] -ne "Guest") } Write-Host "Protection Roles: ${global:Roles} (ReAuthorize: ${ReAuthorize}) ${Script}" -Fore Green } while($ReAuthorize) if(!$Script) { if($Warnings) { Send-Message -Type Message -To "#PowerBot" -Message "WARNING [${Channel}:${Nick}]: $($warnings -join ' | ')" # Send-Message -Type Notice -To $Data.Nick -Message "I think you're trying to trick me into doing something I don't want to do. Please stop, or I'll scream. $($warnings -join ' | ')" } return } $local:MaxLength = 497 - $Sender.Length - $irc.Who.Mask.Length if($Script) { Write-Verbose "SCRIPT: $Script" try { Invoke-Expression $Script | Format-Csv -Width $MaxLength | Select-Object -First 8 | # Hard limit to number of messages no matter what. Send-Message -To $Sender } catch { Send-Message -To "#PowerBot" -Message "ERROR [${Channel}:${Nick}]: $_" Write-Warning "EXCEPTION IN COMMAND ($Script): $_" } } Remove-Item Variable:Global:Channel Remove-Item Variable:Global:From Remove-Item Variable:Global:Hostname Remove-Item Variable:Global:Ident Remove-Item Variable:Global:Message Remove-Item Variable:Global:Nick } Add-Type @" using System; using System.Management.Automation; using System.Collections.Generic; [AttributeUsage(AttributeTargets.Method)] public class PowerBotHookAttribute : Attribute { // The event(s) this method handles public string Event { get; set; } } "@ # A NOTE ABOUT MESSAGE LENGTH: #IRC max length is 512, minus the CR LF and other headers ... # In practice, it looks like this: # :Nick!Ident@Host PRIVMSG #Powershell :Your Message Here ###### The part that never changes is the 512-2 (for the \r\n) ###### And the "PRIVMSG" and extra spaces and colons # So that inflexible part of the header is: # 1 = ":".Length # 9 = " PRIVMSG ".Length # 2 = " :".Length # So therefore our hard-coded magic number is: # 498 = 510 - 12 # (I take an extra one off for good luck: 510 - 13) # In a real world example with my host mask and "Shelly" as the nick and user id: # Host : geoshell/dev/Jaykul # Ident : ~Shelly # Nick : Shelly # We calculate the mask in our OnWho: # Mask : Shelly!~Shelly@geoshell/dev/Jaykul # So if the "$Sender" is "#PowerShell" our header is: # 57 = ":Shelly!~Shelly@geoshell/dev/Jaykul PRIVMSG #Powershell :".Length # As we said before/, 12 is constant # 12 = ":" + " PRIVMSG " + " :" # And our Who.Mask ends up as: # 34 = "Shelly!~Shelly@geoshell/dev/Jaykul".Length # And our Sender.Length is: # 11 = "#Powershell".Length # The resulting MaxLength would be # 452 = 497 - 11 - 34 # Which is one less than the real MaxLength: # 453 = 512 - 2 - 57 |