Skip to content

Instantly share code, notes, and snippets.

@japj
Forked from thejh/bot.coffee
Created August 19, 2011 20:23
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save japj/1157912 to your computer and use it in GitHub Desktop.
Save japj/1157912 to your computer and use it in GitHub Desktop.
My IRC bot, jhbot
coffee = require 'coffee-script'
https = require 'https'
npm = require 'npm'
Irc = require 'irc-js'
cradle = require 'cradle'
{GitHubApi} = require 'github'
request = require 'request'
gitHubApi = new GitHubApi()
githubIssueApi = gitHubApi.getIssueApi()
githubObjectApi = gitHubApi.getObjectApi()
githubCommitApi = gitHubApi.getCommitApi()
Search = require './search'
BASIC_AUTH_DATA = "Basic #{new Buffer('jhbot:XXXXXXXXXXX').toString 'base64'}"
BOTSAFE = /[a-zA-Z0-9]/
NICKNAME_REGEX = /^[a-zA-Z0-9_][.a-zA-Z0-9_+-]+$/
npmData = {}
lastNpmUpdate = 0
lastNpmUpdateLocaltime = 0
npmFetching = null
updateNpm = (cb) ->
return cb(npmData) if (new Date().getTime() - lastNpmUpdateLocaltime) < 1000*60*5
return npmFetching.push cb if npmFetching?
npmFetching = [cb]
request {
uri: "http://registry.npmjs.org/-/all/since?startkey=#{lastNpmUpdate}"
}, (error, response, body) ->
npmData[key] = value for key, value of JSON.parse body
lastNpmUpdate = Date.parse response.headers.date
lastNpmUpdateLocaltime = new Date().getTime()
callback(npmData) for callback in npmFetching
npmFetching = null
github =
# description: string
# public: boolean
# files: {string: content: string}
postGist: (description, public, files, callback) ->
request {
uri: 'https://api.github.com/gists'
method: 'POST'
headers:
'Authorization': BASIC_AUTH_DATA
body: JSON.stringify {description, public, files}
}, (error, response, body) ->
if error
return callback error
try
responseJson = JSON.parse body.toString()
catch e
return callback e
return callback "didn't get a gist back" if not responseJson.id?
callback null, responseJson.id
getGist: (id, callback) ->
if not id? or not /^[0-9a-f]+$/.exec id
return callback 'invalid id'
request {
uri: "https://api.github.com/gists/#{id}"
}, (error, response, body) ->
if error
return callback error
try
responseJson = JSON.parse body.toString()
catch e
return callback e
if not responseJson.id?
console.log body.toString()
return callback "didn't get a gist back"
callback null, responseJson
database = new (cradle.Connection)(
'https://thejh.cloudant.com'
443
auth:
username: 'thejh'
password: 'XXXXXXXXXX'
).database 'ircbot'
npmDatabase = new (cradle.Connection)(
''
80
).database 'registry'
MYNICK = 'jhbot'
npmLoaded = false
npm.load {}, (err) ->
throw err if err?
npmLoaded = true
irc = new Irc {
server: 'irc.freenode.net'
nick: MYNICK
flood_protection: true
user: {
username: 'jhbot'
realname: 'TheJHs Bot'
}
}
process.on 'uncaughtException', (err) ->
console.log err.stack||err
try
irc.privmsg 'thejh', "EXCEPTION: "+err
catch e
_cachedIssueList = null
_cachedIssueListUpdated = 0
lastCsGist = null
min = (a, b) -> if a<b then a else b
max = (a, b) -> if a>b then a else b
contains = (arr, el) -> -1 isnt arr.indexOf el
arrayMax = (arr) ->
n = -1/0
n = val for val in arr when val > n
n
zeropad = (num, maxnum) ->
padlen = (maxnum+"").length - (num+"").length
("0" for [0...padlen]).join('') + num
spacepadEnd = (str, paddedLength) ->
padlen = paddedLength - str.length
str + (" " for [0...padlen]).join('')
getIssueList = (cb) ->
# time in ms
time = new Date().getTime()
# assume five minutes fresh cache
if _cachedIssueList? and time-_cachedIssueListUpdated < 1000*60*5
return cb null, _cachedIssueList
githubIssueApi.getList 'joyent', 'node', 'open', (err, issues) ->
unless err?
_cachedIssueList = issues
_cachedIssueListUpdated = time
console.log "fetched issue list in #{new Date().getTime()-time}ms"
cb err, issues
commands =
remember: (message, [name, value...], reply) ->
if value.length is 0
return reply "you need to specify name and definition", error: true
value = value.join ' '
unless BOTSAFE.exec value[0]
return reply "that value starts with a non-alphanumeric character, I don't want to store bot commands", error: true
docid = "definitions:#{name}"
database.get docid, (err, oldData) ->
savedCb = (err, doc) ->
reply if err?
console.error err
"something went wrong"
else
"saved definition of '#{name}'"
if err?
database.save docid, {data: value}, savedCb
else
database.save docid, oldData._rev, {data: value}, savedCb
git:
context: (message, [project, file, line], reply) ->
SAFE_STRING_REGEX = /^[a-zA-Z0-9_-]+$/
if not line?
return reply "you must specify project, file and line", error: true
if not /^[0-9]+$/.exec line
return reply "line must be numeric", error: true
# we start from 0, humans start from 1
line -= 1
[user, project] = project.split '/'
if not SAFE_STRING_REGEX.exec user
return reply "that user name looks weird", error: true
if not SAFE_STRING_REGEX.exec project
return reply "that project name looks weird", error: true
githubCommitApi.getFileCommits user, project, 'master', file, (err, commits) ->
if err? or commits.length is 0
return reply "error, getFileCommits() failed, are you sure that the data is correct?", error: true
githubObjectApi.showBlob user, project, commits[0].tree, file, (err, fileData) ->
if err?
return reply "error, showBlob() failed", error: true
fileData = fileData.data
fileLines = fileData.split '\n'
if line >= fileLines.length
return reply "that file only has #{fileLines.length} lines", error: true
console.log "base line #{line}"
fromLine = max 0, line-1
toLine = min fileLines.length-1, line+1
console.log "extracting lines #{fromLine}...#{toLine}"
showedLines = for lineData, i in fileLines.slice fromLine, toLine+1
"#{zeropad(i+fromLine+1, toLine+1)} #{lineData}"
console.log "ME HAZ TEH BLOB! #{showedLines.length} shown (out of #{fileLines.length})"
for lineData in showedLines
reply lineData
issue:
search: (message, keywords, reply) ->
MAX_ISSUES_COUNT = 3
if keywords.length is 0
return reply "please specify at least one keyword", error: true
keywords = (keyword.toLowerCase() for keyword in keywords)
getIssueList (err, issues) ->
if err?
return reply "something went wrong", error: true
foundIssues = for issue in issues
continue unless (do ->
issueTitle = issue.title.toLowerCase()
for keyword in keywords
if -1 is issueTitle.indexOf keyword
return false
true
)
issue
secondHitIssues = for issue in issues
continue if -1 isnt foundIssues.indexOf issue
issuestr = (issue.title + " " + issue.body).toLowerCase()
continue unless (do ->
for keyword in keywords
if -1 is issuestr.indexOf keyword
return false
true
)
issue
foundIssues = foundIssues.concat secondHitIssues
foundIssueCount = foundIssues.length
foundIssues = foundIssues.slice 0, MAX_ISSUES_COUNT
if foundIssues.length is 0
return reply "no issues found"
reply "found issues: #{foundIssueCount}#{[if foundIssueCount > MAX_ISSUES_COUNT then ", showing the first #{MAX_ISSUES_COUNT}"]}"
for {number, title} in foundIssues
reply "Issue: ##{number}: #{title}"
return
mem: (message, [name, substitutions...], reply) ->
if not name?
return reply "you need to specify a name", error: true
database.get "definitions:#{name}", (err, doc) ->
if err?
reply "i don't know what a #{name} is", error: true
else
data = doc.data
if substitutions?
for subst in substitutions
data = data.replace '$', subst
reply data
coffee:
compile: (message, code, reply) ->
code = code.join " "
try
compiled = coffee.compile code, bare: true
compiled = compiled.replace /\n/g, ' '
compiled = compiled.replace /\s+/g, ' '
reply compiled
catch e
reply 'compile() failed', error: true
compilegist: (message, [gistid], reply) ->
if not gistid?
if lastCsGist? and message.params[0] is '#coffeescript'
gistid = lastCsGist
else
return reply "please specify a gist by id or url", error: true
gistid = gistid.split('/').pop()
github.getGist gistid, (err, gist) ->
if err?
console.log err.stack or err
return reply "couldn't fetch the gist", error: true
coffeeFiles = {}
for filename, {content} of gist.files
coffeeFiles[filename] = content: try
coffee.compile content, bare: true
catch compileErr
if compileErr.stack?
compileErr.stack
else
compileErr+""
github.postGist "COMPILED"+gist.description, gist.public, coffeeFiles, (err, id) ->
if err?
console.log err.stack or err
return reply "couldn't publish the gist", error: true
reply "https://gist.github.com/#{id}"
admin:
join: (message, [channel], reply) ->
if isChannel channel
irc.join channel
reply 'ok'
say: (message, [target, what...]) ->
irc.privmsg target, what.join ' '
testAccountLookup: (message, [nick], reply) ->
getNicksAccount nick, (account) ->
reply "account name of #{nick} is #{account}"
npm:
owner: (message, [package], reply) ->
if not package?
return reply "package name missing", error: true
npm.commands.owner ['ls', package], (err, owners) ->
if err?
reply "error", error: true
else
if not owners?.length
msg = "admin party!"
else
msg = "owners: " + ("#{o.name} <#{o.email}>" for o in owners).join ', '
reply msg
search: (message, keywords, reply) ->
NAMESLIMIT = 20
if keywords.length == 0
return reply "you must specify at least one keyword", error: true
updateNpm (npmData) ->
try
search = new Search keywords.join(' '), (results) ->
return reply "no results" if results.length is 0
if results.length > NAMESLIMIT
truncated = true
results = results.slice 0, NAMESLIMIT
if results.length > 5 and isChannel message.params[0]
reply "packages (short format#{[if truncated then ', truncated']}): #{results.join ', '}"
else
reply "truncated list:" if truncated
for result in results
reply "package #{result}: #{npmData[result].description or '<no description>'}"
return
for keyword in search.keywords
ids = for id, entry of npmData
continue unless (
entry.keywords? and contains entry.keywords, keyword
) or (
contains entry.name, keyword
) or (
entry.description? and contains entry.description, keyword
)
id
search.provideKeywordData keyword, ids
#do (keyword) ->
# npmDatabase.view 'app/search', startkey: keyword, endkey: keyword+'ZZZZZZZZ', (err, rows) ->
# if err?
# console.log err.stack||err
# return reply "internal error", error: true
# for row in rows
# searchResultModules[row.id] = row
# search.provideKeywordData keyword, (id for {id} in rows)
catch err
if err.stack?
console.log err.stack
return reply "internal error", error: true
else
return reply "error: #{err}", error: true
help: (message) ->
syntaxes =
"remember": "<keyword> <string>"
"git context": "<user>/<repo> <file> <line>"
"mem": "<keyword> [<placeholderReplacement1> [...]]"
"coffee compile": "<code>"
"coffee compilegist": "<url or id>"
"admin join": "<channel>"
"admin say": "<target> <what>"
"npm owner": "<project>"
descriptions =
"npm search": """
search for stuff on npm. you can use '&', '|', parens, keywords. default op is '&'.
no operator precedence, just parens first. will show a maximum of 20 results, one per line.
in channels, if there are more than 5 results, they will be printed in short format.
"""
"remember": "store a string, $ is a placeholder"
"git context": "get three lines from the specified position in a file on github"
"mem": "print a stored string, replace placeholders with given parameters"
"coffee compile": "compile a given line of coffeescript"
"coffee compilegist": "compile the given coffee-gist into another js-gist"
"help": "print this help"
lines = []
addHelp = (prefix, obj) ->
for subkey, value of obj
fullname = if prefix then "#{prefix} #{subkey}" else subkey
switch typeof value
when 'object'
addHelp fullname, value
when 'function'
lines.push fullname
addHelp null, commands
longestLine = 2 + arrayMax (length for {length} in lines)
outputLines = []
for line in lines
syntax = syntaxes[line]
description = descriptions[line]
line += ' ' + syntax if syntax?
outputLines.push 'command: '+line
outputLines.push ' ' + descriptionLine for descriptionLine in description.split('\n') if description?
reply message.person.nick, line for line in outputLines
#time: (message, [location]) ->
# if not location?
# zone = 0
# else
# if /^[+-]?[0-9]+$/.exec location
# zone = parseInt location, 10
# else
# return reply message, "unknown zone (try +/-5)"
# reply message, new Date(new Date().getTime() + 1000*60*60*zone).toGMTString()
isChannel = (chanOrNick) ->
chanOrNick[0] == '#'
isOwner = (person) ->
{host} = person
host is 'wikipedia/TheJH'
reply = (do ->
replyQueue = []
lastTime = 0
WAIT_TIME = 2000
doReply = (originalMessage, message) ->
if typeof originalMessage is 'string'
target = originalMessage
else
{person: {nick: senderNick}, params: [originalTarget]} = originalMessage
target = if isChannel originalTarget
originalTarget
else
senderNick
irc.privmsg target, message
lastTime = new Date().getTime()
canReplyHandler = ->
{originalMessage, message} = replyQueue.shift()
doReply originalMessage, message
if replyQueue.length > 0
setTimeout canReplyHandler, WAIT_TIME
(originalMessage, message) ->
time = new Date().getTime()
if replyQueue.length is 0 and time - lastTime > WAIT_TIME
doReply originalMessage, message
else
if replyQueue.length is 0
setTimeout canReplyHandler, WAIT_TIME - (time - lastTime)
replyQueue.push {originalMessage, message}
)
handleCommand = (message, commandParts) ->
obj = commands
i = 0
if commandParts[0]?[0] is '@'
answerTargetNick = commandParts[0].substring 1
unless NICKNAME_REGEX.exec answerTargetNick
return reply message, "That nick looks weird. I refuse."
commandParts.shift()
while typeof obj is 'object'
if i is commandParts.length
return
nextPart = commandParts[i++]
if nextPart is "admin" and not isOwner message.person
console.log i
return reply message, "you're not my admin"
if nextPart is "admin"
console.log "valid admin command from #{JSON.stringify message.person}"
if obj.hasOwnProperty(nextPart) and not {}.hasOwnProperty(nextPart)
obj = obj[nextPart]
else
return
if typeof obj is 'function'
obj message, commandParts.slice(i), (answer, options = {}) ->
reply message, [if not options.error and answerTargetNick? then "#{answerTargetNick}, "] + answer
autoLint = (original, nick, message) ->
nick = " #{nick}" if not NICKNAME_REGEX.exec nick
GIST_REGEX = /https:\/\/gist\.github\.com\/([0-9a-f]+)/
gist_match = GIST_REGEX.exec message
lintWarn = (warning) ->
reply original, "#{nick}, #{warning}"
if gist_match
lastCsGist = gist_id = gist_match[1]
github.getGist gist_id, (err, gist) ->
return if err?
for filename, {content} of gist.files
lines = content.split "\n"
hasTabIndent = hasSpaceIndent = false
levels = [0]
indents = for line in lines
[indent, contentStart] = line.split /[^\t\s]/
continue if not contentStart?
hasSpaceIndent = true if -1 < indent.indexOf " "
hasTabIndent = true if -1 < indent.indexOf "\t"
indentLevel = indent.length
lastIndentLevel = levels[levels.length-1]
levels.push indentLevel if indentLevel > lastIndentLevel
if indentLevel < lastIndentLevel
newLevelIndex = levels.indexOf indentLevel
if newLevelIndex is -1
badOutdent = true
break
levels = levels.slice 0, newLevelIndex+1
try
coffee.compile content, bare: true
valid_coffee = true
catch compileErr
if valid_coffee and hasTabIndent and hasSpaceIndent
lintWarn "you're using both spaces and tabs for indentation. "+
"coffee treats one tab as one space, therefore the meaning of your code is messed up."
if badOutdent
lintWarn "you seem to have an outdent in your code that doesn't match the indents"
genericWarnings = (original, nick, message, channel) ->
nick = " #{nick}" if not NICKNAME_REGEX.exec nick
GIST_REGEX = /https:\/\/gist\.github\.com\/([0-9a-f]+)/
gist_match = GIST_REGEX.exec message
warn = (warning) -> reply original, "#{nick}, #{warning}"
if channel == '#node.js' and message.indexOf('graceful-fs') != -1 and message.indexOf('npm') != -1
nick = " #{nick}" if not NICKNAME_REGEX.exec nick
reply args, "#{nick}, if you have problems installing npm because of some 'graceful-fs not found' error, your node.js version is outdated."
if gist_match
github.getGist gist_match[1], (err, gist) ->
return if err?
for filename, {content} of gist.files
if content.indexOf('npm info using npm@0.') != -1
warn "that version of npm (0.x) is ancient. update npm with `curl http://npmjs.org/install.sh | sudo sh`."
if content.indexOf("Error: Cannot find module 'graceful-fs'") != -1 and content.indexOf("fetching: http://registry.npmjs.org/") != -1
warn "that version of nodejs is ancient, use 0.4.x or newer"
irc.on 'privmsg', (args) ->
BOTS = ['jhbot', 'v8bot', 'v8bot_', 'catbot']
{person: {nick, user, host}, params: [chanOrNick, message]} = args
return if -1 isnt BOTS.indexOf nick
if chanOrNick is '#coffeescript'
autoLint args, nick, message
genericWarnings args, nick, message, chanOrNick.toLowerCase()
if message[0] isnt '!' and isChannel chanOrNick
return
if message[0] is '!'
message = message.substring 1
messageparts = message.split ' '
handleCommand args, messageparts
nickInfoListeners = {}
getNicksAccount = (usersNick, cb) ->
if not nickInfoListeners[usersNick]
nickInfoListeners[usersNick] = []
irc.privmsg 'NickServ', "info =#{usersNick}"
nickInfoListeners[usersNick].push cb
_handleNicksAccount = (nick, account) ->
if nickInfoListeners[nick]?
for listener in nickInfoListeners[nick]
listener account
delete nickInfoListeners[nick]
# Information on \2TheJH\2 (account \2TheJH\2):
NICKSERV_USERINFO_REGEX = /^Information on \x02([^\x02]*)\x02 \(account \x02([^\x02]*)\x02\)/
NICKSERV_HASNOUSER_REGEX = /^\x02=([^\x02]*)\x02 is not registered.$/
irc.on 'notice', (args) ->
# server notices aren't interesting
return if not args.person?
{person: {nick, user, host}, params: [_, message]} = args
if nick == 'NickServ'
if 0 == message.indexOf 'You are now identified'
console.log 'alright, were identified, go on'
setTimeout (->
irc.join "##{chan}" for chan in ['node.js', 'coffeescript']
), 10000
userinfoMatch = NICKSERV_USERINFO_REGEX.exec message
if userinfoMatch?
console.log "userinfo match"
_handleNicksAccount userinfoMatch[1], userinfoMatch[2]
hasNoUserMatch = NICKSERV_HASNOUSER_REGEX.exec message
if hasNoUserMatch?
_handleNicksAccount hasNoUserMatch[1], null
updateNpm (npmData) ->
console.log "NPM ready with #{Object.keys(npmData).length} entries"
irc.connect ->
irc.privmsg 'NickServ', 'IDENTIFY jhbot PASSWORD'
OPERATORS = ['|', '&']
contains = (arr, el) -> -1 isnt arr.indexOf el
isOp = (type) -> contains OPERATORS, type.type or type
uniq = (arr) ->
result = []
result.push e for e in arr when not contains result, e
result
# both arrays should be uniq'd
common = (arr1, arr2) -> e for e in arr1 when contains arr2, e
join = (arr1, arr2) -> uniq arr1.concat arr2
createNodes = (tokens) ->
nodes = tokens.concat()
# parse a bunch of strings, results and ops
# every second token is an op
_parse = (from, to) ->
subNodes = nodes.slice from, to+1
throw 'internal error, createNodes:_parse:subNodes.length isn\'t odd' if subNodes.length%2 isnt 1
while subNodes.length > 1
operandA = subNodes.shift()
operator = subNodes.shift()
operandB = subNodes.shift()
result = {type: 'result', op: operator.type, operands: [operandA, operandB]}
subNodes.unshift result
throw 'internal error, createNodes:_parse:subNodes.length isn\'t 1 at the end' if subNodes.length isnt 1
subNodes[0]
openPositions = []
i = 0
while i < nodes.length
node = nodes[i]
switch node.type
when 'parenOpen'
openPositions.push i
when 'parenClose'
openI = openPositions.pop()
throw 'closing paren without opening paren' if not openI?
newNode = _parse openI+1, i-1
nodes.splice openI, i-openI+1, newNode
i = openI
i++
throw 'unclosed parens' if openPositions.length isnt 0
_parse 0, nodes.length-1
postprocessTokens = (tokens) ->
throw 'first and last token may not be ops' if isOp(tokens[0]) or isOp(tokens[tokens.length-1])
i = 0
lastType = null
while i < tokens.length
{type} = tokens[i]
if -1 isnt ['parenClose', 'string'].indexOf(lastType) and -1 isnt ['parenOpen', 'string'].indexOf(type)
tokens.splice i, 0, type: type = 'and'
if -1 isnt ['|', '&'].indexOf(lastType) and -1 isnt ['|', '&'].indexOf(type)
throw "you can't do &| or && or whatever"
lastType = type
i++
tokens
tokenizeSearchString = (str) ->
if not /^[()0-9a-zA-Z_&| -]+$/.exec str
throw 'invalid character'
tokens = []
i = 0
while i < str.length
char = str[i]
switch char
when '('
tokens.push type: 'parenOpen'
i++
when ')'
tokens.push type: 'parenClose'
i++
when '|'
tokens.push type: 'or'
i++
when '&'
tokens.push type: 'and'
i++
when ' '
i++
else
# string
fromI = i
i++ while i < str.length and -1 is [' ', '|', '&', '(', ')'].indexOf str[i]
tokens.push type: 'string', value: str.slice fromI, i
postprocessTokens tokens
performFilter = (node, keywordResults) ->
switch node.type
when 'string'
keywordResults[node.value]
when 'result'
switch node.op
when 'or'
join(
performFilter node.operands[0], keywordResults
performFilter node.operands[1], keywordResults
)
when 'and'
common(
performFilter node.operands[0], keywordResults
performFilter node.operands[1], keywordResults
)
getKeywords = (tokens) -> uniq (t.value for t in tokens when t.type is 'string').sort()
module.exports = class Search
# use with try/catch!
constructor: (string, @callback) ->
tokens = tokenizeSearchString string
@_nodes = createNodes tokens
@keywords = getKeywords tokens
@_keywordResults = {}
@_keywordResultsNeeded = @keywords.length
provideKeywordData: (keyword, data) ->
throw new Error 'duplicate keyword data' if @_keywordResults[keyword]?
throw new Error 'unknown keyword' unless contains @keywords, keyword
@_keywordResults[keyword] = data
@_keywordResultsNeeded--
if @_keywordResultsNeeded is 0
@callback uniq performFilter @_nodes, @_keywordResults
#try
# console.error JSON.stringify tokens = tokenizeSearchString 'sax halfstreamxml'
# console.error JSON.stringify createNodes tokens
#catch err
# console.log err.stack||err
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment