├── Setup.hs ├── ansible ├── roles │ ├── templates │ │ ├── version.txt │ │ └── supervisor.conf │ ├── handlers │ │ └── main.yml │ └── tasks │ │ └── main.yaml └── group_vars │ └── base ├── media └── screenshot.png ├── .gitignore ├── ltxbot.example.conf ├── HLint.hs ├── stack.yaml ├── src └── Web │ └── Twitter │ ├── LtxBot │ ├── Types.hs │ ├── Latex.hs │ └── Common.hs │ └── LtxBot.hs ├── tex2png.sh ├── docker-tex2png.sh ├── LICENSE ├── test ├── Spec.hs └── Fixtures.hs ├── README.markdown ├── app └── Main.hs ├── package.yaml ├── .travis.yml └── ltxbot.cabal /Setup.hs: -------------------------------------------------------------------------------- 1 | import Distribution.Simple 2 | main = defaultMain 3 | -------------------------------------------------------------------------------- /ansible/roles/templates/version.txt: -------------------------------------------------------------------------------- 1 | {{ ltxbot.version }} 2 | -------------------------------------------------------------------------------- /media/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/passy/ltxbot/HEAD/media/screenshot.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .cabal-sandbox 3 | cabal.sandbox.config 4 | ltxbot.conf 5 | .stack-work 6 | /*.cabal 7 | -------------------------------------------------------------------------------- /ansible/group_vars/base: -------------------------------------------------------------------------------- 1 | --- 2 | ltxbot: 3 | home: /srv/ltxbot 4 | user: ltxbot 5 | version: 0.0.3 6 | 7 | -------------------------------------------------------------------------------- /ltxbot.example.conf: -------------------------------------------------------------------------------- 1 | oauthConsumerKey = "xxx" 2 | oauthConsumerSecret = "xxx" 3 | accessToken = "xxx" 4 | accessSecret = "xxx" 5 | userName = "ltxbot" 6 | -------------------------------------------------------------------------------- /ansible/roles/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: start ltxbot 3 | supervisorctl: name=ltxbot state=started 4 | 5 | - name: restart ltxbot 6 | supervisorctl: name=ltxbot state=restarted 7 | -------------------------------------------------------------------------------- /HLint.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE PackageImports #-} 2 | module HLint.HLint where 3 | 4 | import "hint" HLint.Builtin.All 5 | import "hint" HLint.Default 6 | import "hint" HLint.Dollar 7 | import "hint" HLint.Generalise 8 | -------------------------------------------------------------------------------- /stack.yaml: -------------------------------------------------------------------------------- 1 | flags: {} 2 | packages: 3 | - '.' 4 | extra-deps: 5 | - http-client-0.4.31.2 6 | - http-client-tls-0.2.4.1 7 | - http-conduit-2.1.11 8 | - twitter-conduit-0.2.1 9 | - twitter-types-0.7.2.2 10 | - twitter-types-lens-0.7.2 11 | resolver: lts-8.0 12 | -------------------------------------------------------------------------------- /ansible/roles/templates/supervisor.conf: -------------------------------------------------------------------------------- 1 | [program:ltxbot] 2 | command={{ ltxbot.home }}/bin/ltxbot {{ ltxbot.home }}/ltxbot.conf 3 | autostart=true 4 | autorestart=true 5 | stdout_logfile=/var/log/ltxbot.out.log 6 | stderr_logfile=/var/log/ltxbot.out.log 7 | user=ltxbot 8 | directory={{ ltxbot.home }}/bin 9 | -------------------------------------------------------------------------------- /src/Web/Twitter/LtxBot/Types.hs: -------------------------------------------------------------------------------- 1 | module Web.Twitter.LtxBot.Types where 2 | 3 | import Control.Monad.Reader (ReaderT) 4 | import Network.HTTP.Conduit (Manager) 5 | import Web.Twitter.Conduit (TWInfo) 6 | import Web.Twitter.Types (UserId) 7 | 8 | data LtxbotEnv = LtxbotEnv { envUserId :: UserId 9 | , envTwInfo :: TWInfo 10 | , envManager :: Manager } 11 | 12 | type LTXE m = ReaderT LtxbotEnv m 13 | -------------------------------------------------------------------------------- /tex2png.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -xe 4 | 5 | SCRIPT=$(readlink -f "$0") 6 | OUTDIR=$(dirname "$(readlink -f "$1")") 7 | SCRIPTPATH=$(dirname "$SCRIPT") 8 | TEX=$1 9 | PDF=$(basename -s .tex "$1").pdf 10 | PNG=$(basename -s .tex "$1").png 11 | TDIR=$(mktemp -d) 12 | 13 | trap "{ cd - ; rm -rf $TDIR; exit 255; }" SIGINT 14 | 15 | cd "$TDIR" 16 | pdflatex -interaction=batchmode "$SCRIPTPATH/$TEX" 17 | pdfcrop "$PDF" "$PDF" 18 | pdftocairo -singlefile -png "$PDF" 19 | cd - 20 | 21 | mv "$TDIR/$PNG" "$OUTDIR" 22 | rm -rf "$TDIR" 23 | 24 | exit 0 25 | -------------------------------------------------------------------------------- /src/Web/Twitter/LtxBot/Latex.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | module Web.Twitter.LtxBot.Latex 4 | (renderLaTeXStatus) 5 | where 6 | 7 | import Text.LaTeX (document, raw, documentclass, ClassName, ClassOption(..), render, LaTeX) 8 | import qualified Data.Text as T 9 | import Data.Monoid ((<>)) 10 | import Web.Twitter.Types (Status) 11 | import qualified Web.Twitter.Types.Lens as TL 12 | import Control.Lens ((^.)) 13 | 14 | standalone :: ClassName 15 | standalone = "standalone" 16 | 17 | -- | Wrap a raw string to a minimal LaTeX document 18 | standaloneLaTeX :: T.Text -> LaTeX 19 | standaloneLaTeX input = 20 | documentclass [CustomOption "preview"] standalone 21 | <> document (raw input) 22 | 23 | wrapLaTeXStatus :: Status -> LaTeX 24 | wrapLaTeXStatus s = standaloneLaTeX (s ^. TL.text) 25 | 26 | renderLaTeXStatus :: Status -> T.Text 27 | renderLaTeXStatus s = render (wrapLaTeXStatus s) 28 | -------------------------------------------------------------------------------- /docker-tex2png.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | SCRIPTPATH="$(dirname "$SCRIPT")" 6 | KERNEL="$(uname -s)" 7 | IMAGE="passy/texlive-poppler" 8 | READLINK=readlink 9 | MKTEMP=mktemp 10 | 11 | if [ "Linux" != "$KERNEL" ]; then 12 | READLINK=greadlink 13 | MKTEMP=gmktemp 14 | export TMPDIR="/Volumes/data" 15 | fi 16 | 17 | SCRIPT="$("$READLINK" -f "$0")" 18 | OUTPUT="$("$READLINK" -f "$1")" 19 | TDIR="$("$MKTEMP" -d)" 20 | TBASEDIR="$(basename "$TDIR")" 21 | TEX="tmp.tex" 22 | PNG="tmp.png" 23 | 24 | trap "{ cd - ; rm -rf '$TDIR'; exit 255; }" SIGINT 25 | 26 | cat /dev/stdin > "$TDIR/$TEX" 27 | cp "$SCRIPTPATH/tex2png.sh" "$TDIR" 28 | 29 | if [ "Linux" == "$KERNEL" ]; then 30 | docker run --rm -v "$TDIR":/data -w /data "$IMAGE" /bin/bash tex2png.sh "$TEX" 31 | else 32 | docker run --rm --volumes-from my-data -w "/data/$TBASEDIR" "$IMAGE" /bin/bash "tex2png.sh" "$TEX" 33 | fi 34 | 35 | cp "$TDIR/$PNG" "$OUTPUT" 36 | rm -rf "$TDIR" 37 | 38 | exit 0 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Pascal Hartig 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included 12 | in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /test/Spec.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | module Main where 3 | 4 | import Test.Hspec 5 | import Fixtures (testStatus) 6 | 7 | import Control.Applicative ((<$>)) 8 | import Control.Lens.Operators ((^.)) 9 | import Web.Twitter.LtxBot (extractStatusMentions, stripEntities) 10 | 11 | import qualified Web.Twitter.Types.Lens as TL 12 | 13 | 14 | main :: IO () 15 | main = hspec $ do 16 | describe "extractStatusMentions" $ do 17 | it "should extract all mentions" $ do 18 | let mentions = extractStatusMentions testStatus 19 | let screenNames = (^. TL.userEntityUserScreenName) <$> mentions 20 | 21 | screenNames `shouldBe` ["ltxbot", "passy"] 22 | 23 | describe "stripEntities" $ do 24 | it "should remove leading entities from a string" $ do 25 | let text = "not a test" 26 | let indices = [[0, 3]] 27 | 28 | stripEntities indices text `shouldBe` "a test" 29 | 30 | it "should remove all entities from a string" $ do 31 | let text = "this is a test string" 32 | let indices = [[5, 7], [8, 8], [9, 9], [14, 20]] 33 | 34 | stripEntities indices text `shouldBe` "this test" 35 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # ltxbot [![Build Status](https://travis-ci.org/passy/ltxbot.svg?branch=master)](https://travis-ci.org/passy/ltxbot) 2 | 3 | A Twitter bot rendering mentions to PNGs. 4 | 5 | ![Screenshot](media/screenshot.png) 6 | 7 | ## Config 8 | 9 | Modify `ltxbot.example.conf` and enter your [Twiter OAuth 10 | credentials](https://apps.twitter.com/) for an R/W application. 11 | You also need to provide a user token and secret for an authenticated account. 12 | An easy way to obtain these is through 13 | [`twurl authenticate`](https://github.com/twitter/twurl). 14 | 15 | ## Setup 16 | 17 | You need a reasonably recent Haskell installation (GHC 7.8.3 at the time of this 18 | writing) and [Docker](https://docker.com) on your system. 19 | 20 | *Docker preparation* 21 | 22 | ```bash 23 | $ # Pull my docker image with texlive-full and poppler-utils 24 | $ docker pull passy/texlive-poppler 25 | ``` 26 | 27 | If you poor soul are on OS X, you need to set up a shared network drive 28 | with boot2docker. (*N.B.* This may no longer be necessary. Luckily, the Docker 29 | integration is far less painful these days.) 30 | 31 | ```bash 32 | $ # Make a volume container (only need to do this once) 33 | $ docker run -v /data --name my-data busybox true 34 | $ # Share it using Samba (Windows file sharing) 35 | $ docker run --rm -v /usr/local/bin/docker:/docker -v /var/run/docker.sock:/docker.sock svendowideit/samba my-data 36 | $ # then find out the IP address of your Boot2Docker host 37 | $ boot2docker ip 38 | 192.168.59.103 39 | ``` 40 | 41 | And connect to it through Finder by using `cifs://192.168.59.103/data`. 42 | 43 | You may need to adjust the two `*tex2png.sh` scripts if any of your paths or 44 | volume names differ. 45 | 46 | ## Provisioning 47 | 48 | There's also a set of [Ansible](http://ansible.com) scripts available under 49 | [`ansible/`](ansible/) in this repository that can be used to set up a server. 50 | 51 | ## Building 52 | 53 | ```bash 54 | $ stack setup 55 | $ stack build 56 | $ stack test 57 | $ stack exec ltxbot -- ltxbot.conf 58 | ``` 59 | -------------------------------------------------------------------------------- /ansible/roles/tasks/main.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: install supervisor 3 | apt: pkg=supervisor state=latest 4 | 5 | - name: install docker 6 | apt: pkg=docker.io state=latest 7 | 8 | - name: install libgmp 9 | apt: pkg=libgmp10 state=latest 10 | 11 | - name: create user 12 | user: name={{ ltxbot.user }} home={{ ltxbot.home }} createhome=true shell=/bin/false groups=docker 13 | 14 | - name: setup ltxbot home 15 | file: dest={{ ltxbot.home }}/bin owner={{ ltxbot.user }} state=directory 16 | 17 | - name: get local version 18 | shell: if [ -f {{ltxbot.home }}/version.txt ]; then cat {{ ltxbot.home }}/version.txt; else echo "0.0"; fi 19 | register: local_ltxbot_version 20 | 21 | - name: get ltxbot binary 22 | get_url: dest={{ ltxbot.home }}/bin/ltxbot.tmp url=https://github.com/passy/ltxbot/releases/download/v{{ ltxbot.version }}/ltxbot-{{ ltxbot.version }}.lnx.x86_64 force=true 23 | when: local_ltxbot_version != "{{ ltxbot.version }}" 24 | register: fetch_ltxbot 25 | 26 | - name: move ltxbot binary 27 | command: mv {{ ltxbot.home }}/bin/ltxbot.tmp {{ ltxbot.home }}/bin/ltxbot 28 | when: fetch_ltxbot.changed 29 | 30 | - name: write ltxbot version 31 | template: src=version.txt dest={{ ltxbot.home }}/version.txt backup=no mode=0644 32 | 33 | - name: get additional ltxbot files 34 | get_url: dest={{ ltxbot.home }}/bin/{{ item.file }} url={{ item.url }} force=true 35 | with_items: 36 | - { url: "https://raw.githubusercontent.com/passy/ltxbot/v{{ ltxbot.version }}/docker-tex2png.sh", file: 'docker-tex2png.sh' } 37 | - { url: "https://raw.githubusercontent.com/passy/ltxbot/v{{ ltxbot.version }}/tex2png.sh", file: 'tex2png.sh' } 38 | 39 | - name: set ltxbot binary permissions 40 | file: path={{ ltxbot.home }}/bin/{{ item }} state=file mode=700 owner={{ ltxbot.user }} 41 | with_items: 42 | - ltxbot 43 | - docker-tex2png.sh 44 | - tex2png.sh 45 | 46 | - name: create ltxbot service 47 | template: src=supervisor.conf dest=/etc/supervisor/conf.d/ltxbot.conf backup=yes mode=0644 48 | 49 | - name: setup ltxbot.conf 50 | copy: src=ltxbot.conf dest={{ ltxbot.home }}/ltxbot.conf owner={{ ltxbot.user }} mode=0600 51 | notify: 52 | - start ltxbot 53 | -------------------------------------------------------------------------------- /src/Web/Twitter/LtxBot/Common.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE FlexibleContexts, OverloadedStrings #-} 2 | module Web.Twitter.LtxBot.Common where 3 | 4 | import qualified Data.ByteString.Char8 as S8 5 | import qualified Data.CaseInsensitive as CI 6 | import qualified Data.Configurator as Conf 7 | import qualified Data.Map as M 8 | import qualified Network.URI as URI 9 | import qualified Web.Authenticate.OAuth as OA 10 | 11 | import Control.Applicative ((<$>), (<|>), (<*>)) 12 | import Control.Lens 13 | import Data.Configurator.Types (Config) 14 | import Network.HTTP.Conduit (Proxy(..)) 15 | import System.Environment (getEnvironment) 16 | import Web.Authenticate.OAuth (OAuth(..), Credential, newOAuth, newCredential) 17 | import Web.Twitter.Conduit (setCredential, twProxy, TWInfo) 18 | import Data.Monoid ((<>)) 19 | 20 | getProxyEnv :: 21 | IO (Maybe Proxy) 22 | getProxyEnv = do 23 | env <- M.fromList . over (mapped . _1) CI.mk <$> getEnvironment 24 | let u = M.lookup "https_proxy" env <|> 25 | M.lookup "http_proxy" env <|> 26 | M.lookup "proxy" env >>= URI.parseURI >>= URI.uriAuthority 27 | return $ Proxy <$> (S8.pack . URI.uriRegName <$> u) <*> (parsePort . URI.uriPort <$> u) 28 | where 29 | parsePort :: String -> Int 30 | parsePort [] = 8080 31 | parsePort (':':xs) = read xs 32 | parsePort xs = error $ "port number parse failed " <> xs 33 | 34 | getOAuthTokens :: 35 | Config -> 36 | IO (OAuth, Credential) 37 | getOAuthTokens conf = do 38 | oauth <- makeOAuth 39 | cred <- makeCredential 40 | 41 | return (oauth, cred) 42 | 43 | where 44 | makeOAuth = do 45 | key <- Conf.lookupDefault "" conf "oauthConsumerKey" 46 | secret <- Conf.lookupDefault "" conf "oauthConsumerSecret" 47 | return $ newOAuth { 48 | oauthConsumerKey = key, 49 | oauthConsumerSecret = secret 50 | } 51 | makeCredential = do 52 | token <- Conf.lookupDefault "" conf "accessToken" 53 | secret <- Conf.lookupDefault "" conf "accessSecret" 54 | return $ newCredential token secret 55 | 56 | getTWInfoFromEnv :: 57 | Config -> 58 | IO TWInfo 59 | getTWInfoFromEnv conf = do 60 | pr <- getProxyEnv 61 | (oa, cred) <- getOAuthTokens conf 62 | return $ (setCredential oa cred OA.def) { twProxy = pr } 63 | -------------------------------------------------------------------------------- /app/Main.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings, FlexibleContexts, DeriveDataTypeable, QuasiQuotes #-} 2 | module Main where 3 | 4 | import Prelude 5 | 6 | import Control.Lens.Action 7 | import Control.Monad (when, void) 8 | import Control.Monad.IO.Class (liftIO, MonadIO(..)) 9 | import Control.Monad.Trans.Reader (runReaderT) 10 | import Control.Monad.Trans.Resource (runResourceT) 11 | import Data.Configurator.Types (Config) 12 | import Data.Data (Data) 13 | import Data.Maybe (listToMaybe, isNothing, fromJust) 14 | import Data.Typeable (Typeable) 15 | import Data.Version (showVersion) 16 | import Paths_ltxbot (version) 17 | import System.Console.CmdArgs.Explicit (HelpFormat(..), helpText) 18 | import Web.Twitter.Conduit (stream, statusesFilterByTrack) 19 | import Web.Twitter.LtxBot (actTL, normalizeMentions) 20 | import Web.Twitter.LtxBot.Common (getTWInfoFromEnv) 21 | import Web.Twitter.LtxBot.Types (LtxbotEnv(..)) 22 | import Web.Twitter.Types (UserId) 23 | 24 | import qualified Data.Conduit as C 25 | import qualified Data.Conduit.List as CL 26 | import qualified Data.Configurator as Conf 27 | import qualified Data.Text as T 28 | import qualified Data.Text.IO as T 29 | import qualified Network.HTTP.Conduit as HTTP 30 | import qualified System.Console.CmdArgs.Implicit as CA 31 | 32 | data Ltxbot = Ltxbot { config :: FilePath } 33 | deriving (Show, Data, Typeable) 34 | 35 | programName :: String 36 | programName = "ltxbot" 37 | 38 | args :: CA.Mode (CA.CmdArgs Ltxbot) 39 | args = CA.cmdArgsMode $ Ltxbot { config = CA.def CA.&= CA.args } 40 | CA.&= CA.summary (unwords [programName, showVersion version]) 41 | CA.&= CA.program programName 42 | 43 | main :: IO () 44 | main = do 45 | mainArgs <- CA.cmdArgsRun args 46 | let confFile = config mainArgs 47 | if null confFile 48 | then print $ helpText [] HelpFormatDefault args 49 | else runBot confFile 50 | 51 | runBot :: FilePath -> IO () 52 | runBot confFile = do 53 | conf <- Conf.load [Conf.Required confFile] 54 | username <- Conf.lookupDefault "" conf "userName" 55 | 56 | twInfo <- getTWInfoFromEnv conf 57 | manager <- HTTP.newManager HTTP.tlsManagerSettings 58 | userId <- getUserId conf 59 | when (isNothing userId) $ error "accessToken must contain a '-'" 60 | 61 | void . runResourceT $ do 62 | liftIO . T.putStrLn $ T.unwords ["Listening for Tweets to", username, "..."] 63 | 64 | -- TODO: This should not be created here but in Common, but 65 | -- that requires that we can pull the reader evaluation up, the lenv 66 | -- must not appear in the equation down there. 67 | let lenv = LtxbotEnv { envUserId = fromJust userId 68 | , envTwInfo = twInfo 69 | , envManager = manager } 70 | 71 | src <- stream twInfo manager (statusesFilterByTrack $ T.concat ["@", username]) 72 | src C.$=+ normalizeMentions C.$$+- CL.mapM_ (^! act ((`runReaderT` lenv) . actTL)) 73 | 74 | where 75 | getUserId :: Config -> IO (Maybe UserId) 76 | getUserId conf = do 77 | maybeUid <- liftIO $ fmap (listToMaybe . T.split (== '-')) (Conf.lookupDefault "" conf "accessToken") 78 | return $ fmap (read . T.unpack) maybeUid 79 | -------------------------------------------------------------------------------- /package.yaml: -------------------------------------------------------------------------------- 1 | name: ltxbot 2 | version: 0.1.0 3 | synopsis: Twitter Bot turning LaTeX tweets into PNGs 4 | description: 5 | A Twitter bot that listens for mentions and replies with an image of the Tweet 6 | interpreted as LaTeX. The image is rendered in a Docker container that is 7 | spawned on every incoming mention. Possibly not race-condition-proof. 8 | homepage: https://github.com/passy/ltxbot 9 | license: MIT 10 | author: Pascal Hartig 11 | maintainer: Pascal Hartig 12 | category: Web 13 | extra-source-files: 14 | - stack.yaml 15 | - README.markdown 16 | 17 | ghc-options: 18 | - -Wall 19 | - -fwarn-tabs 20 | - -fwarn-incomplete-record-updates 21 | - -fwarn-monomorphism-restriction 22 | - -fwarn-unused-do-bind 23 | 24 | dependencies: 25 | - base >= 4.7 && < 5 26 | - text 27 | 28 | tests: 29 | spec: 30 | main: Spec.hs 31 | source-dirs: test 32 | ghc-options: -threaded -rtsopts -with-rtsopts=-N 33 | dependencies: 34 | - ltxbot 35 | - twitter-conduit >= 0.2.1 36 | - twitter-types 37 | - twitter-types-lens 38 | - HaTeX 39 | - mtl 40 | - network-uri 41 | - containers 42 | - case-insensitive 43 | - lens >=4.0 44 | - lens-action 45 | - transformers-base 46 | - monad-logger 47 | - authenticate-oauth >=1.4 48 | - bytestring >=0.10 49 | - conduit >= 1.0 50 | - configurator >= 0.2 51 | - exceptions >= 0.5 52 | - http-conduit >= 2.0 53 | - resourcet >= 0.4 54 | - text >= 1.1 55 | - transformers >= 0.3 56 | - temporary 57 | - filepath 58 | - process 59 | - hspec 60 | - time 61 | - aeson 62 | 63 | library: 64 | source-dirs: src 65 | ghc-options: 66 | - -Wall 67 | dependencies: 68 | - HaTeX 69 | - authenticate-oauth >=1.4 70 | - bytestring >=0.10 71 | - case-insensitive 72 | - cmdargs >= 0.10 73 | - conduit >= 1.0 74 | - configurator >= 0.2 75 | - containers 76 | - exceptions >= 0.5 77 | - filepath 78 | - http-conduit >= 2.0 79 | - lens >= 4.0 80 | - lens-action 81 | - monad-logger 82 | - mtl 83 | - network-uri 84 | - process 85 | - resourcet >= 0.4 86 | - temporary 87 | - transformers >= 0.3 88 | - transformers-base 89 | - twitter-conduit >= 0.2.1 90 | - twitter-types 91 | - twitter-types-lens 92 | - aeson 93 | - time 94 | exposed-modules: 95 | - Web.Twitter.LtxBot 96 | - Web.Twitter.LtxBot.Common 97 | - Web.Twitter.LtxBot.Latex 98 | - Web.Twitter.LtxBot.Types 99 | 100 | executables: 101 | disruption-tracker: 102 | main: Main.hs 103 | source-dirs: app 104 | ghc-options: 105 | - -threaded 106 | - -rtsopts 107 | - -with-rtsopts=-N 108 | - -Wall 109 | dependencies: 110 | - ltxbot 111 | - HaTeX 112 | - authenticate-oauth >=1.4 113 | - bytestring >=0.10 114 | - case-insensitive 115 | - cmdargs >= 0.10 116 | - conduit >= 1.0 117 | - configurator >= 0.2 118 | - containers 119 | - exceptions >= 0.5 120 | - filepath 121 | - http-conduit >= 2.0 122 | - lens >= 4.0 123 | - lens-action 124 | - monad-logger 125 | - mtl 126 | - network-uri 127 | - process 128 | - resourcet >= 0.4 129 | - temporary 130 | - transformers >= 0.3 131 | - transformers-base 132 | - twitter-conduit 133 | - twitter-types 134 | - twitter-types-lens 135 | - aeson 136 | - time 137 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Copy these contents into the root directory of your Github project in a file 2 | # named .travis.yml 3 | 4 | # Use new container infrastructure to enable caching 5 | sudo: false 6 | 7 | # Choose a lightweight base image; we provide our own build tools. 8 | language: c 9 | 10 | # Caching so the next build will be fast too. 11 | cache: 12 | directories: 13 | - $HOME/.ghc 14 | - $HOME/.cabal 15 | - $HOME/.stack 16 | 17 | # The different configurations we want to test. We have BUILD=cabal which uses 18 | # cabal-install, and BUILD=stack which uses Stack. More documentation on each 19 | # of those below. 20 | # 21 | # We set the compiler values here to tell Travis to use a different 22 | # cache file per set of arguments. 23 | # 24 | # If you need to have different apt packages for each combination in the 25 | # matrix, you can use a line such as: 26 | # addons: {apt: {packages: [libfcgi-dev,libgmp-dev]}} 27 | matrix: 28 | include: 29 | # The Stack builds. We can pass in arbitrary Stack arguments via the ARGS 30 | # variable, such as using --stack-yaml to point to a different file. 31 | - env: BUILD=stack ARGS="--resolver lts-8" 32 | compiler: ": #stack lts" 33 | addons: {apt: {packages: [ghc-7.10.3], sources: [hvr-ghc]}} 34 | 35 | # Nightly builds are allowed to fail 36 | - env: BUILD=stack ARGS="--resolver nightly" 37 | compiler: ": #stack nightly" 38 | addons: {apt: {packages: [libgmp-dev]}} 39 | 40 | - env: BUILD=stack ARGS="--resolver lts-8" 41 | compiler: ": #stack lts osx" 42 | os: osx 43 | 44 | - env: BUILD=stack ARGS="--resolver nightly" 45 | compiler: ": #stack nightly osx" 46 | os: osx 47 | 48 | allow_failures: 49 | - env: BUILD=stack ARGS="--resolver nightly" 50 | # OS X is too flaky at the moment. 51 | - compiler: ": #stack lts osx" 52 | 53 | before_install: 54 | # Using compiler above sets CC to an invalid value, so unset it 55 | - unset CC 56 | 57 | # We want to always allow newer versions of packages when building on GHC HEAD 58 | - CABALARGS="" 59 | - if [ "x$GHCVER" = "xhead" ]; then CABALARGS=--allow-newer; fi 60 | 61 | # Download and unpack the stack executable 62 | - export PATH=/opt/ghc/$GHCVER/bin:/opt/cabal/$CABALVER/bin:$HOME/.local/bin:$PATH 63 | - mkdir -p ~/.local/bin 64 | - | 65 | if [ `uname` = "Darwin" ] 66 | then 67 | curl --insecure -L https://www.stackage.org/stack/osx-x86_64 | tar xz --strip-components=1 --include '*/stack' -C ~/.local/bin 68 | else 69 | curl -L https://www.stackage.org/stack/linux-x86_64 | tar xz --wildcards --strip-components=1 -C ~/.local/bin '*/stack' 70 | fi 71 | 72 | install: 73 | - echo "$(ghc --version) [$(ghc --print-project-git-commit-id 2> /dev/null || echo '?')]" 74 | - if [ -f configure.ac ]; then autoreconf -i; fi 75 | - | 76 | case "$BUILD" in 77 | stack) 78 | stack --no-terminal --install-ghc $ARGS test --only-dependencies 79 | ;; 80 | cabal) 81 | cabal --version 82 | travis_retry cabal update 83 | cabal install --only-dependencies --enable-tests --enable-benchmarks --force-reinstalls --ghc-options=-O0 --reorder-goals --max-backjumps=-1 $CABALARGS 84 | ;; 85 | esac 86 | 87 | script: 88 | - | 89 | case "$BUILD" in 90 | stack) 91 | stack --no-terminal $ARGS test --haddock --no-haddock-deps 92 | ;; 93 | cabal) 94 | cabal configure --enable-tests --enable-benchmarks -v2 --ghc-options="-O0 -Werror" 95 | cabal build 96 | cabal check || [ "$CABALVER" == "1.16" ] 97 | cabal test 98 | cabal sdist 99 | cabal copy 100 | SRC_TGZ=$(cabal info . | awk '{print $2;exit}').tar.gz && \ 101 | (cd dist && cabal install --force-reinstalls "$SRC_TGZ") 102 | ;; 103 | esac 104 | -------------------------------------------------------------------------------- /ltxbot.cabal: -------------------------------------------------------------------------------- 1 | -- This file has been generated from package.yaml by hpack version 0.15.0. 2 | -- 3 | -- see: https://github.com/sol/hpack 4 | 5 | name: ltxbot 6 | version: 0.1.0 7 | synopsis: Twitter Bot turning LaTeX tweets into PNGs 8 | description: A Twitter bot that listens for mentions and replies with an image of the Tweet interpreted as LaTeX. The image is rendered in a Docker container that is spawned on every incoming mention. Possibly not race-condition-proof. 9 | category: Web 10 | homepage: https://github.com/passy/ltxbot 11 | author: Pascal Hartig 12 | maintainer: Pascal Hartig 13 | license: MIT 14 | license-file: LICENSE 15 | build-type: Simple 16 | cabal-version: >= 1.10 17 | 18 | extra-source-files: 19 | README.markdown 20 | stack.yaml 21 | 22 | library 23 | hs-source-dirs: 24 | src 25 | ghc-options: -Wall -fwarn-tabs -fwarn-incomplete-record-updates -fwarn-monomorphism-restriction -fwarn-unused-do-bind -Wall 26 | build-depends: 27 | base >= 4.7 && < 5 28 | , text 29 | , HaTeX 30 | , authenticate-oauth >=1.4 31 | , bytestring >=0.10 32 | , case-insensitive 33 | , cmdargs >= 0.10 34 | , conduit >= 1.0 35 | , configurator >= 0.2 36 | , containers 37 | , exceptions >= 0.5 38 | , filepath 39 | , http-conduit >= 2.0 40 | , lens >= 4.0 41 | , lens-action 42 | , monad-logger 43 | , mtl 44 | , network-uri 45 | , process 46 | , resourcet >= 0.4 47 | , temporary 48 | , transformers >= 0.3 49 | , transformers-base 50 | , twitter-conduit >= 0.2.1 51 | , twitter-types 52 | , twitter-types-lens 53 | , aeson 54 | , time 55 | exposed-modules: 56 | Web.Twitter.LtxBot 57 | Web.Twitter.LtxBot.Common 58 | Web.Twitter.LtxBot.Latex 59 | Web.Twitter.LtxBot.Types 60 | other-modules: 61 | Paths_ltxbot 62 | default-language: Haskell2010 63 | 64 | executable disruption-tracker 65 | main-is: Main.hs 66 | hs-source-dirs: 67 | app 68 | ghc-options: -Wall -fwarn-tabs -fwarn-incomplete-record-updates -fwarn-monomorphism-restriction -fwarn-unused-do-bind -threaded -rtsopts -with-rtsopts=-N -Wall 69 | build-depends: 70 | base >= 4.7 && < 5 71 | , text 72 | , ltxbot 73 | , HaTeX 74 | , authenticate-oauth >=1.4 75 | , bytestring >=0.10 76 | , case-insensitive 77 | , cmdargs >= 0.10 78 | , conduit >= 1.0 79 | , configurator >= 0.2 80 | , containers 81 | , exceptions >= 0.5 82 | , filepath 83 | , http-conduit >= 2.0 84 | , lens >= 4.0 85 | , lens-action 86 | , monad-logger 87 | , mtl 88 | , network-uri 89 | , process 90 | , resourcet >= 0.4 91 | , temporary 92 | , transformers >= 0.3 93 | , transformers-base 94 | , twitter-conduit 95 | , twitter-types 96 | , twitter-types-lens 97 | , aeson 98 | , time 99 | default-language: Haskell2010 100 | 101 | test-suite spec 102 | type: exitcode-stdio-1.0 103 | main-is: Spec.hs 104 | hs-source-dirs: 105 | test 106 | ghc-options: -Wall -fwarn-tabs -fwarn-incomplete-record-updates -fwarn-monomorphism-restriction -fwarn-unused-do-bind -threaded -rtsopts -with-rtsopts=-N 107 | build-depends: 108 | base >= 4.7 && < 5 109 | , text 110 | , ltxbot 111 | , twitter-conduit >= 0.2.1 112 | , twitter-types 113 | , twitter-types-lens 114 | , HaTeX 115 | , mtl 116 | , network-uri 117 | , containers 118 | , case-insensitive 119 | , lens >=4.0 120 | , lens-action 121 | , transformers-base 122 | , monad-logger 123 | , authenticate-oauth >=1.4 124 | , bytestring >=0.10 125 | , conduit >= 1.0 126 | , configurator >= 0.2 127 | , exceptions >= 0.5 128 | , http-conduit >= 2.0 129 | , resourcet >= 0.4 130 | , text >= 1.1 131 | , transformers >= 0.3 132 | , temporary 133 | , filepath 134 | , process 135 | , hspec 136 | , time 137 | , aeson 138 | other-modules: 139 | Fixtures 140 | default-language: Haskell2010 141 | -------------------------------------------------------------------------------- /test/Fixtures.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | module Fixtures where 3 | 4 | import Web.Twitter.Types 5 | import Data.Time.Clock.POSIX (posixSecondsToUTCTime) 6 | 7 | 8 | testStatus :: Status 9 | testStatus = Status {statusContributors = Nothing, statusCoordinates = Nothing, statusCreatedAt = posixSecondsToUTCTime 1411299046, statusCurrentUserRetweet = Nothing, statusEntities = Just Entities {enHashTags = [], enUserMentions = [Entity {entityBody = UserEntity {userEntityUserId = 2810142194, userEntityUserName = "LaTeX Bot", userEntityUserScreenName = "ltxbot"}, entityIndices = [0,7]},Entity {entityBody = UserEntity {userEntityUserId = 14383077, userEntityUserName = "Pascal Hartig", userEntityUserScreenName = "passy"}, entityIndices = [8,14]}], enURLs = [], enMedia = []}, statusExtendedEntities = Nothing, statusFavoriteCount = 0, statusFavorited = Just False, statusFilterLevel = Just "medium", statusId = 513651547662983169, statusInReplyToScreenName = Just "ltxbot", statusInReplyToStatusId = Nothing, statusInReplyToUserId = Just 2810142194 , statusLang = Just "und", statusPlace = Nothing, statusPossiblySensitive = Just False, statusScopes = Nothing, statusRetweetCount = 0, statusRetweeted = Just False, statusRetweetedStatus = Nothing, statusSource = "TweetDeck", statusText = "@ltxbot @passy yo", statusTruncated = False, statusUser = User {userContributorsEnabled = False, userCreatedAt = posixSecondsToUTCTime 1380705505, userDefaultProfile = False, userDefaultProfileImage = False, userDescription = Nothing, userFavoritesCount = 53, userFollowRequestSent = Nothing, userFollowing = Nothing, userFollowersCount = 12, userFriendsCount = 20, userGeoEnabled = True, userId = 1926324931, userIsTranslator = False, userLang = "en", userListedCount = 0, userLocation = Just "", userName = "Horse", userNotifications = Nothing, userProfileBackgroundColor = Just "FFFFFF", userProfileBackgroundImageURL = Just "http://pbs.twimg.com/profile_background _images/378800000086486461/66330906653250065e3a1b5665d0971a.png", userProfileBackgroundImageURLHttps = Just "https://pbs.twimg.com/profile_ba ckground_images/378800000086486461/66330906653250065e3a1b5665d0971a.png", userProfileBackgroundTile = Just True, userProfileBannerURL = Just "https://pbs.twimg.com/profile_banners/1926324931/1380706325", userProfileImageURL = Just "http://pbs.twimg.com/profile_images/37880000053733 5374/78d58d698fc464e16d1d7e7314962118_normal.jpeg", userProfileImageURLHttps = Just "https://pbs.twimg.com/profile_images/378800000537335374/ 78d58d698fc464e16d1d7e7314962118_normal.jpeg", userProfileLinkColor = "000000", userProfileSidebarBorderColor = "FFFFFF", userProfileSidebarFillColor = "FFFFFF", userProfileTextColor = "000000", userProfileUseBackgroundImage = True, userProtected = False, userScreenName = "horse_me dium", userShowAllInlineMedia = Nothing, userStatusesCount = 42, userTimeZone = Nothing, userURL = Nothing, userUtcOffset = Nothing, userVerified = False, userWithheldInCountries = Nothing, userWithheldScope = Nothing}, statusWithheldCopyright = Nothing, statusWithheldInCountries = Nothing, statusWithheldScope = Nothing} 10 | 11 | testStatusDotMention :: Status 12 | testStatusDotMention = Status {statusContributors = Nothing, statusCoordinates = Nothing, statusCreatedAt = posixSecondsToUTCTime 1411299046, statusCurrentUserRetweet = Nothing, statusEntities = Just Entities {enHashTags = [], enUserMentions = [Entity {entityBody = UserEntity {userEntityUserId = 2810142194, userEntityUserName = "LaTeX Bot", userEntityUserScreenName = "ltxbot"}, entityIndices = [1,8]},Entity {entityBody = UserEntity {userEntityUserId = 14383077, userEntityUserName = "Pascal Hartig", userEntityUserScreenName = "passy"}, entityIndices = [9,15]}], enURLs = [], enMedia = []}, statusExtendedEntities = Nothing, statusFavoriteCount = 0, statusFavorited = Just False, statusFilterLevel = Just "medium", statusId = 513753836004327424, statusInReplyToScreenName = Nothing, statusInReplyToStatusId = Nothing, statusInReplyToUserId = Nothing, statusLang = Just "en", statusPlace = Nothing, statusPossiblySensitive = Just True, statusScopes = Nothing, statusRetweetCount = 0, statusRetweeted = Just False, statusRetweetedStatus = Nothing, statusSource = "TweetDeck", statusText = ".@ltxbot @passy how about dot-mentions?", statusTruncated = False, statusUser = User {userContributorsEnabled = False, userCreatedAt = posixSecondsToUTCTime 1380705505, userDefaultProfile = True, userDefaultProfileImage = True, userDescription = Just "Just some guy selling quality eggs", userFavoritesCount = 4, userFollowRequestSent = Nothing, userFollowing = Nothing, userFollowersCount = 2, userFriendsCount = 3, userGeoEnabled = False, userId = 2230197692, userIsTranslator = False, userLang = "en", userListedCount = 0, userLocation = Just "Where the chick(en)s are>q", userName = "Egg Vendor", userNotifications = Nothing, userProfileBackgroundColor = Just "C0DEED", userProfileBackgroundImageURL = Just "http://abs.twimg.com/images/themes/theme1/bg.png", userProfileBackgroundImageURLHttps = Just "https://abs.twimg.com/images/themes/theme1/bg.png", userProfileBackgroundTile = Just False, userProfileBannerURL = Nothing, userProfileImageURL = Just "http://abs.twimg.com/sticky/default_profile_images/default_profile_2_normal.png", userProfileImageURLHttps = Just "https://abs.twimg.com/sticky/default_profile_images/default_profile_2_normal.png", userProfileLinkColor = "0084B4", userProfileSidebarBorderColor = "C0DEED", userProfileSidebarFillColor = "DDEEF6", userProfileTextColor = "333333", userProfileUseBackgroundImage = True, userProtected = False, userScreenName = "EggVendor", userShowAllInlineMedia = Nothing, userStatusesCount = 24, userTimeZone = Just "Casablanca", userURL = Nothing, userUtcOffset = Just 3600, userVerified = False, userWithheldInCountries = Nothing, userWithheldScope = Nothing}, statusWithheldCopyright = Nothing, statusWithheldInCountries = Nothing, statusWithheldScope = Nothing} 13 | -------------------------------------------------------------------------------- /src/Web/Twitter/LtxBot.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings, FlexibleContexts #-} 2 | module Web.Twitter.LtxBot where 3 | 4 | import Prelude 5 | 6 | import qualified Data.Conduit as C 7 | import qualified Data.Text as T 8 | import qualified Data.Text.IO as T 9 | import qualified Web.Twitter.Types.Lens as TL 10 | import qualified Web.Twitter.Types as TT 11 | 12 | import Data.Aeson (FromJSON) 13 | import Control.Applicative ((<$>)) 14 | import Control.Lens 15 | import Control.Monad (join) 16 | import Control.Monad.Catch (MonadMask) 17 | import Control.Monad.IO.Class (liftIO, MonadIO(..)) 18 | import Control.Monad.Trans.Resource (MonadResource) 19 | import Control.Monad.Reader.Class (asks) 20 | import Data.Maybe (maybeToList) 21 | import System.Exit (ExitCode(ExitSuccess, ExitFailure)) 22 | import System.IO (hClose) 23 | import System.IO.Temp (withSystemTempFile) 24 | import System.Process (readProcessWithExitCode) 25 | import Web.Twitter.Conduit (MediaData(..), APIRequest, updateWithMedia, call, update) 26 | import Web.Twitter.Conduit.Parameters (inReplyToStatusId) 27 | import Web.Twitter.LtxBot.Latex (renderLaTeXStatus) 28 | import Web.Twitter.LtxBot.Types (LTXE, LtxbotEnv(..)) 29 | import Web.Twitter.Types (StreamingAPI(..), Status(..), UserId) 30 | 31 | -- | Remove all mentions from StreamingAPI SStatus messages 32 | -- so that this bot doesn't have to deal with it further down the line. 33 | normalizeMentions :: (MonadIO m) => C.Conduit StreamingAPI m StreamingAPI 34 | normalizeMentions = C.awaitForever handleStream 35 | where 36 | handleStream (SStatus s) = do 37 | let text = s ^. TL.statusText 38 | -- WIZARDRY! 39 | let mentions = s ^.. TL.statusEntities 40 | . _Just 41 | . TL.enUserMentions 42 | . traverse 43 | . TL.entityIndices 44 | let newText = stripEntities mentions text 45 | C.yield $ SStatus (s & TL.statusText .~ newText) 46 | handleStream s@_ = C.yield s 47 | 48 | -- | Strip the entities defined by the given indices. 49 | -- Indices have to be tuples of two, must be in order and most not overlap. 50 | -- Dependent types would be totally rad here. Also a proper EntityIndices type. 51 | stripEntities :: [TT.EntityIndices] -> T.Text -> T.Text 52 | stripEntities i t = 53 | -- Read this backwards: Create a string annotated with its index, 54 | -- then filter by the ranges of characters to exclude and put it back 55 | -- together. 56 | (T.pack . fmap snd) . filter (\ e -> fst e `notElem` excludeRange) $ zip [0..] (T.unpack t) 57 | where 58 | -- These are all indices of the original string we want to avoid. 59 | excludeRange :: [Int] 60 | excludeRange = join [[x..y] | [x, y] <- i] 61 | 62 | actTL :: 63 | (MonadResource m, MonadMask m) => 64 | StreamingAPI -> 65 | LTXE m () 66 | actTL (SStatus s) = actStatus s 67 | actTL _ = liftIO $ T.putStrLn "Other event" 68 | 69 | actStatus :: (MonadResource m, MonadMask m) => 70 | Status -> 71 | LTXE m () 72 | actStatus s = do 73 | uid <- asks envUserId 74 | let content = T.unpack $ renderLaTeXStatus s 75 | withSystemTempFile "hatmp.png" (\ tmpFile tmpHandle -> do 76 | -- Yuck, this is mutable state, global mutable state even. Let's figure 77 | -- out if this can be piped through stdin. 78 | liftIO $ do 79 | T.putStrLn $ showStatus s 80 | hClose tmpHandle -- The runtime maintains a lock on the file otherwise. 81 | 82 | (status, _, _) <- liftIO $ readProcessWithExitCode "./docker-tex2png.sh" [tmpFile] content 83 | case status of 84 | ExitSuccess -> replyStatusWithImage uid s tmpFile 85 | ExitFailure _ -> replyStatusWithError s) 86 | 87 | showStatus :: 88 | TL.AsStatus s => 89 | s -> 90 | T.Text 91 | showStatus s = T.concat [ s ^. TL.user . TL.userScreenName 92 | , ": " 93 | , s ^. TL.text 94 | ] 95 | 96 | call' :: 97 | (MonadIO m, MonadResource m, FromJSON responseType) => 98 | APIRequest apiName responseType -> 99 | LTXE m responseType 100 | call' request = do 101 | twInfo <- asks envTwInfo 102 | mngr <- asks envManager 103 | liftIO $ call twInfo mngr request 104 | 105 | replyStatusWithError :: 106 | (MonadResource m) => 107 | Status -> 108 | LTXE m () 109 | replyStatusWithError status = do 110 | res <- call' updateCall 111 | liftIO $ print res 112 | where 113 | errorMessage :: T.Text 114 | errorMessage = "Sorry, I could not compile your LaTeX, friend." 115 | 116 | statusString = T.unwords [T.concat ["@", status ^. TL.user . TL.userScreenName], errorMessage] 117 | updateCall = update statusString & inReplyToStatusId ?~ statusId status 118 | 119 | replyStatusWithImage :: 120 | (MonadResource m) => 121 | UserId -> 122 | Status -> 123 | FilePath -> 124 | LTXE m () 125 | replyStatusWithImage uid status filepath = do 126 | -- TODO: Do something with res, don't return () 127 | res <- call' updateCall 128 | liftIO $ print res 129 | where 130 | allMentions = extractStatusMentions status 131 | otherMentions = filter (\u -> TT.userEntityUserId u /= uid) allMentions 132 | mentionsString = T.unwords $ (\m -> T.concat ["@", m ^. TL.userEntityUserScreenName]) <$> otherMentions 133 | statusString = T.unwords [T.concat ["@", status ^. TL.user . TL.userScreenName], mentionsString] 134 | media = MediaFromFile filepath 135 | updateCall = updateWithMedia statusString media & inReplyToStatusId ?~ statusId status 136 | 137 | extractStatusMentions :: Status -> [TL.UserEntity] 138 | extractStatusMentions s = do 139 | -- Should be obvious that this needs to be refactored ... 140 | -- I'm sure there's a way to do all of this in a single combined 141 | -- lens operation 142 | let ues = s ^. TL.statusEntities >>= (^? TL.enUserMentions) 143 | let mentions = fmap (fmap (^. TL.entityBody)) ues 144 | join $ maybeToList mentions 145 | --------------------------------------------------------------------------------