rss: don't return since there's no cb anymore

Former-commit-id: c628f0d32fc0c6624fb823df0ffb9aab8002e780
Former-commit-id: 2b52f848cfb6cf497ee461d996299f59a3b77b1e

-- Module for subscribing to RSS and announce to channels
feedparser = require 'feedparser' -- http://feedparser.luaforge.net/
{:simplehttp, :json, :urlEncode, :bold} = require'util'
html2unicode = require 'html'
sql = require'lsqlite3'

moduleName = 'rss'

-- share a db reference
dbref = false

get_db = ->
  unless dbref
    dbref = sql.open 'cache/rss.sql'
    code = dbref\exec [[
      CREATE TABLE IF NOT EXISTS feed (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT,
        rssurl VARCHAR(1024) NOT NULL,
        url VARCHAR(1024),
        title VARCHAR(1024),
        author VARCHAR(1024),
        etag TEXT,
        lastmodified TEXT
      );

      CREATE TABLE IF NOT EXISTS item (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        feed_id INTEGER NOT NULL,
        guid VARCHAR(64),
        title VARCHAR(1024),
        author VARCHAR(1024),
        url VARCHAR(1024),
        feedurl VARCHAR(1024),
        pubDate TEXT,
        summary TEXT,
        content TEXT,
        FOREIGN KEY(feed_id) REFERENCES feed(id),
        UNIQUE (guid, feed_id)
      );

      CREATE TABLE IF NOT EXISTS subscription (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        destination varchar(100),
        feed_id INTEGER,
        last INTEGER DEFAULT 0,
        created_at timestamp DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY(feed_id) REFERENCES feed(id)
      );
      ]]
    if code != sql.OK
      ivar2\Log 'error', "Error during opendb: #{dbref\errmsg!}"
      return nil

  unless dbref\isopen!
    dbref = sql.open 'cache/rss.sql'

  return dbref

prepare = (db, stmt, ...) ->
  code = db\prepare stmt
  unless code
    ivar2\Log 'error', "Error with statement prepare: #{db\errmsg!}"
    return nil
  code\bind_values ...
  return code

-- Function that add functionality to extract specific information for specific sites
feedSpecific = (feedurl, link, title, summary, content) ->
  if feedurl\match 'https://comics.io/my/'

    title = summary\match '
(.-)
' or ''
    img, alt = summary\match '
'
    return "#{html2unicode title} #{img} #{html2unicode alt}"

  return "#{title} - #{link}"

announce = (db, id) ->
  stmt = prepare db, "SELECT DISTINCT feed.id, destination FROM feed JOIN subscription ON subscription.feed_id = feed.id WHERE feed.id = ?", id
  for row in stmt\rows!
    destination = row[2]

    highest = 0
    -- Check last value
    stmt = prepare db, "SELECT last FROM subscription WHERE feed_id = ? and destination = ?", id, destination
    stmt\step!
    value = stmt\get_values()[1]
    if value and value ~= '' and tonumber(value) and tonumber(value) > 0
      highest = value

    ivar2\Log 'debug', 'Got highest %s, %s, %s', id, destination, highest

    stmt = prepare db, "SELECT feed.id AS feed_id, item.id AS item_id, rssurl, pubDate, name, etag, item.url AS link, item.title, content, summary FROM feed JOIN item on item.feed_id = feed.id WHERE feed.id = ? AND item.id > ? ORDER BY item.id DESC LIMIT 100", id, highest
    out = {}
    for item in stmt\nrows!
      with item
        if .item_id > highest
          highest = .item_id
        table.insert out, "[#{bold .name}]: #{feedSpecific(.rssurl, .link, .title, .summary, .content)}"
    if #out > 0
      ivar2\Msg 'privmsg', destination, ivar2.nick, "RSS "..table.concat(out, ', ')
    -- Update last read
    ins = prepare db, 'UPDATE subscription SET last=? WHERE feed_id = ? and destination = ?', highest, id, destination
    code, err = ins\step!
    code, err = ins\finalize!

poll = ->
  db = get_db!
  channels = {}
  for c,_ in pairs(ivar2.channels)
    channels[c] = true
  for row in db\rows "SELECT DISTINCT feed.id, rssurl, lastmodified, name, etag, destination FROM feed JOIN subscription ON subscription.feed_id = feed.id"
    -- Ignore feeds this particular instance of the bot does not subscribe to
    if not channels[row[6]]
      continue
    id = row[1]
    rssurl = row[2]
    lm = row[3]
    name = row[4]
    etag = row[5]
    headers = {}
    if lm and lm != '' then
      headers['If-Modified-Since'] = lm
    if etag and etag != '' then
      headers['If-None-Match'] = etag

    data, url, response = simplehttp {url:rssurl,headers:headers}
    if not data or not url or not response
      continue
    sdb = db

    lastModified = response.headers['Last-Modified']
    if not lastModified
      lastModified = response.headers['Date']

    -- Unmodified content
    if response.status_code == 304 or not data
      continue

    ok, feed, err = pcall -> feedparser.parse data
    if not ok
      ivar2\Log 'error', "#{moduleName}: Error during parsing: <#{feed}> data for feed: <#{name}> with URL <#{url}>"
      continue
    if err
      ivar2\Log 'error', "#{moduleName}: Error during parsing: <#{err}> data for feed: <#{name}> with URL <#{url}>"
      continue
    else
      title = feed.title
      author = feed.author
      url = feed.link

      -- Update feed with new values
      ins = sdb\prepare 'UPDATE feed SET title=?, author=?, url=?, lastmodified=? WHERE id=?'
      code, err = ins\bind_values title, author, url, lastModified, id
      code, err = ins\step!
      code, err = ins\finalize!

      out = {}
      for i, e in ipairs(feed.entries)
        -- Attempt to get a unique entry ID
        guid = e.guid
        if not guid then guid = e.id
        if not guid then guid = e.link
        if not guid
          ivar2\Log 'error', "#{moduleName}: No GUID when parsing entry: <#{e}> data for feed: <#{name}> with URL <#{url}>"
          break

        ins = sdb\prepare [[
          INSERT OR ABORT INTO
            item(feed_id, guid, title, author, url, pubDate, content, summary)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
        ]]
        if not ins then
          ivar2\Log 'error', "#{moduleName}: Error during inserting entry: <#{err}> feed: <#{name}> with URL <#{url}> and position: #{i}"
        else
          ivar2\Log 'debug', "#{moduleName}: Inserting entry: #{e.link}, #{e.title}"
          code = ins\bind_values id, guid, e.title, e.author, e.link, e.updated, e.content, e.summary
          code = ins\step!
          code = ins\finalize!
          if code == sql.CONSTRAINT -- duplicate value
            ivar2\Log 'debug', "Reached duplicate link, breaking"
            break
    announce(db, id)
  db\close!

subscribe = (source, destination, name, url) =>
  db = get_db!
  ins = db\prepare "INSERT INTO feed (name, rssurl) VALUES(?, ?)"
  code = ins\bind_values name, url
  code = ins\step!
  code = ins\finalize!
  ins = db\prepare "INSERT INTO subscription (feed_id, destination) VALUES(?, ?)"
  code = ins\bind_values db\last_insert_rowid!, destination
  code = ins\step!
  code = ins\finalize!
  db\close!
  reply "Ok. Subscribed to #{bold name}"
  poll!

unsubscribe = (source, destination, name) =>
  db = get_db!
  ins = db\prepare "DELETE FROM subscription WHERE feed_id IN (select id from feed where name = ?) and destination = ?"
  code = ins\bind_values name, destination
  code = ins\step!
  code = ins\finalize!
  total_changes = db\total_changes!
  db\close!
  if total_changes < 1
    reply "Wasn't subscribed. But, sure."
  else
    reply "Ok. Stopped caring about #{bold name}"

