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:
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.
- Import-Module prompts for login info
- New-Tweet is used to send tweets
- Get-Tweet is used to retrieve cached tweets
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 *


