Posts Tagged ‘Twitter’

Some of you may realize I’m a bit of an odd personality: when I get an idea, I don’t want to let it go until I at least prove it’s possible … but then I have a tendency to abandon things or leave them for others to finish. Having said all that: here’s my latest one-off script module. I’m probably done playing with it, and it’s definitely not a finished, release-quality project … but it works, it does something neat, and maybe you could learn something from it.

Dave Winer was blogging about wanting a web-based command-line app for twitter, and although I’m not a big fan of web apps, it did make me thing about how it would be interesting to have a command-line twitter application…

So, I grabbed a TwitterLib.dll from Witty, which in turn depends on TweetSharp … stuffed all of those into a new folder “Twitter” inside my Documents\WindowsPowerShell\Modules, and started experimenting in the console:

[Reflection.Assembly]::LoadFrom( (resolve-path ".\TwitterLib.dll") )
Get-Constructor TwitterLib.TwitterNet
# I see a constructor that takes a string user name and a SecureString password, so I'll need:
$twitterCred  = Get-Credential
$twitter = New-Object TwitterLib.TwitterNet $twitterCred.UserName.TrimStart("\"), $twitterCred.Password
$twitter | Get-Member
# There's quite a few neat things there, let's try:
$twitter.GetFriendsTimeline()
 

Incidentally, I’m using Get-Constructor (it’s on PoshCode) to enumerate the constructors, and the output of that GetFriendsTimeline method was really verbose. Way more information than I wanted, but, it did have the information I needed.

Now, what I wanted was to have the most recent tweets show up as part of my prompt, but I couldn’t be waiting around while it fetched it every time, so the next thing I did was write a little Start-Job script to run that stuff in a background thread, where I could just Receive-Job to get the data when I was ready for it. I also had to implement something to make sure I wasn’t going to get the same tweets over and over again (multiple calls to GetFriendsTimeline() return the most recent 20 tweets or so, without regard for whether you’ve seen them or not):


Start-Job -Name "Twitter" {
   Param($twitterCred, $assemblyPath)
   [Reflection.Assembly]::LoadFrom( $assemblyPath )
   $twitter = New-Object TwitterLib.TwitterNet $twitterCred.UserName.TrimStart("\"), $twitterCred.Password
   while($true) {
      $twitter.GetFriendsTimeline() | tee -var cache |
          where { !$cache -or $_.id -gt $cache[0].Id } |
          ft id, @{n="ScreenName";e={$_.User.ScreenName}}, text -wrap -auto | out-string
          sleep 45 # or else you start getting rejected
   }
} -Arg (Get-Credential), (resolve-path ".\TwitterLib.dll")
 

With that done, I can easily fetch all the tweets since the last time I fetched them by just calling Receive-Job Twitter. The one thing you’ll notice in that is that I had to add a Hash in the Format-Table to get the “ScreenName” property to equal the users actual ScreenName. The reason for that is that the TwitterLib.TwitterUser class isn’t properly serializable — so when I tried doing the Format-Table stuff on the receiving end, I was getting “TwitterLib.TwitterUser” as the value for the User property when I did Receive-Job later. To fix it, I extracted the information I wanted into a string property while the class was still in the “remote” runspace —thus it would serialize easier — at first I did it just the way it’s written there, but eventually I realized I should still return the actual object, so I changed it to use Add-Member.

In the end, the module I’ll release is somewhat more complete: it includes a few functions for tweeting and replying and following and even unfollowing, and I wrote a Format file to hide the extra data, and added my own URL un-shortener to resolve long URLs for display. If I was going to use this full time, I would need to get some support for creating a search that would be permanent too (ie: so I could pull in anything “public” about PowerShell — I think I’d probably try using the new Bing twitter interface for that.

If anyone wants to play with it, I’ve put it up here for download [new: 11/9/2009], and you’re welcome to use it however you like. I actually tweaked the source code to the Witty TwittlerLib a bit (I’ll make that available shortly), adding methods for removing friends, and for getting a user by their username instead of by their ID. I also happened to notice, while I was mucking around in the source, there’s already support in TwitterLib and TweetSharp for several cool things:

  • Posting an image via a service using a System.IO.FileInfo (this is what PowerShell returns from Get-ChildItem (aka: dir or ls), so it’s trivial to implement).
  • OAuth from the desktop app (this is a little complicated, but since you can cache it, it would let you avoid asking for the password the way I do right now).
  • Search using Twitter’s API (I suspect Bing’s search is rather better, even without real-time AJAX results).

UPDATE [11/9/09]

Ok, I polished this up a little more while I was using it, and ended up with a pretty nice client — it uses Growl for Windows to pop up notices (if it’s available), and resolves shortened urls, and it now caches better, and has functions for searching/filtering that cache, and opening links from posts, etc. That is … it’s basically usable as a client now :) . The download includes those two dependent modules, but not Growl for Windows.

I also renamed it, after I finally remembered where I had heard PowerTwitter before. I’ve registered “PoshTweet” with twitter as an app name to make sure it was unique, so maybe I’ll add OAuth support just so it can show up property and advertise itself. :)

<# Starting with TwitterLib.dll from http://code.google.com/p/wittytwitter/
   ToDo:
   * Add persistent search support (via bing?) and a search command
   * Add Block commands
   * Consider using new TweetSharp lib when they release one not dependent on extension methods. TwitterLib is *really* rough.
#>

param( [System.Management.Automation.PSCredential]$twitterCred = (Get-Credential), [int]$interval = 60 )
 Set-StrictMode -Version Latest
# ((1 / $interval) + (2 / ($interval * 3))) * 3,600  ... must be less than 150
if((((1 / $interval) + (2 / ($interval * 3))) * 3600) -ge 150) {
   throw "Your interval is set too short, you should set it over 40"
}

$null = [Reflection.Assembly]::LoadFrom( "$PsScriptRoot\TwitterLib.dll" )
$global:twitter = New-Object TwitterLib.TwitterNet $twitterCred.UserName.TrimStart("\"), $twitterCred.Password
$global:twitter.ClientName = "PoshTweet"

Get-Job Twitter -EA 0| Stop-Job -Passthru | Remove-Job
Start-Job -Name "Twitter" {
   Param([System.Management.Automation.PSCredential]$Cred,$ScriptRoot, $interval)
   $null = [Reflection.Assembly]::LoadFrom( "$ScriptRoot\TwitterLib.dll" )

   $twitter = New-Object TwitterLib.TwitterNet $Cred.UserName.TrimStart("\"), $Cred.Password
   # $twitter.ClientName = "PoshTweet"

   ## This part depends on HttpRest. If it's not present, it just won't work...
   if(Get-Module -List HttpRest -EA 0) {
      Import-Module HttpRest

      [regex]$isgd   = "(?:https?://)?is.gd/([^?/ ]*)\b"
      [regex]$xrl    = "(?:https?://)?xrl.us/([^?/ ]*)\b"
      [regex]$snip   = "(?:https?://)?(?:snurl|snipr|snipurl)\.com/([^?/ ]*)\b"
      [regex]$twurl  = "(?:https?://)?twurl.nl/([^?/ ]*)\b"
      [regex]$tiny   = "(?:https?://)?tinyurl.com/([^?/ ]*)\b"
      [regex]$shrink = "(?:https?://)?shrinkster.com/([^?/ ]*)\b"
      [regex]$bitly  = "(?:https?://)?bit.ly/([^?/ ]*)\b"
      [regex]$trim   = "(?:https?://)?tr.im/([^?/ ]*)\b"


      function Replace-Matches {
      Param( [string]$string, $matches, [scriptblock]$getBlock )
         for($i = $matches.Count-1; $i -ge 0; $i--) {
            $string = $string.Remove($matches[$i].Index, $matches[$i].Length).Insert($matches[$i].Index, ($matches[$i].groups[1].value | % $getBlock ))
         }
         write-output $string
      }

      function Resolve-URL {
         Param([Parameter(ValueFromPipeline=$true)]$url)
         PROCESS {
            $old = $url
            $url = Replace-Matches $url $isgd.Matches($url)   {Invoke-Http GET ("http`://is.gd/{0}-" -f $_ )                           | Receive-Http TEXT "//*[@id='main']/*[local-name() = 'p']/*[local-name() = 'a']/@href" }
            $url = Replace-Matches $url $xrl.Matches($url)    {Invoke-Http GET "http`://metamark.net/api/rest/simple"  @{short_url=$_} | Receive-Http TEXT }
            $url = Replace-Matches $url $snip.Matches($url)   {Invoke-Http GET "http`://snipurl.com/resolveurl"        @{id=$_}        | Receive-Http TEXT }
            $url = Replace-Matches $url $twurl.Matches($url)  {Invoke-Http GET "http`://tweetburner.com/links/$_"                       | Receive-Http TEXT "//div[4]/p/a/@href" }
            $url = Replace-Matches $url $tiny.Matches($url)   {Invoke-Http GET "http`://tinyurl.com/preview.php"       @{num=$_}       | Receive-Http TEXT "//a[@id='redirecturl']/@href" }
            $url = Replace-Matches $url $shrink.Matches($url) {Invoke-Http GET "http`://shrinkster.com/Track.aspx"     @{AddressID=$_} | Receive-Http TEXT "//*[@id='tdOriginalURL']" }
            $url = Replace-Matches $url $trim.Matches($url)  {Invoke-Http GET "http`://api.tr.im/api/trim_destination.xml" @{trimpath=$_} | Receive-Http Text "//trim/destination" }
            # bitly's is horrid, 'cause it requires an apiKey, and returns invalid xml
            $url = Replace-Matches $url $bitly.Matches($url)  {Invoke-Http GET "http`://api.bit.ly/expand" @{version = "2.0.1"; login="jaykul"; apiKey="R_05c31e25dd38fb6113044336ae23a441"; format="xml"; shortUrl=$_ } | Receive-Http Text |% { $_ -replace ".*longUrl\>(.*)\</longUrl.*",'' }}
           
            Write-Output $url
         }
      }
   } else {
      filter Resolve-URL { $_ }
   }
   ## And this, depends on Growl. If it's not working, we just won't do it...
   if(Get-Module -List Growl -EA 0) {
      Import-Module Growl
      Register-GrowlType PoshTweet NewTweet -AppIcon "$ScriptRoot\PoshTweetSq.png" -Icon "$ScriptRoot\PoshTweetSq.png"
   }
   $urlpat = [regex]"(?:http:|ftp:)//[^ ]+"
   
   function PostProcess {
      PARAM(
         [Parameter(ValueFromPipeline=$true, Position=100)]$status
,
         [Parameter(Position=0)][ref]$LastId
      )
      PROCESS {
         $status = Add-Member NoteProperty Tweet -Value $( Resolve-URL $status.Text ) -Input $status -Passthru
         if($LastId.Value -lt $status.id) { $LastId.Value = $status.id }
         if(Get-Module Growl -EA 0) {
            if( $status.TimeLine -eq "DirectMessages" ) {
               $url = $urlpat.Matches($status.Tweet) | Select -First 1 -Expand Value
               if(!$url) { $url = "http://twitter.com/inbox" }
            } else {
               $url = "http://twitter.com/{0}/status/{1}" -f $status.ScreenName, $status.Id
            }
           
            Send-Growl PoshTweet NewTweet $status.FullName $status.Tweet -Url $url
         }
         Write-Output $status
      }
   }
   
   ## Initialize the "last" values so we don't get spammed at the begining
   $LastTweet = $twitter.RetrieveTimeline( "Friends", 0, 5, [String]::Empty ) | Select -Last 1 -Expand Id
   $LastReply = $twitter.RetrieveTimeline( "Replies", 0, 2, [String]::Empty ) | Select -Last 1 -Expand Id
   $LastDm    = $twitter.GetDirectMessages( 0, 1) | Select -Last 1 -Expand Id
   
   $i = 0
   while($true) {
      # initially, shows all tweets, then, only new ones
      $twitter.GetFriendsTimeline( $lastTweet ) | PostProcess ([ref]$LastTweet)

      # check for these two only every 3rd time
      if(!($i++ % 3)) {
         # initially shows nothing, then new dms
         $twitter.GetDirectMessages( $lastDm ).ToTweetCollection() | PostProcess ([ref]$lastDm)
         $twitter.GetReplies( $lastReply ) | PostProcess ([ref]$lastReply)
      }
      sleep $interval # Twitter allows 150 updates per hour
   }
} -Arg $twitterCred, $PSScriptRoot, $interval

if(!(Test-Path Variable:TwitterOriginalPrompt -EA 0)){
   $Global:TwitterOriginalPrompt = ${Function:Prompt}
}

$global:LastTweets = new-object System.Collections.Generic.List[PSObject]
function Global:Prompt {
   [Array]$newTweets = rcjb twitter
   if( $newTweets ) {
      $index = $global:LastTweets.Count
      ForEach($tweet in $newTweets[$($newTweets.Count-1)..0]) {
         $tweet.Index = $index++
         $global:LastTweets.Add( $tweet )
      }
      $i = 0
      foreach($tweet in $newTweets | Sort-Object DateCreated | Out-String -Stream) {
         $i += [int]($tweet[0] -ne " ");
         write-host $tweet -Fore $( switch($i % 2) { 0 {"DarkGray"} default { "Gray"} } )
      }
   }
   return &$TwitterOriginalPrompt @args
}

function Get-Tweet {
   [CmdletBinding(DefaultParameterSetName="WhereTweet")]
PARAM(
   [Parameter(Mandatory=$false,ParameterSetName="Search",Position=1)]
   [String[]]$ByUser
,
   [Parameter(Mandatory=$false,ParameterSetName="Search",Position=0)]
   [String[]]$WithText
,
   [Parameter(Mandatory=$false,ParameterSetName="Search")]
   [Int[]]$Index
,
   [Parameter(Mandatory=$false,ParameterSetName="WhereTweet")]
   [ScriptBlock]$Where={$true}
,
   [Switch]$First,
   [Switch]$Last
)
BEGIN {
   if($PSCmdlet.ParameterSetName -eq "Search") {
      [string[]]$clauses = @()
      if($ByUser)      {  $clauses += '$ByUser -contains $_.ScreenName'        }
      if($WithText)    {  $clauses += '$_.Tweet -match ($WithText -join "|")'  }
      if($Index) {  $clauses += '$Index -contains $_.Index'  }
      $Where = iex "{ $($clauses -join ' -and ' ) }"
   }
   [ScriptBlock]$RealPredicate = { $args | Where $Where }
}  
Process {
   if($First) {
      $LastTweets.FindFirst( $RealPredicate )
   } elseif( $Last) {
      $LastTweets.FindLast( $RealPredicate )
   } else {
      $LastTweets.FindAll( $RealPredicate )
   }
}
}

function Start-Url {
[CmdletBinding(SupportsShouldProcess=$true)]
Param([Parameter(Mandatory=$true,ValueFromPipeline=$true)]$msg)
PROCESS{
   ForEach($link in Select-Url $msg.Tweet){
      if($PSCmdlet.ShouldProcess($link)) {
         Start-Process $link -Confirm:$False > $null
      }
   }
}}

$urlpat = [regex]"(?:http:|ftp:)//[^ ]+"
function Select-Url {
Param( [Parameter(Mandatory=$true,ValueFromPipeline=$true)][String]$msg)
PROCESS{ $urlpat.Matches( $msg ) | Select -Expand Value }
}




function New-Reply {
Param([int]$index)
   $OFS = " "
   $source = Get-Tweet -Index $index
   $tweet = "$args"
   if($tweet -notmatch $source.ScreenName) {
      $tweet = "@$($source.ScreenName) $tweet"
   }
   $global:twitter.AddTweet( $tweet, $source.Id )
}

function New-Tweet {
   $OFS = " "
   $global:twitter.AddTweet( "$args" )
}

function Add-Friend {
Param([string[]]$userName)
   foreach($user in $userName) {
      $global:twitter.FollowUser( $user )
   }
}

function Remove-Friend {
Param([string[]]$userName)
   foreach($user in $userName) {
      $global:twitter.UnfollowUser( $user )
   }
}
function Get-Friend {
Param([string[]]$userName)
   if(!$userName -or $userNaem.Count -eq 0) {
      $global:twitter.GetFriends()
   } else {
      foreach($user in $userName) {
         $global:twitter.GetUser($user)
      }
   }
}
function Get-FriendTweet {
Param([string[]]$userName)
   foreach($user in $userName) {
      $global:twitter.GetUserTimeline($user)
   }
}


#.Synopsis
#  Calculates a relative text version of a duration
#.Description
#  Generates a string approximation of a timespan, like "x minutes" or "x days." Note this method does not add "about" to the front, nor "ago" to the end unless you pass them in.
#.Parameter Span
#  A TimeSpan to convert to a string
#.Parameter Before
#  A DateTime representing the start of a timespan.
#.Parameter After
#  A DateTime representing the end of a timespan.
#.Parameter Prefix
#  The prefix string, pass "about" to render: "about 4 minutes"
#.Parameter Postfix
#  The postfix string, like "ago" to render: "about 4 minutes ago"
function ConvertTo-RelativeTimeString {
[CmdletBinding(DefaultParameterSetName="TwoDates")]
PARAM(
   [Parameter(ParameterSetName="TimeSpan",Mandatory=$true)]
   [TimeSpan]$span
,
   [Parameter(ParameterSetName="TwoDates",Mandatory=$true,ValueFromPipeline=$true)]
   [Alias("DateCreated")]
   [DateTime]$before
,
   [Parameter(ParameterSetName="TwoDates", Mandatory=$true, Position=0)]
   [DateTime]$after
,
   [Parameter(Position=1)]
   [String]$prefix = ""
,
   [Parameter(Position=2)]
   [String]$postfix = ""
 
)
PROCESS {
   if($PSCmdlet.ParameterSetName -eq "TwoDates") {
      $span = $after - $before
   }
   
   "$(
   switch($span.TotalSeconds) {
      {$_ -le 1}      { "
$prefix a second $postfix "; break }    
      {$_ -le 60}     { "
$prefix $($span.Seconds) seconds $postfix "; break }
      {$_ -le 120}    { "
$prefix a minute $postfix "; break }
      {$_ -le 2700}   { "
$prefix $($span.Minutes) minutes $postfix "; break } # 45 minutes or less
      {$_ -le 5400}   { "
$prefix an hour $postfix "; break } # 45 minutes to 1.5 hours
      {$_ -le 86400}  { "
$prefix $($span.Hours) hours $postfix "; break } # less than a day
      {$_ -le 172800} { "
$prefix 1 day $postfix "; break } # less than two days
      default         { "
$prefix $($span.Days) days $postfix "; break }
   }
   )"
.Trim()
}
}

Set-Alias tweet New-Tweet
Set-Alias follow Add-Friend
Set-Alias unfollow Remove-Friend

Set-Alias nt New-Tweet
Set-Alias nr New-Reply
Set-Alias gt Get-Tweet
Set-Alias gft Get-FriendTweet
Set-Alias af Add-Friend
Set-Alias rf Remove-Friend
Set-Alias sau Start-Url

Set-Alias Get-TwitterUser Get-Friend
Set-Alias Add-TwitterFriend Add-Friend
Set-Alias Remove-TwitterFriend Remove-Friend


Export-ModuleMember -Function New-Reply, New-Tweet, Add-Friend, Remove-Friend, Get-Friend, Resolve-URL, Get-Tweet, Get-FriendTweet, Start-Url, ConvertTo-RelativeTimeString -Alias *

So a lot of people seem to be taking the latest missteps by Twitter’s management (and the accompanying admission of bad design) as an opportunity to try out some alternatives. Many of them seem to be coming over to FriendFeed (which has been better than Twitter for a long time, but nevermind that) ... so I thought I’d update and release a PowerShell 2.0 script I wrote to create imaginary friends out of your friends that stay on Twitter.

The first part of it is a WatiN script (that automates your browser) called New-ImaginaryFriend which takes three parameters: a name for the imaginary friend, a url for an avatar for the friend, and a HashTable… Of course, we sort-of cheat by using the HashTable … it’s basically a bunch of key-value pairs of remote services and user names. You can use it to add twitter ID’s like twitter="jsnover" or blogs like blog="http://HuddledMasses.org/" etc. You can even add multiple sources (eg: twitter + diigo, two blogs, etc) to a single new imaginary friend 8)...

This script is done using WatiN because the FriendFeed API doesn’t support creating imaginary friends yet, and as a result it’s slow, and requires IE (and doesn’t seem to work very well with IE8 — at least, I couldn’t get it to set the avatars using IE 8 on Windows 7, so I commented out the avatar part of the next-to-last line).

The other part of the script is a pair of functions: the first is Get-FriendFeedFriends which retrieves profile information for all your friends in a slick format that includes all their services and such … you may find other uses for this later ;-) , the second is Get-TwitterFriends … Both have an -Exclude parameter so you can pass it a list of people to ignore.

When you put these three functions together, you can just import the FriendFeed module, and start creating friends (don’t forget this version of the scripts only works with IE6 or IE7 for the purpose of avatars, as WatiN can’t seem to set the file upload value in IE8 yet).


Import-Module FriendFeed
## Get any twitter friends who aren't on friendfeed
## Make sure you use FriendFeed's built in "add all your twitter friends" first
$twits = Get-TwitterFriends `
         -Nickname jaykul
         -Exclude $(Get-FriendFeedFriends jaykul | select -expand twitter)

## Add them to friend feed
foreach($twit in $twits) {
   New-ImaginaryFriend $twit.name @{twitter=$twit.screen_name} $twit.profile_image_url
}

You can download all of the required modules at once (7z), or grab the latest versions of them from PoshCode: FriendFeed, HttpRest, and WatiN … but if you do that, you’ll still need to get the binaries separately :-/

Reblog this post [with Zemanta]

Well, over the weekend I stole a few moments from thinking about PoshCode 2.0 to think about PoshCode 1.0 … and I added two things:

Feedback

I’ve replaced the misused “comment” box on the website with a full GetSatisfaction.com widget, so you can get your voice heard and get feedback (in the past it’s been impossible for me to respond to comments).

Twitter

PoshCode now posts new contributions to twitter on it’s own PoshCode account. Feel free to follow along. Of course, the information on there is severely truncated, by comparison with the PoshCode RSS feed … and only includes the link to the website, whereas the RSS feed also includes direct download links … but I figured some of you would appreciate it anyway, and I aim to please. :D

Of course, now in PoshCode 2 I’ll have to make sure we have users enter their twitter id’s in the profile page so we can be sure to cite you properly. Oh what a tangled web we weave…

There are a few programming projects that just have to be re-written in each and every programming language. You might think it’s a waste of time, but because they’re used primarily by programmers and geeks, and because using them usually involves heavily modifying them, everyone wants one that was written in the language (s)he’s most familiar with, and writing them can be a good learning experience. One such project is the IRC bot.

I wrote a bot in PowerShell awhile back that we called PowerBot … the script using XMPP (Jabber), and could connect to IRC through a gateway. Although it could do the thing a bot should be able to do (join channels, respond to messages, etc), and had bonus features like announcing new posts from PoshCode.org, and following channel members on Twitter … it was technically a Jabber bot, not an IRC bot (and thus, a little slow to respond on IRC).

In any case, I’ve created a very simple framework for people to write PowerShell IRC decided to rectify that now by publishing a new bot based on the SmartIrc4Net library. Of course, it’s not a “pure” PowerShell bot (since I’m using an IRC library), but this is .Net after all, and we don’t really believe in rewriting things that already work just fine. SmartIrc4net is a very nice bit of work, and it’s a single dll you just have to plop in a folder with the script, and it’s LGPL (2.1) so you can reuse it at will with very few restrictions. :)

The Script, Explained

The full script is available on PoshCode but I wanted to go through and explain a few pieces of it, so I’ll break it up in pieces here. Incidentally, if you run this command on that version of the script, you’ll see there are exactly 39 lines of code, including loading the irc library, and the initial Hello World command.


gc PowerBot.ps1 |?{$_ -notmatch "^\s*#" -and $_.trim().length} | measure-object -line -word

The first section of the script loads the assembly and contains the Start-PowerBot script. I’ve wrapped things onto multiple lines here to fit in my blog a bit better and for readability:


## Add-Type -path $ProfileDir\Libraries\Meebey.SmartIrc4net.dll
$null = [Reflection.Assembly]::LoadFrom("$ProfileDir\Libraries\Meebey.SmartIrc4net.dll")

function Start-PowerBot {
PARAM(
  $server = "irc.freenode.net"
, [string[]]$channels = @("#PowerShell")
, [string[]]$nick     = @(Read-Host 'You must provide a nickname')
, [string]$password
, $realname           = "PowerShell Bot"
, $port               = 6667
)
   
   if(!$global:irc) {
      $global:irc = New-Object Meebey.SmartIrc4net.IrcClient
      $irc.ActiveChannelSyncing = $true # $irc will track channels for us
      # $irc.Encoding = [Text.Encoding]::UTF8
      $irc.Add_OnError( {Write-Error $_.ErrorMessage} )
      $irc.Add_OnQueryMessage( {PrivateMessage} )
      $irc.Add_OnChannelMessage( {ChannelMessage} )
   }
   
   $irc.Connect($server, $port)
   $irc.Login($nick, $realname, 0, $nick, $password)
   ## $channels | % { $irc.RfcJoin( $_ ) }
   foreach($channel in $channels) { $irc.RfcJoin( $channel ) }
   Resume-PowerBot # Shortcut so starting this thing up only takes one command
}

The most important thing to notice here is that the SmartIrc4Net library is event driven, so you just have to Add scriptblock event handlers to things, and it will call them when something happens. You can see I’ve handled the error condition directly in the scriptblock, but for the other handlers I’ve called out functions which I will specify later.

In this simple bot I’ve only handled errors, private messages (a QueryMessage is a message sent directly to the bot, instead of to the channel), and channel messages. The “channel” is where you all hang out and chat — you young’uns may know this as a “chat room” ... but on IRC we still like to pretend we’re on HAM radio, so we have channels and go by weird aliases like “Gaurhoth” and “Gnopeg” and “SmellyHippy” and consider it impolite to ask what people’s real names are (we’ll probably kick-ban you if you ask “A/S/L?”).

You do NOT need a password for the $irc.login call, but if you have registered your bot’s nick (which I highly recommend if your IRC network supports it), then you should pass it’s password to the function. Finally, the Resume-Powerbot call at the end is a call to our simplest function. This is essentially the “message loop” of our program:


## Note that PowerBot stops listening if you press a key ...
## You'll have to re-run Resume-Powerbot to get him to listen again
function Resume-PowerBot {
   while(!$Host.UI.RawUI.KeyAvailable) { $irc.ListenOnce($false) }
}

function Stop-PowerBot ($msg="If people listened to themselves more often, they would talk less."){
   $irc.RfcQuit($msg)
   $irc.Disconnect()
}

Resume-PowerBot is the simplest possible loop: it will exit if you press any key while the PowerShell window has focus. You might want to beef up the loop, and add the ability to type while the script is running (see my demo script). Putting that together with this would let you implement a simple interactive PowerShell IRC client … which is scriptable to the Nth degree.

When I call ListenOnce($false) I pass the optional boolean parameter $false to specify that I don’t want ListenOnce to block — otherwise, ListenOnce() will block until it actually receives a message: it’s basically like the difference between writing if($Host.UI.RawUI.KeyAvailable){$Host.UI.RawUI.ReadKey()} and just writing $Host.UI.RawUI.ReadKey()

Stop-PowerBot actually sends a quite message and then disconnects from IRC, and if you call it, you’ll need to call Start-PowerBot before you can actually use the $irc variable again…

Event handlers

Event handlers in PowerShell are really very frustrating. Instead of just passing the arguments to the event handler, thus allowing your event handler to match “any” event signature, PowerShell takes exactly two arguments to it’s event handlers: the first is the $this parameter which represents what is triggering the event, and the second is the $_ parameter which represents the EventArgs. In any case, although that is sometimes limiting when developer use non-standard events, it does represent the usual CLR-compliant event signature, and luckily, most of the events in SmartIrc4net match it.

The two handlers I’ve written in this demo handle private (Query) messages and public channel messages. They are virtually identical, except that they send the return message differently :)


function PrivateMessage {
   $Data = $_.Data
   # Write-Verbose $($Data | Out-String)
   
   $command, $params = $Data.MessageArray
   if($PowerBotCommands.ContainsKey($command)) {
      &amp;$PowerBotCommands[$command] $params $Data |
         Out-String -width (510 - $Data.From.Length - $nick.Length - 3) |
            % { $_.Trim().Split("`n") | %{ $irc.SendMessage("Message", $Data.From, $_.Trim() ) }}
   }
}

function ChannelMessage {
   $Data = $_.Data
   # Write-Verbose $($Data | Out-String)
   
   $command, $params = $Data.MessageArray
   if($PowerBotCommands.ContainsKey($command)) {
      &amp;$PowerBotCommands[$command] $params $Data |
         Out-String -width (510 - $Data.Channel.Length - $nick.Length - 3) |
            % { $_.Trim().Split("`n") | %{ $irc.SendMessage("Message", $Data.Channel, $_.Trim() ) }}
   }
}

Finally, the actual commands for this bot are stored as a hash. You “register” a command by adding a key to the hash, where the key is a single word that matches the hash (it has to be the first word in the message) and the value is a scriptblock that will be executed. Generally, you just need to return a string (or an object) that you want to reply with, but you can, obviously, take any action you want.

Unlike the event handlers, the powerbot commands receive parameters: the first is a convenience parameter: it is the rest of the message that came after the keyword. The second parameter is the full “Data” member from SmartIrc4Net, and includes all the text that was in the message. Although the first parameter is a little redundant, most commands only need to handle the first parameter, so I’m keeping it there.


$PowerBotCommands=@{}

## A simple Hello World -like command to get you started:
$PowerBotCommands."Hello" = {Param($Query,$Data)
   "Hello, $($Data.Nick)!"
}

## A scary feature that lets you use the bot as a mannequin:
$PowerBotCommands."!Echo" = {Param($Query,$Data)
   "$Query"
}

## Executing PowerShell cmdlets in response to a spoken command:
$PowerBotCommands."!Get-Help" = {Param($Query)
   $help = get-help $Query | Select Name,Synopsis,Syntax
   if($?) {
      if($help -is [array]) {
         "You're going to need to be more specific, I know all about $((($help | % { $_.Name })[0..($help.Length-2)] -join ', ') + ' and even ' + $help[-1].Name)"
      } else {
         @($help.Synopsis,($help.Syntax | Out-String -width 1000).Trim().Split("`n",4,"RemoveEmptyEntries")[0..3])
      }
   } else {
      "I couldn't find the help file for '$Query', sorry.  I probably don't have that snapin loaded."
   }
}
Reblog this post [with Zemanta]
Search My Content