Telegram now supports Scripts

You can add scripts to your Telegram rendering so that you can programmatically add snippets, access external data, and do all kinds of other stuff as part of rendering your Telegram site.

Our first Script

Here's a pretty complex script I wrote as part of migrating a PHP site to Telegram:

package sloth

import org.hoisted.lib._
import net.liftweb._
import common._
import util._
import Helpers._
import scala.xml._

object Moose {

  def renderNews(xml: NodeSeq): NodeSeq => NodeSeq = {
    def order(n: Node): Int = (n \ "LinkID").headOption.flatMap(x => Helpers.asInt(x.text)) getOrElse 1
    val what = (xml \ "Link").toList.sortWith((n, n2) => order(n) < order(n2))

    def start = "http://www.lafayettedolphins.net/index.php?item="

    def fixLink(in: Node): String = in.text match {
      case s if s.startsWith(start) => "/content/" + s.substring(start.length).toLowerCase
      case s if s.startsWith("http:") || s.startsWith("https:") => s
      case s if !s.startsWith("/") => "/content/" + s.toLowerCase
      case s => s
    }

    "li" #> what.map(item =>
      "img [src+]" #> (((item \ "LinkType").headOption.map(_.text) match {
        case Some("pdf") => "pdf_icon.png"
        case _ => "external_link_icon.png"
      }): String) andThen "a [href]" #> (item \ "LinkUrl").headOption.map(fixLink(_)) &
        "a [target]" #> ((item \ "LinkType").headOption.map(_.text) match {
          case Some("pdf") => "pdfWindow"
          case Some("external") => "_blank"
          case _ => "_self"
        }) & "a -*" #> (item \ "LinkDisplayName").headOption.map(_.text))
  }
}

object Item {
  def fromXml(in: NodeSeq): List[Item] = {

    ((in \ "Item").toList.flatMap(fromXml(_)) :::
      (in \ "SubItem").toList.flatMap(fromXml(_)) :::
      (((in \ "ItemID").map(_.text).flatMap(Helpers.asInt(_)).headOption,
        (in \ "ItemName").map(_.text.replace(" ", "_").toLowerCase).headOption,
        (in \ "ItemDisplayName").map(_.text).headOption,
        (in \ "ItemDescription").map(_.text).headOption) match {
        case (Some(id), Some(name), Some(displayName), Some(description)) =>

          List(Item(id, name, displayName, description, fromXml(in \ "SubItems")))
        case _ => Nil
      }) :::
      (((in \ "SubItemID").map(_.text).flatMap(Helpers.asInt(_)).headOption,
        (in \ "SubItemName").map(_.text.replace(" ", "_").toLowerCase).headOption,
        (in \ "SubItemDisplayName").map(_.text).headOption,
        (in \ "SubItemDescription").map(_.text).headOption) match {
        case (Some(id), Some(name), Some(displayName), Some(description)) =>
          List(Item(id, name, displayName, description, fromXml(in \ "SubItems")))
        case _ => Nil
      })).sortWith((a, b) => a.id < b.id)
  }
}

case class Item(id: Int, name: String, displayName: String, description: String,
                kids: List[Item]) {
  def path = name match {
    case "home" => "/home"
    case x => "/content/" + x
  }
}


class Moose extends PluginPhase1 {
  def apply(in: List[ParsedFile]): List[ParsedFile] = {

    val env = HoistedEnvironmentManager.value

    val promo = in.filter(p => p.fileInfo.pathAndSuffix.path.startsWith("promotion_slide_show" :: Nil) &&
      p.fileInfo.pathAndSuffix.suffix == Some("jpg"))

    env.addSnippet {
      case ("promo", "render") =>
        Full("img" #> promo.map(file => "img [src]" #> file.fileInfo.pathAndSuffix.display))
    }
    
    
    for {
      xml <- HoistedUtil.xmlForFile("school_news" :: "links.xml" :: Nil, in)
    } {
      env.addSnippet(
        Map(("dolphins", "news") -> Full(Moose.renderNews(xml))))
    }

    for {
      xml <- HoistedUtil.xmlForFile("navigation" :: "navigation.xml" :: Nil, in)
    } {

      def currentPath(i: Item): Boolean = {
        CurrentFile.value match {
          case null => false
          case f => f.fileInfo.pathAndSuffix.path.contains(i.name)
        }
      }

      val items = Item.fromXml(xml \ "Items")
      env.addSnippet {
        case ("dmenu", "short") => Full("li" #> items.map(i => (if (currentPath(i)) "li [class+]" #> "selected" else PassThru) andThen
          "a *" #> i.displayName & "a [href]" #> i.path))


        case ("dmenu", "bottom") => Full("li" #> items.zipWithIndex.map {
          case (i, _) if i.kids.isEmpty =>

            "a [class]" #> "header" & "a [href]" #> i.path & "a *" #> i.displayName
          case (i, pos) =>
            "a [id]" #> ("footer_" + (pos + 1)) & "a [class]" #> "header" & "a [href]" #> i.path &
              "a [onMouseover]" #> ("showmenu('footer_" + (pos + 1) + "', event, linkset[" + (pos + 1) + "])") & "a [onMouseout]" #> "delayhidemenu()" &
              "a *" #> i.displayName

        })

        case ("dmenu", "left") =>
          def topItem = items.find(i => CurrentFile.value.fileInfo.pathAndSuffix.path.contains(i.name)).headOption

          Full("li" #> topItem.toList.flatMap(topper => {

            topper.kids.map(i => (if (currentPath(i)) "li [class+]" #> "selected" else PassThru) andThen "a *" #> i.displayName & "a [href]" #>
              (if (i.name == "home") "/home" else "/content/" + topper.name + "/" + i.name))
          }))

      }
    }

    in.flatMap(pf => pf.fileInfo.pathAndSuffix.path match {
      case "content" :: rest if rest.takeRight(1) != List("index") =>
        val fi = pf.fileInfo
        val ps = fi.pathAndSuffix
        val nps = ps.copy(path = ps.path.dropRight(1) ::: List("index"))
        val nfi = nps.toFileInfo(Empty)

        val ret = pf.updateFileInfo(nfi)

        List(pf, ret)

      case _ => List(pf)
    })
  }
}

