clit 0.1.1.2 → 0.2.0.0
raw patch · 8 files changed
+222/−110 lines, 8 filesdep +data-defaultdep +lensdep ~aesondep ~authenticate-oauthdep ~basePVP ok
version bump matches the API change (PVP)
Dependencies added: data-default, lens
Dependency ranges changed: aeson, authenticate-oauth, base, bytestring, http-client, http-client-tls, http-types, optparse-applicative, split
API changes (from Hackage documentation)
- Web.Exec: exec :: IO ()
- Web.Tweet: instance Data.Aeson.Types.Class.ToJSON Web.Tweet.Tweet
- Web.Tweet: instance GHC.Generics.Generic Web.Tweet.Tweet
- Web.Tweet: tweet :: FilePath -> ByteString -> IO ()
+ Web.Tweet: Tweet :: String -> Bool -> [String] -> Maybe Int -> Tweet
+ Web.Tweet: [_handles] :: Tweet -> [String]
+ Web.Tweet: [_replyID] :: Tweet -> Maybe Int
+ Web.Tweet: [_status] :: Tweet -> String
+ Web.Tweet: [_trimUser] :: Tweet -> Bool
+ Web.Tweet: basicTweet :: String -> FilePath -> IO Int
+ Web.Tweet: data Tweet
+ Web.Tweet: handles :: Lens' Tweet [String]
+ Web.Tweet: replyID :: Lens' Tweet (Maybe Int)
+ Web.Tweet: status :: Lens' Tweet String
+ Web.Tweet: thread :: String -> [String] -> Maybe Int -> Int -> FilePath -> IO ()
+ Web.Tweet: trimUser :: Lens' Tweet Bool
+ Web.Tweet: tweetData :: Tweet -> FilePath -> IO Int
+ Web.Tweet.Exec: Program :: Maybe FilePath -> Maybe String -> Maybe String -> Maybe [String] -> Program
+ Web.Tweet.Exec: data Program
+ Web.Tweet.Exec: exec :: IO ()
- Web.Tweet: urlString :: ByteString -> IO String
+ Web.Tweet: urlString :: Tweet -> String
Files
- app/Main.hs +1/−1
- clit.cabal +7/−2
- src/Web/Exec.hs +0/−46
- src/Web/Tweet.hs +69/−61
- src/Web/Tweet/Exec.hs +66/−0
- src/Web/Tweet/Sign.hs +29/−0
- src/Web/Tweet/Types.hs +27/−0
- src/Web/Tweet/Utils.hs +23/−0
app/Main.hs view
@@ -1,6 +1,6 @@ module Main where -import Web.Exec+import Web.Tweet.Exec main :: IO () main = exec
clit.cabal view
@@ -1,5 +1,5 @@ name: clit-version: 0.1.1.2+version: 0.2.0.0 synopsis: Post tweets from stdin description: a Command Line Interface Tweeter homepage: https://github.com/vmchale/command-line-tweeter#readme@@ -16,7 +16,10 @@ library hs-source-dirs: src exposed-modules: Web.Tweet- , Web.Exec+ , Web.Tweet.Exec+ other-modules: Web.Tweet.Types+ , Web.Tweet.Utils+ , Web.Tweet.Sign build-depends: base >= 4.7 && < 5 , aeson , http-client-tls@@ -26,6 +29,8 @@ , bytestring , split , optparse-applicative < 0.13.0.0+ , lens+ , data-default default-language: Haskell2010 executable tweet
− src/Web/Exec.hs
@@ -1,46 +0,0 @@--- | Provides IO action that parses command line options and tweets from stdin-module Web.Exec (exec) where--import Web.Tweet-import Options.Applicative-import qualified Data.ByteString.Char8 as BS-import Data.List.Split (chunksOf)---- | Executes parser-exec :: IO ()-exec = execParser opts >>= select- where - opts = info (helper <*> program)- (fullDesc- <> progDesc "Tweet from stdin!"- <> header "clit - a Command Line Interface Tweeter")---- | query twitter to post stdin-fromStdIn :: Int -> FilePath -> IO ()-fromStdIn num filepath = do- content <- fmap ((take num) . (map BS.pack) . (chunksOf 140)) getContents- sequence_ $ fmap (tweet filepath) content---- | Data type for our program; just one optional argument for path to credential file.-data Program = Program { cred :: Maybe FilePath , tweets :: Maybe String }---- | Executes program-select :: Program -> IO ()-select (Program Nothing (Just n)) = fromStdIn (read n) ".cred"-select (Program Nothing Nothing) = fromStdIn 4 ".cred"-select (Program (Just file) (Just n)) = fromStdIn (read n) file-select (Program (Just file) Nothing) = fromStdIn 4 file---- | Parser to return a program datatype-program :: Parser Program-program = Program- <$> (optional $ strOption- (long "cred"- <> short 'c'- <> metavar "CREDENTIALS"- <> help "path to credentials"))- <*> (optional $ strOption- (long "tweets"- <> short 't'- <> metavar "NUM"- <> help "Number of tweets in a row, default 4"))
src/Web/Tweet.hs view
@@ -1,10 +1,11 @@ {-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE DeriveGeneric #-} -- | Various utilities to tweet using the twitter api -- --- Make sure you have a file credentials file (default `.cred`) with the following info:+-- Make sure you have a file credentials file (default the executable looks for is `.cred`) with the following info: --+-- ```+-- -- api-key: API_KEY -- -- api-sec: API_SECRE@@ -12,88 +13,95 @@ -- tok: OAUTH_TOKEN -- -- tok-sec: TOKEN_SECRET+--+-- ``` module Web.Tweet- ( tweet+ (+ -- * Functions to tweet+ basicTweet+ , tweetData+ , thread+ -- * Data type for a tweet+ , module Web.Tweet.Types+ -- * Functions to sign API requests , signRequest+ -- * Functions to generate a URL string from a `Tweet` , urlString ) where -import Data.Aeson-import GHC.Generics import Network.HTTP.Client import Network.HTTP.Client.TLS import Network.HTTP.Types.Status (statusCode)-import Web.Authenticate.OAuth import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Lazy.Char8 as BSL import Data.Char (toLower)+import Web.Tweet.Types+import Web.Tweet.Utils+import Control.Monad+import Data.List.Split (chunksOf)+import Data.Maybe+import Control.Lens+import Web.Authenticate.OAuth+import Web.Tweet.Sign --- | Data type for our request-data Tweet = Tweet- { status :: String- , trim_user :: Bool- } deriving Generic+-- | thread tweets together nicely. Takes a string, a list of handles to reply to, plus the ID of the status you're replying to.+-- If you need to thread tweets without replying, pass a `Nothing` as the third argument.+-- `thread "Hi I'm back in New York!" ["friend1","friend2"] Nothing 1 ".cred"`+thread :: String -> [String] -> Maybe Int -> Int -> FilePath -> IO ()+thread contents hs idNum num filepath = do+ let handleStr = concatMap (((++) " ") . ((++) "@")) hs+ let content = (take num) . (chunksOf (140-(length handleStr))) $ contents+ print $ urlString (Tweet { _status = content !! 0, _trimUser = True, _handles = hs, _replyID = idNum})+ let f = (\str i -> (flip tweetData filepath) (Tweet { _status = str, _trimUser = True, _handles = hs, _replyID = if i == 0 then Nothing else Just i }))+ let initial = f (content !! 0)+ void $ foldr ((>=>) . f) initial (drop 1 content) $ maybe 0 id idNum -instance ToJSON Tweet where+-- | Reply with a single tweet. Works the same as `thread` but doesn't take the fourth argument.+-- `reply "Idk what that means" ["friend1"] (Just 189943500) ".cred"`+reply :: String -> [String] -> Maybe Int -> FilePath -> IO ()+reply contents hs idNum filepath = thread contents hs idNum 1 filepath --- | tweet a byteString, with credentials from a given file-tweet :: FilePath -> BS.ByteString -> IO ()-tweet filepath content = do- requestString <- urlString content+-- | Tweet a string given a path to credentials; return the id of the status.+-- `basicTweet "On the airplane." ".cred"+basicTweet :: String -> FilePath -> IO Int+basicTweet contents = tweetData (mkTweet contents)++-- | Make a `Tweet` with only the contents.+mkTweet :: String -> Tweet+mkTweet contents = over (status) (const (contents)) $ def ++-- | tweet, given a `Tweet` and path to credentials. Return id of posted tweet.+tweetData :: Tweet -> FilePath -> IO Int+tweetData tweet filepath = do+ let requestString = urlString tweet manager <- newManager tlsManagerSettings initialRequest <- parseRequest ("https://api.twitter.com/1.1/statuses/update.json" ++ requestString) request <- signRequest filepath $ initialRequest { method = "POST" } response request manager --- | print output of a request-response :: Request -> Manager -> IO ()+-- | print output of a request and return status id as an `Int`. +response :: Request -> Manager -> IO Int response request manager = do response <- httpLbs request manager putStrLn $ "The status code was: " ++ show (statusCode $ responseStatus response) BSL.putStrLn $ responseBody response---- | Sign a request using your OAuth dev token.--- Uses the IO monad because signatures require a timestamp-signRequest :: FilePath -> Request -> IO Request-signRequest filepath req = do- o <- oAuth filepath- c <- credential filepath- signOAuth o c req---- | Create an OAuth token-oAuth :: FilePath -> IO OAuth-oAuth filepath = do- secret <- (lineByKey "api-sec") <$> getConfigData filepath- key <- (lineByKey "api-key") <$> getConfigData filepath- let url = "api.twitter.com"- return newOAuth { oauthConsumerKey = key , oauthConsumerSecret = secret , oauthServerName = url }---- | Create a new credential from a token and secret component of that token-credential :: FilePath -> IO Credential-credential filepath = newCredential <$> token <*> secretToken- where token = (lineByKey "tok") <$> getConfigData filepath- secretToken = (lineByKey "tok-sec") <$> getConfigData filepath---- | Pick out a key value from a key-lineByKey :: BS.ByteString -> [(BS.ByteString, BS.ByteString)] -> BS.ByteString-lineByKey key = snd . head . (filter (\i -> fst i == key))---- | Get pairs of "key" to search for and actual values-getConfigData :: FilePath -> IO [(BS.ByteString, BS.ByteString)]-getConfigData filepath = zip <$> keys <*> content- where content = (map (BS.pack . filterLine)) . lines <$> file- keys = (map (BS.pack . keyLine)) . lines <$> file- file = readFile filepath--keyLine :: String -> String-keyLine = takeWhile (/=':')+ return $ (read . (takeWhile (/=',')) . (drop 52)) (BSL.unpack $ responseBody response) --- | Filter a line of a file for only the actual data and no descriptors-filterLine :: String -> String-filterLine = reverse . (takeWhile (not . (`elem` (" :" :: String)))) . reverse+-- | Convert a tweet to a percent-encoded url for querying an API+urlString :: Tweet -> String+urlString tweet = concat [ "?status="+ , BS.unpack (tweetEncode tweet)+ , "&trim_user="+ , map toLower (show trim)+ , (if isJust (_replyID tweet) then "&in_reply_to_status_id=" else "")+ , reply ]+ where trim = _trimUser tweet+ reply = maybe "" id (fmap show $ _replyID $ tweet) --- | Convert a byteString to the percent-encoded version-urlString :: BS.ByteString -> IO String-urlString content = do- return $ "?status=" ++ ((BS.unpack . paramEncode) content) ++ "&trim_user" ++ (map toLower $ show True)+-- | Percent-ncode the status update so it's fit for a URL+tweetEncode :: Tweet -> BS.ByteString+tweetEncode tweet = paramEncode $ handleStr `BS.append` content+ where content = BS.pack . _status $ tweet+ handleStr = BS.pack $ concatMap ((++ " ") . ((++) "@")) hs+ hs = _handles tweet
+ src/Web/Tweet/Exec.hs view
@@ -0,0 +1,66 @@+-- | Provides IO action that parses command line options and tweets from stdin+module Web.Tweet.Exec ( exec+ , Program (Program)) where++import Web.Tweet+import Options.Applicative+import qualified Data.ByteString.Char8 as BS+import Control.Monad+import Data.Foldable (fold)++-- | Data type for our program: one optional path to a credential file, (optionally) the number of tweets to make, the id of the status you're replying to, and a list of users you wish to mention.+data Program = Program { cred :: Maybe FilePath , tweets :: Maybe String, replyId :: Maybe String, replyHandles :: Maybe [String] }++-- | query twitter to post stdin with no fancy options+fromStdIn :: Int -> FilePath -> IO ()+fromStdIn = threadStdIn [] Nothing++-- | Threaded tweets from stdIn+threadStdIn :: [String] -> Maybe Int -> Int -> FilePath -> IO ()+threadStdIn hs idNum num filepath = do+ contents <- getContents+ thread contents hs idNum num filepath++-- | Executes parser+exec :: IO ()+exec = execParser opts >>= select+ where+ opts = info (helper <*> program)+ (fullDesc+ <> progDesc "Tweet from stdin!"+ <> header "clit - a Command Line Interface Tweeter")++-- | Executes program+select :: Program -> IO ()+select (Program Nothing (Just n) Nothing Nothing) = fromStdIn (read n) ".cred"+select (Program Nothing Nothing Nothing Nothing) = fromStdIn 4 ".cred"+select (Program (Just file) (Just n) Nothing Nothing) = fromStdIn (read n) file+select (Program (Just file) Nothing Nothing Nothing) = fromStdIn 4 file+select (Program Nothing (Just n) (Just id) (Just handles)) = threadStdIn handles (read id) (read n) ".cred"+select (Program (Just file) (Just n) (Just id) (Just handles)) = threadStdIn handles (pure . read $ id) (read n) file+select (Program (Just file) Nothing (Just id) (Just handles)) = threadStdIn handles (pure . read $ id) 4 file+select (Program Nothing (Just n) (Just id) Nothing) = threadStdIn [] (pure . read $ id) (read n) ".cred"+select (Program Nothing Nothing (Just id) Nothing) = threadStdIn [] (pure . read $ id) 4 ".cred"+select (Program Nothing Nothing (Just id) (Just handles)) = threadStdIn handles (pure . read $ id) 4 ".cred"+select (Program (Just file) (Just n) (Just id) Nothing) = threadStdIn [] (pure . read $ id) (read n) file++-- | Parser to return a program datatype+program :: Parser Program+program = Program+ <$> (optional $ strOption+ (long "cred"+ <> short 'c'+ <> metavar "CREDENTIALS"+ <> help "path to credentials"))+ <*> (optional $ strOption+ (long "tweets"+ <> short 't'+ <> metavar "NUM"+ <> help "Number of tweets in a row, default 4"))+ <*> (optional $ strOption+ (long "reply"+ <> short 'r'+ <> help "id of status to reply to - be sure to include their handle, e.g. @my_build_errors"))+ <*> (optional $ some $ argument str+ (metavar "HANDLE1"+ <> help "handles to include in replies"))
+ src/Web/Tweet/Sign.hs view
@@ -0,0 +1,29 @@+{-# LANGUAGE OverloadedStrings #-}++module Web.Tweet.Sign where++import Web.Tweet.Utils+import Web.Authenticate.OAuth+import Network.HTTP.Client++-- | Sign a request using your OAuth dev token.+-- Uses the IO monad because signatures require a timestamp+signRequest :: FilePath -> Request -> IO Request+signRequest filepath req = do+ o <- oAuth filepath+ c <- credential filepath+ signOAuth o c req++-- | Create an OAuth from config data in a file+oAuth :: FilePath -> IO OAuth+oAuth filepath = do+ secret <- (lineByKey "api-sec") <$> getConfigData filepath+ key <- (lineByKey "api-key") <$> getConfigData filepath+ let url = "api.twitter.com"+ return newOAuth { oauthConsumerKey = key , oauthConsumerSecret = secret , oauthServerName = url }++-- | Create a new credential from a token and token secret+credential :: FilePath -> IO Credential+credential filepath = newCredential <$> token <*> secretToken+ where token = (lineByKey "tok") <$> getConfigData filepath+ secretToken = (lineByKey "tok-sec") <$> getConfigData filepath
+ src/Web/Tweet/Types.hs view
@@ -0,0 +1,27 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE TemplateHaskell #-}++-- | Exports the `Tweet` type, a datatype for building tweets easily+module Web.Tweet.Types where++import Data.Aeson+import GHC.Generics+import Control.Lens+import Data.Default++-- | Default value for Bool for trim_user (`True` in our case)+instance Default Bool where+ def = True++-- | Data type for our request: consists of the status text, whether to trium user information in the response, the handles to mention, and optionally the id of the status to reply to.+data Tweet = Tweet+ { _status :: String+ , _trimUser :: Bool+ , _handles :: [String]+ , _replyID :: Maybe Int+ } deriving (Generic, Default)++makeLenses ''Tweet++instance ToJSON Tweet where
+ src/Web/Tweet/Utils.hs view
@@ -0,0 +1,23 @@+-- | Miscellaneous functions that don't fit the project directly+module Web.Tweet.Utils where++import qualified Data.ByteString.Char8 as BS++-- | helper function to get the key as read from a file+keyLinePie :: String -> String+keyLinePie = takeWhile (/=':')++-- | Pick out a key value from a key+lineByKey :: BS.ByteString -> [(BS.ByteString, BS.ByteString)] -> BS.ByteString+lineByKey key = snd . head . (filter (\i -> fst i == key))++-- | Filter a line of a file for only the actual data and no descriptors+filterLine :: String -> String+filterLine = reverse . (takeWhile (not . (`elem` (" :" :: String)))) . reverse++-- | Get pairs of "key" to search for and actual values+getConfigData :: FilePath -> IO [(BS.ByteString, BS.ByteString)]+getConfigData filepath = zip <$> keys <*> content+ where content = (map (BS.pack . filterLine)) . lines <$> file+ keys = (map (BS.pack . keyLinePie)) . lines <$> file+ file = readFile filepath