list = (source, destination) =>
  out = {}
  db = get_db!
  stmt = db\prepare("SELECT name FROM feed JOIN subscription ON feed.id = subscription.feed_id WHERE destination = ?")
  stmt\bind_values destination
  for row in stmt\rows!
    out[#out+1] = row[1]
  if #out > 0 then
    reply "Subscribed to: #{table.concat(out, ', ')}"
  else
    reply "Not subscribed to any feeds."

  db\close!

-- Start the subscribe poller
interval = 60*60
ivar2\Timer(moduleName, interval, interval, poll)

PRIVMSG:
  '^%prss latest (.*)': getLatest
  '^%prss subscribe (.-) (.*)': subscribe
  '^%prss unsubscribe (.*)': unsubscribe
  '^%prss list': list
  -- for debugging
  '^%prss poll': poll

-- Module for subscribing to RSS and announce to channels
feedparser = require 'feedparser' -- http://feedparser.luaforge.net/
{:simplehttp, :json, :urlEncode, :bold} = require'util'
html2unicode = require 'html'
sql = require'lsqlite3'

moduleName = 'rss'

-- share a db reference
dbref = false

get_db = ->
  unless dbref
    dbref = sql.open 'cache/rss.sql'
    code = dbref\exec [[
      CREATE TABLE IF NOT EXISTS feed (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT,
        rssurl VARCHAR(1024) NOT NULL,
        url VARCHAR(1024),
        title VARCHAR(1024),
        author VARCHAR(1024),
        etag TEXT,
        lastmodified TEXT
      );

      CREATE TABLE IF NOT EXISTS item (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        feed_id INTEGER NOT NULL,
        guid VARCHAR(64),
        title VARCHAR(1024),
        author VARCHAR(1024),
        url VARCHAR(1024),
        feedurl VARCHAR(1024),
        pubDate TEXT,
        summary TEXT,
        content TEXT,
        FOREIGN KEY(feed_id) REFERENCES feed(id),
        UNIQUE (guid, feed_id)
      );

      CREATE TABLE IF NOT EXISTS subscription (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        destination varchar(100),
        feed_id INTEGER,
        last INTEGER DEFAULT 0,
        created_at timestamp DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY(feed_id) REFERENCES feed(id)
      );
      ]]
    if code != sql.OK
      ivar2\Log 'error', "Error during opendb: #{dbref\errmsg!}"
      return nil

  unless dbref\isopen!
    dbref = sql.open 'cache/rss.sql'

  return dbref

prepare = (db, stmt, ...) ->
  code = db\prepare stmt
  unless code
    ivar2\Log 'error', "Error with statement prepare: #{db\errmsg!}"
    return nil
  code\bind_values ...
  return code

-- Function that add functionality to extract specific information for specific sites
feedSpecific = (feedurl, link, title, summary, content) ->
  if feedurl\match 'https://comics.io/my/'

    title = summary\match '
(.-)
' or ''
    img, alt = summary\match '
'
    return "#{html2unicode title} #{img} #{html2unicode alt}"

  return "#{title} - #{link}"

announce = (db, id) ->
  stmt = prepare db, "SELECT DISTINCT feed.id, destination FROM feed JOIN subscription ON subscription.feed_id = feed.id WHERE feed.id = ?", id
  for row in stmt\rows!
    destination = row[2]

    highest = 0
    -- Check last value
    stmt = prepare db, "SELECT last FROM subscription WHERE feed_id = ? and destination = ?", id, destination
    stmt\step!
    value = stmt\get_values()[1]
    if value and value ~= '' and tonumber(value) and tonumber(value) > 0
      highest = value

    ivar2\Log 'debug', 'Got highest %s, %s, %s', id, destination, highest

    stmt = prepare db, "SELECT feed.id AS feed_id, item.id AS item_id, rssurl, pubDate, name, etag, item.url AS link, item.title, content, summary FROM feed JOIN item on item.feed_id = feed.id WHERE feed.id = ? AND item.id > ? ORDER BY item.id DESC LIMIT 100", id, highest
    out = {}
    for item in stmt\nrows!
      with item
        if .item_id > highest
          highest = .item_id
        table.insert out, "[#{bold .name}]: #{feedSpecific(.rssurl, .link, .title, .summary, .content)}"
    if #out > 0
      ivar2\Msg 'privmsg', destination, ivar2.nick, "RSS "..table.concat(out, ', ')
    -- Update last read
    ins = prepare db, 'UPDATE subscription SET last=? WHERE feed_id = ? and destination = ?', highest, id, destination
    code, err = ins\step!
    code, err = ins\finalize!

poll = ->
  db = get_db!
  channels = {}
  for c,_ in pairs(ivar2.channels)
    channels[c] = true
  for row in db\rows "SELECT DISTINCT feed.id, rssurl, lastmodified, name, etag, destination FROM feed JOIN subscription ON subscription.feed_id = feed.id"
    -- Ignore feeds this particular instance of the bot does not subscribe to
    if not channels[row[6]]
      continue
    id = row[1]
    rssurl = row[2]
    lm = row[3]
    name = row[4]
    etag = row[5]
    headers = {}
    if lm and lm != '' then
      headers['If-Modified-Since'] = lm
    if etag and etag != '' then
      headers['If-None-Match'] = etag

    data, url, response = simplehttp {url:rssurl,headers:headers}
      -- Reopen DB because of async
      --sdb = get_db!
    sdb = db

    lastModified = response.headers['Last-Modified']
    if not lastModified
      lastModified = response.headers['Date']

    -- Unmodified content
    if response.status_code == 304 or not data
      -- return from the simplehttp callback
      return

    ok, feed, err = pcall -> feedparser.parse data
    if not ok
      ivar2\Log 'error', "#{moduleName}: Error during parsing: <#{feed}> data for feed: <#{name}> with URL <#{url}>"
      return
    if err
      ivar2\Log 'error', "#{moduleName}: Error during parsing: <#{err}> data for feed: <#{name}> with URL <#{url}>"
      return
    else
      title = feed.title
      author = feed.author
      url = feed.link

      -- Update feed with new values
      ins = sdb\prepare 'UPDATE feed SET title=?, author=?, url=?, lastmodified=? WHERE id=?'
      code, err = ins\bind_values title, author, url, lastModified, id
      code, err = ins\step!
      code, err = ins\finalize!

      out = {}
      for i, e in ipairs(feed.entries)
        -- Attempt to get a unique entry ID
        guid = e.guid
        if not guid then guid = e.id
        if not guid then guid = e.link
        if not guid
          ivar2\Log 'error', "#{moduleName}: No GUID when parsing entry: <#{e}> data for feed: <#{name}> with URL <#{url}>"
          break

        ins = sdb\prepare [[
          INSERT OR ABORT INTO
            item(feed_id, guid, title, author, url, pubDate, content, summary)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
        ]]
        if not ins then
          ivar2\Log 'error', "#{moduleName}: Error during inserting entry: <#{err}> feed: <#{name}> with URL <#{url}> and position: #{i}"
        else
          ivar2\Log 'debug', "#{moduleName}: Inserting entry: #{e.link}, #{e.title}"
          code = ins\bind_values id, guid, e.title, e.author, e.link, e.updated, e.content, e.summary
          code = ins\step!
          code = ins\finalize!
          if code == sql.CONSTRAINT -- duplicate value
            ivar2\Log 'debug', "Reached duplicate link, breaking"
            break
    --sdb\close!
    announce(db, id)
  db\close!

subscribe = (source, destination, name, url) =>
  db = get_db!
  ins = db\prepare "INSERT INTO feed (name, rssurl) VALUES(?, ?)"
  code = ins\bind_values name, url
  code = ins\step!
  code = ins\finalize!
  ins = db\prepare "INSERT INTO subscription (feed_id, destination) VALUES(?, ?)"
  code = ins\bind_values db\last_insert_rowid!, destination
  code = ins\step!
  code = ins\finalize!
  db\close!
  reply "Ok. Subscribed to #{bold name}"
  poll!

unsubscribe = (source, destination, name) =>
  db = get_db!
  ins = db\prepare "DELETE FROM subscription WHERE feed_id IN (select id from feed where name = ?) and destination = ?"
  code = ins\bind_values name, destination
  code = ins\step!
  code = ins\finalize!
  total_changes = db\total_changes!
  db\close!
  if total_changes < 1
    reply "Wasn't subscribed. But, sure."
  else
    reply "Ok. Stopped caring about #{bold name}"

list = (source, destination) =>
  out = {}
  db = get_db!
  stmt = db\prepare("SELECT name FROM feed JOIN subscription ON feed.id = subscription.feed_id WHERE destination = ?")
  stmt\bind_values destination
  for row in stmt\rows!
    out[#out+1] = row[1]
  if #out > 0 then
    reply "Subscribed to: #{table.concat(out, ', ')}"
  else
    reply "Not subscribed to any feeds."

  db\close!

-- Start the subscribe poller
interval = 60*60
ivar2\Timer(moduleName, interval, interval, poll)

PRIVMSG:
  '^%prss latest (.*)': getLatest
  '^%prss subscribe (.-) (.*)': subscribe
  '^%prss unsubscribe (.*)': unsubscribe
  '^%prss list': list
  -- for debugging
  '^%prss poll': poll


Example: replace logRect with dumpRect.

Example: replace logRect with dumpRect.

-- This example is unlicensed under CC0

export script_name        = "ASSInspector Example"
export script_description = "Calculates bounding rectangles on the start and end times of selected events."
export script_author      = "torque"
export script_version     = 0x000003

ffi  = require( 'ffi' )
util = require( 'aegisub.util' )
log  = require( 'a-mo.log' )

ffi.cdef( [[
typedef struct {
	int32_t x, y;
	uint32_t w, h;
} ASSI_Rect;

uint32_t    assi_getVersion( void );
void*       assi_init( int, int, const char*, uint32_t );
const char* assi_getErrorString( void* );
int         assi_setScript( void*, const char*, uint32_t, const char *, const uint32_t );
int         assi_calculateBounds( void*, ASSI_Rect*, const int32_t*, const uint32_t );
void        assi_cleanup( void* );
]] )

-- Figure out a better way to load this?
extension = ('OSX' == ffi.os and '.dylib' or 'Windows' == ffi.os and '.dll' or '.so')
libraryPath = "?user/automation/include/ASSInspector"
success, ASSInspector = pcall( ffi.load, aegisub.decode_path( libraryPath .. "/libASSInspector" .. extension ) )
if not success
	libraryPath = "?data/automation/include/ASSInspector"
	success, ASSInspector = pcall( ffi.load, aegisub.decode_path( libraryPath .. "/libASSInspector" .. extension ) )
	if not success
		error( "Could not load required libASSInspector library." )

dumpRect = ( event, rect ) ->
	bounds = {
		x:  tonumber( rect.x )
		y:  tonumber( rect.y )
		x2: tonumber( rect.w + rect.x )
		y2: tonumber( rect.h + rect.y )
	}

	newEvent = util.deep_copy( event )
	with bounds
		newEvent.text = ([[{\an7\pos(0,0)\bord0\shad0\c&H0000FF&\1a&H00&\p1}m %d %d l %d %d %d %d %d %d]])\format( .x, .y, .x2, .y, .x2, .y2, .x, .y2 )

	event.layer += 1
	return newEvent

mainFunction = ( subtitle, selectedLines, activeLine ) ->
	if ASSInspector.assi_getVersion( ) < script_version
		log.windowError( "Installed ASSInspector library is outdated. Please update it." )
		return selectedLines
	elseif ASSInspector.assi_getVersion( ) > script_version
		log.windowError( "Installed OptimASS is outdated. Please update it." )
		return selectedLines

	scriptHeader = {
		"[Script Info]"
	}

	-- These are the only header fields that actually affect the way ASS
	-- is rendered. I don't actually know if ScriptType makes a matters.
	infoFields = {
		PlayResX:   true
		PlayResY:   true
		WrapStyle:  true
		ScriptType: true
		ScaledBorderAndShadow: true
	}

	styles = { }

	-- If there is no video (or, at minimum, timecodes) loaded, this will
	-- return nil.
	hasTimecodes = aegisub.frame_from_ms( 0 )

	-- Copy these functions into local variables to shorten them.
	ffms = aegisub.frame_from_ms
	msff = aegisub.ms_from_frame

	subtitleLen = #subtitle
	seenStyles = false
	resX = 640
	resY = 480

	-- This loop assumes the subtitle script is nicely organized in the
	-- order [Script Info] -> [V4+ Styles] -> [Events]. This isn't
	-- guaranteed for scripts floating around in the wild, but fortunately
	-- Aegisub 3.2 seems to do a very good job of ensuring this order when
	-- the script is loaded. In other words, this loop is probably only
	-- safe to do from within automation.
	for index = 1, subtitleLen
		with line = subtitle[index]
			if "info" == .class
				if infoFields[.key]
					table.insert( scriptHeader, .raw )

				if "PlayResX" == .key
					resX = tonumber( .value )
				elseif "PlayResY" == .key
					resY = tonumber( .value )

			elseif "style" == .class
				unless seenStyles
					table.insert( scriptHeader, "[V4+ Styles]\n" )
					seenStyles = true

				styles[.name] = .raw

			elseif "dialogue" == .class
				break

	-- If a video is loaded, use its resolution instead of the script's
	-- resolution. Not convinced this is a great idea because people may
	-- use smaller workraws or something like that.
	vidResX, vidResY = aegisub.video_size( )
	resX = vidResX or resX
	resY = vidResY or resY

	minimalHeader = table.concat( scriptHeader, '\n' )
	inspector = ASSInspector.assi_init( resX, resY, minimalHeader, #minimalHeader )
	-- I honestly don't know if this comparison actually works.
	if nil == inspector
		log.windowError( "ASSInspector library init failed." )

	for eventIndex in *selectedLines
		with event = subtitle[eventIndex]
			-- This does not account for \r[name] being in the line.
			styles = (styles[.style] or "") .. "\n[Events]\n"
			events = .raw

			-- It occurs to me that perhaps having the user just pass in one
			-- string that contains both styles and events may be easier.
			if 0 < ASSInspector.assi_setScript( inspector, styles, #styles, events, #events )
				log.warn( assi_getErrorString( inspector ) )
				ASSInspector.assi_cleanup( inspector )
				aegisub.cancel( )

			-- We're going to render a line twice, once at its start and once
			-- at its end.
			renderCount = 2

			renderTimes = { }

			-- Cheat a bit when generating the times to render. This obviously
			-- will not scale with a renderCount > 2.
			if hasTimecodes
				-- Using the aegisub.frame_from_ms function pointers we copied
				-- earlier.
				startFrame = ffms( .start_time )
				endFrame   = ffms( .end_time ) - 1
				renderTimes[0] = math.floor( 0.5*( msff( startFrame ) + msff( startFrame + 1 ) ) )
				renderTimes[1] = math.floor( 0.5*( msff( endFrame ) + msff( endFrame + 1 ) ) )
			else
				renderTimes[0] = .start_time
				-- Trying to render exactly at end_time will result in a
				-- completely blank frame.
				renderTimes[1] = .end_time - 1

			-- Because we're rendering the line twice, we need two rects.
			rects = ffi.new( "ASSI_Rect[?]", renderCount )

			-- Allocate the array of times for rendering.
			times = ffi.new( "int32_t[?]", renderCount )

			-- Set up the render times. There's no real reason to do this in a
			-- loop if you know beforehand you're only going to render the
			-- line twice, but loops are more generic.
			for render = 0, renderCount - 1
				times[render] = renderTimes[render]

			-- Send the data in for rendering and bounds calculation.
			if 0 < ASSInspector.assi_calculateBounds( inspector, rects, times, renderCount )
				log.warn( inspector.error )
				ASSInspector.assi_cleanup( inspector )
				aegisub.cancel( )

			-- Check out the calculated bounding rects
			for render = 0, renderCount - 1
				newEvent = dumpRect( event, rects[render] )
				-- log.dump newEvent
				subtitle.insert( eventIndex + render, newEvent )

			subtitle[eventIndex + renderCount] = event

	-- That's all, folks.
	ASSInspector.assi_cleanup( inspector )

aegisub.register_macro( script_name, script_description, mainFunction )

-- This example is unlicensed under CC0

export script_name        = "ASSInspector Example"
export script_description = "Calculates bounding rectangles on the start and end times of selected events."
export script_author      = "torque"
export script_version     = 0x000003

ffi = require( 'ffi' )
log = require( 'a-mo.log' )

ffi.cdef( [[
typedef struct {
	int32_t x, y;
	uint32_t w, h;
} ASSI_Rect;

uint32_t    assi_getVersion( void );
void*       assi_init( int, int, const char*, uint32_t );
const char* assi_getErrorString( void* );
int         assi_setScript( void*, const char*, uint32_t, const char *, const uint32_t );
int         assi_calculateBounds( void*, ASSI_Rect*, const int32_t*, const uint32_t );
void        assi_cleanup( void* );
]] )

-- Figure out a better way to load this?
extension = ('OSX' == ffi.os and '.dylib' or 'Windows' == ffi.os and '.dll' or '.so')
libraryPath = "?user/automation/include/ASSInspector"
success, ASSInspector = pcall( ffi.load, aegisub.decode_path( libraryPath .. "/libASSInspector" .. extension ) )
if not success
	libraryPath = "?data/automation/include/ASSInspector"
	success, ASSInspector = pcall( ffi.load, aegisub.decode_path( libraryPath .. "/libASSInspector" .. extension ) )
	if not success
		error( "Could not load required libASSInspector library." )

logRect = ( rect ) ->
	log.dump( {
		x: rect.x
		y: rect.y
		w: rect.w
		h: rect.h
	} )

mainFunction = ( subtitle, selectedLines, activeLine ) ->
	if ASSInspector.assi_getVersion( ) < script_version
		log.windowError( "Installed ASSInspector library is outdated. Please update it." )
		return selectedLines
	elseif ASSInspector.assi_getVersion( ) > script_version
		log.windowError( "Installed OptimASS is outdated. Please update it." )
		return selectedLines

	scriptHeader = {
		"[Script Info]"
	}

	-- These are the only header fields that actually affect the way ASS
	-- is rendered. I don't actually know if ScriptType makes a matters.
	infoFields = {
		PlayResX:   true
		PlayResY:   true
		WrapStyle:  true
		ScriptType: true
		ScaledBorderAndShadow: true
	}

	styles = { }

	-- If there is no video (or, at minimum, timecodes) loaded, this will
	-- return nil.
	hasTimecodes = aegisub.frame_from_ms( 0 )

	-- Copy these functions into local variables to shorten them.
	ffms = aegisub.frame_from_ms
	msff = aegisub.ms_from_frame

	subtitleLen = #subtitle
	seenStyles = false
	resX = 640
	resY = 480

	-- This loop assumes the subtitle script is nicely organized in the
	-- order [Script Info] -> [V4+ Styles] -> [Events]. This isn't
	-- guaranteed for scripts floating around in the wild, but fortunately
	-- Aegisub 3.2 seems to do a very good job of ensuring this order when
	-- the script is loaded. In other words, this loop is probably only
	-- safe to do from within automation.
	for index = 1, subtitleLen
		with line = subtitle[index]
			if "info" == .class
				if infoFields[.key]
					table.insert( scriptHeader, .raw )

				if "PlayResX" == .key
					resX = tonumber( .value )
				elseif "PlayResY" == .key
					resY = tonumber( .value )

			elseif "style" == .class
				unless seenStyles
					table.insert( scriptHeader, "[V4+ Styles]\n" )
					seenStyles = true

				styles[.name] = .raw

			elseif "dialogue" == .class
				break

	-- If a video is loaded, use its resolution instead of the script's
	-- resolution. Not convinced this is a great idea because people may
	-- use smaller workraws or something like that.
	vidResX, vidResY = aegisub.video_size( )
	resX = vidResX or resX
	resY = vidResY or resY

	minimalHeader = table.concat( scriptHeader, '\n' )
	inspector = ASSInspector.assi_init( resX, resY, minimalHeader, #minimalHeader )
	-- I honestly don't know if this comparison actually works.
	if nil == inspector
		log.windowError( "ASSInspector library init failed." )

	for eventIndex in *selectedLines
		with event = subtitle[eventIndex]
			-- This does not account for \r[name] being in the line.
			styles = (styles[.style] or "") .. "\n[Events]\n"
			events = .raw

			-- It occurs to me that perhaps having the user just pass in one
			-- string that contains both styles and events may be easier.
			if 0 < ASSInspector.assi_setScript( inspector, styles, #styles, events, #events )
				log.warn( assi_getErrorString( inspector ) )
				ASSInspector.assi_cleanup( inspector )
				aegisub.cancel( )

			-- We're going to render a line twice, once at its start and once
			-- at its end.
			renderCount = 2

			renderTimes = { }

			-- Cheat a bit when generating the times to render. This obviously
			-- will not scale with a renderCount > 2.
			if hasTimecodes
				-- Using the aegisub.frame_from_ms function pointers we copied
				-- earlier.
				startFrame = ffms( .start_time )
				endFrame   = ffms( .end_time ) - 1
				renderTimes[0] = math.floor( 0.5*( msff( startFrame ) + msff( startFrame + 1 ) ) )
				renderTimes[1] = math.floor( 0.5*( msff( endFrame ) + msff( endFrame + 1 ) ) )
			else
				renderTimes[0] = .start_time
				-- Trying to render exactly at end_time will result in a
				-- completely blank frame.
				renderTimes[1] = .end_time - 1

			-- Because we're rendering the line twice, we need two rects.
			rects = ffi.new( "ASSI_Rect[?]", renderCount )

			-- Allocate the array of times for rendering.
			times = ffi.new( "int32_t[?]", renderCount )

			-- Set up the render times. There's no real reason to do this in a
			-- loop if you know beforehand you're only going to render the
			-- line twice, but loops are more generic.
			for render = 0, renderCount - 1
				times[render] = renderTimes[render]

			-- Send the data in for rendering and bounds calculation.
			if 0 < ASSInspector.assi_calculateBounds( inspector, rects, times, renderCount )
				log.warn( inspector.error )
				ASSInspector.assi_cleanup( inspector )
				aegisub.cancel( )

			-- Check out the calculated bounding rects
			for render = 0, renderCount - 1
				logRect( rects[render] )

	-- That's all, folks.
	ASSInspector.assi_cleanup( inspector )

aegisub.register_macro( script_name, script_description, mainFunction )


Useless

Useless

--
-- Copyright (C) 2017 DBot
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
--     http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--

net.Receive 'PPM2.DamageAnimation', ->
    ent = net.ReadEntity()
    return if not IsValid(ent) or not ent\GetPonyData()
    hook.Call('PPM2_HurtAnimation', nil, ent)

net.Receive 'PPM2.KillAnimation', ->
    ent = net.ReadEntity()
    return if not IsValid(ent) or not ent\GetPonyData()
    hook.Call('PPM2_KillAnimation', nil, ent)

net.Receive 'PPM2.AngerAnimation', ->
    ent = net.ReadEntity()
    return if not IsValid(ent) or not ent\GetPonyData()
    hook.Call('PPM2_AngerAnimation', nil, ent)

net.Receive 'PPM2.PlayEmote', ->
    emoteID = net.ReadUInt(8)
    ply = net.ReadEntity()
    return if not IsValid(ply) or not ply\GetPonyData()
    return if not PPM2.AVALIABLE_EMOTES[emoteID]
    hook.Call('PPM2_EmoteAnimation', nil, ply, PPM2.AVALIABLE_EMOTES[emoteID].sequence, PPM2.AVALIABLE_EMOTES[emoteID].time)

PPM2.EmotesPanelContext\Remove() if IsValid(PPM2.EmotesPanelContext)
PPM2.EmotesPanel\Remove() if IsValid(PPM2.EmotesPanel)

CONSOLE_EMOTES_COMMAND = (ply = LocalPlayer(), cmd = '', args = {}) ->
    args[1] = args[1] or ''
    emoteID = tonumber(args[1])

    if emoteID
        if not PPM2.AVALIABLE_EMOTES[emoteID]
            PPM2.Message('No such emotion with ID: ', emoteID)
            return
        net.Start('PPM2.PlayEmote')
        net.WriteUInt(emoteID, 8)
        net.SendToServer()
        hook.Call('PPM2_EmoteAnimation', nil, LocalPlayer(), PPM2.AVALIABLE_EMOTES[emoteID].sequence, PPM2.AVALIABLE_EMOTES[emoteID].time)
    else
        emoteID = args[1]\lower()
        if not PPM2.AVALIABLE_EMOTES_BY_SEQUENCE[emoteID]
            PPM2.Message('No such emotion with ID: ', emoteID)
            return
        net.Start('PPM2.PlayEmote')
        net.WriteUInt(PPM2.AVALIABLE_EMOTES_BY_SEQUENCE[emoteID].id, 8)
        net.SendToServer()
        hook.Call('PPM2_EmoteAnimation', nil, LocalPlayer(), emoteID, PPM2.AVALIABLE_EMOTES_BY_SEQUENCE[emoteID].time)

CONSOLE_DEF_LIST = ['ppm2_emote "' .. sequence .. '"' for {:sequence} in *PPM2.AVALIABLE_EMOTES]
CONSOLE_EMOTES_AUTOCOMPLETE = (cmd = '', args = '') ->
    args = args\Trim()
    return CONSOLE_DEF_LIST if args == ''
    output = {}
    for {:sequence} in *PPM2.AVALIABLE_EMOTES
        if string.find(sequence, '^' .. args)
            table.insert(output, 'ppm2_emote "' .. sequence .. '"')
    return output

concommand.Add 'ppm2_emote', CONSOLE_EMOTES_COMMAND, CONSOLE_EMOTES_AUTOCOMPLETE

BUTTON_DRAW_FUNC = (w = 0, h = 0) =>
    @hoverDelta = math.Clamp(@hoverDelta + (@IsHovered() and FrameTime() or -FrameTime()) * 5, 0, 1)
    col = @hoverDelta * 150
    col = 200 if @IsDown()
    surface.SetDrawColor(col, col, col, 150)
    surface.DrawRect(0, 0, w, h)

BUTTON_CLICK_FUNC = =>
    if @sendToServer
        net.Start('PPM2.PlayEmote')
        net.WriteUInt(@id, 8)
        net.SendToServer()
        hook.Call('PPM2_EmoteAnimation', nil, LocalPlayer(), @sequence, @time)
    else
        hook.Call('PPM2_EmoteAnimation', nil, @target, @sequence, @time)

BUTTON_TEXT_COLOR = Color(255, 255, 255)

IMAGE_PANEL_THINK = =>
    @lastThink = RealTime() + .4
    if @IsHovered()
        if not @oldHover
            @oldHover = true
            @hoverPnl\SetVisible(true)
            x, y = @LocalToScreen(0, 0)
            @hoverPnl\SetPos(x - 256, y - 224)
    else
        if @oldHover
            @oldHover = false
            @hoverPnl\SetVisible(false)
HOVERED_IMAGE_PANEL_THINK = =>
    if not @parent\IsValid()
        @Remove()
        return
    if @parent.lastThink < RealTime()
        @SetVisible(false)
PPM2.CreateEmotesPanel = (parent, target = LocalPlayer(), sendToServer = true) ->
    self = vgui.Create('DPanel', parent)
    @SetSize(200, 300)
    @Paint = (w = 0, h = 0) =>
        surface.SetDrawColor(0, 0, 0, 150)
        surface.DrawRect(0, 0, w, h)
    @scroll = vgui.Create('DScrollPanel', @)
    with @scroll
        \Dock(FILL)
        \SetSize(200, 300)
        .Paint = ->
        \SetMouseInputEnabled(true)
    @buttons = for {:name, :id, :sequence, :time, :fexists, :filecrop} in *PPM2.AVALIABLE_EMOTES
        btn = vgui.Create('DButton', @scroll)
        with btn
            \SetTextColor(BUTTON_TEXT_COLOR)
            .Paint = BUTTON_DRAW_FUNC
            .id = id
            .time = time
            .sequence = sequence
            .hoverDelta = 0
            .sendToServer = sendToServer
            .target = target
            .DoClick = BUTTON_CLICK_FUNC
            \SetSize(200, 32)
            \SetText(name)
            \SetFont('HudHintTextLarge')
            \Dock(TOP)
            if fexists
                image = vgui.Create('DImage', btn)
                with image
                    \Dock(LEFT)
                    \SetSize(32, 32)
                    \SetImage(filecrop)
                    \SetMouseInputEnabled(true)
                    .hoverPnl = vgui.Create('DImage')
                    .Think = IMAGE_PANEL_THINK
                    .oldHover = false
                    with .hoverPnl
                        \SetMouseInputEnabled(false)
                        \SetVisible(false)
                        \SetImage(filecrop)
                        \SetSize(256, 256)
                        .Think = HOVERED_IMAGE_PANEL_THINK
                        .parent = image
                    .OnRemove = -> .hoverPnl\Remove() if IsValid(.hoverPnl)
        btn
    @scroll\AddItem(btn) for btn in *@buttons
    @SetVisible(false)
    @SetMouseInputEnabled(false)
    return @

hook.Add 'ContextMenuCreated', 'PPM2.Emotes', =>
    return if not IsValid(@)
    PPM2.EmotesPanelContext\Remove() if IsValid(PPM2.EmotesPanelContext)
    PPM2.EmotesPanelContext = PPM2.CreateEmotesPanel(@)
    PPM2.EmotesPanelContext\SetPos(ScrW() / 2 - 100, ScrH() - 300)
    PPM2.EmotesPanelContext\SetVisible(true)
    PPM2.EmotesPanelContext\SetMouseInputEnabled(true)
    timer.Create 'PPM2.ContextMenuEmotesUpdate', 1, 0, ->
        if not IsValid(PPM2.EmotesPanelContext)
            timer.Remove 'PPM2.ContextMenuEmotesUpdate'
            return
        return if not IsValid(LocalPlayer())
        status = LocalPlayer()\IsPony()
        PPM2.EmotesPanelContext\SetVisible(status)
        PPM2.EmotesPanelContext\SetMouseInputEnabled(status)

hook.Add 'StartChat', 'PPM2.Emotes', ->
    if not IsValid(PPM2.EmotesPanel)
        PPM2.EmotesPanel = PPM2.CreateEmotesPanel()
        PPM2.EmotesPanel\SetPos(ScrW() - 500, ScrH() - 300)

    if IsValid(PPM2.EmotesPanel)
        if LocalPlayer()\IsPony()
            PPM2.EmotesPanel\SetVisible(true)
            PPM2.EmotesPanel\SetMouseInputEnabled(true)
            PPM2.EmotesPanel\RequestFocus()
        else
            PPM2.EmotesPanel\SetVisible(false)
            PPM2.EmotesPanel\SetMouseInputEnabled(false)
            PPM2.EmotesPanel\KillFocus()
hook.Add 'FinishChat', 'PPM2.Emotes', ->
    if IsValid(PPM2.EmotesPanel)
        PPM2.EmotesPanel\KillFocus()
        PPM2.EmotesPanel\SetVisible(false)
        PPM2.EmotesPanel\SetMouseInputEnabled(false)

--
-- Copyright (C) 2017 DBot
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
--     http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--

net.Receive 'PPM2.DamageAnimation', ->
    ent = net.ReadEntity()
    return if not IsValid(ent) or not ent\GetPonyData()
    hook.Call('PPM2_HurtAnimation', nil, ent)

net.Receive 'PPM2.KillAnimation', ->
    ent = net.ReadEntity()
    return if not IsValid(ent) or not ent\GetPonyData()
    hook.Call('PPM2_KillAnimation', nil, ent)

net.Receive 'PPM2.AngerAnimation', ->
    ent = net.ReadEntity()
    return if not IsValid(ent) or not ent\GetPonyData()
    hook.Call('PPM2_AngerAnimation', nil, ent)

net.Receive 'PPM2.PlayEmote', ->
    emoteID = net.ReadUInt(8)
    ply = net.ReadEntity()
    return if not IsValid(ply) or not ply\GetPonyData()
    return if not PPM2.AVALIABLE_EMOTES[emoteID]
    hook.Call('PPM2_EmoteAnimation', nil, ply, PPM2.AVALIABLE_EMOTES[emoteID].sequence, PPM2.AVALIABLE_EMOTES[emoteID].time)

PPM2.EmotesPanelContext\Remove() if IsValid(PPM2.EmotesPanelContext)
PPM2.EmotesPanel\Remove() if IsValid(PPM2.EmotesPanel)

CONSOLE_EMOTES_COMMAND = (ply = LocalPlayer(), cmd = '', args = {}) ->
    args[1] = args[1] or ''
    emoteID = tonumber(args[1])

    if emoteID
        if not PPM2.AVALIABLE_EMOTES[emoteID]
            PPM2.Message('No such emotion with ID: ', emoteID)
            return
        net.Start('PPM2.PlayEmote')
        net.WriteUInt(emoteID, 8)
        net.SendToServer()
        hook.Call('PPM2_EmoteAnimation', nil, LocalPlayer(), PPM2.AVALIABLE_EMOTES[emoteID].sequence, PPM2.AVALIABLE_EMOTES[emoteID].time)
    else
        emoteID = args[1]\lower()
        if not PPM2.AVALIABLE_EMOTES_BY_SEQUENCE[emoteID]
            PPM2.Message('No such emotion with ID: ', emoteID)
            return
        net.Start('PPM2.PlayEmote')
        net.WriteUInt(PPM2.AVALIABLE_EMOTES_BY_SEQUENCE[emoteID].id, 8)
        net.SendToServer()
        hook.Call('PPM2_EmoteAnimation', nil, LocalPlayer(), emoteID, PPM2.AVALIABLE_EMOTES_BY_SEQUENCE[emoteID].time)

CONSOLE_DEF_LIST = ['ppm2_emote "' .. sequence .. '"' for {:sequence} in *PPM2.AVALIABLE_EMOTES]
CONSOLE_EMOTES_AUTOCOMPLETE = (cmd = '', args = '') ->
    args = args\Trim()
    return CONSOLE_DEF_LIST if args == ''
    output = {}
    for {:sequence} in *PPM2.AVALIABLE_EMOTES
        if string.find(sequence, '^' .. args)
            table.insert(output, 'ppm2_emote "' .. sequence .. '"')
    return output

concommand.Add 'ppm2_emote', CONSOLE_EMOTES_COMMAND, CONSOLE_EMOTES_AUTOCOMPLETE

BUTTON_DRAW_FUNC = (w = 0, h = 0) =>
    @hoverDelta = math.Clamp(@hoverDelta + (@IsHovered() and FrameTime() or -FrameTime()) * 5, 0, 1)
    col = @hoverDelta * 150
    col = 200 if @IsDown()
    surface.SetDrawColor(col, col, col, 150)
    surface.DrawRect(0, 0, w, h)

BUTTON_CLICK_FUNC = =>
    if @sendToServer
        net.Start('PPM2.PlayEmote')
        net.WriteUInt(@id, 8)
        net.SendToServer()
        hook.Call('PPM2_EmoteAnimation', nil, LocalPlayer(), @sequence, @time)
    else
        hook.Call('PPM2_EmoteAnimation', nil, @target, @sequence, @time)

BUTTON_TEXT_COLOR = Color(255, 255, 255)

IMAGE_PANEL_THINK = =>
    @lastThink = RealTime() + .4
    if @IsHovered()
        if not @oldHover
            @oldHover = true
            @hoverPnl\SetVisible(true)
            x, y = @LocalToScreen(0, 0)
            @hoverPnl\SetPos(x - 256, y - 224)
    else
        if @oldHover
            @oldHover = false
            @hoverPnl\SetVisible(false)
HOVERED_IMAGE_PANEL_THINK = =>
    if not @parent\IsValid()
        @Remove()
        return
    if @parent.lastThink < RealTime()
        @SetVisible(false)
PPM2.CreateEmotesPanel = (parent, target = LocalPlayer(), sendToServer = true) ->
    self = vgui.Create('DPanel', parent)
    @SetSize(200, 300)
    @Paint = (w = 0, h = 0) =>
        surface.SetDrawColor(0, 0, 0, 150)
        surface.DrawRect(0, 0, w, h)
    @scroll = vgui.Create('DScrollPanel', @)
    with @scroll
        \Dock(FILL)
        \SetSize(200, 300)
        .Paint = ->
        \SetMouseInputEnabled(true)
    @buttons = for {:name, :id, :sequence, :time, :fexists, :filecrop} in *PPM2.AVALIABLE_EMOTES
        btn = vgui.Create('DButton', @scroll)
        with btn
            \SetTextColor(BUTTON_TEXT_COLOR)
            .Paint = BUTTON_DRAW_FUNC
            .id = id
            .time = time
            .sequence = sequence
            .hoverDelta = 0
            .sendToServer = sendToServer
            .target = target
            .DoClick = BUTTON_CLICK_FUNC
            \SetSize(200, 32)
            \SetText(name)
            \SetFont('HudHintTextLarge')
            \Dock(TOP)
            if fexists
                image = vgui.Create('DImage', btn)
                with image
                    \Dock(LEFT)
                    \SetSize(32, 32)
                    \SetImage(filecrop)
                    \SetMouseInputEnabled(true)
                    .hoverPnl = vgui.Create('DImage')
                    .Think = IMAGE_PANEL_THINK
                    .oldHover = false
                    with .hoverPnl
                        \SetMouseInputEnabled(false)
                        \SetVisible(false)
                        \SetImage(filecrop)
                        \SetSize(256, 256)
                        .Think = HOVERED_IMAGE_PANEL_THINK
                        .parent = image
                    .OnRemove = -> .hoverPnl\Remove() if IsValid(.hoverPnl)
        btn
    @scroll\AddItem(btn) for btn in *@buttons
    @SetVisible(false)
    @SetMouseInputEnabled(false)
    return @

hook.Add 'ContextMenuCreated', 'PPM2.Emotes', =>
    timer.Simple 0, ->
        timer.Simple 0, ->
            timer.Simple 0, -> -- fuk
                return if not IsValid(@)
                PPM2.EmotesPanelContext\Remove() if IsValid(PPM2.EmotesPanelContext)
                PPM2.EmotesPanelContext = PPM2.CreateEmotesPanel(@)
                PPM2.EmotesPanelContext\SetPos(ScrW() / 2 - 100, ScrH() - 300)
                PPM2.EmotesPanelContext\SetVisible(true)
                PPM2.EmotesPanelContext\SetMouseInputEnabled(true)
                timer.Create 'PPM2.ContextMenuEmotesUpdate', 1, 0, ->
                    if not IsValid(PPM2.EmotesPanelContext)
                        timer.Remove 'PPM2.ContextMenuEmotesUpdate'
                        return
                    return if not IsValid(LocalPlayer())
                    status = LocalPlayer()\IsPony()
                    PPM2.EmotesPanelContext\SetVisible(status)
                    PPM2.EmotesPanelContext\SetMouseInputEnabled(status)

hook.Add 'StartChat', 'PPM2.Emotes', ->
    if not IsValid(PPM2.EmotesPanel)
        PPM2.EmotesPanel = PPM2.CreateEmotesPanel()
        PPM2.EmotesPanel\SetPos(ScrW() - 500, ScrH() - 300)

    if IsValid(PPM2.EmotesPanel)
        if LocalPlayer()\IsPony()
            PPM2.EmotesPanel\SetVisible(true)
            PPM2.EmotesPanel\SetMouseInputEnabled(true)
            PPM2.EmotesPanel\RequestFocus()
        else
            PPM2.EmotesPanel\SetVisible(false)
            PPM2.EmotesPanel\SetMouseInputEnabled(false)
            PPM2.EmotesPanel\KillFocus()
hook.Add 'FinishChat', 'PPM2.Emotes', ->
    if IsValid(PPM2.EmotesPanel)
        PPM2.EmotesPanel\KillFocus()
        PPM2.EmotesPanel\SetVisible(false)
        PPM2.EmotesPanel\SetMouseInputEnabled(false)


main: Allow for per-bot commands during configuration

#!/usr/bin/env moon
--- Create and manage IRC bots.
-- @script main.moon
-- @author Ryan "ChickenNuggers" <ryan@hashbang.sh>

Logger = require 'logger' -- vim:set noet sts=0 sw=3 ts=3:
cqueues = require 'cqueues'
lfs = require 'lfs'
moonscript = require "moonscript"

import IRCClient from require 'lib.irc'

wd = lfs.currentdir()
Logger.set_debug true if os.getenv 'DEBUG'

pcall -> -- use StackTracePlus if available; it's much better than builtin
	debug.traceback = require("StackTracePlus").stacktrace

load_modules = (folder)->
	for file in lfs.dir folder
		if file\match "%.moon$"
			path = "#{folder}/#{file}"
			IRCClient\with_context path, assert moonscript.loadfile path

load_modules_in_plugin_folders = ->
	for module_folder in *{'plugins', 'modules'}
		full_path = wd .. '/' .. module_folder
		load_modules full_path if lfs.attributes(full_path, 'mode') == 'directory'

load_modules_in_plugin_folders!

bots = {}
conf_home = os.getenv('XDG_CONFIG_HOME') or os.getenv('HOME') .. '/.config'
for file in lfs.dir "#{conf_home}/moonmoon"
	if file\match "%.lua$"
		local data
		env =
			bot: (name)->
				return (file_data)->
					file_data.name = name
					file_data.file = file
					data = file_data

			async: (fn)->
				return (...)->
					args = {...}
					require("queue")\wrap -> fn table.unpack args
		setmetatable(env, __index: _ENV)

		fn = assert loadfile("#{conf_home}/moonmoon/#{file}", nil, env)
		fn!
		unless data and data.server
			print "Missing `server` field: [#{file}]"
			continue
		if os.getenv 'DEBUG'
			for key, value in pairs data
				if type(value) == "string" then
					print ("%q: %q")\format key, value
				else
					print ("%q: %s")\format key, value
		bot = IRCClient data.server, data.port, data
		bot\with_context "config", ->
			if bot.config.custom_commands
				for command, handler in pairs bot.config.custom_commands
					bot\add_command command, handler
		table.insert(bots, bot)

queue = cqueues.new!

package.loaded["queue"] = queue -- for use in modules

for bot in *bots
	queue\wrap ->
		while true
			local success
			for i=1, 3 do -- three tries
				ok, err = pcall bot.connect, bot
				success = ok
				if not ok
					Logger.print Logger.level.error .. '*** Unable to connect: ' .. bot.config.server
					Logger.debug Logger.level.error .. '*** ' .. err
				else
					break
			if not success
				Logger.print Logger.level.fatal .. '*** Not connecting anymore for: ' .. bot.config.file
				return

			ok, err = pcall -> bot\loop!
			if not ok then
				Logger.print Logger.level.error .. err

while not queue\empty!
	ok, err = queue\step!
	if not ok
		Logger.debug "#{Logger.level.error} *** #{err}"

#!/usr/bin/env moon
--- Create and manage IRC bots.
-- @script main.moon
-- @author Ryan "ChickenNuggers" <ryan@hashbang.sh>

Logger = require 'logger' -- vim:set noet sts=0 sw=3 ts=3:
cqueues = require 'cqueues'
lfs = require 'lfs'
moonscript = require "moonscript"

import IRCClient from require 'lib.irc'

wd = lfs.currentdir()
Logger.set_debug true if os.getenv 'DEBUG'

pcall -> -- use StackTracePlus if available; it's much better than builtin
	debug.traceback = require("StackTracePlus").stacktrace

load_modules = (folder)->
	for file in lfs.dir folder
		if file\match "%.moon$"
			path = "#{folder}/#{file}"
			IRCClient\with_context path, assert moonscript.loadfile path

load_modules_in_plugin_folders = ->
	for module_folder in *{'plugins', 'modules'}
		full_path = wd .. '/' .. module_folder
		load_modules full_path if lfs.attributes(full_path, 'mode') == 'directory'

load_modules_in_plugin_folders!

bots = {}
conf_home = os.getenv('XDG_CONFIG_HOME') or os.getenv('HOME') .. '/.config'
for file in lfs.dir "#{conf_home}/moonmoon"
	if file\match "%.lua$"
		local data
		env =
			bot: (name)->
				return (file_data)->
					file_data.name = name
					file_data.file = file
					data = file_data

			async: (fn)->
				return (...)->
					args = {...}
					require("queue")\wrap -> fn table.unpack args
		setmetatable(env, __index: _ENV)

		fn = assert loadfile("#{conf_home}/moonmoon/#{file}", nil, env)
		fn!
		unless data and data.server
			print "Missing `server` field: [#{file}]"
			continue
		if os.getenv 'DEBUG'
			for key, value in pairs data
				if type(value) == "string" then
					print ("%q: %q")\format key, value
				else
					print ("%q: %s")\format key, value
		bot = IRCClient data.server, data.port, data
		table.insert(bots, bot)

queue = cqueues.new!

package.loaded["queue"] = queue -- for use in modules

for bot in *bots
	queue\wrap ->
		while true
			local success
			for i=1, 3 do -- three tries
				ok, err = pcall bot.connect, bot
				success = ok
				if not ok
					Logger.print Logger.level.error .. '*** Unable to connect: ' .. bot.config.server
					Logger.debug Logger.level.error .. '*** ' .. err
				else
					break
			if not success
				Logger.print Logger.level.fatal .. '*** Not connecting anymore for: ' .. bot.config.file
				return

			ok, err = pcall -> bot\loop!
			if not ok then
				Logger.print Logger.level.error .. err

while not queue\empty!
	ok, err = queue\step!
	if not ok
		Logger.debug "#{Logger.level.error} *** #{err}"


Randomize sizes

Randomize sizes

--
-- Copyright (C) 2017 DBot
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
--     http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--

PREFIX = '[PPM2] '
PREFIX_DEBUG = '[PPM2 DEBUG] '
PREFIX_COLOR = Color(95, 188, 179)
DEBUG_COLOR = Color(209, 207, 183)
DEFAULT_TEXT_COLOR = Color(200, 200, 200)
NUMBER_COLOR = Color(245, 199, 64)
STEAMID_COLOR = Color(255, 255, 255)
ENTITY_COLOR = Color(180, 232, 180)
NPC_COLOR = Color(180, 213, 232)
VEHICLE_COLOR = Color(192, 180, 232)
FUNCTION_COLOR = Color(62, 106, 255)
TABLE_COLOR = Color(107, 200, 224)
URL_COLOR = Color(174, 124, 192)

PPM2.Format = (...) ->
	previousColor = DEFAULT_TEXT_COLOR
	output = {previousColor}

	for value in *{...}
		switch type(value)
			when 'table'
				if value.r and value.g and value.b and value.a
					table.insert(output, value)
					previousColor = value
                else
                    table.insert(output, TABLE_COLOR)
                    table.insert(output, tostring(value))
                    table.insert(output, previousColor)
			when 'Entity'
				table.insert(output, ENTITY_COLOR)
				table.insert(output, tostring(value))
				table.insert(output, previousColor)
			when 'string'
                if value\find('^https?://')
                    table.insert(output, URL_COLOR)
                    table.insert(output, value)
                    table.insert(output, previousColor)
                else
				    table.insert(output, value)
			when 'number'
				table.insert(output, NUMBER_COLOR)
				table.insert(output, tostring(value))
				table.insert(output, previousColor)
			when 'Player'
				tm = value\Team()
				table.insert(output, team.GetColor(tm))
				table.insert(output, value\Nick())
				table.insert(output, " (#{value\SteamName()})") if value.SteamName
				table.insert(output, STEAMID_COLOR)
				table.insert(output, "<#{value\SteamID()}>")
				table.insert(output, previousColor)
			when 'NPC'
				table.insert(output, NPC_COLOR)
				table.insert(output, "[NPC:#{value\GetClass()}]")
				table.insert(output, previousColor)
			when 'Vehicle'
				table.insert(output, VEHICLE_COLOR)
				table.insert(output, "[Vehicle:#{value\GetClass()}|#{value\GetModel()}]")
				table.insert(output, previousColor)
			when 'function'
				table.insert(output, FUNCTION_COLOR)
				table.insert(output, tostring(value))
				table.insert(output, previousColor)
			else
				table.insert(output, tostring(value))
	return output

PPM2.Message = (...) ->
    frmt = PPM2.Format(...)
    MsgC(PREFIX_COLOR, PREFIX, unpack(frmt))
    MsgC('\n')
    return frmt

if CLIENT
    PPM2.ChatPrint = (...) ->
        frmt = PPM2.Format(...)
        chat.AddText(PREFIX_COLOR, PREFIX, unpack(frmt))
        return frmt

DEBUG_LEVEL = CreateConVar('ppm2_debug', '0', {}, 'Enables debug printing. LOTS OF IT. 1 - simple messages; 2 - messages with traceback.')

PPM2.DebugPrint = (...) ->
    return if DEBUG_LEVEL\GetInt() <= 0
    frmt = PPM2.Format(DEBUG_COLOR, ...)
    MsgC(DEBUG_COLOR, PREFIX_DEBUG, unpack(frmt))
    MsgC('\n')
    if DEBUG_LEVEL\GetInt() >= 2
        MsgC(DEBUG_COLOR, debug.traceback())
        MsgC('\n')
    return frmt

PPM2.TransformNewModelID = (id = 0) ->
    bgID = id % 17
    maneModelID = math.floor(id / 16 - .01) + 1
    maneModelID = 1 if maneModelID == 0
    return maneModelID, bgID

do
    randomColor = (a = 255) -> Color(math.random(0, 255), math.random(0, 255), math.random(0, 255), a)
    PPM2.Randomize = (object, ...) ->
        mane, manelower, tail = math.random(PPM2.MIN_UPPER_MANES_NEW, PPM2.MAX_UPPER_MANES_NEW), math.random(PPM2.MIN_LOWER_MANES_NEW, PPM2.MAX_LOWER_MANES_NEW), math.random(PPM2.MIN_TAILS_NEW, PPM2.MAX_TAILS_NEW)
        irisSize = math.random(PPM2.MIN_IRIS * 10, PPM2.MAX_IRIS * 10) / 10
        with object
            \SetGender(math.random(0, 1), ...)
            \SetRace(math.random(0, 3), ...)
            \SetPonySize(math.random(85, 110) / 100, ...)
            \SetNeckSize(math.random(92, 108) / 100, ...)
            \SetLegsSize(math.random(90, 120) / 100, ...)
            \SetWeight(math.random(PPM2.MIN_WEIGHT * 10, PPM2.MAX_WEIGHT * 10) / 10, ...)
            \SetTailType(tail, ...)
            \SetTailTypeNew(tail, ...)
            \SetManeType(mane, ...)
            \SetManeTypeLower(manelower, ...)
            \SetManeTypeNew(mane, ...)
            \SetManeTypeLowerNew(manelower, ...)
            \SetBodyColor(randomColor(), ...)
            \SetEyeIrisTop(randomColor(), ...)
            \SetEyeIrisBottom(randomColor(), ...)
            \SetEyeIrisLine1(randomColor(), ...)
            \SetEyeIrisLine2(randomColor(), ...)
            \SetIrisSize(irisSize, ...)
            \SetManeColor1(randomColor(), ...)
            \SetManeColor2(randomColor(), ...)
            \SetManeDetailColor1(randomColor(), ...)
            \SetManeDetailColor2(randomColor(), ...)
            \SetUpperManeColor1(randomColor(), ...)
            \SetUpperManeColor2(randomColor(), ...)
            \SetUpperManeDetailColor1(randomColor(), ...)
            \SetUpperManeDetailColor2(randomColor(), ...)
            \SetLowerManeColor1(randomColor(), ...)
            \SetLowerManeColor2(randomColor(), ...)
            \SetLowerManeDetailColor1(randomColor(), ...)
            \SetLowerManeDetailColor2(randomColor(), ...)
            \SetTailColor1(randomColor(), ...)
            \SetTailColor2(randomColor(), ...)
            \SetTailDetailColor1(randomColor(), ...)
            \SetTailDetailColor2(randomColor(), ...)
            \SetSocksAsModel(math.random(1, 2) == 1, ...)
            \SetSocksColor(randomColor(), ...)
        return object

entMeta = FindMetaTable('Entity')
entMeta.IsPony = =>
    model = @GetModel()
    @__ppm2_lastmodel = @__ppm2_lastmodel or model
    if @__ppm2_lastmodel ~= model
        data = @GetPonyData()
        if data and data.ModelChanges
            oldModel = @__ppm2_lastmodel
            @__ppm2_lastmodel = model
            data\ModelChanges(oldModel, model)
    switch model
        when 'models/ppm/player_default_base.mdl'
            return true
        when 'models/ppm/player_default_base_new.mdl'
            return true
        when 'models/ppm/player_default_base_new_nj.mdl'
            return true
        when 'models/ppm/player_default_base_nj.mdl'
            return true
        when 'models/cppm/player_default_base.mdl'
            return true
        when 'models/cppm/player_default_base_nj.mdl'
            return true
        else
            return false
entMeta.IsPonyCached = =>
    switch @__ppm2_lastmodel
        when 'models/ppm/player_default_base.mdl'
            return true
        when 'models/ppm/player_default_base_new.mdl'
            return true
        when 'models/ppm/player_default_base_new_nj.mdl'
            return true
        when 'models/ppm/player_default_base_nj.mdl'
            return true
        when 'models/cppm/player_default_base.mdl'
            return true
        when 'models/cppm/player_default_base_nj.mdl'
            return true
        else
            return false
entMeta.HasPonyModel = entMeta.IsPony

--
-- Copyright (C) 2017 DBot
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
--     http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--

PREFIX = '[PPM2] '
PREFIX_DEBUG = '[PPM2 DEBUG] '
PREFIX_COLOR = Color(95, 188, 179)
DEBUG_COLOR = Color(209, 207, 183)
DEFAULT_TEXT_COLOR = Color(200, 200, 200)
NUMBER_COLOR = Color(245, 199, 64)
STEAMID_COLOR = Color(255, 255, 255)
ENTITY_COLOR = Color(180, 232, 180)
NPC_COLOR = Color(180, 213, 232)
VEHICLE_COLOR = Color(192, 180, 232)
FUNCTION_COLOR = Color(62, 106, 255)
TABLE_COLOR = Color(107, 200, 224)
URL_COLOR = Color(174, 124, 192)

PPM2.Format = (...) ->
	previousColor = DEFAULT_TEXT_COLOR
	output = {previousColor}

	for value in *{...}
		switch type(value)
			when 'table'
				if value.r and value.g and value.b and value.a
					table.insert(output, value)
					previousColor = value
                else
                    table.insert(output, TABLE_COLOR)
                    table.insert(output, tostring(value))
                    table.insert(output, previousColor)
			when 'Entity'
				table.insert(output, ENTITY_COLOR)
				table.insert(output, tostring(value))
				table.insert(output, previousColor)
			when 'string'
                if value\find('^https?://')
                    table.insert(output, URL_COLOR)
                    table.insert(output, value)
                    table.insert(output, previousColor)
                else
				    table.insert(output, value)
			when 'number'
				table.insert(output, NUMBER_COLOR)
				table.insert(output, tostring(value))
				table.insert(output, previousColor)
			when 'Player'
				tm = value\Team()
				table.insert(output, team.GetColor(tm))
				table.insert(output, value\Nick())
				table.insert(output, " (#{value\SteamName()})") if value.SteamName
				table.insert(output, STEAMID_COLOR)
				table.insert(output, "<#{value\SteamID()}>")
				table.insert(output, previousColor)
			when 'NPC'
				table.insert(output, NPC_COLOR)
				table.insert(output, "[NPC:#{value\GetClass()}]")
				table.insert(output, previousColor)
			when 'Vehicle'
				table.insert(output, VEHICLE_COLOR)
				table.insert(output, "[Vehicle:#{value\GetClass()}|#{value\GetModel()}]")
				table.insert(output, previousColor)
			when 'function'
				table.insert(output, FUNCTION_COLOR)
				table.insert(output, tostring(value))
				table.insert(output, previousColor)
			else
				table.insert(output, tostring(value))
	return output

PPM2.Message = (...) ->
    frmt = PPM2.Format(...)
    MsgC(PREFIX_COLOR, PREFIX, unpack(frmt))
    MsgC('\n')
    return frmt

if CLIENT
    PPM2.ChatPrint = (...) ->
        frmt = PPM2.Format(...)
        chat.AddText(PREFIX_COLOR, PREFIX, unpack(frmt))
        return frmt

DEBUG_LEVEL = CreateConVar('ppm2_debug', '0', {}, 'Enables debug printing. LOTS OF IT. 1 - simple messages; 2 - messages with traceback.')

PPM2.DebugPrint = (...) ->
    return if DEBUG_LEVEL\GetInt() <= 0
    frmt = PPM2.Format(DEBUG_COLOR, ...)
    MsgC(DEBUG_COLOR, PREFIX_DEBUG, unpack(frmt))
    MsgC('\n')
    if DEBUG_LEVEL\GetInt() >= 2
        MsgC(DEBUG_COLOR, debug.traceback())
        MsgC('\n')
    return frmt

PPM2.TransformNewModelID = (id = 0) ->
    bgID = id % 17
    maneModelID = math.floor(id / 16 - .01) + 1
    maneModelID = 1 if maneModelID == 0
    return maneModelID, bgID

do
    randomColor = (a = 255) -> Color(math.random(0, 255), math.random(0, 255), math.random(0, 255), a)
    PPM2.Randomize = (object, ...) ->
        mane, manelower, tail = math.random(PPM2.MIN_UPPER_MANES_NEW, PPM2.MAX_UPPER_MANES_NEW), math.random(PPM2.MIN_LOWER_MANES_NEW, PPM2.MAX_LOWER_MANES_NEW), math.random(PPM2.MIN_TAILS_NEW, PPM2.MAX_TAILS_NEW)
        irisSize = math.random(PPM2.MIN_IRIS * 10, PPM2.MAX_IRIS * 10) / 10
        with object
            \SetGender(math.random(0, 1), ...)
            \SetRace(math.random(0, 3), ...)
            \SetWeight(math.random(PPM2.MIN_WEIGHT * 10, PPM2.MAX_WEIGHT * 10) / 10, ...)
            \SetTailType(tail, ...)
            \SetTailTypeNew(tail, ...)
            \SetManeType(mane, ...)
            \SetManeTypeLower(manelower, ...)
            \SetManeTypeNew(mane, ...)
            \SetManeTypeLowerNew(manelower, ...)
            \SetBodyColor(randomColor(), ...)
            \SetEyeIrisTop(randomColor(), ...)
            \SetEyeIrisBottom(randomColor(), ...)
            \SetEyeIrisLine1(randomColor(), ...)
            \SetEyeIrisLine2(randomColor(), ...)
            \SetIrisSize(irisSize, ...)
            \SetManeColor1(randomColor(), ...)
            \SetManeColor2(randomColor(), ...)
            \SetManeDetailColor1(randomColor(), ...)
            \SetManeDetailColor2(randomColor(), ...)
            \SetUpperManeColor1(randomColor(), ...)
            \SetUpperManeColor2(randomColor(), ...)
            \SetUpperManeDetailColor1(randomColor(), ...)
            \SetUpperManeDetailColor2(randomColor(), ...)
            \SetLowerManeColor1(randomColor(), ...)
            \SetLowerManeColor2(randomColor(), ...)
            \SetLowerManeDetailColor1(randomColor(), ...)
            \SetLowerManeDetailColor2(randomColor(), ...)
            \SetTailColor1(randomColor(), ...)
            \SetTailColor2(randomColor(), ...)
            \SetTailDetailColor1(randomColor(), ...)
            \SetTailDetailColor2(randomColor(), ...)
            \SetSocksAsModel(math.random(1, 2) == 1, ...)
            \SetSocksColor(randomColor(), ...)
        return object

entMeta = FindMetaTable('Entity')
entMeta.IsPony = =>
    model = @GetModel()
    @__ppm2_lastmodel = @__ppm2_lastmodel or model
    if @__ppm2_lastmodel ~= model
        data = @GetPonyData()
        if data and data.ModelChanges
            oldModel = @__ppm2_lastmodel
            @__ppm2_lastmodel = model
            data\ModelChanges(oldModel, model)
    switch model
        when 'models/ppm/player_default_base.mdl'
            return true
        when 'models/ppm/player_default_base_new.mdl'
            return true
        when 'models/ppm/player_default_base_new_nj.mdl'
            return true
        when 'models/ppm/player_default_base_nj.mdl'
            return true
        when 'models/cppm/player_default_base.mdl'
            return true
        when 'models/cppm/player_default_base_nj.mdl'
            return true
        else
            return false
entMeta.IsPonyCached = =>
    switch @__ppm2_lastmodel
        when 'models/ppm/player_default_base.mdl'
            return true
        when 'models/ppm/player_default_base_new.mdl'
            return true
        when 'models/ppm/player_default_base_new_nj.mdl'
            return true
        when 'models/ppm/player_default_base_nj.mdl'
            return true
        when 'models/cppm/player_default_base.mdl'
            return true
        when 'models/cppm/player_default_base_nj.mdl'
            return true
        else
            return false
entMeta.HasPonyModel = entMeta.IsPony