Let's break the script down:

All *.scala files in the _scripts directory or any subdirectories will be compiled.

All top level classes (not inner classes) that extend the PluginPhase1 class will be instantiated and the apply(in: List[ParsedFile]): List[ParsedFile] method will be called at Phase 1, which is after the files and metadata from the base repository has been read, but before the linked repositories have been loaded. There will be other plugin phases in the future, but right now, we only have access to Phase 1.

The parameter is the list of all the ParseFiles found as part of the initial scan. You can also access the current EnvironmentManager via the HoistedEnvironmentManager thread-local.

Find all the promotional images and create a snippet for them

The first thing our script does is to find all the jpg files in the promotional_slide_show directory and create a snippet that will populate <img> tags will be images:

    val promo = in.filter(p => p.fileInfo.pathAndSuffix.path.startsWith("promotion_slide_show" :: Nil) &&
      p.fileInfo.pathAndSuffix.suffix == Some("jpg"))

    env.addSnippet {
      case ("promo", "render") =>
        Full("img" #> promo.map(file => "img [src]" #> file.fileInfo.pathAndSuffix.display))
    }

We do this by filtering the incoming list of files to find the matching files. When we register a new snippet with the EnvironmentManager that will populate the src attribute of the <img> tags with the path to the file.

The env.addSnippet method takes a PartualFunction[(String, String), Box[NodeSeq => NodeSeq]]. The pair of Strings is the snippet name, so in this case, the snippet would be invoked with <div data-lift="promo"><img></div> because the second item in the pair is render and that translates to a blank name. If the pair were ("promo", "images") then the invocation would be <div data-lift="promo.images"><img></div>.

School News based on an XML file

The site that I've converted was PHP based and contained a bunch of XML files with configuration information. The next part of the code reads the /school_news/links.xml file and, if the file exists and can be parsed, the dolphin.news snippet is created.

    for {
      xml <- HoistedUtil.xmlForFile("school_news" :: "links.xml" :: Nil, in)
    } {
      env.addSnippet(
        Map(("dolphins", "news") -> Full(Moose.renderNews(xml))))
    }

Here's how the dolphins.news snippet works:


  def renderNews(xml: NodeSeq): NodeSeq => NodeSeq = {
    def order(n: Node): Int = (n \ "LinkID").headOption.flatMap(x => Helpers.asInt(x.text)) getOrElse 1
    val what = (xml \ "Link").toList.sortWith((n, n2) => order(n) < order(n2))

    def start = "http://www.lafayettedolphins.net/index.php?item="

    def fixLink(in: Node): String = in.text match {
      case s if s.startsWith(start) => "/content/" + s.substring(start.length).toLowerCase
      case s if s.startsWith("http:") || s.startsWith("https:") => s
      case s if !s.startsWith("/") => "/content/" + s.toLowerCase
      case s => s
    }

    "li" #> what.map(item =>
      "img [src+]" #> (((item \ "LinkType").headOption.map(_.text) match {
        case Some("pdf") => "pdf_icon.png"
        case _ => "external_link_icon.png"
      }): String) andThen "a [href]" #> (item \ "LinkUrl").headOption.map(fixLink(_)) &
        "a [target]" #> ((item \ "LinkType").headOption.map(_.text) match {
          case Some("pdf") => "pdfWindow"
          case Some("external") => "_blank"
          case _ => "_self"
        }) & "a -*" #> (item \ "LinkDisplayName").headOption.map(_.text))
  }

The what value is populated with all the links, sorted by LinkId.

The core of the work is done in the CSS Selector Transform that transforms the <li> tag updating the <img> src attribute and setting the href, target, and body of the <a> tag.

Next, we read the /navigation/navigation.xml file and create snippets based on the file for the short, left, and bottom menus.

Most of the code is a re-hash of what we've seen. There's one additional piece of code that's interesting: CurrentFile.value. This accesses the current file that's being rendered. This allows us to test how mark the menu path.

Creating shadow index files

The last bit of code creates shadow index.html files in directories. This is due to the layout of the legacy PHP site.

The code looks like:

    in.flatMap(pf => pf.fileInfo.pathAndSuffix.path match {
      case "content" :: rest if rest.takeRight(1) != List("index") =>
        val fi = pf.fileInfo
        val ps = fi.pathAndSuffix
        val nps = ps.copy(path = ps.path.dropRight(1) ::: List("index"))
        val nfi = nps.toFileInfo(Empty)

        val ret = pf.updateFileInfo(nfi)

        List(pf, ret)

      case _ => List(pf)
    })

The code takes the List[ParsedFile] and for all the files in the /content directory that are not named index.html, we create a shadow file that's got the same contents, but a different path.

This demonstrates the transformation of the incoming corpus of documents that make up a site. The corpus can be transformed by adding files, removing files, changing file paths, and adding/removing metadata from files. You can also add synthetic files.

Conclusion

Telegram has scripting capabilities. They are rough and it's difficult to edit/debug (we're working on a live Hoisted server that will show you changes immediately on a local machine). But they offer the ability to add new snippets as well as modifying the document corpus to allow a ton of flexibility for Telegram sites.